Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99507a5ae5 | ||
|
|
55ae0aadf8 | ||
|
|
6c4ba71a23 | ||
|
|
e5f53053e3 | ||
|
|
3e392527e9 | ||
|
|
36d3a9c7fb | ||
|
|
914598d7e3 | ||
|
|
602c24cf8c | ||
|
|
e0b5ecc50f | ||
|
|
a83c7e89e0 | ||
|
|
7a4252b3de | ||
|
|
5c3dd25b32 | ||
|
|
30c8e9b08d | ||
|
|
3e3a16feaf | ||
|
|
0e1fad88c0 | ||
|
|
f442559543 | ||
|
|
2a63f26796 | ||
|
|
7e325b2ff2 | ||
|
|
26487d19b4 | ||
|
|
71a936e89a | ||
|
|
f2c19b456b | ||
|
|
2d3da2a49f | ||
|
|
11b393db9f | ||
|
|
9a01b16d6c |
@@ -1,6 +1,7 @@
|
||||
from collections.abc import Generator, Iterable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from configs import dify_config
|
||||
from core.callback_handler.agent_tool_callback_handler import DifyAgentCallbackHandler, print_text
|
||||
from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from core.tools.entities.tool_entities import ToolInvokeMessage
|
||||
@@ -19,8 +20,9 @@ class DifyWorkflowCallbackHandler(DifyAgentCallbackHandler):
|
||||
trace_manager: TraceQueueManager | None = None,
|
||||
) -> Generator[ToolInvokeMessage, None, None]:
|
||||
for tool_output in tool_outputs:
|
||||
print_text("\n[on_tool_execution]\n", color=self.color)
|
||||
print_text("Tool: " + tool_name + "\n", color=self.color)
|
||||
print_text("Outputs: " + tool_output.model_dump_json()[:1000] + "\n", color=self.color)
|
||||
print_text("\n")
|
||||
if dify_config.DEBUG:
|
||||
print_text("\n[on_tool_execution]\n", color=self.color)
|
||||
print_text("Tool: " + tool_name + "\n", color=self.color)
|
||||
print_text("Outputs: " + tool_output.model_dump_json()[:1000] + "\n", color=self.color)
|
||||
print_text("\n")
|
||||
yield tool_output
|
||||
|
||||
@@ -10,18 +10,21 @@ def is_credential_exists(credential_id: str, credential_type: "PluginCredentialT
|
||||
"""
|
||||
Check if the credential still exists in the database.
|
||||
|
||||
Uses the configured SQLAlchemy session factory instead of Flask-SQLAlchemy's
|
||||
``db.engine`` because workflow graph node construction may run without an
|
||||
active Flask application context.
|
||||
|
||||
:param credential_id: The credential ID to check
|
||||
:param credential_type: The type of credential (MODEL or TOOL)
|
||||
:return: True if credential exists, False otherwise
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from extensions.ext_database import db
|
||||
from core.db import session_factory
|
||||
from models.provider import ProviderCredential, ProviderModelCredential
|
||||
from models.tools import BuiltinToolProvider
|
||||
|
||||
with Session(db.engine) as session:
|
||||
with session_factory.create_session() as session:
|
||||
if credential_type == PluginCredentialType.MODEL:
|
||||
# Check both pre-defined and custom model credentials using a single UNION query
|
||||
stmt = (
|
||||
|
||||
@@ -76,7 +76,11 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
|
||||
if not user_id:
|
||||
user = EndUserService.get_or_create_end_user(app)
|
||||
else:
|
||||
user = cls._get_user(user_id, app)
|
||||
try:
|
||||
user = cls._get_user(user_id, app)
|
||||
except ValueError:
|
||||
# Plugins such as WeCom Bot pass external sender IDs rather than EndUser UUIDs.
|
||||
user = EndUserService.get_or_create_end_user(app, user_id=user_id)
|
||||
|
||||
conversation_id = conversation_id or ""
|
||||
|
||||
@@ -226,6 +230,13 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
|
||||
EndUser.app_id == app.id,
|
||||
)
|
||||
user = session.scalar(stmt)
|
||||
if not user:
|
||||
stmt = select(EndUser).where(
|
||||
EndUser.session_id == user_id,
|
||||
EndUser.tenant_id == app.tenant_id,
|
||||
EndUser.app_id == app.id,
|
||||
)
|
||||
user = session.scalar(stmt)
|
||||
if not user:
|
||||
stmt = select(Account).where(
|
||||
Account.id == user_id,
|
||||
|
||||
@@ -4,6 +4,8 @@ This module owns plugin daemon management calls that are shared by API services
|
||||
and core runtimes. Plugin model provider discovery is cached here, alongside
|
||||
plugin install, uninstall, and upgrade invalidation, so all cache mutations for
|
||||
plugin-owned provider metadata stay tenant-scoped and in one place.
|
||||
Provider cache payloads may be stored as prefixed zstd bytes; readers also
|
||||
accept legacy plain JSON payloads for rolling upgrades and existing Redis keys.
|
||||
|
||||
The console plugin list also normalizes endpoint setup counters against live
|
||||
endpoint records. Some plugin daemon builds return stale ``endpoints_*``
|
||||
@@ -14,12 +16,15 @@ 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 ClassVar
|
||||
from typing import Literal, Protocol
|
||||
|
||||
import zstandard
|
||||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
from redis import RedisError
|
||||
from redis.exceptions import LockError
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
from yarl import URL
|
||||
@@ -67,14 +72,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: str
|
||||
status: Literal["active", "deleted"]
|
||||
deprecated_reason: str
|
||||
alternative_plugin_id: str
|
||||
|
||||
@@ -82,6 +91,12 @@ class PluginService:
|
||||
REDIS_TTL = 60 * 5 # 5 minutes
|
||||
PLUGIN_MODEL_PROVIDERS_REDIS_KEY_PREFIX = "plugin_model_providers:tenant_id:"
|
||||
PLUGIN_MODEL_PROVIDERS_GENERATION_REDIS_KEY_PREFIX = "plugin_model_providers_generation:tenant_id:"
|
||||
PLUGIN_MODEL_PROVIDERS_LOCK_REDIS_KEY_PREFIX = "plugin_model_providers_refresh_lock:tenant_id:"
|
||||
PLUGIN_MODEL_PROVIDERS_LOCK_TTL = 30
|
||||
PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT = 2.0
|
||||
PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL = 0.05
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_COMPRESSION_PREFIX = b"\x00dify-plugin-model-providers-zstd-v1:"
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_COMPRESSION_MIN_BYTES = 64 * 1024
|
||||
PLUGIN_INSTALL_TASK_TERMINAL_STATUSES = (PluginInstallTaskStatus.Success, PluginInstallTaskStatus.Failed)
|
||||
# Mirror the detail-panel endpoint query size so list reconciliation and
|
||||
# the visible endpoint drawer exercise the same daemon pagination path.
|
||||
@@ -98,6 +113,10 @@ class PluginService:
|
||||
def _get_plugin_model_providers_generation_cache_key(cls, tenant_id: str) -> str:
|
||||
return f"{cls.PLUGIN_MODEL_PROVIDERS_GENERATION_REDIS_KEY_PREFIX}{tenant_id}"
|
||||
|
||||
@classmethod
|
||||
def _get_plugin_model_providers_lock_key(cls, tenant_id: str, generation: int) -> str:
|
||||
return f"{cls.PLUGIN_MODEL_PROVIDERS_LOCK_REDIS_KEY_PREFIX}{tenant_id}:generation:{generation}"
|
||||
|
||||
@staticmethod
|
||||
def _get_provider_short_name_alias(provider: PluginModelProviderEntity) -> str:
|
||||
"""
|
||||
@@ -129,8 +148,25 @@ class PluginService:
|
||||
return declaration
|
||||
|
||||
@classmethod
|
||||
def _copy_provider_entities(cls, providers: Sequence[ProviderEntity]) -> tuple[ProviderEntity, ...]:
|
||||
return tuple(provider.model_copy(deep=True) for provider in providers)
|
||||
def _encode_plugin_model_providers_cache_payload(cls, payload: bytes) -> bytes:
|
||||
if len(payload) < cls.PLUGIN_MODEL_PROVIDERS_CACHE_COMPRESSION_MIN_BYTES:
|
||||
return payload
|
||||
|
||||
return cls.PLUGIN_MODEL_PROVIDERS_CACHE_COMPRESSION_PREFIX + zstandard.compress(payload, level=1)
|
||||
|
||||
@classmethod
|
||||
def _decode_plugin_model_providers_cache_payload(cls, payload: bytes | bytearray | str) -> bytes | bytearray | str:
|
||||
if isinstance(payload, str):
|
||||
return payload
|
||||
|
||||
prefix = cls.PLUGIN_MODEL_PROVIDERS_CACHE_COMPRESSION_PREFIX
|
||||
if not payload.startswith(prefix):
|
||||
return payload
|
||||
|
||||
try:
|
||||
return zstandard.decompress(payload[len(prefix) :])
|
||||
except zstandard.ZstdError as exc:
|
||||
raise ValueError("Invalid compressed plugin model providers cache payload.") from exc
|
||||
|
||||
@classmethod
|
||||
def _load_plugin_model_providers_generation(cls, tenant_id: str) -> int | None:
|
||||
@@ -163,76 +199,35 @@ 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
|
||||
def _load_cached_plugin_model_providers_for_generation(
|
||||
cls, tenant_id: str, generation: int | None
|
||||
) -> tuple[tuple[ProviderEntity, ...] | None, bool]:
|
||||
if generation is None:
|
||||
return None, False
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
cache_keys = []
|
||||
if generation is not None:
|
||||
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
|
||||
cache_keys = [cls._get_plugin_model_providers_cache_key(tenant_id, generation)]
|
||||
|
||||
try:
|
||||
cached_provider_entries = redis_client.mget(cache_keys)
|
||||
except (RedisError, RuntimeError):
|
||||
except (LockError, RedisError, RuntimeError):
|
||||
logger.warning("Failed to read cached plugin model providers for tenant %s.", tenant_id, exc_info=True)
|
||||
return None
|
||||
return None, False
|
||||
|
||||
if len(cached_provider_entries) != len(cache_keys):
|
||||
logger.warning(
|
||||
"Unexpected cached plugin model providers response size for tenant %s.",
|
||||
tenant_id,
|
||||
)
|
||||
return None
|
||||
return None, False
|
||||
|
||||
for cache_key, cached_providers in zip(cache_keys, cached_provider_entries):
|
||||
if not cached_providers:
|
||||
continue
|
||||
|
||||
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
|
||||
payload = cls._decode_plugin_model_providers_cache_payload(cached_providers)
|
||||
providers = tuple(_provider_entities_adapter.validate_json(payload))
|
||||
return providers, True
|
||||
except (TypeError, ValueError, ValidationError):
|
||||
logger.warning(
|
||||
"Invalid cached plugin model providers for tenant %s; deleting cache key %s.",
|
||||
@@ -249,7 +244,7 @@ class PluginService:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return None
|
||||
return None, True
|
||||
|
||||
@classmethod
|
||||
def _store_cached_plugin_model_providers(
|
||||
@@ -257,15 +252,94 @@ 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 = cls._encode_plugin_model_providers_cache_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
|
||||
@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:
|
||||
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,
|
||||
)
|
||||
yield False
|
||||
return
|
||||
|
||||
if not lock_acquired:
|
||||
logger.warning(
|
||||
"Provider refresh lock timed out; direct daemon fallback. tenant_id=%s generation=%s",
|
||||
tenant_id,
|
||||
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 _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:
|
||||
@@ -287,21 +361,68 @@ class PluginService:
|
||||
are intentionally owned by this service so tenant isolation and cache
|
||||
expiry are handled in one place.
|
||||
"""
|
||||
cached_providers = cls._load_cached_plugin_model_providers(tenant_id, client=client)
|
||||
if cached_providers is not None:
|
||||
return cached_providers
|
||||
deadline = time.monotonic() + cls.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT
|
||||
|
||||
model_client = client or PluginModelClient()
|
||||
providers = tuple(
|
||||
cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id)
|
||||
)
|
||||
if not providers:
|
||||
return providers
|
||||
generation = cls._load_plugin_model_providers_generation(tenant_id)
|
||||
if generation is not None:
|
||||
cls._store_in_memory_plugin_model_providers(tenant_id, generation, providers)
|
||||
cls._store_cached_plugin_model_providers(tenant_id, generation, providers)
|
||||
return providers
|
||||
while True:
|
||||
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
|
||||
|
||||
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]:
|
||||
|
||||
@@ -214,6 +214,18 @@ class EndUserType(StrEnum):
|
||||
SERVICE_API = "service-api"
|
||||
TRIGGER = "trigger"
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def _missing_(cls, value):
|
||||
# Legacy rows persisted the service-api type with an underscore before it
|
||||
# was normalized to the hyphenated value. The
|
||||
# `4f7b2c8d9a10_normalize_legacy_end_user_type` migration rewrites those
|
||||
# rows, but tolerate the old value here as well so an unmigrated end user
|
||||
# keeps loading instead of failing enum validation on every request.
|
||||
if value == "service_api":
|
||||
return cls.SERVICE_API
|
||||
return super()._missing_(value)
|
||||
|
||||
|
||||
class DocumentDocType(StrEnum):
|
||||
"""Document doc_type classification"""
|
||||
|
||||
@@ -42,6 +42,7 @@ dependencies = [
|
||||
"opentelemetry-propagator-b3>=1.41.1,<2.0.0",
|
||||
"readabilipy==0.3.0",
|
||||
"resend>=2.27.0,<3.0.0",
|
||||
"zstandard==0.25.0",
|
||||
# Emerging: newer and fast-moving, use compatible pins
|
||||
"fastopenapi[flask]==0.7.0",
|
||||
"graphon==0.5.3",
|
||||
|
||||
@@ -364,7 +364,6 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
|
||||
"workspace.member.manage",
|
||||
"api_extension.manage",
|
||||
"plugin.install",
|
||||
"credential.use",
|
||||
@@ -435,6 +434,7 @@ _LEGACY_APP_EDITOR_KEYS: list[str] = [
|
||||
"app.acl.delete",
|
||||
"app.acl.release_and_version",
|
||||
"app.acl.monitor",
|
||||
"app.acl.log_and_annotation",
|
||||
"app.acl.access_config",
|
||||
]
|
||||
|
||||
|
||||
@@ -32,8 +32,16 @@ def mock_print_text(mocker: MockerFixture):
|
||||
return mocker.patch("core.callback_handler.workflow_tool_callback_handler.print_text")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enable_debug(mocker: MockerFixture):
|
||||
"""Force DEBUG on so the handler emits its verbose stdout traces."""
|
||||
mocker.patch("core.callback_handler.workflow_tool_callback_handler.dify_config.DEBUG", True)
|
||||
|
||||
|
||||
class TestDifyWorkflowCallbackHandler:
|
||||
def test_on_tool_execution_single_output_success(self, handler: DifyWorkflowCallbackHandler, mock_print_text):
|
||||
def test_on_tool_execution_single_output_success(
|
||||
self, handler: DifyWorkflowCallbackHandler, mock_print_text, enable_debug
|
||||
):
|
||||
# Arrange
|
||||
tool_name = "test_tool"
|
||||
tool_inputs = {"a": 1}
|
||||
@@ -63,7 +71,9 @@ class TestDifyWorkflowCallbackHandler:
|
||||
]
|
||||
)
|
||||
|
||||
def test_on_tool_execution_multiple_outputs(self, handler: DifyWorkflowCallbackHandler, mock_print_text):
|
||||
def test_on_tool_execution_multiple_outputs(
|
||||
self, handler: DifyWorkflowCallbackHandler, mock_print_text, enable_debug
|
||||
):
|
||||
# Arrange
|
||||
tool_name = "multi_tool"
|
||||
outputs = [
|
||||
@@ -101,6 +111,29 @@ class TestDifyWorkflowCallbackHandler:
|
||||
assert results == []
|
||||
mock_print_text.assert_not_called()
|
||||
|
||||
def test_on_tool_execution_skips_print_when_debug_disabled(
|
||||
self, handler: DifyWorkflowCallbackHandler, mock_print_text, mocker: MockerFixture
|
||||
):
|
||||
"""When DEBUG is off, outputs are still yielded but nothing is printed
|
||||
and model_dump_json() is never invoked."""
|
||||
# Arrange
|
||||
mocker.patch("core.callback_handler.workflow_tool_callback_handler.dify_config.DEBUG", False)
|
||||
message = MagicMock()
|
||||
|
||||
# Act
|
||||
results = list(
|
||||
handler.on_tool_execution(
|
||||
tool_name="quiet_tool",
|
||||
tool_inputs={},
|
||||
tool_outputs=[message],
|
||||
)
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert results == [message]
|
||||
mock_print_text.assert_not_called()
|
||||
message.model_dump_json.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("invalid_outputs", "expected_exception"),
|
||||
[
|
||||
@@ -110,7 +143,7 @@ class TestDifyWorkflowCallbackHandler:
|
||||
],
|
||||
)
|
||||
def test_on_tool_execution_invalid_outputs_type(
|
||||
self, handler: DifyWorkflowCallbackHandler, invalid_outputs, expected_exception
|
||||
self, handler: DifyWorkflowCallbackHandler, invalid_outputs, expected_exception, enable_debug
|
||||
):
|
||||
# Arrange
|
||||
tool_name = "invalid_tool"
|
||||
@@ -125,7 +158,9 @@ class TestDifyWorkflowCallbackHandler:
|
||||
)
|
||||
)
|
||||
|
||||
def test_on_tool_execution_long_json_truncation(self, handler: DifyWorkflowCallbackHandler, mock_print_text):
|
||||
def test_on_tool_execution_long_json_truncation(
|
||||
self, handler: DifyWorkflowCallbackHandler, mock_print_text, enable_debug
|
||||
):
|
||||
# Arrange
|
||||
tool_name = "long_json_tool"
|
||||
long_json = "x" * 1500
|
||||
@@ -147,7 +182,9 @@ class TestDifyWorkflowCallbackHandler:
|
||||
color="blue",
|
||||
)
|
||||
|
||||
def test_on_tool_execution_model_dump_json_exception(self, handler: DifyWorkflowCallbackHandler, mock_print_text):
|
||||
def test_on_tool_execution_model_dump_json_exception(
|
||||
self, handler: DifyWorkflowCallbackHandler, mock_print_text, enable_debug
|
||||
):
|
||||
# Arrange
|
||||
tool_name = "exception_tool"
|
||||
bad_message = MagicMock()
|
||||
@@ -167,7 +204,7 @@ class TestDifyWorkflowCallbackHandler:
|
||||
assert mock_print_text.call_count >= 2
|
||||
|
||||
def test_on_tool_execution_none_message_id_and_trace_manager(
|
||||
self, handler: DifyWorkflowCallbackHandler, mock_print_text
|
||||
self, handler: DifyWorkflowCallbackHandler, mock_print_text, enable_debug
|
||||
):
|
||||
# Arrange
|
||||
tool_name = "optional_params_tool"
|
||||
|
||||
@@ -114,10 +114,11 @@ def test_is_credential_exists_by_type(
|
||||
scalar_result: str | None,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
mocker.patch("extensions.ext_database.db", new=SimpleNamespace(engine=object()))
|
||||
session_cls = mocker.patch("sqlalchemy.orm.Session")
|
||||
session = session_cls.return_value.__enter__.return_value
|
||||
session = mocker.MagicMock()
|
||||
session.scalar.return_value = scalar_result
|
||||
session_context = mocker.MagicMock()
|
||||
session_context.__enter__.return_value = session
|
||||
mocker.patch("core.db.session_factory.create_session", return_value=session_context)
|
||||
|
||||
result = is_credential_exists("cred-1", credential_type)
|
||||
|
||||
@@ -128,11 +129,33 @@ def test_is_credential_exists_by_type(
|
||||
def test_is_credential_exists_returns_false_for_unknown_type(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
mocker.patch("extensions.ext_database.db", new=SimpleNamespace(engine=object()))
|
||||
session_cls = mocker.patch("sqlalchemy.orm.Session")
|
||||
session = session_cls.return_value.__enter__.return_value
|
||||
session = mocker.MagicMock()
|
||||
session_context = mocker.MagicMock()
|
||||
session_context.__enter__.return_value = session
|
||||
mocker.patch("core.db.session_factory.create_session", return_value=session_context)
|
||||
|
||||
result = is_credential_exists("cred-1", cast(PluginCredentialType, "unknown"))
|
||||
|
||||
assert result is False
|
||||
session.scalar.assert_not_called()
|
||||
|
||||
|
||||
def test_is_credential_exists_uses_configured_session_factory_without_flask_app_context(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
class RaisingDB:
|
||||
@property
|
||||
def engine(self):
|
||||
raise RuntimeError("Working outside of application context.")
|
||||
|
||||
session = mocker.MagicMock()
|
||||
session.scalar.return_value = "model-credential"
|
||||
session_context = mocker.MagicMock()
|
||||
session_context.__enter__.return_value = session
|
||||
create_session = mocker.patch("core.db.session_factory.create_session", return_value=session_context)
|
||||
mocker.patch("extensions.ext_database.db", new=RaisingDB())
|
||||
|
||||
result = is_credential_exists("cred-1", PluginCredentialType.MODEL)
|
||||
|
||||
assert result is True
|
||||
create_session.assert_called_once_with()
|
||||
|
||||
@@ -346,14 +346,32 @@ class TestPluginAppBackwardsInvocation:
|
||||
assert "end_users.app_id" in compiled
|
||||
assert stmt.compile().params == {"id_1": "uid", "tenant_id_1": "tenant-1", "app_id_1": "app-1"}
|
||||
|
||||
def test_get_user_returns_end_user_by_session_id(self, mocker: MockerFixture):
|
||||
session = self.patch_create_session(mocker, side_effect=[None, MagicMock(id="session-user")])
|
||||
app = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
|
||||
user = PluginAppBackwardsInvocation._get_user("wecom-sender-1", app)
|
||||
|
||||
assert user.id == "session-user"
|
||||
stmt = session.scalar.call_args_list[1].args[0]
|
||||
compiled = str(stmt.compile(dialect=postgresql.dialect()))
|
||||
assert "end_users.session_id" in compiled
|
||||
assert "end_users.tenant_id" in compiled
|
||||
assert "end_users.app_id" in compiled
|
||||
assert stmt.compile().params == {
|
||||
"session_id_1": "wecom-sender-1",
|
||||
"tenant_id_1": "tenant-1",
|
||||
"app_id_1": "app-1",
|
||||
}
|
||||
|
||||
def test_get_user_falls_back_to_account_user(self, mocker: MockerFixture):
|
||||
session = self.patch_create_session(mocker, side_effect=[None, MagicMock(id="account-user")])
|
||||
session = self.patch_create_session(mocker, side_effect=[None, None, MagicMock(id="account-user")])
|
||||
app = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
|
||||
user = PluginAppBackwardsInvocation._get_user("uid", app)
|
||||
|
||||
assert user.id == "account-user"
|
||||
stmt = session.scalar.call_args_list[1].args[0]
|
||||
stmt = session.scalar.call_args_list[2].args[0]
|
||||
compiled = str(stmt.compile(dialect=postgresql.dialect()))
|
||||
assert "accounts.id" in compiled
|
||||
assert "tenant_account_joins.account_id" in compiled
|
||||
@@ -361,12 +379,41 @@ class TestPluginAppBackwardsInvocation:
|
||||
assert stmt.compile().params == {"id_1": "uid", "tenant_id_1": "tenant-1"}
|
||||
|
||||
def test_get_user_raises_when_user_not_found(self, mocker: MockerFixture):
|
||||
self.patch_create_session(mocker, side_effect=[None, None])
|
||||
self.patch_create_session(mocker, side_effect=[None, None, None])
|
||||
app = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
|
||||
with pytest.raises(ValueError, match="user not found"):
|
||||
PluginAppBackwardsInvocation._get_user("uid", app)
|
||||
|
||||
def test_invoke_app_creates_end_user_for_unknown_external_user_id(self, mocker: MockerFixture):
|
||||
app = MagicMock(mode=AppMode.WORKFLOW)
|
||||
end_user = MagicMock()
|
||||
workflow = MagicMock()
|
||||
mocker.patch.object(PluginAppBackwardsInvocation, "_get_app", return_value=app)
|
||||
mocker.patch.object(PluginAppBackwardsInvocation, "_get_workflow", return_value=workflow)
|
||||
mocker.patch.object(PluginAppBackwardsInvocation, "_get_user", side_effect=ValueError("user not found"))
|
||||
get_or_create = mocker.patch(
|
||||
"core.plugin.backwards_invocation.app.EndUserService.get_or_create_end_user",
|
||||
return_value=end_user,
|
||||
)
|
||||
route = mocker.patch.object(PluginAppBackwardsInvocation, "invoke_workflow_app", return_value={"ok": True})
|
||||
|
||||
result = PluginAppBackwardsInvocation.invoke_app(
|
||||
MagicMock(),
|
||||
app_id="app",
|
||||
user_id="wecom-sender-1",
|
||||
tenant_id="tenant",
|
||||
conversation_id="",
|
||||
query=None,
|
||||
stream=True,
|
||||
inputs={},
|
||||
files=[],
|
||||
)
|
||||
|
||||
assert result == {"ok": True}
|
||||
get_or_create.assert_called_once_with(app, user_id="wecom-sender-1")
|
||||
assert route.call_args.args[2] is end_user
|
||||
|
||||
def test_get_app_returns_app(self, mocker: MockerFixture):
|
||||
app_obj = MagicMock(id="app")
|
||||
self.patch_create_session(mocker, return_value=app_obj)
|
||||
|
||||
@@ -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,12 +44,34 @@ class _FakeRedis:
|
||||
def delete(self, key: str) -> None:
|
||||
self._values.pop(key, None)
|
||||
|
||||
def lock(
|
||||
self,
|
||||
key: str,
|
||||
*,
|
||||
timeout: int,
|
||||
sleep: float,
|
||||
) -> "_FakeRedisLock":
|
||||
return _FakeRedisLock(self, key)
|
||||
|
||||
@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()
|
||||
|
||||
class _FakeRedisLock:
|
||||
def __init__(self, redis: _FakeRedis, key: str) -> None:
|
||||
self._redis = redis
|
||||
self._key = key
|
||||
self._acquired = False
|
||||
|
||||
def acquire(self, *, blocking: bool = True, blocking_timeout: float | None = None) -> bool:
|
||||
if self._key in self._redis._values:
|
||||
return False
|
||||
|
||||
self._redis._values[self._key] = "locked"
|
||||
self._acquired = True
|
||||
return True
|
||||
|
||||
def release(self) -> None:
|
||||
if self._acquired:
|
||||
self._redis.delete(self._key)
|
||||
self._acquired = False
|
||||
|
||||
|
||||
def _build_model_schema() -> AIModelEntity:
|
||||
@@ -413,12 +435,13 @@ 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=MagicMock()),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(plugin_service_module.dify_config, "PLUGIN_MODEL_PROVIDERS_CACHE_TTL", 300)
|
||||
monkeypatch.setattr(plugin_service_module.dify_config, "PLUGIN_MODEL_PROVIDERS_CACHE_TTL", 0)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
runtime.fetch_model_providers()
|
||||
|
||||
@@ -2,6 +2,9 @@ import ast
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.engine.default import DefaultDialect
|
||||
|
||||
from models.enums import EndUserType
|
||||
from models.model import EndUser
|
||||
from models.types import EnumText
|
||||
@@ -24,6 +27,24 @@ def test_end_user_type_is_plain_persisted_value_enum():
|
||||
assert not hasattr(EndUserType, "from_invoke_from")
|
||||
|
||||
|
||||
def test_end_user_type_accepts_legacy_service_api_value():
|
||||
# Legacy rows stored the service-api type with an underscore before it was
|
||||
# normalized to the hyphenated value; loading them must not raise. See #38201.
|
||||
assert EndUserType("service_api") is EndUserType.SERVICE_API
|
||||
assert EndUserType("service-api") is EndUserType.SERVICE_API
|
||||
|
||||
|
||||
def test_end_user_type_still_rejects_unknown_values():
|
||||
with pytest.raises(ValueError):
|
||||
EndUserType("not-a-real-end-user-type")
|
||||
|
||||
|
||||
def test_enum_text_deserializes_legacy_service_api_value():
|
||||
# Exercises the actual column load path that raised for unmigrated rows.
|
||||
column_type = EnumText(EndUserType)
|
||||
assert column_type.process_result_value("service_api", DefaultDialect()) is EndUserType.SERVICE_API
|
||||
|
||||
|
||||
def test_end_user_service_creation_methods_accept_end_user_type():
|
||||
assert inspect.signature(EndUserService.get_or_create_end_user_by_type).parameters["type"].annotation is EndUserType
|
||||
assert inspect.signature(EndUserService.create_end_user_batch).parameters["type"].annotation is EndUserType
|
||||
|
||||
@@ -633,6 +633,8 @@ class TestMyPermissions:
|
||||
assert "dataset.acl.preview" in out.workspace.permission_keys
|
||||
assert "app.acl.preview" in out.app.default_permission_keys
|
||||
assert "dataset.acl.preview" in out.dataset.default_permission_keys
|
||||
if role == "editor":
|
||||
assert "app.acl.log_and_annotation" in out.app.default_permission_keys
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("role", "expected_snippet_keys"),
|
||||
|
||||
@@ -4,6 +4,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, call, patch
|
||||
|
||||
import pytest
|
||||
import zstandard
|
||||
from pydantic import TypeAdapter
|
||||
from redis import RedisError
|
||||
|
||||
@@ -14,15 +15,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()
|
||||
@@ -137,17 +129,71 @@ class TestFetchLatestPluginVersion:
|
||||
|
||||
|
||||
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."""
|
||||
def test_store_cached_plugin_model_providers_compresses_large_payload(self) -> None:
|
||||
"""Large provider metadata payloads are compressed before being stored in Redis."""
|
||||
large_provider = _build_provider_entity()
|
||||
large_provider.label = I18nObject(en_US="OpenAI " * 10000)
|
||||
raw_payload = TypeAdapter(list[ProviderEntity]).dump_json([large_provider])
|
||||
cache_key = _provider_cache_key("tenant-1", 0)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.redis_client") as redis_client,
|
||||
patch(f"{MODULE}.dify_config") as mock_config,
|
||||
):
|
||||
mock_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL = 86400
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
PluginService._store_cached_plugin_model_providers("tenant-1", 0, [large_provider])
|
||||
|
||||
redis_client.setex.assert_called_once()
|
||||
stored_key, ttl, stored_payload = redis_client.setex.call_args.args
|
||||
assert stored_key == cache_key
|
||||
assert ttl == 86400
|
||||
assert isinstance(stored_payload, bytes)
|
||||
prefix = PluginService.PLUGIN_MODEL_PROVIDERS_CACHE_COMPRESSION_PREFIX
|
||||
assert stored_payload.startswith(prefix)
|
||||
assert len(stored_payload) < len(raw_payload)
|
||||
assert zstandard.decompress(stored_payload[len(prefix) :]) == raw_payload
|
||||
|
||||
def test_fetch_plugin_model_providers_reads_compressed_cached_provider_without_calling_daemon(self) -> None:
|
||||
"""Compressed tenant cache entries are decoded before provider schema validation."""
|
||||
cached_provider = _build_provider_entity()
|
||||
cached_payload = TypeAdapter(list[ProviderEntity]).dump_json([cached_provider]).decode("utf-8")
|
||||
cached_provider.label = I18nObject(en_US="OpenAI " * 10000)
|
||||
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")
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
compressed_payload = PluginService.PLUGIN_MODEL_PROVIDERS_CACHE_COMPRESSION_PREFIX + zstandard.compress(
|
||||
cached_payload, level=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 = [compressed_payload]
|
||||
client = Mock()
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
assert result[0].label.en_us == "OpenAI " * 10000
|
||||
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])
|
||||
|
||||
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])
|
||||
generation_key = _provider_generation_key("tenant-1")
|
||||
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 = [cached_payload]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
@@ -158,19 +204,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 +229,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 +252,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 +263,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()]
|
||||
@@ -235,6 +282,352 @@ 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_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])
|
||||
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.side_effect = [[None], [cached_payload]]
|
||||
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)
|
||||
|
||||
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()
|
||||
client.fetch_model_providers.assert_not_called()
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
|
||||
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}.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]
|
||||
redis_client.lock.return_value.acquire.return_value = False
|
||||
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=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 (
|
||||
patch(f"{MODULE}.redis_client") as redis_client,
|
||||
patch(f"{MODULE}.time.sleep") as sleep,
|
||||
):
|
||||
redis_client.get.return_value = 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()
|
||||
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)
|
||||
|
||||
sleep.assert_not_called()
|
||||
redis_client.lock.assert_called_once()
|
||||
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_caches_empty_provider_list(self) -> None:
|
||||
"""An empty provider list is still a valid refresh result for single-flight waiters."""
|
||||
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]
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = []
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
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()
|
||||
|
||||
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, None, "1"]
|
||||
redis_client.mget.return_value = [None]
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = []
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
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()
|
||||
|
||||
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([])
|
||||
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 = [empty_payload]
|
||||
client = Mock()
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
assert result == ()
|
||||
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:
|
||||
"""The service owns plugin daemon access when no runtime-provided client is injected."""
|
||||
with (
|
||||
@@ -242,7 +635,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()]
|
||||
|
||||
@@ -254,35 +647,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
|
||||
@@ -310,41 +674,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"]
|
||||
|
||||
|
||||
|
||||
Generated
+2
@@ -1374,6 +1374,7 @@ dependencies = [
|
||||
{ name = "resend" },
|
||||
{ name = "sendgrid" },
|
||||
{ name = "sseclient-py" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -1657,6 +1658,7 @@ requires-dist = [
|
||||
{ name = "resend", specifier = ">=2.27.0,<3.0.0" },
|
||||
{ name = "sendgrid", specifier = ">=6.12.5,<7.0.0" },
|
||||
{ name = "sseclient-py", specifier = ">=1.8.0,<2.0.0" },
|
||||
{ name = "zstandard", specifier = "==0.25.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
|
||||
@@ -3335,14 +3335,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/explore/sidebar/app-nav-item/index.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/explore/try-app/app/text-generation.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
@@ -3619,11 +3611,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/model-provider-page-body.tsx": {
|
||||
"jsx-a11y/anchor-has-content": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
|
||||
@@ -3,19 +3,16 @@ import { toast, ToastHost } from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
type BaseUIAnimationGlobal = typeof globalThis & {
|
||||
BASE_UI_ANIMATIONS_DISABLED: boolean
|
||||
}
|
||||
|
||||
const dispatchToastMouseOver = (element: HTMLElement | SVGElement) => {
|
||||
element.dispatchEvent(new MouseEvent('mouseover', {
|
||||
bubbles: true,
|
||||
}))
|
||||
}
|
||||
|
||||
const dispatchToastMouseOut = (element: HTMLElement | SVGElement) => {
|
||||
element.dispatchEvent(new MouseEvent('mouseout', {
|
||||
bubbles: true,
|
||||
relatedTarget: document.body,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('@langgenius/dify-ui/toast', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -38,13 +35,10 @@ describe('@langgenius/dify-ui/toast', () => {
|
||||
|
||||
await expect.element(screen.getByText('Saved')).toBeInTheDocument()
|
||||
await expect.element(screen.getByText('Your changes are available now.')).toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveAttribute('aria-live', 'polite')
|
||||
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveClass('z-60')
|
||||
expect(screen.getByRole('region', { name: 'Notifications' }).element().firstElementChild).toHaveClass('top-4')
|
||||
expect(screen.getByText('Saved').element().closest('[class*="transition-opacity"]')).toHaveClass('motion-reduce:transition-none')
|
||||
expect(screen.getByRole('dialog').element()).not.toHaveClass('outline-hidden')
|
||||
const viewport = screen.getByRole('region', { name: 'Notifications' })
|
||||
await expect.element(viewport).toHaveAttribute('aria-live', 'polite')
|
||||
await expect.element(viewport).toHaveClass('z-60')
|
||||
expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument()
|
||||
expect(document.body.querySelector('button[aria-label="Close notification"][aria-hidden="true"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep multiple toast roots mounted in a collapsed stack', async () => {
|
||||
@@ -58,37 +52,6 @@ describe('@langgenius/dify-ui/toast', () => {
|
||||
|
||||
await expect.element(screen.getByText('Third toast')).toBeInTheDocument()
|
||||
expect(document.body.querySelectorAll('[role="dialog"]')).toHaveLength(3)
|
||||
expect(document.body.querySelectorAll('button[aria-label="Close notification"][aria-hidden="true"]')).toHaveLength(3)
|
||||
|
||||
const viewport = screen.getByRole('region', { name: 'Notifications' }).element()
|
||||
dispatchToastMouseOver(viewport)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('button[aria-label="Close notification"][aria-hidden="true"]')).not.toBeInTheDocument()
|
||||
})
|
||||
dispatchToastMouseOut(viewport)
|
||||
})
|
||||
|
||||
it('should clamp varying-height toasts to the frontmost stack height when collapsed', async () => {
|
||||
const screen = await render(<ToastHost />)
|
||||
|
||||
toast.info('Long background toast', {
|
||||
description: 'This longer toast intentionally spans multiple lines so it would overflow the collapsed stack without matching the frontmost toast height.',
|
||||
})
|
||||
toast.success('Short front toast', {
|
||||
description: 'Short message.',
|
||||
})
|
||||
|
||||
await expect.element(screen.getByText('Short front toast')).toBeInTheDocument()
|
||||
await expect.element(screen.getByText('Long background toast')).toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveAttribute('aria-live', 'polite')
|
||||
await expect.element(screen.getByRole('dialog', { name: 'Short front toast' })).toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('dialog', { name: 'Long background toast' })).toBeInTheDocument()
|
||||
|
||||
const longToastContent = screen.getByText('Long background toast').element().closest('[class*="transition-opacity"]')
|
||||
expect(longToastContent).toHaveAttribute('data-behind')
|
||||
expect(longToastContent).toHaveClass('h-full')
|
||||
expect(longToastContent?.parentElement).toHaveClass('h-full')
|
||||
})
|
||||
|
||||
it('should render a neutral toast when called directly', async () => {
|
||||
@@ -157,7 +120,6 @@ describe('@langgenius/dify-ui/toast', () => {
|
||||
dispatchToastMouseOver(viewport)
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Close notification' })).toBeInTheDocument()
|
||||
dispatchToastMouseOut(viewport)
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Close notification' }).element()).click()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -166,15 +128,112 @@ describe('@langgenius/dify-ui/toast', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should keep zero-timeout toasts persistent', async () => {
|
||||
const screen = await render(<ToastHost />)
|
||||
it('should let pointer events pass through a toast while it is exiting', async () => {
|
||||
const onClick = vi.fn()
|
||||
const baseUIAnimationGlobal = globalThis as BaseUIAnimationGlobal
|
||||
const animationState = baseUIAnimationGlobal.BASE_UI_ANIMATIONS_DISABLED
|
||||
baseUIAnimationGlobal.BASE_UI_ANIMATIONS_DISABLED = false
|
||||
|
||||
try {
|
||||
const screen = await render(
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
[role="dialog"] {
|
||||
transition: opacity 10000s, transform 10000s !important;
|
||||
}
|
||||
[role="dialog"][data-ending-style] {
|
||||
opacity: 0 !important;
|
||||
transform: translateY(-150%) !important;
|
||||
}
|
||||
.data-ending-style\\:pointer-events-none[data-ending-style] {
|
||||
pointer-events: none;
|
||||
}
|
||||
.data-ending-style\\:after\\:pointer-events-none[data-ending-style]::after {
|
||||
pointer-events: none;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '16px',
|
||||
right: '32px',
|
||||
width: '360px',
|
||||
height: '96px',
|
||||
}}
|
||||
>
|
||||
Underlying action
|
||||
</button>
|
||||
<ToastHost />
|
||||
</>,
|
||||
)
|
||||
|
||||
toast('Dismiss me', {
|
||||
timeout: 0,
|
||||
})
|
||||
|
||||
await expect.element(screen.getByRole('dialog', { name: 'Dismiss me' })).toBeInTheDocument()
|
||||
asHTMLElement(screen.getByRole('dialog', { name: 'Dismiss me' }).element()).click()
|
||||
|
||||
const viewport = screen.getByRole('region', { name: 'Notifications' }).element()
|
||||
dispatchToastMouseOver(viewport)
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Close notification' })).toBeInTheDocument()
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Close notification' }).element()).click()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: 'Dismiss me' }).element()).toHaveAttribute('data-ending-style')
|
||||
})
|
||||
|
||||
asHTMLElement(screen.getByRole('dialog', { name: 'Dismiss me' }).element()).click()
|
||||
|
||||
const underlyingAction = asHTMLElement(screen.getByRole('button', { name: 'Underlying action' }).element())
|
||||
const rect = underlyingAction.getBoundingClientRect()
|
||||
const x = rect.left + rect.width / 2
|
||||
const y = rect.top + rect.height / 2
|
||||
|
||||
document.elementFromPoint(x, y)?.dispatchEvent(new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
}))
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
finally {
|
||||
baseUIAnimationGlobal.BASE_UI_ANIMATIONS_DISABLED = animationState
|
||||
}
|
||||
})
|
||||
|
||||
it('should pass the host timeout to added toasts', async () => {
|
||||
const screen = await render(<ToastHost timeout={1000} />)
|
||||
|
||||
toast('Auto dismiss')
|
||||
await expect.element(screen.getByText('Auto dismiss')).toBeInTheDocument()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(999)
|
||||
expect(document.body).toHaveTextContent('Auto dismiss')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body).not.toHaveTextContent('Auto dismiss')
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep a toast persistent when its timeout is zero', async () => {
|
||||
const screen = await render(<ToastHost timeout={1000} />)
|
||||
|
||||
toast('Persistent', {
|
||||
timeout: 0,
|
||||
})
|
||||
|
||||
await expect.element(screen.getByText('Persistent')).toBeInTheDocument()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10000)
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(document.body).toHaveTextContent('Persistent')
|
||||
})
|
||||
|
||||
@@ -185,6 +244,7 @@ describe('@langgenius/dify-ui/toast', () => {
|
||||
description: 'Preparing your data…',
|
||||
})
|
||||
await expect.element(screen.getByText('Loading')).toBeInTheDocument()
|
||||
expect(document.body.querySelector('[aria-hidden="true"].i-ri-information-2-fill')).toBeInTheDocument()
|
||||
|
||||
toast.update(toastId, {
|
||||
title: 'Done',
|
||||
@@ -195,27 +255,28 @@ describe('@langgenius/dify-ui/toast', () => {
|
||||
await expect.element(screen.getByText('Done')).toBeInTheDocument()
|
||||
await expect.element(screen.getByText('Your data is ready.')).toBeInTheDocument()
|
||||
expect(document.body).not.toHaveTextContent('Loading')
|
||||
expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should upsert an existing toast when add is called with the same id', async () => {
|
||||
const screen = await render(<ToastHost />)
|
||||
|
||||
toast('Syncing', {
|
||||
id: 'sync-job',
|
||||
description: 'Uploading changes…',
|
||||
toast('Draft saving', {
|
||||
id: 'draft-save-status',
|
||||
description: 'Saving changes…',
|
||||
})
|
||||
await expect.element(screen.getByText('Syncing')).toBeInTheDocument()
|
||||
await expect.element(screen.getByText('Draft saving')).toBeInTheDocument()
|
||||
|
||||
toast.success('Synced', {
|
||||
id: 'sync-job',
|
||||
description: 'All changes are uploaded.',
|
||||
toast.success('Draft saved', {
|
||||
id: 'draft-save-status',
|
||||
description: 'All changes are saved.',
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body).not.toHaveTextContent('Syncing')
|
||||
expect(document.body).not.toHaveTextContent('Draft saving')
|
||||
})
|
||||
await expect.element(screen.getByText('Synced')).toBeInTheDocument()
|
||||
await expect.element(screen.getByText('All changes are uploaded.')).toBeInTheDocument()
|
||||
await expect.element(screen.getByText('Draft saved')).toBeInTheDocument()
|
||||
await expect.element(screen.getByText('All changes are saved.')).toBeInTheDocument()
|
||||
expect(document.body.querySelectorAll('[role="dialog"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -261,5 +322,6 @@ describe('@langgenius/dify-ui/toast', () => {
|
||||
|
||||
await expect.element(screen.getByText('Saved')).toBeInTheDocument()
|
||||
await expect.element(screen.getByText('Your changes are available now.')).toBeInTheDocument()
|
||||
expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -153,13 +153,13 @@ function ToastCard({
|
||||
<BaseToast.Root
|
||||
toast={toastItem}
|
||||
className={cn(
|
||||
'pointer-events-auto absolute top-0 right-0 w-90 max-w-[calc(100vw-2rem)] origin-top cursor-default rounded-xl select-none focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
'pointer-events-auto absolute top-0 right-0 w-full origin-top cursor-default rounded-xl select-none focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
'[--toast-current-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:8px] [--toast-peek:5px] [--toast-scale:calc(1-(var(--toast-index)*0.0225))] [--toast-shrink:calc(1-var(--toast-scale))]',
|
||||
'z-[calc(100-var(--toast-index))] h-(--toast-current-height)',
|
||||
'[transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms] motion-reduce:transition-none',
|
||||
'transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-current-height))))_scale(var(--toast-scale))]',
|
||||
'data-expanded:h-(--toast-height) data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-offset-y)+var(--toast-swipe-movement-y)+(var(--toast-index)*8px)))_scale(1)]',
|
||||
'data-ending-style:transform-[translateY(-150%)] data-ending-style:opacity-0',
|
||||
'data-ending-style:pointer-events-none data-ending-style:after:pointer-events-none data-ending-style:transform-[translateY(-150%)] data-ending-style:opacity-0',
|
||||
'data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+150%))]',
|
||||
'data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+150%))]',
|
||||
'data-limited:pointer-events-none data-limited:opacity-0 data-starting-style:transform-[translateY(-150%)] data-starting-style:opacity-0',
|
||||
@@ -178,13 +178,13 @@ function ToastCard({
|
||||
<div className="min-w-0 flex-1 p-1">
|
||||
<div className="flex w-full min-w-0 items-center gap-1">
|
||||
{toastItem.title != null && (
|
||||
<BaseToast.Title className="min-w-0 flex-1 system-sm-semibold wrap-break-word [overflow-wrap:anywhere] text-text-primary">
|
||||
<BaseToast.Title className="min-w-0 flex-1 system-sm-semibold wrap-break-word text-text-primary">
|
||||
{toastItem.title}
|
||||
</BaseToast.Title>
|
||||
)}
|
||||
</div>
|
||||
{toastItem.description != null && (
|
||||
<BaseToast.Description className="mt-1 min-w-0 system-xs-regular wrap-break-word [overflow-wrap:anywhere] text-text-secondary">
|
||||
<BaseToast.Description className="mt-1 min-w-0 system-xs-regular wrap-break-word text-text-secondary">
|
||||
{toastItem.description}
|
||||
</BaseToast.Description>
|
||||
)}
|
||||
@@ -222,21 +222,15 @@ function ToastViewport() {
|
||||
<BaseToast.Viewport
|
||||
aria-label={toastViewportLabel}
|
||||
className={cn(
|
||||
'group/toast-viewport pointer-events-none fixed inset-0 z-60 overflow-visible',
|
||||
'group/toast-viewport pointer-events-none fixed top-4 right-4 z-60 w-90 max-w-[calc(100vw-2rem)] overflow-visible sm:right-8',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute top-4 right-4 w-90 max-w-[calc(100vw-2rem)] sm:right-8',
|
||||
)}
|
||||
>
|
||||
{toasts.map(toastItem => (
|
||||
<ToastCard
|
||||
key={toastItem.id}
|
||||
toast={toastItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{toasts.map(toastItem => (
|
||||
<ToastCard
|
||||
key={toastItem.id}
|
||||
toast={toastItem}
|
||||
/>
|
||||
))}
|
||||
</BaseToast.Viewport>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -232,7 +232,20 @@ describe('Billing Page + Plan Integration', () => {
|
||||
|
||||
// Verify billing URL button visibility and behavior
|
||||
describe('Billing URL button', () => {
|
||||
it('should show billing button when subscription management permission is granted', () => {
|
||||
it('should show billing button when manager has subscription management permission', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupAppContext({
|
||||
isCurrentWorkspaceManager: true,
|
||||
workspacePermissionKeys: ['billing.subscription.manage'],
|
||||
})
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button when subscription management permission is granted without manager role', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupAppContext({
|
||||
isCurrentWorkspaceManager: false,
|
||||
@@ -241,8 +254,7 @@ describe('Billing Page + Plan Integration', () => {
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button when subscription management permission is missing', () => {
|
||||
|
||||
@@ -204,12 +204,5 @@ describe('Sidebar Lifecycle Flow', () => {
|
||||
|
||||
expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide NoApps on mobile', () => {
|
||||
mockMediaType = MediaType.mobile
|
||||
renderSidebar()
|
||||
|
||||
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,9 @@ import PluginPage from '@/app/components/plugins/plugin-page'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
|
||||
const mockFetchManifestFromMarketPlace = vi.fn()
|
||||
const { mockRouterReplace } = vi.hoisted(() => ({
|
||||
mockRouterReplace: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
@@ -26,6 +29,12 @@ vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockRouterReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
|
||||
}))
|
||||
|
||||
@@ -2,17 +2,65 @@ import type { LegacyPluginsSearchParams } from '@/app/components/plugins/plugin-
|
||||
import Marketplace from '@/app/components/plugins/marketplace'
|
||||
import PluginPage from '@/app/components/plugins/plugin-page'
|
||||
import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel'
|
||||
import { getLegacyPluginRedirectPath } from '@/app/components/plugins/plugin-routes'
|
||||
import {
|
||||
getFirstPackageIdFromSearchParams,
|
||||
getInstallRedirectPathByPluginCategory,
|
||||
getInstallRedirectPathFromSearchParams,
|
||||
getLegacyPluginRedirectPath,
|
||||
shouldResolveInstallCategoryRedirect,
|
||||
} from '@/app/components/plugins/plugin-routes'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
import { redirect } from '@/next/navigation'
|
||||
|
||||
type PluginListProps = {
|
||||
searchParams?: Promise<LegacyPluginsSearchParams>
|
||||
}
|
||||
|
||||
type MarketplaceManifestCategoryResponse = {
|
||||
data?: {
|
||||
plugin?: {
|
||||
category?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPluginCategoryFromMarketplace = async (packageId: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${MARKETPLACE_API_PREFIX}/plugins/identifier?unique_identifier=${encodeURIComponent(packageId)}`,
|
||||
{ cache: 'no-store' },
|
||||
)
|
||||
|
||||
if (!response.ok)
|
||||
return undefined
|
||||
|
||||
const payload = await response.json() as MarketplaceManifestCategoryResponse
|
||||
return payload.data?.plugin?.category
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const PluginList = async ({
|
||||
searchParams,
|
||||
}: PluginListProps) => {
|
||||
const redirectPath = getLegacyPluginRedirectPath(await searchParams)
|
||||
const resolvedSearchParams = await searchParams ?? {}
|
||||
const installRedirectPathFromSearchParams = getInstallRedirectPathFromSearchParams(resolvedSearchParams)
|
||||
|
||||
if (installRedirectPathFromSearchParams)
|
||||
redirect(installRedirectPathFromSearchParams)
|
||||
|
||||
if (shouldResolveInstallCategoryRedirect(resolvedSearchParams)) {
|
||||
const packageId = getFirstPackageIdFromSearchParams(resolvedSearchParams)
|
||||
const category = packageId ? await fetchPluginCategoryFromMarketplace(packageId) : undefined
|
||||
const installRedirectPath = getInstallRedirectPathByPluginCategory(category, resolvedSearchParams)
|
||||
|
||||
if (installRedirectPath)
|
||||
redirect(installRedirectPath)
|
||||
}
|
||||
|
||||
const redirectPath = getLegacyPluginRedirectPath(resolvedSearchParams)
|
||||
|
||||
if (redirectPath)
|
||||
redirect(redirectPath)
|
||||
|
||||
@@ -21,6 +21,7 @@ let mockChatConversationDetail: Record<string, unknown> | undefined
|
||||
let mockCompletionConversationDetail: Record<string, unknown> | undefined
|
||||
let mockShowMessageLogModal = false
|
||||
let mockShowPromptLogModal = false
|
||||
let mockShowAgentLogModal = false
|
||||
let mockCurrentLogItem: Record<string, unknown> | undefined
|
||||
let mockCurrentLogModalActiveTab = 'messages'
|
||||
|
||||
@@ -81,6 +82,7 @@ vi.mock('@/app/components/app/store', () => ({
|
||||
setShowAgentLogModal: mockSetShowAgentLogModal,
|
||||
setShowMessageLogModal: mockSetShowMessageLogModal,
|
||||
showPromptLogModal: mockShowPromptLogModal,
|
||||
showAgentLogModal: mockShowAgentLogModal,
|
||||
currentLogModalActiveTab: mockCurrentLogModalActiveTab,
|
||||
}),
|
||||
}))
|
||||
@@ -126,6 +128,7 @@ vi.mock('@/app/components/base/chat/chat', () => ({
|
||||
onAnnotationEdited,
|
||||
onAnnotationRemoved,
|
||||
switchSibling,
|
||||
hideLogModal,
|
||||
}: {
|
||||
chatList: Array<{ id: string }>
|
||||
onFeedback: (mid: string, value: { rating: string, content?: string }) => Promise<boolean>
|
||||
@@ -133,8 +136,9 @@ vi.mock('@/app/components/base/chat/chat', () => ({
|
||||
onAnnotationEdited: (query: string, answer: string, index: number) => void
|
||||
onAnnotationRemoved: (index: number) => Promise<boolean>
|
||||
switchSibling: (siblingMessageId: string) => void
|
||||
hideLogModal?: boolean
|
||||
}) => (
|
||||
<div data-testid="chat-panel">
|
||||
<div data-testid="chat-panel" data-hide-log-modal={String(hideLogModal)}>
|
||||
<div>{chatList.length}</div>
|
||||
<button onClick={() => void onFeedback('message-1', { rating: 'like', content: 'nice' })}>chat-feedback</button>
|
||||
<button onClick={() => onAnnotationAdded('annotation-2', 'Admin', 'Edited question', 'Edited answer', 1)}>chat-add-annotation</button>
|
||||
@@ -145,6 +149,14 @@ vi.mock('@/app/components/base/chat/chat', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/agent-log-modal', () => ({
|
||||
default: ({ floating, onCancel }: { floating?: boolean, onCancel: () => void }) => (
|
||||
<div data-testid="agent-log-modal" data-floating={String(floating)}>
|
||||
<button onClick={onCancel}>close-agent-log-modal</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/message-log-modal', () => ({
|
||||
default: ({ onCancel }: { onCancel: () => void }) => (
|
||||
<div data-testid="message-log-modal">
|
||||
@@ -255,6 +267,7 @@ describe('ConversationList', () => {
|
||||
mockCompletionConversationDetail = undefined
|
||||
mockShowMessageLogModal = false
|
||||
mockShowPromptLogModal = false
|
||||
mockShowAgentLogModal = false
|
||||
mockCurrentLogItem = undefined
|
||||
mockCurrentLogModalActiveTab = 'messages'
|
||||
mockDelAnnotation.mockResolvedValue(undefined)
|
||||
@@ -383,6 +396,7 @@ describe('ConversationList', () => {
|
||||
|
||||
expect(screen.getByTestId('var-panel')).toHaveTextContent('query:Latest question')
|
||||
expect(screen.getByTestId('model-info')).toHaveTextContent('gpt-4o')
|
||||
expect(screen.getByTestId('chat-panel')).toHaveAttribute('data-hide-log-modal', 'true')
|
||||
expect(screen.getByTestId('message-log-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('chat-feedback'))
|
||||
@@ -399,6 +413,61 @@ describe('ConversationList', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should mount agent log modals from the detail panel instead of the nested chat layout', async () => {
|
||||
mockChatConversationDetail = {
|
||||
id: 'conversation-1',
|
||||
created_at: 1710000000,
|
||||
model_config: {
|
||||
model: 'gpt-4o',
|
||||
configs: {
|
||||
introduction: 'Hello there',
|
||||
},
|
||||
user_input_form: [],
|
||||
},
|
||||
message: {
|
||||
inputs: {},
|
||||
},
|
||||
}
|
||||
mockShowAgentLogModal = true
|
||||
mockCurrentLogItem = {
|
||||
id: 'message-1',
|
||||
conversationId: 'conversation-1',
|
||||
}
|
||||
mockFetchChatMessages.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'message-1',
|
||||
answer: 'Assistant reply',
|
||||
query: 'Latest question',
|
||||
created_at: 1710000000,
|
||||
inputs: {},
|
||||
feedbacks: [],
|
||||
message: [],
|
||||
message_files: [],
|
||||
agent_thoughts: [{ id: 'thought-1' }],
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
})
|
||||
|
||||
renderConversationList({
|
||||
searchParams: '?page=2&conversation_id=conversation-1',
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chat-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('chat-panel')).toHaveAttribute('data-hide-log-modal', 'true')
|
||||
expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('agent-log-modal')).toHaveAttribute('data-floating', 'true')
|
||||
|
||||
fireEvent.click(screen.getByText('close-agent-log-modal'))
|
||||
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalled()
|
||||
expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should render completion details and refetch after feedback updates', async () => {
|
||||
mockCompletionConversationDetail = {
|
||||
id: 'conversation-1',
|
||||
@@ -424,7 +493,7 @@ describe('ConversationList', () => {
|
||||
},
|
||||
}
|
||||
mockShowPromptLogModal = true
|
||||
mockCurrentLogItem = { id: 'log-2' }
|
||||
mockCurrentLogItem = { id: 'log-2', log: [{ role: 'user', text: 'Prompt body' }] }
|
||||
|
||||
renderConversationList({
|
||||
appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any,
|
||||
@@ -626,7 +695,7 @@ describe('ConversationList', () => {
|
||||
},
|
||||
}
|
||||
mockShowPromptLogModal = true
|
||||
mockCurrentLogItem = { id: 'log-2' }
|
||||
mockCurrentLogItem = { id: 'log-2', log: [{ role: 'user', text: 'Prompt body' }] }
|
||||
|
||||
renderConversationList({
|
||||
appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any,
|
||||
|
||||
@@ -36,6 +36,7 @@ import ModelInfo from '@/app/components/app/log/model-info'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import TextGeneration from '@/app/components/app/text-generate/item'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import AgentLogModal from '@/app/components/base/agent-log-modal'
|
||||
import Chat from '@/app/components/base/chat/chat'
|
||||
import CopyIcon from '@/app/components/base/copy-icon'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
@@ -165,13 +166,25 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
})
|
||||
const { formatTime } = useTimestamp()
|
||||
const { onClose, appDetail } = useContext(DrawerContext)
|
||||
const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, showPromptLogModal, setShowPromptLogModal, currentLogModalActiveTab } = useAppStore(useShallow((state: AppStoreState) => ({
|
||||
const {
|
||||
currentLogItem,
|
||||
setCurrentLogItem,
|
||||
showMessageLogModal,
|
||||
setShowMessageLogModal,
|
||||
showPromptLogModal,
|
||||
setShowPromptLogModal,
|
||||
showAgentLogModal,
|
||||
setShowAgentLogModal,
|
||||
currentLogModalActiveTab,
|
||||
} = useAppStore(useShallow((state: AppStoreState) => ({
|
||||
currentLogItem: state.currentLogItem,
|
||||
setCurrentLogItem: state.setCurrentLogItem,
|
||||
showMessageLogModal: state.showMessageLogModal,
|
||||
setShowMessageLogModal: state.setShowMessageLogModal,
|
||||
showPromptLogModal: state.showPromptLogModal,
|
||||
setShowPromptLogModal: state.setShowPromptLogModal,
|
||||
showAgentLogModal: state.showAgentLogModal,
|
||||
setShowAgentLogModal: state.setShowAgentLogModal,
|
||||
currentLogModalActiveTab: state.currentLogModalActiveTab,
|
||||
})))
|
||||
const { t } = useTranslation()
|
||||
@@ -395,6 +408,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
|
||||
const isChatMode = appDetail?.mode !== AppModeEnum.COMPLETION
|
||||
const isAdvanced = appDetail?.mode === AppModeEnum.ADVANCED_CHAT
|
||||
const shouldShowPromptLogModal = showPromptLogModal && !!currentLogItem?.log
|
||||
|
||||
const varList = getDetailVarList(detail, varValues)
|
||||
const message_files = getCompletionMessageFiles(detail, isChatMode)
|
||||
@@ -507,6 +521,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
noChatInput
|
||||
showPromptLog
|
||||
hideProcessDetail
|
||||
hideLogModal
|
||||
chatContainerInnerClassName="px-3"
|
||||
switchSibling={switchSibling}
|
||||
/>
|
||||
@@ -546,6 +561,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
noChatInput
|
||||
showPromptLog
|
||||
hideProcessDetail
|
||||
hideLogModal
|
||||
chatContainerInnerClassName="px-3"
|
||||
switchSibling={switchSibling}
|
||||
/>
|
||||
@@ -574,7 +590,18 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
/>
|
||||
</WorkflowContextProvider>
|
||||
)}
|
||||
{!isChatMode && showPromptLogModal && (
|
||||
{showAgentLogModal && (
|
||||
<AgentLogModal
|
||||
floating
|
||||
width={width}
|
||||
currentLogItem={currentLogItem}
|
||||
onCancel={() => {
|
||||
setCurrentLogItem()
|
||||
setShowAgentLogModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{shouldShowPromptLogModal && (
|
||||
<PromptLogModal
|
||||
width={width}
|
||||
currentLogItem={currentLogItem}
|
||||
|
||||
@@ -1058,11 +1058,20 @@ export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-[26px] shrink-0 items-start px-3" />
|
||||
<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={cn(
|
||||
'flex min-w-0 shrink-0 items-center overflow-hidden 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 flex-1 items-center gap-1 whitespace-nowrap">
|
||||
<div className="truncate">{app.author_name}</div>
|
||||
<div className="shrink-0">·</div>
|
||||
<div className="truncate">{editTimeText}</div>
|
||||
{app.author_name && (
|
||||
<>
|
||||
<div className="min-w-0 truncate">{app.author_name}</div>
|
||||
<div className="shrink-0">·</div>
|
||||
</>
|
||||
)}
|
||||
<div className="min-w-0 truncate">{editTimeText}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -119,6 +119,17 @@ describe('AgentLogModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the floating modal through a dialog portal', () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
const { container } = render(<AgentLogModal {...mockProps} floating />)
|
||||
|
||||
const modal = screen.getByRole('dialog')
|
||||
expect(container).not.toContainElement(modal)
|
||||
expect(document.body).toContainElement(modal)
|
||||
expect(modal).toHaveClass('fixed', 'z-50', 'w-[480px]!', 'left-[max(8px,calc(100vw-1136px))]!')
|
||||
})
|
||||
|
||||
it('should call onCancel when close button is clicked', () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
@@ -158,4 +169,18 @@ describe('AgentLogModal', () => {
|
||||
|
||||
expect(mockProps.onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not use click-away to close the floating dialog', () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
let clickAwayHandler!: (event: Event) => void
|
||||
vi.mocked(useClickAway).mockImplementation((callback) => {
|
||||
clickAwayHandler = callback
|
||||
})
|
||||
|
||||
render(<AgentLogModal {...mockProps} floating />)
|
||||
clickAwayHandler(new Event('click'))
|
||||
|
||||
expect(mockProps.onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
@@ -10,11 +11,13 @@ import AgentLogDetail from './detail'
|
||||
type AgentLogModalProps = Readonly<{
|
||||
currentLogItem?: IChatItem
|
||||
width: number
|
||||
floating?: boolean
|
||||
onCancel: () => void
|
||||
}>
|
||||
const AgentLogModal: FC<AgentLogModalProps> = ({
|
||||
currentLogItem,
|
||||
width,
|
||||
floating,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
@@ -22,7 +25,7 @@ const AgentLogModal: FC<AgentLogModalProps> = ({
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useClickAway(() => {
|
||||
if (mounted)
|
||||
if (mounted && !floating)
|
||||
onCancel()
|
||||
}, ref)
|
||||
|
||||
@@ -33,6 +36,44 @@ const AgentLogModal: FC<AgentLogModalProps> = ({
|
||||
if (!currentLogItem || !currentLogItem.conversationId)
|
||||
return null
|
||||
|
||||
const detailContent = (
|
||||
<>
|
||||
<AgentLogDetail
|
||||
conversationID={currentLogItem.conversationId}
|
||||
messageID={currentLogItem.id}
|
||||
log={currentLogItem}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
if (floating) {
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
backdropClassName="bg-transparent!"
|
||||
className="top-16! bottom-4! left-[max(8px,calc(100vw-1136px))]! flex max-h-none! w-[480px]! max-w-[calc(100vw-16px)]! translate-x-0! translate-y-0! flex-col overflow-hidden! rounded-xl! border-[0.5px]! border-components-panel-border! bg-components-panel-bg! p-0! pt-3! pb-3! shadow-xl!"
|
||||
>
|
||||
<DialogTitle className="text-md shrink-0 px-4 py-1 font-semibold text-text-primary">{t('runDetail.workflowTitle', { ns: 'appLog' })}</DialogTitle>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
className="absolute top-4 right-3 z-20 cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" />
|
||||
</button>
|
||||
{detailContent}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative z-10 flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg py-3 shadow-xl')}
|
||||
@@ -54,11 +95,7 @@ const AgentLogModal: FC<AgentLogModalProps> = ({
|
||||
>
|
||||
<RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" />
|
||||
</button>
|
||||
<AgentLogDetail
|
||||
conversationID={currentLogItem.conversationId}
|
||||
messageID={currentLogItem.id}
|
||||
log={currentLogItem}
|
||||
/>
|
||||
{detailContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@ const ChatWrapper = () => {
|
||||
} = useChatWithHistoryContext()
|
||||
|
||||
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
|
||||
const timezone = appSourceType === AppSourceType.webApp
|
||||
? Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
: undefined
|
||||
|
||||
// Semantic variable for better code readability
|
||||
const isHistoryConversation = !!currentConversationId
|
||||
@@ -91,6 +94,8 @@ const ChatWrapper = () => {
|
||||
taskId => stopChatMessageResponding('', taskId, appSourceType, appId),
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
undefined,
|
||||
{ timezone },
|
||||
)
|
||||
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current
|
||||
const inputDisabled = useMemo(() => {
|
||||
|
||||
@@ -6,6 +6,10 @@ import { useParams, usePathname } from '@/next/navigation'
|
||||
import { sseGet, ssePost } from '@/service/base'
|
||||
import { useChat } from '../hooks'
|
||||
|
||||
const useTimestampMock = vi.hoisted(() =>
|
||||
vi.fn(() => ({ formatTime: vi.fn().mockReturnValue('10:00 AM') })),
|
||||
)
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
sseGet: vi.fn(),
|
||||
ssePost: vi.fn(),
|
||||
@@ -31,7 +35,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({ formatTime: vi.fn().mockReturnValue('10:00 AM') }),
|
||||
default: useTimestampMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
@@ -91,6 +95,21 @@ describe('useChat', () => {
|
||||
expect(result.current.suggestedQuestions).toEqual([])
|
||||
})
|
||||
|
||||
it('should pass timestamp options to timestamp formatter', () => {
|
||||
renderHook(() => useChat(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ timezone: 'UTC' },
|
||||
))
|
||||
|
||||
expect(useTimestampMock).toHaveBeenCalledWith({ timezone: 'UTC' })
|
||||
})
|
||||
|
||||
it('should initialize with opening statement and suggested questions', () => {
|
||||
const config = {
|
||||
opening_statement: 'Hello {{name}}',
|
||||
|
||||
@@ -55,6 +55,10 @@ type SendCallback = {
|
||||
isPublicAPI?: boolean
|
||||
}
|
||||
|
||||
type UseChatOptions = {
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
export const useChat = (
|
||||
config?: ChatConfig,
|
||||
formSettings?: {
|
||||
@@ -66,9 +70,10 @@ export const useChat = (
|
||||
clearChatList?: boolean,
|
||||
clearChatListCallback?: (state: boolean) => void,
|
||||
initialConversationId?: string,
|
||||
options: UseChatOptions = {},
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
const { formatTime } = useTimestamp()
|
||||
const { formatTime } = useTimestamp({ timezone: options.timezone })
|
||||
const conversationIdRef = useRef(initialConversationId ?? '')
|
||||
const initialConversationIdRef = useRef(initialConversationId ?? '')
|
||||
const hasStopRespondedRef = useRef(false)
|
||||
|
||||
@@ -81,6 +81,9 @@ const ChatWrapper = () => {
|
||||
opening_statement: currentConversationItem?.introduction || (config as any).opening_statement,
|
||||
} as ChatConfig
|
||||
}, [appParams, currentConversationItem?.introduction])
|
||||
const timezone = appSourceType === AppSourceType.webApp
|
||||
? Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
: undefined
|
||||
const {
|
||||
chatList,
|
||||
handleSend,
|
||||
@@ -98,6 +101,8 @@ const ChatWrapper = () => {
|
||||
taskId => stopChatMessageResponding('', taskId, appSourceType, appId),
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
undefined,
|
||||
{ timezone },
|
||||
)
|
||||
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current
|
||||
const inputDisabled = useMemo(() => {
|
||||
|
||||
+5
-1
@@ -109,7 +109,11 @@ describe('InputField', () => {
|
||||
const scrollBody = panel?.children[1]
|
||||
const footer = panel?.lastElementChild
|
||||
|
||||
expect(panel).toHaveClass('max-h-(--shortcut-popup-max-height)', 'overflow-hidden')
|
||||
// The max-height falls back to a viewport unit so the panel stays bounded
|
||||
// (and the footer/actions reachable via the internal scroll) even when it is
|
||||
// rendered outside the shortcuts popup that defines --shortcut-popup-max-height,
|
||||
// e.g. inside the edit dialog. See issue #37979.
|
||||
expect(panel).toHaveClass('max-h-[var(--shortcut-popup-max-height,80dvh)]', 'overflow-hidden')
|
||||
expect(header).toHaveClass('shrink-0', 'pb-2')
|
||||
expect(scrollBody).toHaveClass('min-h-0', 'flex-1', 'overflow-y-auto')
|
||||
expect(footer).toHaveClass('shrink-0', 'bg-components-panel-bg')
|
||||
|
||||
@@ -216,7 +216,7 @@ const InputField: React.FC<InputFieldProps> = ({
|
||||
}, [handleSave])
|
||||
|
||||
return (
|
||||
<div className="flex max-h-(--shortcut-popup-max-height) w-[372px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
|
||||
<div className="flex max-h-[var(--shortcut-popup-max-height,80dvh)] w-[372px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
|
||||
<div className="shrink-0 p-3 pb-2">
|
||||
<div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ let fetching = false
|
||||
let isManager = true
|
||||
let enableBilling = true
|
||||
let workspacePermissionKeys: string[] = ['billing.subscription.manage']
|
||||
let billingUrlEnabled = false
|
||||
|
||||
const refetchMock = vi.fn()
|
||||
const openAsyncWindowMock = vi.fn()
|
||||
@@ -19,11 +20,14 @@ type BillingWindowOptions = {
|
||||
type OpenAsyncWindowCall = [BillingUrlCallback, BillingWindowOptions]
|
||||
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBillingUrl: () => ({
|
||||
data: currentBillingUrl,
|
||||
isFetching: fetching,
|
||||
refetch: refetchMock,
|
||||
}),
|
||||
useBillingUrl: (enabled: boolean) => {
|
||||
billingUrlEnabled = enabled
|
||||
return {
|
||||
data: currentBillingUrl,
|
||||
isFetching: fetching,
|
||||
refetch: refetchMock,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
@@ -54,28 +58,32 @@ describe('Billing', () => {
|
||||
fetching = false
|
||||
isManager = true
|
||||
enableBilling = true
|
||||
billingUrlEnabled = false
|
||||
workspacePermissionKeys = ['billing.subscription.manage']
|
||||
refetchMock.mockResolvedValue({ data: 'https://billing' })
|
||||
})
|
||||
|
||||
it('shows the billing action when subscription management permission is granted without manager role', () => {
|
||||
it('hides the billing action when subscription management permission is granted without manager role', () => {
|
||||
isManager = false
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /billing\.viewBillingTitle/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
|
||||
expect(billingUrlEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the billing action when subscription management permission is missing or billing is disabled', () => {
|
||||
workspacePermissionKeys = []
|
||||
render(<Billing />)
|
||||
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
|
||||
expect(billingUrlEnabled).toBe(false)
|
||||
|
||||
vi.clearAllMocks()
|
||||
workspacePermissionKeys = ['billing.subscription.manage']
|
||||
enableBilling = false
|
||||
render(<Billing />)
|
||||
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
|
||||
expect(billingUrlEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('opens the billing window with the immediate url when the button is clicked', async () => {
|
||||
|
||||
@@ -11,9 +11,9 @@ import PlanComp from '../plan'
|
||||
|
||||
const Billing: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { workspacePermissionKeys } = useAppContext()
|
||||
const { isCurrentWorkspaceManager, workspacePermissionKeys } = useAppContext()
|
||||
const { enableBilling } = useProviderContext()
|
||||
const canManageBillingSubscription = hasPermission(workspacePermissionKeys, BillingPermission.SubscriptionManage)
|
||||
const canManageBillingSubscription = isCurrentWorkspaceManager && hasPermission(workspacePermissionKeys, BillingPermission.SubscriptionManage)
|
||||
const { data: billingUrl, isFetching, refetch } = useBillingUrl(enableBilling && canManageBillingSubscription)
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
|
||||
@@ -131,9 +131,9 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => {
|
||||
return <AppUnavailable code={500} unknownReason={t('error.unavailable', { ns: 'datasetCreation' }) as string} />
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden bg-components-panel-bg" style={{ height: 'calc(100vh - 56px)' }}>
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-components-panel-bg">
|
||||
<TopBar activeIndex={step - 1} datasetId={datasetId} />
|
||||
<div style={{ height: 'calc(100% - 52px)' }}>
|
||||
<div className="min-h-0 flex-1">
|
||||
{
|
||||
isLoadingAuthedDataSourceList && (
|
||||
<Loading type="app" />
|
||||
|
||||
@@ -218,7 +218,11 @@ const StepOne = ({
|
||||
{/* Notion Data Source */}
|
||||
{dataSourceType === DataSourceType.NOTION && (
|
||||
<>
|
||||
{!isNotionAuthed && <NotionConnector onSetting={onSetting} />}
|
||||
{!isNotionAuthed && (
|
||||
<div className={cn('mb-8 w-[640px]', !shouldShowDataSourceTypeList && 'mt-12')}>
|
||||
<NotionConnector onSetting={onSetting} />
|
||||
</div>
|
||||
)}
|
||||
{isNotionAuthed && (
|
||||
<>
|
||||
<div className="mb-8 w-[640px]">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { expectLoadingButton } from '@/test/button'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import SideBar from '../index'
|
||||
@@ -16,7 +15,6 @@ const mockUpdatePinStatus = vi.fn()
|
||||
let mockIsPending = false
|
||||
let mockIsUninstallPending = false
|
||||
let mockInstalledApps: InstalledApp[] = []
|
||||
let mockMediaType: string = MediaType.pc
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => '/',
|
||||
@@ -26,15 +24,6 @@ vi.mock('@/next/navigation', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: () => mockMediaType,
|
||||
MediaType: {
|
||||
mobile: 'mobile',
|
||||
tablet: 'tablet',
|
||||
pc: 'pc',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledApps: () => ({
|
||||
isPending: mockIsPending,
|
||||
@@ -87,7 +76,6 @@ describe('SideBar', () => {
|
||||
mockIsPending = false
|
||||
mockIsUninstallPending = false
|
||||
mockInstalledApps = []
|
||||
mockMediaType = MediaType.pc
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@@ -97,13 +85,6 @@ describe('SideBar', () => {
|
||||
expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expose an accessible name for the discovery link when the text is hidden', () => {
|
||||
mockMediaType = MediaType.mobile
|
||||
renderSideBar()
|
||||
|
||||
expect(screen.getByRole('link', { name: 'explore.sidebar.title' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workspace items when installed apps exist', () => {
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
renderSideBar()
|
||||
@@ -156,6 +137,17 @@ describe('SideBar', () => {
|
||||
|
||||
expect(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon-only content when folded', () => {
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
renderSideBar()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }))
|
||||
|
||||
expect(screen.getByRole('link', { name: 'explore.sidebar.title' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'My App' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
@@ -229,14 +221,4 @@ describe('SideBar', () => {
|
||||
expectLoadingButton(screen.getByText('common.operation.confirm').closest('button'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should hide NoApps and app names on mobile', () => {
|
||||
mockMediaType = MediaType.mobile
|
||||
renderSideBar()
|
||||
|
||||
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('explore.sidebar.webApps')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import AppNavItem from '../index'
|
||||
|
||||
const baseProps = {
|
||||
isMobile: false,
|
||||
name: 'My App',
|
||||
id: 'app-123',
|
||||
icon_type: 'emoji' as const,
|
||||
@@ -22,18 +21,12 @@ describe('AppNavItem', () => {
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render name and item operation on desktop', () => {
|
||||
it('should render name and item operation when expanded', () => {
|
||||
render(<AppNavItem {...baseProps} />)
|
||||
|
||||
expect(screen.getByText('My App')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('item-operation-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide name on mobile', () => {
|
||||
render(<AppNavItem {...baseProps} isMobile />)
|
||||
|
||||
expect(screen.queryByText('My App')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
@@ -43,6 +36,26 @@ describe('AppNavItem', () => {
|
||||
const link = screen.getByRole('link', { name: 'My App' })
|
||||
|
||||
expect(link).toHaveAttribute('href', '/installed/app-123')
|
||||
expect(link).toHaveAttribute('aria-label', 'My App')
|
||||
expect(link).not.toHaveAttribute('aria-current')
|
||||
})
|
||||
|
||||
it('should use a contextual accessible name when ariaLabel is provided', () => {
|
||||
render(<AppNavItem {...baseProps} variant="mainNav" ariaLabel="Open My App web app" />)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Open My App web app' })
|
||||
|
||||
expect(link).toHaveAttribute('href', '/installed/app-123')
|
||||
expect(link).toHaveAttribute('aria-label', 'Open My App web app')
|
||||
expect(screen.getByText('My App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expose selected state through the current link', () => {
|
||||
render(<AppNavItem {...baseProps} isSelected />)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'My App' })
|
||||
|
||||
expect(link).toHaveAttribute('aria-current', 'page')
|
||||
})
|
||||
|
||||
it('should only show the row focus ring when the app link receives focus', () => {
|
||||
@@ -50,8 +63,8 @@ describe('AppNavItem', () => {
|
||||
|
||||
const row = screen.getByText('My App').closest('.group')
|
||||
|
||||
expect(row).toHaveClass('has-[>a:focus-visible]:ring-2')
|
||||
expect(row).toHaveClass('has-[>a:focus-visible]:ring-state-accent-solid')
|
||||
expect(row).toHaveClass('has-[>a:focus-visible]:inset-ring-2')
|
||||
expect(row).toHaveClass('has-[>a:focus-visible]:inset-ring-state-accent-solid')
|
||||
expect(row).not.toHaveClass('focus-within:ring-2')
|
||||
})
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ import ItemOperation from '@/app/components/explore/item-operation'
|
||||
import Link from '@/next/link'
|
||||
|
||||
type IAppNavItemProps = {
|
||||
isMobile: boolean
|
||||
variant?: 'default' | 'mainNav'
|
||||
name: string
|
||||
ariaLabel?: string
|
||||
id: string
|
||||
icon_type: AppIconType | null
|
||||
icon: string
|
||||
@@ -25,9 +25,9 @@ type IAppNavItemProps = {
|
||||
}
|
||||
|
||||
export default function AppNavItem({
|
||||
isMobile,
|
||||
variant = 'default',
|
||||
name,
|
||||
ariaLabel,
|
||||
id,
|
||||
icon_type,
|
||||
icon,
|
||||
@@ -47,43 +47,31 @@ export default function AppNavItem({
|
||||
key={id}
|
||||
className={cn(
|
||||
isMainNav
|
||||
? 'group flex h-8 items-center justify-between gap-2 rounded-lg py-0.5 pr-0.5 pl-2 transition-colors has-[>a:focus-visible]:ring-2 has-[>a:focus-visible]:ring-state-accent-solid has-[>a:focus-visible]:ring-inset'
|
||||
: 'group flex h-8 items-center justify-between rounded-lg px-2 system-sm-medium text-sm font-normal text-components-menu-item-text has-[>a:focus-visible]:ring-2 has-[>a:focus-visible]:ring-state-accent-solid has-[>a:focus-visible]:ring-inset mobile:justify-center mobile:px-1',
|
||||
isMainNav
|
||||
? (isSelected ? 'bg-state-base-hover' : 'hover:bg-state-base-hover')
|
||||
: (isSelected ? 'bg-state-base-active text-components-menu-item-text-active' : 'hover:bg-state-base-hover hover:text-components-menu-item-text-hover'),
|
||||
? 'group flex h-8 items-center justify-between gap-2 rounded-lg py-0.5 pr-0.5 pl-2 transition-colors not-has-[>a[aria-current=page]]:hover:bg-state-base-hover has-[>a:focus-visible]:inset-ring-2 has-[>a:focus-visible]:inset-ring-state-accent-solid has-[>a[aria-current=page]]:bg-state-base-active'
|
||||
: cn(
|
||||
'group flex h-8 items-center rounded-lg system-sm-medium text-sm font-normal text-components-menu-item-text transition-colors not-has-[>a[aria-current=page]]:hover:bg-state-base-hover not-has-[>a[aria-current=page]]:hover:text-components-menu-item-text-hover has-[>a:focus-visible]:inset-ring-2 has-[>a:focus-visible]:inset-ring-state-accent-solid has-[>a[aria-current=page]]:bg-state-base-active has-[>a[aria-current=page]]:text-components-menu-item-text-active',
|
||||
'w-full justify-start px-2 group-data-[folded=true]/sidebar:justify-center group-data-[folded=true]/sidebar:px-1',
|
||||
),
|
||||
)}
|
||||
>
|
||||
{isMobile && (
|
||||
<Link
|
||||
href={url}
|
||||
aria-label={name}
|
||||
title={name}
|
||||
className="flex min-w-0 flex-1 items-center justify-center outline-hidden"
|
||||
>
|
||||
<AppIcon size="tiny" iconType={icon_type} icon={icon} background={icon_background} imageUrl={icon_url} />
|
||||
</Link>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Link
|
||||
href={url}
|
||||
title={name}
|
||||
className={cn(isMainNav ? 'flex min-w-0 flex-1 items-center gap-2 outline-hidden' : 'flex w-0 grow items-center space-x-2 outline-hidden')}
|
||||
>
|
||||
<AppIcon size="tiny" className={cn(isMainNav && 'size-5 rounded-md text-sm')} iconType={icon_type} icon={icon} background={icon_background} imageUrl={icon_url} />
|
||||
<div className={cn(isMainNav ? 'min-w-0 flex-1 truncate py-1 pr-1 system-sm-regular' : 'truncate system-sm-regular text-components-menu-item-text')} title={name}>{name}</div>
|
||||
</Link>
|
||||
<div className="h-6 shrink-0" onClick={e => e.stopPropagation()}>
|
||||
<ItemOperation
|
||||
isPinned={isPinned}
|
||||
togglePin={togglePin}
|
||||
isShowDelete={!uninstallable && !isSelected}
|
||||
onDelete={() => onDelete(id)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href={url}
|
||||
aria-current={isSelected ? 'page' : undefined}
|
||||
aria-label={ariaLabel ?? name}
|
||||
title={name}
|
||||
className={cn(isMainNav ? 'flex min-w-0 flex-1 items-center gap-2 outline-hidden' : 'flex w-0 grow items-center space-x-2 outline-hidden group-data-[folded=true]/sidebar:w-auto group-data-[folded=true]/sidebar:justify-center group-data-[folded=true]/sidebar:space-x-0')}
|
||||
>
|
||||
<AppIcon size="tiny" className={cn(isMainNav && 'size-5 rounded-md text-sm')} iconType={icon_type} icon={icon} background={icon_background} imageUrl={icon_url} />
|
||||
<div className={cn(isMainNav ? 'min-w-0 flex-1 truncate py-1 pr-1 system-sm-regular' : 'truncate system-sm-regular text-components-menu-item-text group-data-[folded=true]/sidebar:hidden')} title={name}>{name}</div>
|
||||
</Link>
|
||||
<div className={cn(isMainNav ? 'h-6 shrink-0' : 'h-6 shrink-0 group-data-[folded=true]/sidebar:hidden')}>
|
||||
<ItemOperation
|
||||
isPinned={isPinned}
|
||||
togglePin={togglePin}
|
||||
isShowDelete={!uninstallable && !isSelected}
|
||||
onDelete={() => onDelete(id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import Link from '@/next/link'
|
||||
import { usePathname, useSelectedLayoutSegments } from '@/next/navigation'
|
||||
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
|
||||
@@ -40,8 +39,6 @@ const SideBar = () => {
|
||||
const { mutateAsync: uninstallApp, isPending: isUninstalling } = useUninstallApp()
|
||||
const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus()
|
||||
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
const [isFold, {
|
||||
toggle: toggleIsFold,
|
||||
}] = useBoolean(false)
|
||||
@@ -61,12 +58,10 @@ const SideBar = () => {
|
||||
}
|
||||
|
||||
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
|
||||
const shouldUseExpandedScrollArea = !isMobile && !isFold
|
||||
const webAppsLabelId = React.useId()
|
||||
const installedAppItems = installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } }, index) => (
|
||||
<React.Fragment key={id}>
|
||||
<Item
|
||||
isMobile={isMobile || isFold}
|
||||
name={name}
|
||||
icon_type={icon_type}
|
||||
icon={icon}
|
||||
@@ -87,21 +82,24 @@ const SideBar = () => {
|
||||
))
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full w-fit shrink-0 cursor-pointer flex-col px-3 pt-6 sm:w-[240px]', isFold && 'sm:w-[56px]')}>
|
||||
<div
|
||||
data-folded={isFold ? 'true' : undefined}
|
||||
className={cn('group/sidebar flex h-full w-fit shrink-0 cursor-pointer flex-col px-3 pt-6 sm:w-[240px]', isFold && 'sm:w-[56px]')}
|
||||
>
|
||||
<div className={cn(isDiscoverySelected ? 'text-text-accent' : 'text-text-tertiary')}>
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={isMobile || isFold ? t('sidebar.title', { ns: 'explore' }) : undefined}
|
||||
className={cn(isDiscoverySelected ? 'bg-state-base-active' : 'hover:bg-state-base-hover', 'flex h-8 items-center gap-2 rounded-lg px-1 mobile:w-fit mobile:justify-center pc:w-full pc:justify-start')}
|
||||
aria-label={isFold ? t('sidebar.title', { ns: 'explore' }) : undefined}
|
||||
className={cn(isDiscoverySelected ? 'bg-state-base-active' : 'hover:bg-state-base-hover', 'flex h-8 w-full items-center justify-start gap-2 rounded-lg px-1')}
|
||||
>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid">
|
||||
<span aria-hidden="true" className="i-ri-apps-fill size-3.5 text-components-avatar-shape-fill-stop-100" />
|
||||
</div>
|
||||
{!isMobile && !isFold && <div className={cn('truncate', isDiscoverySelected ? 'system-sm-semibold text-components-menu-item-text-active' : 'system-sm-regular text-components-menu-item-text')}>{t('sidebar.title', { ns: 'explore' })}</div>}
|
||||
{!isFold && <div className={cn('truncate', isDiscoverySelected ? 'system-sm-semibold text-components-menu-item-text-active' : 'system-sm-regular text-components-menu-item-text')}>{t('sidebar.title', { ns: 'explore' })}</div>}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!isPending && installedApps.length === 0 && !isMobile && !isFold
|
||||
{!isPending && installedApps.length === 0 && !isFold
|
||||
&& (
|
||||
<div className="mt-5">
|
||||
<NoApps />
|
||||
@@ -110,8 +108,8 @@ const SideBar = () => {
|
||||
|
||||
{installedApps.length > 0 && (
|
||||
<div className="mt-5 flex min-h-0 flex-1 flex-col">
|
||||
{!isMobile && !isFold && <p id={webAppsLabelId} className="mb-1.5 pl-2 system-xs-medium-uppercase break-all text-text-tertiary uppercase mobile:px-0">{t('sidebar.webApps', { ns: 'explore' })}</p>}
|
||||
{shouldUseExpandedScrollArea
|
||||
{!isFold && <p id={webAppsLabelId} className="mb-1.5 pl-2 system-xs-medium-uppercase break-all text-text-tertiary uppercase">{t('sidebar.webApps', { ns: 'explore' })}</p>}
|
||||
{!isFold
|
||||
? (
|
||||
<div className="min-h-0 flex-1">
|
||||
<ScrollArea
|
||||
@@ -133,22 +131,20 @@ const SideBar = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMobile && (
|
||||
<div className="mt-auto flex py-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isFold ? t('sidebar.expandSidebar', { ns: 'layout' }) : t('sidebar.collapseSidebar', { ns: 'layout' })}
|
||||
className="flex size-8 items-center justify-center rounded-lg text-text-tertiary transition-colors hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden focus-visible:ring-inset"
|
||||
onClick={toggleIsFold}
|
||||
>
|
||||
{isFold
|
||||
? <span aria-hidden="true" className="i-ri-expand-right-line" />
|
||||
: (
|
||||
<span aria-hidden="true" className="i-ri-layout-left-2-line" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-auto flex py-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isFold ? t('sidebar.expandSidebar', { ns: 'layout' }) : t('sidebar.collapseSidebar', { ns: 'layout' })}
|
||||
className="flex size-8 items-center justify-center rounded-lg text-text-tertiary transition-colors hover:bg-state-base-hover focus-visible:inset-ring-1 focus-visible:inset-ring-components-input-border-hover focus-visible:outline-hidden"
|
||||
onClick={toggleIsFold}
|
||||
>
|
||||
{isFold
|
||||
? <span aria-hidden="true" className="i-ri-expand-right-line" />
|
||||
: (
|
||||
<span aria-hidden="true" className="i-ri-layout-left-2-line" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||
<AlertDialogContent>
|
||||
|
||||
@@ -26,10 +26,6 @@ import { useProviderContext } from '@/context/provider-context'
|
||||
import Link from '@/next/link'
|
||||
import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content'
|
||||
|
||||
const mainNavMenuGroupClassName = 'p-1'
|
||||
const mainNavMenuItemClassName = 'mx-0 h-8 gap-1 px-3 py-1'
|
||||
const mainNavMenuSubPopupClassName = 'w-60 max-h-[360px] bg-components-panel-bg-blur! p-1! backdrop-blur-[5px]'
|
||||
|
||||
type MainNavRadioItemContentProps = {
|
||||
iconClassName?: string
|
||||
label: ReactNode
|
||||
@@ -56,7 +52,7 @@ function AppearanceSubmenu() {
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className={mainNavMenuItemClassName}>
|
||||
<DropdownMenuSubTrigger className="mx-0 h-8 gap-1 px-3 py-1">
|
||||
<MenuItemContent
|
||||
iconClassName="i-ri-sun-line"
|
||||
label={t('account.appearanceLabel', { ns: 'common' })}
|
||||
@@ -65,16 +61,16 @@ function AppearanceSubmenu() {
|
||||
<DropdownMenuSubContent
|
||||
placement="right-start"
|
||||
sideOffset={6}
|
||||
popupClassName={mainNavMenuSubPopupClassName}
|
||||
popupClassName="w-[139px] max-h-[360px] bg-components-panel-bg-blur p-1 backdrop-blur-[5px]"
|
||||
>
|
||||
<DropdownMenuRadioGroup value={theme || 'system'} onValueChange={value => setTheme(value as Theme)}>
|
||||
<DropdownMenuRadioItem value="light" closeOnClick className={mainNavMenuItemClassName}>
|
||||
<DropdownMenuRadioItem value="light" closeOnClick className="mx-0 h-8 gap-1 px-2 py-1">
|
||||
<MainNavRadioItemContent iconClassName="i-ri-sun-line" label={t('account.appearanceLight', { ns: 'common' })} />
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="dark" closeOnClick className={mainNavMenuItemClassName}>
|
||||
<DropdownMenuRadioItem value="dark" closeOnClick className="mx-0 h-8 gap-1 px-2 py-1">
|
||||
<MainNavRadioItemContent iconClassName="i-ri-moon-line" label={t('account.appearanceDark', { ns: 'common' })} />
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="system" closeOnClick className={mainNavMenuItemClassName}>
|
||||
<DropdownMenuRadioItem value="system" closeOnClick className="mx-0 h-8 gap-1 px-2 py-1">
|
||||
<MainNavRadioItemContent iconClassName="i-ri-computer-line" label={t('account.appearanceSystem', { ns: 'common' })} />
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
@@ -97,7 +93,7 @@ export function MainNavMenuContent({
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuGroup className={mainNavMenuGroupClassName}>
|
||||
<DropdownMenuGroup className="p-1">
|
||||
<div className="flex items-center gap-3 rounded-xl bg-gradient-to-b from-background-section-burn to-background-section p-3">
|
||||
<div className="flex min-w-0 grow flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
@@ -114,9 +110,9 @@ export function MainNavMenuContent({
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" className="shrink-0" />
|
||||
</div>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuGroup className={mainNavMenuGroupClassName}>
|
||||
<DropdownMenuGroup className="p-1">
|
||||
<DropdownMenuLinkItem
|
||||
className={cn('justify-between', mainNavMenuItemClassName)}
|
||||
className="mx-0 h-8 justify-between gap-1 px-3 py-1"
|
||||
render={<Link href="/account" />}
|
||||
>
|
||||
<MenuItemContent
|
||||
@@ -126,7 +122,7 @@ export function MainNavMenuContent({
|
||||
/>
|
||||
</DropdownMenuLinkItem>
|
||||
<DropdownMenuItem
|
||||
className={mainNavMenuItemClassName}
|
||||
className="mx-0 h-8 gap-1 px-3 py-1"
|
||||
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PREFERENCES })}
|
||||
>
|
||||
<MenuItemContent
|
||||
@@ -137,9 +133,9 @@ export function MainNavMenuContent({
|
||||
<AppearanceSubmenu />
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator className="my-0! bg-divider-subtle" />
|
||||
<DropdownMenuGroup className={mainNavMenuGroupClassName}>
|
||||
<DropdownMenuGroup className="p-1">
|
||||
<DropdownMenuItem
|
||||
className={mainNavMenuItemClassName}
|
||||
className="mx-0 h-8 gap-1 px-3 py-1"
|
||||
onClick={() => {
|
||||
void onLogout()
|
||||
}}
|
||||
|
||||
+17
@@ -180,5 +180,22 @@ describe('InstallFromMarketplace Component', () => {
|
||||
// Assert
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use the marketplace callback action when provided', () => {
|
||||
// Arrange
|
||||
vi.mocked(useMarketplaceAllPlugins).mockReturnValue({
|
||||
plugins: mockPlugins,
|
||||
isLoading: false,
|
||||
})
|
||||
const onOpenMarketplace = vi.fn()
|
||||
render(<InstallFromMarketplace providers={mockProviders} searchText="" onOpenMarketplace={onOpenMarketplace} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.difyMarketplace' }))
|
||||
|
||||
// Assert
|
||||
expect(onOpenMarketplace).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByRole('link', { name: 'plugin.marketplace.difyMarketplace' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import InstallFromMarketplace from './install-from-marketplace'
|
||||
|
||||
type DataSourcePageProps = {
|
||||
layout?: (parts: { body: ReactNode, toolbar: ReactNode }) => ReactNode
|
||||
onOpenMarketplace?: () => void
|
||||
stickyToolbar?: boolean
|
||||
}
|
||||
|
||||
@@ -53,6 +54,7 @@ function DataSourceListSkeleton() {
|
||||
|
||||
const DataSourcePage = ({
|
||||
layout,
|
||||
onOpenMarketplace,
|
||||
stickyToolbar,
|
||||
}: DataSourcePageProps) => {
|
||||
const { t } = useTranslation()
|
||||
@@ -164,6 +166,7 @@ const DataSourcePage = ({
|
||||
<InstallFromMarketplace
|
||||
providers={dataSources}
|
||||
searchText={searchText}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+20
-9
@@ -1,10 +1,6 @@
|
||||
import type { DataSourceAuth } from './types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightUpLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import {
|
||||
memo,
|
||||
@@ -25,10 +21,12 @@ import {
|
||||
} from './hooks'
|
||||
|
||||
type InstallFromMarketplaceProps = {
|
||||
onOpenMarketplace?: () => void
|
||||
providers: DataSourceAuth[]
|
||||
searchText: string
|
||||
}
|
||||
const InstallFromMarketplace = ({
|
||||
onOpenMarketplace,
|
||||
providers,
|
||||
searchText,
|
||||
}: InstallFromMarketplaceProps) => {
|
||||
@@ -58,15 +56,28 @@ const InstallFromMarketplace = ({
|
||||
className="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left system-md-semibold text-text-primary"
|
||||
onClick={() => setCollapse(!collapse)}
|
||||
>
|
||||
<RiArrowDownSLine className={cn('size-4', collapse && '-rotate-90')} aria-hidden="true" />
|
||||
<span className={cn('i-ri-arrow-down-s-line size-4', collapse && '-rotate-90')} aria-hidden="true" />
|
||||
{t('modelProvider.installDataSource', { ns: 'common' })}
|
||||
</button>
|
||||
<div className="mb-2 flex items-center pt-2">
|
||||
<span className="pr-1 system-sm-regular text-text-tertiary">{t('modelProvider.discoverMore', { ns: 'common' })}</span>
|
||||
<Link target="_blank" href={getMarketplaceCategoryUrl(PluginCategoryEnum.datasource, { theme })} className="inline-flex items-center system-sm-medium text-text-accent">
|
||||
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
|
||||
<RiArrowRightUpLine className="size-4" />
|
||||
</Link>
|
||||
{onOpenMarketplace
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center border-0 bg-transparent p-0 system-sm-medium text-text-accent"
|
||||
onClick={onOpenMarketplace}
|
||||
>
|
||||
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
|
||||
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<Link target="_blank" rel="noopener noreferrer" href={getMarketplaceCategoryUrl(PluginCategoryEnum.datasource, { theme })} className="inline-flex items-center system-sm-medium text-text-accent">
|
||||
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
|
||||
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!collapse && isAllPluginsLoading && <Loading type="area" />}
|
||||
|
||||
+11
@@ -132,4 +132,15 @@ describe('InstallFromMarketplace', () => {
|
||||
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
|
||||
expect(screen.getByText('plugin.marketplace.difyMarketplace')).toHaveAttribute('href', 'https://marketplace.test/plugins/model?theme=light')
|
||||
})
|
||||
|
||||
it('should use the marketplace callback action when provided', () => {
|
||||
const onOpenMarketplace = vi.fn()
|
||||
|
||||
render(<InstallFromMarketplace providers={mockProviders} searchText="" onOpenMarketplace={onOpenMarketplace} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.difyMarketplace' }))
|
||||
|
||||
expect(onOpenMarketplace).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByRole('link', { name: 'plugin.marketplace.difyMarketplace' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,7 @@ type SystemModelConfigStatus = 'no-provider' | 'none-configured' | 'partially-co
|
||||
|
||||
type Props = Readonly<{
|
||||
layout?: (parts: { body: ReactNode, toolbar: ReactNode }) => ReactNode
|
||||
onOpenMarketplace?: () => void
|
||||
onSearchTextChange?: (value: string) => void
|
||||
searchText: string
|
||||
stickyToolbar?: boolean
|
||||
@@ -41,6 +42,7 @@ const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/an
|
||||
|
||||
const ModelProviderPage = ({
|
||||
layout,
|
||||
onOpenMarketplace,
|
||||
onSearchTextChange,
|
||||
searchText,
|
||||
stickyToolbar,
|
||||
@@ -139,6 +141,7 @@ const ModelProviderPage = ({
|
||||
ttsDefaultModel={ttsDefaultModel}
|
||||
isLoading={isDefaultModelLoading}
|
||||
hideProviderSettingsFooter={hideSystemModelSelectorProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -210,6 +213,7 @@ const ModelProviderPage = ({
|
||||
enableMarketplace={enableMarketplace}
|
||||
searchText={searchText}
|
||||
pluginDetailMap={pluginDetailMap}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
+24
-9
@@ -19,10 +19,12 @@ import {
|
||||
} from './hooks'
|
||||
|
||||
type InstallFromMarketplaceProps = {
|
||||
onOpenMarketplace?: () => void
|
||||
providers: ModelProvider[]
|
||||
searchText: string
|
||||
}
|
||||
const InstallFromMarketplace = ({
|
||||
onOpenMarketplace,
|
||||
providers,
|
||||
searchText,
|
||||
}: InstallFromMarketplaceProps) => {
|
||||
@@ -57,15 +59,28 @@ const InstallFromMarketplace = ({
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="system-sm-regular text-text-tertiary">{t('modelProvider.discoverMore', { ns: 'common' })}</span>
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
|
||||
className="inline-flex items-center system-sm-medium text-text-accent"
|
||||
>
|
||||
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
|
||||
<span className="i-ri-arrow-right-up-line size-4" />
|
||||
</Link>
|
||||
{onOpenMarketplace
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center border-0 bg-transparent p-0 system-sm-medium text-text-accent"
|
||||
onClick={onOpenMarketplace}
|
||||
>
|
||||
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
|
||||
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
|
||||
className="inline-flex items-center system-sm-medium text-text-accent"
|
||||
>
|
||||
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
|
||||
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!collapse && isAllPluginsLoading && <Loading type="area" />}
|
||||
|
||||
+4
@@ -21,6 +21,7 @@ type ModelProviderPageBodyProps = {
|
||||
enableMarketplace: boolean
|
||||
searchText: string
|
||||
pluginDetailMap: Map<string, PluginDetail>
|
||||
onOpenMarketplace?: () => void
|
||||
}
|
||||
|
||||
function ModelProviderCardSkeleton() {
|
||||
@@ -79,6 +80,7 @@ function EmptyProviderState({
|
||||
marketplace: (
|
||||
<a
|
||||
href="#model-provider-marketplace"
|
||||
aria-label={t('marketplace.difyMarketplace', { ns: 'plugin' })}
|
||||
className="system-xs-medium text-text-accent hover:underline"
|
||||
/>
|
||||
),
|
||||
@@ -128,6 +130,7 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
|
||||
enableMarketplace,
|
||||
searchText,
|
||||
pluginDetailMap,
|
||||
onOpenMarketplace,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -165,6 +168,7 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
|
||||
<InstallFromMarketplace
|
||||
providers={providers}
|
||||
searchText={searchText}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -33,6 +33,7 @@ type ModelSelectorProps = {
|
||||
showDeprecatedWarnIcon?: boolean
|
||||
hideProviderSettingsFooter?: boolean
|
||||
onConfigureEmptyState?: () => void
|
||||
onOpenMarketplace?: () => void
|
||||
providerSettingsSource?: 'agent'
|
||||
showModelMeta?: boolean
|
||||
}
|
||||
@@ -49,6 +50,7 @@ function ModelSelector({
|
||||
showDeprecatedWarnIcon = true,
|
||||
hideProviderSettingsFooter,
|
||||
onConfigureEmptyState,
|
||||
onOpenMarketplace,
|
||||
providerSettingsSource,
|
||||
showModelMeta,
|
||||
}: ModelSelectorProps) {
|
||||
@@ -168,6 +170,7 @@ function ModelSelector({
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
providerSettingsSource={providerSettingsSource}
|
||||
onConfigureEmptyState={onConfigureEmptyState ? handleConfigureEmptyState : undefined}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onInputValueChange={setInputValue}
|
||||
onHide={handleHide}
|
||||
/>
|
||||
|
||||
+28
-11
@@ -15,6 +15,7 @@ type MarketplaceSectionProps = {
|
||||
theme?: string
|
||||
onMarketplaceCollapsedChange: (collapsed: boolean) => void
|
||||
onInstallPlugin: (key: ModelProviderQuotaGetPaid) => void | Promise<void>
|
||||
onOpenMarketplace?: () => void
|
||||
}
|
||||
|
||||
function MarketplaceSection({
|
||||
@@ -26,6 +27,7 @@ function MarketplaceSection({
|
||||
theme,
|
||||
onMarketplaceCollapsedChange,
|
||||
onInstallPlugin,
|
||||
onOpenMarketplace,
|
||||
}: MarketplaceSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -82,17 +84,32 @@ function MarketplaceSection({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<a
|
||||
className="flex cursor-pointer items-center gap-0.5 px-3 py-1.5"
|
||||
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="flex-1 system-xs-regular text-text-accent">
|
||||
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
|
||||
</span>
|
||||
<span className="i-ri-arrow-right-up-line size-3! text-text-accent" />
|
||||
</a>
|
||||
{onOpenMarketplace
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center gap-0.5 border-0 bg-transparent px-3 py-1.5 text-left"
|
||||
onClick={onOpenMarketplace}
|
||||
>
|
||||
<span className="flex-1 system-xs-regular text-text-accent">
|
||||
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
|
||||
</span>
|
||||
<span className="i-ri-arrow-right-up-line size-3! text-text-accent" aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<a
|
||||
className="flex cursor-pointer items-center gap-0.5 px-3 py-1.5"
|
||||
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="flex-1 system-xs-regular text-text-accent">
|
||||
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
|
||||
</span>
|
||||
<span className="i-ri-arrow-right-up-line size-3! text-text-accent" aria-hidden="true" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,7 @@ export type PopupProps = {
|
||||
providerSettingsSource?: 'agent'
|
||||
onConfigureEmptyState?: () => void
|
||||
onInputValueChange: (value: string) => void
|
||||
onOpenMarketplace?: () => void
|
||||
onHide: () => void
|
||||
}
|
||||
function Popup({
|
||||
@@ -53,6 +54,7 @@ function Popup({
|
||||
providerSettingsSource,
|
||||
onConfigureEmptyState,
|
||||
onInputValueChange,
|
||||
onOpenMarketplace,
|
||||
onHide,
|
||||
}: PopupProps) {
|
||||
const { t } = useTranslation()
|
||||
@@ -236,6 +238,7 @@ function Popup({
|
||||
theme={theme}
|
||||
onMarketplaceCollapsedChange={setMarketplaceCollapsed}
|
||||
onInstallPlugin={handleInstallPlugin}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+7
@@ -38,6 +38,7 @@ type SystemModelSelectorProps = {
|
||||
notConfigured: boolean
|
||||
isLoading?: boolean
|
||||
hideProviderSettingsFooter?: boolean
|
||||
onOpenMarketplace?: () => void
|
||||
}
|
||||
|
||||
type SystemModelLabelKey
|
||||
@@ -64,6 +65,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
notConfigured,
|
||||
isLoading,
|
||||
hideProviderSettingsFooter,
|
||||
onOpenMarketplace,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { workspacePermissionKeys } = useAppContext()
|
||||
@@ -189,6 +191,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
defaultModel={currentTextGenerationDefaultModel}
|
||||
modelList={textGenerationModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.textGeneration, model)}
|
||||
@@ -202,6 +205,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
defaultModel={currentEmbeddingsDefaultModel}
|
||||
modelList={embeddingModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.textEmbedding, model)}
|
||||
@@ -215,6 +219,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
defaultModel={currentRerankDefaultModel}
|
||||
modelList={rerankModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.rerank, model)}
|
||||
@@ -228,6 +233,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
defaultModel={currentSpeech2textDefaultModel}
|
||||
modelList={speech2textModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.speech2text, model)}
|
||||
@@ -241,6 +247,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
defaultModel={currentTTSDefaultModel}
|
||||
modelList={ttsModelList}
|
||||
hideProviderSettingsFooter={hideProviderSettingsFooter}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
onConfigureEmptyState={() => setOpen(false)}
|
||||
showModelMeta={false}
|
||||
onSelect={model => handleChangeDefaultModel(ModelTypeEnum.tts, model)}
|
||||
|
||||
@@ -140,14 +140,23 @@ vi.mock('@/app/components/plugins/plugin-page/plugin-tasks', () => ({
|
||||
default: () => <button type="button" aria-label="plugin tasks">tasks</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace-query', () => ({
|
||||
__esModule: true,
|
||||
default: ({ installContextCategory }: { installContextCategory?: string }) => (
|
||||
<div data-testid="install-from-marketplace-query" data-install-context-category={installContextCategory} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
layout,
|
||||
onOpenMarketplace,
|
||||
onSearchTextChange,
|
||||
searchText,
|
||||
}: {
|
||||
layout?: (parts: { body: React.ReactNode, toolbar: React.ReactNode }) => React.ReactNode
|
||||
onOpenMarketplace?: () => void
|
||||
onSearchTextChange?: (value: string) => void
|
||||
searchText: string
|
||||
}) => {
|
||||
@@ -160,7 +169,11 @@ vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
const body = <div data-testid="model-provider-page" />
|
||||
const body = (
|
||||
<div data-testid="model-provider-page">
|
||||
<button type="button" aria-label="model provider marketplace" onClick={onOpenMarketplace}>marketplace</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (layout)
|
||||
return layout({ body, toolbar })
|
||||
@@ -179,9 +192,13 @@ vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/data-source-page-new', () => ({
|
||||
__esModule: true,
|
||||
default: ({ layout }: { layout?: (parts: { body: React.ReactNode, toolbar: React.ReactNode }) => React.ReactNode }) => {
|
||||
default: ({ layout, onOpenMarketplace }: { layout?: (parts: { body: React.ReactNode, toolbar: React.ReactNode }) => React.ReactNode, onOpenMarketplace?: () => void }) => {
|
||||
const toolbar = <div data-testid="data-source-toolbar" />
|
||||
const body = <div data-testid="data-source-page" />
|
||||
const body = (
|
||||
<div data-testid="data-source-page">
|
||||
<button type="button" aria-label="data source marketplace" onClick={onOpenMarketplace}>marketplace</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (layout)
|
||||
return layout({ body, toolbar })
|
||||
@@ -292,6 +309,7 @@ describe('IntegrationsPage', () => {
|
||||
renderIntegrationsPage({ section: 'provider' })
|
||||
|
||||
expect(screen.getByTestId('model-provider-page')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('install-from-marketplace-query')).toHaveAttribute('data-install-context-category', 'model')
|
||||
expect(screen.getByTestId('model-provider-toolbar').closest('[class*="max-w-[1600px]"]')).toHaveClass('px-6', 'pt-3', 'pb-2')
|
||||
expect(within(screen.getByTestId('model-provider-toolbar').closest('section')!).getByText('common.settings.provider')).toHaveClass('title-2xl-semi-bold')
|
||||
expect(screen.getByTestId('model-provider-page').parentElement).toHaveClass('max-w-[1600px]', 'px-6')
|
||||
@@ -353,13 +371,17 @@ describe('IntegrationsPage', () => {
|
||||
expect(screen.getByRole('link', { name: 'plugin.categorySingle.extension' })).toHaveAttribute('href', '/integrations/extension')
|
||||
})
|
||||
|
||||
it('opens the integrations marketplace path from plugin category empty states', () => {
|
||||
renderIntegrationsPage({ section: 'extension' })
|
||||
it.each([
|
||||
['provider', 'model provider marketplace', '/plugins/model'],
|
||||
['data-source', 'data source marketplace', '/plugins/datasource'],
|
||||
['extension', 'empty marketplace', '/plugins/extension'],
|
||||
] as const)('opens the %s marketplace path from integrations', (section, buttonName, marketplacePath) => {
|
||||
renderIntegrationsPage({ section })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'empty marketplace' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: buttonName }))
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/plugins/extension?source='),
|
||||
expect.stringContaining(`${marketplacePath}?source=`),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
@@ -380,6 +402,7 @@ describe('IntegrationsPage', () => {
|
||||
const { unmount } = renderIntegrationsPage({ section: 'data-source' })
|
||||
|
||||
expect(screen.getByTestId('data-source-page')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('install-from-marketplace-query')).toHaveAttribute('data-install-context-category', 'datasource')
|
||||
expect(screen.getByRole('button', { name: 'plugin debug' })).toHaveTextContent('plugin.debugInfo.title')
|
||||
|
||||
unmount()
|
||||
@@ -626,13 +649,13 @@ describe('IntegrationsPage', () => {
|
||||
expect(screen.getAllByText('common.settings.customEndpoint')).toHaveLength(2)
|
||||
expect(screen.getByText('common.apiBasedExtensionPage.description')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('api-extension-toolbar')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/self-host/use-dify/workspace/api-extension/api-extension')
|
||||
expect(screen.getByRole('link', { name: /common\.modelProvider\.learnMore/i })).toHaveAttribute('href', 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/endpoint')
|
||||
expect(screen.queryByText('common.toolsPage.description')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['trigger', 'plugin.categorySingle.trigger', 'common.triggerPage.description', 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/trigger-plugin'],
|
||||
['extension', 'plugin.categorySingle.extension', 'common.extensionPage.description', 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/endpoint'],
|
||||
['extension', 'plugin.categorySingle.extension', 'common.extensionPage.description', 'https://docs.dify.ai/en/self-host/use-dify/workspace/api-extension/api-extension'],
|
||||
['agent-strategy', 'plugin.categorySingle.agent', 'common.agentStrategyPage.description', 'https://docs.dify.ai/en/develop-plugin/dev-guides-and-walkthroughs/agent-strategy-plugin'],
|
||||
] as const)('renders the %s header with a docs link', (section, title, description, href) => {
|
||||
renderIntegrationsPage({ section })
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import PluginCategoryPage from '../plugin-category-page'
|
||||
|
||||
const {
|
||||
mockContainerRef,
|
||||
mockFetchManifestFromMarketPlace,
|
||||
mockSetInstallState,
|
||||
mockUseUploader,
|
||||
mockUsePluginInstallation,
|
||||
mockPluginInstallationPermission,
|
||||
} = vi.hoisted(() => ({
|
||||
mockContainerRef: { current: null },
|
||||
mockFetchManifestFromMarketPlace: vi.fn(),
|
||||
mockSetInstallState: vi.fn(),
|
||||
mockUseUploader: vi.fn((_: unknown) => ({
|
||||
dragging: false,
|
||||
fileUploader: { current: null },
|
||||
fileChangeHandle: undefined,
|
||||
removeFile: undefined,
|
||||
})),
|
||||
mockUsePluginInstallation: vi.fn(),
|
||||
mockPluginInstallationPermission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
@@ -56,6 +62,34 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', ()
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
|
||||
default: ({
|
||||
installContextCategory,
|
||||
onClose,
|
||||
uniqueIdentifier,
|
||||
}: {
|
||||
installContextCategory?: PluginCategoryEnum
|
||||
onClose: () => void
|
||||
uniqueIdentifier: string
|
||||
}) => (
|
||||
<div
|
||||
data-testid="install-from-marketplace"
|
||||
data-install-context-category={installContextCategory}
|
||||
data-unique-identifier={uniqueIdentifier}
|
||||
>
|
||||
<button type="button" onClick={onClose}>close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-query-params', () => ({
|
||||
usePluginInstallation: () => mockUsePluginInstallation(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
fetchManifestFromMarketPlace: (...args: unknown[]) => mockFetchManifestFromMarketPlace(...args),
|
||||
}))
|
||||
|
||||
type UploaderOptions = {
|
||||
onFileChange: (file: File | null) => void
|
||||
}
|
||||
@@ -64,6 +98,7 @@ describe('PluginCategoryPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPluginInstallationPermission.restrict_to_marketplace_only = false
|
||||
mockUsePluginInstallation.mockReturnValue([{ packageId: null, bundleInfo: null }, mockSetInstallState])
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -112,6 +147,33 @@ describe('PluginCategoryPage', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps marketplace install params while install permission is loading', async () => {
|
||||
const packageId = 'junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2'
|
||||
mockUsePluginInstallation.mockReturnValue([{ packageId, bundleInfo: null }, mockSetInstallState])
|
||||
|
||||
render(<PluginCategoryPage canInstall={false} isInstallPermissionLoading category={PluginCategoryEnum.agent} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseUploader).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockFetchManifestFromMarketPlace).not.toHaveBeenCalled()
|
||||
expect(mockSetInstallState).not.toHaveBeenCalled()
|
||||
expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears marketplace install params when install permission is unavailable after loading', async () => {
|
||||
const packageId = 'junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2'
|
||||
mockUsePluginInstallation.mockReturnValue([{ packageId, bundleInfo: null }, mockSetInstallState])
|
||||
|
||||
render(<PluginCategoryPage canInstall={false} category={PluginCategoryEnum.agent} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetInstallState).toHaveBeenCalledWith(null)
|
||||
})
|
||||
expect(mockFetchManifestFromMarketPlace).not.toHaveBeenCalled()
|
||||
expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the local package installer for supported dropped files', () => {
|
||||
render(<PluginCategoryPage category={PluginCategoryEnum.tool} />)
|
||||
|
||||
@@ -124,6 +186,33 @@ describe('PluginCategoryPage', () => {
|
||||
expect(screen.getByTestId('install-from-local-package')).toHaveAttribute('data-install-context-category', PluginCategoryEnum.tool)
|
||||
})
|
||||
|
||||
it('opens the marketplace installer from package id query params', async () => {
|
||||
const packageId = 'langgenius/telegram_trigger:0.0.6@923a18de89d8cdb7f419d0dff60bf08a8b81b65fef6bf606cf0ce4b0ee56a9ca'
|
||||
mockUsePluginInstallation.mockReturnValue([{ packageId, bundleInfo: null }, mockSetInstallState])
|
||||
mockFetchManifestFromMarketPlace.mockResolvedValue({
|
||||
data: {
|
||||
plugin: {
|
||||
org: 'langgenius',
|
||||
name: 'telegram_trigger',
|
||||
category: PluginCategoryEnum.trigger,
|
||||
},
|
||||
version: { version: '0.0.6' },
|
||||
},
|
||||
})
|
||||
|
||||
render(<PluginCategoryPage category={PluginCategoryEnum.trigger} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchManifestFromMarketPlace).toHaveBeenCalledWith(encodeURIComponent(packageId))
|
||||
expect(screen.getByTestId('install-from-marketplace')).toHaveAttribute('data-unique-identifier', packageId)
|
||||
})
|
||||
expect(screen.getByTestId('install-from-marketplace')).toHaveAttribute('data-install-context-category', PluginCategoryEnum.trigger)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close' }))
|
||||
|
||||
expect(mockSetInstallState).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('ignores dropped files when install permission is unavailable', () => {
|
||||
render(<PluginCategoryPage canInstall={false} category={PluginCategoryEnum.agent} />)
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('integration routes', () => {
|
||||
[undefined, { type: 'redirect', destination: '/integrations/model-provider' }],
|
||||
[[], { type: 'redirect', destination: '/integrations/model-provider' }],
|
||||
[['model-provider'], { type: 'section', section: 'provider' }],
|
||||
[['model-provider', 'plugins'], { type: 'redirect', destination: '/integrations/model-provider' }],
|
||||
[['tools'], { type: 'redirect', destination: '/integrations/tools/built-in' }],
|
||||
[['tools', 'built-in'], { type: 'section', section: 'builtin' }],
|
||||
[['tool', 'api'], { type: 'redirect', destination: '/integrations/tools/api' }],
|
||||
@@ -57,6 +58,10 @@ describe('integration routes', () => {
|
||||
[['trigger'], { type: 'section', section: 'trigger' }],
|
||||
[['agent-strategy'], { type: 'section', section: 'agent-strategy' }],
|
||||
[['extension'], { type: 'section', section: 'extension' }],
|
||||
[['agent-strategy', 'plugins'], { type: 'redirect', destination: '/integrations/agent-strategy' }],
|
||||
[['trigger', 'plugins'], { type: 'redirect', destination: '/integrations/trigger' }],
|
||||
[['tools', 'built-in', 'plugins'], { type: 'redirect', destination: '/integrations/tools/built-in' }],
|
||||
[['data-source', 'plugins'], { type: 'redirect', destination: '/integrations/data-source' }],
|
||||
[['model-providers'], { type: 'not-found' }],
|
||||
[['data-sources'], { type: 'not-found' }],
|
||||
[['api-extensions'], { type: 'not-found' }],
|
||||
@@ -81,6 +86,30 @@ describe('integration routes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves marketplace install query params when redirecting nested marketplace callbacks', () => {
|
||||
expect(getIntegrationRouteTargetBySlug(['agent-strategy', 'plugins'], {
|
||||
'package-ids': '["junjiem/mcp_see_agent"]',
|
||||
})).toEqual({
|
||||
type: 'redirect',
|
||||
destination: '/integrations/agent-strategy?package-ids=%5B%22junjiem%2Fmcp_see_agent%22%5D',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves marketplace install query params when redirecting model and data source callbacks', () => {
|
||||
expect(getIntegrationRouteTargetBySlug(['model-provider', 'plugins'], {
|
||||
'package-ids': '["langgenius/openai"]',
|
||||
})).toEqual({
|
||||
type: 'redirect',
|
||||
destination: '/integrations/model-provider?package-ids=%5B%22langgenius%2Fopenai%22%5D',
|
||||
})
|
||||
expect(getIntegrationRouteTargetBySlug(['data-source', 'plugins'], {
|
||||
'package-ids': '["langgenius/notion_datasource"]',
|
||||
})).toEqual({
|
||||
type: 'redirect',
|
||||
destination: '/integrations/data-source?package-ids=%5B%22langgenius%2Fnotion_datasource%22%5D',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{}, '/integrations/tools/built-in'],
|
||||
[{ section: 'provider' }, '/integrations/model-provider'],
|
||||
|
||||
@@ -43,9 +43,9 @@ const headerDescriptionDocPaths: Partial<Record<IntegrationSection, string>> = {
|
||||
'custom-tool': '/use-dify/workspace/tools#custom-tool',
|
||||
'workflow-tool': '/use-dify/workspace/tools#workflow-tool',
|
||||
'mcp': '/use-dify/build/mcp',
|
||||
'custom-endpoint': '/use-dify/workspace/api-extension/api-extension',
|
||||
'custom-endpoint': '/develop-plugin/dev-guides-and-walkthroughs/endpoint',
|
||||
'trigger': '/develop-plugin/dev-guides-and-walkthroughs/trigger-plugin',
|
||||
'extension': '/develop-plugin/dev-guides-and-walkthroughs/endpoint',
|
||||
'extension': '/use-dify/workspace/api-extension/api-extension',
|
||||
'agent-strategy': '/develop-plugin/dev-guides-and-walkthroughs/agent-strategy-plugin',
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ export default function IntegrationsPage({
|
||||
canUpdatePlugin,
|
||||
handlePermissionChange,
|
||||
isPluginCategory,
|
||||
isReferenceSettingLoading,
|
||||
permission,
|
||||
showPermissionQuickPanel,
|
||||
showPluginCategorySetting,
|
||||
@@ -285,6 +286,7 @@ export default function IntegrationsPage({
|
||||
onSwitchToMarketplace={handleSwitchToMarketplace}
|
||||
canInstallPlugin={canInstallPlugin}
|
||||
canDeletePlugin={canDeletePlugin}
|
||||
isInstallPermissionLoading={isReferenceSettingLoading}
|
||||
canUpdatePlugin={canUpdatePlugin}
|
||||
pluginCategoryToolbarAction={pluginSettingAction}
|
||||
/>
|
||||
@@ -310,6 +312,7 @@ export default function IntegrationsPage({
|
||||
onSwitchToMarketplace={handleSwitchToMarketplace}
|
||||
canInstallPlugin={canInstallPlugin}
|
||||
canDeletePlugin={canDeletePlugin}
|
||||
isInstallPermissionLoading={isReferenceSettingLoading}
|
||||
canUpdatePlugin={canUpdatePlugin}
|
||||
pluginCategoryToolbarAction={pluginSettingAction}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useMemo, useState } from 'react'
|
||||
import InstallFromLocalPackage from '@/app/components/plugins/install-plugin/install-from-local-package'
|
||||
import InstallFromMarketplaceQuery from '@/app/components/plugins/install-plugin/install-from-marketplace-query'
|
||||
import { usePluginPageContext } from '@/app/components/plugins/plugin-page/context'
|
||||
import { PluginPageContextProvider } from '@/app/components/plugins/plugin-page/context-provider'
|
||||
import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel'
|
||||
@@ -16,6 +17,7 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
type PluginCategoryPageProps = {
|
||||
canInstall?: boolean
|
||||
canDeletePlugin?: boolean
|
||||
isInstallPermissionLoading?: boolean
|
||||
canUpdatePlugin?: boolean
|
||||
category: PluginCategoryEnum
|
||||
layout?: (parts: { body: ReactNode, toolbar: ReactNode }) => ReactNode
|
||||
@@ -28,6 +30,7 @@ const supportedLocalPackageExtensions = SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS.sp
|
||||
const PluginCategoryPageContent = ({
|
||||
canInstall = true,
|
||||
canDeletePlugin = true,
|
||||
isInstallPermissionLoading = false,
|
||||
canUpdatePlugin = true,
|
||||
category,
|
||||
layout,
|
||||
@@ -93,6 +96,11 @@ const PluginCategoryPageContent = ({
|
||||
onSuccess={noop}
|
||||
/>
|
||||
)}
|
||||
<InstallFromMarketplaceQuery
|
||||
canInstallPlugin={canInstall}
|
||||
isPermissionLoading={isInstallPermissionLoading}
|
||||
installContextCategory={category}
|
||||
/>
|
||||
<input
|
||||
ref={fileUploader}
|
||||
className="hidden"
|
||||
@@ -108,6 +116,7 @@ const PluginCategoryPageContent = ({
|
||||
const PluginCategoryPage = ({
|
||||
canInstall = true,
|
||||
canDeletePlugin = true,
|
||||
isInstallPermissionLoading = false,
|
||||
canUpdatePlugin = true,
|
||||
category,
|
||||
layout,
|
||||
@@ -125,6 +134,7 @@ const PluginCategoryPage = ({
|
||||
<PluginCategoryPageContent
|
||||
canInstall={canInstall}
|
||||
canDeletePlugin={canDeletePlugin}
|
||||
isInstallPermissionLoading={isInstallPermissionLoading}
|
||||
canUpdatePlugin={canUpdatePlugin}
|
||||
category={category}
|
||||
layout={layout}
|
||||
|
||||
@@ -136,8 +136,29 @@ type IntegrationRouteTarget
|
||||
| { type: 'section', section: IntegrationSection }
|
||||
| { type: 'not-found' }
|
||||
|
||||
const getSectionByCanonicalPath = (path: string) => {
|
||||
const entry = Object.entries(integrationPathBySection).find(([, sectionPath]) => {
|
||||
return sectionPath.replace('/integrations/', '') === path
|
||||
})
|
||||
|
||||
return entry?.[0] as IntegrationSection | undefined
|
||||
}
|
||||
|
||||
export const getIntegrationRouteTargetBySlug = (slug?: string[], searchParams?: IntegrationRouteSearchParams): IntegrationRouteTarget => {
|
||||
const path = slug?.join('/') ?? ''
|
||||
const nestedMarketplaceCallbackPath = path.endsWith('/plugins')
|
||||
? path.slice(0, -'/plugins'.length)
|
||||
: undefined
|
||||
const nestedMarketplaceCallbackSection = nestedMarketplaceCallbackPath
|
||||
? getSectionByCanonicalPath(nestedMarketplaceCallbackPath)
|
||||
: undefined
|
||||
|
||||
if (nestedMarketplaceCallbackSection) {
|
||||
return {
|
||||
type: 'redirect',
|
||||
destination: appendSearchParams(buildIntegrationPath(nestedMarketplaceCallbackSection), searchParams),
|
||||
}
|
||||
}
|
||||
|
||||
switch (path) {
|
||||
case '':
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { IntegrationSection } from './routes'
|
||||
import { ApiBasedExtensionPage } from '@/app/components/header/account-setting/api-based-extension-page'
|
||||
import DataSourcePage from '@/app/components/header/account-setting/data-source-page-new'
|
||||
import ModelProviderPage from '@/app/components/header/account-setting/model-provider-page'
|
||||
import InstallFromMarketplaceQuery from '@/app/components/plugins/install-plugin/install-from-marketplace-query'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { toolsContentFrameClassNames, toolsContentInsetClassNames } from '@/app/components/tools/content-inset'
|
||||
import { IntegrationPageHeader } from './page-header'
|
||||
@@ -15,6 +16,7 @@ import ToolProviderList from './tool-provider-list'
|
||||
type IntegrationSectionRendererProps = {
|
||||
canInstallPlugin?: boolean
|
||||
canDeletePlugin?: boolean
|
||||
isInstallPermissionLoading?: boolean
|
||||
canUpdatePlugin?: boolean
|
||||
description?: ReactNode
|
||||
onProviderSearchTextChange: (value: string) => void
|
||||
@@ -29,6 +31,7 @@ type IntegrationSectionRendererProps = {
|
||||
const IntegrationSectionRenderer = ({
|
||||
canInstallPlugin = true,
|
||||
canDeletePlugin = true,
|
||||
isInstallPermissionLoading = false,
|
||||
canUpdatePlugin = true,
|
||||
description,
|
||||
onProviderSearchTextChange,
|
||||
@@ -74,6 +77,7 @@ const IntegrationSectionRenderer = ({
|
||||
<PluginCategoryPage
|
||||
canInstall={canInstallPlugin}
|
||||
canDeletePlugin={canDeletePlugin}
|
||||
isInstallPermissionLoading={isInstallPermissionLoading}
|
||||
canUpdatePlugin={canUpdatePlugin}
|
||||
category={category}
|
||||
layout={renderDirectLayout}
|
||||
@@ -81,17 +85,28 @@ const IntegrationSectionRenderer = ({
|
||||
toolbarAction={pluginCategoryToolbarAction}
|
||||
/>
|
||||
)
|
||||
const renderMarketplaceInstallQuery = (category: PluginCategoryEnum) => (
|
||||
<InstallFromMarketplaceQuery
|
||||
canInstallPlugin={canInstallPlugin}
|
||||
isPermissionLoading={isInstallPermissionLoading}
|
||||
installContextCategory={category}
|
||||
/>
|
||||
)
|
||||
|
||||
switch (section) {
|
||||
case 'provider':
|
||||
return (
|
||||
<ModelProviderPage
|
||||
hideSystemModelSelectorProviderSettingsFooter
|
||||
layout={renderScrollableLayout}
|
||||
searchText={providerSearchText}
|
||||
stickyToolbar
|
||||
onSearchTextChange={onProviderSearchTextChange}
|
||||
/>
|
||||
<>
|
||||
<ModelProviderPage
|
||||
hideSystemModelSelectorProviderSettingsFooter
|
||||
layout={renderScrollableLayout}
|
||||
onOpenMarketplace={onSwitchToMarketplace}
|
||||
searchText={providerSearchText}
|
||||
stickyToolbar
|
||||
onSearchTextChange={onProviderSearchTextChange}
|
||||
/>
|
||||
{renderMarketplaceInstallQuery(PluginCategoryEnum.model)}
|
||||
</>
|
||||
)
|
||||
case 'builtin':
|
||||
return renderPluginCategoryPage(PluginCategoryEnum.tool)
|
||||
@@ -103,7 +118,10 @@ const IntegrationSectionRenderer = ({
|
||||
return <ToolProviderList category="workflow" contentInset="compact" layout={renderDirectLayout} />
|
||||
case 'data-source':
|
||||
return (
|
||||
<DataSourcePage stickyToolbar layout={renderScrollableLayout} />
|
||||
<>
|
||||
<DataSourcePage stickyToolbar layout={renderScrollableLayout} onOpenMarketplace={onSwitchToMarketplace} />
|
||||
{renderMarketplaceInstallQuery(PluginCategoryEnum.datasource)}
|
||||
</>
|
||||
)
|
||||
case 'custom-endpoint':
|
||||
return <ApiBasedExtensionPage layout={renderScrollableLayout} />
|
||||
|
||||
@@ -422,7 +422,11 @@ describe('MainNav', () => {
|
||||
it('aligns the global navigation spacing with the main sidebar design', () => {
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
|
||||
renderMainNav()
|
||||
const { container } = renderMainNav()
|
||||
|
||||
const mainNav = container.querySelector('aside')
|
||||
expect(mainNav).toHaveClass('w-62', 'p-1')
|
||||
expect(mainNav?.firstElementChild).toHaveClass('w-60')
|
||||
|
||||
const logoLink = screen.getByLabelText('Dify')
|
||||
expect(logoLink).not.toHaveClass('px-2')
|
||||
@@ -436,6 +440,8 @@ describe('MainNav', () => {
|
||||
expect(webAppsButton.parentElement).toHaveClass('py-1', 'pr-2', 'pl-2')
|
||||
|
||||
const helpButton = screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })
|
||||
expect(helpButton.parentElement?.parentElement).toHaveClass('w-60')
|
||||
expect(helpButton.parentElement?.parentElement).not.toHaveClass('w-full')
|
||||
expect(helpButton.parentElement).toHaveClass('shrink-0', 'rounded-full', 'p-1')
|
||||
})
|
||||
|
||||
@@ -1078,7 +1084,8 @@ describe('MainNav', () => {
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Alpha App')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Beta Tool' })).toHaveAttribute('href', '/installed/installed-2')
|
||||
expect(screen.getByText('Beta Tool')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'common.mainNav.webApps.openApp:{"name":"Beta Tool"}' })).toHaveAttribute('href', '/installed/installed-2')
|
||||
})
|
||||
|
||||
it('renders web app skeleton rows while installed apps are loading', () => {
|
||||
|
||||
@@ -148,8 +148,8 @@ const WebAppsSectionContent = () => {
|
||||
<AppNavItem
|
||||
key={id}
|
||||
variant="mainNav"
|
||||
isMobile={false}
|
||||
name={app.name}
|
||||
ariaLabel={t('mainNav.webApps.openApp', { ns: 'common', name: app.name })}
|
||||
icon_type={app.icon_type}
|
||||
icon={app.icon}
|
||||
icon_background={app.icon_background}
|
||||
|
||||
@@ -225,7 +225,7 @@ const MainNav = ({
|
||||
: 'w-16 bg-background-body p-1'
|
||||
: showSnippetDetailBottomNavigation
|
||||
? 'w-16 bg-background-body p-1'
|
||||
: 'w-60 flex-col',
|
||||
: 'w-62 flex-col p-1',
|
||||
'bg-background-body',
|
||||
className,
|
||||
)}
|
||||
@@ -233,6 +233,7 @@ const MainNav = ({
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col',
|
||||
!showDetailNavigation && !showSnippetDetailBottomNavigation && 'w-60 overflow-hidden',
|
||||
showDetailNavigation && (
|
||||
isDetailNavigationHoverPreviewOpen
|
||||
? 'absolute top-1 bottom-1 left-1 z-40 w-60 overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg shadow-lg'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getLegacyPluginRedirectPath } from '../plugin-routes'
|
||||
import { getFirstPackageIdFromSearchParams, getInstallRedirectPathByPluginCategory, getInstallRedirectPathFromSearchParams, getLegacyPluginRedirectPath, shouldResolveInstallCategoryRedirect } from '../plugin-routes'
|
||||
|
||||
describe('plugin routes', () => {
|
||||
it.each([
|
||||
@@ -13,10 +13,91 @@ describe('plugin routes', () => {
|
||||
[{ tab: 'trigger' }, '/integrations/trigger'],
|
||||
[{ tab: 'agent-strategy' }, '/integrations/agent-strategy'],
|
||||
[{ tab: 'extension' }, '/integrations/extension'],
|
||||
[
|
||||
{ 'tab': 'trigger', 'package-ids': '["langgenius/telegram_trigger"]', 'source': 'https://marketplace.dify.ai' },
|
||||
'/integrations/trigger?package-ids=%5B%22langgenius%2Ftelegram_trigger%22%5D',
|
||||
],
|
||||
])('redirects legacy plugin category URLs for search params %j', (searchParams, expected) => {
|
||||
expect(getLegacyPluginRedirectPath(searchParams)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ 'package-ids': '["langgenius/telegram_trigger"]' },
|
||||
{ 'tab': 'plugins', 'package-ids': '["langgenius/telegram_trigger"]' },
|
||||
{ 'bundle-info': '{"org":"langgenius","name":"bundle","version":"1.0.0"}' },
|
||||
])('keeps install deep links on the legacy plugin page for search params %j', (searchParams) => {
|
||||
expect(getLegacyPluginRedirectPath(searchParams)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses the first package id from marketplace install search params', () => {
|
||||
expect(getFirstPackageIdFromSearchParams({
|
||||
'package-ids': '["junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2"]',
|
||||
})).toBe('junjiem/mcp_see_agent:0.2.4@82caf96890992e9dec2c43c3fac82bfce8bd18a41de7c2b6948151b2d7f7b7a2')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ 'package-ids': '["junjiem/mcp_see_agent"]' }, true],
|
||||
[{ 'tab': 'plugins', 'package-ids': '["junjiem/mcp_see_agent"]' }, true],
|
||||
[{ 'tab': 'trigger', 'package-ids': '["langgenius/telegram_trigger"]' }, false],
|
||||
[{ 'bundle-info': '{"org":"langgenius","name":"bundle","version":"1.0.0"}' }, false],
|
||||
])('detects package install deep links that need category resolution for search params %j', (searchParams, expected) => {
|
||||
expect(shouldResolveInstallCategoryRedirect(searchParams)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'model',
|
||||
{ 'package-ids': '["langgenius/openai"]' },
|
||||
'/integrations/model-provider?package-ids=%5B%22langgenius%2Fopenai%22%5D',
|
||||
],
|
||||
[
|
||||
'agent-strategy',
|
||||
{ 'package-ids': '["junjiem/mcp_see_agent"]' },
|
||||
'/integrations/agent-strategy?package-ids=%5B%22junjiem%2Fmcp_see_agent%22%5D',
|
||||
],
|
||||
[
|
||||
'trigger',
|
||||
{ 'tab': 'plugins', 'package-ids': '["langgenius/telegram_trigger"]', 'source': 'https://marketplace.dify.ai' },
|
||||
'/integrations/trigger?package-ids=%5B%22langgenius%2Ftelegram_trigger%22%5D',
|
||||
],
|
||||
[
|
||||
'datasource',
|
||||
{ 'package-ids': '["langgenius/notion_datasource"]' },
|
||||
'/integrations/data-source?package-ids=%5B%22langgenius%2Fnotion_datasource%22%5D',
|
||||
],
|
||||
])('builds install redirect paths from marketplace plugin category %s', (category, searchParams, expected) => {
|
||||
expect(getInstallRedirectPathByPluginCategory(category, searchParams)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
{ 'category': 'agent-strategy', 'package-ids': '["junjiem/mcp_see_agent"]', 'source': 'https://marketplace.dify.ai' },
|
||||
'/integrations/agent-strategy?package-ids=%5B%22junjiem%2Fmcp_see_agent%22%5D',
|
||||
],
|
||||
[
|
||||
{ 'tab': 'trigger', 'package-ids': '["langgenius/telegram_trigger"]', 'source': 'https://marketplace.dify.ai' },
|
||||
'/integrations/trigger?package-ids=%5B%22langgenius%2Ftelegram_trigger%22%5D',
|
||||
],
|
||||
[
|
||||
{ 'category': 'model', 'package-ids': '["langgenius/openai"]', 'source': 'https://marketplace.dify.ai' },
|
||||
'/integrations/model-provider?package-ids=%5B%22langgenius%2Fopenai%22%5D',
|
||||
],
|
||||
[
|
||||
{ 'category': 'datasource', 'package-ids': '["langgenius/notion_datasource"]', 'source': 'https://marketplace.dify.ai' },
|
||||
'/integrations/data-source?package-ids=%5B%22langgenius%2Fnotion_datasource%22%5D',
|
||||
],
|
||||
[
|
||||
{ 'category': 'bundle', 'package-ids': '["langgenius/bundle"]' },
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
{ category: 'agent-strategy' },
|
||||
undefined,
|
||||
],
|
||||
])('builds install redirect paths directly from install search params %j', (searchParams, expected) => {
|
||||
expect(getInstallRedirectPathFromSearchParams(searchParams)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ tab: 'discover' }, '/marketplace'],
|
||||
[{ tab: 'discover', category: 'extension' }, '/marketplace?category=extension'],
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import type { Dependency, PluginDeclaration, PluginManifestInMarket } from '../../types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
import { usePluginInstallation } from '@/hooks/use-query-params'
|
||||
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
|
||||
|
||||
type MarketplaceInstall = {
|
||||
manifest: PluginDeclaration | PluginManifestInMarket
|
||||
uniqueIdentifier: string
|
||||
}
|
||||
|
||||
export type UseInstallFromMarketplaceQueryOptions = {
|
||||
canInstallPlugin: boolean
|
||||
isPermissionLoading?: boolean
|
||||
onPackageCategoryResolved?: (category: string | undefined, packageId: string) => boolean
|
||||
}
|
||||
|
||||
export const useInstallFromMarketplaceQuery = ({
|
||||
canInstallPlugin,
|
||||
isPermissionLoading = false,
|
||||
onPackageCategoryResolved,
|
||||
}: UseInstallFromMarketplaceQueryOptions) => {
|
||||
const [{ packageId, bundleInfo }, setInstallState] = usePluginInstallation()
|
||||
const [marketplaceInstall, setMarketplaceInstall] = useState<MarketplaceInstall | null>(null)
|
||||
const [dependencies, setDependencies] = useState<Dependency[]>([])
|
||||
const [isShowInstallFromMarketplace, setIsShowInstallFromMarketplace] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!packageId && !bundleInfo)
|
||||
return
|
||||
|
||||
if (isPermissionLoading)
|
||||
return
|
||||
|
||||
if (!canInstallPlugin) {
|
||||
setInstallState(null)
|
||||
return
|
||||
}
|
||||
|
||||
let ignore = false
|
||||
|
||||
const loadMarketplaceInstall = async () => {
|
||||
if (packageId) {
|
||||
try {
|
||||
const { data } = await fetchManifestFromMarketPlace(encodeURIComponent(packageId))
|
||||
if (ignore)
|
||||
return
|
||||
|
||||
const { plugin, version } = data
|
||||
const redirected = onPackageCategoryResolved?.(plugin.category, packageId)
|
||||
if (redirected)
|
||||
return
|
||||
|
||||
setMarketplaceInstall({
|
||||
uniqueIdentifier: packageId,
|
||||
manifest: {
|
||||
...plugin,
|
||||
version: version.version,
|
||||
icon: `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`,
|
||||
},
|
||||
})
|
||||
setIsShowInstallFromMarketplace(true)
|
||||
}
|
||||
catch (error) {
|
||||
if (!ignore)
|
||||
console.error('Failed to load marketplace plugin manifest:', error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (bundleInfo) {
|
||||
try {
|
||||
const { data } = await fetchBundleInfoFromMarketPlace(bundleInfo)
|
||||
if (ignore)
|
||||
return
|
||||
|
||||
setDependencies(data.version.dependencies)
|
||||
setIsShowInstallFromMarketplace(true)
|
||||
}
|
||||
catch (error) {
|
||||
if (!ignore)
|
||||
console.error('Failed to load bundle info:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadMarketplaceInstall()
|
||||
|
||||
return () => {
|
||||
ignore = true
|
||||
}
|
||||
}, [bundleInfo, canInstallPlugin, isPermissionLoading, onPackageCategoryResolved, packageId, setInstallState])
|
||||
|
||||
const hideInstallFromMarketplace = () => {
|
||||
setMarketplaceInstall(null)
|
||||
setDependencies([])
|
||||
setIsShowInstallFromMarketplace(false)
|
||||
setInstallState(null)
|
||||
}
|
||||
|
||||
return {
|
||||
bundleInfo,
|
||||
dependencies,
|
||||
hideInstallFromMarketplace,
|
||||
isShowInstallFromMarketplace,
|
||||
marketplaceInstall: marketplaceInstall?.uniqueIdentifier === packageId ? marketplaceInstall : null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import type { PluginCategoryEnum, PluginManifestInMarket } from '../types'
|
||||
import type { UseInstallFromMarketplaceQueryOptions } from './hooks/use-install-from-marketplace-query'
|
||||
import { useInstallFromMarketplaceQuery as useInstallFromMarketplaceQueryHook } from './hooks/use-install-from-marketplace-query'
|
||||
import InstallFromMarketplace from './install-from-marketplace'
|
||||
|
||||
type InstallFromMarketplaceQueryProps = UseInstallFromMarketplaceQueryOptions & {
|
||||
installContextCategory?: PluginCategoryEnum
|
||||
}
|
||||
|
||||
const InstallFromMarketplaceQuery = ({
|
||||
installContextCategory,
|
||||
...options
|
||||
}: InstallFromMarketplaceQueryProps) => {
|
||||
const {
|
||||
bundleInfo,
|
||||
dependencies,
|
||||
hideInstallFromMarketplace,
|
||||
isShowInstallFromMarketplace,
|
||||
marketplaceInstall,
|
||||
} = useInstallFromMarketplaceQueryHook(options)
|
||||
|
||||
if (!isShowInstallFromMarketplace)
|
||||
return null
|
||||
|
||||
if (!marketplaceInstall && !bundleInfo)
|
||||
return null
|
||||
|
||||
return (
|
||||
<InstallFromMarketplace
|
||||
manifest={marketplaceInstall?.manifest as PluginManifestInMarket}
|
||||
uniqueIdentifier={marketplaceInstall?.uniqueIdentifier ?? ''}
|
||||
isBundle={!!bundleInfo}
|
||||
dependencies={dependencies}
|
||||
installContextCategory={installContextCategory}
|
||||
onClose={hideInstallFromMarketplace}
|
||||
onSuccess={hideInstallFromMarketplace}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstallFromMarketplaceQuery
|
||||
@@ -12,6 +12,9 @@ import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/
|
||||
import PluginPageWithContext from '../index'
|
||||
|
||||
let mockEnableMarketplace = true
|
||||
const { mockRouterReplace } = vi.hoisted(() => ({
|
||||
mockRouterReplace: vi.fn(),
|
||||
}))
|
||||
|
||||
const render = (ui: ReactElement, options: Parameters<typeof renderWithSystemFeatures>[1] = {}) =>
|
||||
renderWithSystemFeatures(ui, {
|
||||
@@ -33,6 +36,12 @@ vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockRouterReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en-US',
|
||||
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
|
||||
@@ -466,7 +475,7 @@ describe('PluginPage Component', () => {
|
||||
plugin: { org: 'test-org', name: 'test-plugin', category: 'tool' },
|
||||
version: { version: '1.0.0' },
|
||||
},
|
||||
} as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
|
||||
} as unknown as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
|
||||
|
||||
render(<PluginPageWithContext {...createDefaultProps()} />)
|
||||
|
||||
@@ -502,10 +511,10 @@ describe('PluginPage Component', () => {
|
||||
|
||||
vi.mocked(fetchManifestFromMarketPlace).mockResolvedValue({
|
||||
data: {
|
||||
plugin: { org: 'test-org', name: 'test-plugin', category: 'tool' },
|
||||
plugin: { org: 'test-org', name: 'test-plugin', category: 'unknown' },
|
||||
version: { version: '1.0.0' },
|
||||
},
|
||||
} as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
|
||||
} as unknown as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
|
||||
|
||||
render(<PluginPageWithContext {...createDefaultProps()} />)
|
||||
|
||||
@@ -514,6 +523,28 @@ describe('PluginPage Component', () => {
|
||||
}, { timeout: 3000 })
|
||||
})
|
||||
|
||||
it('should redirect supported plugin categories to integrations before opening the modal', async () => {
|
||||
const mockSetInstallState = vi.fn()
|
||||
vi.mocked(usePluginInstallation).mockReturnValue([
|
||||
{ packageId: 'junjiem/mcp_see_agent:0.2.4@test', bundleInfo: null },
|
||||
mockSetInstallState,
|
||||
])
|
||||
|
||||
vi.mocked(fetchManifestFromMarketPlace).mockResolvedValue({
|
||||
data: {
|
||||
plugin: { org: 'junjiem', name: 'mcp_see_agent', category: 'agent-strategy' },
|
||||
version: { version: '0.2.4' },
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
|
||||
|
||||
render(<PluginPageWithContext {...createDefaultProps()} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith('/integrations/agent-strategy?package-ids=%5B%22junjiem%2Fmcp_see_agent%3A0.2.4%40test%22%5D')
|
||||
})
|
||||
expect(screen.queryByTestId('install-marketplace-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle fetch error gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
@@ -764,10 +795,10 @@ describe('PluginPage Component', () => {
|
||||
|
||||
vi.mocked(fetchManifestFromMarketPlace).mockResolvedValue({
|
||||
data: {
|
||||
plugin: { org: 'test-org', name: 'test-plugin', category: 'tool' },
|
||||
plugin: { org: 'test-org', name: 'test-plugin', category: 'unknown' },
|
||||
version: { version: '1.0.0' },
|
||||
},
|
||||
} as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
|
||||
} as unknown as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
|
||||
|
||||
render(<PluginPageWithContext {...createDefaultProps()} />)
|
||||
|
||||
@@ -1044,10 +1075,10 @@ describe('PluginPage Integration', () => {
|
||||
|
||||
vi.mocked(fetchManifestFromMarketPlace).mockResolvedValue({
|
||||
data: {
|
||||
plugin: { org: 'langgenius', name: 'test-plugin', category: 'tool' },
|
||||
plugin: { org: 'langgenius', name: 'test-plugin', category: 'unknown' },
|
||||
version: { version: '1.0.0' },
|
||||
},
|
||||
} as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
|
||||
} as unknown as Awaited<ReturnType<typeof fetchManifestFromMarketPlace>>)
|
||||
|
||||
render(<PluginPageWithContext {...createDefaultProps()} />)
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('useReferenceSetting Hook', () => {
|
||||
expect(result.current.canDebugger).toBe(true)
|
||||
})
|
||||
|
||||
it('should ignore legacy admin permission for managers without plugin keys', () => {
|
||||
it('should allow debug for managers with legacy admin permission when RBAC is disabled', () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
@@ -146,10 +146,10 @@ describe('useReferenceSetting Hook', () => {
|
||||
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
|
||||
|
||||
expect(result.current.canManagement).toBe(false)
|
||||
expect(result.current.canDebugger).toBe(false)
|
||||
expect(result.current.canDebugger).toBe(true)
|
||||
})
|
||||
|
||||
it('should ignore legacy admin permission for owners without plugin keys', () => {
|
||||
it('should allow debug for owners with legacy admin permission when RBAC is disabled', () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
isCurrentWorkspaceManager: false,
|
||||
isCurrentWorkspaceOwner: true,
|
||||
@@ -167,7 +167,30 @@ describe('useReferenceSetting Hook', () => {
|
||||
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
|
||||
|
||||
expect(result.current.canManagement).toBe(false)
|
||||
expect(result.current.canDebugger).toBe(false)
|
||||
expect(result.current.canDebugger).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow debug for normal users when legacy debug permission is everyone and RBAC is disabled', () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
isCurrentWorkspaceManager: false,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
|
||||
workspacePermissionKeys: ['plugin.install'],
|
||||
} as ReturnType<typeof useAppContext>)
|
||||
|
||||
vi.mocked(usePluginPermissionSettings).mockReturnValue({
|
||||
data: {
|
||||
install_permission: PermissionType.everyone,
|
||||
debug_permission: PermissionType.everyone,
|
||||
},
|
||||
} as ReturnType<typeof usePluginPermissionSettings>)
|
||||
|
||||
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool), {
|
||||
systemFeatures: { rbac_enabled: false },
|
||||
})
|
||||
|
||||
expect(result.current.canDebugPlugin).toBe(true)
|
||||
expect(result.current.canDebugger).toBe(true)
|
||||
})
|
||||
|
||||
it('should use plugin keys even when legacy admin permission is configured and RBAC is enabled', () => {
|
||||
@@ -346,6 +369,35 @@ describe('useReferenceSetting Hook', () => {
|
||||
expect(result.current.canManagement).toBe(true)
|
||||
expect(result.current.canDebugger).toBe(true)
|
||||
})
|
||||
|
||||
it('should keep permission state loading while workspace permission keys are loading', () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
isCurrentWorkspaceManager: false,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
isLoadingWorkspacePermissionKeys: true,
|
||||
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
|
||||
workspacePermissionKeys: [] as string[],
|
||||
} as ReturnType<typeof useAppContext>)
|
||||
|
||||
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
|
||||
|
||||
expect(result.current.isPermissionLoading).toBe(true)
|
||||
expect(result.current.canInstallPlugin).toBe(false)
|
||||
})
|
||||
|
||||
it('should keep permission state loading while current workspace is loading', () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
isCurrentWorkspaceManager: false,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
isLoadingCurrentWorkspace: true,
|
||||
langGeniusVersionInfo: { current_version: '1.0.0', latest_version: '', version: '' },
|
||||
workspacePermissionKeys: ['plugin.install'],
|
||||
} as ReturnType<typeof useAppContext>)
|
||||
|
||||
const { result } = renderHook(() => useReferenceSetting(PluginCategoryEnum.tool))
|
||||
|
||||
expect(result.current.isPermissionLoading).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RBAC permissions', () => {
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('Empty Component', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render basic structure correctly', async () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Empty />)
|
||||
render(<Empty />)
|
||||
await flushEffects()
|
||||
|
||||
// Assert - file input
|
||||
@@ -144,10 +144,6 @@ describe('Empty Component', () => {
|
||||
expect(fileInput.style.display).toBe('none')
|
||||
expect(fileInput.accept).toBe('.difypkg,.difybndl')
|
||||
|
||||
// Assert - skeleton cards (20 in the grid + 1 icon container)
|
||||
const skeletonCards = container.querySelectorAll('.rounded-xl.bg-components-card-bg')
|
||||
expect(skeletonCards.length).toBeGreaterThanOrEqual(20)
|
||||
|
||||
// Assert - group icon container
|
||||
const iconContainer = document.querySelector('.size-14')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
@@ -170,12 +166,6 @@ describe('Empty Component', () => {
|
||||
expect(container.querySelector('.i-custom-vender-integrations-trigger-active')).toBeInTheDocument()
|
||||
expect(container.querySelector('.i-custom-vender-integrations-trigger')).not.toBeInTheDocument()
|
||||
|
||||
const skeletonGrid = container.querySelector('.grid')
|
||||
expect(skeletonGrid).toHaveClass('max-w-[1600px]', 'px-6', 'gap-x-[7px]', 'gap-y-[15px]', 'pt-2')
|
||||
|
||||
const skeletonCards = container.querySelectorAll('.h-\\[72px\\].rounded-lg')
|
||||
expect(skeletonCards).toHaveLength(22)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
buttons.forEach(button => expect(button).toHaveClass('h-8', 'w-full', 'justify-start'))
|
||||
})
|
||||
@@ -191,8 +181,6 @@ describe('Empty Component', () => {
|
||||
expect(container.querySelector('.i-ri-drag-drop-line')).toBeInTheDocument()
|
||||
expect(container.firstElementChild).toHaveClass('bg-components-panel-bg')
|
||||
|
||||
const skeletonGrid = container.querySelector('.grid')
|
||||
expect(skeletonGrid).toHaveClass('max-w-[1600px]', 'px-6', 'gap-x-[7px]', 'gap-y-[15px]', 'pt-2')
|
||||
expect(container.querySelector('.items-center')).toBeInTheDocument()
|
||||
expect(container.querySelector('.-translate-y-7')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.i-custom-vender-integrations-agent-strategy-active')).toHaveClass('size-6', 'shrink-0')
|
||||
@@ -208,9 +196,6 @@ describe('Empty Component', () => {
|
||||
expect(screen.getByText('plugin.installModal.dropIntegrationToInstall')).toBeInTheDocument()
|
||||
expect(container.querySelector('.i-ri-drag-drop-line')).toBeInTheDocument()
|
||||
|
||||
const skeletonGrid = container.querySelector('.grid')
|
||||
expect(skeletonGrid).toHaveClass('max-w-[1600px]', 'px-6', 'gap-x-[7px]', 'gap-y-[15px]', 'pt-2')
|
||||
expect(skeletonGrid).toHaveStyle({ background: 'radial-gradient(ellipse at 50% 48%, #F3F4F7 0%, #FFFFFF 58%)' })
|
||||
expect(container.querySelector('.i-custom-vender-integrations-extension-active')).toHaveClass('size-6', 'shrink-0')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -132,26 +132,22 @@ const Empty = ({
|
||||
: isIntegrationsExtension
|
||||
? t('list.noExtensionFound', { ns: 'plugin' })
|
||||
: text
|
||||
const placeholderItemCount = isIntegrationsCategory ? 14 : 20
|
||||
|
||||
return (
|
||||
<div className="relative z-0 w-full grow bg-components-panel-bg">
|
||||
{/* skeleton */}
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute top-0 left-1/2 z-10 grid h-full -translate-x-1/2 grid-cols-2 content-start overflow-hidden',
|
||||
isIntegrationsCategory ? 'gap-x-[7px] gap-y-[15px] pt-2' : 'gap-2',
|
||||
'pointer-events-none absolute top-0 left-1/2 z-10 grid h-full -translate-x-1/2 grid-cols-2 content-start gap-2 overflow-hidden',
|
||||
contentFrameClassName,
|
||||
)}
|
||||
style={isIntegrationsCategory
|
||||
? { background: 'radial-gradient(ellipse at 50% 48%, #F3F4F7 0%, #FFFFFF 58%)' }
|
||||
: undefined}
|
||||
>
|
||||
{Array.from({ length: isIntegrationsCategory ? 22 : 20 }).fill(0).map((_, i) => (
|
||||
<div key={i} className={cn(isIntegrationsCategory ? 'h-[72px] rounded-lg bg-[#F9FAFB]/[0.52]' : 'h-24 rounded-xl bg-components-card-bg')} />
|
||||
{Array.from({ length: placeholderItemCount }, (_, i) => (
|
||||
<div key={i} className={cn(isIntegrationsCategory ? 'h-24 rounded-lg bg-background-section-burn/30' : 'h-24 rounded-xl bg-components-card-bg')} />
|
||||
))}
|
||||
</div>
|
||||
{/* mask */}
|
||||
<div className="absolute z-20 size-full bg-linear-to-b from-components-panel-bg-transparent to-components-panel-bg" />
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 z-20 bg-linear-to-b from-components-panel-bg-transparent to-components-panel-bg" />
|
||||
<div className={cn(
|
||||
'relative z-30 flex h-full',
|
||||
showDropInstallTip ? 'flex-col' : 'items-center justify-center',
|
||||
@@ -170,9 +166,9 @@ const Empty = ({
|
||||
>
|
||||
<div className="flex flex-col items-center gap-y-3">
|
||||
<div className={cn(
|
||||
'relative -z-10 flex items-center justify-center border-dashed bg-components-card-bg',
|
||||
'relative -z-10 flex items-center justify-center border-dashed bg-components-card-bg backdrop-blur-md',
|
||||
isIntegrationsCategory
|
||||
? 'size-[60px] rounded-[13px] border-[0.667px] border-divider-deep shadow-xl shadow-shadow-shadow-5'
|
||||
? 'size-14 rounded-xl border border-divider-regular'
|
||||
: 'size-14 rounded-xl border border-divider-deep shadow-xl shadow-shadow-shadow-5',
|
||||
)}
|
||||
>
|
||||
@@ -196,11 +192,11 @@ const Empty = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn(isIntegrationsCategory ? 'system-sm-regular text-text-primary' : 'system-md-regular text-text-tertiary')}>
|
||||
<div className={cn(isIntegrationsCategory ? 'system-sm-regular text-text-tertiary' : 'system-md-regular text-text-tertiary')}>
|
||||
{emptyText}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('flex flex-col', isIntegrationsCategory ? 'w-[200px]' : 'w-[236px]')}>
|
||||
<div className="flex w-[236px] flex-col">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import type { Dependency, PluginDeclaration, PluginManifestInMarket } from '../types'
|
||||
import type { PluginPageTab } from './context'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@@ -14,23 +13,22 @@ import {
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { cloneElement, isValidElement, useEffect, useMemo, useState } from 'react'
|
||||
import { cloneElement, isValidElement, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import TabSlider from '@/app/components/base/tab-slider'
|
||||
import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal'
|
||||
import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
|
||||
import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { usePluginInstallation } from '@/hooks/use-query-params'
|
||||
import Link from '@/next/link'
|
||||
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
|
||||
import { sleep } from '@/utils'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { PLUGIN_PAGE_TABS_MAP } from '../hooks'
|
||||
import { PluginInstallPermissionProvider } from '../install-plugin/components/plugin-install-permission-provider'
|
||||
import InstallFromLocalPackage from '../install-plugin/install-from-local-package'
|
||||
import InstallFromMarketplace from '../install-plugin/install-from-marketplace'
|
||||
import InstallFromMarketplaceQuery from '../install-plugin/install-from-marketplace-query'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants'
|
||||
import { getInstallRedirectPathByPluginCategory } from '../plugin-routes'
|
||||
import { PluginCategoryEnum } from '../types'
|
||||
import { usePluginPageContext } from './context'
|
||||
import { PluginPageContextProvider } from './context-provider'
|
||||
@@ -50,6 +48,29 @@ const isPluginPageTab = (value: string): value is PluginPageTab => {
|
||||
return pluginPageTabSet.has(value)
|
||||
}
|
||||
|
||||
const getCurrentInstallSearchParams = (packageId: string) => {
|
||||
const searchParams: Record<string, string | string[]> = {}
|
||||
|
||||
new URLSearchParams(window.location.search).forEach((value, key) => {
|
||||
const existing = searchParams[key]
|
||||
if (existing === undefined) {
|
||||
searchParams[key] = value
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(existing)) {
|
||||
existing.push(value)
|
||||
return
|
||||
}
|
||||
|
||||
searchParams[key] = [existing, value]
|
||||
})
|
||||
|
||||
searchParams['package-ids'] ??= JSON.stringify([packageId])
|
||||
|
||||
return searchParams
|
||||
}
|
||||
|
||||
export type PluginPageProps = {
|
||||
plugins: React.ReactNode
|
||||
marketplace: React.ReactNode
|
||||
@@ -65,24 +86,7 @@ const PluginPage = ({
|
||||
}: PluginPageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
|
||||
// Use nuqs hook for installation state
|
||||
const [{ packageId, bundleInfo }, setInstallState] = usePluginInstallation()
|
||||
|
||||
const [uniqueIdentifier, setUniqueIdentifier] = useState<string | null>(null)
|
||||
const [dependencies, setDependencies] = useState<Dependency[]>([])
|
||||
|
||||
const [isShowInstallFromMarketplace, {
|
||||
setTrue: showInstallFromMarketplace,
|
||||
setFalse: doHideInstallFromMarketplace,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const hideInstallFromMarketplace = () => {
|
||||
doHideInstallFromMarketplace()
|
||||
setInstallState(null)
|
||||
}
|
||||
|
||||
const [manifest, setManifest] = useState<PluginDeclaration | PluginManifestInMarket | null>(null)
|
||||
const { replace } = useRouter()
|
||||
|
||||
const {
|
||||
referenceSetting,
|
||||
@@ -98,41 +102,15 @@ const PluginPage = ({
|
||||
setReferenceSettings,
|
||||
} = useReferenceSetting(PluginCategoryEnum.tool)
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setUniqueIdentifier(null)
|
||||
await sleep(100)
|
||||
if (isPermissionLoading)
|
||||
return
|
||||
const handlePackageCategoryResolved = useCallback((category: string | undefined, packageId: string) => {
|
||||
const installRedirectPath = getInstallRedirectPathByPluginCategory(category, getCurrentInstallSearchParams(packageId))
|
||||
if (!installRedirectPath)
|
||||
return false
|
||||
|
||||
replace(installRedirectPath)
|
||||
return true
|
||||
}, [replace])
|
||||
|
||||
if (!canInstallPlugin) {
|
||||
setInstallState(null)
|
||||
return
|
||||
}
|
||||
if (packageId) {
|
||||
const { data } = await fetchManifestFromMarketPlace(encodeURIComponent(packageId))
|
||||
const { plugin, version } = data
|
||||
setManifest({
|
||||
...plugin,
|
||||
version: version.version,
|
||||
icon: `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`,
|
||||
})
|
||||
setUniqueIdentifier(packageId)
|
||||
showInstallFromMarketplace()
|
||||
return
|
||||
}
|
||||
if (bundleInfo) {
|
||||
try {
|
||||
const { data } = await fetchBundleInfoFromMarketPlace(bundleInfo)
|
||||
setDependencies(data.version.dependencies)
|
||||
showInstallFromMarketplace()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to load bundle info:', error)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, [packageId, bundleInfo, canInstallPlugin, isPermissionLoading, setInstallState, showInstallFromMarketplace])
|
||||
const [showPluginSettingModal, {
|
||||
setTrue: setShowPluginSettingModal,
|
||||
setFalse: setHidePluginSettingModal,
|
||||
@@ -351,18 +329,11 @@ const PluginPage = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{
|
||||
isShowInstallFromMarketplace && uniqueIdentifier && (
|
||||
<InstallFromMarketplace
|
||||
manifest={manifest! as PluginManifestInMarket}
|
||||
uniqueIdentifier={uniqueIdentifier}
|
||||
isBundle={!!bundleInfo}
|
||||
dependencies={dependencies}
|
||||
onClose={hideInstallFromMarketplace}
|
||||
onSuccess={hideInstallFromMarketplace}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<InstallFromMarketplaceQuery
|
||||
canInstallPlugin={canInstallPlugin}
|
||||
isPermissionLoading={isPermissionLoading}
|
||||
onPackageCategoryResolved={handlePackageCategoryResolved}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ const ErrorPluginItem: FC<ErrorPluginItemProps> = ({ plugin, getIconUrl, languag
|
||||
</span>
|
||||
)}
|
||||
statusText={(
|
||||
<span className="block max-w-full wrap-break-word whitespace-pre-line">
|
||||
<span className="block max-w-full min-w-0 [overflow-wrap:anywhere] break-words whitespace-pre-wrap">
|
||||
{plugin.message || errorMsg}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -29,7 +29,7 @@ const PluginItem: FC<PluginItemProps> = ({
|
||||
const pluginName = plugin.labels[language] || plugin.plugin_unique_identifier
|
||||
|
||||
return (
|
||||
<div className="group/item flex gap-1 rounded-lg p-2 hover:bg-state-base-hover">
|
||||
<div className="group/item flex w-full max-w-full min-w-0 gap-1 overflow-hidden rounded-lg p-2 hover:bg-state-base-hover">
|
||||
<div className="relative shrink-0 self-start">
|
||||
{hasPluginIcon
|
||||
? (
|
||||
@@ -44,11 +44,11 @@ const PluginItem: FC<PluginItemProps> = ({
|
||||
{statusIcon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 grow flex-col gap-0.5 px-1">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 px-1 [overflow-wrap:anywhere]">
|
||||
<div className="truncate system-sm-medium text-text-secondary">
|
||||
{plugin.labels[language]}
|
||||
</div>
|
||||
<div className={`min-w-0 system-xs-regular wrap-break-word ${statusClassName || 'text-text-tertiary'}`}>
|
||||
<div className={`max-w-full min-w-0 system-xs-regular [overflow-wrap:anywhere] wrap-break-word ${statusClassName || 'text-text-tertiary'}`}>
|
||||
{statusText}
|
||||
</div>
|
||||
{action}
|
||||
|
||||
+8
-11
@@ -33,15 +33,15 @@ function PluginTaskList({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg"
|
||||
className="w-[360px] max-w-[calc(100vw-32px)] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg"
|
||||
data-testid="plugin-task-list"
|
||||
>
|
||||
<ScrollArea
|
||||
className="max-h-[420px] overflow-hidden"
|
||||
label={t('task.installing', { ns: 'plugin' })}
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'min-w-0',
|
||||
viewport: 'max-h-[420px] overscroll-contain',
|
||||
content: 'w-full! max-w-full! min-w-0! overflow-x-hidden!',
|
||||
}}
|
||||
>
|
||||
{runningPlugins.length > 0 && (
|
||||
@@ -103,13 +103,10 @@ function PluginTaskList({
|
||||
{t('task.clearAll', { ns: 'plugin' })}
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea
|
||||
className="overflow-hidden"
|
||||
label={errorSectionTitle}
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'min-w-0',
|
||||
}}
|
||||
<div
|
||||
aria-label={errorSectionTitle}
|
||||
className="w-full max-w-full min-w-0 overflow-hidden"
|
||||
role="region"
|
||||
>
|
||||
{errorPlugins.map(plugin => (
|
||||
<ErrorPluginItem
|
||||
@@ -120,7 +117,7 @@ function PluginTaskList({
|
||||
onClear={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
@@ -25,7 +25,14 @@ const useCanSetPluginSettings = () => {
|
||||
|
||||
export const usePluginSettingsAccess = () => {
|
||||
const { t } = useTranslation()
|
||||
const { isCurrentWorkspaceManager, isCurrentWorkspaceOwner, workspacePermissionKeys, langGeniusVersionInfo } = useAppContext()
|
||||
const {
|
||||
isCurrentWorkspaceManager,
|
||||
isCurrentWorkspaceOwner,
|
||||
isLoadingCurrentWorkspace,
|
||||
isLoadingWorkspacePermissionKeys,
|
||||
workspacePermissionKeys,
|
||||
langGeniusVersionInfo,
|
||||
} = useAppContext()
|
||||
const { data: rbacEnabled } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: s => s.rbac_enabled,
|
||||
@@ -52,7 +59,9 @@ export const usePluginSettingsAccess = () => {
|
||||
const canInstallPlugin = hasPermission(workspacePermissionKeys, 'plugin.install') && legacyCanInstallPlugin
|
||||
const canUpdatePlugin = hasPermission(workspacePermissionKeys, 'plugin.install') && legacyCanInstallPlugin
|
||||
const canDeletePlugin = hasPermission(workspacePermissionKeys, 'plugin.delete') && legacyCanInstallPlugin
|
||||
const canDebugPlugin = hasPermission(workspacePermissionKeys, 'plugin.debug') && legacyCanDebugPlugin
|
||||
const canDebugPlugin = rbacEnabled
|
||||
? hasPermission(workspacePermissionKeys, 'plugin.debug')
|
||||
: legacyCanDebugPlugin
|
||||
|
||||
return {
|
||||
permission: permissions,
|
||||
@@ -66,7 +75,7 @@ export const usePluginSettingsAccess = () => {
|
||||
canDebugger: canDebugPlugin,
|
||||
canSetPermissions,
|
||||
currentDifyVersion: langGeniusVersionInfo?.current_version,
|
||||
isPermissionLoading: permissionQuery.isLoading || permissionQuery.isFetching,
|
||||
isPermissionLoading: permissionQuery.isLoading || permissionQuery.isFetching || !!isLoadingCurrentWorkspace || !!isLoadingWorkspacePermissionKeys,
|
||||
permissionError: permissionQuery.error,
|
||||
isPermissionUpdatePending,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ export type LegacyPluginsSearchParams = Record<string, string | string[] | undef
|
||||
|
||||
const INSTALLED_PLUGINS_TAB = 'plugins'
|
||||
const MARKETPLACE_TAB = 'discover'
|
||||
const installSearchParamKeys = new Set(['package-ids', 'bundle-info'])
|
||||
const PACKAGE_IDS_SEARCH_PARAM = 'package-ids'
|
||||
|
||||
const integrationPluginPathByTab = new Map<string, string>([
|
||||
['trigger', '/integrations/trigger'],
|
||||
@@ -11,6 +13,15 @@ const integrationPluginPathByTab = new Map<string, string>([
|
||||
['extension', '/integrations/extension'],
|
||||
])
|
||||
|
||||
const integrationPluginPathByInstallCategory = new Map<string, string>([
|
||||
['model', '/integrations/model-provider'],
|
||||
['tool', '/integrations/tools/built-in'],
|
||||
['datasource', '/integrations/data-source'],
|
||||
['trigger', '/integrations/trigger'],
|
||||
['agent-strategy', '/integrations/agent-strategy'],
|
||||
['extension', '/integrations/extension'],
|
||||
])
|
||||
|
||||
const getIntegrationPluginPathByTab = (tab: string) => {
|
||||
return integrationPluginPathByTab.get(tab)
|
||||
}
|
||||
@@ -27,14 +38,42 @@ const getFirstSearchParamValue = (value: string | string[] | undefined) => {
|
||||
return value
|
||||
}
|
||||
|
||||
const buildMarketplaceRedirectPath = (
|
||||
const hasInstallSearchParams = (searchParams: LegacyPluginsSearchParams) => {
|
||||
return Object.keys(searchParams).some(key => installSearchParamKeys.has(key))
|
||||
}
|
||||
|
||||
export const getFirstPackageIdFromSearchParams = (searchParams: LegacyPluginsSearchParams) => {
|
||||
const value = getFirstSearchParamValue(searchParams[PACKAGE_IDS_SEARCH_PARAM])
|
||||
if (!value)
|
||||
return null
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value)
|
||||
if (Array.isArray(parsed)) {
|
||||
const first = parsed[0]
|
||||
return typeof first === 'string' ? first : null
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return value
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export const shouldResolveInstallCategoryRedirect = (searchParams: LegacyPluginsSearchParams) => {
|
||||
const tab = getFirstSearchParamValue(searchParams.tab)
|
||||
return (!tab || tab === INSTALLED_PLUGINS_TAB) && !!getFirstPackageIdFromSearchParams(searchParams)
|
||||
}
|
||||
|
||||
const buildPreservedSearchParams = (
|
||||
searchParams: LegacyPluginsSearchParams,
|
||||
tab: string,
|
||||
ignoredKeys: Set<string>,
|
||||
) => {
|
||||
const preservedSearchParams = new URLSearchParams()
|
||||
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
if (key === 'tab' || value === undefined)
|
||||
if (ignoredKeys.has(key) || value === undefined)
|
||||
return
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
@@ -45,11 +84,74 @@ const buildMarketplaceRedirectPath = (
|
||||
preservedSearchParams.set(key, value)
|
||||
})
|
||||
|
||||
return preservedSearchParams
|
||||
}
|
||||
|
||||
const buildRedirectPath = (
|
||||
path: string,
|
||||
searchParams: LegacyPluginsSearchParams,
|
||||
ignoredKeys: Set<string>,
|
||||
) => {
|
||||
const query = buildPreservedSearchParams(searchParams, ignoredKeys).toString()
|
||||
return query ? `${path}?${query}` : path
|
||||
}
|
||||
|
||||
const buildInstallRedirectPath = (
|
||||
path: string,
|
||||
searchParams: LegacyPluginsSearchParams,
|
||||
) => {
|
||||
const installSearchParams: LegacyPluginsSearchParams = {}
|
||||
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
if (installSearchParamKeys.has(key))
|
||||
installSearchParams[key] = value
|
||||
})
|
||||
|
||||
return buildRedirectPath(path, installSearchParams, new Set())
|
||||
}
|
||||
|
||||
export const getInstallRedirectPathByPluginCategory = (
|
||||
category: string | undefined,
|
||||
searchParams: LegacyPluginsSearchParams,
|
||||
) => {
|
||||
if (!category)
|
||||
return undefined
|
||||
|
||||
const path = integrationPluginPathByInstallCategory.get(category)
|
||||
if (!path)
|
||||
return undefined
|
||||
|
||||
return buildInstallRedirectPath(path, searchParams)
|
||||
}
|
||||
|
||||
export const getInstallRedirectPathFromSearchParams = (
|
||||
searchParams: LegacyPluginsSearchParams,
|
||||
) => {
|
||||
if (!hasInstallSearchParams(searchParams))
|
||||
return undefined
|
||||
|
||||
const category = getFirstSearchParamValue(searchParams.category)
|
||||
const pathByCategory = getInstallRedirectPathByPluginCategory(category, searchParams)
|
||||
if (pathByCategory)
|
||||
return pathByCategory
|
||||
|
||||
const tab = getFirstSearchParamValue(searchParams.tab)
|
||||
return getInstallRedirectPathByPluginCategory(tab, searchParams)
|
||||
}
|
||||
|
||||
const buildMarketplaceRedirectPath = (
|
||||
searchParams: LegacyPluginsSearchParams,
|
||||
tab: string,
|
||||
) => {
|
||||
const path = '/marketplace'
|
||||
const ignoredKeys = new Set(['tab'])
|
||||
const preservedSearchParams = buildPreservedSearchParams(searchParams, ignoredKeys)
|
||||
|
||||
if (tab !== MARKETPLACE_TAB && !preservedSearchParams.has('category'))
|
||||
preservedSearchParams.set('category', tab)
|
||||
|
||||
const query = preservedSearchParams.toString()
|
||||
return query ? `/marketplace?${query}` : '/marketplace'
|
||||
return query ? `${path}?${query}` : path
|
||||
}
|
||||
|
||||
export const getLegacyPluginRedirectPath = (
|
||||
@@ -57,12 +159,18 @@ export const getLegacyPluginRedirectPath = (
|
||||
) => {
|
||||
const tab = getFirstSearchParamValue(searchParams.tab)
|
||||
|
||||
if ((!tab || tab === INSTALLED_PLUGINS_TAB) && hasInstallSearchParams(searchParams))
|
||||
return undefined
|
||||
|
||||
if (!tab || tab === INSTALLED_PLUGINS_TAB)
|
||||
return '/integrations'
|
||||
|
||||
const integrationPluginPath = getIntegrationPluginPathByTab(tab)
|
||||
if (integrationPluginPath)
|
||||
return integrationPluginPath
|
||||
if (integrationPluginPath) {
|
||||
return hasInstallSearchParams(searchParams)
|
||||
? buildInstallRedirectPath(integrationPluginPath, searchParams)
|
||||
: buildRedirectPath(integrationPluginPath, searchParams, new Set(['tab']))
|
||||
}
|
||||
|
||||
if (marketplacePluginTabs.has(tab))
|
||||
return buildMarketplaceRedirectPath(searchParams, tab)
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ describe('useAvailableNodesMetaData', () => {
|
||||
expect(result.current.nodesMap?.[BlockEnum.IterationStart]?.metaData.helpLinkUri).toBeUndefined()
|
||||
expect(result.current.nodesMap?.[BlockEnum.LoopStart]?.metaData.helpLinkUri).toBeUndefined()
|
||||
expect(result.current.nodesMap?.[BlockEnum.LoopEnd]?.metaData.helpLinkUri).toBeUndefined()
|
||||
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.helpLinkUri).toBe('/docs/use-dify/nodes/user-input')
|
||||
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.helpLinkUri).toBe('/docs/use-dify/nodes/start')
|
||||
})
|
||||
|
||||
it('should expose Agent v2 instead of legacy Agent when Agent v2 is enabled', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ const metaData = genNodeMetaData({
|
||||
isRequired: false,
|
||||
isSingleton: true,
|
||||
isTypeFixed: true,
|
||||
helpLinkUri: 'user-input',
|
||||
helpLinkUri: 'start',
|
||||
})
|
||||
|
||||
const nodeDefault: NodeDefault<StartPlaceholderNodeType> = {
|
||||
|
||||
@@ -10,7 +10,7 @@ const metaData = genNodeMetaData({
|
||||
isRequired: false,
|
||||
isSingleton: true,
|
||||
isTypeFixed: false, // support node type change for start node(user input)
|
||||
helpLinkUri: 'user-input',
|
||||
helpLinkUri: 'start',
|
||||
})
|
||||
const nodeDefault: NodeDefault<StartNodeType> = {
|
||||
metaData,
|
||||
|
||||
@@ -178,6 +178,14 @@ describe('useDocLink', () => {
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/cloud/use-dify/workspace/subscription-management#dify-for-education`)
|
||||
})
|
||||
|
||||
it('should use the self-host Start node docs path outside cloud edition', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = false
|
||||
|
||||
const { result } = renderHook(() => useDocLink())
|
||||
const url = result.current('/use-dify/nodes/start')
|
||||
expect(url).toBe(`${defaultDocBaseUrl}/en/self-host/use-dify/nodes/start`)
|
||||
})
|
||||
|
||||
it('should use the existing self-host docs path for self-host-only product docs in cloud edition', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSetNeedRefreshAppList } from '@/app/components/apps/storage'
|
||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { DSLImportStatus } from '@/models/app'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
@@ -45,6 +46,7 @@ export const useImportDSL = () => {
|
||||
const { push } = useRouter()
|
||||
const invalidateAppList = useInvalidateAppList()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { userProfile, workspacePermissionKeys } = useAppContext()
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>()
|
||||
const importIdRef = useRef<string>('')
|
||||
@@ -95,7 +97,12 @@ export const useImportDSL = () => {
|
||||
setNeedRefresh('1')
|
||||
invalidateAppList()
|
||||
await handleCheckPluginDependencies(app_id)
|
||||
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { isRbacEnabled })
|
||||
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, {
|
||||
currentUserId: userProfile?.id,
|
||||
resourceMaintainer: userProfile?.id,
|
||||
workspacePermissionKeys,
|
||||
isRbacEnabled,
|
||||
})
|
||||
}
|
||||
else if (status === DSLImportStatus.PENDING) {
|
||||
setVersions({
|
||||
@@ -117,7 +124,7 @@ export const useImportDSL = () => {
|
||||
finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}, [isFetching, t, handleCheckPluginDependencies, isRbacEnabled, push, setNeedRefresh, invalidateAppList])
|
||||
}, [isFetching, t, handleCheckPluginDependencies, isRbacEnabled, push, setNeedRefresh, invalidateAppList, userProfile?.id, workspacePermissionKeys])
|
||||
|
||||
const handleImportDSLConfirm = useCallback(async (
|
||||
{
|
||||
@@ -146,7 +153,12 @@ export const useImportDSL = () => {
|
||||
await handleCheckPluginDependencies(app_id)
|
||||
setNeedRefresh('1')
|
||||
invalidateAppList()
|
||||
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { isRbacEnabled })
|
||||
getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, {
|
||||
currentUserId: userProfile?.id,
|
||||
resourceMaintainer: userProfile?.id,
|
||||
workspacePermissionKeys,
|
||||
isRbacEnabled,
|
||||
})
|
||||
}
|
||||
else if (status === DSLImportStatus.FAILED) {
|
||||
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
|
||||
@@ -160,7 +172,7 @@ export const useImportDSL = () => {
|
||||
finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}, [isFetching, t, handleCheckPluginDependencies, isRbacEnabled, setNeedRefresh, push, invalidateAppList])
|
||||
}, [isFetching, t, handleCheckPluginDependencies, isRbacEnabled, setNeedRefresh, push, invalidateAppList, userProfile?.id, workspacePermissionKeys])
|
||||
|
||||
return {
|
||||
handleImportDSL,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { createAccountProfileQueryWrapper } from '@/test/account-profile-query'
|
||||
import useTimestamp from './use-timestamp'
|
||||
|
||||
@@ -21,6 +25,22 @@ vi.mock('@/context/app-context', () => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
const createEmptyQueryWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function EmptyQueryWrapper({ children }: { children: ReactNode }) {
|
||||
return createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
}
|
||||
|
||||
return { queryClient, wrapper: EmptyQueryWrapper }
|
||||
}
|
||||
|
||||
describe('useTimestamp', () => {
|
||||
describe('formatTime', () => {
|
||||
it('should format unix timestamp correctly', () => {
|
||||
@@ -63,4 +83,14 @@ describe('useTimestamp', () => {
|
||||
.toBe('20:00')
|
||||
})
|
||||
})
|
||||
|
||||
it('should not request account profile when timezone is provided', () => {
|
||||
const { queryClient, wrapper } = createEmptyQueryWrapper()
|
||||
|
||||
const { result } = renderHook(() => useTimestamp({ timezone: 'UTC' }), { wrapper })
|
||||
|
||||
expect(result.current.formatTime(1704132000, 'YYYY-MM-DD HH:mm')).toBe('2024-01-01 18:00')
|
||||
expect(queryClient.isFetching({ queryKey: userProfileQueryOptions().queryKey })).toBe(0)
|
||||
expect(queryClient.getQueryState(userProfileQueryOptions().queryKey)?.fetchStatus).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,19 +9,29 @@ import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
|
||||
const useTimestamp = () => {
|
||||
const { data: timezone } = useQuery({
|
||||
type UseTimestampOptions = {
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
const getBrowserTimezone = () => {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
}
|
||||
|
||||
const useTimestamp = ({ timezone: timezoneOverride }: UseTimestampOptions = {}) => {
|
||||
const { data: accountTimezone } = useQuery({
|
||||
...userProfileQueryOptions(),
|
||||
select: data => data.profile.timezone ?? undefined,
|
||||
enabled: timezoneOverride === undefined,
|
||||
})
|
||||
const resolvedTimezone = timezoneOverride ?? accountTimezone ?? getBrowserTimezone()
|
||||
|
||||
const formatTime = useCallback((value: number, format: string) => {
|
||||
return dayjs.unix(value).tz(timezone).format(format)
|
||||
}, [timezone])
|
||||
return dayjs.unix(value).tz(resolvedTimezone).format(format)
|
||||
}, [resolvedTimezone])
|
||||
|
||||
const formatDate = useCallback((value: string, format: string) => {
|
||||
return dayjs(value).tz(timezone).format(format)
|
||||
}, [timezone])
|
||||
return dayjs(value).tz(resolvedTimezone).format(format)
|
||||
}, [resolvedTimezone])
|
||||
|
||||
return { formatTime, formatDate }
|
||||
}
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.integrations": "التكاملات",
|
||||
"mainNav.marketplace": "سوق الإضافات",
|
||||
"mainNav.webApps.noResults": "لم يتم العثور على تطبيقات ويب",
|
||||
"mainNav.webApps.openApp": "فتح تطبيق الويب {{name}}",
|
||||
"mainNav.webApps.searchPlaceholder": "البحث في تطبيقات الويب",
|
||||
"mainNav.workspace.credits": "{{count}} رصيد",
|
||||
"mainNav.workspace.creditsUnit": "أرصدة",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.integrations": "Integrationen",
|
||||
"mainNav.marketplace": "Marketplace",
|
||||
"mainNav.webApps.noResults": "Keine Web-Apps gefunden",
|
||||
"mainNav.webApps.openApp": "Web-App {{name}} öffnen",
|
||||
"mainNav.webApps.searchPlaceholder": "Web-Apps suchen",
|
||||
"mainNav.workspace.credits": "{{count}} Credits",
|
||||
"mainNav.workspace.creditsUnit": "Credits",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.integrations": "Integrations",
|
||||
"mainNav.marketplace": "Marketplace",
|
||||
"mainNav.webApps.noResults": "No web apps found",
|
||||
"mainNav.webApps.openApp": "Open {{name}} web app",
|
||||
"mainNav.webApps.searchPlaceholder": "Search web apps",
|
||||
"mainNav.workspace.credits": "{{count}} credits",
|
||||
"mainNav.workspace.creditsUnit": "credits",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.integrations": "Integraciones",
|
||||
"mainNav.marketplace": "Marketplace",
|
||||
"mainNav.webApps.noResults": "No se encontraron aplicaciones web",
|
||||
"mainNav.webApps.openApp": "Abrir la aplicación web {{name}}",
|
||||
"mainNav.webApps.searchPlaceholder": "Buscar aplicaciones web",
|
||||
"mainNav.workspace.credits": "{{count}} créditos",
|
||||
"mainNav.workspace.creditsUnit": "créditos",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.integrations": "یکپارچهسازیها",
|
||||
"mainNav.marketplace": "بازارچه",
|
||||
"mainNav.webApps.noResults": "هیچ برنامه وبی یافت نشد",
|
||||
"mainNav.webApps.openApp": "باز کردن برنامه وب {{name}}",
|
||||
"mainNav.webApps.searchPlaceholder": "جستجوی برنامههای وب",
|
||||
"mainNav.workspace.credits": "{{count}} اعتبار",
|
||||
"mainNav.workspace.creditsUnit": "اعتبار",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.integrations": "Intégrations",
|
||||
"mainNav.marketplace": "Marketplace",
|
||||
"mainNav.webApps.noResults": "Aucune application web trouvée",
|
||||
"mainNav.webApps.openApp": "Ouvrir l’application web {{name}}",
|
||||
"mainNav.webApps.searchPlaceholder": "Rechercher des applications web",
|
||||
"mainNav.workspace.credits": "{{count}} crédits",
|
||||
"mainNav.workspace.creditsUnit": "crédits",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.integrations": "इंटीग्रेशन",
|
||||
"mainNav.marketplace": "मार्केटप्लेस",
|
||||
"mainNav.webApps.noResults": "कोई वेब ऐप नहीं मिला",
|
||||
"mainNav.webApps.openApp": "{{name}} वेब ऐप खोलें",
|
||||
"mainNav.webApps.searchPlaceholder": "वेब ऐप खोजें",
|
||||
"mainNav.workspace.credits": "{{count}} क्रेडिट",
|
||||
"mainNav.workspace.creditsUnit": "क्रेडिट",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.integrations": "Integrasi",
|
||||
"mainNav.marketplace": "Marketplace",
|
||||
"mainNav.webApps.noResults": "Tidak ada aplikasi web ditemukan",
|
||||
"mainNav.webApps.openApp": "Buka aplikasi web {{name}}",
|
||||
"mainNav.webApps.searchPlaceholder": "Cari aplikasi web",
|
||||
"mainNav.workspace.credits": "{{count}} kredit",
|
||||
"mainNav.workspace.creditsUnit": "kredit",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.integrations": "Integrazioni",
|
||||
"mainNav.marketplace": "Marketplace",
|
||||
"mainNav.webApps.noResults": "Nessuna app web trovata",
|
||||
"mainNav.webApps.openApp": "Apri l'app web {{name}}",
|
||||
"mainNav.webApps.searchPlaceholder": "Cerca app web",
|
||||
"mainNav.workspace.credits": "{{count}} crediti",
|
||||
"mainNav.workspace.creditsUnit": "crediti",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.integrations": "連携",
|
||||
"mainNav.marketplace": "マーケットプレイス",
|
||||
"mainNav.webApps.noResults": "Webアプリが見つかりません",
|
||||
"mainNav.webApps.openApp": "{{name}} のWebアプリを開く",
|
||||
"mainNav.webApps.searchPlaceholder": "Webアプリを検索",
|
||||
"mainNav.workspace.credits": "{{count}} クレジット",
|
||||
"mainNav.workspace.creditsUnit": "クレジット",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.integrations": "연동",
|
||||
"mainNav.marketplace": "마켓플레이스",
|
||||
"mainNav.webApps.noResults": "웹 앱을 찾을 수 없습니다",
|
||||
"mainNav.webApps.openApp": "{{name}} 웹 앱 열기",
|
||||
"mainNav.webApps.searchPlaceholder": "웹 앱 검색",
|
||||
"mainNav.workspace.credits": "{{count}} 크레딧",
|
||||
"mainNav.workspace.creditsUnit": "크레딧",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.integrations": "Integraties",
|
||||
"mainNav.marketplace": "Marketplace",
|
||||
"mainNav.webApps.noResults": "Geen webapps gevonden",
|
||||
"mainNav.webApps.openApp": "Webapp {{name}} openen",
|
||||
"mainNav.webApps.searchPlaceholder": "Webapps zoeken",
|
||||
"mainNav.workspace.credits": "{{count}} credits",
|
||||
"mainNav.workspace.creditsUnit": "credits",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.integrations": "Integracje",
|
||||
"mainNav.marketplace": "Marketplace",
|
||||
"mainNav.webApps.noResults": "Nie znaleziono aplikacji webowych",
|
||||
"mainNav.webApps.openApp": "Otwórz aplikację webową {{name}}",
|
||||
"mainNav.webApps.searchPlaceholder": "Szukaj aplikacji webowych",
|
||||
"mainNav.workspace.credits": "{{count}} kredytów",
|
||||
"mainNav.workspace.creditsUnit": "kredyty",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.integrations": "Integrações",
|
||||
"mainNav.marketplace": "Marketplace",
|
||||
"mainNav.webApps.noResults": "Nenhum app web encontrado",
|
||||
"mainNav.webApps.openApp": "Abrir o app web {{name}}",
|
||||
"mainNav.webApps.searchPlaceholder": "Pesquisar apps web",
|
||||
"mainNav.workspace.credits": "{{count}} créditos",
|
||||
"mainNav.workspace.creditsUnit": "créditos",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.integrations": "Integrări",
|
||||
"mainNav.marketplace": "Marketplace",
|
||||
"mainNav.webApps.noResults": "Nu s-au găsit aplicații web",
|
||||
"mainNav.webApps.openApp": "Deschide aplicația web {{name}}",
|
||||
"mainNav.webApps.searchPlaceholder": "Caută aplicații web",
|
||||
"mainNav.workspace.credits": "{{count}} credite",
|
||||
"mainNav.workspace.creditsUnit": "credite",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.integrations": "Интеграции",
|
||||
"mainNav.marketplace": "Маркетплейс",
|
||||
"mainNav.webApps.noResults": "Веб-приложения не найдены",
|
||||
"mainNav.webApps.openApp": "Открыть веб-приложение {{name}}",
|
||||
"mainNav.webApps.searchPlaceholder": "Поиск веб-приложений",
|
||||
"mainNav.workspace.credits": "{{count}} кредитов",
|
||||
"mainNav.workspace.creditsUnit": "кредиты",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"mainNav.integrations": "Integracije",
|
||||
"mainNav.marketplace": "Marketplace",
|
||||
"mainNav.webApps.noResults": "Ni najdenih spletnih aplikacij",
|
||||
"mainNav.webApps.openApp": "Odpri spletno aplikacijo {{name}}",
|
||||
"mainNav.webApps.searchPlaceholder": "Išči spletne aplikacije",
|
||||
"mainNav.workspace.credits": "{{count}} kreditov",
|
||||
"mainNav.workspace.creditsUnit": "krediti",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user