Compare commits

...
Author SHA1 Message Date
Crazywoolaandyyh 1ef8d3a2a9 fix: display errors for oauth page (#38546)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
(cherry picked from commit 5ffd4345dc)
2026-07-08 16:31:31 +08:00
yyh b8adb0cac2 refactor(web): move app context layout styles to shell (#38511)
(cherry picked from commit 2c6ec1a761)
2026-07-08 16:31:31 +08:00
yyh df88c45e3c chore(dify-ui): update theme tokens (#38189)
(cherry picked from commit f8afa49ab9)
2026-07-08 16:31:31 +08:00
autofix-ci[bot]andYansong Zhang d3df1b2847 [autofix.ci] apply automated fixes 2026-07-08 09:55:47 +08:00
Yansong Zhang 709aac9337 test: cover credit pool quota paths 2026-07-08 09:55:47 +08:00
Yansong Zhang 865affdd9b test: update quota billing service mocks 2026-07-08 09:55:47 +08:00
Yansong Zhang 71840c3f71 fix(api): route quota calls to new billing 2026-07-08 09:55:47 +08:00
Yansong Zhang dc09aea7b3 feat(api): use billing quota for credit pool 2026-07-08 09:55:47 +08:00
非法操作 99507a5ae5 fix: editor should not manage member (#38503)
(cherry picked from commit faaa4708a6)
2026-07-07 16:09:47 +08:00
55ae0aadf8 fix(api): resolve plugin external user ids in backwards invocation (#38098)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
Co-authored-by: Harsh Kashyap <harshkashyap@Harshs-MacBook-Pro.local>
2026-07-06 17:12:39 +08:00
非法操作 6c4ba71a23 chore: use zstd for plugin model provider cache (#38382)
(cherry picked from commit 91cc50371b)
2026-07-03 17:19:43 +08:00
非法操作 e5f53053e3 fix: incorrect backgroud when dark mode and no plugins (#38378)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
(cherry picked from commit 81cc43b753)
2026-07-03 17:19:43 +08:00
非法操作 3e392527e9 chore: compress large plugin_model obj in redis (#38374)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
(cherry picked from commit 059a1fe090)
2026-07-03 17:19:43 +08:00
WH-2099and非法操作 36d3a9c7fb fix(api): keep provider refresh waiters single-flight (#38226)
(cherry picked from commit 7fb797c121)
2026-07-03 17:19:43 +08:00
Jingyiand非法操作 914598d7e3 fix: prevent app card meta overflow (#38349)
(cherry picked from commit d120995efc)
2026-07-03 10:55:44 +08:00
yyhand非法操作 602c24cf8c fix(web): align main nav app item states (#38326)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Jingyi-Dify <jingyi.qi@dify.ai>
(cherry picked from commit 57f687fb45)
2026-07-03 10:55:44 +08:00
非法操作 e0b5ecc50f fix: Notion sync empty state width in knowledge creation (#38321)
(cherry picked from commit 3d10f6c510)
2026-07-03 10:55:44 +08:00
Manan Bansaland非法操作 a83c7e89e0 fix(api): tolerate legacy service_api end-user type on load (#38271)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 41fb484491)
2026-07-03 10:55:44 +08:00
yyhand非法操作 7a4252b3de fix(web): fill dataset create layout height (#38308)
(cherry picked from commit 6ec56e7bd1)
2026-07-02 16:25:42 +08:00
hjlarry 5c3dd25b32 fix: repair main nav hotfix backport
(cherry picked from commit 0bd359bb8f)
2026-07-02 15:25:06 +08:00
wangxiaoleiand非法操作 30c8e9b08d fix: Working outside of application context. (#38300)
(cherry picked from commit 826b259d9f)
2026-07-02 15:07:12 +08:00
Joeland非法操作 3e3a16feaf fix: web detail adjustment before release (#38296)
Co-authored-by: Jingyi-Dify <jingyi.qi@dify.ai>
(cherry picked from commit 0bd359bb8f)
2026-07-02 15:07:12 +08:00
hjlarry 0e1fad88c0 fix: avoid profile query in public web app chat
(cherry picked from commit 52c106b532)
2026-07-01 14:05:02 +08:00
yyhand非法操作 f442559543 fix: prevent exiting toasts from blocking page clicks (#38063)
(cherry picked from commit 484633d261)
2026-07-01 14:05:02 +08:00
Bond Zhuand非法操作 2a63f26796 fix(workflow): guard on_tool_execution stdout traces behind DEBUG (#38200)
(cherry picked from commit 03d59dba47)
2026-07-01 14:05:02 +08:00
非法操作 7e325b2ff2 fix: debug plugin permission setting not work (#38197)
(cherry picked from commit 4303103304)
2026-07-01 14:05:02 +08:00
Jingyiand非法操作 26487d19b4 fix: handle integration marketplace install callbacks (#38236)
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
(cherry picked from commit f816ae2e95)
2026-07-01 14:05:02 +08:00
非法操作 71a936e89a fix: editor can view the logs (#38165)
(cherry picked from commit fa1ac75922)
2026-07-01 14:05:02 +08:00
非法操作 f2c19b456b fix: editor should not query billing subscriptions (#38157)
(cherry picked from commit 34f62e7df6)
2026-07-01 14:05:02 +08:00
Manan Bansaland非法操作 2d3da2a49f fix(web): keep HITL input-field save button visible in the edit dialog (#38007)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 267b34caaf)
2026-07-01 14:05:02 +08:00
非法操作 11b393db9f fix: agent app log detail modal not display well (#38014)
(cherry picked from commit 23917c7b3e)
2026-07-01 14:05:02 +08:00
非法操作 9a01b16d6c fix: plugin installation task popover layout when some failed too long (#38000)
(cherry picked from commit e22fd9efd6)
2026-07-01 14:05:02 +08:00
120 changed files with 4463 additions and 2156 deletions
@@ -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
+6 -3
View File
@@ -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 = (
+12 -1
View File
@@ -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,
+199 -78
View File
@@ -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]:
+12
View File
@@ -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"""
+1
View File
@@ -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",
+97 -18
View File
@@ -50,9 +50,26 @@ class QuotaReleaseResult(TypedDict):
released: int
class QuotaBalanceResult(TypedDict):
available: int
reserved: int
quota: int
usage: int
class QuotaConsumeCappedResult(TypedDict):
deducted: int
available: int
reserved: int
quota: int
usage: int
_quota_reserve_adapter = TypeAdapter(QuotaReserveResult)
_quota_commit_adapter = TypeAdapter(QuotaCommitResult)
_quota_release_adapter = TypeAdapter(QuotaReleaseResult)
_quota_balance_adapter = TypeAdapter(QuotaBalanceResult)
_quota_consume_capped_adapter = TypeAdapter(QuotaConsumeCappedResult)
class _TenantFeatureQuota(TypedDict):
@@ -176,6 +193,7 @@ class DismissNotificationDict(TypedDict):
class BillingService:
base_url = os.environ.get("BILLING_API_URL", "BILLING_API_URL")
quota_base_url = os.environ.get("BILLING_QUOTA_API_URL") or base_url
secret_key = os.environ.get("BILLING_API_SECRET_KEY", "BILLING_API_SECRET_KEY")
compliance_download_rate_limiter = RateLimiter("compliance_download_rate_limiter", 4, 60)
@@ -215,12 +233,18 @@ class BillingService:
def get_quota_info(cls, tenant_id: str) -> TenantFeatureQuotaInfo:
params = {"tenant_id": tenant_id}
return _tenant_feature_quota_info_adapter.validate_python(
cls._send_request("GET", "/quota/info", params=params)
cls._send_quota_request("GET", "/quota/info", params=params)
)
@classmethod
def quota_reserve(
cls, tenant_id: str, feature_key: str, request_id: str, amount: int = 1, meta: dict | None = None
cls,
tenant_id: str,
feature_key: str,
request_id: str,
amount: int = 1,
meta: dict | None = None,
bucket: str = "",
) -> QuotaReserveResult:
"""Reserve quota before task execution."""
payload: dict = {
@@ -229,13 +253,21 @@ class BillingService:
"request_id": request_id,
"amount": amount,
}
if bucket:
payload["bucket"] = bucket
if meta:
payload["meta"] = meta
return _quota_reserve_adapter.validate_python(cls._send_request("POST", "/quota/reserve", json=payload))
return _quota_reserve_adapter.validate_python(cls._send_quota_request("POST", "/quota/reserve", json=payload))
@classmethod
def quota_commit(
cls, tenant_id: str, feature_key: str, reservation_id: str, actual_amount: int, meta: dict | None = None
cls,
tenant_id: str,
feature_key: str,
reservation_id: str,
actual_amount: int,
meta: dict | None = None,
bucket: str = "",
) -> QuotaCommitResult:
"""Commit a reservation with actual consumption."""
payload: dict = {
@@ -244,23 +276,57 @@ class BillingService:
"reservation_id": reservation_id,
"actual_amount": actual_amount,
}
if bucket:
payload["bucket"] = bucket
if meta:
payload["meta"] = meta
return _quota_commit_adapter.validate_python(cls._send_request("POST", "/quota/commit", json=payload))
return _quota_commit_adapter.validate_python(cls._send_quota_request("POST", "/quota/commit", json=payload))
@classmethod
def quota_release(cls, tenant_id: str, feature_key: str, reservation_id: str) -> QuotaReleaseResult:
def quota_release(
cls, tenant_id: str, feature_key: str, reservation_id: str, bucket: str = ""
) -> QuotaReleaseResult:
"""Release a reservation (cancel, return frozen quota)."""
return _quota_release_adapter.validate_python(
cls._send_request(
"POST",
"/quota/release",
json={
"tenant_id": tenant_id,
"feature_key": feature_key,
"reservation_id": reservation_id,
},
)
payload = {
"tenant_id": tenant_id,
"feature_key": feature_key,
"reservation_id": reservation_id,
}
if bucket:
payload["bucket"] = bucket
return _quota_release_adapter.validate_python(cls._send_quota_request("POST", "/quota/release", json=payload))
@classmethod
def quota_get_balance(cls, tenant_id: str, feature_key: str, bucket: str = "") -> QuotaBalanceResult:
"""Get quota balance for a feature bucket."""
params = {"tenant_id": tenant_id, "feature_key": feature_key}
if bucket:
params["bucket"] = bucket
return _quota_balance_adapter.validate_python(cls._send_quota_request("GET", "/quota/balance", params=params))
@classmethod
def quota_consume_capped(
cls,
tenant_id: str,
feature_key: str,
request_id: str,
amount: int,
meta: dict | None = None,
bucket: str = "",
) -> QuotaConsumeCappedResult:
"""Consume up to the available quota and return the actual deducted amount."""
payload: dict = {
"tenant_id": tenant_id,
"feature_key": feature_key,
"request_id": request_id,
"amount": amount,
}
if bucket:
payload["bucket"] = bucket
if meta:
payload["meta"] = meta
return _quota_consume_capped_adapter.validate_python(
cls._send_quota_request("POST", "/quota/consume-capped", json=payload)
)
@classmethod
@@ -334,6 +400,12 @@ class BillingService:
params = {"tenant_id": tenant_id, "feature_key": feature_key}
return cls._send_request("GET", "/billing/tenant_feature_plan/usage", params=params)
@classmethod
def _send_quota_request(
cls, method: Literal["GET", "POST", "DELETE", "PUT"], endpoint: str, json=None, params=None
):
return cls._send_request(method, endpoint, json=json, params=params, base_url=cls.quota_base_url)
@classmethod
@retry(
wait=wait_fixed(2),
@@ -341,10 +413,17 @@ class BillingService:
retry=retry_if_exception_type(httpx.RequestError),
reraise=True,
)
def _send_request(cls, method: Literal["GET", "POST", "DELETE", "PUT"], endpoint: str, json=None, params=None):
def _send_request(
cls,
method: Literal["GET", "POST", "DELETE", "PUT"],
endpoint: str,
json=None,
params=None,
base_url: str | None = None,
):
headers = {"Content-Type": "application/json", "Billing-Api-Secret-Key": cls.secret_key}
url = f"{cls.base_url}{endpoint}"
url = f"{base_url or cls.base_url}{endpoint}"
response = _http_client.request(method, url, json=json, params=params, headers=headers, follow_redirects=True)
if method == "GET" and response.status_code != httpx.codes.OK:
raise ValueError("Unable to retrieve billing information. Please try again later or contact support.")
+110 -6
View File
@@ -7,6 +7,8 @@ from piling up database transactions while preserving cross-tenant concurrency.
import logging
from collections.abc import Callable
from dataclasses import dataclass
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -21,11 +23,37 @@ from models.enums import ProviderQuotaType
logger = logging.getLogger(__name__)
FEATURE_KEY_CREDIT_POOL = "credit_pool"
CREDIT_POOL_TENANT_LOCK_TIMEOUT_SECONDS = 10
CREDIT_POOL_TENANT_LOCK_BLOCKING_TIMEOUT_SECONDS = 5
@dataclass(frozen=True)
class CreditPoolBalance:
tenant_id: str
pool_type: str
quota_limit: int
quota_used: int
@property
def remaining_credits(self) -> int:
if self.quota_limit == -1:
return -1
return max(0, self.quota_limit - self.quota_used)
def has_sufficient_credits(self, required_credits: int) -> bool:
return self.quota_limit == -1 or self.remaining_credits >= required_credits
class CreditPoolService:
@staticmethod
def _normalize_pool_type(pool_type: str | ProviderQuotaType) -> str:
return pool_type.value if isinstance(pool_type, ProviderQuotaType) else str(pool_type)
@staticmethod
def _use_billing_quota() -> bool:
return bool(dify_config.BILLING_ENABLED)
@staticmethod
def _get_tenant_lock_key(tenant_id: str) -> str:
return f"credit_pool:tenant:{tenant_id}:deduct_lock"
@@ -79,14 +107,32 @@ class CreditPoolService:
return credit_pool
@classmethod
def get_pool(cls, tenant_id: str, pool_type: str = "trial") -> TenantCreditPool | None:
def get_pool(
cls, tenant_id: str, pool_type: str | ProviderQuotaType = "trial"
) -> TenantCreditPool | CreditPoolBalance | None:
"""get tenant credit pool"""
normalized_pool_type = cls._normalize_pool_type(pool_type)
if cls._use_billing_quota():
from services.billing_service import BillingService
balance = BillingService.quota_get_balance(
tenant_id=tenant_id,
feature_key=FEATURE_KEY_CREDIT_POOL,
bucket=normalized_pool_type,
)
return CreditPoolBalance(
tenant_id=tenant_id,
pool_type=normalized_pool_type,
quota_limit=balance["quota"],
quota_used=balance["usage"],
)
with session_factory.get_session_maker().begin() as session:
return session.scalar(
select(TenantCreditPool)
.where(
TenantCreditPool.tenant_id == tenant_id,
TenantCreditPool.pool_type == pool_type,
TenantCreditPool.pool_type == normalized_pool_type,
)
.limit(1)
)
@@ -102,7 +148,7 @@ class CreditPoolService:
pool = cls.get_pool(tenant_id, pool_type)
if not pool:
return False
return pool.remaining_credits >= credits_required
return pool.has_sufficient_credits(credits_required)
@classmethod
def check_and_deduct_credits(
@@ -114,10 +160,54 @@ class CreditPoolService:
"""Deduct exactly the requested credits or raise without mutating the pool."""
if credits_required <= 0:
return 0
normalized_pool_type = cls._normalize_pool_type(pool_type)
if cls._use_billing_quota():
from services.billing_service import BillingService
request_id = str(uuid4())
result = BillingService.quota_reserve(
tenant_id=tenant_id,
feature_key=FEATURE_KEY_CREDIT_POOL,
bucket=normalized_pool_type,
request_id=request_id,
amount=credits_required,
meta={"source": "credit_pool.check_and_deduct"},
)
reservation_id = result.get("reservation_id", "")
if not reservation_id:
raise QuotaExceededError("Insufficient credits remaining")
try:
BillingService.quota_commit(
tenant_id=tenant_id,
feature_key=FEATURE_KEY_CREDIT_POOL,
bucket=normalized_pool_type,
reservation_id=reservation_id,
actual_amount=credits_required,
meta={"source": "credit_pool.check_and_deduct"},
)
except Exception:
try:
BillingService.quota_release(
tenant_id=tenant_id,
feature_key=FEATURE_KEY_CREDIT_POOL,
bucket=normalized_pool_type,
reservation_id=reservation_id,
)
except Exception:
logger.warning(
"Failed to release reserved credit pool quota, tenant_id=%s, pool_type=%s, reservation_id=%s",
tenant_id,
normalized_pool_type,
reservation_id,
exc_info=True,
)
raise
return credits_required
def deduct() -> int:
with session_factory.get_session_maker().begin() as session:
pool = cls._get_locked_pool(session=session, tenant_id=tenant_id, pool_type=pool_type)
pool = cls._get_locked_pool(session=session, tenant_id=tenant_id, pool_type=normalized_pool_type)
if not pool:
raise QuotaExceededError("Credit pool not found")
@@ -148,12 +238,26 @@ class CreditPoolService:
"""Deduct up to the available balance and return the actual deducted credits."""
if credits_required <= 0:
return 0
normalized_pool_type = cls._normalize_pool_type(pool_type)
if cls._use_billing_quota():
from services.billing_service import BillingService
result = BillingService.quota_consume_capped(
tenant_id=tenant_id,
feature_key=FEATURE_KEY_CREDIT_POOL,
bucket=normalized_pool_type,
request_id=str(uuid4()),
amount=credits_required,
meta={"source": "credit_pool.deduct_capped"},
)
return result["deducted"]
def deduct() -> int:
with session_factory.get_session_maker().begin() as session:
pool = cls._get_locked_pool(session=session, tenant_id=tenant_id, pool_type=pool_type)
pool = cls._get_locked_pool(session=session, tenant_id=tenant_id, pool_type=normalized_pool_type)
if not pool:
logger.warning("Credit pool not found, tenant_id=%s, pool_type=%s", tenant_id, pool_type)
logger.warning("Credit pool not found, tenant_id=%s, pool_type=%s", tenant_id, normalized_pool_type)
return 0
deducted_credits = min(credits_required, pool.remaining_credits)
+1 -1
View File
@@ -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"]
@@ -73,6 +73,23 @@ class TestBillingServiceSendRequest:
assert call_args[1]["headers"]["Billing-Api-Secret-Key"] == "test-secret-key"
assert call_args[1]["headers"]["Content-Type"] == "application/json"
def test_send_request_with_base_url_override(self, mock_httpx_request, mock_billing_config):
"""Quota APIs can use the new billing service without changing legacy billing calls."""
# Arrange
expected_response = {"result": "success"}
mock_response = MagicMock()
mock_response.status_code = httpx.codes.OK
mock_response.json.return_value = expected_response
mock_httpx_request.return_value = mock_response
# Act
result = BillingService._send_request("GET", "/quota/balance", base_url="https://quota.example.com")
# Assert
assert result == expected_response
call_args = mock_httpx_request.call_args
assert call_args[0][1] == "https://quota.example.com/quota/balance"
@pytest.mark.parametrize(
"status_code", [httpx.codes.NOT_FOUND, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.BAD_REQUEST]
)
@@ -393,6 +410,20 @@ class TestBillingServiceSubscriptionInfo:
params={"tenant_id": tenant_id},
)
def test_quota_get_balance_uses_quota_request(self):
tenant_id = "tenant-123"
with patch.object(BillingService, "_send_quota_request") as mock_send_quota_request:
mock_send_quota_request.return_value = {"quota": "200", "usage": "6", "available": "194", "reserved": "0"}
result = BillingService.quota_get_balance(tenant_id, "credit_pool", bucket="trial")
assert result == {"quota": 200, "usage": 6, "available": 194, "reserved": 0}
mock_send_quota_request.assert_called_once_with(
"GET",
"/quota/balance",
params={"tenant_id": tenant_id, "feature_key": "credit_pool", "bucket": "trial"},
)
def test_get_knowledge_rate_limit_with_defaults(self, mock_send_request):
"""Test knowledge rate limit retrieval with default values."""
# Arrange
@@ -518,19 +549,20 @@ class TestBillingServiceUsageCalculation:
assert result == expected_response
mock_send_request.assert_called_once_with("GET", "/tenant-feature-usage/info", params={"tenant_id": tenant_id})
def test_get_quota_info(self, mock_send_request):
def test_get_quota_info(self):
"""Test retrieval of quota info from new endpoint."""
# Arrange
tenant_id = "tenant-123"
expected_response = {"trigger_event": {"limit": 100, "usage": 30}, "api_rate_limit": {"limit": -1, "usage": 0}}
mock_send_request.return_value = expected_response
with patch.object(BillingService, "_send_quota_request") as mock_send_quota_request:
mock_send_quota_request.return_value = expected_response
# Act
result = BillingService.get_quota_info(tenant_id)
# Act
result = BillingService.get_quota_info(tenant_id)
# Assert
assert result == expected_response
mock_send_request.assert_called_once_with("GET", "/quota/info", params={"tenant_id": tenant_id})
mock_send_quota_request.assert_called_once_with("GET", "/quota/info", params={"tenant_id": tenant_id})
def test_update_tenant_feature_plan_usage_positive_delta(self, mock_send_request):
"""Test updating tenant feature usage with positive delta (adding credits)."""
@@ -614,7 +646,7 @@ class TestBillingServiceQuotaOperations:
@pytest.fixture
def mock_send_request(self):
with patch.object(BillingService, "_send_request") as mock:
with patch.object(BillingService, "_send_quota_request") as mock:
yield mock
def test_quota_reserve_success(self, mock_send_request):
@@ -652,6 +684,16 @@ class TestBillingServiceQuotaOperations:
call_json = mock_send_request.call_args[1]["json"]
assert call_json["meta"] == {"source": "webhook"}
def test_quota_reserve_with_bucket(self, mock_send_request):
mock_send_request.return_value = {"reservation_id": "rid-2", "available": 98, "reserved": 1}
BillingService.quota_reserve(
tenant_id="t1", feature_key="credit_pool", request_id="req-2", amount=1, bucket="trial"
)
call_json = mock_send_request.call_args[1]["json"]
assert call_json["bucket"] == "trial"
def test_quota_commit_success(self, mock_send_request):
expected = {"available": 98, "reserved": 0, "refunded": 0}
mock_send_request.return_value = expected
@@ -696,6 +738,20 @@ class TestBillingServiceQuotaOperations:
call_json = mock_send_request.call_args[1]["json"]
assert call_json["meta"] == {"reason": "partial"}
def test_quota_commit_with_bucket(self, mock_send_request):
mock_send_request.return_value = {"available": 97, "reserved": 0, "refunded": 0}
BillingService.quota_commit(
tenant_id="t1",
feature_key="credit_pool",
reservation_id="rid-1",
actual_amount=1,
bucket="paid",
)
call_json = mock_send_request.call_args[1]["json"]
assert call_json["bucket"] == "paid"
def test_quota_release_success(self, mock_send_request):
expected = {"available": 100, "reserved": 0, "released": 1}
mock_send_request.return_value = expected
@@ -720,6 +776,64 @@ class TestBillingServiceQuotaOperations:
assert result["released"] == 1
assert isinstance(result["released"], int)
def test_quota_release_with_bucket(self, mock_send_request):
mock_send_request.return_value = {"available": 100, "reserved": 0, "released": 1}
BillingService.quota_release(tenant_id="t1", feature_key="credit_pool", reservation_id="rid-1", bucket="trial")
call_json = mock_send_request.call_args[1]["json"]
assert call_json["bucket"] == "trial"
def test_quota_consume_capped_success(self, mock_send_request):
mock_send_request.return_value = {
"deducted": "2",
"available": "8",
"reserved": "0",
"quota": "10",
"usage": "2",
}
result = BillingService.quota_consume_capped(
tenant_id="t1",
feature_key="credit_pool",
request_id="req-1",
amount=5,
bucket="paid",
meta={"source": "test"},
)
assert result == {"deducted": 2, "available": 8, "reserved": 0, "quota": 10, "usage": 2}
mock_send_request.assert_called_once_with(
"POST",
"/quota/consume-capped",
json={
"tenant_id": "t1",
"feature_key": "credit_pool",
"request_id": "req-1",
"amount": 5,
"bucket": "paid",
"meta": {"source": "test"},
},
)
def test_send_quota_request_uses_quota_base_url(self):
with (
patch.object(BillingService, "quota_base_url", "https://quota.example.com/v1"),
patch.object(BillingService, "_send_request") as mock_send_request,
):
mock_send_request.return_value = {"ok": True}
result = BillingService._send_quota_request("GET", "/quota/info", params={"tenant_id": "t1"})
assert result == {"ok": True}
mock_send_request.assert_called_once_with(
"GET",
"/quota/info",
json=None,
params={"tenant_id": "t1"},
base_url="https://quota.example.com/v1",
)
def test_get_quota_info_coerces_string_to_int(self, mock_send_request):
"""Test that TypeAdapter coerces string values to int for get_quota_info."""
mock_send_request.return_value = {
@@ -1,7 +1,7 @@
from collections.abc import Generator
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from unittest.mock import ANY, MagicMock, patch
from uuid import uuid4
import pytest
@@ -15,6 +15,8 @@ from models.enums import ProviderQuotaType
from services.credit_pool_service import (
CREDIT_POOL_TENANT_LOCK_BLOCKING_TIMEOUT_SECONDS,
CREDIT_POOL_TENANT_LOCK_TIMEOUT_SECONDS,
FEATURE_KEY_CREDIT_POOL,
CreditPoolBalance,
CreditPoolService,
)
@@ -64,6 +66,12 @@ def _make_redis_lock() -> MagicMock:
return lock
@pytest.fixture(autouse=True)
def _disable_billing_quota_by_default() -> Generator[None, None, None]:
with patch("services.credit_pool_service.dify_config.BILLING_ENABLED", False):
yield
def test_get_pool_uses_configured_session_factory_without_flask_app_context() -> None:
engine, tenant_id, _ = _create_engine_with_pool(quota_limit=10, quota_used=2)
@@ -75,6 +83,13 @@ def test_get_pool_uses_configured_session_factory_without_flask_app_context() ->
assert pool.quota_used == 2
def test_credit_pool_balance_unlimited_remaining_and_sufficiency() -> None:
pool = CreditPoolBalance(tenant_id="tenant-1", pool_type="paid", quota_limit=-1, quota_used=999)
assert pool.remaining_credits == -1
assert pool.has_sufficient_credits(10_000)
def test_check_and_deduct_credits_deducts_exact_amount_when_sufficient() -> None:
engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2)
@@ -224,7 +239,7 @@ def test_check_and_deduct_credits_uses_tenant_redis_lock_before_db_deduction() -
)
redis_lock.acquire.assert_called_once_with(blocking=True)
redis_lock.release.assert_called_once_with()
get_locked_pool.assert_called_once_with(session=session, tenant_id=tenant_id, pool_type=ProviderQuotaType.TRIAL)
get_locked_pool.assert_called_once_with(session=session, tenant_id=tenant_id, pool_type="trial")
def test_deduct_credits_capped_uses_tenant_redis_lock_before_db_deduction() -> None:
@@ -254,7 +269,152 @@ def test_deduct_credits_capped_uses_tenant_redis_lock_before_db_deduction() -> N
)
redis_lock.acquire.assert_called_once_with(blocking=True)
redis_lock.release.assert_called_once_with()
get_locked_pool.assert_called_once_with(session=session, tenant_id=tenant_id, pool_type=ProviderQuotaType.PAID)
get_locked_pool.assert_called_once_with(session=session, tenant_id=tenant_id, pool_type="paid")
def test_get_pool_uses_billing_quota_balance_when_enabled() -> None:
tenant_id = "tenant-1"
with (
patch("services.credit_pool_service.dify_config.BILLING_ENABLED", True),
patch("services.billing_service.BillingService.quota_get_balance") as quota_get_balance,
):
quota_get_balance.return_value = {"quota": 1000, "usage": 250, "available": 750, "reserved": 0}
pool = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type=ProviderQuotaType.PAID)
assert isinstance(pool, CreditPoolBalance)
assert pool.quota_limit == 1000
assert pool.quota_used == 250
assert pool.remaining_credits == 750
quota_get_balance.assert_called_once_with(
tenant_id=tenant_id,
feature_key=FEATURE_KEY_CREDIT_POOL,
bucket="paid",
)
def test_check_and_deduct_credits_uses_billing_reserve_and_commit_when_enabled() -> None:
tenant_id = "tenant-1"
with (
patch("services.credit_pool_service.dify_config.BILLING_ENABLED", True),
patch("services.billing_service.BillingService.quota_reserve") as quota_reserve,
patch("services.billing_service.BillingService.quota_commit") as quota_commit,
patch("services.billing_service.BillingService.quota_release") as quota_release,
):
quota_reserve.return_value = {"reservation_id": "reservation-1", "available": 7, "reserved": 3}
result = CreditPoolService.check_and_deduct_credits(
tenant_id=tenant_id,
credits_required=3,
pool_type=ProviderQuotaType.TRIAL,
)
assert result == 3
quota_reserve.assert_called_once_with(
tenant_id=tenant_id,
feature_key=FEATURE_KEY_CREDIT_POOL,
bucket="trial",
request_id=ANY,
amount=3,
meta={"source": "credit_pool.check_and_deduct"},
)
quota_commit.assert_called_once_with(
tenant_id=tenant_id,
feature_key=FEATURE_KEY_CREDIT_POOL,
bucket="trial",
reservation_id="reservation-1",
actual_amount=3,
meta={"source": "credit_pool.check_and_deduct"},
)
quota_release.assert_not_called()
def test_check_and_deduct_credits_raises_when_billing_reserve_is_insufficient() -> None:
with (
patch("services.credit_pool_service.dify_config.BILLING_ENABLED", True),
patch("services.billing_service.BillingService.quota_reserve") as quota_reserve,
):
quota_reserve.return_value = {"reservation_id": "", "available": 1, "reserved": 0}
with pytest.raises(QuotaExceededError, match="Insufficient credits remaining"):
CreditPoolService.check_and_deduct_credits(tenant_id="tenant-1", credits_required=3)
def test_check_and_deduct_credits_releases_billing_reservation_when_commit_fails() -> None:
with (
patch("services.credit_pool_service.dify_config.BILLING_ENABLED", True),
patch("services.billing_service.BillingService.quota_reserve") as quota_reserve,
patch("services.billing_service.BillingService.quota_commit", side_effect=RuntimeError("commit failed")),
patch("services.billing_service.BillingService.quota_release") as quota_release,
):
quota_reserve.return_value = {"reservation_id": "reservation-1", "available": 7, "reserved": 3}
with pytest.raises(RuntimeError, match="commit failed"):
CreditPoolService.check_and_deduct_credits(tenant_id="tenant-1", credits_required=3)
quota_release.assert_called_once_with(
tenant_id="tenant-1",
feature_key=FEATURE_KEY_CREDIT_POOL,
bucket="trial",
reservation_id="reservation-1",
)
def test_check_and_deduct_credits_logs_when_billing_release_fails() -> None:
with (
patch("services.credit_pool_service.dify_config.BILLING_ENABLED", True),
patch("services.billing_service.BillingService.quota_reserve") as quota_reserve,
patch("services.billing_service.BillingService.quota_commit", side_effect=RuntimeError("commit failed")),
patch(
"services.billing_service.BillingService.quota_release", side_effect=RuntimeError("release failed")
) as quota_release,
patch("services.credit_pool_service.logger.warning") as logger_warning,
):
quota_reserve.return_value = {"reservation_id": "reservation-1", "available": 7, "reserved": 3}
with pytest.raises(RuntimeError, match="commit failed"):
CreditPoolService.check_and_deduct_credits(tenant_id="tenant-1", credits_required=3)
quota_release.assert_called_once_with(
tenant_id="tenant-1",
feature_key=FEATURE_KEY_CREDIT_POOL,
bucket="trial",
reservation_id="reservation-1",
)
logger_warning.assert_called_once()
assert logger_warning.call_args.args[3] == "reservation-1"
assert logger_warning.call_args.kwargs["exc_info"] is True
def test_deduct_credits_capped_uses_billing_consume_capped_when_enabled() -> None:
tenant_id = "tenant-1"
with (
patch("services.credit_pool_service.dify_config.BILLING_ENABLED", True),
patch("services.billing_service.BillingService.quota_consume_capped") as quota_consume_capped,
):
quota_consume_capped.return_value = {
"deducted": 2,
"available": 0,
"reserved": 0,
"quota": 10,
"usage": 10,
}
result = CreditPoolService.deduct_credits_capped(
tenant_id=tenant_id,
credits_required=5,
pool_type=ProviderQuotaType.PAID,
)
assert result == 2
quota_consume_capped.assert_called_once_with(
tenant_id=tenant_id,
feature_key=FEATURE_KEY_CREDIT_POOL,
bucket="paid",
request_id=ANY,
amount=5,
meta={"source": "credit_pool.deduct_capped"},
)
@pytest.mark.parametrize(
Generated
+2
View File
@@ -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]
-18
View File
@@ -310,11 +310,6 @@
"count": 1
}
},
"web/app/account/oauth/authorize/layout.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/account/oauth/authorize/page.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -3335,14 +3330,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 +3606,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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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()
})
})
+11 -17
View File
@@ -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}`,
}))
+24 -20
View File
@@ -6,6 +6,7 @@ import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import { GotoAnything } from '@/app/components/goto-anything'
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
import MainNavLayout from '@/app/components/main-nav/layout'
import { NextRouteStateBridge } from '@/app/components/next-route-state'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
@@ -21,34 +22,37 @@ import { RoleRouteGuard } from './role-route-guard'
export default async function Layout({ children }: { children: ReactNode }) {
return (
<>
<React.Fragment>
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<NextRouteStateBridge>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<MainNavLayout>
<RoleRouteGuard>
{children}
</RoleRouteGuard>
</MainNavLayout>
<InSiteMessageNotification />
<PartnerStack />
<ReadmePanel />
<GotoAnything />
<WorkflowGeneratorMount />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
<div className="flex h-full flex-col overflow-hidden">
<MaintenanceNotice />
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<MainNavLayout>
<RoleRouteGuard>
{children}
</RoleRouteGuard>
</MainNavLayout>
<InSiteMessageNotification />
<PartnerStack />
<ReadmePanel />
<GotoAnything />
<WorkflowGeneratorMount />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</div>
</NextRouteStateBridge>
</CommonLayoutHydrationBoundary>
<Zendesk />
</>
</React.Fragment>
)
}
+50 -2
View File
@@ -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 -19
View File
@@ -1,10 +1,10 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import { CommonLayoutHydrationBoundary } from '@/app/(commonLayout)/hydration-boundary'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import { AppContextProvider } from '@/context/app-context-provider'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
@@ -12,30 +12,32 @@ import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import Header from './header'
const Layout = async ({ children }: { children: ReactNode }) => {
export default async function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<React.Fragment>
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<HeaderWrapper>
<Header />
</HeaderWrapper>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-components-panel-bg">
{children}
</div>
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
<div className="flex h-full flex-col overflow-hidden bg-background-body">
<MaintenanceNotice />
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<HeaderWrapper>
<Header />
</HeaderWrapper>
<div className="relative flex h-0 min-h-0 shrink-0 grow flex-col overflow-y-auto bg-components-panel-bg">
{children}
</div>
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</div>
</CommonLayoutHydrationBoundary>
</>
</React.Fragment>
)
}
export default Layout
+25 -45
View File
@@ -1,60 +1,40 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import Loading from '@/app/components/base/loading'
import Header from '@/app/signin/_header'
import { AppContextProvider } from '@/context/app-context-provider'
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useDocumentTitle from '@/hooks/use-document-title'
export default function SignInLayout({ children }: any) {
type Props = {
children: ReactNode
}
const copyrightYear = new Date().getFullYear()
export default function OAuthAuthorizeLayout({ children }: Props) {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
useDocumentTitle('')
// Probe login state. 401 stays as `error` (not thrown) so this layout can render
// the signin/oauth UI for unauthenticated users; other errors bubble to error.tsx.
// (When unauthenticated, service/base.ts's auto-redirect to /signin still fires.)
const { isPending, data: userResp, error } = useQuery({
...userProfileQueryOptions(),
throwOnError: err => !isLegacyBase401(err),
})
const isLoggedIn = !!userResp && !error
if (isPending) {
return (
<div className="flex min-h-screen w-full justify-center bg-background-default-burn">
<Loading />
</div>
)
}
return (
<>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
<Header />
<div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}>
<div className="flex flex-col md:w-[400px]">
{isLoggedIn
? (
<AppContextProvider>
{children}
</AppContextProvider>
)
: children}
</div>
<div className="flex min-h-screen w-full justify-center bg-background-default-burn p-6">
<div className="flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle">
<Header />
<div className="flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]">
<div className="flex flex-col md:w-[400px]">
{children}
</div>
{systemFeatures.branding.enabled === false && (
<div className="px-8 py-6 system-xs-regular text-text-tertiary">
©
{' '}
{new Date().getFullYear()}
{' '}
LangGenius, Inc. All rights reserved.
</div>
)}
</div>
{systemFeatures.branding.enabled === false && (
<div className="px-8 py-6 system-xs-regular text-text-tertiary">
©
{' '}
{copyrightYear}
{' '}
LangGenius, Inc. All rights reserved.
</div>
)}
</div>
</>
</div>
)
}
@@ -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,
+29 -2
View File
@@ -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}
+13 -4
View File
@@ -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}}',
+6 -1
View File
@@ -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(() => {
@@ -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()
+2 -2
View File
@@ -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>
)
}
+24 -28
View File
@@ -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>
@@ -4,10 +4,18 @@ import { useLanguage } from '@/app/components/header/account-setting/model-provi
import { NOTICE_I18N } from '@/i18n-config/language'
import MaintenanceNotice from '../maintenance-notice'
const mockEnv = vi.hoisted(() => ({
NEXT_PUBLIC_MAINTENANCE_NOTICE: 'true',
}))
vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({
X: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
}))
vi.mock('@/env', () => ({
env: mockEnv,
}))
vi.mock(
'@/app/components/header/account-setting/model-provider-page/hooks',
() => ({
@@ -44,6 +52,7 @@ describe('MaintenanceNotice', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mockEnv.NEXT_PUBLIC_MAINTENANCE_NOTICE = 'true'
vi.mocked(useLanguage).mockReturnValue('en_US')
setNoticeHref('#')
})
@@ -71,6 +80,14 @@ describe('MaintenanceNotice', () => {
const { container } = render(<MaintenanceNotice />)
expect(container.firstChild).toBeNull()
})
it('should not render when the notice env flag is disabled', () => {
mockEnv.NEXT_PUBLIC_MAINTENANCE_NOTICE = ''
const { container } = render(<MaintenanceNotice />)
expect(container.firstChild).toBeNull()
})
})
describe('User Interactions', () => {
@@ -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()
}}
@@ -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}
/>
)
}
@@ -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" />}
@@ -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}
/>
)
@@ -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" />}
@@ -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}
/>
@@ -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>
@@ -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)}
@@ -1,11 +1,21 @@
'use client'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { X } from '@/app/components/base/icons/src/vender/line/general'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { env } from '@/env'
import { NOTICE_I18N } from '@/i18n-config/language'
import { useHideMaintenanceNotice } from './storage'
const MaintenanceNotice = () => {
function MaintenanceNotice() {
if (!env.NEXT_PUBLIC_MAINTENANCE_NOTICE)
return null
return <MaintenanceNoticeContent />
}
function MaintenanceNoticeContent() {
const { t } = useTranslation()
const locale = useLanguage()
@@ -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'],
+5 -2
View File
@@ -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}
+21
View File
@@ -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}
+2 -1
View File
@@ -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'],
@@ -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>
)
}
@@ -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}
@@ -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,
}
+114 -6
View File
@@ -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)
@@ -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,
+3 -10
View File
@@ -2,14 +2,13 @@
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { FC, ReactNode } from 'react'
import type { ReactNode } from 'react'
import type { ICurrentWorkspace, LangGeniusVersionResponse } from '@/models/common'
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo } from 'react'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
import { ZENDESK_FIELD_IDS } from '@/config'
import {
AppContext,
@@ -18,7 +17,6 @@ import {
userProfilePlaceholder,
useSelector,
} from '@/context/app-context'
import { env } from '@/env'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useWorkspacePermissionKeys } from '@/service/access-control/use-permission-keys'
@@ -66,7 +64,7 @@ const normalizeCurrentWorkspace = (workspace?: PostWorkspacesCurrentResponse): I
}
}
export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) => {
export function AppContextProvider({ children }: AppContextProviderProps) {
const queryClient = useQueryClient()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
@@ -192,12 +190,7 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
workspacePermissionKeys: workspacePermissionKeysQuery.data?.workspace.permission_keys ?? emptyWorkspacePermissionKeys,
}}
>
<div className="flex h-full flex-col overflow-hidden">
{env.NEXT_PUBLIC_MAINTENANCE_NOTICE && <MaintenanceNotice />}
<div className="relative flex h-0 min-h-0 grow flex-col overflow-hidden bg-background-body">
{children}
</div>
</div>
{children}
</AppContext.Provider>
)
}
+8
View File
@@ -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
+16 -4
View File
@@ -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,
+30
View File
@@ -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')
})
})
+16 -6
View File
@@ -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 }
}
+1
View File
@@ -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": "أرصدة",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",

Some files were not shown because too many files have changed in this diff Show More