Compare commits

..
Author SHA1 Message Date
FFXN 4743360620 Merge branch 'main' into feat/ce-telemetry-dev 2026-07-23 15:41:19 +08:00
FFXN 33a61e32ed Merge remote-tracking branch 'origin/main' 2026-07-23 15:33:31 +08:00
FFXN 2a7968d210 feat(telemetry): add community telemetry configuration and reporting 2026-07-23 15:32:49 +08:00
Stephen ZhouGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2a0661a769 feat(dataset): add New Knowledge creation flow (#39315)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-23 07:25:14 +00:00
autofix-ci[bot]andGitHub 2f7480c900 [autofix.ci] apply automated fixes 2026-07-23 06:55:26 +00:00
euxGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
00fe96325d fix(web): support TTS playback in Safari and Firefox (#39444)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-23 06:36:52 +00:00
FFXN 037b7f6b5f feat(telemetry): add community telemetry configuration and reporting 2026-07-23 14:18:09 +08:00
非法操作andGitHub a3c18c561e chore: improve lambda plugin runtime error display (#39437) 2026-07-23 06:05:14 +00:00
FFXN 3e6c3ace7b Merge remote-tracking branch 'origin/main' 2026-07-23 11:56:26 +08:00
FFXN f26812310f feat(telemetry): add community telemetry configuration and reporting 2026-07-23 11:55:42 +08:00
FFXN 0a9f1d58bd feat(telemetry): add community telemetry configuration and reporting 2026-07-21 15:54:47 +08:00
FFXN a1a2abb533 feat(telemetry): add community telemetry configuration and reporting 2026-07-21 15:45:26 +08:00
FFXN 3f3422c37f feat(telemetry): add community telemetry configuration and reporting 2026-07-20 14:55:10 +08:00
FFXN ccbb556a8b Merge remote-tracking branch 'origin/main' 2026-07-20 14:43:08 +08:00
FFXN ad2094c96f Merge remote-tracking branch 'origin/main' 2026-07-13 18:07:43 +08:00
FFXN 0f77b623d8 Merge remote-tracking branch 'origin/main' 2026-07-07 15:15:08 +08:00
FFXN 87e7e1ca2d chore: improve error message for missing cherry-pick provenance. 2026-07-07 12:13:18 +08:00
FFXN 796328e9f2 Merge remote-tracking branch 'origin/main' 2026-07-07 11:54:54 +08:00
FFXN 526059faeb Merge remote-tracking branch 'origin/main' 2026-07-03 16:20:52 +08:00
FFXN 272bdeb555 feat: enhance SQL query safety and add metadata key validation 2026-07-02 15:37:14 +08:00
175 changed files with 4448 additions and 1967 deletions
-1
View File
@@ -729,7 +729,6 @@ OTEL_MAX_EXPORT_BATCH_SIZE=512
OTEL_METRIC_EXPORT_INTERVAL=60000
OTEL_BATCH_EXPORT_TIMEOUT=10000
OTEL_METRIC_EXPORT_TIMEOUT=30000
# Prevent Clickjacking
ALLOW_EMBED=false
+36
View File
@@ -816,6 +816,41 @@ class UpdateConfig(BaseSettings):
)
class CommunityTelemetryConfig(BaseSettings):
"""
Configuration for anonymous self-hosted community telemetry.
"""
DISABLE_TELEMETRY: bool = Field(
description="Disable anonymous community telemetry",
default=False,
)
DO_NOT_TRACK: bool = Field(
description="Respect the standard do-not-track opt-out signal for telemetry",
default=False,
)
TELEMETRY_ENDPOINT: str = Field(
description="Endpoint for anonymous community telemetry events",
default="https://otel.dify.ai/v1/events",
)
TELEMETRY_FALLBACK_ENDPOINT: str = Field(
description="Fallback endpoint for anonymous community telemetry events",
default="https://otel.dify.cn/v1/events",
)
TELEMETRY_TIMEOUT_SECONDS: PositiveInt = Field(
description="HTTP timeout in seconds for anonymous community telemetry requests",
default=3,
)
TELEMETRY_HEARTBEAT_INTERVAL_MINUTES: PositiveInt = Field(
description="Celery beat interval in minutes for checking whether heartbeat telemetry is due",
default=30,
)
CI: bool = Field(
description="Whether the process is running in CI; telemetry is skipped when true",
default=False,
)
class WorkflowVariableTruncationConfig(BaseSettings):
WORKFLOW_VARIABLE_TRUNCATION_MAX_SIZE: PositiveInt = Field(
# 1000 KiB
@@ -1594,6 +1629,7 @@ class FeatureConfig(
TenantIsolatedTaskQueueConfig,
ToolConfig,
UpdateConfig,
CommunityTelemetryConfig,
WorkflowConfig,
WorkflowNodeExecutionConfig,
WorkspaceConfig,
+2 -29
View File
@@ -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
View File
@@ -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
+7 -20
View File
@@ -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
-20
View File
@@ -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
-4
View File
@@ -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,
+12 -25
View File
@@ -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):
+13 -21
View File
@@ -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:
+1 -3
View File
@@ -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
+1 -48
View File
@@ -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:
-2
View File
@@ -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
+14 -1
View File
@@ -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__:
+12
View File
@@ -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"
-1
View File
@@ -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"
-58
View File
@@ -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,
)
+31 -17
View File
@@ -1,4 +1,3 @@
import logging
import ssl
from datetime import timedelta
from typing import Any
@@ -6,16 +5,14 @@ from typing import Any
import pytz # type: ignore[import-untyped]
from celery import Celery, Task
from celery.schedules import crontab
from celery.signals import beat_init
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
@@ -40,6 +37,19 @@ class CeleryBeatScheduleEntry(TypedDict):
schedule: crontab | timedelta
def _enqueue_initial_community_telemetry_heartbeat(sender: Any, **_: Any) -> None:
task_name = "community_telemetry.send_heartbeat"
if "community_telemetry_heartbeat" not in sender.app.conf.beat_schedule:
return
task = sender.app.tasks.get(task_name)
if task is not None:
task.apply_async()
beat_init.connect(_enqueue_initial_community_telemetry_heartbeat, weak=False)
def get_celery_ssl_options() -> CelerySSLOptionsDict | None:
"""Get SSL configuration for Celery broker/backend connections."""
# Only apply SSL if we're using Redis as broker/backend
@@ -112,12 +122,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 +142,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 +155,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,
@@ -273,6 +274,19 @@ def init_app(app: DifyApp) -> Celery:
"schedule": timedelta(minutes=dify_config.API_TOKEN_LAST_USED_UPDATE_INTERVAL),
}
if (
dify_config.EDITION == "SELF_HOSTED"
and not dify_config.ENTERPRISE_ENABLED
and not dify_config.DISABLE_TELEMETRY
and not dify_config.DO_NOT_TRACK
and not dify_config.CI
):
imports.append("tasks.community_telemetry_task")
beat_schedule["community_telemetry_heartbeat"] = {
"task": "community_telemetry.send_heartbeat",
"schedule": timedelta(minutes=dify_config.TELEMETRY_HEARTBEAT_INTERVAL_MINUTES),
}
if dify_config.ENTERPRISE_ENABLED and dify_config.ENTERPRISE_TELEMETRY_ENABLED:
imports.append("tasks.enterprise_telemetry_task")
celery_app.conf.update(beat_schedule=beat_schedule, imports=imports)
+1 -14
View File
@@ -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
)
+17
View File
@@ -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)
@@ -0,0 +1,30 @@
"""add telemetry fields to dify_setups
Revision ID: 6f5a9c2d8e1b
Revises: d2825e7b9c10
Create Date: 2026-07-23 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "6f5a9c2d8e1b"
down_revision = "d2825e7b9c10"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("dify_setups", schema=None) as batch_op:
batch_op.add_column(sa.Column("instance_id", sa.String(length=255), nullable=True))
batch_op.add_column(sa.Column("install_reported_at", sa.DateTime(), nullable=True))
batch_op.add_column(sa.Column("last_heartbeat_at", sa.DateTime(), nullable=True))
def downgrade():
with op.batch_alter_table("dify_setups", schema=None) as batch_op:
batch_op.drop_column("last_heartbeat_at")
batch_op.drop_column("install_reported_at")
batch_op.drop_column("instance_id")
+16 -31
View File
@@ -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
@@ -362,6 +361,9 @@ class DifySetup(TypeBase):
__table_args__ = (sa.PrimaryKeyConstraint("version", name="dify_setup_pkey"),)
version: Mapped[str] = mapped_column(String(255), nullable=False)
instance_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
install_reported_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True, default=None)
last_heartbeat_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True, default=None)
setup_at: Mapped[datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)
@@ -502,42 +504,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:
-1
View File
@@ -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
+7 -1
View File
@@ -75,6 +75,7 @@ from services.errors.account import (
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.telemetry_service import CommunityTelemetryService
from tasks.delete_account_task import delete_account_task
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
from tasks.mail_change_mail_task import (
@@ -1961,7 +1962,7 @@ class RegisterService:
TenantService.create_owner_tenant_if_not_exist(account=account, is_setup=True, session=session)
dify_setup = DifySetup(version=dify_config.project.version)
dify_setup = DifySetup(version=dify_config.project.version, instance_id=str(uuid.uuid4()))
session.add(dify_setup)
session.commit()
except Exception as e:
@@ -1974,6 +1975,11 @@ class RegisterService:
logger.exception("Setup account failed, email: %s, name: %s", email, name)
raise ValueError(f"Setup failed: {e}")
try:
CommunityTelemetryService.report_install(session=session)
except Exception:
logger.debug("Failed to report install telemetry", exc_info=True)
@classmethod
def register(
cls,
+7 -33
View File
@@ -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
-26
View File
@@ -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")
-3
View File
@@ -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] = [
+165
View File
@@ -0,0 +1,165 @@
import logging
import platform
import uuid
from datetime import datetime
from typing import Literal
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from libs.datetime_utils import naive_utc_now
from models.model import DifySetup
logger = logging.getLogger(__name__)
TelemetryEvent = Literal["install", "heartbeat"]
SCHEMA_VERSION = 1
class CommunityTelemetryService:
@classmethod
def report_install(cls, *, session: Session) -> bool:
setup = cls._get_setup(session)
if setup is None:
return False
if setup.instance_id is None:
setup.instance_id = str(uuid.uuid4())
session.add(setup)
session.commit()
payload = cls._build_payload(setup, "install")
if not cls._send_event(payload):
return False
setup.install_reported_at = naive_utc_now()
session.add(setup)
session.commit()
return True
@classmethod
def report_heartbeat(cls, *, session: Session, now: datetime | None = None) -> bool:
setup = cls._get_setup(session)
if setup is None:
return False
if setup.instance_id is None:
setup.instance_id = str(uuid.uuid4())
session.add(setup)
session.commit()
now = now or naive_utc_now()
if not cls._is_heartbeat_due(setup, now):
return False
if setup.install_reported_at is None:
cls.report_install(session=session)
payload = cls._build_payload(setup, "heartbeat")
if not cls._send_event(payload):
return False
setup.last_heartbeat_at = now
session.add(setup)
session.commit()
return True
@classmethod
def _get_setup(cls, session: Session) -> DifySetup | None:
return session.scalar(select(DifySetup).order_by(DifySetup.setup_at.asc()).limit(1))
@classmethod
def _is_enabled(cls) -> bool:
return (
dify_config.EDITION == "SELF_HOSTED"
and not dify_config.ENTERPRISE_ENABLED
and not dify_config.DISABLE_TELEMETRY
and not dify_config.DO_NOT_TRACK
and not dify_config.CI
and bool(dify_config.TELEMETRY_ENDPOINT)
)
@classmethod
def _build_payload(cls, setup: DifySetup, event: TelemetryEvent) -> dict[str, str | int]:
payload: dict[str, str | int] = {
"event": event,
"instance_id": setup.instance_id or "",
"version": setup.version if event == "install" else dify_config.project.version,
"edition": dify_config.EDITION,
"deployment_type": "unknown",
"schema_version": SCHEMA_VERSION,
"os": cls._normalize_os(platform.system()),
"arch": cls._normalize_arch(platform.machine()),
"sent_at": cls._format_datetime(naive_utc_now()),
}
if event == "install":
payload["installed_at"] = cls._format_datetime(setup.setup_at)
return payload
@classmethod
def _send_event(cls, payload: dict[str, str | int]) -> bool:
if not cls._is_enabled():
return False
endpoints = [dify_config.TELEMETRY_ENDPOINT]
if dify_config.TELEMETRY_FALLBACK_ENDPOINT not in endpoints:
endpoints.append(dify_config.TELEMETRY_FALLBACK_ENDPOINT)
for endpoint in endpoints:
if not endpoint:
continue
try:
response = httpx.post(
endpoint,
json=payload,
timeout=dify_config.TELEMETRY_TIMEOUT_SECONDS,
)
response.raise_for_status()
return True
except httpx.RequestError:
logger.debug("Failed to send community telemetry event to %s", endpoint, exc_info=True)
except httpx.HTTPStatusError:
logger.debug("Community telemetry endpoint returned an error: %s", endpoint, exc_info=True)
return False
return False
@classmethod
def _is_heartbeat_due(cls, setup: DifySetup, now: datetime) -> bool:
if setup.instance_id is None:
return False
if setup.last_heartbeat_at is not None and setup.last_heartbeat_at.date() >= now.date():
return False
return True
@staticmethod
def _format_datetime(value: datetime) -> str:
return value.replace(microsecond=0).isoformat() + "Z"
@staticmethod
def _normalize_os(value: str) -> str:
os_name = value.lower()
if os_name in {"linux", "darwin", "windows"}:
return os_name
return "unknown"
@staticmethod
def _normalize_arch(value: str) -> str:
arch = value.lower()
if arch in {"x86_64", "amd64"}:
return "amd64"
if arch in {"aarch64", "arm64"}:
return "arm64"
if arch.startswith("arm"):
return "arm"
if arch in {"i386", "i686", "x86"}:
return "386"
return "unknown"
+19
View File
@@ -0,0 +1,19 @@
import logging
from celery import shared_task
from sqlalchemy.orm import sessionmaker
from extensions.ext_database import db
from services.telemetry_service import CommunityTelemetryService
logger = logging.getLogger(__name__)
@shared_task(name="community_telemetry.send_heartbeat", queue="schedule_executor")
def send_community_telemetry_heartbeat() -> None:
session_factory = sessionmaker(bind=db.engine, expire_on_commit=False)
with session_factory() as session:
try:
CommunityTelemetryService.report_heartbeat(session=session)
except Exception:
logger.debug("Failed to process community telemetry heartbeat", exc_info=True)
@@ -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
@@ -0,0 +1,43 @@
from types import SimpleNamespace
from unittest.mock import Mock
from extensions.ext_celery import _enqueue_initial_community_telemetry_heartbeat
def test_beat_start_enqueues_community_telemetry_heartbeat() -> None:
task = Mock()
sender = SimpleNamespace(
app=SimpleNamespace(
conf=SimpleNamespace(beat_schedule={"community_telemetry_heartbeat": {}}),
tasks={"community_telemetry.send_heartbeat": task},
)
)
_enqueue_initial_community_telemetry_heartbeat(sender)
task.apply_async.assert_called_once_with()
def test_beat_start_skips_community_telemetry_when_not_scheduled() -> None:
task = Mock()
sender = SimpleNamespace(
app=SimpleNamespace(
conf=SimpleNamespace(beat_schedule={}),
tasks={"community_telemetry.send_heartbeat": task},
)
)
_enqueue_initial_community_telemetry_heartbeat(sender)
task.apply_async.assert_not_called()
def test_beat_start_skips_community_telemetry_when_task_is_unavailable() -> None:
sender = SimpleNamespace(
app=SimpleNamespace(
conf=SimpleNamespace(beat_schedule={"community_telemetry_heartbeat": {}}),
tasks={},
)
)
_enqueue_initial_community_telemetry_heartbeat(sender)
@@ -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
@@ -2,6 +2,7 @@ import json
from collections.abc import Iterator
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
from uuid import UUID
import pytest
from sqlalchemy import event, select
@@ -1329,7 +1330,10 @@ class TestRegisterService:
with patch("services.account_service.AccountService.create_account") as mock_create_account:
mock_create_account.return_value = mock_account
with patch("services.account_service.TenantService.create_owner_tenant_if_not_exist") as mock_create_tenant:
with (
patch("services.account_service.TenantService.create_owner_tenant_if_not_exist") as mock_create_tenant,
patch("services.account_service.CommunityTelemetryService.report_install") as mock_report_install,
):
RegisterService.setup(
"admin@example.com",
"Admin User",
@@ -1348,7 +1352,39 @@ class TestRegisterService:
session=sqlite_session,
)
mock_create_tenant.assert_called_once_with(account=mock_account, is_setup=True, session=sqlite_session)
assert sqlite_session.scalar(select(DifySetup)) is not None
dify_setup = sqlite_session.scalar(select(DifySetup))
assert dify_setup is not None
assert dify_setup.instance_id is not None
assert str(UUID(dify_setup.instance_id)) == dify_setup.instance_id
assert dify_setup.install_reported_at is None
assert dify_setup.last_heartbeat_at is None
mock_report_install.assert_called_once_with(session=sqlite_session)
def test_setup_succeeds_when_telemetry_install_report_fails(
self, sqlite_session: Session, mock_external_service_dependencies
):
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
with (
patch("services.account_service.AccountService.create_account", return_value=mock_account),
patch("services.account_service.TenantService.create_owner_tenant_if_not_exist"),
patch(
"services.account_service.CommunityTelemetryService.report_install",
side_effect=RuntimeError("telemetry unavailable"),
),
):
RegisterService.setup(
"admin@example.com",
"Admin User",
"password123",
"192.168.1.1",
"en-US",
session=sqlite_session,
)
assert sqlite_session.scalar(select(DifySetup)) is not None
def test_setup_failure_rollback(self, sqlite_session: Session, mock_external_service_dependencies):
"""Test setup failure with proper rollback."""
@@ -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",
)
@@ -0,0 +1,333 @@
import uuid
from datetime import datetime
from unittest.mock import Mock
import httpx
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session
from models.model import DifySetup
from services import telemetry_service
from services.telemetry_service import CommunityTelemetryService
@pytest.fixture
def telemetry_enabled(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(telemetry_service.dify_config, "EDITION", "SELF_HOSTED")
monkeypatch.setattr(telemetry_service.dify_config, "ENTERPRISE_ENABLED", False)
monkeypatch.setattr(telemetry_service.dify_config, "DISABLE_TELEMETRY", False)
monkeypatch.setattr(telemetry_service.dify_config, "DO_NOT_TRACK", False)
monkeypatch.setattr(telemetry_service.dify_config, "CI", False)
monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_ENDPOINT", "https://telemetry.example.test/v1/events")
monkeypatch.setattr(
telemetry_service.dify_config,
"TELEMETRY_FALLBACK_ENDPOINT",
"https://telemetry-cn.example.test/v1/events",
)
monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_TIMEOUT_SECONDS", 2)
def test_telemetry_is_disabled_for_enterprise(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(telemetry_service.dify_config, "EDITION", "SELF_HOSTED")
monkeypatch.setattr(telemetry_service.dify_config, "ENTERPRISE_ENABLED", True)
assert CommunityTelemetryService._is_enabled() is False
@pytest.mark.parametrize(
("setting", "value"),
[
("EDITION", "CLOUD"),
("DISABLE_TELEMETRY", True),
("DO_NOT_TRACK", True),
("CI", True),
("TELEMETRY_ENDPOINT", ""),
],
)
def test_telemetry_is_disabled_when_a_required_condition_is_not_met(
telemetry_enabled, monkeypatch: pytest.MonkeyPatch, setting: str, value: str | bool
):
monkeypatch.setattr(telemetry_service.dify_config, setting, value)
assert CommunityTelemetryService._is_enabled() is False
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
def test_reporting_without_setup_is_skipped(sqlite_session: Session, telemetry_enabled):
assert CommunityTelemetryService.report_install(session=sqlite_session) is False
assert CommunityTelemetryService.report_heartbeat(session=sqlite_session) is False
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
def test_report_install_marks_reported_at(sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch):
setup = DifySetup(version="installed-version", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
sqlite_session.add(setup)
sqlite_session.commit()
monkeypatch.setattr(telemetry_service.dify_config.project, "version", "running-version")
sent_payloads: list[dict[str, str | int]] = []
def fake_post(url: str, json: dict[str, str | int], timeout: int):
sent_payloads.append(json)
return httpx.Response(204, request=httpx.Request("POST", url))
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
assert CommunityTelemetryService.report_install(session=sqlite_session) is True
saved_setup = sqlite_session.scalar(select(DifySetup))
assert saved_setup is not None
assert saved_setup.install_reported_at is not None
assert sent_payloads[0]["event"] == "install"
assert sent_payloads[0]["instance_id"] == setup.instance_id
assert sent_payloads[0]["version"] == "installed-version"
assert "installed_at" in sent_payloads[0]
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
def test_report_install_generates_missing_instance_id(
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
):
setup = DifySetup(version="installed-version")
sqlite_session.add(setup)
sqlite_session.commit()
monkeypatch.setattr(
telemetry_service.httpx,
"post",
lambda url, json, timeout: httpx.Response(204, request=httpx.Request("POST", url)),
)
assert CommunityTelemetryService.report_install(session=sqlite_session) is True
assert setup.instance_id is not None
assert str(uuid.UUID(setup.instance_id)) == setup.instance_id
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
def test_report_heartbeat_generates_missing_instance_id(
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
):
setup = DifySetup(version="1.0.0", install_reported_at=datetime(2026, 7, 12, 8, 0, 0))
sqlite_session.add(setup)
sqlite_session.commit()
monkeypatch.setattr(
telemetry_service.httpx,
"post",
lambda url, json, timeout: httpx.Response(204, request=httpx.Request("POST", url)),
)
assert CommunityTelemetryService.report_heartbeat(
session=sqlite_session, now=datetime(2026, 7, 13, 12, 0, 0)
) is True
assert setup.instance_id is not None
assert str(uuid.UUID(setup.instance_id)) == setup.instance_id
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
def test_report_install_failure_keeps_install_pending(
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
):
setup = DifySetup(version="1.0.0", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
sqlite_session.add(setup)
sqlite_session.commit()
def fake_post(url: str, json: dict[str, str | int], timeout: int):
raise httpx.ConnectError("offline", request=httpx.Request("POST", url))
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
assert CommunityTelemetryService.report_install(session=sqlite_session) is False
saved_setup = sqlite_session.scalar(select(DifySetup))
assert saved_setup is not None
assert saved_setup.install_reported_at is None
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
def test_report_install_uses_fallback_endpoint_after_network_failure(
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
):
setup = DifySetup(version="1.0.0", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
sqlite_session.add(setup)
sqlite_session.commit()
urls: list[str] = []
def fake_post(url: str, json: dict[str, str | int], timeout: int):
urls.append(url)
if url == telemetry_service.dify_config.TELEMETRY_ENDPOINT:
raise httpx.ConnectError("offline", request=httpx.Request("POST", url))
return httpx.Response(204, request=httpx.Request("POST", url))
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
assert CommunityTelemetryService.report_install(session=sqlite_session) is True
assert urls == [
telemetry_service.dify_config.TELEMETRY_ENDPOINT,
telemetry_service.dify_config.TELEMETRY_FALLBACK_ENDPOINT,
]
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
def test_report_install_does_not_use_fallback_endpoint_after_http_error(
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
):
setup = DifySetup(version="1.0.0", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
sqlite_session.add(setup)
sqlite_session.commit()
post_mock = Mock(
return_value=httpx.Response(
500,
request=httpx.Request("POST", telemetry_service.dify_config.TELEMETRY_ENDPOINT),
)
)
monkeypatch.setattr(telemetry_service.httpx, "post", post_mock)
assert CommunityTelemetryService.report_install(session=sqlite_session) is False
post_mock.assert_called_once()
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
def test_report_heartbeat_retries_pending_install_before_heartbeat(
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
):
setup = DifySetup(version="installed-version", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
sqlite_session.add(setup)
sqlite_session.commit()
monkeypatch.setattr(telemetry_service.dify_config.project, "version", "running-version")
sent_payloads: list[dict[str, str | int]] = []
def fake_post(url: str, json: dict[str, str | int], timeout: int):
sent_payloads.append(json)
return httpx.Response(204, request=httpx.Request("POST", url))
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
now = datetime(2026, 7, 13, 0, 0, 0)
assert CommunityTelemetryService.report_heartbeat(session=sqlite_session, now=now) is True
saved_setup = sqlite_session.scalar(select(DifySetup))
assert saved_setup is not None
assert saved_setup.install_reported_at is not None
assert saved_setup.last_heartbeat_at == now
assert [(payload["event"], payload["version"]) for payload in sent_payloads] == [
("install", "installed-version"),
("heartbeat", "running-version"),
]
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
def test_report_heartbeat_skips_when_already_sent_today(
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
):
setup = DifySetup(
version="1.0.0",
instance_id="d246c3a1-350b-406c-92c7-6043df680758",
install_reported_at=datetime(2026, 7, 13, 8, 0, 0),
last_heartbeat_at=datetime(2026, 7, 13, 9, 0, 0),
)
sqlite_session.add(setup)
sqlite_session.commit()
post_mock = Mock()
monkeypatch.setattr(telemetry_service.httpx, "post", post_mock)
assert (
CommunityTelemetryService.report_heartbeat(session=sqlite_session, now=datetime(2026, 7, 13, 12, 0, 0)) is False
)
post_mock.assert_not_called()
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
def test_report_heartbeat_failure_does_not_mark_the_day_reported(
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
):
setup = DifySetup(
version="1.0.0",
instance_id="d246c3a1-350b-406c-92c7-6043df680758",
install_reported_at=datetime(2026, 7, 13, 8, 0, 0),
)
sqlite_session.add(setup)
sqlite_session.commit()
def fake_post(url: str, json: dict[str, str | int], timeout: int):
raise httpx.ConnectError("offline", request=httpx.Request("POST", url))
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
assert CommunityTelemetryService.report_heartbeat(
session=sqlite_session, now=datetime(2026, 7, 13, 12, 0, 0)
) is False
assert setup.last_heartbeat_at is None
def test_send_event_skips_when_telemetry_is_disabled(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(telemetry_service.dify_config, "DISABLE_TELEMETRY", True)
post_mock = Mock()
monkeypatch.setattr(telemetry_service.httpx, "post", post_mock)
assert CommunityTelemetryService._send_event({"event": "heartbeat"}) is False
post_mock.assert_not_called()
def test_send_event_skips_an_empty_fallback_endpoint(telemetry_enabled, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_FALLBACK_ENDPOINT", "")
def fake_post(url: str, json: dict[str, str], timeout: int):
raise httpx.ConnectError("offline", request=httpx.Request("POST", url))
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
assert CommunityTelemetryService._send_event({"event": "heartbeat"}) is False
def test_send_event_does_not_retry_the_same_endpoint(telemetry_enabled, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
telemetry_service.dify_config,
"TELEMETRY_FALLBACK_ENDPOINT",
telemetry_service.dify_config.TELEMETRY_ENDPOINT,
)
post_mock = Mock(
return_value=httpx.Response(
204,
request=httpx.Request("POST", telemetry_service.dify_config.TELEMETRY_ENDPOINT),
)
)
monkeypatch.setattr(telemetry_service.httpx, "post", post_mock)
assert CommunityTelemetryService._send_event({"event": "heartbeat"}) is True
post_mock.assert_called_once()
def test_heartbeat_is_not_due_without_instance_id():
setup = DifySetup(version="1.0.0")
assert CommunityTelemetryService._is_heartbeat_due(setup, datetime(2026, 7, 13, 12, 0, 0)) is False
@pytest.mark.parametrize(
("value", "expected"),
[
("Linux", "linux"),
("Plan9", "unknown"),
],
)
def test_normalize_os(value: str, expected: str):
assert CommunityTelemetryService._normalize_os(value) == expected
@pytest.mark.parametrize(
("value", "expected"),
[
("x86_64", "amd64"),
("aarch64", "arm64"),
("armv7l", "arm"),
("i686", "386"),
("riscv64", "unknown"),
],
)
def test_normalize_arch(value: str, expected: str):
assert CommunityTelemetryService._normalize_arch(value) == expected
@@ -0,0 +1,40 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock
import pytest
from tasks import community_telemetry_task
def _configure_task_session(monkeypatch: pytest.MonkeyPatch) -> Mock:
session = Mock()
session_factory = MagicMock()
session_factory.return_value.__enter__.return_value = session
monkeypatch.setattr(community_telemetry_task, "db", SimpleNamespace(engine=object()))
monkeypatch.setattr(community_telemetry_task, "sessionmaker", Mock(return_value=session_factory))
return session
def test_send_community_telemetry_heartbeat_reports_with_a_database_session(monkeypatch: pytest.MonkeyPatch):
session = _configure_task_session(monkeypatch)
report_heartbeat = Mock()
monkeypatch.setattr(community_telemetry_task.CommunityTelemetryService, "report_heartbeat", report_heartbeat)
community_telemetry_task.send_community_telemetry_heartbeat.run()
report_heartbeat.assert_called_once_with(session=session)
def test_send_community_telemetry_heartbeat_swallows_report_errors(monkeypatch: pytest.MonkeyPatch):
_configure_task_session(monkeypatch)
monkeypatch.setattr(
community_telemetry_task.CommunityTelemetryService,
"report_heartbeat",
Mock(side_effect=RuntimeError("telemetry unavailable")),
)
log_debug = Mock()
monkeypatch.setattr(community_telemetry_task.logger, "debug", log_debug)
community_telemetry_task.send_community_telemetry_heartbeat.run()
log_debug.assert_called_once_with("Failed to process community telemetry heartbeat", exc_info=True)
Generated
-18
View File
@@ -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"
-1
View File
@@ -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
-8
View File
@@ -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
-2
View File
@@ -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 -2
View File
@@ -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/')
)
@@ -0,0 +1,5 @@
import { KnowledgeRoutePlaceholder } from '@/features/new-rag/knowledge-route-placeholder'
export default function Page() {
return <KnowledgeRoutePlaceholder type="documents" />
}
@@ -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,5 @@
import { KnowledgeRoutePlaceholder } from '@/features/new-rag/knowledge-route-placeholder'
export default function Page() {
return <KnowledgeRoutePlaceholder type="sources" />
}
@@ -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 {}
}
+174 -86
View File
@@ -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 {
@@ -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([
{
-4
View File
@@ -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,
+4 -3
View File
@@ -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}
+26 -14
View File
@@ -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,
@@ -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({
@@ -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()
@@ -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',
)
})
})
@@ -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>
)
}
-59
View File
@@ -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)
})
})
-1
View File
@@ -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, '')
-1
View File
@@ -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
-7
View File
@@ -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.
@@ -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'])}
@@ -1,8 +1,8 @@
'use client'
import type { AgentChatRuntimeProps } from './chat-runtime'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useTranslation } from 'react-i18next'
import { CommunityEditionTip } from '../community-edition-tip'
import { AgentChatRuntime } from './chat-runtime'
const buildIconGridCellOpacities = [
@@ -53,11 +53,31 @@ function AgentBuildChatEmptyState() {
<div className="min-w-0 truncate system-md-medium text-text-secondary">
{t(($) => $['agentDetail.configure.build.empty.title'])}
</div>
<CommunityEditionTip
tip={communityEditionBuildModeTip}
placement="top"
popupClassName="max-w-[340px]"
/>
<Popover>
<PopoverTrigger
openOnHover
delay={300}
closeDelay={200}
aria-label={communityEditionBuildModeTip}
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="top"
popupClassName="max-w-[340px] px-3 py-2 system-xs-regular text-text-tertiary"
>
{communityEditionBuildModeTip}
</PopoverContent>
</Popover>
</div>
<p className="mt-1 max-w-full body-md-regular text-text-tertiary">
{t(($) => $['agentDetail.configure.build.empty.description'])}
-13
View File
@@ -1,13 +0,0 @@
'use client'
import { useAtomValue } from 'jotai'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { hasPermission } from '@/utils/permission'
const AGENT_MANAGE_PERMISSION_KEY = 'agent.manage'
export const useCanManageAgents = () => {
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
return hasPermission(workspacePermissionKeys, AGENT_MANAGE_PERMISSION_KEY)
}
+4 -2
View File
@@ -4,15 +4,17 @@ Renders the KnowledgeFS-backed knowledge list, creation entry points, and first-
## Internal Modules
- `components/create-knowledge-dialog-parts`
- `components/create-knowledge-exit-dialog`
- `components/knowledge-space-card`
- `components/knowledge-view-switcher`
- `components/new-knowledge-list-states`
- `create-knowledge-workflow`
- `storage`
## External Modules
- `app/components/apps/first-empty-state/action-card`
- `app/components/base/infotip`
- `app/components/base/corner-label`
- `app/components/base/search-input`
- `app/components/base/skeleton`
- `app/components/datasets/external-api/external-api-panel`
@@ -0,0 +1,536 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CreateKnowledgePage } from '../create-knowledge-page'
const serviceMock = vi.hoisted(() => ({
create: vi.fn(),
getPolicy: vi.fn(),
patchPolicy: vi.fn(),
listKey: vi.fn(() => ['console', 'knowledgeFs', 'listKnowledgeSpaces']),
}))
const routerMock = vi.hoisted(() => ({
back: vi.fn(),
replace: vi.fn(),
}))
const navigationMock = vi.hoisted(() => ({
startMode: null as string | null,
}))
const permissionStateMock = vi.hoisted(() => ({
atom: Symbol('workspacePermissionKeysAtom'),
keys: ['dataset.create_and_management', 'dataset.acl.access_config'],
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => routerMock,
useSearchParams: () => ({
get: (key: string) => (key === 'start' ? navigationMock.startMode : null),
}),
}))
vi.mock('@/context/permission-state', () => ({
workspacePermissionKeysAtom: permissionStateMock.atom,
}))
vi.mock('jotai', async (importOriginal) => {
const original = await importOriginal<typeof import('jotai')>()
return {
...original,
useAtomValue: (atom: unknown) =>
atom === permissionStateMock.atom
? permissionStateMock.keys
: original.useAtomValue(atom as Parameters<typeof original.useAtomValue>[0]),
}
})
vi.mock('@/service/client', () => ({
consoleClient: {
knowledgeFs: {
createKnowledgeSpace: serviceMock.create,
getKnowledgeSpacesByIdAccessPolicy: serviceMock.getPolicy,
patchKnowledgeSpacesByIdAccessPolicy: serviceMock.patchPolicy,
},
},
consoleQuery: {
knowledgeFs: {
listKnowledgeSpaces: {
key: serviceMock.listKey,
},
},
},
}))
const createdKnowledge = {
configurationStatus: 'ready',
createdAt: '2026-07-20T00:00:00Z',
id: 'e735c1dc-d2b8-4dc4-86dc-abaf2fb7d084',
name: 'Product handbook',
revision: 1,
slug: 'product-handbook',
tenantId: 'tenant-1',
updatedAt: '2026-07-20T00:00:00Z',
}
function renderPage(
queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }),
) {
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
return { queryClient, ...render(<CreateKnowledgePage />, { wrapper: Wrapper }) }
}
async function fillRequiredFields(user: ReturnType<typeof userEvent.setup>) {
await user.type(
screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' }),
' Product handbook ',
)
await user.type(
screen.getByRole('textbox', { name: /dataset\.newKnowledge\.description/ }),
' Internal answers ',
)
}
async function choosePermission(user: ReturnType<typeof userEvent.setup>, optionName: string) {
await user.click(screen.getByRole('combobox', { name: 'dataset.newKnowledge.permission' }))
await user.click(await screen.findByRole('option', { name: optionName }))
}
describe('CreateKnowledgePage', () => {
beforeEach(() => {
vi.clearAllMocks()
serviceMock.create.mockResolvedValue(createdKnowledge)
serviceMock.getPolicy.mockResolvedValue({
id: 'policy-1',
ownerSubjectId: 'user-1',
partialMemberSubjectIds: [],
revision: 4,
visibility: 'only_me',
})
serviceMock.patchPolicy.mockResolvedValue({
id: 'policy-1',
ownerSubjectId: 'user-1',
partialMemberSubjectIds: [],
revision: 5,
visibility: 'all_members',
})
permissionStateMock.keys = ['dataset.create_and_management', 'dataset.acl.access_config']
navigationMock.startMode = null
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue(
'a9c36c57-2d84-44d6-a36d-841f0d92a179',
)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('keeps create reachable and reports an empty knowledge name', async () => {
const user = userEvent.setup()
renderPage()
const createButton = screen.getByRole('button', {
name: 'dataset.newKnowledge.createTitle',
})
expect(createButton).toBeEnabled()
await user.click(createButton)
expect(await screen.findByText('dataset.newKnowledge.nameRequired')).toBeInTheDocument()
expect(serviceMock.create).not.toHaveBeenCalled()
})
it('creates a private empty knowledge space, invalidates the list, and navigates', async () => {
const user = userEvent.setup()
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
renderPage(queryClient)
await fillRequiredFields(user)
await choosePermission(user, 'dataset.newKnowledge.permissionOnlyMe')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => {
expect(serviceMock.create).toHaveBeenCalledWith({
body: {
description: 'Internal answers',
idempotencyKey: 'a9c36c57-2d84-44d6-a36d-841f0d92a179',
name: 'Product handbook',
},
})
})
expect(serviceMock.getPolicy).not.toHaveBeenCalled()
expect(invalidate).toHaveBeenCalledWith({
queryKey: ['console', 'knowledgeFs', 'listKnowledgeSpaces'],
})
expect(routerMock.replace).toHaveBeenCalledWith(
'/datasets/new/e735c1dc-d2b8-4dc4-86dc-abaf2fb7d084/sources',
)
})
it('defaults authorized users to the Figma all-members policy and updates its revision', async () => {
const user = userEvent.setup()
renderPage()
await fillRequiredFields(user)
expect(
screen.getByRole('combobox', { name: 'dataset.newKnowledge.permission' }),
).toHaveTextContent('dataset.newKnowledge.permissionAllMembers')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => {
expect(serviceMock.patchPolicy).toHaveBeenCalledWith({
body: {
expectedRevision: 4,
partialMemberSubjectIds: [],
visibility: 'all_members',
},
params: { id: createdKnowledge.id },
})
})
})
it('forces users without access-config permission to create a private space', async () => {
const user = userEvent.setup()
permissionStateMock.keys = ['dataset.create_and_management']
renderPage()
await fillRequiredFields(user)
const permission = screen.getByRole('combobox', {
name: 'dataset.newKnowledge.permission',
})
expect(permission).toBeDisabled()
expect(permission).toHaveTextContent('dataset.newKnowledge.permissionOnlyMe')
expect(permission).toHaveAccessibleDescription('dataset.newKnowledge.permissionRestricted')
expect(screen.getByText('dataset.newKnowledge.permissionRestricted')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledOnce())
expect(serviceMock.getPolicy).not.toHaveBeenCalled()
})
it('prevents duplicate pending submissions', async () => {
const user = userEvent.setup()
let resolveCreate: (value: typeof createdKnowledge) => void = () => undefined
serviceMock.create.mockImplementation(
() =>
new Promise((resolve) => {
resolveCreate = resolve
}),
)
renderPage()
await fillRequiredFields(user)
const createButton = screen.getByRole('button', {
name: 'dataset.newKnowledge.createTitle',
})
await user.dblClick(createButton)
expect(serviceMock.create).toHaveBeenCalledOnce()
expect(createButton).toHaveAttribute('aria-disabled', 'true')
resolveCreate(createdKnowledge)
})
it('keeps the same idempotency key for a safe retry after failure', async () => {
const user = userEvent.setup()
serviceMock.create.mockRejectedValueOnce(new Error('upstream unavailable'))
renderPage()
await fillRequiredFields(user)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
expect(await screen.findByRole('alert')).toHaveTextContent('dataset.newKnowledge.createFailed')
expect(screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledTimes(2))
expect(serviceMock.create.mock.calls[0]?.[0].body.idempotencyKey).toBe(
serviceMock.create.mock.calls[1]?.[0].body.idempotencyKey,
)
})
it.each([400, 401, 403, 422])(
'unlocks editable fields and rotates the idempotency key after a definitive %s rejection',
async (status) => {
const user = userEvent.setup()
vi.mocked(globalThis.crypto.randomUUID)
.mockReturnValueOnce('11111111-1111-4111-8111-111111111111')
.mockReturnValueOnce('22222222-2222-4222-8222-222222222222')
serviceMock.create.mockRejectedValueOnce({ status })
renderPage()
await fillRequiredFields(user)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.createFailed',
)
const nameInput = screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' })
expect(nameInput).toBeEnabled()
await user.clear(nameInput)
await user.type(nameInput, 'Updated handbook')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledTimes(2))
expect(serviceMock.create.mock.calls[0]?.[0].body.idempotencyKey).toBe(
'11111111-1111-4111-8111-111111111111',
)
expect(serviceMock.create.mock.calls[1]?.[0].body).toMatchObject({
idempotencyKey: '22222222-2222-4222-8222-222222222222',
name: 'Updated handbook',
})
},
)
it.each([409, 429, 503])(
'keeps request identity frozen after an ambiguous %s response',
async (status) => {
const user = userEvent.setup()
serviceMock.create.mockRejectedValueOnce({ status })
renderPage()
await fillRequiredFields(user)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.createFailed',
)
expect(screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' })).toBeDisabled()
expect(
screen.getByRole('combobox', { name: 'dataset.newKnowledge.permission' }),
).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledTimes(2))
expect(serviceMock.create.mock.calls[0]?.[0].body.idempotencyKey).toBe(
serviceMock.create.mock.calls[1]?.[0].body.idempotencyKey,
)
},
)
it('safely resumes the permission step after a partial failure', async () => {
const user = userEvent.setup()
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
serviceMock.patchPolicy.mockRejectedValueOnce(new Error('policy update unavailable'))
renderPage(queryClient)
await fillRequiredFields(user)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.permissionUpdateFailed',
)
expect(invalidate).toHaveBeenCalledWith({
queryKey: ['console', 'knowledgeFs', 'listKnowledgeSpaces'],
})
const nameInput = screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' })
expect(nameInput).toBeDisabled()
expect(screen.getByRole('combobox', { name: 'dataset.newKnowledge.permission' })).toBeDisabled()
await user.type(nameInput, ' changed')
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => expect(serviceMock.patchPolicy).toHaveBeenCalledTimes(2))
expect(serviceMock.create).toHaveBeenCalledOnce()
expect(routerMock.replace).toHaveBeenCalledWith(
'/datasets/new/e735c1dc-d2b8-4dc4-86dc-abaf2fb7d084/sources',
)
})
it('converges after a permission update succeeds but its response is lost', async () => {
const user = userEvent.setup()
serviceMock.getPolicy
.mockResolvedValueOnce({
id: 'policy-1',
ownerSubjectId: 'user-1',
partialMemberSubjectIds: [],
revision: 4,
visibility: 'only_me',
})
.mockResolvedValueOnce({
id: 'policy-1',
ownerSubjectId: 'user-1',
partialMemberSubjectIds: [],
revision: 5,
visibility: 'all_members',
})
serviceMock.patchPolicy.mockRejectedValueOnce(new Error('response lost'))
renderPage()
await fillRequiredFields(user)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.permissionUpdateFailed',
)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
await waitFor(() => expect(routerMock.replace).toHaveBeenCalledOnce())
expect(serviceMock.create).toHaveBeenCalledOnce()
expect(serviceMock.getPolicy).toHaveBeenCalledTimes(2)
expect(serviceMock.patchPolicy).toHaveBeenCalledOnce()
})
it('keeps source and upload disabled until their stacked flows land', () => {
navigationMock.startMode = 'source'
renderPage()
const startEmpty = screen.getByRole('radio', { name: 'dataset.newKnowledge.startEmpty' })
expect(startEmpty).toBeChecked()
expect(startEmpty).toHaveAccessibleDescription('dataset.newKnowledge.startEmptyDescription')
const connectSource = screen.getByRole('radio', {
name: 'dataset.newKnowledge.connectSource',
})
const uploadFiles = screen.getByRole('radio', { name: 'dataset.newKnowledge.uploadFiles' })
expect(connectSource).toBeDisabled()
expect(connectSource).toHaveAccessibleDescription(
'dataset.newKnowledge.connectSourceDescription dataset.cornerLabel.unavailable',
)
expect(uploadFiles).toBeDisabled()
expect(uploadFiles).toHaveAccessibleDescription(
'dataset.newKnowledge.uploadFilesDescription dataset.cornerLabel.unavailable',
)
})
it('renders the approved creation modal and exposes both dismiss actions', async () => {
const user = userEvent.setup()
renderPage()
const dialog = screen.getByRole('dialog', {
name: 'dataset.newKnowledge.createTitle',
})
expect(
within(dialog).getByRole('heading', { name: 'dataset.newKnowledge.createTitle' }),
).toBeInTheDocument()
expect(screen.getByPlaceholderText('dataset.newKnowledge.namePlaceholder')).toBeInTheDocument()
expect(
screen.getByPlaceholderText('dataset.newKnowledge.descriptionPlaceholder'),
).toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.descriptionHelp')).toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.startWithHelp')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }),
).toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.illustrationHeadline')).toBeInTheDocument()
expect(document.querySelector('.bg-background-overlay-backdrop')).toBeInTheDocument()
await user.keyboard('{Escape}')
expect(routerMock.replace).toHaveBeenCalledWith('/datasets?view=new')
routerMock.replace.mockClear()
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(routerMock.replace).toHaveBeenCalledWith('/datasets?view=new')
routerMock.replace.mockClear()
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(routerMock.replace).toHaveBeenCalledWith('/datasets?view=new')
})
it('asks before discarding an unsaved draft', async () => {
const user = userEvent.setup()
const historyBack = vi.spyOn(window.history, 'back').mockImplementation(() => undefined)
renderPage()
await user.type(
screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' }),
'Draft knowledge',
)
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(routerMock.back).not.toHaveBeenCalled()
const confirmation = await screen.findByRole('alertdialog', {
name: 'dataset.newKnowledge.discardDraftTitle',
})
expect(confirmation).toHaveTextContent('dataset.newKnowledge.discardDraftDescription')
await user.click(
within(confirmation).getByRole('button', {
name: 'dataset.newKnowledge.discardDraftConfirm',
}),
)
expect(historyBack).toHaveBeenCalledOnce()
act(() => window.dispatchEvent(new PopStateEvent('popstate')))
expect(routerMock.replace).toHaveBeenCalledWith('/datasets?view=new')
})
it('protects an unsaved draft from browser unload', async () => {
const user = userEvent.setup()
renderPage()
await user.type(
screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' }),
'Draft knowledge',
)
const event = new Event('beforeunload', { cancelable: true })
act(() => window.dispatchEvent(event))
expect(event.defaultPrevented).toBe(true)
})
it('asks before leaving an unsaved draft with browser Back', async () => {
const user = userEvent.setup()
renderPage()
await user.type(
screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' }),
'Draft knowledge',
)
act(() => window.dispatchEvent(new PopStateEvent('popstate')))
const confirmation = await screen.findByRole('alertdialog', {
name: 'dataset.newKnowledge.discardDraftTitle',
})
await user.click(
within(confirmation).getByRole('button', {
name: 'dataset.newKnowledge.discardDraftConfirm',
}),
)
expect(routerMock.replace).toHaveBeenCalledWith('/datasets?view=new')
})
it('does not warn after a draft is cleared before browser Back', async () => {
const user = userEvent.setup()
renderPage()
const nameInput = screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' })
await user.type(nameInput, 'Draft knowledge')
await user.clear(nameInput)
act(() => window.dispatchEvent(new PopStateEvent('popstate')))
expect(
screen.queryByRole('alertdialog', {
name: 'dataset.newKnowledge.discardDraftTitle',
}),
).not.toBeInTheDocument()
expect(routerMock.replace).toHaveBeenCalledWith('/datasets?view=new')
})
it('warns before leaving a partially created knowledge space', async () => {
const user = userEvent.setup()
serviceMock.patchPolicy.mockRejectedValueOnce(new Error('policy update unavailable'))
renderPage()
await fillRequiredFields(user)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
expect(
await screen.findByText('dataset.newKnowledge.permissionUpdateFailed'),
).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(routerMock.back).not.toHaveBeenCalled()
const confirmation = await screen.findByRole('alertdialog', {
name: 'dataset.newKnowledge.leavePartialSetupTitle',
})
expect(confirmation).toHaveTextContent('dataset.newKnowledge.leavePartialSetupDescription')
await user.click(
within(confirmation).getByRole('button', {
name: 'dataset.newKnowledge.leavePartialSetupConfirm',
}),
)
expect(routerMock.replace).toHaveBeenCalledWith(
'/datasets/new/e735c1dc-d2b8-4dc4-86dc-abaf2fb7d084/sources',
)
})
})
@@ -0,0 +1,60 @@
import { screen, waitFor } from '@testing-library/react'
import { render } from '@/test/console/render'
import { KnowledgeRouteGuard } from '../knowledge-route-guard'
const featureMock = vi.hoisted(() => ({
enabled: true,
atom: Symbol('systemFeaturesAtom'),
}))
const routerMock = vi.hoisted(() => ({ replace: vi.fn() }))
vi.mock('@/next/navigation', () => ({
useRouter: () => routerMock,
}))
vi.mock('@/context/system-features-state', () => ({
systemFeaturesAtom: featureMock.atom,
}))
vi.mock('jotai', async (importOriginal) => {
const original = await importOriginal<typeof import('jotai')>()
return {
...original,
useAtomValue: (atom: unknown) =>
atom === featureMock.atom
? { knowledge_fs_enabled: featureMock.enabled }
: original.useAtomValue(atom as Parameters<typeof original.useAtomValue>[0]),
}
})
describe('KnowledgeRouteGuard', () => {
beforeEach(() => {
vi.clearAllMocks()
featureMock.enabled = true
})
it('renders new KnowledgeFS routes while enabled', () => {
render(
<KnowledgeRouteGuard>
<div>protected content</div>
</KnowledgeRouteGuard>,
)
expect(screen.getByText('protected content')).toBeInTheDocument()
expect(routerMock.replace).not.toHaveBeenCalled()
})
it('redirects without mounting KnowledgeFS route content while disabled', async () => {
featureMock.enabled = false
render(
<KnowledgeRouteGuard>
<div>protected content</div>
</KnowledgeRouteGuard>,
)
expect(screen.queryByText('protected content')).not.toBeInTheDocument()
await waitFor(() => expect(routerMock.replace).toHaveBeenCalledWith('/datasets'))
})
})
@@ -0,0 +1,154 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '@/test/console/render'
import { KnowledgeSpaceShell } from '../knowledge-space-shell'
const queryMock = vi.hoisted(() => ({
data: undefined as
| {
id: string
name: string
}
| undefined,
error: null as unknown,
isPending: false,
refetch: vi.fn(),
}))
const queryOptionsMock = vi.hoisted(() => vi.fn(() => ({})))
const useQueryOptionsMock = vi.hoisted(() => vi.fn())
const pathnameMock = vi.hoisted(() => ({ value: '/datasets/new/space-1/sources' }))
vi.mock('@/next/navigation', () => ({
usePathname: () => pathnameMock.value,
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const original = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...original,
useQuery: (options: unknown) => {
useQueryOptionsMock(options)
return queryMock
},
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
knowledgeFs: {
getKnowledgeSpacesById: {
queryOptions: queryOptionsMock,
},
},
},
}))
vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn() }))
describe('KnowledgeSpaceShell', () => {
beforeEach(() => {
vi.clearAllMocks()
queryMock.data = undefined
queryMock.error = null
queryMock.isPending = false
pathnameMock.value = '/datasets/new/space-1/sources'
})
it('loads the real knowledge space contract by route id', () => {
queryMock.isPending = true
render(<KnowledgeSpaceShell knowledgeSpaceId="space-1">content</KnowledgeSpaceShell>)
expect(queryOptionsMock).toHaveBeenCalledWith({ input: { params: { id: 'space-1' } } })
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('renders a refresh-safe header and route navigation when loaded', () => {
queryMock.data = { id: 'space-1', name: 'Support knowledge' }
render(<KnowledgeSpaceShell knowledgeSpaceId="space-1">source content</KnowledgeSpaceShell>)
expect(screen.getByRole('heading', { name: 'Support knowledge' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'dataset.newKnowledge.sources' })).toHaveAttribute(
'href',
'/datasets/new/space-1/sources',
)
expect(screen.getByRole('link', { name: 'dataset.newKnowledge.sources' })).toHaveAttribute(
'aria-current',
'page',
)
expect(screen.getByRole('link', { name: 'dataset.newKnowledge.documents' })).toHaveAttribute(
'href',
'/datasets/new/space-1/documents',
)
expect(screen.getByText('source content')).toBeInTheDocument()
})
it('shows a not-found state without rendering children', () => {
queryMock.error = { status: 404 }
render(<KnowledgeSpaceShell knowledgeSpaceId="missing">source content</KnowledgeSpaceShell>)
expect(screen.getByText('dataset.newKnowledge.notFoundTitle')).toBeInTheDocument()
expect(screen.queryByText('source content')).not.toBeInTheDocument()
})
it('recognizes the nested status shape returned by the ORPC client', () => {
queryMock.error = { data: { status: 404 } }
render(<KnowledgeSpaceShell knowledgeSpaceId="missing">source content</KnowledgeSpaceShell>)
expect(screen.getByText('dataset.newKnowledge.notFoundTitle')).toBeInTheDocument()
})
it('treats forbidden detail responses as a terminal non-disclosing state', () => {
queryMock.error = { data: { status: 403 } }
render(<KnowledgeSpaceShell knowledgeSpaceId="private">source content</KnowledgeSpaceShell>)
expect(screen.getByText('dataset.newKnowledge.notFoundTitle')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.retry' })).not.toBeInTheDocument()
})
it.each([{ status: 403 }, { data: { status: 404 } }])(
'does not automatically retry terminal detail errors shaped as $error',
(error) => {
queryMock.error = error
render(<KnowledgeSpaceShell knowledgeSpaceId="private">source content</KnowledgeSpaceShell>)
const options = useQueryOptionsMock.mock.lastCall?.[0] as {
retry: (failureCount: number, queryError: unknown) => boolean
}
expect(options.retry(0, error)).toBe(false)
expect(options.retry(2, new Error('temporary failure'))).toBe(true)
expect(options.retry(3, new Error('temporary failure'))).toBe(false)
},
)
it('marks Documents as the only current detail route', () => {
pathnameMock.value = '/datasets/new/space-1/documents'
queryMock.data = { id: 'space-1', name: 'Support knowledge' }
render(<KnowledgeSpaceShell knowledgeSpaceId="space-1">document content</KnowledgeSpaceShell>)
expect(screen.getByRole('link', { name: 'dataset.newKnowledge.sources' })).not.toHaveAttribute(
'aria-current',
)
expect(screen.getByRole('link', { name: 'dataset.newKnowledge.documents' })).toHaveAttribute(
'aria-current',
'page',
)
})
it('offers a real retry for recoverable loading errors', async () => {
const user = userEvent.setup()
queryMock.error = new Error('temporary failure')
render(<KnowledgeSpaceShell knowledgeSpaceId="space-1">source content</KnowledgeSpaceShell>)
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
expect(queryMock.refetch).toHaveBeenCalledOnce()
})
})
@@ -146,7 +146,7 @@ describe('NewKnowledgeList', () => {
expect(options?.getNextPageParam({ items: [] })).toBeUndefined()
})
it('renders real knowledge spaces as unavailable until the detail contract is supported', () => {
it('links real knowledge spaces to the new detail shell', () => {
setResolvedPage([
{
createdAt: '2026-07-15T00:00:00Z',
@@ -172,15 +172,16 @@ describe('NewKnowledgeList', () => {
renderWithNuqs(<NewKnowledgeList view="new" onViewChange={vi.fn()} />)
const list = screen.getByRole('list', { name: 'dataset.knowledge' })
const supportCard = within(list).getByRole('article', {
name: 'Support knowledge. dataset.cornerLabel.unavailable',
const supportCard = within(list).getByRole('link', {
name: 'Support knowledge',
})
expect(supportCard).toHaveAttribute('href', '/datasets/new/space-1/sources')
expect(supportCard).toBeInTheDocument()
expect(
within(list).getByRole('article', {
name: 'Engineering handbook. dataset.cornerLabel.unavailable',
within(list).getByRole('link', {
name: 'Engineering handbook',
}),
).toBeInTheDocument()
).toHaveAttribute('href', '/datasets/new/space-2/sources')
expect(within(list).getByText('Answers for customer support')).toBeInTheDocument()
expect(within(list).getByText('dataset.newKnowledge.noDescription')).toBeInTheDocument()
expect(within(supportCard).getByLabelText('camera')).toBeInTheDocument()
@@ -188,7 +189,6 @@ describe('NewKnowledgeList', () => {
expect(within(list).getAllByText('dataset.newKnowledge.tags')).toHaveLength(2)
expect(within(list).getAllByText('dataset.newKnowledge.documentsUnavailable')).toHaveLength(2)
expect(within(list).getAllByText('dataset.newKnowledge.appsUnavailable')).toHaveLength(2)
expect(within(list).queryByRole('link')).not.toBeInTheDocument()
expect(within(list).queryByRole('button')).not.toBeInTheDocument()
})
@@ -225,22 +225,21 @@ describe('NewKnowledgeList', () => {
const tags = screen.getByRole('button', { name: 'dataset.newKnowledge.tags' })
const creators = screen.getByRole('button', { name: 'dataset.newKnowledge.creators' })
const search = screen.getByRole('searchbox', { name: 'common.operation.search' })
const create = screen.getByRole('button', { name: 'common.operation.create' })
const create = screen.getByRole('link', { name: 'common.operation.create' })
expect(tags).toBeEnabled()
expect(creators).toBeEnabled()
await user.click(tags)
expect(toastInfoMock).toHaveBeenCalledWith('dataset.newKnowledge.filtersUnavailable')
expect(search).toBeEnabled()
expect(create).toBeDisabled()
expect(create).toHaveAccessibleDescription('dataset.cornerLabel.unavailable')
expect(create).toHaveAttribute('href', '/datasets/new/create')
await user.type(search, 'customer support')
expect(screen.getByText('Support knowledge')).toBeInTheDocument()
expect(screen.queryByText('Engineering handbook')).not.toBeInTheDocument()
})
it('shows unavailable empty-state creation entries to authorized users', () => {
it('keeps stacked creation modes disabled while start empty remains available', () => {
setResolvedPage()
renderWithNuqs(<NewKnowledgeList view="new" onViewChange={vi.fn()} />)
@@ -251,7 +250,7 @@ describe('NewKnowledgeList', () => {
const uploadFiles = screen.getByRole('button', {
name: 'dataset.newKnowledge.uploadFiles',
})
const startEmpty = screen.getByRole('button', {
const startEmpty = screen.getByRole('link', {
name: 'dataset.newKnowledge.startEmpty',
})
@@ -263,10 +262,8 @@ describe('NewKnowledgeList', () => {
expect(uploadFiles).toHaveAccessibleDescription(
'dataset.newKnowledge.uploadFilesDescription dataset.cornerLabel.unavailable',
)
expect(startEmpty).toBeDisabled()
expect(startEmpty).toHaveAccessibleDescription(
'dataset.newKnowledge.startEmptyDescription dataset.cornerLabel.unavailable',
)
expect(startEmpty).toHaveAttribute('href', '/datasets/new/create?start=empty')
expect(startEmpty).toHaveAccessibleDescription('dataset.newKnowledge.startEmptyDescription')
expect(screen.getByText('dataset.newKnowledge.connectSourceDescription')).toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.uploadFilesDescription')).toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.startEmptyDescription')).toBeInTheDocument()
@@ -274,6 +271,19 @@ describe('NewKnowledgeList', () => {
expect(screen.queryByTestId('empty-knowledge-card')).not.toBeInTheDocument()
})
it('does not show the Create route to users with external-connect permission only', () => {
permissionStateMock.workspacePermissionKeys = ['dataset.external.connect']
setResolvedPage()
renderWithNuqs(<NewKnowledgeList view="new" onViewChange={vi.fn()} />)
expect(screen.queryByRole('link', { name: 'common.operation.create' })).not.toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.readOnlyEmpty')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'dataset.newKnowledge.connectSource' }),
).not.toBeInTheDocument()
})
it('hides creation entries from read-only users', () => {
permissionStateMock.workspacePermissionKeys = []
setResolvedPage()
@@ -281,10 +291,10 @@ describe('NewKnowledgeList', () => {
renderWithNuqs(<NewKnowledgeList view="new" onViewChange={vi.fn()} />)
expect(
screen.queryByRole('button', { name: /^dataset\.newKnowledge\.startEmpty/ }),
screen.queryByRole('link', { name: /^dataset\.newKnowledge\.startEmpty/ }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /common\.operation\.create/ }),
screen.queryByRole('link', { name: /common\.operation\.create/ }),
).not.toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.readOnlyEmpty')).toBeInTheDocument()
})
@@ -0,0 +1,95 @@
'use client'
import type { NewKnowledgeStartMode } from '../routes'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioControl, RadioItem } from '@langgenius/dify-ui/radio'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
export function StartMode({
description,
disabled = false,
icon,
title,
value,
}: {
description: string
disabled?: boolean
icon: string
title: string
value: NewKnowledgeStartMode
}) {
const { t } = useTranslation('dataset')
const titleId = useId()
const descriptionId = useId()
const unavailableId = useId()
return (
<RadioItem
value={value}
nativeButton
render={<button type="button" />}
aria-labelledby={titleId}
aria-describedby={disabled ? `${descriptionId} ${unavailableId}` : descriptionId}
disabled={disabled}
className={cn(
'relative flex min-h-16 w-full items-center gap-3 overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg px-4 py-3.5 text-left outline-hidden transition-colors motion-reduce:transition-none',
'hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'data-checked:border-[1.5px] data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg',
'data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:bg-components-option-card-option-bg',
)}
>
<RadioControl aria-hidden />
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-option-card-option-border bg-background-default">
<span aria-hidden className={`${icon} size-[18px] text-text-accent`} />
</span>
<span className="min-w-0 flex-1">
<span id={titleId} className="block system-sm-medium text-text-primary">
{title}
</span>
<span id={descriptionId} className="mt-0.5 block system-xs-regular text-text-tertiary">
{description}
</span>
</span>
{value === 'source' && (
<span
aria-hidden
className="h-4 w-[82px] shrink-0 bg-[url('/images/new-rag/create-knowledge-connectors.svg')] bg-contain bg-center bg-no-repeat"
/>
)}
{disabled && (
<span id={unavailableId} className="ml-3 shrink-0 system-xs-medium text-text-disabled">
{t(($) => $['cornerLabel.unavailable'])}
</span>
)}
</RadioItem>
)
}
export function KnowledgeIllustration({ title }: { title: string }) {
return (
<div className="flex size-full flex-col items-start bg-background-default" aria-hidden>
<div className="min-h-0 w-full flex-1 border-b border-divider-subtle" />
<div className="flex max-h-full w-full shrink-0 flex-col items-start overflow-hidden pb-[94px]">
<div className="flex w-full shrink-0 flex-col items-start gap-4 overflow-hidden py-4 pr-32 pl-8">
<span className="flex size-14 shrink-0 items-center justify-center rounded-[10px] backdrop-blur-[6px]">
<span className="flex size-full items-center justify-center rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-1 text-text-accent">
<span className="i-ri-book-open-line size-6" />
</span>
</span>
<p className="w-full body-2xl-regular font-medium tracking-[-0.09px] text-text-primary">
{title}
</p>
</div>
<div className="aspect-[1489/840] w-full shrink-0 overflow-hidden">
<img
alt=""
className="block size-full max-w-none object-contain"
src="/images/new-rag/create-knowledge-illustration.svg"
/>
</div>
</div>
<div className="min-h-0 w-full flex-1" />
</div>
)
}
@@ -0,0 +1,71 @@
'use client'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { useTranslation } from 'react-i18next'
export type CreateKnowledgeExitReason = 'discard' | 'partial'
export function CreateKnowledgeExitDialog({
onCancel,
onConfirm,
reason,
}: {
onCancel: () => void
onConfirm: () => void
reason: CreateKnowledgeExitReason | null
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const isPartial = reason === 'partial'
return (
<AlertDialog
open={reason !== null}
onOpenChange={(open) => {
if (!open) onCancel()
}}
>
<AlertDialogContent className="w-120 overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-lg">
<div className="flex flex-col items-start gap-2 self-stretch p-6 pb-4">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) =>
isPartial
? $['newKnowledge.leavePartialSetupTitle']
: $['newKnowledge.discardDraftTitle'],
)}
</AlertDialogTitle>
<AlertDialogDescription
render={<div />}
className="system-md-regular text-text-secondary"
>
{t(($) =>
isPartial
? $['newKnowledge.leavePartialSetupDescription']
: $['newKnowledge.discardDraftDescription'],
)}
</AlertDialogDescription>
</div>
<AlertDialogActions className="gap-2 p-6">
<AlertDialogCancelButton variant="secondary">
{tCommon(($) => $['operation.cancel'])}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onConfirm}>
{t(($) =>
isPartial
? $['newKnowledge.leavePartialSetupConfirm']
: $['newKnowledge.discardDraftConfirm'],
)}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -1,7 +1,7 @@
import type { KnowledgeSpace } from '@dify/contracts/knowledge-fs/types.gen'
import { useTranslation } from 'react-i18next'
import CornerLabel from '@/app/components/base/corner-label'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
function getBuiltinIconName(iconRef: string | undefined) {
if (!iconRef?.startsWith('builtin:')) return undefined
@@ -20,15 +20,11 @@ export function KnowledgeSpaceCard({ knowledgeSpace }: { knowledgeSpace: Knowled
return (
<li>
<article
aria-label={`${knowledgeSpace.name}. ${unavailable}`}
className="relative flex h-[166px] w-full cursor-not-allowed flex-col overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg text-left shadow-xs"
<Link
href={`/datasets/new/${knowledgeSpace.id}/sources`}
aria-label={knowledgeSpace.name}
className="relative flex h-[166px] w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg text-left shadow-xs outline-hidden transition-shadow hover:shadow-md focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none"
>
<CornerLabel
label={unavailable}
className="absolute top-0 right-0"
labelClassName="rounded-tr-xl"
/>
<div className="flex w-full items-center gap-3 px-4 pt-4 pb-1.5">
<div
aria-label={iconName ?? t(($) => $['newKnowledge.cardType'])}
@@ -43,14 +39,12 @@ export function KnowledgeSpaceCard({ knowledgeSpace }: { knowledgeSpace: Knowled
<span aria-hidden className="i-ri-book-open-line size-5 text-text-tertiary" />
)}
</div>
<div className="min-w-0 flex-1 py-px pr-16">
<div className="min-w-0 flex-1 py-px">
<h2 className="truncate system-md-semibold text-text-secondary">
{knowledgeSpace.name}
</h2>
<div className="mt-0.5 flex min-w-0 items-center gap-1 system-2xs-medium-uppercase text-text-disabled">
<span className="truncate">{t(($) => $['newKnowledge.cardType'])}</span>
<span aria-hidden>·</span>
<span className="shrink-0">{unavailable}</span>
</div>
</div>
</div>
@@ -86,7 +80,7 @@ export function KnowledgeSpaceCard({ knowledgeSpace }: { knowledgeSpace: Knowled
})}
</span>
</div>
</article>
</Link>
</li>
)
}
@@ -1,8 +1,13 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
import FirstEmptyActionCard from '@/app/components/apps/first-empty-state/action-card'
import { Infotip } from '@/app/components/base/infotip'
import CornerLabel from '@/app/components/base/corner-label'
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
import Link from '@/next/link'
import { newKnowledgeCreatePathWithStartMode } from '../routes'
const LOADING_CARD_IDS = [
'loading-card-1',
@@ -20,22 +25,6 @@ const EMPTY_GHOST_CARD_IDS = Array.from({ length: 16 }, (_, index) => `empty-gho
export const KNOWLEDGE_SPACE_GRID_CLASS_NAME =
'grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-2.5'
export function UnavailableReason({ label, reason }: { label: string; reason: string }) {
return (
<Infotip
aria-label={label}
iconVariant="information"
iconSize="large"
placement="bottom"
sideOffset={6}
className="size-6 rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
popupClassName="max-w-[260px] bg-components-tooltip-bg shadow-lg"
>
{reason}
</Infotip>
)
}
export function NewKnowledgeLoadingState() {
const { t } = useTranslation('common')
@@ -86,11 +75,13 @@ export function NewKnowledgePageState({
function EmptyAction({
description,
href,
iconClassName,
recommended = false,
title,
}: {
description: string
href?: string
iconClassName: string
recommended?: boolean
title: string
@@ -98,18 +89,88 @@ function EmptyAction({
const { t } = useTranslation('dataset')
const unavailable = t(($) => $['cornerLabel.unavailable'])
const recommendedLabel = t(($) => $['firstEmpty.recommended'])
const descriptionId = useId()
const unavailableId = useId()
const recommendedId = useId()
return (
<FirstEmptyActionCard
disabled
disabledReason={unavailable}
badge={recommended ? recommendedLabel : undefined}
className="min-h-[58px] py-2 backdrop-blur-[6px]"
description={description}
icon={<span aria-hidden className={`${iconClassName} size-4 text-text-disabled`} />}
title={title}
visualStyle="list"
/>
<ButtonOrLink
href={href}
aria-label={title}
aria-describedby={`${descriptionId}${href ? '' : ` ${unavailableId}`}${recommended ? ` ${recommendedId}` : ''}`}
className="relative flex min-h-[58px] w-full items-center overflow-hidden rounded-xl bg-components-button-secondary-bg px-3 py-2 text-left text-text-secondary outline-hidden backdrop-blur-[6px] hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-components-button-secondary-bg"
>
<span className="mr-3 flex size-9 shrink-0 items-center justify-center rounded-lg bg-background-default-subtle">
<span
aria-hidden
className={cn(
iconClassName,
'size-4',
href ? 'text-text-tertiary' : 'text-text-disabled',
)}
/>
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
'block system-md-medium',
href ? 'text-text-secondary' : 'text-text-disabled',
)}
>
{title}
</span>
<span
id={descriptionId}
className={cn(
'mt-0.5 block system-xs-regular',
href ? 'text-text-tertiary' : 'text-text-disabled',
)}
>
{description}
</span>
</span>
{!href && (
<span id={unavailableId} className="ml-3 shrink-0 system-xs-medium text-text-disabled">
{unavailable}
</span>
)}
{recommended && (
<div id={recommendedId}>
<CornerLabel
label={recommendedLabel}
className="absolute top-0 right-0 z-5"
cornerClassName="text-util-colors-indigo-indigo-100"
labelClassName="-ml-px rounded-tr-xl bg-util-colors-indigo-indigo-100 pr-2"
textClassName="text-util-colors-indigo-indigo-700"
/>
</div>
)}
</ButtonOrLink>
)
}
function ButtonOrLink({
children,
href,
...props
}: {
'aria-describedby': string
'aria-label': string
children: ReactNode
className: string
href?: string
}) {
if (href)
return (
<Link href={href} {...props}>
{children}
</Link>
)
return (
<button type="button" disabled {...props}>
{children}
</button>
)
}
@@ -150,7 +211,7 @@ export function NewKnowledgeEmptyState({
canCreate: boolean
}) {
const { t } = useTranslation('dataset')
const canStart = canConnect || canCreate
const canStart = canCreate
return (
<div className="relative isolate flex min-h-[calc(100vh-134px)] items-center justify-center overflow-hidden px-4 py-16 text-center sm:px-6">
@@ -197,6 +258,7 @@ export function NewKnowledgeEmptyState({
iconClassName="i-ri-folder-6-line"
title={t(($) => $['newKnowledge.startEmpty'])}
description={t(($) => $['newKnowledge.startEmptyDescription'])}
href={newKnowledgeCreatePathWithStartMode('empty')}
/>
</>
)}
@@ -0,0 +1,434 @@
'use client'
import type { KnowledgeSpaceCreationResponse } from '@dify/contracts/knowledge-fs/types.gen'
import type { CreateKnowledgeExitReason } from './components/create-knowledge-exit-dialog'
import type { KnowledgeVisibility } from './create-knowledge-workflow'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
DialogBackdrop,
DialogPopup,
DialogPortal,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import {
Field,
FieldControl,
FieldDescription,
FieldError,
FieldLabel,
} from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { RadioGroup } from '@langgenius/dify-ui/radio'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectLabel,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { DatasetACLPermission, hasPermission } from '@/utils/permission'
import { KnowledgeIllustration, StartMode } from './components/create-knowledge-dialog-parts'
import { CreateKnowledgeExitDialog } from './components/create-knowledge-exit-dialog'
import {
createKnowledge,
DESCRIPTION_MAX_LENGTH,
isDefinitiveCreationRejection,
KnowledgeCreationError,
NAME_MAX_LENGTH,
} from './create-knowledge-workflow'
import { newKnowledgeDetailPath, newKnowledgeListPath } from './routes'
export function CreateKnowledgePage() {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const router = useRouter()
const queryClient = useQueryClient()
const dialogTitleId = useId()
const permissionDescriptionId = useId()
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canConfigureAccess = hasPermission(
workspacePermissionKeys,
DatasetACLPermission.AccessConfig,
)
const defaultVisibility: KnowledgeVisibility = canConfigureAccess ? 'all_members' : 'only_me'
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [visibility, setVisibility] = useState<KnowledgeVisibility>(defaultVisibility)
const [createdKnowledge, setCreatedKnowledge] = useState<KnowledgeSpaceCreationResponse>()
const [submissionLocked, setSubmissionLocked] = useState(false)
const [exitReason, setExitReason] = useState<CreateKnowledgeExitReason | null>(null)
const idempotencyKeyRef = useRef<string | undefined>(undefined)
const historyGuardArmedRef = useRef(false)
const browserBackExitRef = useRef(false)
const pendingNavigationRef = useRef<string | undefined>(undefined)
const createMutation = useMutation({ mutationFn: createKnowledge })
const hasUnsavedChanges = Boolean(
name || description || visibility !== defaultVisibility || createdKnowledge,
)
const armHistoryGuard = useCallback(() => {
globalThis.history.pushState(globalThis.history.state, '', globalThis.location.href)
historyGuardArmedRef.current = true
}, [])
const replaceAfterHistoryGuard = useCallback(
(path: string) => {
if (!historyGuardArmedRef.current) {
router.replace(path)
return
}
pendingNavigationRef.current = path
globalThis.history.back()
},
[router],
)
useEffect(() => {
if (
!hasUnsavedChanges ||
historyGuardArmedRef.current ||
browserBackExitRef.current ||
pendingNavigationRef.current
)
return
armHistoryGuard()
}, [armHistoryGuard, hasUnsavedChanges])
useEffect(() => {
const handlePopState = () => {
if (!historyGuardArmedRef.current) return
historyGuardArmedRef.current = false
const pendingNavigation = pendingNavigationRef.current
if (pendingNavigation) {
pendingNavigationRef.current = undefined
router.replace(pendingNavigation)
return
}
if (!hasUnsavedChanges) {
router.replace(newKnowledgeListPath)
return
}
browserBackExitRef.current = true
setExitReason(createdKnowledge ? 'partial' : 'discard')
}
globalThis.addEventListener('popstate', handlePopState)
return () => globalThis.removeEventListener('popstate', handlePopState)
}, [createdKnowledge, hasUnsavedChanges, router])
useEffect(() => {
if (!hasUnsavedChanges) return
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
event.returnValue = ''
}
globalThis.addEventListener('beforeunload', handleBeforeUnload)
return () => globalThis.removeEventListener('beforeunload', handleBeforeUnload)
}, [hasUnsavedChanges])
const resetUnsubmittedError = () => {
if (!submissionLocked) createMutation.reset()
}
const requestClose = () => {
if (createMutation.isPending) return
if (createdKnowledge) {
setExitReason('partial')
return
}
if (name || description || visibility !== defaultVisibility) {
setExitReason('discard')
return
}
replaceAfterHistoryGuard(newKnowledgeListPath)
}
const confirmExit = () => {
const confirmedReason = exitReason
setExitReason(null)
if (confirmedReason === 'partial' && createdKnowledge) {
browserBackExitRef.current = false
replaceAfterHistoryGuard(newKnowledgeDetailPath(createdKnowledge.id))
return
}
browserBackExitRef.current = false
replaceAfterHistoryGuard(newKnowledgeListPath)
}
const cancelExit = () => {
setExitReason(null)
if (!browserBackExitRef.current) return
browserBackExitRef.current = false
armHistoryGuard()
}
const handleSubmit = async () => {
if (createMutation.isPending) return
const normalizedName = name.trim()
const normalizedDescription = description.trim()
if (!normalizedName) return
idempotencyKeyRef.current ??= globalThis.crypto.randomUUID()
setSubmissionLocked(true)
try {
const created = await createMutation.mutateAsync({
existingKnowledge: createdKnowledge,
description: normalizedDescription,
idempotencyKey: idempotencyKeyRef.current,
name: normalizedName,
onCreated: (knowledgeSpace) => {
setCreatedKnowledge(knowledgeSpace)
void queryClient.invalidateQueries({
queryKey: consoleQuery.knowledgeFs.listKnowledgeSpaces.key(),
})
},
visibility,
})
replaceAfterHistoryGuard(newKnowledgeDetailPath(created.id))
} catch (error) {
if (error instanceof KnowledgeCreationError && error.createdKnowledge)
setCreatedKnowledge(error.createdKnowledge)
if (
error instanceof KnowledgeCreationError &&
error.stage === 'create' &&
isDefinitiveCreationRejection(error.originalError)
) {
idempotencyKeyRef.current = undefined
setSubmissionLocked(false)
}
// The mutation state renders a retryable, localized error without exposing upstream details.
}
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) requestClose()
}}
>
<DialogPortal>
<DialogBackdrop className="bg-background-overlay-backdrop backdrop-blur-[6px]" />
<DialogPopup
aria-labelledby={dialogTitleId}
className="fixed inset-x-3 top-4 bottom-4 grid min-h-0 min-w-0 overflow-hidden xl:grid-cols-2"
>
<button
type="button"
aria-label={tCommon(($) => $['operation.close'])}
className="absolute top-3 right-3 z-10 flex size-9 items-center justify-center rounded-xl bg-background-section-burn text-text-tertiary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled"
onClick={requestClose}
disabled={createMutation.isPending}
>
<span aria-hidden className="i-ri-close-line size-5" />
</button>
<div className="flex min-h-0 min-w-0 flex-col items-end border-divider-subtle xl:border-r">
<div className="min-h-6 w-full max-w-[760px] flex-1 [@media(max-height:850px)]:h-6 [@media(max-height:850px)]:flex-none" />
<Form
className="flex w-full max-w-[760px] shrink-0 flex-col [@media(max-height:850px)]:min-h-0 [@media(max-height:850px)]:flex-1"
onFormSubmit={handleSubmit}
>
<header className="shrink-0 px-6 pt-2 pb-6 sm:px-10">
<DialogTitle id={dialogTitleId} className="title-2xl-semi-bold text-text-primary">
{t(($) => $['newKnowledge.createTitle'])}
</DialogTitle>
</header>
<div className="flex min-h-0 flex-col gap-4 px-6 sm:px-10 [@media(max-height:850px)]:flex-1 [@media(max-height:850px)]:overflow-y-auto">
<div className="space-y-4">
<Field
name="name"
className="gap-1.5"
validate={(value) => {
if (typeof value === 'string' && value.length > 0 && !value.trim())
return t(($) => $['newKnowledge.nameRequired'])
return null
}}
>
<FieldLabel>
{t(($) => $['newKnowledge.name'])}
<span aria-hidden className="ml-0.5 text-text-destructive">
*
</span>
</FieldLabel>
<FieldControl
autoComplete="off"
disabled={submissionLocked}
maxLength={NAME_MAX_LENGTH}
placeholder={t(($) => $['newKnowledge.namePlaceholder'])}
required
value={name}
onValueChange={(value) => {
setName(value)
resetUnsubmittedError()
}}
/>
<FieldError match="valueMissing">
{t(($) => $['newKnowledge.nameRequired'])}
</FieldError>
<FieldError match="customError" />
</Field>
<Field name="description" className="gap-1.5">
<FieldLabel>{t(($) => $['newKnowledge.description'])}</FieldLabel>
<Textarea
autoComplete="off"
className="min-h-20"
disabled={submissionLocked}
maxLength={DESCRIPTION_MAX_LENGTH}
name="description"
placeholder={t(($) => $['newKnowledge.descriptionPlaceholder'])}
value={description}
onValueChange={(value) => {
setDescription(value)
resetUnsubmittedError()
}}
/>
<FieldDescription>
{t(($) => $['newKnowledge.descriptionHelp'])}
</FieldDescription>
</Field>
<div className="space-y-1.5">
<Select
name="permission"
value={visibility}
disabled={submissionLocked || !canConfigureAccess}
onValueChange={(value) => {
if (value) setVisibility(value)
}}
>
<SelectLabel>{t(($) => $['newKnowledge.permission'])}</SelectLabel>
<SelectTrigger
aria-describedby={!canConfigureAccess ? permissionDescriptionId : undefined}
>
{t(($) =>
visibility === 'all_members'
? $['newKnowledge.permissionAllMembers']
: $['newKnowledge.permissionOnlyMe'],
)}
</SelectTrigger>
<SelectContent>
<SelectItem value="only_me">
<SelectItemText>
{t(($) => $['newKnowledge.permissionOnlyMe'])}
</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="all_members">
<SelectItemText>
{t(($) => $['newKnowledge.permissionAllMembers'])}
</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>
{!canConfigureAccess && (
<p
id={permissionDescriptionId}
className="py-0.5 body-xs-regular text-text-tertiary"
>
{t(($) => $['newKnowledge.permissionRestricted'])}
</p>
)}
</div>
</div>
<fieldset>
<legend className="system-md-semibold text-text-secondary">
{t(($) => $['newKnowledge.startWith'])}
</legend>
<p className="pb-0.5 body-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.startWithHelp'])}
</p>
<RadioGroup
value="empty"
aria-label={t(($) => $['newKnowledge.startWith'])}
className="mt-2 flex-col items-stretch gap-2"
disabled={createMutation.isPending}
>
<StartMode
value="empty"
icon="i-ri-folder-6-line"
title={t(($) => $['newKnowledge.startEmpty'])}
description={t(($) => $['newKnowledge.startEmptyDescription'])}
/>
<StartMode
disabled
value="source"
icon="i-custom-vender-solid-development-api-connection-mod"
title={t(($) => $['newKnowledge.connectSource'])}
description={t(($) => $['newKnowledge.connectSourceDescription'])}
/>
<StartMode
disabled
value="upload"
icon="i-ri-file-text-line"
title={t(($) => $['newKnowledge.uploadFiles'])}
description={t(($) => $['newKnowledge.uploadFilesDescription'])}
/>
</RadioGroup>
</fieldset>
{createMutation.isError && (
<div
className="mt-5 rounded-lg bg-components-badge-status-light-error-bg px-3 py-2 system-sm-regular text-text-destructive"
role="alert"
>
{t(($) =>
createMutation.error instanceof KnowledgeCreationError &&
createMutation.error.stage === 'policy'
? $['newKnowledge.permissionUpdateFailed']
: $['newKnowledge.createFailed'],
)}
</div>
)}
</div>
<div className="shrink-0 px-6 pt-5 pb-10 sm:px-10">
<div className="flex justify-end gap-2">
<Button type="button" disabled={createMutation.isPending} onClick={requestClose}>
{tCommon(($) => $['operation.cancel'])}
</Button>
<Button type="submit" variant="primary" loading={createMutation.isPending}>
{t(($) => $['newKnowledge.createTitle'])}
</Button>
</div>
</div>
</Form>
<div className="min-h-px w-full max-w-[760px] flex-1 [@media(max-height:850px)]:h-6 [@media(max-height:850px)]:flex-none" />
</div>
<aside className="hidden min-h-0 min-w-0 xl:block">
<KnowledgeIllustration title={t(($) => $['newKnowledge.illustrationHeadline'])} />
</aside>
</DialogPopup>
</DialogPortal>
<CreateKnowledgeExitDialog
reason={exitReason}
onCancel={cancelExit}
onConfirm={confirmExit}
/>
</Dialog>
)
}
@@ -0,0 +1,90 @@
import type { KnowledgeSpaceCreationResponse } from '@dify/contracts/knowledge-fs/types.gen'
import { consoleClient } from '@/service/client'
export const NAME_MAX_LENGTH = 160
export const DESCRIPTION_MAX_LENGTH = 2000
export type KnowledgeVisibility = 'all_members' | 'only_me'
type CreateKnowledgeValues = {
existingKnowledge?: KnowledgeSpaceCreationResponse
description: string
idempotencyKey: string
name: string
onCreated: (knowledgeSpace: KnowledgeSpaceCreationResponse) => void
visibility: KnowledgeVisibility
}
export class KnowledgeCreationError extends Error {
readonly stage: 'create' | 'policy'
readonly originalError: unknown
readonly createdKnowledge?: KnowledgeSpaceCreationResponse
constructor(
stage: 'create' | 'policy',
originalError: unknown,
createdKnowledge?: KnowledgeSpaceCreationResponse,
) {
super(`Knowledge creation failed during ${stage}`)
this.name = 'KnowledgeCreationError'
this.stage = stage
this.originalError = originalError
this.createdKnowledge = createdKnowledge
}
}
function responseStatus(error: unknown) {
if (error instanceof Response) return error.status
if (error && typeof error === 'object' && 'status' in error) return error.status
if (error && typeof error === 'object' && 'data' in error) {
const data = error.data
if (data && typeof data === 'object' && 'status' in data) return data.status
}
}
export function isDefinitiveCreationRejection(error: unknown) {
const status = responseStatus(error)
return status === 400 || status === 401 || status === 403 || status === 422
}
export async function createKnowledge(
values: CreateKnowledgeValues,
): Promise<KnowledgeSpaceCreationResponse> {
let created = values.existingKnowledge
if (!created) {
try {
created = await consoleClient.knowledgeFs.createKnowledgeSpace({
body: {
description: values.description || undefined,
idempotencyKey: values.idempotencyKey,
name: values.name,
},
})
} catch (error) {
throw new KnowledgeCreationError('create', error)
}
}
values.onCreated(created)
try {
if (values.visibility === 'all_members') {
const policy = await consoleClient.knowledgeFs.getKnowledgeSpacesByIdAccessPolicy({
params: { id: created.id },
})
if (policy.visibility !== values.visibility) {
await consoleClient.knowledgeFs.patchKnowledgeSpacesByIdAccessPolicy({
body: {
expectedRevision: policy.revision,
partialMemberSubjectIds: [],
visibility: values.visibility,
},
params: { id: created.id },
})
}
}
} catch (error) {
throw new KnowledgeCreationError('policy', error, created)
}
return created
}
@@ -0,0 +1,20 @@
'use client'
import type { ReactNode } from 'react'
import { useAtomValue } from 'jotai'
import { useEffect } from 'react'
import { systemFeaturesAtom } from '@/context/system-features-state'
import { useRouter } from '@/next/navigation'
export function KnowledgeRouteGuard({ children }: { children: ReactNode }) {
const { knowledge_fs_enabled: knowledgeFsEnabled } = useAtomValue(systemFeaturesAtom)
const router = useRouter()
useEffect(() => {
if (!knowledgeFsEnabled) router.replace('/datasets')
}, [knowledgeFsEnabled, router])
if (!knowledgeFsEnabled) return null
return children
}

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