Compare commits
162
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d1e583978 | ||
|
|
825b749501 | ||
|
|
fc5dcb10d9 | ||
|
|
50915d4b57 | ||
|
|
7c20436611 | ||
|
|
bde17dc8b0 | ||
|
|
a489dd48cf | ||
|
|
f71e31351d | ||
|
|
2a0661a769 | ||
|
|
00fe96325d | ||
|
|
a3c18c561e | ||
|
|
2a12c2fa29 | ||
|
|
84d9399093 | ||
|
|
041960d9c7 | ||
|
|
35a5bf21ee | ||
|
|
4b4b109bbb | ||
|
|
8e6eb0eaae | ||
|
|
fe67e094aa | ||
|
|
919a8090c3 | ||
|
|
d7e1b2a996 | ||
|
|
3f99f16bd0 | ||
|
|
2da74263d7 | ||
|
|
bc8ae85b88 | ||
|
|
e3a53c8822 | ||
|
|
0ad1631c4a | ||
|
|
513ea8b5fa | ||
|
|
2107c9b9c7 | ||
|
|
6e34915c7a | ||
|
|
cea92da293 | ||
|
|
5a6532c74e | ||
|
|
e6cc04fc36 | ||
|
|
3738920820 | ||
|
|
e98bbea93a | ||
|
|
a86d413ed9 | ||
|
|
e9c1147146 | ||
|
|
9d922114d5 | ||
|
|
ccdb1249d2 | ||
|
|
831acbb34d | ||
|
|
8239658259 | ||
|
|
6ac0aeeb2a | ||
|
|
7c8037f496 | ||
|
|
5b14e29c08 | ||
|
|
566359fefc | ||
|
|
b65c51f5f5 | ||
|
|
68a5c97baf | ||
|
|
f60a42f781 | ||
|
|
54a334303f | ||
|
|
6190202a4e | ||
|
|
f0961f7484 | ||
|
|
a002efaac5 | ||
|
|
d7aa59e72d | ||
|
|
75a9a67947 | ||
|
|
10495a7103 | ||
|
|
de146c4efe | ||
|
|
6a7aa2222c | ||
|
|
aeafe69626 | ||
|
|
1fc1ac16d0 | ||
|
|
9fb67fade9 | ||
|
|
de56f03784 | ||
|
|
558d0a3af3 | ||
|
|
301a32e7c0 | ||
|
|
cff84914bd | ||
|
|
833bdc4b70 | ||
|
|
e50daa9081 | ||
|
|
01f03c5f7a | ||
|
|
f3cbfacd3e | ||
|
|
268b8e91d2 | ||
|
|
0a31eb298b | ||
|
|
18a888b6bb | ||
|
|
49634eb302 | ||
|
|
9daa6a876b | ||
|
|
828a6f02dd | ||
|
|
c68e2bdc60 | ||
|
|
d9ab09c303 | ||
|
|
a6bc237d34 | ||
|
|
2df867ad1d | ||
|
|
5ffe4d8ab7 | ||
|
|
ef050944cf | ||
|
|
225c6e5d30 | ||
|
|
0f77618fcc | ||
|
|
018640d069 | ||
|
|
1f8a8dfc4d | ||
|
|
ca9a771985 | ||
|
|
f6e5cdaf28 | ||
|
|
2c88ae3ea2 | ||
|
|
ca36ba5585 | ||
|
|
8a0438825c | ||
|
|
737ce40c0d | ||
|
|
23946300ae | ||
|
|
9ea92a2528 | ||
|
|
7b74b0434c | ||
|
|
4aaf347798 | ||
|
|
63673e2243 | ||
|
|
3c512de30d | ||
|
|
66e3afb850 | ||
|
|
9f43e962ef | ||
|
|
05e06c88ee | ||
|
|
1753d256c2 | ||
|
|
c684679ce9 | ||
|
|
2231289f6a | ||
|
|
cbd48e9d7e | ||
|
|
f6b19fbfa8 | ||
|
|
518a4a7226 | ||
|
|
6444ff8d93 | ||
|
|
f60575bd1d | ||
|
|
5bd7664d1e | ||
|
|
e6d0e71f89 | ||
|
|
1e539101e9 | ||
|
|
6732cadc0f | ||
|
|
551885dc10 | ||
|
|
ccf62b17f1 | ||
|
|
942b7a4796 | ||
|
|
7481cd2e7f | ||
|
|
ab249cf248 | ||
|
|
c4ee6d3c77 | ||
|
|
c2f23edc6a | ||
|
|
445c36c356 | ||
|
|
fcb0de7747 | ||
|
|
98869def82 | ||
|
|
bc1de7a5d0 | ||
|
|
1ffb081a05 | ||
|
|
4b658b2028 | ||
|
|
63dc57a70b | ||
|
|
532025c46b | ||
|
|
e26c85b07e | ||
|
|
5e6382b20e | ||
|
|
1f418ad5d2 | ||
|
|
cf98fc7ed2 | ||
|
|
28762fef0a | ||
|
|
f14b666127 | ||
|
|
2a43b5a168 | ||
|
|
5012d4643d | ||
|
|
fb1a9901d4 | ||
|
|
ee0a0a5af3 | ||
|
|
820eaa1a63 | ||
|
|
3d3ecb73f6 | ||
|
|
457247c159 | ||
|
|
3dbf6b00e3 | ||
|
|
9c36aed1f8 | ||
|
|
b0c8a791ba | ||
|
|
6a3dcd179a | ||
|
|
ee867d53ad | ||
|
|
a440659408 | ||
|
|
ad0b90e0d7 | ||
|
|
2529973c21 | ||
|
|
619920c1df | ||
|
|
e8679d1d23 | ||
|
|
5c99fd1b6b | ||
|
|
45d2f2761b | ||
|
|
7101bd46db | ||
|
|
897b79980a | ||
|
|
a9f78ea568 | ||
|
|
0857172091 | ||
|
|
d5e95cf989 | ||
|
|
eca1af1b9d | ||
|
|
f3f8eb655d | ||
|
|
34747ff0eb | ||
|
|
bb07109753 | ||
|
|
8a74f7e23f | ||
|
|
76e846382c | ||
|
|
3392cd8606 | ||
|
|
de4a2c6910 |
@@ -1,17 +1,8 @@
|
||||
import os
|
||||
from typing import Any, Literal, TypedDict, cast
|
||||
from urllib.parse import parse_qsl, quote_plus
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
from pydantic import (
|
||||
Field,
|
||||
NonNegativeFloat,
|
||||
NonNegativeInt,
|
||||
PositiveFloat,
|
||||
PositiveInt,
|
||||
computed_field,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt, computed_field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
from .cache.redis_config import RedisConfig
|
||||
@@ -417,22 +408,4 @@ class MiddlewareConfig(
|
||||
DatasetQueueMonitorConfig,
|
||||
MatrixoneConfig,
|
||||
):
|
||||
@model_validator(mode="after")
|
||||
def _validate_redis_urls_db_for_azure(self):
|
||||
"""Azure Managed Redis only supports db 0; reject non-zero db in Redis URLs."""
|
||||
if not self.REDIS_USE_AZURE_MANAGED_IDENTITY:
|
||||
return self
|
||||
|
||||
for url, name in (
|
||||
(self.CELERY_BROKER_URL, "CELERY_BROKER_URL"),
|
||||
(self.PUBSUB_REDIS_URL, "PUBSUB_REDIS_URL"),
|
||||
):
|
||||
if not url:
|
||||
continue
|
||||
db: str = _urlparse(url).path.lstrip("/") or "0"
|
||||
if db != "0":
|
||||
raise ValueError(
|
||||
f"Azure Managed Redis only supports db 0, but {name} uses db {db}. "
|
||||
"Please set the db index to 0 in your URL."
|
||||
)
|
||||
return self
|
||||
pass
|
||||
|
||||
+4
-32
@@ -1,4 +1,4 @@
|
||||
from pydantic import Field, NonNegativeInt, PositiveFloat, PositiveInt, field_validator, model_validator
|
||||
from pydantic import Field, NonNegativeInt, PositiveFloat, PositiveInt, field_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
@@ -42,13 +42,6 @@ class RedisConfig(BaseSettings):
|
||||
default=False,
|
||||
)
|
||||
|
||||
REDIS_USE_AZURE_MANAGED_IDENTITY: bool = Field(
|
||||
description="Use Azure Managed Identity (Entra ID) for Redis authentication."
|
||||
" When enabled, username/password are ignored and a token is acquired via DefaultAzureCredential."
|
||||
" Requires azure-identity and redis-entraid packages.",
|
||||
default=False,
|
||||
)
|
||||
|
||||
REDIS_SSL_CERT_REQS: str = Field(
|
||||
description="SSL certificate requirements (CERT_NONE, CERT_OPTIONAL, CERT_REQUIRED)",
|
||||
default="CERT_NONE",
|
||||
@@ -165,33 +158,12 @@ class RedisConfig(BaseSettings):
|
||||
REDIS_KEEPALIVE_INTERVAL: PositiveInt = Field(default=10, description="redis keepalive interval")
|
||||
REDIS_KEEPALIVE_COUNT: PositiveInt = Field(default=10, description="redis keepalive count")
|
||||
|
||||
@field_validator(
|
||||
"REDIS_SSL_CA_CERTS",
|
||||
"REDIS_SSL_CERTFILE",
|
||||
"REDIS_SSL_KEYFILE",
|
||||
"REDIS_MAX_CONNECTIONS",
|
||||
mode="before",
|
||||
)
|
||||
@field_validator("REDIS_MAX_CONNECTIONS", mode="before")
|
||||
@classmethod
|
||||
def _empty_string_to_none(cls, v):
|
||||
"""Allow empty string in env/.env to mean 'unset' (None).
|
||||
|
||||
Particularly important for SSL file paths: an empty string would cause
|
||||
redis-py to call ``ssl.SSLContext.load_verify_locations(cafile="")``
|
||||
which raises ``FileNotFoundError``.
|
||||
"""
|
||||
def _empty_string_to_none_for_max_conns(cls, v):
|
||||
"""Allow empty string in env/.env to mean 'unset' (None)."""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, str) and v.strip() == "":
|
||||
return None
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_azure_managed_identity(self):
|
||||
"""Azure Managed Redis only supports db 0."""
|
||||
if self.REDIS_USE_AZURE_MANAGED_IDENTITY and self.REDIS_DB != 0:
|
||||
raise ValueError(
|
||||
f"Azure Managed Redis only supports db 0, but REDIS_DB is set to {self.REDIS_DB}. "
|
||||
"Please set REDIS_DB=0 when REDIS_USE_AZURE_MANAGED_IDENTITY is enabled."
|
||||
)
|
||||
return self
|
||||
|
||||
@@ -10,7 +10,6 @@ from extensions.ext_database import db
|
||||
from libs.login import current_account_with_tenant
|
||||
from models.dataset import Dataset
|
||||
from models.model import App
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.enterprise.rbac_service import RBACService
|
||||
|
||||
__all__ = ["RBACPermission", "RBACResourceScope", "enforce_rbac_access", "rbac_permission_required"]
|
||||
@@ -52,7 +51,7 @@ def enforce_rbac_access(
|
||||
check_resource_type = None if resource_type == RBACResourceScope.WORKSPACE else resource_type
|
||||
resource_id = None
|
||||
if resource_required and check_resource_type:
|
||||
resource_id = _extract_resource_id(resource_type, tenant_id, path_args)
|
||||
resource_id = _extract_resource_id(resource_type, path_args)
|
||||
if _is_resource_owned_by_current_user(tenant_id, account_id, resource_type, resource_id):
|
||||
return
|
||||
allowed = RBACService.CheckAccess.check(
|
||||
@@ -132,14 +131,11 @@ def _is_resource_owned_by_current_user(
|
||||
return False
|
||||
|
||||
|
||||
def _extract_resource_id(
|
||||
resource_type: RBACResourceScope, tenant_id: str, path_args: dict[str, object] | None = None
|
||||
) -> str:
|
||||
def _extract_resource_id(resource_type: RBACResourceScope, path_args: dict[str, object] | None = None) -> str:
|
||||
"""Extract the resource ID from matched path arguments.
|
||||
|
||||
Some legacy route classes use neutral names such as ``resource_id`` for
|
||||
app/dataset resources, and Agent routes carry ``agent_id``, which is
|
||||
resolved to the App backing that Agent.
|
||||
app/dataset resources, and Agent App routes use ``agent_id`` as the app id.
|
||||
Dataset endpoints behind a rag-pipeline route contain ``pipeline_id``
|
||||
instead of ``dataset_id``. In that case we look up the associated
|
||||
``Dataset`` row via ``Dataset.pipeline_id``.
|
||||
@@ -150,19 +146,10 @@ def _extract_resource_id(
|
||||
matched_args = {**view_args, **(path_args or {})}
|
||||
|
||||
if resource_type == RBACResourceScope.APP:
|
||||
app_id = matched_args.get("app_id")
|
||||
if app_id:
|
||||
return str(app_id)
|
||||
|
||||
agent_id = matched_args.get("agent_id")
|
||||
if agent_id:
|
||||
authz_app_id = AgentRosterService(db.session).peek_authz_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
return authz_app_id or str(agent_id)
|
||||
|
||||
resource_id = matched_args.get("resource_id")
|
||||
if resource_id:
|
||||
return str(resource_id)
|
||||
raise ValueError("Missing app_id in request path")
|
||||
app_id = matched_args.get("app_id") or matched_args.get("agent_id") or matched_args.get("resource_id")
|
||||
if not app_id:
|
||||
raise ValueError("Missing app_id in request path")
|
||||
return str(app_id)
|
||||
|
||||
if resource_type == RBACResourceScope.DATASET:
|
||||
dataset_id = matched_args.get("dataset_id") or matched_args.get("resource_id")
|
||||
|
||||
@@ -230,7 +230,6 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -440,7 +439,6 @@ class SnippetAgentComposerSaveToRosterApi(Resource):
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -480,7 +478,6 @@ class AgentComposerApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
|
||||
@@ -552,7 +552,6 @@ class AgentAppListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -590,7 +589,6 @@ class AgentAppListApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -632,7 +630,6 @@ class AgentAppApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -658,7 +655,6 @@ class AgentAppApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def delete(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
@@ -716,7 +712,6 @@ class AgentPublishApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -739,7 +734,6 @@ class AgentBuildDraftCheckoutApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -816,7 +810,6 @@ class AgentBuildDraftApplyApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -839,7 +832,6 @@ class AgentAppCopyApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -865,7 +857,6 @@ class AgentApiAccessApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
@@ -882,7 +873,6 @@ class AgentApiStatusApi(Resource):
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
@@ -901,7 +891,6 @@ class AgentApiKeyListApi(BaseApiKeyListResource):
|
||||
token_prefix = "app-"
|
||||
|
||||
@console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__])
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID) -> dict[str, object]:
|
||||
@@ -912,7 +901,6 @@ class AgentApiKeyListApi(BaseApiKeyListResource):
|
||||
@console_ns.response(400, "Maximum keys exceeded")
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str, agent_id: UUID) -> tuple[dict[str, object], int]:
|
||||
@@ -932,7 +920,6 @@ class AgentApiKeyApi(BaseApiKeyResource):
|
||||
@console_ns.response(204, "Agent service API key deleted")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@with_session
|
||||
def delete(
|
||||
@@ -978,7 +965,6 @@ class AgentLogsApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
@@ -1017,7 +1003,6 @@ class AgentLogMessagesApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
@@ -1056,7 +1041,6 @@ class AgentLogSourcesApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
@@ -1077,7 +1061,6 @@ class AgentStatisticsSummaryApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
@@ -1103,7 +1086,6 @@ class AgentRosterVersionsApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
@@ -1119,7 +1101,6 @@ class AgentRosterVersionDetailApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID, version_id: UUID):
|
||||
@@ -1140,7 +1121,6 @@ class AgentRosterVersionRestoreApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
|
||||
@@ -12,7 +12,6 @@ from werkzeug.exceptions import Forbidden
|
||||
from configs import dify_config
|
||||
from controllers.common.schema import register_response_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console.app.wraps import agent_manage_required_for_agent_app
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
@@ -195,7 +194,6 @@ class AppApiKeyListResource(BaseApiKeyListResource):
|
||||
@console_ns.doc(params={"resource_id": "App ID"})
|
||||
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
||||
@with_current_tenant_id
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
|
||||
"""Get all API keys for an app"""
|
||||
@@ -212,7 +210,6 @@ class AppApiKeyListResource(BaseApiKeyListResource):
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
def post(self, session: Session, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]:
|
||||
"""Create a new API key for an app"""
|
||||
@@ -236,7 +233,6 @@ class AppApiKeyResource(BaseApiKeyResource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
def delete(
|
||||
self,
|
||||
|
||||
@@ -9,7 +9,7 @@ from flask_restx import Resource
|
||||
from pydantic import AliasChoices, BaseModel, Field, ValidationInfo, computed_field, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.app_access import resolve_app_access_filter
|
||||
@@ -23,7 +23,7 @@ from controllers.common.schema import (
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model, with_session
|
||||
from controllers.console.app.wraps import get_app_model, with_session
|
||||
from controllers.console.workspace.models import LoadBalancingPayload
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
@@ -75,7 +75,6 @@ from services.entities.knowledge_entities.knowledge_entities import (
|
||||
WeightModel,
|
||||
WeightVectorSetting,
|
||||
)
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.feature_service import FeatureService
|
||||
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
|
||||
|
||||
@@ -828,7 +827,6 @@ class AppApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def put(self, session: Session, app_model: App):
|
||||
@@ -863,7 +861,6 @@ class AppApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_DELETE)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
@get_app_model
|
||||
def delete(self, session: Session, app_model: App):
|
||||
@@ -888,7 +885,6 @@ class AppCopyApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@get_app_model(mode=None)
|
||||
@@ -900,19 +896,16 @@ class AppCopyApi(Resource):
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
import_service = AppDslService(session)
|
||||
yaml_content = import_service.export_dsl(app_model=app_model, session=session, include_secret=True)
|
||||
try:
|
||||
result = import_service.import_app(
|
||||
account=current_user,
|
||||
import_mode=ImportMode.YAML_CONTENT,
|
||||
yaml_content=yaml_content,
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
icon_type=args.icon_type,
|
||||
icon=args.icon,
|
||||
icon_background=args.icon_background,
|
||||
)
|
||||
except NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
result = import_service.import_app(
|
||||
account=current_user,
|
||||
import_mode=ImportMode.YAML_CONTENT,
|
||||
yaml_content=yaml_content,
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
icon_type=args.icon_type,
|
||||
icon=args.icon,
|
||||
icon_background=args.icon_background,
|
||||
)
|
||||
if result.status == ImportStatus.FAILED:
|
||||
session.rollback()
|
||||
return dump_response(AppImportResponse, result), 400
|
||||
@@ -966,7 +959,6 @@ class AppExportApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL)
|
||||
@agent_manage_required_for_agent_app
|
||||
@get_app_model
|
||||
def get(self, app_model: App):
|
||||
"""Export app"""
|
||||
@@ -991,7 +983,6 @@ class AppPublishToCreatorsPlatformApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_current_user_id
|
||||
@get_app_model(mode=None)
|
||||
def post(self, current_user_id: str, app_model: App):
|
||||
@@ -1022,7 +1013,6 @@ class AppNameApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def post(self, session: Session, app_model: App):
|
||||
@@ -1050,7 +1040,6 @@ class AppIconApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def post(self, session: Session, app_model: App):
|
||||
@@ -1084,7 +1073,6 @@ class AppSiteStatus(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def post(self, session: Session, app_model: App):
|
||||
@@ -1112,7 +1100,6 @@ class AppApiStatus(Resource):
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def post(self, session: Session, app_model: App):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.schema import register_enum_models, register_schema_models
|
||||
@@ -29,7 +28,6 @@ from services.app_dsl_service import (
|
||||
)
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
from .. import console_ns
|
||||
@@ -93,21 +91,18 @@ class AppImportApi(Resource):
|
||||
import_service = AppDslService(session)
|
||||
# Import app
|
||||
account = current_user
|
||||
try:
|
||||
result = import_service.import_app(
|
||||
account=account,
|
||||
import_mode=args.mode,
|
||||
yaml_content=args.yaml_content,
|
||||
yaml_url=args.yaml_url,
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
icon_type=args.icon_type,
|
||||
icon=args.icon,
|
||||
icon_background=args.icon_background,
|
||||
app_id=args.app_id,
|
||||
)
|
||||
except NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
result = import_service.import_app(
|
||||
account=account,
|
||||
import_mode=args.mode,
|
||||
yaml_content=args.yaml_content,
|
||||
yaml_url=args.yaml_url,
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
icon_type=args.icon_type,
|
||||
icon=args.icon,
|
||||
icon_background=args.icon_background,
|
||||
app_id=args.app_id,
|
||||
)
|
||||
if result.status == ImportStatus.FAILED:
|
||||
session.rollback()
|
||||
else:
|
||||
@@ -162,10 +157,7 @@ class AppImportConfirmApi(Resource):
|
||||
import_service = AppDslService(session)
|
||||
# Confirm import
|
||||
account = current_user
|
||||
try:
|
||||
result = import_service.confirm_import(import_id=import_id, account=account)
|
||||
except NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
result = import_service.confirm_import(import_id=import_id, account=account)
|
||||
if result.status == ImportStatus.FAILED:
|
||||
session.rollback()
|
||||
else:
|
||||
|
||||
@@ -10,7 +10,7 @@ from constants.languages import supported_language
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
@@ -93,7 +93,6 @@ class AppSite(Resource):
|
||||
@login_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@@ -146,7 +145,6 @@ class AppSiteAccessTokenReset(Resource):
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
|
||||
@@ -12,22 +12,14 @@ from typing import cast, overload
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.session import with_session
|
||||
from controllers.common.wraps import RBACPermission, RBACResourceScope, enforce_rbac_access
|
||||
from controllers.console.app.error import AppNotFoundError
|
||||
from extensions.ext_database import db
|
||||
from libs.login import current_account_with_tenant
|
||||
from models import App, AppMode, TrialApp
|
||||
from models.agent import AgentScope
|
||||
from services.recommended_app_service import RecommendedAppService
|
||||
|
||||
__all__ = [
|
||||
"agent_manage_required_for_agent_app",
|
||||
"get_app_model",
|
||||
"get_app_model_with_trial",
|
||||
"with_session",
|
||||
]
|
||||
__all__ = ["get_app_model", "get_app_model_with_trial", "with_session"]
|
||||
|
||||
|
||||
def _load_app_model(session: Session, app_id: str) -> App | None:
|
||||
@@ -56,45 +48,6 @@ def _load_app_model_with_trial(session: Session, app_id: str) -> App | None:
|
||||
return app_model
|
||||
|
||||
|
||||
def agent_manage_required_for_agent_app[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
"""Gate generic app management routes that target an Agent App.
|
||||
|
||||
A hidden workflow-only backing App only reuses the App runtime and is not
|
||||
part of the general app management plane, so generic routes reject it
|
||||
outright. Managing a roster Agent App mutates the roster Agent behind it
|
||||
(rename/icon sync, archive, API enablement), so it additionally requires
|
||||
workspace ``agent.manage`` on top of the route's existing App permission
|
||||
checks when RBAC is enabled. A no-op for non-agent Apps. Must be placed
|
||||
above ``get_app_model`` so the ``app_id`` path parameter is still present.
|
||||
"""
|
||||
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
raw_app_id = kwargs.get("app_id") or kwargs.get("resource_id")
|
||||
if raw_app_id is not None:
|
||||
app_model = _load_app_model_from_scoped_session(str(raw_app_id))
|
||||
binding = (
|
||||
app_model.agent_app_binding_with_session(session=db.session(), include_archived=True)
|
||||
if app_model is not None
|
||||
else None
|
||||
)
|
||||
if binding is not None:
|
||||
if binding.scope == AgentScope.WORKFLOW_ONLY:
|
||||
raise AppNotFoundError()
|
||||
if dify_config.RBAC_ENABLED:
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
enforce_rbac_access(
|
||||
tenant_id=current_tenant_id,
|
||||
account_id=current_user.id,
|
||||
resource_type=RBACResourceScope.WORKSPACE,
|
||||
scene=RBACPermission.AGENT_MANAGE,
|
||||
resource_required=False,
|
||||
)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def _get_injected_session(args: tuple[object, ...]) -> Session | None:
|
||||
"""Return the request session inserted by `with_session`, if this handler has been migrated."""
|
||||
if len(args) < 2:
|
||||
|
||||
@@ -23,7 +23,6 @@ from .knowledge import retrieval as _knowledge_retrieval
|
||||
from .plugin import agent_config as _agent_config
|
||||
from .plugin import agent_drive as _agent_drive
|
||||
from .plugin import plugin as _plugin
|
||||
from .workspace import plugin_model_providers as _plugin_model_providers
|
||||
from .workspace import workspace as _workspace
|
||||
|
||||
api.add_namespace(inner_api_ns)
|
||||
@@ -36,7 +35,6 @@ __all__ = [
|
||||
"_knowledge_retrieval",
|
||||
"_mail",
|
||||
"_plugin",
|
||||
"_plugin_model_providers",
|
||||
"_runtime_credentials",
|
||||
"_workspace",
|
||||
"api",
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from controllers.common.schema import register_schema_model
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import enterprise_inner_api_only
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
|
||||
class InvalidatePluginModelProvidersCachePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
tenant_ids: list[str] = Field(default_factory=list, description="Workspace ids whose cache should be invalidated")
|
||||
|
||||
|
||||
register_schema_model(inner_api_ns, InvalidatePluginModelProvidersCachePayload)
|
||||
|
||||
|
||||
@inner_api_ns.route("/enterprise/workspace/plugin-model-providers/invalidate")
|
||||
class EnterprisePluginModelProvidersCacheInvalidate(Resource):
|
||||
@setup_required
|
||||
@enterprise_inner_api_only
|
||||
@inner_api_ns.doc(
|
||||
"enterprise_invalidate_plugin_model_providers_cache",
|
||||
responses={
|
||||
200: "Cache invalidated",
|
||||
400: "Invalid request",
|
||||
401: "Unauthorized - invalid API key",
|
||||
},
|
||||
)
|
||||
@inner_api_ns.expect(inner_api_ns.models[InvalidatePluginModelProvidersCachePayload.__name__])
|
||||
def post(self):
|
||||
args = InvalidatePluginModelProvidersCachePayload.model_validate(inner_api_ns.payload or {})
|
||||
|
||||
for tenant_id in args.tenant_ids:
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
|
||||
return {"result": "success"}, 200
|
||||
@@ -1,7 +1,7 @@
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable, Generator
|
||||
from collections.abc import Callable, Generator, Mapping
|
||||
from typing import Any, cast
|
||||
from urllib.parse import unquote
|
||||
|
||||
@@ -23,6 +23,7 @@ from core.plugin.impl.exc import (
|
||||
PluginLLMPollingUnsupportedError,
|
||||
PluginNotFoundError,
|
||||
PluginPermissionDeniedError,
|
||||
PluginRuntimeError,
|
||||
PluginUniqueIdentifierError,
|
||||
)
|
||||
from core.trigger.errors import (
|
||||
@@ -375,6 +376,18 @@ class BasePluginClient:
|
||||
# type `PluginLLMPollingUnsupportedError`.
|
||||
case PluginLLMPollingUnsupportedError.__name__:
|
||||
raise PluginLLMPollingUnsupportedError(description=error_object.get("message"))
|
||||
case PluginRuntimeError.__name__:
|
||||
args = error_object.get("args")
|
||||
lambda_request_id = args.get("request_id") if isinstance(args, Mapping) else None
|
||||
if not isinstance(lambda_request_id, str):
|
||||
lambda_request_id = None
|
||||
runtime_message = error_object.get("message")
|
||||
if not isinstance(runtime_message, str):
|
||||
runtime_message = "Plugin runtime request failed"
|
||||
raise PluginRuntimeError(
|
||||
description=runtime_message,
|
||||
lambda_request_id=lambda_request_id,
|
||||
)
|
||||
case _:
|
||||
raise PluginInvokeError(description=message)
|
||||
case PluginDaemonInternalServerError.__name__:
|
||||
|
||||
@@ -49,6 +49,18 @@ class PluginDaemonBadRequestError(PluginDaemonClientSideError):
|
||||
description: str = "Bad Request"
|
||||
|
||||
|
||||
class PluginRuntimeError(PluginDaemonInternalError):
|
||||
"""A plugin runtime failed before it could return a valid plugin response."""
|
||||
|
||||
lambda_request_id: str | None
|
||||
|
||||
def __init__(self, description: str, lambda_request_id: str | None = None) -> None:
|
||||
self.lambda_request_id = lambda_request_id
|
||||
if lambda_request_id:
|
||||
description = description.replace(f"RequestId: {lambda_request_id} Error: ", "", 1)
|
||||
super().__init__(description)
|
||||
|
||||
|
||||
class PluginInvokeError(PluginDaemonClientSideError, ValueError):
|
||||
description: str = "Invoke Error"
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ class RBACPermission(StrEnum):
|
||||
WORKSPACE_ROLE_MANAGE = "workspace_role_manage"
|
||||
API_EXTENSION_MANAGE = "api_extension_manage"
|
||||
CUSTOMIZATION_MANAGE = "customization_manage"
|
||||
AGENT_MANAGE = "agent_manage"
|
||||
|
||||
SNIPPETS_CREATE_AND_MODIFY = "snippets_create_and_modify"
|
||||
SNIPPETS_MANAGE = "snippets_management"
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Azure-specific helpers for Redis authentication via Entra ID (Managed Identity)."""
|
||||
|
||||
from typing import Union, override
|
||||
|
||||
from redis import CredentialProvider
|
||||
|
||||
AZURE_REDIS_SCOPE = "https://redis.azure.com/.default"
|
||||
|
||||
|
||||
class AzureEntraIdCredentialProvider(CredentialProvider):
|
||||
"""Redis credential provider for Azure Entra ID (Managed Identity) authentication.
|
||||
|
||||
Wraps ``redis-entraid``'s provider so that it can be instantiated with no
|
||||
arguments — required by kombu's URL-based ``credential_provider`` resolution.
|
||||
"""
|
||||
|
||||
_inner: CredentialProvider
|
||||
|
||||
def __init__(self) -> None:
|
||||
from redis_entraid.cred_provider import create_from_default_azure_credential
|
||||
|
||||
self._inner = create_from_default_azure_credential(
|
||||
scopes=(AZURE_REDIS_SCOPE,),
|
||||
)
|
||||
|
||||
@override
|
||||
def get_credentials(self) -> Union[tuple[str], tuple[str, str]]:
|
||||
return self._inner.get_credentials()
|
||||
|
||||
|
||||
def get_azure_credential_provider() -> CredentialProvider:
|
||||
"""Create a redis-py credential provider for Azure Entra ID authentication."""
|
||||
from redis_entraid.cred_provider import create_from_default_azure_credential
|
||||
|
||||
return create_from_default_azure_credential(
|
||||
scopes=(AZURE_REDIS_SCOPE,),
|
||||
)
|
||||
|
||||
|
||||
def apply_azure_redis_auth(params: dict) -> None:
|
||||
"""Apply Azure Entra ID authentication to a Redis connection params dict.
|
||||
|
||||
Removes static username/password and injects a credential_provider instead.
|
||||
"""
|
||||
params.pop("username", None)
|
||||
params.pop("password", None)
|
||||
params["credential_provider"] = get_azure_credential_provider()
|
||||
|
||||
|
||||
def apply_azure_celery_broker_auth(celery_app, broker_url: str) -> None:
|
||||
"""Configure Celery broker to authenticate via Azure Entra ID credential provider."""
|
||||
cred_param = "credential_provider=extensions.azure.AzureEntraIdCredentialProvider"
|
||||
sep = "&" if "?" in broker_url else "?"
|
||||
broker_url_with_cred = f"{broker_url}{sep}{cred_param}"
|
||||
celery_app.conf.update(
|
||||
broker_read_url=broker_url_with_cred,
|
||||
broker_write_url=broker_url_with_cred,
|
||||
)
|
||||
@@ -5,6 +5,8 @@ from dify_app import DifyApp
|
||||
BASE_CORS_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE, HEADER_NAME_PASSPORT)
|
||||
SERVICE_API_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, "Authorization")
|
||||
AUTHENTICATED_HEADERS: tuple[str, ...] = (*SERVICE_API_HEADERS, HEADER_NAME_CSRF_TOKEN)
|
||||
KNOWLEDGE_FS_REQUEST_HEADERS: tuple[str, ...] = ("Idempotency-Key", "Last-Event-Id", "X-Trace-Id")
|
||||
CONSOLE_HEADERS: tuple[str, ...] = (*AUTHENTICATED_HEADERS, *KNOWLEDGE_FS_REQUEST_HEADERS)
|
||||
FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN)
|
||||
EMBED_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE)
|
||||
EXPOSED_HEADERS: tuple[str, ...] = ("X-Version", "X-Env", "X-Trace-Id")
|
||||
@@ -93,7 +95,7 @@ def init_app(app: DifyApp):
|
||||
console_app_bp,
|
||||
resources={r"/*": {"origins": dify_config.CONSOLE_CORS_ALLOW_ORIGINS}},
|
||||
supports_credentials=True,
|
||||
allow_headers=list(AUTHENTICATED_HEADERS),
|
||||
allow_headers=list(CONSOLE_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
expose_headers=list(EXPOSED_HEADERS),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import logging
|
||||
import ssl
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
@@ -10,12 +9,9 @@ from typing_extensions import TypedDict
|
||||
|
||||
from configs import dify_config
|
||||
from dify_app import DifyApp
|
||||
from extensions.azure import AzureEntraIdCredentialProvider, apply_azure_celery_broker_auth
|
||||
from extensions.redis_names import normalize_redis_key_prefix
|
||||
from extensions.workflow_warm_shutdown import setup_workflow_warm_shutdown_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _CelerySentinelKwargsDict(TypedDict):
|
||||
socket_timeout: float | None
|
||||
@@ -112,12 +108,10 @@ def init_app(app: DifyApp) -> Celery:
|
||||
|
||||
broker_transport_options = get_celery_broker_transport_options()
|
||||
|
||||
broker_url = dify_config.CELERY_BROKER_URL
|
||||
|
||||
celery_app = Celery(
|
||||
app.name,
|
||||
task_cls=FlaskTask,
|
||||
broker=broker_url,
|
||||
broker=dify_config.CELERY_BROKER_URL,
|
||||
backend=dify_config.CELERY_BACKEND,
|
||||
)
|
||||
|
||||
@@ -134,12 +128,9 @@ def init_app(app: DifyApp) -> Celery:
|
||||
)
|
||||
|
||||
if dify_config.CELERY_BACKEND == "redis":
|
||||
redis_backend_conf: dict[str, Any] = {
|
||||
"result_backend_transport_options": broker_transport_options,
|
||||
}
|
||||
if dify_config.REDIS_USE_AZURE_MANAGED_IDENTITY:
|
||||
redis_backend_conf["redis_backend_credential_provider"] = AzureEntraIdCredentialProvider()
|
||||
celery_app.conf.update(**redis_backend_conf)
|
||||
celery_app.conf.update(
|
||||
result_backend_transport_options=broker_transport_options,
|
||||
)
|
||||
|
||||
# Apply SSL configuration if enabled
|
||||
ssl_options = get_celery_ssl_options()
|
||||
@@ -150,10 +141,6 @@ def init_app(app: DifyApp) -> Celery:
|
||||
redis_backend_use_ssl=ssl_options if dify_config.CELERY_BACKEND == "redis" else None,
|
||||
)
|
||||
|
||||
if dify_config.REDIS_USE_AZURE_MANAGED_IDENTITY and broker_url:
|
||||
apply_azure_celery_broker_auth(celery_app, broker_url)
|
||||
logger.info("Celery broker: using Azure Managed Identity (Entra ID) authentication")
|
||||
|
||||
if dify_config.LOG_FILE:
|
||||
celery_app.conf.update(
|
||||
worker_logfile=dify_config.LOG_FILE,
|
||||
|
||||
@@ -6,7 +6,7 @@ from flask_cors import CORS
|
||||
from configs import dify_config
|
||||
from controllers.fastopenapi import console_router
|
||||
from dify_app import DifyApp
|
||||
from extensions.ext_blueprints import AUTHENTICATED_HEADERS, EXPOSED_HEADERS
|
||||
from extensions.ext_blueprints import CONSOLE_HEADERS, EXPOSED_HEADERS
|
||||
|
||||
DOCS_PREFIX = "/fastopenapi"
|
||||
|
||||
@@ -47,7 +47,7 @@ def init_app(app: DifyApp) -> None:
|
||||
app,
|
||||
resources={r"/console/api/.*": {"origins": dify_config.CONSOLE_CORS_ALLOW_ORIGINS}},
|
||||
supports_credentials=True,
|
||||
allow_headers=list(AUTHENTICATED_HEADERS),
|
||||
allow_headers=list(CONSOLE_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
expose_headers=list(EXPOSED_HEADERS),
|
||||
)
|
||||
|
||||
@@ -21,7 +21,6 @@ from typing_extensions import TypedDict
|
||||
|
||||
from configs import dify_config
|
||||
from dify_app import DifyApp
|
||||
from extensions.azure import apply_azure_redis_auth, get_azure_credential_provider
|
||||
from extensions.redis_names import (
|
||||
normalize_redis_key_prefix,
|
||||
serialize_redis_name,
|
||||
@@ -439,10 +438,6 @@ def _create_standalone_client(redis_params: RedisBaseParamsDict) -> Union[redis.
|
||||
"connection_class": connection_class,
|
||||
}
|
||||
|
||||
if dify_config.REDIS_USE_AZURE_MANAGED_IDENTITY:
|
||||
apply_azure_redis_auth(params)
|
||||
logger.info("Redis: using Azure Managed Identity (Entra ID) authentication")
|
||||
|
||||
if dify_config.REDIS_MAX_CONNECTIONS:
|
||||
params["max_connections"] = dify_config.REDIS_MAX_CONNECTIONS
|
||||
|
||||
@@ -462,20 +457,12 @@ def _create_pubsub_client(pubsub_url: str, use_clusters: bool) -> redis.Redis |
|
||||
kwargs: dict[str, Any] = {**health_params}
|
||||
if max_conns:
|
||||
kwargs["max_connections"] = max_conns
|
||||
if dify_config.REDIS_USE_AZURE_MANAGED_IDENTITY:
|
||||
kwargs["credential_provider"] = get_azure_credential_provider()
|
||||
kwargs["ssl_cert_reqs"] = ssl.CERT_NONE
|
||||
logger.info("PubSub Redis (cluster): using Azure Managed Identity (Entra ID) authentication")
|
||||
return RedisCluster.from_url(pubsub_url, **kwargs)
|
||||
|
||||
standalone_health_params: dict[str, Any] = dict(_get_connection_health_params())
|
||||
kwargs = {**standalone_health_params}
|
||||
if max_conns:
|
||||
kwargs["max_connections"] = max_conns
|
||||
if dify_config.REDIS_USE_AZURE_MANAGED_IDENTITY:
|
||||
kwargs["credential_provider"] = get_azure_credential_provider()
|
||||
kwargs["ssl_cert_reqs"] = ssl.CERT_NONE
|
||||
logger.info("PubSub Redis: using Azure Managed Identity (Entra ID) authentication")
|
||||
return redis.Redis.from_url(pubsub_url, **kwargs)
|
||||
|
||||
|
||||
@@ -499,7 +486,7 @@ def init_app(app: DifyApp):
|
||||
|
||||
global _pubsub_redis_client
|
||||
_pubsub_redis_client = client
|
||||
if dify_config.PUBSUB_REDIS_URL:
|
||||
if dify_config.normalized_pubsub_redis_url:
|
||||
_pubsub_redis_client = _create_pubsub_client(
|
||||
dify_config.normalized_pubsub_redis_url, dify_config.PUBSUB_REDIS_USE_CLUSTERS
|
||||
)
|
||||
|
||||
@@ -9,6 +9,8 @@ from werkzeug.http import HTTP_STATUS_CODES
|
||||
|
||||
from configs import dify_config
|
||||
from core.errors.error import AppInvokeQuotaExceededError
|
||||
from core.plugin.impl.exc import PluginRuntimeError
|
||||
from extensions.ext_logging import get_request_id
|
||||
from libs.flask_restx_compat import install_swagger_compatibility
|
||||
from libs.token import build_force_logout_cookie_headers
|
||||
|
||||
@@ -100,6 +102,20 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
|
||||
data = {"code": "too_many_requests", "message": str(e), "status": status_code}
|
||||
return _finalize(e, data, status_code), status_code
|
||||
|
||||
def handle_plugin_runtime_error(e: PluginRuntimeError):
|
||||
got_request_exception.send(current_app, exception=e)
|
||||
status_code = 502
|
||||
details = {"request_id": get_request_id()}
|
||||
if e.lambda_request_id:
|
||||
details["lambda_request_id"] = e.lambda_request_id
|
||||
data = {
|
||||
"code": "plugin_runtime_error",
|
||||
"message": e.description,
|
||||
"details": details,
|
||||
"status": status_code,
|
||||
}
|
||||
return _finalize(e, data, status_code), status_code
|
||||
|
||||
def handle_general_exception(e: Exception):
|
||||
got_request_exception.send(current_app, exception=e)
|
||||
|
||||
@@ -121,6 +137,7 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
|
||||
api.errorhandler(HTTPException)(handle_http_exception)
|
||||
api.errorhandler(ValueError)(handle_value_error)
|
||||
api.errorhandler(AppInvokeQuotaExceededError)(handle_quota_exceeded)
|
||||
api.errorhandler(PluginRuntimeError)(handle_plugin_runtime_error)
|
||||
api.errorhandler(Exception)(handle_general_exception)
|
||||
|
||||
|
||||
|
||||
+13
-31
@@ -56,7 +56,6 @@ from .provider_ids import GenericProviderID
|
||||
from .types import EnumText, LongText, StringUUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .agent import Agent
|
||||
from .workflow import Workflow
|
||||
|
||||
|
||||
@@ -502,42 +501,25 @@ class App(Base):
|
||||
Resolved via ``Agent.app_id`` so the console can open the Composer in
|
||||
roster-detail mode from the app id. ``None`` for non-agent apps.
|
||||
"""
|
||||
agent = self.agent_app_binding_with_session(session=session)
|
||||
return agent.id if agent else None
|
||||
|
||||
def agent_app_binding_with_session(self, *, session: Session, include_archived: bool = False) -> Agent | None:
|
||||
"""For an Agent App (mode=agent), the Agent bound to it.
|
||||
|
||||
A roster Agent is bound through ``Agent.app_id``; a workflow-only Agent
|
||||
is bound to its hidden runtime backing App through
|
||||
``Agent.backing_app_id``. Callers branch on ``Agent.scope`` to tell the
|
||||
public roster Agent App apart from the hidden backing App. Archived
|
||||
Agents are excluded unless ``include_archived`` is set (authorization
|
||||
gates must keep covering an Agent App after its Agent is archived).
|
||||
``None`` for non-agent apps and unbound agent apps.
|
||||
"""
|
||||
if self.mode != AppMode.AGENT:
|
||||
return None
|
||||
from .agent import APP_BACKED_AGENT_SOURCES, Agent, AgentScope, AgentStatus
|
||||
|
||||
conditions = [
|
||||
Agent.tenant_id == self.tenant_id,
|
||||
sa.or_(
|
||||
sa.and_(
|
||||
Agent.app_id == self.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
|
||||
),
|
||||
sa.and_(
|
||||
agent = session.scalar(
|
||||
select(Agent).where(
|
||||
Agent.tenant_id == self.tenant_id,
|
||||
sa.or_(
|
||||
sa.and_(
|
||||
Agent.app_id == self.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
|
||||
),
|
||||
Agent.backing_app_id == self.id,
|
||||
Agent.scope == AgentScope.WORKFLOW_ONLY,
|
||||
),
|
||||
),
|
||||
]
|
||||
if not include_archived:
|
||||
conditions.append(Agent.status == AgentStatus.ACTIVE)
|
||||
|
||||
return session.scalar(select(Agent).where(*conditions).limit(1))
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
return agent.id if agent else None
|
||||
|
||||
@property
|
||||
def api_base_url(self) -> str:
|
||||
|
||||
@@ -21,7 +21,6 @@ dependencies = [
|
||||
"psycopg2-binary>=2.9.12,<3.0.0",
|
||||
"python-socketio>=5.13.0,<6.0.0",
|
||||
"redis[hiredis]>=7.4.0,<8.0.0",
|
||||
"redis-entraid>=1.2.0,<2.0.0",
|
||||
"sendgrid>=6.12.5,<7.0.0",
|
||||
"sseclient-py>=1.8.0,<2.0.0",
|
||||
# Stable: production-proven, cap below the next major
|
||||
|
||||
@@ -911,14 +911,16 @@ class AgentRosterService:
|
||||
raise AgentNotFoundError()
|
||||
return app
|
||||
|
||||
def _get_runtime_resolvable_agent(self, *, tenant_id: str, agent_id: str) -> Agent | None:
|
||||
"""Load an Agent that is eligible to resolve to a runtime backing App.
|
||||
def get_agent_runtime_app_model(self, *, tenant_id: str, agent_id: str) -> App:
|
||||
"""Resolve the App that backs an Agent runtime surface.
|
||||
|
||||
Shared by the runtime resolver and the read-only authorization resolver
|
||||
so both agree on what counts as a resolvable Agent.
|
||||
Roster Agents use their public Agent App. Workflow-only Agents use a
|
||||
hidden Agent App stored in ``backing_app_id`` so console chat/logs can
|
||||
reuse the app runtime without exposing the resource in workspace app
|
||||
lists.
|
||||
"""
|
||||
|
||||
return self._session.scalar(
|
||||
agent = self._session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
@@ -936,34 +938,6 @@ class AgentRosterService:
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
def peek_authz_app_id(self, *, tenant_id: str, agent_id: str) -> str | None:
|
||||
"""Resolve the App id whose access policy governs an Agent.
|
||||
|
||||
Roster Agents are governed by their own Agent App, while workflow-only
|
||||
Agents are governed by their parent workflow App: the hidden runtime
|
||||
backing App never receives a resource access policy, so it must not be
|
||||
used for authorization. Stays read-only — unlike
|
||||
:meth:`get_agent_runtime_app_model`, this never materializes the hidden
|
||||
backing App. Returns ``None`` when the Agent does not resolve, leaving
|
||||
the caller to decide how to treat it.
|
||||
"""
|
||||
|
||||
agent = self._get_runtime_resolvable_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
if agent is None:
|
||||
return None
|
||||
return agent.app_id
|
||||
|
||||
def get_agent_runtime_app_model(self, *, tenant_id: str, agent_id: str) -> App:
|
||||
"""Resolve the App that backs an Agent runtime surface.
|
||||
|
||||
Roster Agents use their public Agent App. Workflow-only Agents use a
|
||||
hidden Agent App stored in ``backing_app_id`` so console chat/logs can
|
||||
reuse the app runtime without exposing the resource in workspace app
|
||||
lists.
|
||||
"""
|
||||
|
||||
agent = self._get_runtime_resolvable_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
if agent is None:
|
||||
raise AgentNotFoundError()
|
||||
should_commit_backing_app = agent.scope == AgentScope.WORKFLOW_ONLY and not agent.backing_app_id
|
||||
|
||||
@@ -19,7 +19,6 @@ from configs import dify_config
|
||||
from constants.dsl_version import CURRENT_APP_DSL_VERSION
|
||||
from core.file import remote_fetcher
|
||||
from core.plugin.entities.plugin import PluginDependency
|
||||
from core.rbac import RBACPermission
|
||||
from core.trigger.constants import (
|
||||
TRIGGER_PLUGIN_NODE_TYPE,
|
||||
TRIGGER_SCHEDULE_NODE_TYPE,
|
||||
@@ -44,9 +43,7 @@ from services.agent.dsl_service import AgentDslService, AgentPackage
|
||||
from services.agent.workflow_publish_service import WorkflowAgentPublishService
|
||||
from services.dsl_content import DSL_MAX_SIZE, dsl_content_size
|
||||
from services.dsl_version import check_version_compatibility
|
||||
from services.enterprise.rbac_service import RBACService
|
||||
from services.entities.dsl_entities import CheckDependenciesResult, DslImportWarning, ImportMode, ImportStatus
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.errors.app import WorkflowNotFoundError
|
||||
from services.plugin.dependencies_analysis import DependenciesAnalysisService
|
||||
from services.workflow_draft_variable_service import WorkflowDraftVariableService
|
||||
@@ -304,9 +301,6 @@ class AppDslService:
|
||||
error=f"Invalid YAML format: {str(e)}",
|
||||
)
|
||||
|
||||
except NoPermissionError:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to import app")
|
||||
return Import(
|
||||
@@ -370,9 +364,6 @@ class AppDslService:
|
||||
warnings=self._warnings,
|
||||
)
|
||||
|
||||
except NoPermissionError:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error confirming import")
|
||||
return Import(
|
||||
@@ -404,21 +395,6 @@ class AppDslService:
|
||||
leaked_dependencies=leaked_dependencies,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_agent_manage_permission(account: Account) -> None:
|
||||
"""Importing an Agent DSL creates a roster Agent, which requires ``agent.manage``."""
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
return
|
||||
if account.current_tenant_id is None:
|
||||
raise ValueError("Current tenant is not set")
|
||||
allowed = RBACService.CheckAccess.check(
|
||||
account.current_tenant_id,
|
||||
account.id,
|
||||
scene=RBACPermission.AGENT_MANAGE,
|
||||
)
|
||||
if not allowed:
|
||||
raise NoPermissionError("Agent management permission is required to import an Agent App")
|
||||
|
||||
def _create_or_update_app(
|
||||
self,
|
||||
*,
|
||||
@@ -439,8 +415,6 @@ class AppDslService:
|
||||
if not app_mode:
|
||||
raise ValueError("loss app mode")
|
||||
app_mode = AppMode(app_mode)
|
||||
if app_mode == AppMode.AGENT:
|
||||
self._ensure_agent_manage_permission(account)
|
||||
|
||||
# Set icon type
|
||||
icon_type_value = icon_type or app_data.get("icon_type")
|
||||
|
||||
@@ -330,7 +330,6 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [
|
||||
"snippets.management",
|
||||
"tool.manage",
|
||||
"mcp.manage",
|
||||
"agent.manage",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
|
||||
@@ -358,7 +357,6 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
|
||||
"snippets.management",
|
||||
"tool.manage",
|
||||
"mcp.manage",
|
||||
"agent.manage",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
|
||||
@@ -373,7 +371,6 @@ _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
|
||||
"dataset.external.connect",
|
||||
"snippets.create_and_modify",
|
||||
"tool.manage",
|
||||
"agent.manage",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.console.app.error import AppNotFoundError
|
||||
from controllers.console.app.wraps import agent_manage_required_for_agent_app
|
||||
from core.rbac import RBACPermission, RBACResourceScope
|
||||
from models.agent import AgentScope
|
||||
|
||||
TENANT_ID = "tenant-1"
|
||||
ACCOUNT = SimpleNamespace(id="account-1")
|
||||
|
||||
|
||||
def _guarded_view():
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
@agent_manage_required_for_agent_app
|
||||
def view(*args, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return "ok"
|
||||
|
||||
return view, calls
|
||||
|
||||
|
||||
def _app_with_binding(binding):
|
||||
app_model = MagicMock()
|
||||
app_model.agent_app_binding_with_session.return_value = binding
|
||||
return app_model
|
||||
|
||||
|
||||
def _patch_guard(app_model, rbac_enabled: bool):
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.scalar.return_value = app_model
|
||||
return (
|
||||
patch("controllers.console.app.wraps.db", mock_db),
|
||||
patch("controllers.console.app.wraps.current_account_with_tenant", return_value=(ACCOUNT, TENANT_ID)),
|
||||
patch("controllers.console.app.wraps.dify_config.RBAC_ENABLED", rbac_enabled),
|
||||
)
|
||||
|
||||
|
||||
class TestAgentManageRequiredForAgentApp:
|
||||
def test_non_agent_app_passes_through_without_workspace_check(self):
|
||||
view, calls = _guarded_view()
|
||||
patches = _patch_guard(_app_with_binding(None), rbac_enabled=True)
|
||||
|
||||
with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate:
|
||||
assert view(app_id="app-1") == "ok"
|
||||
|
||||
gate.assert_not_called()
|
||||
assert calls == [{"app_id": "app-1"}]
|
||||
|
||||
def test_roster_agent_app_requires_agent_manage_when_rbac_enabled(self):
|
||||
view, _ = _guarded_view()
|
||||
binding = SimpleNamespace(scope=AgentScope.ROSTER)
|
||||
patches = _patch_guard(_app_with_binding(binding), rbac_enabled=True)
|
||||
|
||||
with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate:
|
||||
assert view(app_id="app-1") == "ok"
|
||||
|
||||
gate.assert_called_once_with(
|
||||
tenant_id=TENANT_ID,
|
||||
account_id=ACCOUNT.id,
|
||||
resource_type=RBACResourceScope.WORKSPACE,
|
||||
scene=RBACPermission.AGENT_MANAGE,
|
||||
resource_required=False,
|
||||
)
|
||||
|
||||
def test_roster_agent_app_denied_without_agent_manage(self):
|
||||
view, calls = _guarded_view()
|
||||
binding = SimpleNamespace(scope=AgentScope.ROSTER)
|
||||
patches = _patch_guard(_app_with_binding(binding), rbac_enabled=True)
|
||||
|
||||
with (
|
||||
patches[0],
|
||||
patches[1],
|
||||
patches[2],
|
||||
patch("controllers.console.app.wraps.enforce_rbac_access", side_effect=Forbidden()),
|
||||
):
|
||||
with pytest.raises(Forbidden):
|
||||
view(app_id="app-1")
|
||||
|
||||
assert calls == []
|
||||
|
||||
def test_roster_agent_app_skips_workspace_check_when_rbac_disabled(self):
|
||||
view, _ = _guarded_view()
|
||||
binding = SimpleNamespace(scope=AgentScope.ROSTER)
|
||||
patches = _patch_guard(_app_with_binding(binding), rbac_enabled=False)
|
||||
|
||||
with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate:
|
||||
assert view(app_id="app-1") == "ok"
|
||||
|
||||
gate.assert_not_called()
|
||||
|
||||
def test_hidden_backing_app_is_rejected_even_without_rbac(self):
|
||||
"""A workflow-only backing App is not part of the general app management plane."""
|
||||
view, calls = _guarded_view()
|
||||
binding = SimpleNamespace(scope=AgentScope.WORKFLOW_ONLY)
|
||||
patches = _patch_guard(_app_with_binding(binding), rbac_enabled=False)
|
||||
|
||||
with patches[0], patches[1], patches[2]:
|
||||
with pytest.raises(AppNotFoundError):
|
||||
view(app_id="app-1")
|
||||
|
||||
assert calls == []
|
||||
|
||||
def test_hidden_backing_app_is_rejected_before_workspace_check(self):
|
||||
view, calls = _guarded_view()
|
||||
binding = SimpleNamespace(scope=AgentScope.WORKFLOW_ONLY)
|
||||
patches = _patch_guard(_app_with_binding(binding), rbac_enabled=True)
|
||||
|
||||
with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate:
|
||||
with pytest.raises(AppNotFoundError):
|
||||
view(app_id="app-1")
|
||||
|
||||
gate.assert_not_called()
|
||||
assert calls == []
|
||||
|
||||
def test_binding_lookup_covers_archived_agents(self):
|
||||
"""An Agent App stays gated after its roster Agent is archived."""
|
||||
view, _ = _guarded_view()
|
||||
app_model = _app_with_binding(SimpleNamespace(scope=AgentScope.ROSTER))
|
||||
patches = _patch_guard(app_model, rbac_enabled=True)
|
||||
|
||||
with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access"):
|
||||
view(app_id="app-1")
|
||||
|
||||
_, call_kwargs = app_model.agent_app_binding_with_session.call_args
|
||||
assert call_kwargs["include_archived"] is True
|
||||
|
||||
def test_resource_id_path_alias_is_resolved(self):
|
||||
view, _ = _guarded_view()
|
||||
binding = SimpleNamespace(scope=AgentScope.ROSTER)
|
||||
patches = _patch_guard(_app_with_binding(binding), rbac_enabled=True)
|
||||
|
||||
with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate:
|
||||
assert view(resource_id="app-1") == "ok"
|
||||
|
||||
gate.assert_called_once()
|
||||
|
||||
def test_unknown_app_passes_through_for_downstream_handling(self):
|
||||
view, calls = _guarded_view()
|
||||
patches = _patch_guard(None, rbac_enabled=True)
|
||||
|
||||
with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate:
|
||||
assert view(app_id="app-1") == "ok"
|
||||
|
||||
gate.assert_not_called()
|
||||
assert calls == [{"app_id": "app-1"}]
|
||||
@@ -201,7 +201,7 @@ class TestRbacPermissionRequired:
|
||||
):
|
||||
assert protected_view(app_id="app-123") == "ok"
|
||||
|
||||
mock_extract.assert_called_once_with(RBACResourceScope.APP, "tenant-1", {"app_id": "app-123"})
|
||||
mock_extract.assert_called_once_with("app", {"app_id": "app-123"})
|
||||
mock_owned.assert_called_once_with("tenant-1", "account-1", "app", "app-123")
|
||||
mock_check.assert_called_once_with(
|
||||
"tenant-1",
|
||||
@@ -307,7 +307,7 @@ class TestRbacPermissionRequired:
|
||||
with app.test_request_context("/"):
|
||||
request.view_args = {"app_id": "view-app"}
|
||||
|
||||
assert _extract_resource_id("app", "tenant-1", {"app_id": "path-app"}) == "path-app"
|
||||
assert _extract_resource_id("app", {"app_id": "path-app"}) == "path-app"
|
||||
|
||||
def test_extract_resource_id_falls_back_to_request_view_args(self):
|
||||
app = Flask(__name__)
|
||||
@@ -315,59 +315,22 @@ class TestRbacPermissionRequired:
|
||||
with app.test_request_context("/"):
|
||||
request.view_args = {"app_id": "view-app"}
|
||||
|
||||
assert _extract_resource_id("app", "tenant-1") == "view-app"
|
||||
assert _extract_resource_id("app") == "view-app"
|
||||
|
||||
def test_extract_resource_id_supports_legacy_route_aliases(self):
|
||||
app = Flask(__name__)
|
||||
|
||||
with app.test_request_context("/apps/app-1/api-keys"):
|
||||
request.view_args = {"resource_id": "app-1"}
|
||||
assert _extract_resource_id(RBACResourceScope.APP, "tenant-1") == "app-1"
|
||||
assert _extract_resource_id(RBACResourceScope.APP) == "app-1"
|
||||
|
||||
with app.test_request_context("/agent/agent-1/features"):
|
||||
request.view_args = {"agent_id": "agent-1"}
|
||||
assert _extract_resource_id(RBACResourceScope.APP) == "agent-1"
|
||||
|
||||
with app.test_request_context("/datasets/dataset-1/api-keys"):
|
||||
request.view_args = {"resource_id": "dataset-1"}
|
||||
assert _extract_resource_id(RBACResourceScope.DATASET, "tenant-1") == "dataset-1"
|
||||
|
||||
def test_extract_resource_id_resolves_agent_to_its_authz_app(self):
|
||||
app = Flask(__name__)
|
||||
|
||||
with (
|
||||
app.test_request_context("/agent/agent-1/chat-messages"),
|
||||
patch("controllers.common.wraps.AgentRosterService") as mock_service,
|
||||
):
|
||||
request.view_args = {"agent_id": "agent-1"}
|
||||
mock_service.return_value.peek_authz_app_id.return_value = "parent-app-1"
|
||||
|
||||
assert _extract_resource_id(RBACResourceScope.APP, "tenant-1") == "parent-app-1"
|
||||
|
||||
def test_extract_resource_id_scopes_agent_resolution_to_the_calling_tenant(self):
|
||||
"""The tenant must reach the resolver, or an Agent id from any tenant resolves."""
|
||||
app = Flask(__name__)
|
||||
|
||||
with (
|
||||
app.test_request_context("/agent/agent-1/chat-messages"),
|
||||
patch("controllers.common.wraps.AgentRosterService") as mock_service,
|
||||
):
|
||||
request.view_args = {"agent_id": "agent-1"}
|
||||
mock_service.return_value.peek_authz_app_id.return_value = "parent-app-1"
|
||||
|
||||
_extract_resource_id(RBACResourceScope.APP, "tenant-9")
|
||||
|
||||
mock_service.return_value.peek_authz_app_id.assert_called_once_with(
|
||||
tenant_id="tenant-9", agent_id="agent-1"
|
||||
)
|
||||
|
||||
def test_extract_resource_id_keeps_agent_id_when_the_agent_does_not_resolve(self):
|
||||
app = Flask(__name__)
|
||||
|
||||
with (
|
||||
app.test_request_context("/agent/agent-1/chat-messages"),
|
||||
patch("controllers.common.wraps.AgentRosterService") as mock_service,
|
||||
):
|
||||
request.view_args = {"agent_id": "agent-1"}
|
||||
mock_service.return_value.peek_authz_app_id.return_value = None
|
||||
|
||||
assert _extract_resource_id(RBACResourceScope.APP, "tenant-1") == "agent-1"
|
||||
assert _extract_resource_id(RBACResourceScope.DATASET) == "dataset-1"
|
||||
|
||||
def test_legacy_admin_decorator_noops_when_rbac_enabled(self):
|
||||
@is_admin_or_owner_required
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import inspect
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.inner_api.workspace.plugin_model_providers import (
|
||||
EnterprisePluginModelProvidersCacheInvalidate,
|
||||
InvalidatePluginModelProvidersCachePayload,
|
||||
)
|
||||
|
||||
|
||||
class TestInvalidatePluginModelProvidersCachePayload:
|
||||
def test_valid_payload(self):
|
||||
payload = InvalidatePluginModelProvidersCachePayload.model_validate(
|
||||
{"tenant_ids": ["tenant-alpha", "tenant-beta"]}
|
||||
)
|
||||
assert payload.tenant_ids == ["tenant-alpha", "tenant-beta"]
|
||||
|
||||
def test_missing_tenant_ids_defaults_to_empty(self):
|
||||
payload = InvalidatePluginModelProvidersCachePayload.model_validate({})
|
||||
assert payload.tenant_ids == []
|
||||
|
||||
def test_unknown_field_rejected(self):
|
||||
with pytest.raises(ValidationError):
|
||||
InvalidatePluginModelProvidersCachePayload.model_validate({"tenant_ids": ["tenant-alpha"], "generation": 7})
|
||||
|
||||
|
||||
class TestEnterprisePluginModelProvidersCacheInvalidate:
|
||||
@pytest.fixture
|
||||
def api_instance(self):
|
||||
return EnterprisePluginModelProvidersCacheInvalidate()
|
||||
|
||||
def _post(self, api_instance, app: Flask, payload):
|
||||
unwrapped_post = inspect.unwrap(api_instance.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.workspace.plugin_model_providers.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = payload
|
||||
return unwrapped_post(api_instance)
|
||||
|
||||
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
|
||||
def test_post_invalidates_once_per_tenant(self, mock_plugin_service, api_instance, app: Flask):
|
||||
result = self._post(api_instance, app, {"tenant_ids": ["tenant-alpha", "tenant-beta"]})
|
||||
|
||||
assert result == ({"result": "success"}, 200)
|
||||
assert mock_plugin_service.invalidate_plugin_model_providers_cache.call_args_list == [
|
||||
call("tenant-alpha"),
|
||||
call("tenant-beta"),
|
||||
]
|
||||
|
||||
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
|
||||
def test_post_with_empty_list_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask):
|
||||
result = self._post(api_instance, app, {"tenant_ids": []})
|
||||
|
||||
assert result == ({"result": "success"}, 200)
|
||||
mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called()
|
||||
|
||||
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
|
||||
def test_post_with_missing_payload_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask):
|
||||
result = self._post(api_instance, app, None)
|
||||
|
||||
assert result == ({"result": "success"}, 200)
|
||||
mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called()
|
||||
@@ -7,7 +7,7 @@ from pytest_mock import MockerFixture
|
||||
from core.plugin.endpoint.exc import EndpointSetupFailedError
|
||||
from core.plugin.entities.plugin_daemon import PluginDaemonInnerError
|
||||
from core.plugin.impl.base import PLUGIN_DAEMON_MAX_PATH_LENGTH, BasePluginClient
|
||||
from core.plugin.impl.exc import PluginLLMPollingUnsupportedError
|
||||
from core.plugin.impl.exc import PluginLLMPollingUnsupportedError, PluginRuntimeError
|
||||
from core.trigger.errors import (
|
||||
EventIgnoreError,
|
||||
TriggerInvokeError,
|
||||
@@ -175,3 +175,25 @@ class TestBasePluginClientImpl:
|
||||
|
||||
with pytest.raises(PluginLLMPollingUnsupportedError):
|
||||
client._handle_plugin_daemon_error("PluginInvokeError", message)
|
||||
|
||||
def test_handle_plugin_daemon_error_maps_runtime_error_to_typed_exception(self):
|
||||
client = BasePluginClient()
|
||||
lambda_request_id = "45664803-3d3c-4d4f-93fe-e3b19e43092b"
|
||||
message = json.dumps(
|
||||
{
|
||||
"error_type": PluginRuntimeError.__name__,
|
||||
"message": (
|
||||
"Plugin runtime request failed: Runtime.ExitError: "
|
||||
f"RequestId: {lambda_request_id} Error: Runtime exited with error: exit status 1"
|
||||
),
|
||||
"args": {"request_id": lambda_request_id, "status_code": 200},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(PluginRuntimeError) as exc_info:
|
||||
client._handle_plugin_daemon_error("PluginInvokeError", message)
|
||||
|
||||
assert exc_info.value.description == (
|
||||
"Plugin runtime request failed: Runtime.ExitError: Runtime exited with error: exit status 1"
|
||||
)
|
||||
assert exc_info.value.lambda_request_id == lambda_request_id
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
"""Tests for Azure Managed Identity Redis helpers."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from configs import DifyConfig
|
||||
from configs.middleware.cache.redis_config import RedisConfig
|
||||
from extensions.azure import (
|
||||
AzureEntraIdCredentialProvider,
|
||||
apply_azure_celery_broker_auth,
|
||||
apply_azure_redis_auth,
|
||||
get_azure_credential_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestAzureConfigValidation:
|
||||
"""Test pydantic config validation for Azure Managed Redis constraints."""
|
||||
|
||||
def test_redis_db_0_passes(self):
|
||||
config = RedisConfig(REDIS_USE_AZURE_MANAGED_IDENTITY=True, REDIS_DB=0)
|
||||
assert config.REDIS_DB == 0
|
||||
|
||||
def test_redis_db_nonzero_raises(self):
|
||||
with pytest.raises(ValueError, match="only supports db 0"):
|
||||
RedisConfig(REDIS_USE_AZURE_MANAGED_IDENTITY=True, REDIS_DB=1)
|
||||
|
||||
def test_redis_db_nonzero_without_azure_mi_passes(self):
|
||||
config = RedisConfig(REDIS_USE_AZURE_MANAGED_IDENTITY=False, REDIS_DB=5)
|
||||
assert config.REDIS_DB == 5
|
||||
|
||||
def test_redis_db_default_with_azure_mi_passes(self):
|
||||
config = RedisConfig(REDIS_USE_AZURE_MANAGED_IDENTITY=True)
|
||||
assert config.REDIS_DB == 0
|
||||
|
||||
def test_celery_broker_url_db_0_with_azure_mi_passes(self):
|
||||
config = DifyConfig(
|
||||
REDIS_USE_AZURE_MANAGED_IDENTITY=True,
|
||||
REDIS_DB=0,
|
||||
CELERY_BROKER_URL="rediss://:@host:10000/0",
|
||||
)
|
||||
assert config.CELERY_BROKER_URL == "rediss://:@host:10000/0"
|
||||
|
||||
def test_celery_broker_url_nonzero_db_with_azure_mi_raises(self):
|
||||
with pytest.raises(ValueError, match="only supports db 0"):
|
||||
DifyConfig(
|
||||
REDIS_USE_AZURE_MANAGED_IDENTITY=True,
|
||||
REDIS_DB=0,
|
||||
CELERY_BROKER_URL="rediss://:@host:10000/1",
|
||||
)
|
||||
|
||||
def test_celery_broker_url_nonzero_db_without_azure_mi_passes(self):
|
||||
config = DifyConfig(
|
||||
REDIS_USE_AZURE_MANAGED_IDENTITY=False,
|
||||
CELERY_BROKER_URL="redis://localhost:6379/5",
|
||||
)
|
||||
assert config.CELERY_BROKER_URL == "redis://localhost:6379/5"
|
||||
|
||||
def test_pubsub_redis_url_db_0_with_azure_mi_passes(self):
|
||||
config = DifyConfig(
|
||||
REDIS_USE_AZURE_MANAGED_IDENTITY=True,
|
||||
REDIS_DB=0,
|
||||
PUBSUB_REDIS_URL="rediss://:@host:10000/0",
|
||||
)
|
||||
assert config.PUBSUB_REDIS_URL == "rediss://:@host:10000/0"
|
||||
|
||||
def test_pubsub_redis_url_nonzero_db_with_azure_mi_raises(self):
|
||||
with pytest.raises(ValueError, match="only supports db 0"):
|
||||
DifyConfig(
|
||||
REDIS_USE_AZURE_MANAGED_IDENTITY=True,
|
||||
REDIS_DB=0,
|
||||
PUBSUB_REDIS_URL="rediss://:@host:10000/2",
|
||||
)
|
||||
|
||||
def test_pubsub_redis_url_nonzero_db_without_azure_mi_passes(self):
|
||||
config = DifyConfig(
|
||||
REDIS_USE_AZURE_MANAGED_IDENTITY=False,
|
||||
PUBSUB_REDIS_URL="redis://localhost:6379/3",
|
||||
)
|
||||
assert config.PUBSUB_REDIS_URL == "redis://localhost:6379/3"
|
||||
|
||||
|
||||
class TestApplyAzureCeleryBrokerAuth:
|
||||
"""Test apply_azure_celery_broker_auth Celery configuration."""
|
||||
|
||||
def test_sets_broker_read_and_write_urls(self):
|
||||
mock_app = MagicMock()
|
||||
apply_azure_celery_broker_auth(mock_app, "rediss://:@host:10000/0")
|
||||
|
||||
mock_app.conf.update.assert_called_once()
|
||||
call_kwargs = mock_app.conf.update.call_args[1]
|
||||
|
||||
assert "broker_read_url" in call_kwargs
|
||||
assert "broker_write_url" in call_kwargs
|
||||
|
||||
def test_appends_credential_provider_query_param(self):
|
||||
mock_app = MagicMock()
|
||||
apply_azure_celery_broker_auth(mock_app, "rediss://:@host:10000/0")
|
||||
|
||||
call_kwargs = mock_app.conf.update.call_args[1]
|
||||
expected_param = "credential_provider=extensions.azure.AzureEntraIdCredentialProvider"
|
||||
assert expected_param in call_kwargs["broker_read_url"]
|
||||
assert expected_param in call_kwargs["broker_write_url"]
|
||||
|
||||
def test_uses_ampersand_when_url_already_has_query(self):
|
||||
mock_app = MagicMock()
|
||||
apply_azure_celery_broker_auth(mock_app, "rediss://:@host:10000/0?timeout=5")
|
||||
|
||||
call_kwargs = mock_app.conf.update.call_args[1]
|
||||
assert "&credential_provider=" in call_kwargs["broker_read_url"]
|
||||
|
||||
def test_uses_question_mark_when_url_has_no_query(self):
|
||||
mock_app = MagicMock()
|
||||
apply_azure_celery_broker_auth(mock_app, "rediss://:@host:10000/0")
|
||||
|
||||
call_kwargs = mock_app.conf.update.call_args[1]
|
||||
assert "?credential_provider=" in call_kwargs["broker_read_url"]
|
||||
|
||||
|
||||
class TestAzureEntraIdCredentialProvider:
|
||||
"""Test AzureEntraIdCredentialProvider wrapper."""
|
||||
|
||||
def test_get_credentials_delegates_to_inner(self):
|
||||
provider = AzureEntraIdCredentialProvider.__new__(AzureEntraIdCredentialProvider)
|
||||
mock_inner = MagicMock()
|
||||
mock_inner.get_credentials.return_value = ("user-oid", "jwt-token")
|
||||
provider._inner = mock_inner
|
||||
|
||||
result = provider.get_credentials()
|
||||
assert result == ("user-oid", "jwt-token")
|
||||
|
||||
|
||||
class TestGetAzureCredentialProvider:
|
||||
"""Test get_azure_credential_provider factory."""
|
||||
|
||||
@patch("redis_entraid.cred_provider.create_from_default_azure_credential")
|
||||
def test_calls_create_with_correct_scope(self, mock_create):
|
||||
mock_create.return_value = MagicMock()
|
||||
get_azure_credential_provider()
|
||||
|
||||
mock_create.assert_called_once_with(
|
||||
scopes=("https://redis.azure.com/.default",),
|
||||
)
|
||||
|
||||
@patch("redis_entraid.cred_provider.create_from_default_azure_credential")
|
||||
def test_returns_provider_instance(self, mock_create):
|
||||
sentinel = MagicMock()
|
||||
mock_create.return_value = sentinel
|
||||
|
||||
result = get_azure_credential_provider()
|
||||
assert result is sentinel
|
||||
|
||||
|
||||
class TestApplyAzureRedisAuth:
|
||||
"""Test apply_azure_redis_auth params mutation."""
|
||||
|
||||
@patch("extensions.azure.get_azure_credential_provider")
|
||||
def test_removes_username_and_password(self, mock_get_provider):
|
||||
mock_get_provider.return_value = MagicMock()
|
||||
params: dict = {"host": "localhost", "username": "user", "password": "secret"}
|
||||
|
||||
apply_azure_redis_auth(params)
|
||||
|
||||
assert "username" not in params
|
||||
assert "password" not in params
|
||||
|
||||
@patch("extensions.azure.get_azure_credential_provider")
|
||||
def test_injects_credential_provider(self, mock_get_provider):
|
||||
sentinel = MagicMock()
|
||||
mock_get_provider.return_value = sentinel
|
||||
params: dict = {"host": "localhost", "username": "u", "password": "p"}
|
||||
|
||||
apply_azure_redis_auth(params)
|
||||
|
||||
assert params["credential_provider"] is sentinel
|
||||
|
||||
@patch("extensions.azure.get_azure_credential_provider")
|
||||
def test_handles_missing_username_password(self, mock_get_provider):
|
||||
mock_get_provider.return_value = MagicMock()
|
||||
params: dict = {"host": "localhost"}
|
||||
|
||||
apply_azure_redis_auth(params)
|
||||
|
||||
assert "username" not in params
|
||||
assert "password" not in params
|
||||
assert "credential_provider" in params
|
||||
@@ -4,6 +4,7 @@ from werkzeug.exceptions import BadRequest, Unauthorized
|
||||
|
||||
from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_CSRF_TOKEN, COOKIE_NAME_REFRESH_TOKEN
|
||||
from core.errors.error import AppInvokeQuotaExceededError
|
||||
from core.plugin.impl.exc import PluginRuntimeError
|
||||
from libs.exception import BaseHTTPException
|
||||
from libs.external_api import ExternalApi
|
||||
from libs.rate_limit import _BearerRateLimited
|
||||
@@ -39,6 +40,14 @@ def _create_api_app():
|
||||
def get(self):
|
||||
raise RuntimeError("oops")
|
||||
|
||||
@api.route("/plugin-runtime-error")
|
||||
class PluginRuntime(Resource):
|
||||
def get(self):
|
||||
raise PluginRuntimeError(
|
||||
"Plugin runtime request failed: Runtime.ExitError: Runtime exited with error: exit status 1",
|
||||
lambda_request_id="lambda-request-id",
|
||||
)
|
||||
|
||||
# Note: We avoid altering default_mediatype to keep normal error paths
|
||||
|
||||
# Special 400 message rewrite
|
||||
@@ -107,6 +116,24 @@ def test_external_api_json_message_and_bad_request_rewrite():
|
||||
assert res.get_json()["message"] == "Invalid JSON payload received or JSON payload is empty."
|
||||
|
||||
|
||||
def test_external_api_plugin_runtime_error(mocker):
|
||||
mocker.patch("libs.external_api.get_request_id", return_value="api-request-id")
|
||||
app = _create_api_app()
|
||||
|
||||
res = app.test_client().get("/api/plugin-runtime-error")
|
||||
|
||||
assert res.status_code == 502
|
||||
assert res.get_json() == {
|
||||
"code": "plugin_runtime_error",
|
||||
"message": "Plugin runtime request failed: Runtime.ExitError: Runtime exited with error: exit status 1",
|
||||
"details": {
|
||||
"request_id": "api-request-id",
|
||||
"lambda_request_id": "lambda-request-id",
|
||||
},
|
||||
"status": 502,
|
||||
}
|
||||
|
||||
|
||||
def test_external_api_param_mapping_and_quota():
|
||||
app = _create_api_app()
|
||||
client = app.test_client()
|
||||
|
||||
@@ -135,38 +135,6 @@ def test_get_published_agent_soul_for_app_returns_none_without_backing_agent():
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_peek_authz_app_id_uses_the_parent_app_not_the_hidden_backing_app():
|
||||
"""A workflow-only Agent is authorized against its parent workflow App."""
|
||||
agent = SimpleNamespace(id="agent-1", backing_app_id="backing-app-1", app_id="parent-app-1")
|
||||
service = AgentRosterService(FakeSession(scalar=[agent]))
|
||||
|
||||
result = service.peek_authz_app_id(tenant_id="tenant-1", agent_id="agent-1")
|
||||
|
||||
assert result == "parent-app-1"
|
||||
|
||||
|
||||
def test_peek_authz_app_id_uses_the_roster_agent_app():
|
||||
agent = SimpleNamespace(id="agent-1", backing_app_id=None, app_id="roster-app-1")
|
||||
service = AgentRosterService(FakeSession(scalar=[agent]))
|
||||
|
||||
result = service.peek_authz_app_id(tenant_id="tenant-1", agent_id="agent-1")
|
||||
|
||||
assert result == "roster-app-1"
|
||||
|
||||
|
||||
def test_peek_authz_app_id_returns_none_without_creating_a_backing_app():
|
||||
"""Authorization checks must not materialize the hidden backing App."""
|
||||
session = FakeSession(scalar=[None])
|
||||
service = AgentRosterService(session)
|
||||
|
||||
result = service.peek_authz_app_id(tenant_id="tenant-1", agent_id="agent-1")
|
||||
|
||||
assert result is None
|
||||
assert session.added == []
|
||||
assert session.commits == 0
|
||||
assert session.flushes == 0
|
||||
|
||||
|
||||
def test_load_workflow_composer_returns_empty_state(monkeypatch: pytest.MonkeyPatch):
|
||||
session = FakeSession()
|
||||
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1"))
|
||||
|
||||
@@ -933,20 +933,3 @@ class TestListOption:
|
||||
"page_number": 1,
|
||||
"resource_type": "app",
|
||||
}
|
||||
|
||||
|
||||
class TestLegacyAgentManageKey:
|
||||
def test_legacy_agent_manage_key_membership(self):
|
||||
# Mirrors the builtin roles in the rbac service, which grant agent.manage
|
||||
# to owner/admin/editor only.
|
||||
for keys in (
|
||||
svc._LEGACY_WORKSPACE_OWNER_KEYS,
|
||||
svc._LEGACY_WORKSPACE_ADMIN_KEYS,
|
||||
svc._LEGACY_WORKSPACE_EDITOR_KEYS,
|
||||
):
|
||||
assert "agent.manage" in keys
|
||||
for keys in (
|
||||
svc._LEGACY_WORKSPACE_NORMAL_KEYS,
|
||||
svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS,
|
||||
):
|
||||
assert "agent.manage" not in keys
|
||||
|
||||
@@ -5,12 +5,10 @@ from unittest.mock import Mock
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.rbac import RBACPermission
|
||||
from models import App, AppMode
|
||||
from models.model import AppModelConfig, IconType
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.entities.dsl_entities import ImportStatus
|
||||
from services.errors.account import NoPermissionError
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
@@ -168,61 +166,3 @@ def test_export_dsl_loads_model_config_and_annotation_reply_with_request_session
|
||||
session.get.assert_called_once_with(AppModelConfig, "config-1")
|
||||
load_annotation_reply_config.assert_called_once_with(session, "app-1")
|
||||
app_model_config.to_dict.assert_called_once_with(annotation_reply=annotation_reply)
|
||||
|
||||
|
||||
def test_ensure_agent_manage_permission_noops_when_rbac_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", False)
|
||||
check = Mock()
|
||||
monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check)
|
||||
|
||||
AppDslService._ensure_agent_manage_permission(Mock(id="account-1", current_tenant_id="tenant-1"))
|
||||
|
||||
check.assert_not_called()
|
||||
|
||||
|
||||
def test_ensure_agent_manage_permission_allows_agent_manager(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True)
|
||||
check = Mock(return_value=True)
|
||||
monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check)
|
||||
|
||||
AppDslService._ensure_agent_manage_permission(Mock(id="account-1", current_tenant_id="tenant-1"))
|
||||
|
||||
check.assert_called_once_with("tenant-1", "account-1", scene=RBACPermission.AGENT_MANAGE)
|
||||
|
||||
|
||||
def test_ensure_agent_manage_permission_rejects_without_agent_manage(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True)
|
||||
monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", Mock(return_value=False))
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
AppDslService._ensure_agent_manage_permission(Mock(id="account-1", current_tenant_id="tenant-1"))
|
||||
|
||||
|
||||
def test_create_or_update_app_gates_agent_mode_before_creation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True)
|
||||
monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", Mock(return_value=False))
|
||||
session = Mock()
|
||||
service = AppDslService(session=session)
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
service._create_or_update_app(
|
||||
app=None,
|
||||
data={"app": {"mode": "agent", "name": "Gated agent"}},
|
||||
account=Mock(id="account-1", current_tenant_id="tenant-1"),
|
||||
)
|
||||
|
||||
session.add.assert_not_called()
|
||||
session.flush.assert_not_called()
|
||||
|
||||
|
||||
def test_import_app_reraises_permission_denial_instead_of_failed_result(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True)
|
||||
monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", Mock(return_value=False))
|
||||
service = AppDslService(session=Mock())
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
service.import_app(
|
||||
account=Mock(id="account-1", current_tenant_id="tenant-1"),
|
||||
import_mode="yaml-content",
|
||||
yaml_content="app:\n mode: agent\n name: Denied agent\n",
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from pydantic import SecretStr
|
||||
from core.helper import ssrf_proxy
|
||||
from core.rbac import RBACPermission
|
||||
from core.tools.errors import ToolSSRFError
|
||||
from extensions.ext_blueprints import CONSOLE_HEADERS
|
||||
from services.knowledge_fs_operations import (
|
||||
KNOWLEDGE_FS_CONSOLE_OPERATIONS,
|
||||
KnowledgeFSMethod,
|
||||
@@ -250,6 +251,17 @@ def test_console_registry_preserves_explicit_scope_and_authorization_policies()
|
||||
assert operation.response_headers == ("x-trace-id",)
|
||||
|
||||
|
||||
def test_console_cors_allows_every_contract_declared_request_header() -> None:
|
||||
cors_headers = {header.lower() for header in CONSOLE_HEADERS}
|
||||
contract_headers = {
|
||||
header.lower()
|
||||
for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS
|
||||
for header in operation.request_headers
|
||||
}
|
||||
|
||||
assert contract_headers <= cors_headers
|
||||
|
||||
|
||||
def test_console_registry_preserves_special_transport_contracts() -> None:
|
||||
crawl_preview = get_knowledge_fs_operation(
|
||||
"POST",
|
||||
|
||||
Generated
-18
@@ -1371,7 +1371,6 @@ dependencies = [
|
||||
{ name = "python-socketio" },
|
||||
{ name = "readabilipy" },
|
||||
{ name = "redis", extra = ["hiredis"] },
|
||||
{ name = "redis-entraid" },
|
||||
{ name = "resend" },
|
||||
{ name = "sendgrid" },
|
||||
{ name = "sseclient-py" },
|
||||
@@ -1656,7 +1655,6 @@ requires-dist = [
|
||||
{ name = "python-socketio", specifier = ">=5.13.0,<6.0.0" },
|
||||
{ name = "readabilipy", specifier = "==0.3.0" },
|
||||
{ name = "redis", extras = ["hiredis"], specifier = ">=7.4.0,<8.0.0" },
|
||||
{ name = "redis-entraid", specifier = ">=1.2.0,<2.0.0" },
|
||||
{ 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" },
|
||||
@@ -5821,22 +5819,6 @@ hiredis = [
|
||||
{ name = "hiredis" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis-entraid"
|
||||
version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "azure-identity" },
|
||||
{ name = "msal" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "redis" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/a7/0ddaeb27b33c76709e05a12b3bbeefce893c82a3a830146608d6fe620000/redis_entraid-1.2.1.tar.gz", hash = "sha256:a7c479ce46e6edb35bce9dd804d1cad7be99a3330815cfe028a648b486a10b41", size = 9792, upload-time = "2026-06-03T11:38:55.613Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/ca/01b8607102de756b270d3f6befeee700bd82dace4303d6f47ce0f53c11b0/redis_entraid-1.2.1-py3-none-any.whl", hash = "sha256:9de7e4a716b156d966a2d6bb5b5ccd64a692db30ae21fe3987f57d233793d558", size = 7967, upload-time = "2026-06-03T11:38:54.497Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
|
||||
@@ -121,7 +121,6 @@ REDIS_KEEPALIVE=true
|
||||
REDIS_KEEPALIVE_IDLE=30
|
||||
REDIS_KEEPALIVE_INTERVAL=10
|
||||
REDIS_KEEPALIVE_COUNT=10
|
||||
REDIS_USE_AZURE_MANAGED_IDENTITY=false
|
||||
CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1
|
||||
CELERY_BACKEND=redis
|
||||
BROKER_USE_SSL=false
|
||||
|
||||
+14
-1
@@ -114,7 +114,20 @@ Behavior depends on instance state:
|
||||
- uninitialized instance: completes install and stores authenticated state
|
||||
- initialized instance: signs in and reuses authenticated state
|
||||
|
||||
The `pnpm -C e2e e2e:full*` flows prove reset and authentication bootstrap by failing setup when initialization cannot complete; they do not model bootstrap state as a Gherkin scenario. Deterministic runs exclude `@prepared`, `@external-model`, and `@external-tool`. Post-merge first seeds required fixtures, then runs prepared and external scenarios.
|
||||
The `pnpm -C e2e e2e:full*` flows prove reset and authentication bootstrap by failing setup when initialization cannot complete; they do not model bootstrap state as a Gherkin scenario. Deterministic runs exclude `@prepared`, `@external-model`, `@external-tool`, and `@new-rag-smoke`. Post-merge first seeds required fixtures, then runs prepared and external scenarios.
|
||||
|
||||
The New RAG release smoke is an opt-in live integration matrix. The first matrix entry resets E2E state; later entries restart middleware, API, Celery, and web without resetting persisted data. The default-disabled, explicit-disabled, and enabled configurations therefore verify the same Legacy dataset snapshot while reusing a running KnowledgeFS service and a real website-source provider. Run it with:
|
||||
|
||||
```bash
|
||||
E2E_NEW_RAG_KNOWLEDGE_FS_BASE_URL=http://127.0.0.1:8788 \
|
||||
E2E_NEW_RAG_KNOWLEDGE_FS_JWT_SECRET='<shared-jwt-secret>' \
|
||||
E2E_NEW_RAG_CRAWL_URL=https://example.com \
|
||||
E2E_NEW_RAG_CONNECTION_CREDENTIALS_JSON='{"apiKey":"<provider-key>"}' \
|
||||
pnpm -C e2e e2e:new-rag
|
||||
```
|
||||
|
||||
Keep `@new-rag-smoke` scenarios out of the default suite. They require a reachable KnowledgeFS `/health` endpoint, valid provider credentials, and real external crawling; the runner fails before starting Dify when any live input is missing or invalid.
|
||||
Credential object keys must match the fields exposed by the live datasource provider catalog.
|
||||
|
||||
Reset all persisted E2E state:
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { IConfiguration } from '@cucumber/cucumber'
|
||||
import './scripts/env-register'
|
||||
|
||||
const hasCliTags = process.argv.some((arg) => arg === '--tags' || arg.startsWith('--tags='))
|
||||
const defaultNonExternalTags = 'not @prepared and not @external-model and not @external-tool'
|
||||
const defaultNonExternalTags =
|
||||
'not @prepared and not @external-model and not @external-tool and not @new-rag-smoke'
|
||||
const defaultTags =
|
||||
process.env.E2E_CUCUMBER_TAGS || (hasCliTags ? undefined : defaultNonExternalTags)
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
@datasets @authenticated @new-rag-smoke
|
||||
Feature: New RAG release smoke
|
||||
@new-rag-flag-default
|
||||
Scenario: The default feature configuration preserves the Legacy Knowledge experience
|
||||
Given I am signed in as the default E2E admin
|
||||
And a Legacy Knowledge dataset exists
|
||||
And I monitor New RAG network requests
|
||||
When I open the Knowledge console
|
||||
Then the New RAG feature should be disabled by "default-disabled"
|
||||
And the New Knowledge view should be unavailable
|
||||
And the Legacy Knowledge dataset should remain available
|
||||
When I try to open the New Knowledge creation route
|
||||
Then I should return to the Legacy Knowledge console
|
||||
And the Legacy Knowledge dataset should remain available
|
||||
And no KnowledgeFS request should leave the browser
|
||||
|
||||
@new-rag-flag-disabled
|
||||
Scenario: Explicitly disabling the feature preserves the Legacy Knowledge experience
|
||||
Given I am signed in as the default E2E admin
|
||||
And a Legacy Knowledge dataset exists
|
||||
And I monitor New RAG network requests
|
||||
When I open the Knowledge console
|
||||
Then the New RAG feature should be disabled by "explicit-disabled"
|
||||
And the New Knowledge view should be unavailable
|
||||
And the Legacy Knowledge dataset should remain available
|
||||
When I try to open the New Knowledge creation route
|
||||
Then I should return to the Legacy Knowledge console
|
||||
And the Legacy Knowledge dataset should remain available
|
||||
And no KnowledgeFS request should leave the browser
|
||||
|
||||
@new-rag-happy-path @external-source
|
||||
Scenario: Create a Knowledge space from a website and inspect its ready document
|
||||
Given I am signed in as the default E2E admin
|
||||
And a Legacy Knowledge dataset exists
|
||||
And the Firecrawl datasource plugin is installed
|
||||
And I monitor New RAG network requests
|
||||
When I open the Knowledge console
|
||||
Then the New RAG feature should be enabled
|
||||
And the Legacy Knowledge view should remain available
|
||||
And the Legacy Knowledge dataset should remain available
|
||||
When I switch to the New Knowledge view
|
||||
And I create a private E2E Knowledge space
|
||||
And I connect the configured Firecrawl provider
|
||||
And I crawl the configured website
|
||||
And I select every crawled page with a manual sync policy
|
||||
Then the website source should become active
|
||||
When I open the source Documents
|
||||
Then a crawled document should become ready
|
||||
When I open the ready document
|
||||
Then I should see its revision and chunk tree
|
||||
When I refresh the current page
|
||||
Then the same document detail should be restored
|
||||
When I return to the source Documents
|
||||
Then the same ready document should remain available
|
||||
When I reopen the ready document
|
||||
Then the same document detail should be restored
|
||||
And the New RAG requests should be proxied with diagnostics
|
||||
When I return to Legacy Knowledge
|
||||
Then the Legacy Knowledge dataset should remain available
|
||||
And the KnowledgeFS tenant and read-only boundaries should hold
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { ConsoleClient } from '../../../support/api/console-client'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { createHmac } from 'node:crypto'
|
||||
import { resolveNewRagSmokeConfig } from '../../../scripts/run-new-rag-smoke'
|
||||
|
||||
type KnowledgeFsScope = 'knowledge-spaces:read' | 'knowledge-spaces:write'
|
||||
type DifyIdentity = { accountId: string; tenantId: string }
|
||||
type KnowledgeSpaceDeletionSnapshot = { name: string; revision: number }
|
||||
|
||||
const base64Url = (value: string | Buffer) => Buffer.from(value).toString('base64url')
|
||||
|
||||
export const createKnowledgeFsJwt = ({
|
||||
accountId,
|
||||
scope,
|
||||
secret,
|
||||
tenantId,
|
||||
}: {
|
||||
accountId: string
|
||||
scope: KnowledgeFsScope
|
||||
secret: string
|
||||
tenantId: string
|
||||
}) => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const header = base64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const payload = base64Url(
|
||||
JSON.stringify({
|
||||
aud: 'knowledge-fs',
|
||||
caller_kind: 'interactive',
|
||||
dify_account_id: `dify-account:${accountId}`,
|
||||
exp: now + 60,
|
||||
iat: now,
|
||||
iss: 'dify',
|
||||
scopes: [scope],
|
||||
sub: `dify-workspace:${tenantId}`,
|
||||
tenant_id: tenantId,
|
||||
}),
|
||||
)
|
||||
const signature = createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url')
|
||||
return `${header}.${payload}.${signature}`
|
||||
}
|
||||
|
||||
const getDifyIdentity = async (client: ConsoleClient) => {
|
||||
const [account, workspace] = await Promise.all([
|
||||
client.account.profile.get(),
|
||||
client.workspaces.current.post(),
|
||||
])
|
||||
if (!account.id || !workspace.id)
|
||||
throw new Error('The current Dify account or workspace has no stable identifier.')
|
||||
return { accountId: account.id, tenantId: workspace.id }
|
||||
}
|
||||
|
||||
const directKnowledgeFsRequest = async ({
|
||||
accountId,
|
||||
body,
|
||||
method,
|
||||
path,
|
||||
requestHeaders,
|
||||
scope,
|
||||
tenantId,
|
||||
}: {
|
||||
accountId: string
|
||||
body?: Record<string, unknown>
|
||||
method: 'DELETE' | 'GET' | 'POST'
|
||||
path: string
|
||||
requestHeaders?: Record<string, string>
|
||||
scope: KnowledgeFsScope
|
||||
tenantId: string
|
||||
}) => {
|
||||
const config = resolveNewRagSmokeConfig(process.env)
|
||||
const token = createKnowledgeFsJwt({
|
||||
accountId,
|
||||
scope,
|
||||
secret: config.knowledgeFsJwtSecret,
|
||||
tenantId,
|
||||
})
|
||||
return fetch(new URL(path, `${config.knowledgeFsBaseUrl}/`), {
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...requestHeaders,
|
||||
},
|
||||
method,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
}
|
||||
|
||||
export const assertKnowledgeFsAccessBoundaries = async (
|
||||
knowledgeSpaceId: string,
|
||||
client: ConsoleClient,
|
||||
) => {
|
||||
const identity = await getDifyIdentity(client)
|
||||
const crossTenant = await directKnowledgeFsRequest({
|
||||
accountId: identity.accountId,
|
||||
method: 'GET',
|
||||
path: `knowledge-spaces/${knowledgeSpaceId}`,
|
||||
scope: 'knowledge-spaces:read',
|
||||
tenantId: `${identity.tenantId}-other`,
|
||||
})
|
||||
if (![403, 404].includes(crossTenant.status))
|
||||
throw new Error(
|
||||
`Cross-tenant KnowledgeFS read returned ${crossTenant.status}; expected 403 or 404.`,
|
||||
)
|
||||
|
||||
const readOnlyWrite = await directKnowledgeFsRequest({
|
||||
accountId: identity.accountId,
|
||||
body: {
|
||||
idempotencyKey: `e2e-read-only-${knowledgeSpaceId}`,
|
||||
name: `E2E forbidden ${knowledgeSpaceId}`,
|
||||
},
|
||||
method: 'POST',
|
||||
path: 'knowledge-spaces',
|
||||
scope: 'knowledge-spaces:read',
|
||||
tenantId: identity.tenantId,
|
||||
})
|
||||
if (readOnlyWrite.status !== 403)
|
||||
throw new Error(`Read-only KnowledgeFS write returned ${readOnlyWrite.status}; expected 403.`)
|
||||
}
|
||||
|
||||
export const deleteKnowledgeFsSpaceWithIdentity = async (
|
||||
knowledgeSpaceId: string,
|
||||
identity: DifyIdentity,
|
||||
) => {
|
||||
const snapshotResponse = await directKnowledgeFsRequest({
|
||||
accountId: identity.accountId,
|
||||
method: 'GET',
|
||||
path: `knowledge-spaces/${knowledgeSpaceId}`,
|
||||
scope: 'knowledge-spaces:read',
|
||||
tenantId: identity.tenantId,
|
||||
})
|
||||
if (snapshotResponse.status === 404) return
|
||||
if (!snapshotResponse.ok)
|
||||
throw new Error(
|
||||
`Read KnowledgeFS smoke space ${knowledgeSpaceId} for cleanup failed with ${snapshotResponse.status} ${snapshotResponse.statusText}.`,
|
||||
)
|
||||
const snapshot = (await snapshotResponse.json()) as Partial<KnowledgeSpaceDeletionSnapshot>
|
||||
if (
|
||||
!snapshot.name ||
|
||||
typeof snapshot.revision !== 'number' ||
|
||||
!Number.isInteger(snapshot.revision) ||
|
||||
snapshot.revision < 1
|
||||
)
|
||||
throw new Error(`KnowledgeFS smoke space ${knowledgeSpaceId} has no valid deletion snapshot.`)
|
||||
|
||||
const response = await directKnowledgeFsRequest({
|
||||
accountId: identity.accountId,
|
||||
body: { challenge: snapshot.name, expectedRevision: snapshot.revision },
|
||||
method: 'DELETE',
|
||||
path: `knowledge-spaces/${knowledgeSpaceId}`,
|
||||
requestHeaders: { 'Idempotency-Key': `e2e-delete-${knowledgeSpaceId}` },
|
||||
scope: 'knowledge-spaces:write',
|
||||
tenantId: identity.tenantId,
|
||||
})
|
||||
if (response.ok || response.status === 404) return
|
||||
const body = await response.text().catch(() => '')
|
||||
throw new Error(
|
||||
`Delete KnowledgeFS smoke space ${knowledgeSpaceId} failed with ${response.status} ${response.statusText}${body ? `: ${body}` : ''}.`,
|
||||
)
|
||||
}
|
||||
|
||||
export const deleteKnowledgeFsSpace = async (knowledgeSpaceId: string, client: ConsoleClient) =>
|
||||
deleteKnowledgeFsSpaceWithIdentity(knowledgeSpaceId, await getDifyIdentity(client))
|
||||
@@ -0,0 +1,399 @@
|
||||
import type { DatasetDetailResponse } from '@dify/contracts/api/console/datasets/types.gen'
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import { resolveNewRagSmokeConfig } from '../../../scripts/run-new-rag-smoke'
|
||||
import { bootstrapMarketplacePlugins } from '../../../support/marketplace-plugins'
|
||||
import { createE2EResourceName } from '../../../support/naming'
|
||||
import {
|
||||
assertKnowledgeFsAccessBoundaries,
|
||||
deleteKnowledgeFsSpace,
|
||||
} from '../../new-rag/support/runtime'
|
||||
|
||||
const knowledgeFsProxyPath = '/console/api/knowledge-fs/'
|
||||
|
||||
type LegacyDatasetSnapshot = Pick<
|
||||
DatasetDetailResponse,
|
||||
'created_at' | 'created_by' | 'description' | 'id' | 'name' | 'permission' | 'updated_at'
|
||||
>
|
||||
|
||||
const legacyDatasetEnvironment = () => {
|
||||
const name = process.env.E2E_NEW_RAG_LEGACY_DATASET_NAME?.trim()
|
||||
const statePath = process.env.E2E_NEW_RAG_LEGACY_DATASET_STATE_PATH?.trim()
|
||||
if (!name || !statePath)
|
||||
throw new Error('The shared Legacy Knowledge dataset configuration is missing.')
|
||||
return { name, statePath }
|
||||
}
|
||||
|
||||
const legacyDatasetSnapshot = (dataset: DatasetDetailResponse): LegacyDatasetSnapshot => ({
|
||||
created_at: dataset.created_at,
|
||||
created_by: dataset.created_by,
|
||||
description: dataset.description,
|
||||
id: dataset.id,
|
||||
name: dataset.name,
|
||||
permission: dataset.permission,
|
||||
updated_at: dataset.updated_at,
|
||||
})
|
||||
|
||||
const isLegacyDatasetSnapshot = (value: unknown): value is LegacyDatasetSnapshot => {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const snapshot = value as Record<string, unknown>
|
||||
return (
|
||||
typeof snapshot.created_at === 'number' &&
|
||||
typeof snapshot.created_by === 'string' &&
|
||||
(snapshot.description === null || typeof snapshot.description === 'string') &&
|
||||
typeof snapshot.id === 'string' &&
|
||||
typeof snapshot.name === 'string' &&
|
||||
typeof snapshot.permission === 'string' &&
|
||||
typeof snapshot.updated_at === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
const readLegacyDatasetSnapshot = async (statePath: string) => {
|
||||
const snapshot = JSON.parse(await readFile(statePath, 'utf8')) as unknown
|
||||
if (!isLegacyDatasetSnapshot(snapshot))
|
||||
throw new Error('The shared Legacy Knowledge dataset snapshot is invalid.')
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const humanizeFieldName = (name: string) =>
|
||||
name
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/^./, (character) => character.toUpperCase())
|
||||
|
||||
Given('a Legacy Knowledge dataset exists', async function (this: DifyWorld) {
|
||||
const { name, statePath } = legacyDatasetEnvironment()
|
||||
const mode = process.env.E2E_NEW_RAG_EXPECTED_FLAG_MODE
|
||||
if (mode === 'default-disabled') {
|
||||
const dataset = await this.getConsoleClient().datasets.post({ body: { name } })
|
||||
await writeFile(statePath, JSON.stringify(legacyDatasetSnapshot(dataset)), 'utf8')
|
||||
this.newRag.legacyDatasetName = name
|
||||
return
|
||||
}
|
||||
|
||||
const expectedSnapshot = await readLegacyDatasetSnapshot(statePath)
|
||||
if (mode === 'enabled') this.createdDatasetIds.push(expectedSnapshot.id)
|
||||
const dataset = await this.getConsoleClient().datasets.byDatasetId.get({
|
||||
params: { dataset_id: expectedSnapshot.id },
|
||||
})
|
||||
expect(legacyDatasetSnapshot(dataset)).toEqual(expectedSnapshot)
|
||||
this.newRag.legacyDatasetName = expectedSnapshot.name
|
||||
})
|
||||
|
||||
Given(
|
||||
'the Firecrawl datasource plugin is installed',
|
||||
{ timeout: 360_000 },
|
||||
async function (this: DifyWorld) {
|
||||
const result = await bootstrapMarketplacePlugins(
|
||||
{ consoleClient: this.getConsoleClient(), dryRun: false, resources: new Map() },
|
||||
{
|
||||
defaultPluginIds: ['langgenius/firecrawl_datasource'],
|
||||
pluginIdsEnv: 'E2E_NEW_RAG_PLUGIN_IDS',
|
||||
title: 'New RAG Firecrawl datasource plugin',
|
||||
},
|
||||
)
|
||||
if (result.status === 'blocked')
|
||||
throw new Error(result.reason ?? 'The Firecrawl datasource plugin could not be installed.')
|
||||
},
|
||||
)
|
||||
|
||||
Given('I monitor New RAG network requests', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url())
|
||||
if (url.pathname.includes(knowledgeFsProxyPath))
|
||||
this.newRag.knowledgeFsRequests.push(request.url())
|
||||
})
|
||||
page.on('requestfailed', (request) => {
|
||||
const url = new URL(request.url())
|
||||
if (!url.pathname.includes(knowledgeFsProxyPath)) return
|
||||
this.newRag.knowledgeFsRequestFailures.push({
|
||||
error: request.failure()?.errorText ?? 'Unknown browser request failure',
|
||||
method: request.method(),
|
||||
url: request.url(),
|
||||
})
|
||||
})
|
||||
page.on('response', (response) => {
|
||||
const url = new URL(response.url())
|
||||
if (!url.pathname.includes(knowledgeFsProxyPath)) return
|
||||
const traceId = response.headers()['x-trace-id']
|
||||
this.newRag.knowledgeFsResponses.push({
|
||||
method: response.request().method(),
|
||||
status: response.status(),
|
||||
...(traceId ? { traceId } : {}),
|
||||
url: response.url(),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
When('I open the Knowledge console', async function (this: DifyWorld) {
|
||||
await this.getPage().goto('/datasets')
|
||||
await expect(
|
||||
this.getPage().getByRole('heading', { name: 'Knowledge', exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
Then(
|
||||
'the New RAG feature should be disabled by {string}',
|
||||
async function (this: DifyWorld, expectedMode: string) {
|
||||
void this
|
||||
expect(process.env.E2E_NEW_RAG_EXPECTED_FLAG_MODE).toBe(expectedMode)
|
||||
expect((await this.getConsoleClient().systemFeatures.get()).knowledge_fs_enabled).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
Then('the New RAG feature should be enabled', async function (this: DifyWorld) {
|
||||
expect(process.env.E2E_NEW_RAG_EXPECTED_FLAG_MODE).toBe('enabled')
|
||||
expect((await this.getConsoleClient().systemFeatures.get()).knowledge_fs_enabled).toBe(true)
|
||||
await expect(this.getPage().getByRole('button', { name: 'New', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
Then('the Legacy Knowledge view should remain available', async function (this: DifyWorld) {
|
||||
const legacyView = this.getPage().getByRole('button', { name: 'Legacy', exact: true })
|
||||
await expect(legacyView).toBeVisible()
|
||||
await expect(legacyView).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
Then('the Legacy Knowledge dataset should remain available', async function (this: DifyWorld) {
|
||||
const name = this.newRag.legacyDatasetName
|
||||
if (!name) throw new Error('The Legacy Knowledge smoke dataset name is missing.')
|
||||
await expect(this.getPage().getByText(name, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
Then('the New Knowledge view should be unavailable', async function (this: DifyWorld) {
|
||||
await expect(this.getPage().getByRole('button', { name: 'New', exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
When('I try to open the New Knowledge creation route', async function (this: DifyWorld) {
|
||||
await this.getPage().goto('/datasets/new/create')
|
||||
})
|
||||
|
||||
Then('I should return to the Legacy Knowledge console', async function (this: DifyWorld) {
|
||||
await expect(this.getPage()).toHaveURL(/\/datasets(?:\?.*)?$/)
|
||||
await expect(
|
||||
this.getPage().getByRole('heading', { name: 'Knowledge', exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
Then('no KnowledgeFS request should leave the browser', async function (this: DifyWorld) {
|
||||
expect(this.newRag.knowledgeFsRequests).toEqual([])
|
||||
expect(this.newRag.knowledgeFsRequestFailures).toEqual([])
|
||||
expect(this.newRag.knowledgeFsResponses).toEqual([])
|
||||
})
|
||||
|
||||
When('I switch to the New Knowledge view', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const guide = page.getByRole('dialog', { name: 'Meet the new Knowledge Base ✨' })
|
||||
await expect(guide).toBeVisible()
|
||||
await guide.getByRole('button', { name: 'Got it' }).click()
|
||||
await page.getByRole('button', { name: 'New', exact: true }).click()
|
||||
await expect(page.getByRole('region', { name: 'New', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
When('I create a private E2E Knowledge space', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const name = createE2EResourceName('New RAG Knowledge')
|
||||
await page.getByRole('link', { name: 'Create' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Knowledge' })).toBeVisible()
|
||||
await page.getByLabel('Knowledge name').fill(name)
|
||||
await page.getByLabel('Description').fill('New RAG end-to-end release smoke resource')
|
||||
const permission = page.getByRole('combobox', { name: 'Permission' })
|
||||
if (await permission.isEnabled()) {
|
||||
await permission.click()
|
||||
await page.getByRole('option', { name: 'Only me' }).click()
|
||||
} else {
|
||||
await expect(permission).toBeDisabled()
|
||||
await expect(permission).toContainText('Only me')
|
||||
}
|
||||
await page.getByRole('button', { name: 'Create Knowledge' }).click()
|
||||
await expect(page).toHaveURL(/\/datasets\/new\/([^/]+)\/sources$/)
|
||||
const match = page.url().match(/\/datasets\/new\/([^/]+)\/sources$/)
|
||||
const knowledgeSpaceId = match?.[1]
|
||||
if (!knowledgeSpaceId) throw new Error('Created Knowledge route did not include a space id.')
|
||||
this.newRag.knowledgeSpaceId = knowledgeSpaceId
|
||||
this.newRag.knowledgeSpaceName = name
|
||||
this.registerCleanup(() => deleteKnowledgeFsSpace(knowledgeSpaceId, this.getConsoleClient()))
|
||||
await expect(page.getByRole('heading', { name })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'No sources connected yet' })).toBeVisible()
|
||||
})
|
||||
|
||||
When('I connect the configured Firecrawl provider', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const config = resolveNewRagSmokeConfig(process.env)
|
||||
await page.getByRole('link', { name: 'Add source' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Add source' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Configure Firecrawl' }).click()
|
||||
for (const [field, value] of Object.entries(config.connectionCredentials))
|
||||
await page.getByLabel(humanizeFieldName(field)).fill(value)
|
||||
await page.getByRole('button', { name: 'Connect Firecrawl' }).click()
|
||||
await expect(page.getByRole('status').filter({ hasText: 'Firecrawl connected' })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
})
|
||||
})
|
||||
|
||||
When('I crawl the configured website', { timeout: 210_000 }, async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const config = resolveNewRagSmokeConfig(process.env)
|
||||
const sourceName = createE2EResourceName('Website Source')
|
||||
this.newRag.sourceName = sourceName
|
||||
await page.getByLabel('Root URL').fill(config.crawlUrl)
|
||||
await page.getByLabel('Source name').fill(sourceName)
|
||||
const responseStart = this.newRag.knowledgeFsResponses.length
|
||||
const failureStart = this.newRag.knowledgeFsRequestFailures.length
|
||||
await page.getByRole('button', { name: 'Crawl & preview' }).click()
|
||||
const selectAll = page.getByRole('checkbox', { name: 'Select all' })
|
||||
const crawlFailure = page.getByRole('alert').filter({ hasText: "Couldn't crawl" })
|
||||
await selectAll.or(crawlFailure).waitFor({ state: 'visible', timeout: 180_000 })
|
||||
if (await crawlFailure.isVisible()) {
|
||||
const errorResponse = this.newRag.knowledgeFsResponses
|
||||
.slice(responseStart)
|
||||
.filter((response) => response.status >= 400)
|
||||
.at(-1)
|
||||
const failure = this.newRag.knowledgeFsRequestFailures.slice(failureStart).at(-1)
|
||||
const requestDiagnostic = errorResponse
|
||||
? `${errorResponse.method} ${new URL(errorResponse.url).pathname}: HTTP ${errorResponse.status}${errorResponse.traceId ? ` (trace ${errorResponse.traceId})` : ''}`
|
||||
: failure
|
||||
? `${failure.method} ${new URL(failure.url).pathname}: ${failure.error}`
|
||||
: 'No failed KnowledgeFS browser request or error response was captured.'
|
||||
const failureText = (await crawlFailure.textContent()) ?? 'Crawl preview failed.'
|
||||
const message = failureText.replaceAll(/\s+/g, ' ').trim()
|
||||
throw new Error(`${message} ${requestDiagnostic}`)
|
||||
}
|
||||
})
|
||||
|
||||
When('I select every crawled page with a manual sync policy', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
await page.getByRole('checkbox', { name: 'Select all' }).click()
|
||||
await page.getByLabel('Sync policy').selectOption({ label: 'Manual only' })
|
||||
await page.getByRole('button', { name: 'Add source' }).click()
|
||||
await expect(page).toHaveURL(/\/datasets\/new\/[^/]+\/sources$/)
|
||||
})
|
||||
|
||||
Then(
|
||||
'the website source should become active',
|
||||
{ timeout: 210_000 },
|
||||
async function (this: DifyWorld) {
|
||||
const sourceName = this.newRag.sourceName
|
||||
if (!sourceName) throw new Error('The New RAG smoke source name is missing.')
|
||||
const row = this.getPage().getByRole('row').filter({ hasText: sourceName })
|
||||
await expect(row).toContainText('Active', { timeout: 180_000 })
|
||||
},
|
||||
)
|
||||
|
||||
When('I open the source Documents', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
await page.getByRole('link', { name: 'Documents' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible()
|
||||
})
|
||||
|
||||
Then(
|
||||
'a crawled document should become ready',
|
||||
{ timeout: 210_000 },
|
||||
async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const table = page.getByRole('table')
|
||||
const documentLink = table.getByRole('link').first()
|
||||
await expect(documentLink).toBeVisible({ timeout: 180_000 })
|
||||
const row = page.getByRole('row').filter({ has: documentLink })
|
||||
await expect(row).toContainText('Ready', { timeout: 180_000 })
|
||||
this.newRag.documentTitle = (await documentLink.textContent())?.trim()
|
||||
},
|
||||
)
|
||||
|
||||
When('I open the ready document', async function (this: DifyWorld) {
|
||||
const title = this.newRag.documentTitle
|
||||
if (!title) throw new Error('The ready New RAG document title is missing.')
|
||||
const page = this.getPage()
|
||||
await page.getByRole('table').getByRole('link', { name: title }).click()
|
||||
await expect(page).toHaveURL(/\/datasets\/new\/[^/]+\/documents\/[^/]+$/)
|
||||
this.newRag.documentUrl = page.url()
|
||||
})
|
||||
|
||||
Then('I should see its revision and chunk tree', async function (this: DifyWorld) {
|
||||
const title = this.newRag.documentTitle
|
||||
if (!title) throw new Error('The ready New RAG document title is missing.')
|
||||
const page = this.getPage()
|
||||
await expect(page.getByRole('heading', { name: title })).toBeVisible()
|
||||
await expect(page.getByRole('combobox', { name: 'Revision' })).toBeVisible()
|
||||
await expect(page.getByRole('tree')).toBeVisible()
|
||||
})
|
||||
|
||||
Then('the same document detail should be restored', async function (this: DifyWorld) {
|
||||
const { documentTitle, documentUrl } = this.newRag
|
||||
if (!documentTitle || !documentUrl) throw new Error('The document restore checkpoint is missing.')
|
||||
await expect(this.getPage()).toHaveURL(documentUrl)
|
||||
await expect(this.getPage().getByRole('heading', { name: documentTitle })).toBeVisible()
|
||||
await expect(this.getPage().getByRole('tree')).toBeVisible()
|
||||
})
|
||||
|
||||
When('I return to the source Documents', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const knowledgeSpaceName = this.newRag.knowledgeSpaceName
|
||||
if (!knowledgeSpaceName) throw new Error('The New RAG smoke Knowledge name is missing.')
|
||||
await page
|
||||
.getByRole('navigation', { name: knowledgeSpaceName })
|
||||
.getByRole('link', { name: 'Documents' })
|
||||
.click()
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible()
|
||||
})
|
||||
|
||||
Then('the same ready document should remain available', async function (this: DifyWorld) {
|
||||
const title = this.newRag.documentTitle
|
||||
if (!title) throw new Error('The ready New RAG document title is missing.')
|
||||
const documentLink = this.getPage().getByRole('table').getByRole('link', { name: title })
|
||||
await expect(documentLink).toBeVisible()
|
||||
await expect(this.getPage().getByRole('row').filter({ has: documentLink })).toContainText('Ready')
|
||||
})
|
||||
|
||||
When('I reopen the ready document', async function (this: DifyWorld) {
|
||||
const title = this.newRag.documentTitle
|
||||
if (!title) throw new Error('The ready New RAG document title is missing.')
|
||||
const page = this.getPage()
|
||||
await page.getByRole('table').getByRole('link', { name: title }).click()
|
||||
await expect(page).toHaveURL(
|
||||
this.newRag.documentUrl ?? /\/datasets\/new\/[^/]+\/documents\/[^/]+$/,
|
||||
)
|
||||
})
|
||||
|
||||
When('I return to Legacy Knowledge', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
await page.goto('/datasets')
|
||||
await expect(page.getByRole('button', { name: 'Legacy', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
)
|
||||
})
|
||||
|
||||
Then(
|
||||
'the KnowledgeFS tenant and read-only boundaries should hold',
|
||||
async function (this: DifyWorld) {
|
||||
const knowledgeSpaceId = this.newRag.knowledgeSpaceId
|
||||
if (!knowledgeSpaceId) throw new Error('The New RAG smoke Knowledge id is missing.')
|
||||
await assertKnowledgeFsAccessBoundaries(knowledgeSpaceId, this.getConsoleClient())
|
||||
},
|
||||
)
|
||||
|
||||
Then('the New RAG requests should be proxied with diagnostics', async function (this: DifyWorld) {
|
||||
const config = resolveNewRagSmokeConfig(process.env)
|
||||
expect(this.newRag.knowledgeFsRequests.length).toBeGreaterThan(0)
|
||||
expect(this.newRag.knowledgeFsRequestFailures).toEqual([])
|
||||
expect(
|
||||
this.newRag.knowledgeFsRequests.every((url) =>
|
||||
new URL(url).pathname.includes(knowledgeFsProxyPath),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
this.newRag.knowledgeFsRequests.every(
|
||||
(url) => new URL(url).origin !== new URL(config.knowledgeFsBaseUrl).origin,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(this.newRag.knowledgeFsResponses.some((response) => response.method !== 'GET')).toBe(true)
|
||||
expect(this.newRag.knowledgeFsResponses.every((response) => response.status < 400)).toBe(true)
|
||||
expect(
|
||||
this.newRag.knowledgeFsResponses
|
||||
.filter((response) => response.method !== 'GET')
|
||||
.every((response) => Boolean(response.traceId)),
|
||||
).toBe(true)
|
||||
})
|
||||
@@ -77,6 +77,29 @@ export const createAgentBuilderWorldState = () => ({
|
||||
|
||||
export type AgentBuilderWorldState = ReturnType<typeof createAgentBuilderWorldState>
|
||||
|
||||
export const createNewRagWorldState = () => ({
|
||||
documentTitle: undefined as string | undefined,
|
||||
documentUrl: undefined as string | undefined,
|
||||
knowledgeFsRequests: [] as string[],
|
||||
knowledgeFsRequestFailures: [] as Array<{
|
||||
error: string
|
||||
method: string
|
||||
url: string
|
||||
}>,
|
||||
knowledgeFsResponses: [] as Array<{
|
||||
method: string
|
||||
status: number
|
||||
traceId?: string
|
||||
url: string
|
||||
}>,
|
||||
knowledgeSpaceId: undefined as string | undefined,
|
||||
knowledgeSpaceName: undefined as string | undefined,
|
||||
legacyDatasetName: undefined as string | undefined,
|
||||
sourceName: undefined as string | undefined,
|
||||
})
|
||||
|
||||
export type NewRagWorldState = ReturnType<typeof createNewRagWorldState>
|
||||
|
||||
export class DifyWorld extends World {
|
||||
context: BrowserContext | undefined
|
||||
consoleRequestContext: APIRequestContext | undefined
|
||||
@@ -98,6 +121,7 @@ export class DifyWorld extends World {
|
||||
createdAgentDriveFiles: CreatedAgentDriveFile[] = []
|
||||
createdBuiltinToolCredentials: CreatedBuiltinToolCredential[] = []
|
||||
agentBuilder: AgentBuilderWorldState = createAgentBuilderWorldState()
|
||||
newRag: NewRagWorldState = createNewRagWorldState()
|
||||
scenarioCleanups: ScenarioCleanup[] = []
|
||||
capturedDownloads: Download[] = []
|
||||
shareURL: string | undefined
|
||||
@@ -123,6 +147,7 @@ export class DifyWorld extends World {
|
||||
this.createdAgentDriveFiles = []
|
||||
this.createdBuiltinToolCredentials = []
|
||||
this.agentBuilder = createAgentBuilderWorldState()
|
||||
this.newRag = createNewRagWorldState()
|
||||
this.scenarioCleanups = []
|
||||
this.capturedDownloads = []
|
||||
this.shareURL = undefined
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"e2e:install": "playwright install --with-deps chromium webkit",
|
||||
"e2e:middleware:down": "tsx ./scripts/setup.ts middleware-down",
|
||||
"e2e:middleware:up": "tsx ./scripts/setup.ts middleware-up",
|
||||
"e2e:new-rag": "tsx ./scripts/run-new-rag-smoke.ts",
|
||||
"e2e:post-merge": "tsx ./scripts/run-post-merge.ts",
|
||||
"e2e:post-merge:prepare": "tsx ./scripts/seed.ts --pack agent-v2 --profile post-merge",
|
||||
"e2e:reset": "tsx ./scripts/setup.ts reset",
|
||||
|
||||
@@ -13,6 +13,7 @@ type RunCommandOptions = {
|
||||
args: string[]
|
||||
cwd: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
inheritEnv?: boolean
|
||||
stdio?: 'inherit' | 'pipe'
|
||||
}
|
||||
|
||||
@@ -63,14 +64,12 @@ export const runCommand = async ({
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
inheritEnv = true,
|
||||
stdio = 'inherit',
|
||||
}: RunCommandOptions): Promise<RunCommandResult> => {
|
||||
const childProcess = spawn(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
env: inheritEnv ? { ...process.env, ...env } : env,
|
||||
stdio: stdio === 'inherit' ? 'inherit' : 'pipe',
|
||||
})
|
||||
|
||||
|
||||
@@ -14,17 +14,19 @@ type RunOptions = {
|
||||
forwardArgs: string[]
|
||||
full: boolean
|
||||
headed: boolean
|
||||
preserveState: boolean
|
||||
}
|
||||
|
||||
const parseArgs = (argv: string[]): RunOptions => {
|
||||
let full = false
|
||||
let headed = false
|
||||
let preserveState = false
|
||||
const forwardArgs: string[] = []
|
||||
|
||||
for (const [index, arg] of argv.entries()) {
|
||||
if (arg === '--') {
|
||||
forwardArgs.push(...argv.slice(index + 1))
|
||||
return { forwardArgs, full, headed }
|
||||
return { forwardArgs, full, headed, preserveState }
|
||||
}
|
||||
|
||||
if (arg === '--full') {
|
||||
@@ -37,16 +39,22 @@ const parseArgs = (argv: string[]): RunOptions => {
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--preserve-state') {
|
||||
preserveState = true
|
||||
continue
|
||||
}
|
||||
|
||||
forwardArgs.push(arg)
|
||||
}
|
||||
|
||||
return { forwardArgs, full, headed }
|
||||
return { forwardArgs, full, headed, preserveState }
|
||||
}
|
||||
|
||||
const hasCustomTags = (forwardArgs: string[]) =>
|
||||
forwardArgs.some((arg) => arg === '--tags' || arg.startsWith('--tags='))
|
||||
|
||||
const fullNonExternalTags = 'not @prepared and not @external-model and not @external-tool'
|
||||
const fullNonExternalTags =
|
||||
'not @prepared and not @external-model and not @external-tool and not @new-rag-smoke'
|
||||
|
||||
const isTruthyEnv = (value: string | undefined) => value === '1' || value === 'true'
|
||||
|
||||
@@ -88,9 +96,10 @@ const waitForUnexpectedProcessExit = async (
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const { forwardArgs, full, headed } = parseArgs(process.argv.slice(2))
|
||||
const { forwardArgs, full, headed, preserveState } = parseArgs(process.argv.slice(2))
|
||||
if (preserveState && !full) throw new Error('--preserve-state requires --full.')
|
||||
const startMiddlewareForRun = full
|
||||
const resetStateForRun = full
|
||||
const resetStateForRun = full && !preserveState
|
||||
const startAgentBackendForRun = shouldStartAgentBackend()
|
||||
|
||||
if (resetStateForRun) await resetState()
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { e2eDir, isMainModule, runCommand } from './common'
|
||||
import { resetState } from './setup'
|
||||
|
||||
type SmokeEnvironment = NodeJS.ProcessEnv
|
||||
|
||||
export type NewRagSmokeConfig = {
|
||||
connectionCredentials: Record<string, string>
|
||||
crawlUrl: string
|
||||
knowledgeFsBaseUrl: string
|
||||
knowledgeFsJwtSecret: string
|
||||
}
|
||||
|
||||
export type NewRagSmokeRun = {
|
||||
env: SmokeEnvironment
|
||||
label: 'default-disabled' | 'explicit-disabled' | 'enabled-happy-path'
|
||||
preserveState: boolean
|
||||
tag: '@new-rag-flag-default' | '@new-rag-flag-disabled' | '@new-rag-happy-path'
|
||||
}
|
||||
|
||||
const knowledgeFsEnvironmentKeys = [
|
||||
'KNOWLEDGE_FS_BASE_URL',
|
||||
'KNOWLEDGE_FS_ENABLED',
|
||||
'KNOWLEDGE_FS_JWT_SECRET',
|
||||
] as const
|
||||
|
||||
const providerSecretEnvironmentKeys = [
|
||||
'E2E_NEW_RAG_CONNECTION_CREDENTIALS_JSON',
|
||||
'E2E_NEW_RAG_KNOWLEDGE_FS_JWT_SECRET',
|
||||
] as const
|
||||
|
||||
const executionOverrideEnvironmentKeys = ['E2E_CUCUMBER_TAGS', 'E2E_REUSE_WEB_SERVER'] as const
|
||||
|
||||
const required = (env: SmokeEnvironment, name: string) => {
|
||||
const value = env[name]?.trim()
|
||||
if (!value) throw new Error(`${name} is required for the New RAG smoke test.`)
|
||||
return value
|
||||
}
|
||||
|
||||
const httpUrl = (env: SmokeEnvironment, name: string, trimTrailingSlash: boolean) => {
|
||||
const value = required(env, name)
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
throw new Error(`${name} must be an absolute HTTP(S) URL.`)
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
|
||||
throw new Error(`${name} must be an absolute HTTP(S) URL without credentials.`)
|
||||
|
||||
const normalized = url.toString()
|
||||
return trimTrailingSlash ? normalized.replace(/\/$/, '') : normalized
|
||||
}
|
||||
|
||||
const connectionCredentials = (env: SmokeEnvironment) => {
|
||||
const value = required(env, 'E2E_NEW_RAG_CONNECTION_CREDENTIALS_JSON')
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(value)
|
||||
} catch {
|
||||
throw new Error('E2E_NEW_RAG_CONNECTION_CREDENTIALS_JSON must be a JSON object.')
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
throw new Error('E2E_NEW_RAG_CONNECTION_CREDENTIALS_JSON must be a JSON object.')
|
||||
|
||||
const entries = Object.entries(parsed)
|
||||
if (
|
||||
entries.length === 0 ||
|
||||
entries.some(
|
||||
([name, credential]) => !name.trim() || typeof credential !== 'string' || !credential.trim(),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
'E2E_NEW_RAG_CONNECTION_CREDENTIALS_JSON must contain non-empty string credentials.',
|
||||
)
|
||||
|
||||
return Object.fromEntries(entries.map(([name, credential]) => [name, credential.trim()]))
|
||||
}
|
||||
|
||||
export const resolveNewRagSmokeConfig = (env: SmokeEnvironment): NewRagSmokeConfig => {
|
||||
const knowledgeFsBaseUrl = httpUrl(env, 'E2E_NEW_RAG_KNOWLEDGE_FS_BASE_URL', true)
|
||||
const knowledgeFsJwtSecret = required(env, 'E2E_NEW_RAG_KNOWLEDGE_FS_JWT_SECRET')
|
||||
if (knowledgeFsJwtSecret.length < 32)
|
||||
throw new Error('E2E_NEW_RAG_KNOWLEDGE_FS_JWT_SECRET must contain at least 32 characters.')
|
||||
|
||||
return {
|
||||
connectionCredentials: connectionCredentials(env),
|
||||
crawlUrl: httpUrl(env, 'E2E_NEW_RAG_CRAWL_URL', false),
|
||||
knowledgeFsBaseUrl,
|
||||
knowledgeFsJwtSecret,
|
||||
}
|
||||
}
|
||||
|
||||
const withoutKnowledgeFsConfiguration = (source: SmokeEnvironment) => {
|
||||
const env = { ...source }
|
||||
for (const key of knowledgeFsEnvironmentKeys) delete env[key]
|
||||
for (const key of providerSecretEnvironmentKeys) delete env[key]
|
||||
return env
|
||||
}
|
||||
|
||||
const withoutExecutionOverrides = (source: SmokeEnvironment) => {
|
||||
const env = { ...source }
|
||||
for (const key of executionOverrideEnvironmentKeys) delete env[key]
|
||||
return env
|
||||
}
|
||||
|
||||
export const buildNewRagSmokeRuns = (source: SmokeEnvironment): NewRagSmokeRun[] => {
|
||||
const config = resolveNewRagSmokeConfig(source)
|
||||
const isolatedSource = withoutExecutionOverrides(source)
|
||||
const disabledBase = withoutKnowledgeFsConfiguration(isolatedSource)
|
||||
|
||||
return [
|
||||
{
|
||||
env: {
|
||||
...disabledBase,
|
||||
E2E_NEW_RAG_EXPECTED_FLAG_MODE: 'default-disabled',
|
||||
},
|
||||
label: 'default-disabled',
|
||||
preserveState: false,
|
||||
tag: '@new-rag-flag-default',
|
||||
},
|
||||
{
|
||||
env: {
|
||||
...disabledBase,
|
||||
E2E_NEW_RAG_EXPECTED_FLAG_MODE: 'explicit-disabled',
|
||||
KNOWLEDGE_FS_ENABLED: 'false',
|
||||
},
|
||||
label: 'explicit-disabled',
|
||||
preserveState: true,
|
||||
tag: '@new-rag-flag-disabled',
|
||||
},
|
||||
{
|
||||
env: {
|
||||
...isolatedSource,
|
||||
E2E_NEW_RAG_CRAWL_URL: config.crawlUrl,
|
||||
E2E_NEW_RAG_EXPECTED_FLAG_MODE: 'enabled',
|
||||
E2E_NEW_RAG_KNOWLEDGE_FS_BASE_URL: config.knowledgeFsBaseUrl,
|
||||
KNOWLEDGE_FS_BASE_URL: config.knowledgeFsBaseUrl,
|
||||
KNOWLEDGE_FS_ENABLED: 'true',
|
||||
KNOWLEDGE_FS_JWT_SECRET: config.knowledgeFsJwtSecret,
|
||||
},
|
||||
label: 'enabled-happy-path',
|
||||
preserveState: true,
|
||||
tag: '@new-rag-happy-path',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const newRagCucumberArgs = (tag: NewRagSmokeRun['tag'], preserveState: boolean) => [
|
||||
'exec',
|
||||
'tsx',
|
||||
'./scripts/run-cucumber.ts',
|
||||
'--full',
|
||||
...(preserveState ? ['--preserve-state'] : []),
|
||||
'--',
|
||||
'--tags',
|
||||
tag,
|
||||
]
|
||||
|
||||
const requireKnowledgeFsHealth = async (baseUrl: string) => {
|
||||
const healthUrl = new URL('/health', `${baseUrl}/`)
|
||||
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(10_000) }).catch(
|
||||
(error: unknown) => {
|
||||
throw new Error(
|
||||
`KnowledgeFS is not reachable at ${healthUrl}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
},
|
||||
)
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`KnowledgeFS health check failed with ${response.status} ${response.statusText}.`,
|
||||
)
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const config = resolveNewRagSmokeConfig(process.env)
|
||||
await requireKnowledgeFsHealth(config.knowledgeFsBaseUrl)
|
||||
const stateDirectory = await mkdtemp(path.join(tmpdir(), 'dify-new-rag-smoke-'))
|
||||
const smokeEnvironment = {
|
||||
...process.env,
|
||||
E2E_NEW_RAG_LEGACY_DATASET_NAME: `Legacy flag ${Date.now().toString(36)}`,
|
||||
E2E_NEW_RAG_LEGACY_DATASET_STATE_PATH: path.join(stateDirectory, 'legacy-dataset.json'),
|
||||
}
|
||||
|
||||
try {
|
||||
for (const run of buildNewRagSmokeRuns(smokeEnvironment)) {
|
||||
console.warn(`[new-rag-smoke] start ${run.label}`)
|
||||
const result = await runCommand({
|
||||
command: 'pnpm',
|
||||
args: newRagCucumberArgs(run.tag, run.preserveState),
|
||||
cwd: e2eDir,
|
||||
env: run.env,
|
||||
inheritEnv: false,
|
||||
})
|
||||
if (result.exitCode !== 0)
|
||||
throw new Error(`New RAG smoke run ${run.label} failed with exit code ${result.exitCode}.`)
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await resetState({ preserveArtifacts: true })
|
||||
} finally {
|
||||
await rm(stateDirectory, { force: true, recursive: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -52,8 +52,9 @@ const middlewareDataPaths = [
|
||||
path.join(dockerDir, 'volumes', 'weaviate'),
|
||||
]
|
||||
|
||||
const e2eStatePaths = [
|
||||
path.join(e2eDir, '.auth'),
|
||||
const e2eAuthStatePaths = [path.join(e2eDir, '.auth')]
|
||||
|
||||
const e2eArtifactPaths = [
|
||||
path.join(e2eDir, 'cucumber-report'),
|
||||
path.join(e2eDir, 'cucumber-report-non-external'),
|
||||
path.join(e2eDir, 'cucumber-report-webkit'),
|
||||
@@ -65,6 +66,8 @@ const e2eStatePaths = [
|
||||
path.join(e2eDir, 'test-results'),
|
||||
]
|
||||
|
||||
const e2eStatePaths = [...e2eAuthStatePaths, ...e2eArtifactPaths]
|
||||
|
||||
const composeArgs = [
|
||||
'compose',
|
||||
'-f',
|
||||
@@ -466,7 +469,7 @@ export const stopMiddleware = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
export const resetState = async () => {
|
||||
export const resetState = async ({ preserveArtifacts = false } = {}) => {
|
||||
console.log('Stopping middleware services...')
|
||||
try {
|
||||
await stopMiddleware()
|
||||
@@ -483,8 +486,9 @@ export const resetState = async () => {
|
||||
)
|
||||
|
||||
console.log('Removing E2E local state...')
|
||||
const localStatePaths = preserveArtifacts ? e2eAuthStatePaths : e2eStatePaths
|
||||
await Promise.all(
|
||||
e2eStatePaths.map((targetPath) => rm(targetPath, { force: true, recursive: true })),
|
||||
localStatePaths.map((targetPath) => rm(targetPath, { force: true, recursive: true })),
|
||||
)
|
||||
|
||||
console.log('E2E state reset complete.')
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { createHmac } from 'node:crypto'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createKnowledgeFsJwt,
|
||||
deleteKnowledgeFsSpaceWithIdentity,
|
||||
} from '../features/new-rag/support/runtime'
|
||||
|
||||
const decode = (value: string) => JSON.parse(Buffer.from(value, 'base64url').toString('utf8'))
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('createKnowledgeFsJwt', () => {
|
||||
it('binds the interactive Dify identity, tenant, and requested scope', () => {
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-07-21T09:00:00.000Z'))
|
||||
const token = createKnowledgeFsJwt({
|
||||
accountId: 'account-1',
|
||||
scope: 'knowledge-spaces:read',
|
||||
secret: 'knowledge-fs-smoke-secret-at-least-32-characters',
|
||||
tenantId: 'tenant-1',
|
||||
})
|
||||
const [header, payload, signature] = token.split('.')
|
||||
|
||||
expect(decode(header!)).toEqual({ alg: 'HS256', typ: 'JWT' })
|
||||
expect(decode(payload!)).toEqual({
|
||||
aud: 'knowledge-fs',
|
||||
caller_kind: 'interactive',
|
||||
dify_account_id: 'dify-account:account-1',
|
||||
exp: 1784624460,
|
||||
iat: 1784624400,
|
||||
iss: 'dify',
|
||||
scopes: ['knowledge-spaces:read'],
|
||||
sub: 'dify-workspace:tenant-1',
|
||||
tenant_id: 'tenant-1',
|
||||
})
|
||||
expect(signature).toBe(
|
||||
createHmac('sha256', 'knowledge-fs-smoke-secret-at-least-32-characters')
|
||||
.update(`${header}.${payload}`)
|
||||
.digest('base64url'),
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteKnowledgeFsSpaceWithIdentity', () => {
|
||||
it('reads the current revision and submits the durable deletion challenge', async () => {
|
||||
vi.stubEnv('E2E_NEW_RAG_CONNECTION_CREDENTIALS_JSON', '{"apiKey":"provider-secret"}')
|
||||
vi.stubEnv('E2E_NEW_RAG_CRAWL_URL', 'https://docs.example.com')
|
||||
vi.stubEnv('E2E_NEW_RAG_KNOWLEDGE_FS_BASE_URL', 'http://127.0.0.1:8788')
|
||||
vi.stubEnv(
|
||||
'E2E_NEW_RAG_KNOWLEDGE_FS_JWT_SECRET',
|
||||
'knowledge-fs-smoke-secret-at-least-32-characters',
|
||||
)
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ name: 'E2E Knowledge', revision: 3 }), { status: 200 }),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 202 }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
|
||||
await deleteKnowledgeFsSpaceWithIdentity('space-1', {
|
||||
accountId: 'account-1',
|
||||
tenantId: 'tenant-1',
|
||||
})
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2)
|
||||
const deletion = fetch.mock.calls[1]!
|
||||
expect(deletion[0].toString()).toBe('http://127.0.0.1:8788/knowledge-spaces/space-1')
|
||||
expect(deletion[1]).toMatchObject({
|
||||
body: JSON.stringify({ challenge: 'E2E Knowledge', expectedRevision: 3 }),
|
||||
method: 'DELETE',
|
||||
})
|
||||
expect(new Headers(deletion[1]?.headers).get('Idempotency-Key')).toBe('e2e-delete-space-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildNewRagSmokeRuns,
|
||||
newRagCucumberArgs,
|
||||
resolveNewRagSmokeConfig,
|
||||
} from '../scripts/run-new-rag-smoke'
|
||||
|
||||
const completeEnvironment = {
|
||||
E2E_NEW_RAG_CONNECTION_CREDENTIALS_JSON: JSON.stringify({
|
||||
base_url: 'http://host.docker.internal:3002',
|
||||
firecrawl_api_key: 'firecrawl-secret',
|
||||
}),
|
||||
E2E_NEW_RAG_CRAWL_URL: 'https://docs.example.com',
|
||||
E2E_NEW_RAG_KNOWLEDGE_FS_BASE_URL: 'http://127.0.0.1:8788/',
|
||||
E2E_NEW_RAG_KNOWLEDGE_FS_JWT_SECRET: 'knowledge-fs-smoke-secret-at-least-32-characters',
|
||||
E2E_NEW_RAG_LEGACY_DATASET_NAME: 'Legacy flag preservation fixture',
|
||||
E2E_NEW_RAG_LEGACY_DATASET_STATE_PATH: '/tmp/new-rag-legacy-dataset.json',
|
||||
E2E_CUCUMBER_TAGS: '@stale-caller-tag',
|
||||
E2E_REUSE_WEB_SERVER: 'true',
|
||||
PATH: '/test/bin',
|
||||
} satisfies NodeJS.ProcessEnv
|
||||
|
||||
describe('resolveNewRagSmokeConfig', () => {
|
||||
it('requires every live integration input before the smoke starts', () => {
|
||||
expect(() => resolveNewRagSmokeConfig({})).toThrow(
|
||||
'E2E_NEW_RAG_KNOWLEDGE_FS_BASE_URL is required',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects weak JWT secrets and non-object provider credentials', () => {
|
||||
expect(() =>
|
||||
resolveNewRagSmokeConfig({
|
||||
...completeEnvironment,
|
||||
E2E_NEW_RAG_KNOWLEDGE_FS_JWT_SECRET: 'too-short',
|
||||
}),
|
||||
).toThrow('at least 32 characters')
|
||||
|
||||
expect(() =>
|
||||
resolveNewRagSmokeConfig({
|
||||
...completeEnvironment,
|
||||
E2E_NEW_RAG_CONNECTION_CREDENTIALS_JSON: '[]',
|
||||
}),
|
||||
).toThrow('must be a JSON object')
|
||||
})
|
||||
|
||||
it('accepts provider-specific credential field names from the live catalog', () => {
|
||||
expect(
|
||||
resolveNewRagSmokeConfig({
|
||||
...completeEnvironment,
|
||||
E2E_NEW_RAG_CONNECTION_CREDENTIALS_JSON: JSON.stringify({ apiKey: 'provider-secret' }),
|
||||
}).connectionCredentials,
|
||||
).toEqual({ apiKey: 'provider-secret' })
|
||||
})
|
||||
|
||||
it('normalizes the live endpoints without exposing credentials', () => {
|
||||
expect(resolveNewRagSmokeConfig(completeEnvironment)).toEqual({
|
||||
connectionCredentials: {
|
||||
base_url: 'http://host.docker.internal:3002',
|
||||
firecrawl_api_key: 'firecrawl-secret',
|
||||
},
|
||||
crawlUrl: 'https://docs.example.com/',
|
||||
knowledgeFsBaseUrl: 'http://127.0.0.1:8788',
|
||||
knowledgeFsJwtSecret: 'knowledge-fs-smoke-secret-at-least-32-characters',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNewRagSmokeRuns', () => {
|
||||
it('builds an isolated default, explicit-off, and enabled matrix in order', () => {
|
||||
const runs = buildNewRagSmokeRuns(completeEnvironment)
|
||||
|
||||
expect(runs.map(({ label, preserveState, tag }) => ({ label, preserveState, tag }))).toEqual([
|
||||
{ label: 'default-disabled', preserveState: false, tag: '@new-rag-flag-default' },
|
||||
{ label: 'explicit-disabled', preserveState: true, tag: '@new-rag-flag-disabled' },
|
||||
{ label: 'enabled-happy-path', preserveState: true, tag: '@new-rag-happy-path' },
|
||||
])
|
||||
|
||||
expect(runs[0]?.env).not.toHaveProperty('KNOWLEDGE_FS_ENABLED')
|
||||
expect(runs[0]?.env).not.toHaveProperty('KNOWLEDGE_FS_BASE_URL')
|
||||
expect(runs[0]?.env).not.toHaveProperty('KNOWLEDGE_FS_JWT_SECRET')
|
||||
expect(runs[1]?.env).toMatchObject({ KNOWLEDGE_FS_ENABLED: 'false' })
|
||||
expect(runs[1]?.env).not.toHaveProperty('KNOWLEDGE_FS_BASE_URL')
|
||||
expect(runs[2]?.env).toMatchObject({
|
||||
KNOWLEDGE_FS_BASE_URL: 'http://127.0.0.1:8788',
|
||||
KNOWLEDGE_FS_ENABLED: 'true',
|
||||
KNOWLEDGE_FS_JWT_SECRET: 'knowledge-fs-smoke-secret-at-least-32-characters',
|
||||
})
|
||||
expect(runs[2]?.env.PATH).toBe('/test/bin')
|
||||
for (const run of runs) {
|
||||
expect(run.env.E2E_NEW_RAG_LEGACY_DATASET_NAME).toBe('Legacy flag preservation fixture')
|
||||
expect(run.env.E2E_NEW_RAG_LEGACY_DATASET_STATE_PATH).toBe('/tmp/new-rag-legacy-dataset.json')
|
||||
expect(run.env).not.toHaveProperty('E2E_CUCUMBER_TAGS')
|
||||
expect(run.env).not.toHaveProperty('E2E_REUSE_WEB_SERVER')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('newRagCucumberArgs', () => {
|
||||
it('resets the first matrix entry and preserves its data across later service restarts', () => {
|
||||
expect(newRagCucumberArgs('@new-rag-flag-default', false)).toEqual([
|
||||
'exec',
|
||||
'tsx',
|
||||
'./scripts/run-cucumber.ts',
|
||||
'--full',
|
||||
'--',
|
||||
'--tags',
|
||||
'@new-rag-flag-default',
|
||||
])
|
||||
expect(newRagCucumberArgs('@new-rag-happy-path', true)).toEqual([
|
||||
'exec',
|
||||
'tsx',
|
||||
'./scripts/run-cucumber.ts',
|
||||
'--full',
|
||||
'--preserve-state',
|
||||
'--',
|
||||
'--tags',
|
||||
'@new-rag-happy-path',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -835,14 +835,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/audio-btn/audio.ts": {
|
||||
"node-js/prefer-global/buffer": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/base/audio-gallery/AudioPlayer.tsx": {
|
||||
"jsx_a11y/media-has-caption": {
|
||||
"count": 1
|
||||
|
||||
@@ -8,5 +8,5 @@ export const knowledgeFsConsoleDeclarationsSha256 =
|
||||
export const knowledgeFsGeneratedArtifactSha256 = {
|
||||
'orpc.gen.ts': 'e0d9954f817e97a4e95dd38c4522fb403c8659d741ce485fa671b1b4ce90a540',
|
||||
'types.gen.ts': 'a558ab80f32a8555bb5b44b7a596ef4a4a7a8cb7904390993aabcc587915f530',
|
||||
'zod.gen.ts': '6aa3d3e768fe0008ca405ecbe423ee325bd8f65f745dd2d1c86f2bdb887a97fd',
|
||||
'zod.gen.ts': 'ca698a6fa64a0717e29da5d4678976b55355a2c0fe5ddfa7037325aa79ab4762',
|
||||
} as const
|
||||
|
||||
@@ -1725,7 +1725,7 @@ export const zPostKnowledgeSpacesByIdDocumentsBody = z.object({
|
||||
documentId: z.uuid().optional(),
|
||||
expectedActiveRevision: z.union([z.int().gt(0), z.enum(['null'])]).optional(),
|
||||
expectedDocumentRowVersion: z.int().gte(0).nullish(),
|
||||
file: z.string(),
|
||||
file: z.custom<Blob | File>(),
|
||||
sourceId: z.uuid().optional(),
|
||||
})
|
||||
|
||||
@@ -1768,7 +1768,7 @@ export const zDeleteKnowledgeSpacesByIdDocumentsBulkPath = z.object({
|
||||
export const zDeleteKnowledgeSpacesByIdDocumentsBulkResponse = zDurableBulkDeletionAccepted
|
||||
|
||||
export const zPostKnowledgeSpacesByIdDocumentsBulkBody = z.object({
|
||||
files: z.array(z.string()).min(1),
|
||||
files: z.array(z.custom<Blob | File>()).min(1),
|
||||
targets: z.string().optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineConfig } from '@hey-api/openapi-ts'
|
||||
import { $, defineConfig } from '@hey-api/openapi-ts'
|
||||
|
||||
const input = process.env.KNOWLEDGE_FS_OPENAPI
|
||||
const outputPath = process.env.KNOWLEDGE_FS_OUTPUT ?? 'generated/knowledge-fs'
|
||||
@@ -38,6 +38,17 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
name: 'zod',
|
||||
'~resolvers': {
|
||||
string: (ctx) => {
|
||||
if (ctx.schema.format === 'binary')
|
||||
return $(ctx.symbols.z)
|
||||
.attr('custom')
|
||||
.call()
|
||||
.generic($.type.or($.type('Blob'), $.type('File')))
|
||||
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
contracts: {
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
NEXT_PUBLIC_DEPLOY_ENV=DEVELOPMENT
|
||||
# The deployment edition, SELF_HOSTED
|
||||
NEXT_PUBLIC_EDITION=SELF_HOSTED
|
||||
# Whether a self-hosted deployment runs Enterprise Edition
|
||||
NEXT_PUBLIC_ENTERPRISE_ENABLED=false
|
||||
# The base path for the application
|
||||
NEXT_PUBLIC_BASE_PATH=
|
||||
# Server-only console API origin for server-side requests.
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render } from '@/test/console/render'
|
||||
import { AgentsAccessGuard } from '../agents-access-guard'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockConsoleStateReader = vi.fn()
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
|
||||
return createWorkspaceStateModuleMock(() => mockConsoleStateReader())
|
||||
})
|
||||
vi.mock('@/context/permission-state', async () => {
|
||||
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
|
||||
return createPermissionStateModuleMock(() => mockConsoleStateReader())
|
||||
})
|
||||
|
||||
type ConsoleStateFixture = {
|
||||
isLoadingCurrentWorkspace: boolean
|
||||
isLoadingWorkspacePermissionKeys: boolean
|
||||
workspacePermissionKeys: string[]
|
||||
currentWorkspace: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
const baseContext: ConsoleStateFixture = {
|
||||
isLoadingCurrentWorkspace: false,
|
||||
isLoadingWorkspacePermissionKeys: false,
|
||||
workspacePermissionKeys: ['agent.manage'],
|
||||
currentWorkspace: {
|
||||
id: 'workspace-1',
|
||||
},
|
||||
}
|
||||
|
||||
const setConsoleState = (overrides: Partial<ConsoleStateFixture> = {}) => {
|
||||
mockConsoleStateReader.mockReturnValue({
|
||||
...baseContext,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
describe('AgentsAccessGuard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setConsoleState()
|
||||
})
|
||||
|
||||
it('renders loading while the workspace is loading', () => {
|
||||
setConsoleState({ isLoadingCurrentWorkspace: true, currentWorkspace: { id: '' } })
|
||||
|
||||
render(
|
||||
<AgentsAccessGuard>
|
||||
<div>agents</div>
|
||||
</AgentsAccessGuard>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.queryByText('agents')).not.toBeInTheDocument()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders loading while workspace permission keys are loading', () => {
|
||||
setConsoleState({ isLoadingWorkspacePermissionKeys: true, workspacePermissionKeys: [] })
|
||||
|
||||
render(
|
||||
<AgentsAccessGuard>
|
||||
<div>agents</div>
|
||||
</AgentsAccessGuard>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.queryByText('agents')).not.toBeInTheDocument()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redirects to /apps without agent.manage', async () => {
|
||||
setConsoleState({ workspacePermissionKeys: ['dataset.create_and_management'] })
|
||||
|
||||
render(
|
||||
<AgentsAccessGuard>
|
||||
<div>agents</div>
|
||||
</AgentsAccessGuard>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('agents')).not.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders children with agent.manage', () => {
|
||||
render(
|
||||
<AgentsAccessGuard>
|
||||
<div>agents</div>
|
||||
</AgentsAccessGuard>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('agents')).toBeInTheDocument()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -9,12 +8,6 @@ vi.mock('../feature-guard', () => ({
|
||||
guardAgentV2Route: () => mocks.guardAgentV2Route(),
|
||||
}))
|
||||
|
||||
// Access control is covered by agents-access-guard.spec.tsx; this suite is
|
||||
// about the feature-flag guard only.
|
||||
vi.mock('../agents-access-guard', () => ({
|
||||
AgentsAccessGuard: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
describe('RosterLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useEffect } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { workspacePermissionKeysLoadingAtom } from '@/context/permission-state'
|
||||
import { currentWorkspaceIdAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state'
|
||||
import { useCanManageAgents } from '@/features/agent-v2/permissions'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
|
||||
export function AgentsAccessGuard({ children }: { children: ReactNode }) {
|
||||
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom)
|
||||
const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom)
|
||||
const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom)
|
||||
const canManageAgents = useCanManageAgents()
|
||||
const router = useRouter()
|
||||
const isLoadingAccess = isLoadingCurrentWorkspace || !!isLoadingWorkspacePermissionKeys
|
||||
const shouldRedirect = !isLoadingAccess && !!currentWorkspaceId && !canManageAgents
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRedirect) router.replace('/')
|
||||
}, [shouldRedirect, router])
|
||||
|
||||
if (isLoadingAccess || !currentWorkspaceId) return <Loading type="app" />
|
||||
|
||||
if (shouldRedirect) return null
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { AgentsAccessGuard } from './agents-access-guard'
|
||||
import { guardAgentV2Route } from './feature-guard'
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
guardAgentV2Route()
|
||||
|
||||
return <AgentsAccessGuard>{children}</AgentsAccessGuard>
|
||||
return children
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ describe('DatasetsLayout', () => {
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['/datasets/create', '/datasets/create-from-pipeline'])(
|
||||
it.each(['/datasets/create', '/datasets/create-from-pipeline', '/datasets/new/create'])(
|
||||
'should redirect direct dataset creation route to /datasets without dataset.create_and_management: %s',
|
||||
async (pathname) => {
|
||||
mockPathname = pathname
|
||||
@@ -186,6 +186,22 @@ describe('DatasetsLayout', () => {
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render direct new knowledge creation route when workspace has dataset.create_and_management', () => {
|
||||
mockPathname = '/datasets/new/create'
|
||||
setConsoleState({
|
||||
workspacePermissionKeys: ['dataset.create_and_management'],
|
||||
})
|
||||
|
||||
render(
|
||||
<DatasetsLayout>
|
||||
<div>datasets</div>
|
||||
</DatasetsLayout>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('datasets')).toBeInTheDocument()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should redirect direct external dataset connection route to /datasets without dataset.external.connect', async () => {
|
||||
mockPathname = '/datasets/connect'
|
||||
setConsoleState({
|
||||
|
||||
@@ -17,6 +17,7 @@ const isDatasetCreatePath = (pathname: string) => {
|
||||
return (
|
||||
pathname === '/datasets/create' ||
|
||||
pathname.startsWith('/datasets/create/') ||
|
||||
pathname === '/datasets/new/create' ||
|
||||
pathname === '/datasets/create-from-pipeline' ||
|
||||
pathname.startsWith('/datasets/create-from-pipeline/')
|
||||
)
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { DocumentDetailPage } from '@/features/new-rag/document-detail-page'
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ documentId: string; knowledgeSpaceId: string }>
|
||||
}) {
|
||||
const { documentId, knowledgeSpaceId } = await params
|
||||
|
||||
return <DocumentDetailPage documentId={documentId} knowledgeSpaceId={knowledgeSpaceId} />
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { DocumentsPage } from '@/features/new-rag/documents-page'
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ knowledgeSpaceId: string }> }) {
|
||||
const { knowledgeSpaceId } = await params
|
||||
|
||||
return <DocumentsPage knowledgeSpaceId={knowledgeSpaceId} />
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { KnowledgeRouteGuard } from '@/features/new-rag/knowledge-route-guard'
|
||||
import { KnowledgeSpaceShell } from '@/features/new-rag/knowledge-space-shell'
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
params: Promise<{ knowledgeSpaceId: string }>
|
||||
}) {
|
||||
const { knowledgeSpaceId } = await params
|
||||
|
||||
return (
|
||||
<KnowledgeRouteGuard>
|
||||
<KnowledgeSpaceShell knowledgeSpaceId={knowledgeSpaceId}>{children}</KnowledgeSpaceShell>
|
||||
</KnowledgeRouteGuard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@/next/navigation'
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ knowledgeSpaceId: string }> }) {
|
||||
const { knowledgeSpaceId } = await params
|
||||
redirect(`/datasets/new/${knowledgeSpaceId}/sources`)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { AddSourcePage } from '@/features/new-rag/add-source-page'
|
||||
import { singleSearchParam } from '@/features/new-rag/routes'
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ knowledgeSpaceId: string }>
|
||||
searchParams: Promise<{
|
||||
draft?: string | string[]
|
||||
type?: string | string[]
|
||||
}>
|
||||
}) {
|
||||
const { knowledgeSpaceId } = await params
|
||||
const { draft, type } = await searchParams
|
||||
|
||||
return (
|
||||
<AddSourcePage
|
||||
initialSourceType={singleSearchParam(type)}
|
||||
knowledgeSpaceId={knowledgeSpaceId}
|
||||
sourceDraftKey={singleSearchParam(draft)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SourcesPage } from '@/features/new-rag/sources-page'
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ knowledgeSpaceId: string }> }) {
|
||||
const { knowledgeSpaceId } = await params
|
||||
|
||||
return <SourcesPage knowledgeSpaceId={knowledgeSpaceId} />
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CreateKnowledgePage } from '@/features/new-rag/create-knowledge-page'
|
||||
import { KnowledgeRouteGuard } from '@/features/new-rag/knowledge-route-guard'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<KnowledgeRouteGuard>
|
||||
<CreateKnowledgePage />
|
||||
</KnowledgeRouteGuard>
|
||||
)
|
||||
}
|
||||
@@ -12,14 +12,8 @@ type AudioPlayerCtorArgs = [
|
||||
|
||||
type MockAudioPlayerInstance = {
|
||||
setCallback: ReturnType<typeof vi.fn>
|
||||
pauseAudio: ReturnType<typeof vi.fn>
|
||||
destroy: ReturnType<typeof vi.fn>
|
||||
resetMsgId: ReturnType<typeof vi.fn>
|
||||
cacheBuffers: Array<ArrayBuffer>
|
||||
sourceBuffer:
|
||||
| {
|
||||
abort: ReturnType<typeof vi.fn>
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
@@ -31,10 +25,8 @@ const mockAudioPlayerConstructor = vi.hoisted(() => vi.fn())
|
||||
const MockAudioPlayer = vi.hoisted(() => {
|
||||
return class MockAudioPlayerClass {
|
||||
setCallback = vi.fn()
|
||||
pauseAudio = vi.fn()
|
||||
destroy = vi.fn()
|
||||
resetMsgId = vi.fn()
|
||||
cacheBuffers = [new ArrayBuffer(1)]
|
||||
sourceBuffer = { abort: vi.fn() }
|
||||
|
||||
constructor(...args: AudioPlayerCtorArgs) {
|
||||
mockAudioPlayerConstructor(...args)
|
||||
@@ -132,9 +124,7 @@ describe('AudioPlayerManager', () => {
|
||||
callback,
|
||||
)
|
||||
|
||||
expect(previous!.pauseAudio).toHaveBeenCalledTimes(1)
|
||||
expect(previous!.cacheBuffers).toEqual([])
|
||||
expect(previous!.sourceBuffer?.abort).toHaveBeenCalledTimes(1)
|
||||
expect(previous!.destroy).toHaveBeenCalledTimes(1)
|
||||
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(2)
|
||||
expect(next).toBe(mockState.instances[1])
|
||||
})
|
||||
@@ -144,7 +134,7 @@ describe('AudioPlayerManager', () => {
|
||||
const callback = vi.fn()
|
||||
manager.getAudioPlayer('/text-to-audio', false, 'msg-1', 'hello', 'en-US', callback)
|
||||
const previous = mockState.instances[0]
|
||||
previous!.pauseAudio.mockImplementation(() => {
|
||||
previous!.destroy.mockImplementation(() => {
|
||||
throw new Error('cleanup failure')
|
||||
})
|
||||
|
||||
@@ -152,7 +142,7 @@ describe('AudioPlayerManager', () => {
|
||||
manager.getAudioPlayer('/apps/1/text-to-audio', false, 'msg-2', 'world', 'en-US', callback)
|
||||
}).not.toThrow()
|
||||
|
||||
expect(previous!.pauseAudio).toHaveBeenCalledTimes(1)
|
||||
expect(previous!.destroy).toHaveBeenCalledTimes(1)
|
||||
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,15 +3,8 @@ import { waitFor } from '@testing-library/react'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import AudioPlayer from '../audio'
|
||||
|
||||
const mockToastNotify = vi.hoisted(() => vi.fn())
|
||||
const mockTextToAudioStream = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
error: (message: string) => mockToastNotify({ type: 'error', message }),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/share', () => ({
|
||||
AppSourceType: {
|
||||
webApp: 'webApp',
|
||||
@@ -22,7 +15,7 @@ vi.mock('@/service/share', () => ({
|
||||
|
||||
type AudioEventName =
|
||||
| 'ended'
|
||||
| 'paused'
|
||||
| 'pause'
|
||||
| 'loaded'
|
||||
| 'play'
|
||||
| 'timeupdate'
|
||||
@@ -30,6 +23,7 @@ type AudioEventName =
|
||||
| 'canplay'
|
||||
| 'error'
|
||||
| 'sourceopen'
|
||||
| 'updateend'
|
||||
|
||||
type AudioEventListener = () => void
|
||||
|
||||
@@ -51,12 +45,31 @@ type AudioResponse = {
|
||||
|
||||
class MockSourceBuffer {
|
||||
updating = false
|
||||
private listeners: Partial<Record<AudioEventName, AudioEventListener[]>> = {}
|
||||
|
||||
addEventListener = vi.fn((event: AudioEventName, listener: AudioEventListener) => {
|
||||
const listeners = this.listeners[event] || []
|
||||
listeners.push(listener)
|
||||
this.listeners[event] = listeners
|
||||
})
|
||||
|
||||
removeEventListener = vi.fn((event: AudioEventName, listener: AudioEventListener) => {
|
||||
this.listeners[event] = (this.listeners[event] || []).filter((item) => item !== listener)
|
||||
})
|
||||
|
||||
appendBuffer = vi.fn((_buffer: ArrayBuffer) => undefined)
|
||||
abort = vi.fn(() => undefined)
|
||||
|
||||
emit(event: AudioEventName) {
|
||||
const listeners = this.listeners[event] || []
|
||||
listeners.forEach((listener) => {
|
||||
listener()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class MockMediaSource {
|
||||
readyState: 'open' | 'closed' = 'open'
|
||||
readyState: 'open' | 'closed' | 'ended' = 'closed'
|
||||
sourceBuffer = new MockSourceBuffer()
|
||||
private listeners: Partial<Record<AudioEventName, AudioEventListener[]>> = {}
|
||||
|
||||
@@ -66,10 +79,15 @@ class MockMediaSource {
|
||||
this.listeners[event] = listeners
|
||||
})
|
||||
|
||||
removeEventListener = vi.fn((event: AudioEventName, listener: AudioEventListener) => {
|
||||
this.listeners[event] = (this.listeners[event] || []).filter((item) => item !== listener)
|
||||
})
|
||||
|
||||
addSourceBuffer = vi.fn((_contentType: string) => this.sourceBuffer)
|
||||
endOfStream = vi.fn(() => undefined)
|
||||
|
||||
emit(event: AudioEventName) {
|
||||
if (event === 'sourceopen') this.readyState = 'open'
|
||||
const listeners = this.listeners[event] || []
|
||||
listeners.forEach((listener) => {
|
||||
listener()
|
||||
@@ -110,7 +128,7 @@ class MockAudio {
|
||||
}
|
||||
|
||||
class MockAudioContext {
|
||||
state: 'running' | 'suspended' = 'running'
|
||||
state: 'interrupted' | 'running' | 'suspended' = 'running'
|
||||
destination = {}
|
||||
connect = vi.fn(() => undefined)
|
||||
createMediaElementSource = vi.fn((_audio: MockAudio) => ({
|
||||
@@ -121,9 +139,11 @@ class MockAudioContext {
|
||||
this.state = 'running'
|
||||
})
|
||||
|
||||
suspend = vi.fn(() => {
|
||||
suspend = vi.fn(async () => {
|
||||
this.state = 'suspended'
|
||||
})
|
||||
|
||||
close = vi.fn(async () => undefined)
|
||||
}
|
||||
|
||||
const testState = {
|
||||
@@ -133,6 +153,8 @@ const testState = {
|
||||
}
|
||||
|
||||
class MockMediaSourceCtor extends MockMediaSource {
|
||||
static isTypeSupported = vi.fn(() => true)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
testState.mediaSources.push(this)
|
||||
@@ -156,6 +178,7 @@ class MockAudioContextCtor extends MockAudioContext {
|
||||
const originalAudio = globalThis.Audio
|
||||
const originalAudioContext = globalThis.AudioContext
|
||||
const originalCreateObjectURL = globalThis.URL.createObjectURL
|
||||
const originalRevokeObjectURL = globalThis.URL.revokeObjectURL
|
||||
const originalMediaSource = window.MediaSource
|
||||
const originalManagedMediaSource = window.ManagedMediaSource
|
||||
|
||||
@@ -192,6 +215,7 @@ describe('AudioPlayer', () => {
|
||||
testState.mediaSources = []
|
||||
testState.audios = []
|
||||
testState.audioContexts = []
|
||||
MockMediaSourceCtor.isTypeSupported.mockReturnValue(true)
|
||||
|
||||
Object.defineProperty(globalThis, 'Audio', {
|
||||
configurable: true,
|
||||
@@ -208,6 +232,11 @@ describe('AudioPlayer', () => {
|
||||
writable: true,
|
||||
value: vi.fn(() => 'blob:mock-url'),
|
||||
})
|
||||
Object.defineProperty(globalThis.URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn(),
|
||||
})
|
||||
|
||||
setMediaSourceSupport({ mediaSource: true, managedMediaSource: false })
|
||||
})
|
||||
@@ -228,6 +257,11 @@ describe('AudioPlayer', () => {
|
||||
writable: true,
|
||||
value: originalCreateObjectURL,
|
||||
})
|
||||
Object.defineProperty(globalThis.URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalRevokeObjectURL,
|
||||
})
|
||||
Object.defineProperty(window, 'MediaSource', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
@@ -256,7 +290,7 @@ describe('AudioPlayer', () => {
|
||||
expect(audioContext!.connect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should notify unsupported browser when no MediaSource implementation exists', () => {
|
||||
it('should use complete-audio fallback when no MediaSource implementation exists', () => {
|
||||
setMediaSourceSupport({ mediaSource: false, managedMediaSource: false })
|
||||
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
@@ -264,12 +298,22 @@ describe('AudioPlayer', () => {
|
||||
|
||||
expect(player.mediaSource).toBeNull()
|
||||
expect(audio!.src).toBe('')
|
||||
expect(mockToastNotify).toHaveBeenCalledTimes(1)
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
}),
|
||||
)
|
||||
expect(audio!.autoplay).toBe(false)
|
||||
expect(globalThis.URL.createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use complete-audio fallback when MP3 MediaSource is unsupported', () => {
|
||||
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const audio = testState.audios[0]
|
||||
|
||||
expect(MockMediaSourceCtor.isTypeSupported).toHaveBeenCalledWith('audio/mpeg')
|
||||
expect(player.mediaSource).toBeNull()
|
||||
expect(testState.mediaSources).toHaveLength(0)
|
||||
expect(audio!.src).toBe('')
|
||||
expect(audio!.autoplay).toBe(false)
|
||||
expect(globalThis.URL.createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should configure fallback audio controls when ManagedMediaSource is used', () => {
|
||||
@@ -283,6 +327,17 @@ describe('AudioPlayer', () => {
|
||||
expect(audio!.disableRemotePlayback).toBe(true)
|
||||
expect(audio!.controls).toBe(true)
|
||||
})
|
||||
|
||||
it('should configure ManagedMediaSource when both media source implementations exist', () => {
|
||||
setMediaSourceSupport({ mediaSource: true, managedMediaSource: true })
|
||||
|
||||
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, vi.fn())
|
||||
const audio = testState.audios[0]
|
||||
|
||||
expect(player.mediaSource).not.toBeNull()
|
||||
expect(audio!.disableRemotePlayback).toBe(true)
|
||||
expect(audio!.controls).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('event wiring', () => {
|
||||
@@ -294,7 +349,7 @@ describe('AudioPlayer', () => {
|
||||
audio!.emit('play')
|
||||
audio!.emit('ended')
|
||||
audio!.emit('error')
|
||||
audio!.emit('paused')
|
||||
audio!.emit('pause')
|
||||
audio!.emit('loaded')
|
||||
audio!.emit('timeupdate')
|
||||
audio!.emit('loadeddate')
|
||||
@@ -354,6 +409,7 @@ describe('AudioPlayer', () => {
|
||||
})
|
||||
|
||||
it('should emit error callback and reset load flag when stream response status is not 200', async () => {
|
||||
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||
const callback = vi.fn()
|
||||
mockTextToAudioStream.mockResolvedValue(
|
||||
makeAudioResponse(500, [{ value: new Uint8Array([1]), done: true }]),
|
||||
@@ -366,25 +422,171 @@ describe('AudioPlayer', () => {
|
||||
expect(callback).toHaveBeenCalledWith('error')
|
||||
})
|
||||
expect(player.isLoadData).toBe(false)
|
||||
expect(globalThis.URL.createObjectURL).not.toHaveBeenCalled()
|
||||
expect(testState.audios[0]!.play).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should resume and play immediately when playAudio is called in suspended loaded state', async () => {
|
||||
it('should play a complete MP3 blob when MediaSource does not support audio/mpeg', async () => {
|
||||
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||
const callback = vi.fn()
|
||||
mockTextToAudioStream.mockResolvedValue(
|
||||
makeAudioResponse(200, [
|
||||
{ value: new Uint8Array([1, 2]), done: false },
|
||||
{ value: new Uint8Array([3, 4]), done: true },
|
||||
]),
|
||||
)
|
||||
|
||||
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
||||
const audio = testState.audios[0]
|
||||
|
||||
player.playAudio()
|
||||
|
||||
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
|
||||
expect(player.mediaSource).toBeNull()
|
||||
expect(player.cacheBuffers).toHaveLength(0)
|
||||
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(1)
|
||||
const audioBlob = vi.mocked(globalThis.URL.createObjectURL).mock.calls[0]![0] as Blob
|
||||
expect(audioBlob).toBeInstanceOf(Blob)
|
||||
expect(audioBlob).toMatchObject({ type: 'audio/mpeg', size: 4 })
|
||||
expect(new Uint8Array(await audioBlob.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
|
||||
expect(audio!.src).toBe('blob:mock-url')
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
})
|
||||
|
||||
it('should wait for the complete MP3 before retrying playback without MediaSource', async () => {
|
||||
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||
let resolveResponse: ((response: AudioResponse) => void) | undefined
|
||||
mockTextToAudioStream.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<AudioResponse>((resolve) => {
|
||||
resolveResponse = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, vi.fn())
|
||||
const audio = testState.audios[0]
|
||||
|
||||
player.playAudio()
|
||||
player.playAudio()
|
||||
|
||||
expect(audio!.play).not.toHaveBeenCalled()
|
||||
|
||||
resolveResponse?.(makeAudioResponse(200, [{ value: new Uint8Array([1, 2]), done: true }]))
|
||||
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it.each(['suspended', 'interrupted'] as const)(
|
||||
'should resume and play immediately when playAudio is called in %s loaded state',
|
||||
async (audioContextState) => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer(
|
||||
'/text-to-audio',
|
||||
false,
|
||||
'msg-1',
|
||||
'hello',
|
||||
undefined,
|
||||
callback,
|
||||
)
|
||||
const audio = testState.audios[0]
|
||||
const audioContext = testState.audioContexts[0]
|
||||
|
||||
player.isLoadData = true
|
||||
audioContext!.state = audioContextState
|
||||
player.playAudio()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('should request media playback before a suspended audio context finishes resuming', async () => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
||||
const audio = testState.audios[0]
|
||||
const audioContext = testState.audioContexts[0]
|
||||
let resolveResume: (() => void) | undefined
|
||||
|
||||
player.isLoadData = true
|
||||
audioContext!.state = 'suspended'
|
||||
audioContext!.resume.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveResume = () => {
|
||||
audioContext!.state = 'running'
|
||||
resolve()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
player.playAudio()
|
||||
|
||||
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).not.toHaveBeenCalledWith('play')
|
||||
|
||||
resolveResume?.()
|
||||
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
|
||||
})
|
||||
|
||||
it.each(['suspended', 'interrupted'] as const)(
|
||||
'should resume a %s audio context when the media element is still playing',
|
||||
async (audioContextState) => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer(
|
||||
'/text-to-audio',
|
||||
false,
|
||||
'msg-1',
|
||||
'hello',
|
||||
undefined,
|
||||
callback,
|
||||
)
|
||||
const audio = testState.audios[0]
|
||||
const audioContext = testState.audioContexts[0]
|
||||
|
||||
player.isLoadData = true
|
||||
audio!.paused = false
|
||||
audioContext!.state = audioContextState
|
||||
player.playAudio()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
})
|
||||
expect(audio!.play).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('should report an error when the audio context remains interrupted and allow retry', async () => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
||||
const audio = testState.audios[0]
|
||||
const audioContext = testState.audioContexts[0]
|
||||
|
||||
player.isLoadData = true
|
||||
audio!.paused = false
|
||||
audioContext!.state = 'suspended'
|
||||
audioContext!.resume.mockImplementationOnce(async () => {
|
||||
audioContext!.state = 'interrupted'
|
||||
})
|
||||
player.playAudio()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
await waitFor(() => expect(callback).toHaveBeenCalledWith('error'))
|
||||
expect(callback).not.toHaveBeenCalledWith('play')
|
||||
|
||||
audioContext!.resume.mockImplementationOnce(async () => {
|
||||
audioContext!.state = 'running'
|
||||
})
|
||||
player.playAudio()
|
||||
|
||||
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
|
||||
expect(audioContext!.resume).toHaveBeenCalledTimes(2)
|
||||
expect(audio!.play).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should play ended audio when data is already loaded', () => {
|
||||
it('should play ended audio when data is already loaded', async () => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
||||
const audio = testState.audios[0]
|
||||
@@ -395,11 +597,13 @@ describe('AudioPlayer', () => {
|
||||
audio!.ended = true
|
||||
player.playAudio()
|
||||
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
await waitFor(() => {
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
})
|
||||
})
|
||||
|
||||
it('should only emit play callback without replaying when loaded audio is already playing', () => {
|
||||
it('should report loaded audio that is already playing without replaying it', () => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
||||
const audio = testState.audios[0]
|
||||
@@ -407,6 +611,7 @@ describe('AudioPlayer', () => {
|
||||
|
||||
player.isLoadData = true
|
||||
audioContext!.state = 'running'
|
||||
audio!.paused = false
|
||||
audio!.ended = false
|
||||
player.playAudio()
|
||||
|
||||
@@ -451,22 +656,20 @@ describe('AudioPlayer', () => {
|
||||
})
|
||||
|
||||
it('should end stream without playback when playAudioWithAudio receives empty content', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
|
||||
await player.playAudioWithAudio('', true)
|
||||
await vi.advanceTimersByTimeAsync(40)
|
||||
await player.playAudioWithAudio('', true)
|
||||
|
||||
expect(player.isLoadData).toBe(false)
|
||||
expect(player.cacheBuffers).toHaveLength(0)
|
||||
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
|
||||
expect(callback).not.toHaveBeenCalledWith('play')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
expect(player.isLoadData).toBe(false)
|
||||
expect(player.cacheBuffers).toHaveLength(0)
|
||||
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
|
||||
|
||||
mediaSource!.emit('sourceopen')
|
||||
|
||||
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
|
||||
expect(callback).not.toHaveBeenCalledWith('play')
|
||||
})
|
||||
|
||||
it('should decode base64 and start playback when playAudioWithAudio is called with playable content', async () => {
|
||||
@@ -479,8 +682,8 @@ describe('AudioPlayer', () => {
|
||||
|
||||
mediaSource!.emit('sourceopen')
|
||||
audio!.paused = true
|
||||
audioContext!.state = 'suspended'
|
||||
await player.playAudioWithAudio(audioBase64, true)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(player.isLoadData).toBe(true)
|
||||
expect(player.cacheBuffers).toHaveLength(0)
|
||||
@@ -488,9 +691,11 @@ describe('AudioPlayer', () => {
|
||||
const appendedAudioData = mediaSource!.sourceBuffer.appendBuffer.mock.calls[0]![0]
|
||||
expect(appendedAudioData).toBeInstanceOf(ArrayBuffer)
|
||||
expect(appendedAudioData.byteLength).toBeGreaterThan(0)
|
||||
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
await waitFor(() => {
|
||||
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip playback when playAudioWithAudio is called with play=false', async () => {
|
||||
@@ -507,6 +712,88 @@ describe('AudioPlayer', () => {
|
||||
expect(callback).not.toHaveBeenCalledWith('play')
|
||||
})
|
||||
|
||||
it('should combine automatic TTS chunks into a playable MP3 blob without MediaSource', async () => {
|
||||
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||
const audio = testState.audios[0]
|
||||
|
||||
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
|
||||
await player.playAudioWithAudio(Buffer.from([3, 4]).toString('base64'), true)
|
||||
|
||||
expect(audio!.play).not.toHaveBeenCalled()
|
||||
expect(player.cacheBuffers).toHaveLength(2)
|
||||
|
||||
await player.playAudioWithAudio('', false)
|
||||
|
||||
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
|
||||
expect(player.cacheBuffers).toHaveLength(0)
|
||||
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(1)
|
||||
const audioBlob = vi.mocked(globalThis.URL.createObjectURL).mock.calls[0]![0] as Blob
|
||||
expect(audioBlob).toMatchObject({ type: 'audio/mpeg', size: 4 })
|
||||
expect(new Uint8Array(await audioBlob.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
|
||||
expect(audio!.src).toBe('blob:mock-url')
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
})
|
||||
|
||||
it('should not start fallback playback after it is paused while buffering', async () => {
|
||||
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', vi.fn())
|
||||
const audio = testState.audios[0]
|
||||
|
||||
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
|
||||
player.pauseAudio()
|
||||
await player.playAudioWithAudio('', false)
|
||||
|
||||
expect(audio!.autoplay).toBe(false)
|
||||
expect(audio!.play).not.toHaveBeenCalled()
|
||||
expect(audio!.src).toBe('blob:mock-url')
|
||||
})
|
||||
|
||||
it('should fall back to a complete MP3 when addSourceBuffer throws', async () => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
const audio = testState.audios[0]
|
||||
mediaSource!.addSourceBuffer.mockImplementationOnce(() => {
|
||||
throw new DOMException('Unsupported type', 'NotSupportedError')
|
||||
})
|
||||
|
||||
mediaSource!.emit('sourceopen')
|
||||
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
|
||||
await player.playAudioWithAudio('', false)
|
||||
|
||||
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
|
||||
expect(player.mediaSource).toBeNull()
|
||||
expect(audio!.autoplay).toBe(false)
|
||||
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
||||
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should complete buffered fallback when addSourceBuffer throws after stream end', async () => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', vi.fn())
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
const audio = testState.audios[0]
|
||||
mediaSource!.addSourceBuffer.mockImplementationOnce(() => {
|
||||
throw new DOMException('Unsupported type', 'NotSupportedError')
|
||||
})
|
||||
|
||||
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
|
||||
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
|
||||
await player.playAudioWithAudio('', false)
|
||||
audio!.paused = true
|
||||
|
||||
mediaSource!.emit('sourceopen')
|
||||
|
||||
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(2))
|
||||
expect(player.mediaSource).toBeNull()
|
||||
expect(player.cacheBuffers).toHaveLength(0)
|
||||
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(2)
|
||||
const audioBlob = vi.mocked(globalThis.URL.createObjectURL).mock.calls[1]![0] as Blob
|
||||
expect(audioBlob).toMatchObject({ type: 'audio/mpeg', size: 2 })
|
||||
expect(new Uint8Array(await audioBlob.arrayBuffer())).toEqual(new Uint8Array([1, 2]))
|
||||
})
|
||||
|
||||
it('should play immediately for ended audio in playAudioWithAudio', async () => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||
@@ -517,7 +804,7 @@ describe('AudioPlayer', () => {
|
||||
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
|
||||
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
|
||||
})
|
||||
|
||||
it('should not replay when played list exists in playAudioWithAudio', async () => {
|
||||
@@ -534,18 +821,63 @@ describe('AudioPlayer', () => {
|
||||
expect(callback).not.toHaveBeenCalledWith('play')
|
||||
})
|
||||
|
||||
it('should replay when paused is false and played list is empty in playAudioWithAudio', async () => {
|
||||
it('should report a play failure and retry without requesting audio again', async () => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||
const audio = testState.audios[0]
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
mockTextToAudioStream.mockResolvedValue(
|
||||
makeAudioResponse(200, [{ value: undefined, done: true }]),
|
||||
)
|
||||
audio!.play.mockRejectedValueOnce(new DOMException('Playback aborted', 'AbortError'))
|
||||
|
||||
audio!.paused = false
|
||||
audio!.ended = false
|
||||
audio!.played = null
|
||||
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
|
||||
mediaSource!.emit('sourceopen')
|
||||
player.playAudio()
|
||||
|
||||
await waitFor(() => expect(callback).toHaveBeenCalledWith('error'))
|
||||
expect(callback).not.toHaveBeenCalledWith('play')
|
||||
expect(player.isLoadData).toBe(true)
|
||||
|
||||
audio!.play.mockImplementationOnce(async () => {
|
||||
audio!.paused = false
|
||||
})
|
||||
player.playAudio()
|
||||
|
||||
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
|
||||
|
||||
expect(audio!.play).toHaveBeenCalledTimes(2)
|
||||
expect(mockTextToAudioStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should report a resume failure and allow playback to be retried', async () => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||
const audio = testState.audios[0]
|
||||
const audioContext = testState.audioContexts[0]
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
mockTextToAudioStream.mockResolvedValue(
|
||||
makeAudioResponse(200, [{ value: undefined, done: true }]),
|
||||
)
|
||||
audioContext!.state = 'suspended'
|
||||
audioContext!.resume.mockRejectedValueOnce(new DOMException('Not allowed', 'NotAllowedError'))
|
||||
|
||||
mediaSource!.emit('sourceopen')
|
||||
player.playAudio()
|
||||
|
||||
await waitFor(() => expect(callback).toHaveBeenCalledWith('error'))
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
expect(player.isLoadData).toBe(true)
|
||||
|
||||
audioContext!.resume.mockImplementationOnce(async () => {
|
||||
audioContext!.state = 'running'
|
||||
})
|
||||
player.playAudio()
|
||||
|
||||
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
|
||||
|
||||
expect(audioContext!.resume).toHaveBeenCalledTimes(2)
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(mockTextToAudioStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -562,7 +894,7 @@ describe('AudioPlayer', () => {
|
||||
expect(finishStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should finish stream when receiveAudioData gets empty bytes while source is open', () => {
|
||||
it('should finish stream when receiveAudioData gets empty bytes', () => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const finishStream = vi
|
||||
.spyOn(player as unknown as { finishStream: () => void }, 'finishStream')
|
||||
@@ -586,6 +918,52 @@ describe('AudioPlayer', () => {
|
||||
expect(player.cacheBuffers.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should preserve audio received before sourceopen and append it once ready', () => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
|
||||
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
)
|
||||
|
||||
expect(player.cacheBuffers).toHaveLength(1)
|
||||
expect(mediaSource!.sourceBuffer.appendBuffer).not.toHaveBeenCalled()
|
||||
|
||||
mediaSource!.emit('sourceopen')
|
||||
|
||||
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
|
||||
expect(player.cacheBuffers).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should append queued buffers in order after updateend', () => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
mediaSource!.emit('sourceopen')
|
||||
mediaSource!.sourceBuffer.updating = true
|
||||
|
||||
const first = new Uint8Array([1])
|
||||
const second = new Uint8Array([2])
|
||||
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
|
||||
first,
|
||||
)
|
||||
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
|
||||
second,
|
||||
)
|
||||
|
||||
mediaSource!.sourceBuffer.updating = false
|
||||
mediaSource!.sourceBuffer.emit('updateend')
|
||||
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
|
||||
expect(new Uint8Array(mediaSource!.sourceBuffer.appendBuffer.mock.calls[0]![0])).toEqual(
|
||||
first,
|
||||
)
|
||||
|
||||
mediaSource!.sourceBuffer.emit('updateend')
|
||||
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(2)
|
||||
expect(new Uint8Array(mediaSource!.sourceBuffer.appendBuffer.mock.calls[1]![0])).toEqual(
|
||||
second,
|
||||
)
|
||||
})
|
||||
|
||||
it('should append previously queued buffer before new one when source buffer is idle', () => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
@@ -603,19 +981,68 @@ describe('AudioPlayer', () => {
|
||||
expect(player.cacheBuffers.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should append cache chunks and end stream when finishStream drains buffers', () => {
|
||||
vi.useFakeTimers()
|
||||
it('should end the stream only after the final queued buffer is appended', () => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
mediaSource!.emit('sourceopen')
|
||||
mediaSource!.sourceBuffer.updating = false
|
||||
mediaSource!.sourceBuffer.updating = true
|
||||
player.cacheBuffers = [new ArrayBuffer(3)]
|
||||
;(player as unknown as { finishStream: () => void }).finishStream()
|
||||
vi.advanceTimersByTime(50)
|
||||
|
||||
;(player as unknown as { finishStream: () => void }).finishStream()
|
||||
|
||||
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
|
||||
|
||||
mediaSource!.sourceBuffer.updating = false
|
||||
mediaSource!.sourceBuffer.emit('updateend')
|
||||
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
|
||||
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
|
||||
|
||||
mediaSource!.sourceBuffer.emit('updateend')
|
||||
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should end an open stream at most once', () => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
mediaSource!.emit('sourceopen')
|
||||
|
||||
;(player as unknown as { finishStream: () => void }).finishStream()
|
||||
;(player as unknown as { finishStream: () => void }).finishStream()
|
||||
|
||||
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each(['closed', 'ended'] as const)('should not end a %s media source', (readyState) => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
mediaSource!.emit('sourceopen')
|
||||
mediaSource!.readyState = readyState
|
||||
|
||||
;(player as unknown as { finishStream: () => void }).finishStream()
|
||||
|
||||
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop buffering and release browser resources after destroy', async () => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
const audio = testState.audios[0]
|
||||
const audioContext = testState.audioContexts[0]
|
||||
mediaSource!.emit('sourceopen')
|
||||
|
||||
player.destroy()
|
||||
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
|
||||
new Uint8Array([1]),
|
||||
)
|
||||
;(player as unknown as { finishStream: () => void }).finishStream()
|
||||
mediaSource!.sourceBuffer.emit('updateend')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mediaSource!.sourceBuffer.appendBuffer).not.toHaveBeenCalled()
|
||||
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
|
||||
expect(audio!.pause).toHaveBeenCalledTimes(1)
|
||||
expect(audioContext!.close).toHaveBeenCalledTimes(1)
|
||||
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,9 +35,7 @@ export class AudioPlayerManager {
|
||||
} else {
|
||||
if (this.audioPlayers) {
|
||||
try {
|
||||
this.audioPlayers.pauseAudio()
|
||||
this.audioPlayers.cacheBuffers = []
|
||||
this.audioPlayers.sourceBuffer?.abort()
|
||||
this.audioPlayers.destroy()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { AppSourceType, textToAudioStream } from '@/service/share'
|
||||
|
||||
const AUDIO_CONTENT_TYPE = 'audio/mpeg'
|
||||
|
||||
declare global {
|
||||
// oxlint-disable-next-line typescript/consistent-type-definitions
|
||||
interface Window {
|
||||
ManagedMediaSource: any
|
||||
ManagedMediaSource?: typeof MediaSource
|
||||
}
|
||||
}
|
||||
export default class AudioPlayer {
|
||||
mediaSource: MediaSource | null
|
||||
audio: HTMLAudioElement
|
||||
audioContext: AudioContext
|
||||
sourceBuffer?: any
|
||||
sourceBuffer?: SourceBuffer
|
||||
cacheBuffers: ArrayBuffer[] = []
|
||||
pauseTimer: number | null = null
|
||||
msgId: string | undefined
|
||||
msgContent: string | null | undefined = null
|
||||
voice: string | undefined = undefined
|
||||
@@ -21,6 +21,13 @@ export default class AudioPlayer {
|
||||
url: string
|
||||
isPublic: boolean
|
||||
callback: ((event: string) => void) | null
|
||||
private objectUrl = ''
|
||||
private streamEnded = false
|
||||
private endOfStreamCalled = false
|
||||
private destroyed = false
|
||||
private playbackPending = false
|
||||
private playWhenReady = false
|
||||
private sourceOpenListener?: () => void
|
||||
constructor(
|
||||
streamUrl: string,
|
||||
isPublic: boolean,
|
||||
@@ -37,25 +44,26 @@ export default class AudioPlayer {
|
||||
this.voice = voice
|
||||
this.callback = callback
|
||||
// Compatible with iphone ios17 ManagedMediaSource
|
||||
const MediaSource = window.ManagedMediaSource || window.MediaSource
|
||||
if (!MediaSource) {
|
||||
toast.error(
|
||||
'Your browser does not support audio streaming, if you are using an iPhone, please update to iOS 17.1 or later.',
|
||||
)
|
||||
}
|
||||
this.mediaSource = MediaSource ? new MediaSource() : null
|
||||
const MediaSourceConstructor = window.ManagedMediaSource || window.MediaSource
|
||||
const isManagedMediaSource = Boolean(
|
||||
window.ManagedMediaSource && MediaSourceConstructor === window.ManagedMediaSource,
|
||||
)
|
||||
const supportsStreaming = Boolean(MediaSourceConstructor?.isTypeSupported?.(AUDIO_CONTENT_TYPE))
|
||||
this.mediaSource =
|
||||
supportsStreaming && MediaSourceConstructor ? new MediaSourceConstructor() : null
|
||||
this.audio = new Audio()
|
||||
this.setCallback(callback)
|
||||
if (!window.MediaSource) {
|
||||
if (this.mediaSource && isManagedMediaSource) {
|
||||
// if use ManagedMediaSource
|
||||
this.audio.disableRemotePlayback = true
|
||||
this.audio.controls = true
|
||||
}
|
||||
this.audio.src = this.mediaSource ? URL.createObjectURL(this.mediaSource) : ''
|
||||
this.audio.autoplay = true
|
||||
this.listenMediaSource(AUDIO_CONTENT_TYPE)
|
||||
this.objectUrl = this.mediaSource ? URL.createObjectURL(this.mediaSource) : ''
|
||||
this.audio.src = this.objectUrl
|
||||
this.audio.autoplay = Boolean(this.mediaSource)
|
||||
const source = this.audioContext.createMediaElementSource(this.audio)
|
||||
source.connect(this.audioContext.destination)
|
||||
this.listenMediaSource('audio/mpeg')
|
||||
}
|
||||
|
||||
public resetMsgId(msgId: string) {
|
||||
@@ -63,10 +71,77 @@ export default class AudioPlayer {
|
||||
}
|
||||
|
||||
private listenMediaSource(contentType: string) {
|
||||
this.mediaSource?.addEventListener('sourceopen', () => {
|
||||
if (this.sourceBuffer) return
|
||||
this.sourceBuffer = this.mediaSource?.addSourceBuffer(contentType)
|
||||
})
|
||||
this.sourceOpenListener = () => {
|
||||
if (this.destroyed || this.sourceBuffer) return
|
||||
try {
|
||||
this.sourceBuffer = this.mediaSource?.addSourceBuffer(contentType)
|
||||
this.sourceBuffer?.addEventListener('updateend', this.flushBuffers)
|
||||
this.flushBuffers()
|
||||
} catch {
|
||||
this.mediaSource = null
|
||||
this.audio.autoplay = false
|
||||
this.releaseObjectUrl()
|
||||
if (this.streamEnded) this.finishBlobAudio()
|
||||
}
|
||||
}
|
||||
this.mediaSource?.addEventListener('sourceopen', this.sourceOpenListener)
|
||||
}
|
||||
|
||||
private flushBuffers = () => {
|
||||
if (
|
||||
this.destroyed ||
|
||||
!this.sourceBuffer ||
|
||||
this.sourceBuffer.updating ||
|
||||
this.mediaSource?.readyState !== 'open'
|
||||
)
|
||||
return
|
||||
|
||||
const nextBuffer = this.cacheBuffers.shift()
|
||||
if (nextBuffer) {
|
||||
this.sourceBuffer.appendBuffer(nextBuffer)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.streamEnded && !this.endOfStreamCalled) {
|
||||
this.endOfStreamCalled = true
|
||||
this.mediaSource.endOfStream()
|
||||
}
|
||||
}
|
||||
|
||||
private requestPlayback(reportIfPlaying = false) {
|
||||
if (this.destroyed || this.playbackPending) return
|
||||
if (!this.isAudioContextPaused() && !this.audio.paused && !this.audio.ended) {
|
||||
if (reportIfPlaying) this.callback?.('play')
|
||||
return
|
||||
}
|
||||
|
||||
this.playbackPending = true
|
||||
void this.resumeAndPlay()
|
||||
}
|
||||
|
||||
private isAudioContextPaused() {
|
||||
return this.audioContext.state === 'suspended' || this.audioContext.state === 'interrupted'
|
||||
}
|
||||
|
||||
private async resumeAndPlay() {
|
||||
try {
|
||||
const pendingOperations: Promise<unknown>[] = []
|
||||
if (this.isAudioContextPaused()) pendingOperations.push(this.audioContext.resume())
|
||||
if (this.audio.paused || this.audio.ended) pendingOperations.push(this.audio.play())
|
||||
|
||||
await Promise.all(pendingOperations)
|
||||
if (this.destroyed) return
|
||||
if (this.isAudioContextPaused()) {
|
||||
this.callback?.('error')
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.destroyed) this.callback?.('play')
|
||||
} catch {
|
||||
if (!this.destroyed) this.callback?.('error')
|
||||
} finally {
|
||||
this.playbackPending = false
|
||||
}
|
||||
}
|
||||
|
||||
public setCallback(callback: ((event: string) => void) | null) {
|
||||
@@ -80,7 +155,7 @@ export default class AudioPlayer {
|
||||
false,
|
||||
)
|
||||
this.audio.addEventListener(
|
||||
'paused',
|
||||
'pause',
|
||||
() => {
|
||||
callback('paused')
|
||||
},
|
||||
@@ -133,7 +208,7 @@ export default class AudioPlayer {
|
||||
|
||||
private async loadAudio() {
|
||||
try {
|
||||
const audioResponse: any = await textToAudioStream(
|
||||
const audioResponse = (await textToAudioStream(
|
||||
this.url,
|
||||
this.isPublic ? AppSourceType.webApp : AppSourceType.installedApp,
|
||||
{ content_type: 'audio/mpeg' },
|
||||
@@ -143,19 +218,21 @@ export default class AudioPlayer {
|
||||
voice: this.voice,
|
||||
text: this.msgContent,
|
||||
},
|
||||
)
|
||||
)) as Response
|
||||
if (audioResponse.status !== 200) {
|
||||
this.isLoadData = false
|
||||
if (this.callback) this.callback('error')
|
||||
this.callback?.('error')
|
||||
return
|
||||
}
|
||||
if (!audioResponse.body) throw new Error('Audio response body is missing')
|
||||
const reader = audioResponse.body.getReader()
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (value?.byteLength) this.receiveAudioData(value)
|
||||
if (done) {
|
||||
this.receiveAudioData(value)
|
||||
this.finishStream()
|
||||
break
|
||||
}
|
||||
this.receiveAudioData(value)
|
||||
}
|
||||
} catch {
|
||||
this.isLoadData = false
|
||||
@@ -166,46 +243,29 @@ export default class AudioPlayer {
|
||||
// play audio
|
||||
public playAudio() {
|
||||
if (this.isLoadData) {
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
this.audioContext.resume().then((_) => {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
})
|
||||
} else if (this.audio.ended) {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
if (!this.mediaSource && !this.objectUrl) {
|
||||
this.playWhenReady = true
|
||||
return
|
||||
}
|
||||
this.callback?.('play')
|
||||
this.requestPlayback(true)
|
||||
} else {
|
||||
this.isLoadData = true
|
||||
this.audioContext.resume().then((_) => {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
})
|
||||
this.playWhenReady = true
|
||||
if (this.mediaSource) this.requestPlayback(true)
|
||||
else if (this.isAudioContextPaused()) void this.audioContext.resume().catch(() => {})
|
||||
this.loadAudio()
|
||||
}
|
||||
}
|
||||
|
||||
private theEndOfStream() {
|
||||
const endTimer = setInterval(() => {
|
||||
if (!this.sourceBuffer?.updating) {
|
||||
this.mediaSource?.endOfStream()
|
||||
clearInterval(endTimer)
|
||||
}
|
||||
}, 10)
|
||||
}
|
||||
|
||||
private finishStream() {
|
||||
const timer = setInterval(() => {
|
||||
if (!this.cacheBuffers.length) {
|
||||
this.theEndOfStream()
|
||||
clearInterval(timer)
|
||||
}
|
||||
if (this.cacheBuffers.length && !this.sourceBuffer?.updating) {
|
||||
const arrayBuffer = this.cacheBuffers.shift()!
|
||||
this.sourceBuffer?.appendBuffer(arrayBuffer)
|
||||
}
|
||||
}, 10)
|
||||
if (this.destroyed) return
|
||||
this.streamEnded = true
|
||||
if (this.mediaSource) {
|
||||
this.flushBuffers()
|
||||
return
|
||||
}
|
||||
|
||||
this.finishBlobAudio()
|
||||
}
|
||||
|
||||
public async playAudioWithAudio(audio: string, play = true) {
|
||||
@@ -213,54 +273,82 @@ export default class AudioPlayer {
|
||||
this.finishStream()
|
||||
return
|
||||
}
|
||||
const audioContent = Buffer.from(audio, 'base64')
|
||||
this.receiveAudioData(new Uint8Array(audioContent))
|
||||
const audioContent = Uint8Array.from(atob(audio), (char) => char.charCodeAt(0))
|
||||
this.receiveAudioData(audioContent)
|
||||
if (play) {
|
||||
this.isLoadData = true
|
||||
if (this.audio.paused) {
|
||||
this.audioContext.resume().then((_) => {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
})
|
||||
} else if (this.audio.ended) {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
} else if (this.audio.played) {
|
||||
/* empty */
|
||||
} else {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
}
|
||||
this.playWhenReady = true
|
||||
if (this.mediaSource) this.requestPlayback()
|
||||
}
|
||||
}
|
||||
|
||||
public pauseAudio() {
|
||||
this.playWhenReady = false
|
||||
this.callback?.('paused')
|
||||
this.audio.pause()
|
||||
this.audioContext.suspend()
|
||||
void this.audioContext.suspend().catch(() => {})
|
||||
}
|
||||
|
||||
private receiveAudioData(unit8Array: Uint8Array) {
|
||||
public destroy() {
|
||||
if (this.destroyed) return
|
||||
|
||||
this.destroyed = true
|
||||
this.cacheBuffers = []
|
||||
this.callback?.('paused')
|
||||
this.audio.pause()
|
||||
|
||||
if (this.sourceOpenListener)
|
||||
this.mediaSource?.removeEventListener('sourceopen', this.sourceOpenListener)
|
||||
|
||||
if (this.sourceBuffer) {
|
||||
this.sourceBuffer.removeEventListener('updateend', this.flushBuffers)
|
||||
if (this.mediaSource?.readyState === 'open') {
|
||||
try {
|
||||
this.sourceBuffer.abort()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
void this.audioContext.close().catch(() => {})
|
||||
this.releaseObjectUrl()
|
||||
}
|
||||
|
||||
private receiveAudioData(unit8Array: Uint8Array | undefined) {
|
||||
if (this.destroyed || this.streamEnded) return
|
||||
if (!unit8Array) {
|
||||
this.finishStream()
|
||||
return
|
||||
}
|
||||
const audioData = this.byteArrayToArrayBuffer(unit8Array)
|
||||
if (!audioData.byteLength) {
|
||||
if (this.mediaSource?.readyState === 'open') this.finishStream()
|
||||
this.finishStream()
|
||||
return
|
||||
}
|
||||
if (this.sourceBuffer?.updating) {
|
||||
this.cacheBuffers.push(audioData)
|
||||
} else {
|
||||
if (this.cacheBuffers.length && !this.sourceBuffer?.updating) {
|
||||
this.cacheBuffers.push(audioData)
|
||||
const cacheBuffer = this.cacheBuffers.shift()!
|
||||
this.sourceBuffer?.appendBuffer(cacheBuffer)
|
||||
} else {
|
||||
this.sourceBuffer?.appendBuffer(audioData)
|
||||
}
|
||||
this.cacheBuffers.push(audioData)
|
||||
this.flushBuffers()
|
||||
}
|
||||
|
||||
private finishBlobAudio() {
|
||||
if (!this.cacheBuffers.length) {
|
||||
if (!this.objectUrl) this.isLoadData = false
|
||||
return
|
||||
}
|
||||
|
||||
const audioBlob = new Blob(this.cacheBuffers, { type: AUDIO_CONTENT_TYPE })
|
||||
this.cacheBuffers = []
|
||||
this.releaseObjectUrl()
|
||||
this.objectUrl = URL.createObjectURL(audioBlob)
|
||||
this.audio.src = this.objectUrl
|
||||
this.isLoadData = true
|
||||
if (this.playWhenReady) this.requestPlayback()
|
||||
}
|
||||
|
||||
private releaseObjectUrl() {
|
||||
if (!this.objectUrl) return
|
||||
|
||||
URL.revokeObjectURL(this.objectUrl)
|
||||
this.objectUrl = ''
|
||||
this.audio.src = ''
|
||||
}
|
||||
|
||||
private byteArrayToArrayBuffer(byteArray: Uint8Array): ArrayBuffer {
|
||||
|
||||
@@ -39,3 +39,9 @@
|
||||
.spin-animation path:nth-child(4) {
|
||||
animation-delay: 1.5s;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spin-animation path {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,27 @@ describe('SearchInput', () => {
|
||||
const clearButton = screen.getByLabelText('common.operation.clear')
|
||||
expect(clearButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps a disabled searchbox inert and exposes its description', () => {
|
||||
render(
|
||||
<>
|
||||
<SearchInput
|
||||
disabled
|
||||
aria-describedby="search-unavailable"
|
||||
value="has value"
|
||||
onValueChange={() => {}}
|
||||
/>
|
||||
<span id="search-unavailable">Search unavailable</span>
|
||||
</>,
|
||||
)
|
||||
|
||||
const searchbox = screen.getByRole('searchbox', { name: 'common.operation.search' })
|
||||
expect(searchbox).toBeDisabled()
|
||||
expect(searchbox).toHaveAccessibleDescription('Search unavailable')
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.operation.clear' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interaction', () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ type SearchInputProps = {
|
||||
onValueChange: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
} & Pick<InputProps, 'aria-label' | 'autoFocus'>
|
||||
} & Pick<InputProps, 'aria-describedby' | 'aria-label' | 'autoFocus' | 'disabled'>
|
||||
|
||||
export function SearchInput({
|
||||
ref,
|
||||
@@ -20,6 +20,8 @@ export function SearchInput({
|
||||
value,
|
||||
onValueChange,
|
||||
autoFocus,
|
||||
disabled,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
'aria-label': ariaLabel,
|
||||
}: SearchInputProps) {
|
||||
const { t } = useTranslation()
|
||||
@@ -48,6 +50,7 @@ export function SearchInput({
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
name="query"
|
||||
aria-describedby={ariaDescribedBy}
|
||||
aria-label={ariaLabel ?? t(($) => $['operation.search'], { ns: 'common' })}
|
||||
className={cn(
|
||||
'ps-7',
|
||||
@@ -56,6 +59,7 @@ export function SearchInput({
|
||||
)}
|
||||
placeholder={placeholder ?? t(($) => $['operation.search'], { ns: 'common' })}
|
||||
value={inputValue}
|
||||
disabled={disabled}
|
||||
onValueChange={(nextValue) => {
|
||||
if (isComposingRef.current) {
|
||||
setCompositionValue(nextValue)
|
||||
@@ -90,7 +94,7 @@ export function SearchInput({
|
||||
autoFocus={autoFocus}
|
||||
enterKeyHint="search"
|
||||
/>
|
||||
{!!inputValue && (
|
||||
{!!inputValue && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
|
||||
|
||||
@@ -347,7 +347,6 @@ const ownerWorkspacePermissionKeys = [
|
||||
'dataset.external.connect',
|
||||
'tool.manage',
|
||||
'mcp.manage',
|
||||
'agent.manage',
|
||||
]
|
||||
|
||||
const datasetOperatorWorkspacePermissionKeys = [
|
||||
@@ -568,23 +567,6 @@ describe('MainNav', () => {
|
||||
expect(screen.queryByRole('link', { name: /Agents/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the roster entry when the user lacks agent.manage', () => {
|
||||
mockConsoleState.current = {
|
||||
...consoleState,
|
||||
workspacePermissionKeys: ownerWorkspacePermissionKeys.filter((key) => key !== 'agent.manage'),
|
||||
}
|
||||
|
||||
renderMainNav()
|
||||
|
||||
expect(screen.queryByRole('link', { name: /Agents/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the roster entry when the user has agent.manage', () => {
|
||||
renderMainNav()
|
||||
|
||||
expect(screen.getByRole('link', { name: /Agents/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the marketplace entry when marketplace is disabled', () => {
|
||||
renderMainNav({ enable_marketplace: false })
|
||||
|
||||
@@ -739,7 +721,7 @@ describe('MainNav', () => {
|
||||
isCurrentWorkspaceEditor: false,
|
||||
isCurrentWorkspaceManager: false,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
workspacePermissionKeys: ['app_library.access', 'tool.manage', 'agent.manage'],
|
||||
workspacePermissionKeys: ['app_library.access', 'tool.manage'],
|
||||
}
|
||||
|
||||
renderMainNav({ branding: { enabled: false }, enable_app_deploy: true })
|
||||
|
||||
@@ -178,23 +178,41 @@ describe('MainNavLayout', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['/datasets/create', '/datasets/dataset-1/documents/create', '/deployments/create'])(
|
||||
'keeps the global main nav on collection and creation route %s',
|
||||
(pathname) => {
|
||||
;(usePathname as Mock).mockReturnValue(pathname)
|
||||
it('ignores a retained legacy detail sidebar on New Knowledge routes', () => {
|
||||
;(usePathname as Mock).mockReturnValue('/datasets/new/knowledge-1/sources')
|
||||
|
||||
render(
|
||||
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
|
||||
<div>content</div>
|
||||
</MainNavLayout>,
|
||||
)
|
||||
render(
|
||||
<MainNavLayout
|
||||
detailSidebar={<aside aria-label="Legacy dataset sidebar">Legacy dataset sidebar</aside>}
|
||||
>
|
||||
<div>new knowledge detail</div>
|
||||
</MainNavLayout>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('complementary', { name: 'Detail sidebar' }),
|
||||
).not.toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
expect(screen.queryByTestId('main-nav')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('complementary', { name: 'Legacy dataset sidebar' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('main')).toHaveTextContent('new knowledge detail')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/datasets/create',
|
||||
'/datasets/new/create',
|
||||
'/datasets/dataset-1/documents/create',
|
||||
'/deployments/create',
|
||||
])('keeps the global main nav on collection and creation route %s', (pathname) => {
|
||||
;(usePathname as Mock).mockReturnValue(pathname)
|
||||
|
||||
render(
|
||||
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
|
||||
<div>content</div>
|
||||
</MainNavLayout>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
isCurrentWorkspaceEditorAtom,
|
||||
} from '@/context/workspace-state'
|
||||
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
|
||||
import { useCanManageAgents } from '@/features/agent-v2/permissions'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import Link from '@/next/link'
|
||||
@@ -38,7 +37,6 @@ export function MainNav({ className }: MainNavProps) {
|
||||
const isCurrentWorkspaceEditor = useAtomValue(isCurrentWorkspaceEditorAtom)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const agentV2Enabled = isAgentV2Enabled()
|
||||
const canManageAgents = useCanManageAgents()
|
||||
const showEnvTag =
|
||||
langGeniusVersionInfo.current_env === 'TESTING' ||
|
||||
langGeniusVersionInfo.current_env === 'DEVELOPMENT'
|
||||
@@ -49,7 +47,6 @@ export function MainNav({ className }: MainNavProps) {
|
||||
MAIN_NAV_ROUTES.filter((route) =>
|
||||
isMainNavRouteVisible(route, {
|
||||
agentV2Enabled,
|
||||
canManageAgents,
|
||||
canUseAppDeploy,
|
||||
isCurrentWorkspaceDatasetOperator,
|
||||
marketplaceEnabled: systemFeatures.enable_marketplace,
|
||||
@@ -63,7 +60,6 @@ export function MainNav({ className }: MainNavProps) {
|
||||
})),
|
||||
[
|
||||
agentV2Enabled,
|
||||
canManageAgents,
|
||||
canUseAppDeploy,
|
||||
isCurrentWorkspaceDatasetOperator,
|
||||
systemFeatures.enable_marketplace,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { MainNav } from '.'
|
||||
import { shouldUseDetailSidebar } from './routes'
|
||||
import { shouldHideMainNavigation, shouldUseDetailSidebar } from './routes'
|
||||
import { MAIN_CONTENT_ID, SkipNav } from './skip-nav'
|
||||
|
||||
type MainNavLayoutProps = {
|
||||
@@ -47,7 +47,8 @@ const MainNavLayout = ({ children, detailSidebar }: MainNavLayoutProps) => {
|
||||
const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom)
|
||||
const isCurrentWorkspaceEditor = useAtomValue(isCurrentWorkspaceEditorAtom)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const shouldHideMainNav = shouldUseDetailSidebar(pathname, {
|
||||
const hideMainNavigation = shouldHideMainNavigation(pathname)
|
||||
const useDetailSidebar = shouldUseDetailSidebar(pathname, {
|
||||
agentV2Enabled: isAgentV2Enabled(),
|
||||
canUseAppDeploy: isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy,
|
||||
isCurrentWorkspaceDatasetOperator,
|
||||
@@ -57,7 +58,7 @@ const MainNavLayout = ({ children, detailSidebar }: MainNavLayoutProps) => {
|
||||
<div className="flex h-0 min-h-0 min-w-0 grow overflow-hidden bg-background-body">
|
||||
<SkipNav>{t(($) => $['navigation.skipToMain'])}</SkipNav>
|
||||
<AppDetailStoreCleanup />
|
||||
{shouldHideMainNav ? detailSidebar : <MainNav />}
|
||||
{hideMainNavigation ? null : useDetailSidebar ? detailSidebar : <MainNav />}
|
||||
<main
|
||||
id={MAIN_CONTENT_ID}
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { buildIntegrationPath } from '@/app/components/integrations/routes'
|
||||
|
||||
type MainNavRouteVisibility = (options: MainNavRouteVisibilityOptions) => boolean
|
||||
type MainNavRouteVisibility = 'all' | 'notDatasetOperator' | 'appDeployEditor'
|
||||
|
||||
const DATASET_COLLECTION_ROUTES = new Set(['create', 'create-from-pipeline', 'connect'])
|
||||
const DATASET_DOCUMENT_CREATION_ROUTES = new Set(['create', 'create-from-pipeline'])
|
||||
@@ -18,7 +18,6 @@ export type MainNavRouteConfig = {
|
||||
|
||||
export type MainNavRouteVisibilityOptions = {
|
||||
agentV2Enabled: boolean
|
||||
canManageAgents: boolean
|
||||
canUseAppDeploy: boolean
|
||||
isCurrentWorkspaceDatasetOperator: boolean
|
||||
marketplaceEnabled: boolean
|
||||
@@ -29,10 +28,6 @@ export type DetailSidebarVisibilityOptions = Pick<
|
||||
'agentV2Enabled' | 'canUseAppDeploy' | 'isCurrentWorkspaceDatasetOperator'
|
||||
>
|
||||
|
||||
const VISIBLE_TO_ALL: MainNavRouteVisibility = () => true
|
||||
const CAN_MANAGE_AGENTS: MainNavRouteVisibility = (options) => options.canManageAgents
|
||||
const CAN_USE_APP_DEPLOY: MainNavRouteVisibility = (options) => options.canUseAppDeploy
|
||||
|
||||
function isPathUnderRoute(pathname: string, route: string) {
|
||||
return pathname === route || pathname.startsWith(`${route}/`)
|
||||
}
|
||||
@@ -45,7 +40,7 @@ export const MAIN_NAV_ROUTES = [
|
||||
active: (path: string) => path === '/' || path === '/explore/apps',
|
||||
icon: 'i-custom-vender-main-nav-home',
|
||||
activeIcon: 'i-custom-vender-main-nav-home-active',
|
||||
visibility: VISIBLE_TO_ALL,
|
||||
visibility: 'all',
|
||||
},
|
||||
{
|
||||
key: 'apps',
|
||||
@@ -57,7 +52,7 @@ export const MAIN_NAV_ROUTES = [
|
||||
isPathUnderRoute(path, '/snippets'),
|
||||
icon: 'i-custom-vender-main-nav-studio',
|
||||
activeIcon: 'i-custom-vender-main-nav-studio-active',
|
||||
visibility: VISIBLE_TO_ALL,
|
||||
visibility: 'all',
|
||||
},
|
||||
{
|
||||
key: 'roster',
|
||||
@@ -66,7 +61,7 @@ export const MAIN_NAV_ROUTES = [
|
||||
active: (path: string) => isPathUnderRoute(path, '/agents'),
|
||||
icon: 'i-custom-vender-main-nav-roster',
|
||||
activeIcon: 'i-custom-vender-main-nav-roster-active',
|
||||
visibility: CAN_MANAGE_AGENTS,
|
||||
visibility: 'notDatasetOperator',
|
||||
feature: 'agentV2',
|
||||
},
|
||||
{
|
||||
@@ -76,7 +71,7 @@ export const MAIN_NAV_ROUTES = [
|
||||
active: (path: string) => isPathUnderRoute(path, '/datasets'),
|
||||
icon: 'i-custom-vender-main-nav-knowledge',
|
||||
activeIcon: 'i-custom-vender-main-nav-knowledge-active',
|
||||
visibility: VISIBLE_TO_ALL,
|
||||
visibility: 'all',
|
||||
},
|
||||
{
|
||||
key: 'integrations',
|
||||
@@ -86,7 +81,7 @@ export const MAIN_NAV_ROUTES = [
|
||||
isPathUnderRoute(path, '/integrations') || isPathUnderRoute(path, '/tools'),
|
||||
icon: 'i-custom-vender-main-nav-integrations',
|
||||
activeIcon: 'i-custom-vender-main-nav-integrations-active',
|
||||
visibility: VISIBLE_TO_ALL,
|
||||
visibility: 'all',
|
||||
},
|
||||
{
|
||||
key: 'marketplace',
|
||||
@@ -96,7 +91,7 @@ export const MAIN_NAV_ROUTES = [
|
||||
isPathUnderRoute(path, '/marketplace') || isPathUnderRoute(path, '/plugins'),
|
||||
icon: 'i-custom-vender-main-nav-marketplace',
|
||||
activeIcon: 'i-custom-vender-main-nav-marketplace-active',
|
||||
visibility: VISIBLE_TO_ALL,
|
||||
visibility: 'all',
|
||||
feature: 'marketplace',
|
||||
},
|
||||
{
|
||||
@@ -106,7 +101,7 @@ export const MAIN_NAV_ROUTES = [
|
||||
active: (path: string) => isPathUnderRoute(path, '/deployments'),
|
||||
icon: 'i-ri-rocket-line',
|
||||
activeIcon: 'i-ri-rocket-fill',
|
||||
visibility: CAN_USE_APP_DEPLOY,
|
||||
visibility: 'appDeployEditor',
|
||||
},
|
||||
] as const satisfies readonly MainNavRouteConfig[]
|
||||
|
||||
@@ -118,7 +113,11 @@ export function isMainNavRouteVisible(
|
||||
|
||||
if (route.feature === 'marketplace' && !options.marketplaceEnabled) return false
|
||||
|
||||
return route.visibility(options)
|
||||
if (route.visibility === 'all') return true
|
||||
|
||||
if (route.visibility === 'notDatasetOperator') return !options.isCurrentWorkspaceDatasetOperator
|
||||
|
||||
return options.canUseAppDeploy
|
||||
}
|
||||
|
||||
function isAppDetailPathname(pathname: string) {
|
||||
@@ -132,12 +131,25 @@ function isDatasetDetailPathname(pathname: string) {
|
||||
|
||||
if (DATASET_COLLECTION_ROUTES.has(datasetId)) return false
|
||||
|
||||
if (datasetId === 'new' && subSection === 'create') return false
|
||||
|
||||
if (subSection === 'documents' && action && DATASET_DOCUMENT_CREATION_ROUTES.has(action))
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function shouldHideMainNavigation(pathname: string) {
|
||||
const [section, namespace, knowledgeSpaceId] = pathname.split('/').filter(Boolean)
|
||||
|
||||
return (
|
||||
section === 'datasets' &&
|
||||
namespace === 'new' &&
|
||||
!!knowledgeSpaceId &&
|
||||
knowledgeSpaceId !== 'create'
|
||||
)
|
||||
}
|
||||
|
||||
function isAgentDetailPathname(pathname: string) {
|
||||
const [section, agentId] = pathname.split('/').filter(Boolean)
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { AgentSelectorContent } from '../agent-selector'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
canManageAgents: true,
|
||||
agents: [] as Array<{ id: string; name: string }>,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/agent-v2/permissions', () => ({
|
||||
useCanManageAgents: () => mocks.canManageAgents,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks-store', () => ({
|
||||
useHooksStore: () => undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
agent: {
|
||||
inviteOptions: {
|
||||
get: {
|
||||
queryOptions: () => ({
|
||||
queryKey: ['agent-invite-options'],
|
||||
queryFn: async () => ({ data: mocks.agents }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const manageInConsoleLabel = /manageInAgentConsole/
|
||||
const startFromScratchLabel = /startFromScratch/
|
||||
|
||||
const renderSelector = async ({ onStartFromScratch }: { onStartFromScratch?: () => void } = {}) => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentSelectorContent
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onSelect={vi.fn()}
|
||||
onStartFromScratch={onStartFromScratch}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await screen.findByRole('listbox')
|
||||
}
|
||||
|
||||
describe('AgentSelectorContent', () => {
|
||||
beforeEach(() => {
|
||||
mocks.canManageAgents = true
|
||||
mocks.agents = []
|
||||
})
|
||||
|
||||
it('offers the Agent Console link with agent.manage', async () => {
|
||||
await renderSelector()
|
||||
|
||||
expect(screen.getByText(manageInConsoleLabel)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the Agent Console link without agent.manage', async () => {
|
||||
mocks.canManageAgents = false
|
||||
|
||||
await renderSelector()
|
||||
|
||||
expect(screen.queryByText(manageInConsoleLabel)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps start from scratch without agent.manage', async () => {
|
||||
mocks.canManageAgents = false
|
||||
|
||||
await renderSelector({ onStartFromScratch: vi.fn() })
|
||||
|
||||
expect(screen.getByText(startFromScratchLabel)).toBeInTheDocument()
|
||||
expect(screen.queryByText(manageInConsoleLabel)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders no action row when neither action is available', async () => {
|
||||
mocks.canManageAgents = false
|
||||
|
||||
await renderSelector()
|
||||
|
||||
expect(screen.queryByText(startFromScratchLabel)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(manageInConsoleLabel)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -50,12 +50,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Permission-dependent selector actions are covered by agent-selector.spec.tsx;
|
||||
// this suite is about block insertion.
|
||||
vi.mock('@/features/agent-v2/permissions', () => ({
|
||||
useCanManageAgents: () => true,
|
||||
}))
|
||||
|
||||
const createBlock = (
|
||||
type: BlockEnum,
|
||||
title: string,
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import { useCanManageAgents } from '@/features/agent-v2/permissions'
|
||||
import Link from '@/next/link'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import BlockIcon from '../block-icon'
|
||||
@@ -61,13 +60,9 @@ export function AgentSelectorContent({
|
||||
staleTime: 0,
|
||||
})
|
||||
const agents = agentsQuery.data?.data ?? []
|
||||
const canManageAgents = useCanManageAgents()
|
||||
const actionOptions: AgentSelectorActionOption[] = [
|
||||
// Start from scratch stays available to everyone: it only writes the node's
|
||||
// own inline draft and never reaches the Agent Console.
|
||||
...(onStartFromScratch ? (['start-from-scratch'] as const) : []),
|
||||
...(canManageAgents ? (['manage-in-agent-console'] as const) : []),
|
||||
]
|
||||
const actionOptions: AgentSelectorActionOption[] = onStartFromScratch
|
||||
? ['start-from-scratch', 'manage-in-agent-console']
|
||||
: ['manage-in-agent-console']
|
||||
const options: AgentSelectorOption[] = [...agents, ...actionOptions]
|
||||
const getOptionLabel = (option: AgentSelectorOption) => {
|
||||
if (isAgentSelectorActionOption(option)) {
|
||||
@@ -155,13 +150,11 @@ export function AgentSelectorContent({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{actionOptions.length > 0 && (
|
||||
<div role="presentation" className="border-t border-divider-subtle p-1">
|
||||
{actionOptions.map((option) => (
|
||||
<AgentSelectorActionItem key={option} option={option} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div role="presentation" className="border-t border-divider-subtle p-1">
|
||||
{actionOptions.map((option) => (
|
||||
<AgentSelectorActionItem key={option} option={option} />
|
||||
))}
|
||||
</div>
|
||||
</ComboboxList>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
@@ -147,10 +147,6 @@ vi.mock('../../_base/hooks/use-node-crud', () => ({
|
||||
default: (id: string, data: AgentV2NodeType) => mockUseNodeCrud(id, data),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/agent-v2/permissions', () => ({
|
||||
useCanManageAgents: () => true,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector/agent-selector', () => ({
|
||||
AgentSelectorContent: ({
|
||||
onSelect,
|
||||
|
||||
-20
@@ -22,12 +22,6 @@ const mocks = vi.hoisted(() => ({
|
||||
uploadWorkflowSandboxFile: vi.fn(),
|
||||
}))
|
||||
|
||||
const permission = vi.hoisted(() => ({ canManageAgents: true }))
|
||||
|
||||
vi.mock('@/features/agent-v2/permissions', () => ({
|
||||
useCanManageAgents: () => permission.canManageAgents,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useDefaultModel: () => ({
|
||||
data: undefined,
|
||||
@@ -417,7 +411,6 @@ function createInlineComposerState({
|
||||
describe('WorkflowInlineAgentConfigureWorkspace', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
permission.canManageAgents = true
|
||||
mocks.loadBuildDraft.mockRejectedValue(new Response(null, { status: 404 }))
|
||||
mocks.checkoutBuildDraft.mockResolvedValue({
|
||||
agent_soul: {},
|
||||
@@ -515,19 +508,6 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the save-to-roster menu when the user cannot manage agents', async () => {
|
||||
permission.canManageAgents = false
|
||||
|
||||
renderWorkspace({
|
||||
onSaveInlineToRoster: vi.fn(),
|
||||
})
|
||||
|
||||
await screen.findByRole('region', { name: 'orchestrate-panel' })
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.operation.more' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the working directory panel when the header action is clicked', async () => {
|
||||
renderWorkspace({
|
||||
inlineComposerState: createInlineComposerState({
|
||||
|
||||
-60
@@ -3,39 +3,10 @@ import userEvent from '@testing-library/user-event'
|
||||
import { useRef } from 'react'
|
||||
import { AgentRosterField } from '../agent-roster-field'
|
||||
|
||||
const permission = vi.hoisted(() => ({ canManageAgents: true }))
|
||||
|
||||
vi.mock('@/features/agent-v2/permissions', () => ({
|
||||
useCanManageAgents: () => permission.canManageAgents,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector/agent-selector', () => ({
|
||||
AgentSelectorContent: () => null,
|
||||
}))
|
||||
|
||||
function renderDetailRosterField() {
|
||||
function Harness() {
|
||||
const portalContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<div ref={portalContainerRef}>
|
||||
<AgentRosterField
|
||||
agent={{
|
||||
id: 'roster-agent-1',
|
||||
name: 'Roster Agent',
|
||||
role: 'Shared roster agent',
|
||||
}}
|
||||
portalContainerRef={portalContainerRef}
|
||||
onChange={vi.fn()}
|
||||
onMakeCopy={vi.fn()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
}
|
||||
|
||||
function renderInlineRosterField() {
|
||||
function Harness() {
|
||||
const portalContainerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -61,37 +32,6 @@ function renderInlineRosterField() {
|
||||
}
|
||||
|
||||
describe('AgentRosterField', () => {
|
||||
beforeEach(() => {
|
||||
permission.canManageAgents = true
|
||||
})
|
||||
|
||||
it('shows Make Copy in the roster detail panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDetailRosterField()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ }),
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'workflow.nodes.agent.roster.makeCopy' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps Make Copy available when the user cannot manage agents', async () => {
|
||||
permission.canManageAgents = false
|
||||
const user = userEvent.setup()
|
||||
renderDetailRosterField()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ }),
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'workflow.nodes.agent.roster.makeCopy' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns focus to the inline setup trigger when the dialog closes with Escape', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderInlineRosterField()
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { EditInConsoleLink } from '../edit-in-console-link'
|
||||
|
||||
describe('EditInConsoleLink', () => {
|
||||
it('renders a link to the agent console when permitted', () => {
|
||||
render(<EditInConsoleLink agentId="agent-1" canManageAgents />)
|
||||
|
||||
const link = screen.getByRole('link', { name: /editInConsole/ })
|
||||
expect(link).toHaveAttribute('href', expect.stringContaining('/agents/agent-1'))
|
||||
})
|
||||
|
||||
it('renders a disabled control instead of a link when not permitted', () => {
|
||||
render(<EditInConsoleLink agentId="agent-1" canManageAgents={false} />)
|
||||
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /editInConsole/ })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
)
|
||||
})
|
||||
})
|
||||
-4
@@ -62,7 +62,6 @@ import {
|
||||
useAgentConfigureBuildDraftActions,
|
||||
useAgentConfigureBuildDraftData,
|
||||
} from '@/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft'
|
||||
import { useCanManageAgents } from '@/features/agent-v2/permissions'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { useWorkflowInlineAgentConfigureSync } from '../agent-soul-config'
|
||||
@@ -732,9 +731,6 @@ function WorkflowInlineAgentConfigureMoreAction({
|
||||
onSaveInlineToRoster: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('common')
|
||||
const canManageAgents = useCanManageAgents()
|
||||
|
||||
if (!canManageAgents) return null
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
|
||||
@@ -31,8 +31,8 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { AgentSelectorContent } from '@/app/components/workflow/block-selector/agent-selector'
|
||||
import { useCanManageAgents } from '@/features/agent-v2/permissions'
|
||||
import { EditInConsoleLink } from './edit-in-console-link'
|
||||
import { getAgentDetailPath } from '@/features/agent-v2/agent-detail/routes'
|
||||
import Link from '@/next/link'
|
||||
|
||||
const i18nPrefix = 'nodes.agent'
|
||||
type AgentRosterDrawerMode = 'setup' | 'detail'
|
||||
@@ -122,7 +122,6 @@ function AgentRosterDrawer({
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const canManageAgents = useCanManageAgents()
|
||||
const isSetup = mode === 'setup'
|
||||
const title = isInlineSetup
|
||||
? t(($) => $[`${i18nPrefix}.roster.inlineSetup.name`], { ns: 'workflow' })
|
||||
@@ -130,7 +129,7 @@ function AgentRosterDrawer({
|
||||
const description = isSetup
|
||||
? t(($) => $[`${i18nPrefix}.roster.inlineSetup.description`], { ns: 'workflow' })
|
||||
: agent.role
|
||||
const showInlineActions = isInlineSetup && !!onSaveInlineToRoster && canManageAgents
|
||||
const showInlineActions = isInlineSetup && !!onSaveInlineToRoster
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -252,7 +251,17 @@ function AgentRosterDrawer({
|
||||
{!isSetup && showDetailActions && (
|
||||
<div className="flex h-8 gap-2 pl-1">
|
||||
{showConsoleLink && (
|
||||
<EditInConsoleLink agentId={agent.id} canManageAgents={canManageAgents} />
|
||||
<Link
|
||||
href={getAgentDetailPath(agent.id, 'configure')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-8 min-w-0 flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 text-[13px] leading-4 font-medium whitespace-nowrap text-components-button-secondary-text shadow-xs outline-hidden backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span aria-hidden className="i-ri-external-link-line size-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t(($) => $[`${i18nPrefix}.roster.editInConsole`], { ns: 'workflow' })}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getAgentDetailPath } from '@/features/agent-v2/agent-detail/routes'
|
||||
import Link from '@/next/link'
|
||||
|
||||
const layoutClassName = 'min-w-0 flex-1 gap-1.5 px-3'
|
||||
|
||||
export function EditInConsoleLink({
|
||||
agentId,
|
||||
canManageAgents,
|
||||
}: {
|
||||
agentId: string
|
||||
canManageAgents: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const label = t(($) => $['nodes.agent.roster.editInConsole'], { ns: 'workflow' })
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span aria-hidden className="i-ri-external-link-line size-4 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if (canManageAgents) {
|
||||
return (
|
||||
<Link
|
||||
className={cn(buttonVariants({ className: layoutClassName }))}
|
||||
href={getAgentDetailPath(agentId, 'configure')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button className={layoutClassName} disabled focusableWhenDisabled>
|
||||
{content}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t(($) => $['nodes.agent.roster.editInConsoleDisabled'], { ns: 'workflow' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
type DeploymentCase = {
|
||||
edition: 'CLOUD' | 'SELF_HOSTED'
|
||||
enterpriseEnabled: boolean
|
||||
expected: {
|
||||
isCloud: boolean
|
||||
isCommunity: boolean
|
||||
isSelfHosted: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const loadConfig = async ({ edition, enterpriseEnabled }: DeploymentCase) => {
|
||||
vi.resetModules()
|
||||
vi.doMock('@/env', () => ({
|
||||
env: {
|
||||
NEXT_PUBLIC_EDITION: edition,
|
||||
NEXT_PUBLIC_ENTERPRISE_ENABLED: enterpriseEnabled,
|
||||
},
|
||||
}))
|
||||
|
||||
return import('../index')
|
||||
}
|
||||
|
||||
describe('deployment edition config', () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock('@/env')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it.each<DeploymentCase>([
|
||||
{
|
||||
edition: 'CLOUD',
|
||||
enterpriseEnabled: false,
|
||||
expected: { isCloud: true, isCommunity: false, isSelfHosted: false },
|
||||
},
|
||||
{
|
||||
edition: 'CLOUD',
|
||||
enterpriseEnabled: true,
|
||||
expected: { isCloud: true, isCommunity: false, isSelfHosted: false },
|
||||
},
|
||||
{
|
||||
edition: 'SELF_HOSTED',
|
||||
enterpriseEnabled: false,
|
||||
expected: { isCloud: false, isCommunity: true, isSelfHosted: true },
|
||||
},
|
||||
{
|
||||
edition: 'SELF_HOSTED',
|
||||
enterpriseEnabled: true,
|
||||
expected: { isCloud: false, isCommunity: false, isSelfHosted: true },
|
||||
},
|
||||
])('derives flags for $edition with enterpriseEnabled=$enterpriseEnabled', async (deployment) => {
|
||||
const config = await loadConfig(deployment)
|
||||
|
||||
expect({
|
||||
isCloud: config.IS_CLOUD_EDITION,
|
||||
isCommunity: config.IS_COMMUNITY_EDITION,
|
||||
isSelfHosted: config.IS_CE_EDITION,
|
||||
}).toEqual(deployment.expected)
|
||||
})
|
||||
})
|
||||
@@ -29,7 +29,6 @@ const EDITION = env.NEXT_PUBLIC_EDITION
|
||||
|
||||
export const IS_CE_EDITION = EDITION === 'SELF_HOSTED'
|
||||
export const IS_CLOUD_EDITION = EDITION === 'CLOUD'
|
||||
export const IS_COMMUNITY_EDITION = IS_CE_EDITION && !env.NEXT_PUBLIC_ENTERPRISE_ENABLED
|
||||
|
||||
export const AMPLITUDE_API_KEY = getStringConfig(env.NEXT_PUBLIC_AMPLITUDE_API_KEY, '')
|
||||
export const COOKIEYES_SITE_KEY = getStringConfig(env.NEXT_PUBLIC_COOKIEYES_SITE_KEY, '')
|
||||
|
||||
@@ -14,6 +14,8 @@ import { ZENDESK_FIELD_IDS } from '@/config'
|
||||
import { refreshUserProfileAtom, userProfileAtom } from '../account-state'
|
||||
import { initialWorkspaceInfo } from '../app-context-defaults'
|
||||
import {
|
||||
datasetDefaultPermissionKeysAtom,
|
||||
refreshWorkspacePermissionKeysAfterMutationDenialAtom,
|
||||
workspacePermissionKeysAtom,
|
||||
workspacePermissionKeysLoadingAtom,
|
||||
} from '../permission-state'
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
|
||||
const mockGetRequest = vi.hoisted(() => vi.fn())
|
||||
const mockPermissionKeysState = vi.hoisted(() => ({
|
||||
datasetPermissionKeys: ['dataset.acl.edit'],
|
||||
isPending: false,
|
||||
permissionKeys: ['app.create_and_management'],
|
||||
}))
|
||||
@@ -209,11 +212,15 @@ function ConsoleBootstrapProbe() {
|
||||
const isCurrentWorkspaceEditor = useAtomValue(isCurrentWorkspaceEditorAtom)
|
||||
const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom)
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const datasetDefaultPermissionKeys = useAtomValue(datasetDefaultPermissionKeysAtom)
|
||||
const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom)
|
||||
const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom)
|
||||
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
|
||||
const refreshUserProfile = useSetAtom(refreshUserProfileAtom)
|
||||
const refreshCurrentWorkspace = useSetAtom(refreshCurrentWorkspaceAtom)
|
||||
const refreshPermissionsAfterMutationDenial = useSetAtom(
|
||||
refreshWorkspacePermissionKeysAfterMutationDenialAtom,
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -221,6 +228,10 @@ function ConsoleBootstrapProbe() {
|
||||
keys:
|
||||
{workspacePermissionKeys.join(',')}
|
||||
</span>
|
||||
<span>
|
||||
dataset keys:
|
||||
{datasetDefaultPermissionKeys.join(',')}
|
||||
</span>
|
||||
<span>
|
||||
permission loading:
|
||||
{String(isLoadingWorkspacePermissionKeys)}
|
||||
@@ -268,6 +279,9 @@ function ConsoleBootstrapProbe() {
|
||||
<button type="button" onClick={refreshCurrentWorkspace}>
|
||||
refresh workspace
|
||||
</button>
|
||||
<button type="button" onClick={() => void refreshPermissionsAfterMutationDenial()}>
|
||||
refresh permissions after denial
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -324,6 +338,7 @@ describe('Console bootstrap', () => {
|
||||
vi.clearAllMocks()
|
||||
setAnalyticsConsent('granted')
|
||||
mockPermissionKeysState.isPending = false
|
||||
mockPermissionKeysState.datasetPermissionKeys = ['dataset.acl.edit']
|
||||
mockPermissionKeysState.permissionKeys = ['app.create_and_management']
|
||||
mockCurrentWorkspaceQueryState.data = mockCurrentWorkspaceResponse
|
||||
mockCurrentWorkspaceQueryState.isPending = false
|
||||
@@ -369,7 +384,7 @@ describe('Console bootstrap', () => {
|
||||
overrides: [],
|
||||
},
|
||||
dataset: {
|
||||
default_permission_keys: [],
|
||||
default_permission_keys: mockPermissionKeysState.datasetPermissionKeys,
|
||||
overrides: [],
|
||||
},
|
||||
})
|
||||
@@ -388,6 +403,7 @@ describe('Console bootstrap', () => {
|
||||
expect(await screen.findByText('user:[email protected]')).toBeInTheDocument()
|
||||
expect(await screen.findByText('workspace:Workspace')).toBeInTheDocument()
|
||||
expect(await screen.findByText('keys:app.create_and_management')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset keys:dataset.acl.edit')).toBeInTheDocument()
|
||||
expect(screen.getByText('permission loading:false')).toBeInTheDocument()
|
||||
expect(screen.getByText('workspace loading:false')).toBeInTheDocument()
|
||||
expect(await screen.findByText('version:1.0.0/1.0.1/cloud')).toBeInTheDocument()
|
||||
@@ -395,6 +411,7 @@ describe('Console bootstrap', () => {
|
||||
|
||||
it('should fall back to placeholder values when workspace, permission, or version data is missing', async () => {
|
||||
mockCurrentWorkspaceQueryState.data = undefined
|
||||
mockPermissionKeysState.datasetPermissionKeys = []
|
||||
mockPermissionKeysState.permissionKeys = []
|
||||
mockLangGeniusVersionState.data = undefined
|
||||
|
||||
@@ -404,6 +421,7 @@ describe('Console bootstrap', () => {
|
||||
expect(screen.getByText(`workspace:${initialWorkspaceInfo.name}`)).toBeInTheDocument()
|
||||
expect(screen.getByText(`role:${initialWorkspaceInfo.role}`)).toBeInTheDocument()
|
||||
expect(screen.getByText('keys:')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset keys:')).toBeInTheDocument()
|
||||
expect(screen.getByText('version://')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -454,6 +472,33 @@ describe('Console bootstrap', () => {
|
||||
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['user-profile'] })
|
||||
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['current-workspace'] })
|
||||
})
|
||||
|
||||
it('starts a fresh permission request without waiting for an older request', async () => {
|
||||
const { queryClient } = renderConsoleBootstrap()
|
||||
await screen.findByText('dataset keys:dataset.acl.edit')
|
||||
const olderRequest = new Promise(() => {})
|
||||
let permissionRequestCount = 0
|
||||
mockGetRequest.mockImplementation((url: string) => {
|
||||
if (url !== '/workspaces/current/rbac/my-permissions')
|
||||
return Promise.reject(new Error(`Unexpected GET ${url}`))
|
||||
permissionRequestCount += 1
|
||||
if (permissionRequestCount === 1) return olderRequest
|
||||
return Promise.resolve({
|
||||
workspace: { permission_keys: [] },
|
||||
app: { default_permission_keys: [], overrides: [] },
|
||||
dataset: { default_permission_keys: ['dataset.acl.readonly'], overrides: [] },
|
||||
})
|
||||
})
|
||||
|
||||
const backgroundRefresh = queryClient.refetchQueries({
|
||||
queryKey: ['workspace-permission-keys'],
|
||||
})
|
||||
await waitFor(() => expect(permissionRequestCount).toBe(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: /refresh permissions after denial/i }))
|
||||
await waitFor(() => expect(permissionRequestCount).toBe(2))
|
||||
expect(await screen.findByText('dataset keys:dataset.acl.readonly')).toBeInTheDocument()
|
||||
await backgroundRefresh
|
||||
})
|
||||
})
|
||||
|
||||
describe('External side effects', () => {
|
||||
|
||||
@@ -19,6 +19,26 @@ export const workspacePermissionKeysAtom = atom((get) => {
|
||||
)
|
||||
})
|
||||
|
||||
export const datasetDefaultPermissionKeysAtom = atom((get) => {
|
||||
return get(workspacePermissionKeysQueryAtom).data?.dataset?.default_permission_keys ?? []
|
||||
})
|
||||
|
||||
export const workspacePermissionKeysLoadingAtom = atom((get) => {
|
||||
return get(workspacePermissionKeysQueryAtom).isPending
|
||||
})
|
||||
|
||||
export const workspacePermissionKeysFetchingAtom = atom((get) => {
|
||||
return get(workspacePermissionKeysQueryAtom).isFetching
|
||||
})
|
||||
|
||||
export const workspacePermissionKeysErrorAtom = atom((get) => {
|
||||
return get(workspacePermissionKeysQueryAtom).error
|
||||
})
|
||||
|
||||
export const retryWorkspacePermissionKeysAtom = atom(null, (get) => {
|
||||
return get(workspacePermissionKeysQueryAtom).refetch({ cancelRefetch: false })
|
||||
})
|
||||
|
||||
export const refreshWorkspacePermissionKeysAfterMutationDenialAtom = atom(null, (get) => {
|
||||
return get(workspacePermissionKeysQueryAtom).refetch({ cancelRefetch: true })
|
||||
})
|
||||
|
||||
@@ -14,7 +14,6 @@ set -e
|
||||
|
||||
export NEXT_PUBLIC_DEPLOY_ENV=${DEPLOY_ENV}
|
||||
export NEXT_PUBLIC_EDITION=${EDITION}
|
||||
export NEXT_PUBLIC_ENTERPRISE_ENABLED=${NEXT_PUBLIC_ENTERPRISE_ENABLED:-${ENTERPRISE_ENABLED}}
|
||||
export NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
|
||||
export NEXT_PUBLIC_API_PREFIX=${CONSOLE_API_URL}/console/api
|
||||
export NEXT_PUBLIC_PUBLIC_API_PREFIX=${APP_API_URL}/api
|
||||
|
||||
@@ -82,10 +82,6 @@ const clientSchema = {
|
||||
* "Go to Anything" command palette (Cmd/Ctrl+K).
|
||||
*/
|
||||
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW: coercedBoolean.default(true),
|
||||
/**
|
||||
* Whether a self-hosted deployment runs Enterprise Edition.
|
||||
*/
|
||||
NEXT_PUBLIC_ENTERPRISE_ENABLED: coercedBoolean.default(false),
|
||||
|
||||
/**
|
||||
* Cloud-only system-features defaults.
|
||||
@@ -258,9 +254,6 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW: isServer
|
||||
? process.env.NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW
|
||||
: getRuntimeEnvFromBody('enableFeaturePreview'),
|
||||
NEXT_PUBLIC_ENTERPRISE_ENABLED: isServer
|
||||
? process.env.NEXT_PUBLIC_ENTERPRISE_ENABLED
|
||||
: getRuntimeEnvFromBody('enterpriseEnabled'),
|
||||
|
||||
/**
|
||||
* Cloud-only system-features defaults.
|
||||
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { CommunityEditionTip } from '../community-edition-tip'
|
||||
|
||||
const edition = vi.hoisted(() => ({ isCommunity: true }))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/config')>()),
|
||||
get IS_COMMUNITY_EDITION() {
|
||||
return edition.isCommunity
|
||||
},
|
||||
}))
|
||||
|
||||
const tip = 'sandbox runs as a non-root user'
|
||||
|
||||
describe('CommunityEditionTip', () => {
|
||||
it('shows the warning on community edition (self-hosted, non-enterprise)', () => {
|
||||
edition.isCommunity = true
|
||||
|
||||
render(<CommunityEditionTip tip={tip} />)
|
||||
|
||||
expect(screen.getByLabelText(tip)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing on an enterprise or cloud deployment', () => {
|
||||
// Sandbox isolation is a property of the community build, so the tip is
|
||||
// gated on edition alone — not on license or billing state.
|
||||
edition.isCommunity = false
|
||||
|
||||
render(<CommunityEditionTip tip={tip} />)
|
||||
|
||||
expect(screen.queryByLabelText(tip)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { Placement } from '@langgenius/dify-ui/popover'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { IS_COMMUNITY_EDITION } from '@/config'
|
||||
|
||||
type CommunityEditionTipProps = {
|
||||
tip: string
|
||||
placement?: Placement
|
||||
popupClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning affordance for caveats that only apply to community edition.
|
||||
* Renders nothing on enterprise or cloud deployments, so callers do not repeat
|
||||
* the edition check.
|
||||
*/
|
||||
export function CommunityEditionTip({
|
||||
tip,
|
||||
placement = 'bottom',
|
||||
popupClassName,
|
||||
}: CommunityEditionTipProps) {
|
||||
if (!IS_COMMUNITY_EDITION) return null
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={300}
|
||||
closeDelay={200}
|
||||
aria-label={tip}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-line-alertsAndFeedback-alert-triangle size-4 text-text-warning-secondary"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
popupClassName={cn('px-3 py-2 system-xs-regular text-text-tertiary', popupClassName)}
|
||||
>
|
||||
{tip}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CommunityEditionTip } from '../community-edition-tip'
|
||||
|
||||
type AgentOrchestrateHeaderProps = {
|
||||
headingId: string
|
||||
@@ -27,7 +27,31 @@ export function AgentOrchestrateHeader({
|
||||
<h2 id={headingId} className="truncate title-xl-semi-bold text-text-primary">
|
||||
{t(($) => $['agentDetail.configure.title'])}
|
||||
</h2>
|
||||
<CommunityEditionTip tip={communityEditionIsolationTip} popupClassName="max-w-[320px]" />
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={300}
|
||||
closeDelay={200}
|
||||
aria-label={communityEditionIsolationTip}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-line-alertsAndFeedback-alert-triangle size-4 text-text-warning-secondary"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom"
|
||||
popupClassName="max-w-[320px] px-3 py-2 system-xs-regular text-text-tertiary"
|
||||
>
|
||||
{communityEditionIsolationTip}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{isBuildDraftActive && (
|
||||
<span className="flex min-w-[18px] shrink-0 items-center justify-center rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-1.25 py-0.75 system-2xs-medium-uppercase text-text-accent-secondary">
|
||||
{t(($) => $['agentDetail.configure.buildDraft.modeBadge'])}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user