Compare commits

..
Author SHA1 Message Date
GareArc f729bc87a2 feat(inner_api): add endpoint to invalidate plugin model providers cache
Enterprise installs plugins into a workspace by talking to the plugin
daemon directly, so Dify never learns that a tenant's cached model
provider list is stale and serves it for the full 24h TTL.

Expose the existing PluginService.invalidate_plugin_model_providers_cache
over the inner API so enterprise can drop the cache for the tenants it
just changed. Batching is left to the caller.
2026-07-23 20:51:52 -07:00
GareArc 160a1bc405 Merge branch 'gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-22 22:51:51 -07:00
GareArc 6b8937f3e1 Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into gareth/esq1-170-rbac-for-agent 2026-07-22 22:50:36 -07:00
GareArc d673cc96ae fix(agent): drop agent.manage gate from workflow copy-from-roster 2026-07-22 22:50:14 -07:00
GareArc 553c38b1f2 Merge branch 'gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-22 22:17:03 -07:00
Xiyuan ChenandGitHub 9e74520b50 Merge branch 'main' into gareth/esq1-170-rbac-for-agent 2026-07-22 22:01:46 -07:00
GareArc c56b46a318 fix(agent): gate creators platform publish for agent apps 2026-07-22 21:58:28 -07:00
GareArc d16d2d1efa Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into gareth/esq1-170-rbac-for-agent 2026-07-22 21:32:30 -07:00
GareArc 91126a5fda fix(agent): stack agent.manage onto agent app management and import paths
Roster composer writes keep their app_edit / snippets gate and require
workspace agent.manage in addition, instead of replacing it. Generic app
management routes (update/delete/copy/export/name/icon/site/site-enable/
api-enable/api-keys) enforce agent.manage when the target is a roster
Agent App and reject hidden workflow-only backing apps outright, which
stay usable only through the Agent runtime surfaces. Importing an Agent
DSL requires agent.manage and the denial surfaces as 403 instead of a
generic failed import.
2026-07-22 21:31:56 -07:00
GareArc 8482167f1c fix(agent): authorize workflow-only agents against their parent workflow app
The RBAC resource resolver returned the hidden runtime backing app for
workflow-only agents, which never receives a resource access policy, so
per-resource overrides on the parent workflow app were ignored. Resolve
agent_id to the policy-owning agent.app_id instead and keep
backing_app_id strictly runtime-only.
2026-07-22 21:31:43 -07:00
yyhandGitHub 6cd10f5e04 Merge branch 'main' into gareth/esq1-170-rbac-for-agent 2026-07-23 12:07:51 +08:00
GareArc 1b5fbb2142 Merge remote-tracking branch 'origin/main' into gareth/esq1-170-rbac-for-agent 2026-07-22 20:24:53 -07:00
yunlu.wen 60885db938 Merge branch 'feat/redis-azure-managed-identity' into deploy/enterprise 2026-07-23 11:13:31 +08:00
autofix-ci[bot]andGitHub 1ba65a3431 [autofix.ci] apply automated fixes 2026-07-23 02:53:05 +00:00
GareArc 09d7e44bd1 fix(agent): gate roster composer writes with agent.manage
Require agent.manage on the composer endpoints that create or write shared
roster/normal drafts (copy-from-roster, save-to-roster, and the agent
composer PUT) for both app and snippet variants, leaving the local node and
snippet draft saves on their existing edit permissions. Hide the Make Copy
and Save to Roster controls when the user cannot manage agents.
2026-07-22 19:49:05 -07:00
GareArc 67451b0ecc Merge branch 'gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-22 19:08:36 -07:00
yunlu.wen cd62373421 valid pubsub url when mi is enabled 2026-07-23 09:50:08 +08:00
yyh 245c6c3525 fix(web): merge agent console link styles 2026-07-23 09:37:59 +08:00
yyh 4e95e73d86 fix(web): align enterprise edition configuration 2026-07-23 09:37:51 +08:00
GareArc fd88da9743 refactor(web): gate community-edition tip on deployment edition
Replace the enterprise_enabled system feature with an env-derived edition
signal. enterprise_enabled expanded both the Console and Web API contracts,
and via cloudSystemFeatures it made Cloud render the Community Edition
warning.

- Drop enterprise_enabled from SystemFeatureModel; regenerate contracts and
  openapi docs.
- Add NEXT_PUBLIC_ENTERPRISE_ENABLED (forwarding the existing backend
  ENTERPRISE_ENABLED) and derive IS_ENTERPRISE_EDITION / IS_COMMUNITY_EDITION.
- CommunityEditionTip renders only on community edition, so enterprise and
  cloud both correctly hide it.

Review fixes:
- edit-in-console-link: render the enabled control as an anchor instead of a
  Button-rendered link, which forced type="button" onto the <a>; export
  buttonVariants for the styling.
- agents-access-guard: named export; redirect to '/'.
- Remove the redundant @detailSidebar/agents access-guard layout.
2026-07-22 17:44:27 -07:00
yunlu.wen c5b345636d support custom pub/sub url 2026-07-22 17:01:55 +08:00
yunlu.wen 2e30c59652 fix: ssl cert configs default to None 2026-07-22 16:07:50 +08:00
autofix-ci[bot]andGitHub 6cde1271aa [autofix.ci] apply automated fixes 2026-07-22 07:39:00 +00:00
yunlu.wen 1ff2d022ac fix typing 2026-07-22 15:35:33 +08:00
autofix-ci[bot]andGitHub 0064a2d8ff [autofix.ci] apply automated fixes 2026-07-22 07:23:27 +00:00
yunlu.wen 8dd62b6cef delegate get_credentials directly 2026-07-22 15:19:52 +08:00
yunlu.wen 09338631bc move validation to root 2026-07-22 15:15:36 +08:00
yunlu.wen 1c58bd0f15 move db validation to config layer 2026-07-22 14:37:03 +08:00
yunlu.wen 8ce3063ece add to env example 2026-07-22 13:31:52 +08:00
yunlu.wen 28a7c247b9 feat: support entraid for azure managed redis 2026-07-22 13:27:08 +08:00
GareArc bddf574994 Merge branch 'gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-21 16:51:42 -07:00
GareArc bfd4de159e Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into gareth/esq1-170-rbac-for-agent 2026-07-21 16:50:02 -07:00
GareArc 2279298c1a Merge remote-tracking branch 'origin/main' into gareth/esq1-170-rbac-for-agent 2026-07-21 16:38:49 -07:00
autofix-ci[bot]andGitHub 6056646eea [autofix.ci] apply automated fixes 2026-07-21 10:06:36 +00:00
Xiyuan ChenandGitHub e2d0528e29 Merge branch 'main' into gareth/esq1-170-rbac-for-agent 2026-07-21 03:02:56 -07:00
GareArc ac87341d69 Merge remote-tracking branch 'origin/main' into gareth/esq1-170-rbac-for-agent
# Conflicts:
#	api/services/enterprise/rbac_service.py
2026-07-21 03:02:11 -07:00
GareArc 0a2ae4ba1d Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-21 01:35:59 -07:00
GareArc 058fa95517 feat: expose enterprise_enabled and gate the community edition tip on it
The sandbox isolation caveat was suppressed by inspecting license.status,
which answers a different question: a lapsed enterprise licence would start
showing a community edition warning that still does not apply, because sandbox
isolation is a property of how the deployment was built, not of billing state.

ENTERPRISE_ENABLED already drives branding, webapp auth and the plugin manager
in get_system_features, but was never surfaced, leaving the frontend to infer
the edition from side effects. Expose it alongside rbac_enabled and read it
directly, which drops the license status set entirely.
2026-07-21 01:34:28 -07:00
GareArc 75cb8ea408 refactor(api): reuse the roster service's agent resolution for RBAC checks
The app-scoped RBAC gate hand-rolled a query to turn an agent_id into the id
of the App backing it, duplicating AgentRosterService.runtime_backing_app_id.
Worse, it filtered on id and tenant only, so the auth check accepted archived
Agents and sources the runtime resolver rejects -- a weaker notion of a valid
Agent than the layer it guards.

Add peek_runtime_backing_app_id, a read-only counterpart to
get_agent_runtime_app_model that never materializes the hidden backing App,
and share the eligibility filter between the two so they cannot drift.

The tenant filter was also untested: deleting it left all 50 tests passing,
since the specs stubbed the db module wholesale. Assert the resolver is called
with the caller's tenant instead, and cover the precedence and read-only
guarantees against the service directly.
2026-07-21 01:34:16 -07:00
GareArc 457e1e9187 Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-21 00:22:42 -07:00
GareArc edbaaeae23 fix(api): resolve agent routes to their backing app for app-scoped RBAC 2026-07-21 00:22:24 -07:00
GareArc 79370e8c75 fix(web): hide the Agent Console link without agent.manage 2026-07-21 00:22:23 -07:00
GareArc c039a15014 Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-20 22:55:37 -07:00
GareArc 66473d5d51 refactor(web): extract CommunityEditionTip and gate the build-mode caveat 2026-07-20 22:55:16 -07:00
GareArc 024ba2571e Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-20 21:57:26 -07:00
GareArc 4626a9bc59 fix(web): hide the sandbox isolation disclaimer under an enterprise license 2026-07-20 21:57:09 -07:00
GareArc 51e985665f Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-20 20:46:31 -07:00
GareArc 7037f2acc5 fix(web): add agent.manage permission label to all locales 2026-07-20 20:45:20 -07:00
GareArc 13063dbc10 Merge remote-tracking branch 'origin/gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-20 19:27:50 -07:00
GareArc 5e48e39d10 fix(web): stop exporting the unused agent.manage key constant 2026-07-20 18:28:21 -07:00
GareArc e606156580 Merge branch 'main' into gareth/esq1-170-rbac-for-agent 2026-07-20 03:47:12 -07:00
GareArc c56ff84565 Merge branch 'gareth/esq1-170-rbac-for-agent' into deploy/enterprise 2026-07-20 02:42:28 -07:00
GareArc 525e76b577 test: port agent RBAC specs to the console state fixtures
main replaced the dataset-access atom mocks with @/test/console
fixtures (#39239), which landed after this branch was cut. Move the
agents access guard spec onto createWorkspaceStateModuleMock /
createPermissionStateModuleMock and update the main nav additions to
mockConsoleState.
2026-07-20 02:42:01 -07:00
GareArc 181f58ed9c Merge branch 'main' into gareth/esq1-170-rbac-for-agent 2026-07-20 02:31:18 -07:00
GareArc f947cbfffb test: cover the agents access guard and unbreak the roster layout suite
AgentsAccessGuard calls useRouter, which threw "invariant expected app
router to be mounted" in the existing roster layout suite. Mock the guard
there so that suite stays scoped to the feature flag, and give the guard
its own spec covering loading, redirect and pass-through.

Also assert the disabled edit-in-console control through its accessible
role, and move the legacy-key test into a class like its neighbours.
2026-07-20 02:09:31 -07:00
GareArc 87e7b5660a refactor(web): build edit-in-console control from the Button primitive
The enabled and disabled states each carried a hand-written copy of the
secondary-button class list, and the disabled state faked a button with
an aria-disabled span. Render both from dify-ui Button instead, which
already ships the secondary variant and its disabled styling.
2026-07-20 01:49:16 -07:00
GareArc 8440564007 Merge branch 'feat/agentv2-webapp-access-control' into deploy/enterprise 2026-07-20 01:19:17 -07:00
GareArc 582ddbce43 refactor(web): make main nav visibility a predicate
The visibility string union resolved through an if-ladder whose final
branch was an unlabeled fallthrough, so an unrecognized value silently
resolved to the app-deploy check. Replace it with a predicate on the
route config plus named shared predicates, and drop the unused
notDatasetOperator case.
2026-07-20 00:59:55 -07:00
yyh 222169e8d5 Merge remote-tracking branch 'origin/main' into feat/agentv2-webapp-access-control 2026-07-20 15:41:04 +08:00
yyh ac63b8c671 test(web): use complete system feature fixture 2026-07-20 15:36:56 +08:00
yyh ffe318906b fix: lint 2026-07-20 15:30:30 +08:00
yyh 787cda9ee1 fix(web): use radio semantics for app access modes 2026-07-20 15:22:38 +08:00
yyh 6b8b27962d fix(web): preserve app access selection on rerender 2026-07-20 15:19:54 +08:00
yyh 1e5a07ba21 perf(web): lazy load agent access control dialog 2026-07-20 15:19:20 +08:00
yyh 509f92ef2d refactor(web): keep app ACL state reads local 2026-07-20 15:18:37 +08:00
yyh ebe83f3976 test(web): seed agent access system features 2026-07-20 15:17:03 +08:00
yyh f2ba0a7c08 refactor(web): suspend on agent system features 2026-07-20 15:15:39 +08:00
yyh 816c6cad82 refactor(web): centralize web app access cache updates 2026-07-20 15:14:19 +08:00
yyh 4ae9028d4a fix(web): target agent backing app access control 2026-07-20 15:06:00 +08:00
yyh 2ee957bf49 fix(web): guard invalid agent access modes 2026-07-20 15:05:28 +08:00
GareArc 1f9ffc1680 Merge branch 'feat/agentv2-webapp-access-control' into deploy/enterprise 2026-07-19 23:51:58 -07:00
GareArc 96f63dfb1e refactor(web): drop access-mode map extraction no longer needed by agent card 2026-07-19 23:51:53 -07:00
GareArc 37b720fd1d Merge branch 'feat/agentv2-webapp-access-control' into deploy/enterprise 2026-07-19 23:40:42 -07:00
GareArc 2ef1b05afc Revert "fix(web): split agent access cards by container width instead of viewport breakpoint"
This reverts commit 3c460a3bec.
2026-07-19 23:40:29 -07:00
GareArc 1215fb7c25 Merge branch 'feat/agentv2-webapp-access-control' into deploy/enterprise 2026-07-19 23:38:29 -07:00
GareArc 3c460a3bec fix(web): split agent access cards by container width instead of viewport breakpoint 2026-07-19 23:38:23 -07:00
GareArc a14a746931 Merge branch 'feat/agentv2-webapp-access-control' into deploy/enterprise 2026-07-19 23:19:02 -07:00
GareArc 03a005ce2c feat(web): move agent webapp access control entry into action row button 2026-07-19 23:18:48 -07:00
GareArc dac931b5f1 feat(web): disable edit-in-console link without agent.manage 2026-07-19 23:16:55 -07:00
GareArc e284ee15a1 feat(web): redirect non-holders away from agents routes 2026-07-19 23:16:54 -07:00
GareArc 9a3968aba0 feat(web): gate Agents nav entry behind agent.manage 2026-07-19 23:10:09 -07:00
GareArc 3e8b14f8e0 feat(api): require agent.manage on module-exclusive agent endpoints 2026-07-19 23:07:21 -07:00
GareArc 5c42d0928b feat(api): add agent.manage RBAC permission and legacy fallback keys 2026-07-19 23:04:12 -07:00
GareArc d34f3af9c3 Merge remote-tracking branch 'origin/main' into deploy/enterprise 2026-07-19 22:57:47 -07:00
GareArc ae41e8d267 Merge branch 'feat/agentv2-webapp-access-control' into deploy/enterprise 2026-07-19 21:25:39 -07:00
GareArc 48c347eeb7 feat(web): add webapp access control to agent access card 2026-07-19 21:16:18 -07:00
GareArc afc9109148 refactor(web): export access mode maps, narrow AccessControl app prop 2026-07-19 20:54:34 -07:00
175 changed files with 1967 additions and 4448 deletions
+1
View File
@@ -729,6 +729,7 @@ 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,41 +816,6 @@ 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
@@ -1629,7 +1594,6 @@ class FeatureConfig(
TenantIsolatedTaskQueueConfig,
ToolConfig,
UpdateConfig,
CommunityTelemetryConfig,
WorkflowConfig,
WorkflowNodeExecutionConfig,
WorkspaceConfig,
+29 -2
View File
@@ -1,8 +1,17 @@
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
from pydantic import (
Field,
NonNegativeFloat,
NonNegativeInt,
PositiveFloat,
PositiveInt,
computed_field,
model_validator,
)
from pydantic_settings import BaseSettings
from .cache.redis_config import RedisConfig
@@ -408,4 +417,22 @@ class MiddlewareConfig(
DatasetQueueMonitorConfig,
MatrixoneConfig,
):
pass
@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
+32 -4
View File
@@ -1,4 +1,4 @@
from pydantic import Field, NonNegativeInt, PositiveFloat, PositiveInt, field_validator
from pydantic import Field, NonNegativeInt, PositiveFloat, PositiveInt, field_validator, model_validator
from pydantic_settings import BaseSettings
@@ -42,6 +42,13 @@ 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",
@@ -158,12 +165,33 @@ 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_MAX_CONNECTIONS", mode="before")
@field_validator(
"REDIS_SSL_CA_CERTS",
"REDIS_SSL_CERTFILE",
"REDIS_SSL_KEYFILE",
"REDIS_MAX_CONNECTIONS",
mode="before",
)
@classmethod
def _empty_string_to_none_for_max_conns(cls, v):
"""Allow empty string in env/.env to mean 'unset' (None)."""
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``.
"""
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
+20 -7
View File
@@ -10,6 +10,7 @@ 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"]
@@ -51,7 +52,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, path_args)
resource_id = _extract_resource_id(resource_type, tenant_id, path_args)
if _is_resource_owned_by_current_user(tenant_id, account_id, resource_type, resource_id):
return
allowed = RBACService.CheckAccess.check(
@@ -131,11 +132,14 @@ def _is_resource_owned_by_current_user(
return False
def _extract_resource_id(resource_type: RBACResourceScope, path_args: dict[str, object] | None = None) -> str:
def _extract_resource_id(
resource_type: RBACResourceScope, tenant_id: str, 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 App routes use ``agent_id`` as the app id.
app/dataset resources, and Agent routes carry ``agent_id``, which is
resolved to the App backing that Agent.
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``.
@@ -146,10 +150,19 @@ def _extract_resource_id(resource_type: RBACResourceScope, path_args: dict[str,
matched_args = {**view_args, **(path_args or {})}
if resource_type == RBACResourceScope.APP:
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)
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")
if resource_type == RBACResourceScope.DATASET:
dataset_id = matched_args.get("dataset_id") or matched_args.get("resource_id")
@@ -230,6 +230,7 @@ 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
@@ -439,6 +440,7 @@ 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
@@ -478,6 +480,7 @@ 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,6 +552,7 @@ 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
@@ -589,6 +590,7 @@ 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
@@ -630,6 +632,7 @@ 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
@@ -655,6 +658,7 @@ 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):
@@ -712,6 +716,7 @@ 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
@@ -734,6 +739,7 @@ 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
@@ -810,6 +816,7 @@ 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
@@ -832,6 +839,7 @@ 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
@@ -857,6 +865,7 @@ 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):
@@ -873,6 +882,7 @@ 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
@@ -891,6 +901,7 @@ 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]:
@@ -901,6 +912,7 @@ 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]:
@@ -920,6 +932,7 @@ 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(
@@ -965,6 +978,7 @@ 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)
@@ -1003,6 +1017,7 @@ 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)
@@ -1041,6 +1056,7 @@ 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)
@@ -1061,6 +1077,7 @@ 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)
@@ -1086,6 +1103,7 @@ 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):
@@ -1101,6 +1119,7 @@ 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):
@@ -1121,6 +1140,7 @@ 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,6 +12,7 @@ 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
@@ -194,6 +195,7 @@ 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"""
@@ -210,6 +212,7 @@ 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"""
@@ -233,6 +236,7 @@ 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,
+25 -12
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, NotFound
from werkzeug.exceptions import BadRequest, Forbidden, 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 get_app_model, with_session
from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model, with_session
from controllers.console.workspace.models import LoadBalancingPayload
from controllers.console.wraps import (
RBACPermission,
@@ -75,6 +75,7 @@ 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
@@ -827,6 +828,7 @@ 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):
@@ -861,6 +863,7 @@ 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):
@@ -885,6 +888,7 @@ 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)
@@ -896,16 +900,19 @@ 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)
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,
)
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))
if result.status == ImportStatus.FAILED:
session.rollback()
return dump_response(AppImportResponse, result), 400
@@ -959,6 +966,7 @@ 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"""
@@ -983,6 +991,7 @@ 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):
@@ -1013,6 +1022,7 @@ 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):
@@ -1040,6 +1050,7 @@ 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):
@@ -1073,6 +1084,7 @@ 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):
@@ -1100,6 +1112,7 @@ 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):
+21 -13
View File
@@ -1,6 +1,7 @@
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
@@ -28,6 +29,7 @@ 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
@@ -91,18 +93,21 @@ class AppImportApi(Resource):
import_service = AppDslService(session)
# Import app
account = current_user
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,
)
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))
if result.status == ImportStatus.FAILED:
session.rollback()
else:
@@ -157,7 +162,10 @@ class AppImportConfirmApi(Resource):
import_service = AppDslService(session)
# Confirm import
account = current_user
result = import_service.confirm_import(import_id=import_id, account=account)
try:
result = import_service.confirm_import(import_id=import_id, account=account)
except NoPermissionError as e:
raise Forbidden(str(e))
if result.status == ImportStatus.FAILED:
session.rollback()
else:
+3 -1
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 get_app_model
from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
@@ -93,6 +93,7 @@ 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
@@ -145,6 +146,7 @@ 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
+48 -1
View File
@@ -12,14 +12,22 @@ 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__ = ["get_app_model", "get_app_model_with_trial", "with_session"]
__all__ = [
"agent_manage_required_for_agent_app",
"get_app_model",
"get_app_model_with_trial",
"with_session",
]
def _load_app_model(session: Session, app_id: str) -> App | None:
@@ -48,6 +56,45 @@ 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,6 +23,7 @@ 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)
@@ -35,6 +36,7 @@ __all__ = [
"_knowledge_retrieval",
"_mail",
"_plugin",
"_plugin_model_providers",
"_runtime_credentials",
"_workspace",
"api",
@@ -0,0 +1,39 @@
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field
from controllers.common.schema import register_schema_model
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import enterprise_inner_api_only
from core.plugin.plugin_service import PluginService
class InvalidatePluginModelProvidersCachePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
tenant_ids: list[str] = Field(default_factory=list, description="Workspace ids whose cache should be invalidated")
register_schema_model(inner_api_ns, InvalidatePluginModelProvidersCachePayload)
@inner_api_ns.route("/enterprise/workspace/plugin-model-providers/invalidate")
class EnterprisePluginModelProvidersCacheInvalidate(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_ns.doc(
"enterprise_invalidate_plugin_model_providers_cache",
responses={
200: "Cache invalidated",
400: "Invalid request",
401: "Unauthorized - invalid API key",
},
)
@inner_api_ns.expect(inner_api_ns.models[InvalidatePluginModelProvidersCachePayload.__name__])
def post(self):
args = InvalidatePluginModelProvidersCachePayload.model_validate(inner_api_ns.payload or {})
for tenant_id in args.tenant_ids:
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
return {"result": "success"}, 200
+1 -14
View File
@@ -1,7 +1,7 @@
import inspect
import json
import logging
from collections.abc import Callable, Generator, Mapping
from collections.abc import Callable, Generator
from typing import Any, cast
from urllib.parse import unquote
@@ -23,7 +23,6 @@ from core.plugin.impl.exc import (
PluginLLMPollingUnsupportedError,
PluginNotFoundError,
PluginPermissionDeniedError,
PluginRuntimeError,
PluginUniqueIdentifierError,
)
from core.trigger.errors import (
@@ -376,18 +375,6 @@ 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,18 +49,6 @@ 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,6 +61,7 @@ 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
@@ -0,0 +1,58 @@
"""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,
)
+17 -31
View File
@@ -1,3 +1,4 @@
import logging
import ssl
from datetime import timedelta
from typing import Any
@@ -5,14 +6,16 @@ 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
@@ -37,19 +40,6 @@ 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
@@ -122,10 +112,12 @@ 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=dify_config.CELERY_BROKER_URL,
broker=broker_url,
backend=dify_config.CELERY_BACKEND,
)
@@ -142,9 +134,12 @@ def init_app(app: DifyApp) -> Celery:
)
if dify_config.CELERY_BACKEND == "redis":
celery_app.conf.update(
result_backend_transport_options=broker_transport_options,
)
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)
# Apply SSL configuration if enabled
ssl_options = get_celery_ssl_options()
@@ -155,6 +150,10 @@ 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,
@@ -274,19 +273,6 @@ 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)
+14 -1
View File
@@ -21,6 +21,7 @@ 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,
@@ -438,6 +439,10 @@ 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
@@ -457,12 +462,20 @@ 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)
@@ -486,7 +499,7 @@ def init_app(app: DifyApp):
global _pubsub_redis_client
_pubsub_redis_client = client
if dify_config.normalized_pubsub_redis_url:
if dify_config.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,8 +9,6 @@ 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
@@ -102,20 +100,6 @@ 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)
@@ -137,7 +121,6 @@ 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)
@@ -1,30 +0,0 @@
"""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")
+32 -17
View File
@@ -56,6 +56,7 @@ from .provider_ids import GenericProviderID
from .types import EnumText, LongText, StringUUID
if TYPE_CHECKING:
from .agent import Agent
from .workflow import Workflow
@@ -361,9 +362,6 @@ 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
)
@@ -504,25 +502,42 @@ 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
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,
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),
),
Agent.status == AgentStatus.ACTIVE,
)
)
return agent.id if agent else None
sa.and_(
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))
@property
def api_base_url(self) -> str:
+1
View File
@@ -21,6 +21,7 @@ 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
+1 -7
View File
@@ -75,7 +75,6 @@ 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 (
@@ -1962,7 +1961,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, instance_id=str(uuid.uuid4()))
dify_setup = DifySetup(version=dify_config.project.version)
session.add(dify_setup)
session.commit()
except Exception as e:
@@ -1975,11 +1974,6 @@ 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,
+33 -7
View File
@@ -911,16 +911,14 @@ class AgentRosterService:
raise AgentNotFoundError()
return app
def get_agent_runtime_app_model(self, *, tenant_id: str, agent_id: str) -> App:
"""Resolve the App that backs an Agent runtime surface.
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.
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.
Shared by the runtime resolver and the read-only authorization resolver
so both agree on what counts as a resolvable Agent.
"""
agent = self._session.scalar(
return self._session.scalar(
select(Agent)
.where(
Agent.tenant_id == tenant_id,
@@ -938,6 +936,34 @@ 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,6 +19,7 @@ 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,
@@ -43,7 +44,9 @@ 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
@@ -301,6 +304,9 @@ class AppDslService:
error=f"Invalid YAML format: {str(e)}",
)
except NoPermissionError:
raise
except Exception as e:
logger.exception("Failed to import app")
return Import(
@@ -364,6 +370,9 @@ class AppDslService:
warnings=self._warnings,
)
except NoPermissionError:
raise
except Exception as e:
logger.exception("Error confirming import")
return Import(
@@ -395,6 +404,21 @@ 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,
*,
@@ -415,6 +439,8 @@ 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,6 +330,7 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [
"snippets.management",
"tool.manage",
"mcp.manage",
"agent.manage",
]
_LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
@@ -357,6 +358,7 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
"snippets.management",
"tool.manage",
"mcp.manage",
"agent.manage",
]
_LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
@@ -371,6 +373,7 @@ _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
@@ -1,165 +0,0 @@
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
@@ -1,19 +0,0 @@
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)
@@ -0,0 +1,150 @@
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("app", {"app_id": "app-123"})
mock_extract.assert_called_once_with(RBACResourceScope.APP, "tenant-1", {"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", {"app_id": "path-app"}) == "path-app"
assert _extract_resource_id("app", "tenant-1", {"app_id": "path-app"}) == "path-app"
def test_extract_resource_id_falls_back_to_request_view_args(self):
app = Flask(__name__)
@@ -315,22 +315,59 @@ class TestRbacPermissionRequired:
with app.test_request_context("/"):
request.view_args = {"app_id": "view-app"}
assert _extract_resource_id("app") == "view-app"
assert _extract_resource_id("app", "tenant-1") == "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) == "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"
assert _extract_resource_id(RBACResourceScope.APP, "tenant-1") == "app-1"
with app.test_request_context("/datasets/dataset-1/api-keys"):
request.view_args = {"resource_id": "dataset-1"}
assert _extract_resource_id(RBACResourceScope.DATASET) == "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"
def test_legacy_admin_decorator_noops_when_rbac_enabled(self):
@is_admin_or_owner_required
@@ -0,0 +1,64 @@
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, PluginRuntimeError
from core.plugin.impl.exc import PluginLLMPollingUnsupportedError
from core.trigger.errors import (
EventIgnoreError,
TriggerInvokeError,
@@ -175,25 +175,3 @@ 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
@@ -0,0 +1,186 @@
"""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
@@ -1,43 +0,0 @@
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,7 +4,6 @@ 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
@@ -40,14 +39,6 @@ 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
@@ -116,24 +107,6 @@ 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,6 +135,38 @@ 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,3 +933,20 @@ 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,7 +2,6 @@ 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
@@ -1330,10 +1329,7 @@ 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,
patch("services.account_service.CommunityTelemetryService.report_install") as mock_report_install,
):
with patch("services.account_service.TenantService.create_owner_tenant_if_not_exist") as mock_create_tenant:
RegisterService.setup(
"admin@example.com",
"Admin User",
@@ -1352,39 +1348,7 @@ class TestRegisterService:
session=sqlite_session,
)
mock_create_tenant.assert_called_once_with(account=mock_account, is_setup=True, session=sqlite_session)
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
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,10 +5,12 @@ 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)
@@ -166,3 +168,61 @@ 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",
)
@@ -1,333 +0,0 @@
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
@@ -1,40 +0,0 @@
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,6 +1371,7 @@ dependencies = [
{ name = "python-socketio" },
{ name = "readabilipy" },
{ name = "redis", extra = ["hiredis"] },
{ name = "redis-entraid" },
{ name = "resend" },
{ name = "sendgrid" },
{ name = "sseclient-py" },
@@ -1655,6 +1656,7 @@ 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" },
@@ -5819,6 +5821,22 @@ 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,6 +121,7 @@ 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,6 +835,14 @@
"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,6 +2,8 @@
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.
@@ -0,0 +1,110 @@
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,3 +1,4 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
const mocks = vi.hoisted(() => ({
@@ -8,6 +9,12 @@ 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()
@@ -0,0 +1,30 @@
'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
}
+2 -1
View File
@@ -1,8 +1,9 @@
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 children
return <AgentsAccessGuard>{children}</AgentsAccessGuard>
}
@@ -149,7 +149,7 @@ describe('DatasetsLayout', () => {
expect(mockReplace).not.toHaveBeenCalled()
})
it.each(['/datasets/create', '/datasets/create-from-pipeline', '/datasets/new/create'])(
it.each(['/datasets/create', '/datasets/create-from-pipeline'])(
'should redirect direct dataset creation route to /datasets without dataset.create_and_management: %s',
async (pathname) => {
mockPathname = pathname
@@ -186,22 +186,6 @@ 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,7 +17,6 @@ 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/')
)
@@ -1,5 +0,0 @@
import { KnowledgeRoutePlaceholder } from '@/features/new-rag/knowledge-route-placeholder'
export default function Page() {
return <KnowledgeRoutePlaceholder type="documents" />
}
@@ -1,18 +0,0 @@
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>
)
}
@@ -1,6 +0,0 @@
import { redirect } from '@/next/navigation'
export default async function Page({ params }: { params: Promise<{ knowledgeSpaceId: string }> }) {
const { knowledgeSpaceId } = await params
redirect(`/datasets/new/${knowledgeSpaceId}/sources`)
}
@@ -1,5 +0,0 @@
import { KnowledgeRoutePlaceholder } from '@/features/new-rag/knowledge-route-placeholder'
export default function Page() {
return <KnowledgeRoutePlaceholder type="sources" />
}
@@ -1,10 +0,0 @@
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,8 +12,14 @@ type AudioPlayerCtorArgs = [
type MockAudioPlayerInstance = {
setCallback: ReturnType<typeof vi.fn>
destroy: ReturnType<typeof vi.fn>
pauseAudio: ReturnType<typeof vi.fn>
resetMsgId: ReturnType<typeof vi.fn>
cacheBuffers: Array<ArrayBuffer>
sourceBuffer:
| {
abort: ReturnType<typeof vi.fn>
}
| undefined
}
const mockState = vi.hoisted(() => ({
@@ -25,8 +31,10 @@ const mockAudioPlayerConstructor = vi.hoisted(() => vi.fn())
const MockAudioPlayer = vi.hoisted(() => {
return class MockAudioPlayerClass {
setCallback = vi.fn()
destroy = vi.fn()
pauseAudio = vi.fn()
resetMsgId = vi.fn()
cacheBuffers = [new ArrayBuffer(1)]
sourceBuffer = { abort: vi.fn() }
constructor(...args: AudioPlayerCtorArgs) {
mockAudioPlayerConstructor(...args)
@@ -124,7 +132,9 @@ describe('AudioPlayerManager', () => {
callback,
)
expect(previous!.destroy).toHaveBeenCalledTimes(1)
expect(previous!.pauseAudio).toHaveBeenCalledTimes(1)
expect(previous!.cacheBuffers).toEqual([])
expect(previous!.sourceBuffer?.abort).toHaveBeenCalledTimes(1)
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(2)
expect(next).toBe(mockState.instances[1])
})
@@ -134,7 +144,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!.destroy.mockImplementation(() => {
previous!.pauseAudio.mockImplementation(() => {
throw new Error('cleanup failure')
})
@@ -142,7 +152,7 @@ describe('AudioPlayerManager', () => {
manager.getAudioPlayer('/apps/1/text-to-audio', false, 'msg-2', 'world', 'en-US', callback)
}).not.toThrow()
expect(previous!.destroy).toHaveBeenCalledTimes(1)
expect(previous!.pauseAudio).toHaveBeenCalledTimes(1)
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(2)
})
})
@@ -3,8 +3,15 @@ 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',
@@ -15,7 +22,7 @@ vi.mock('@/service/share', () => ({
type AudioEventName =
| 'ended'
| 'pause'
| 'paused'
| 'loaded'
| 'play'
| 'timeupdate'
@@ -23,7 +30,6 @@ type AudioEventName =
| 'canplay'
| 'error'
| 'sourceopen'
| 'updateend'
type AudioEventListener = () => void
@@ -45,31 +51,12 @@ 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' | 'ended' = 'closed'
readyState: 'open' | 'closed' = 'open'
sourceBuffer = new MockSourceBuffer()
private listeners: Partial<Record<AudioEventName, AudioEventListener[]>> = {}
@@ -79,15 +66,10 @@ 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()
@@ -128,7 +110,7 @@ class MockAudio {
}
class MockAudioContext {
state: 'interrupted' | 'running' | 'suspended' = 'running'
state: 'running' | 'suspended' = 'running'
destination = {}
connect = vi.fn(() => undefined)
createMediaElementSource = vi.fn((_audio: MockAudio) => ({
@@ -139,11 +121,9 @@ class MockAudioContext {
this.state = 'running'
})
suspend = vi.fn(async () => {
suspend = vi.fn(() => {
this.state = 'suspended'
})
close = vi.fn(async () => undefined)
}
const testState = {
@@ -153,8 +133,6 @@ const testState = {
}
class MockMediaSourceCtor extends MockMediaSource {
static isTypeSupported = vi.fn(() => true)
constructor() {
super()
testState.mediaSources.push(this)
@@ -178,7 +156,6 @@ 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
@@ -215,7 +192,6 @@ describe('AudioPlayer', () => {
testState.mediaSources = []
testState.audios = []
testState.audioContexts = []
MockMediaSourceCtor.isTypeSupported.mockReturnValue(true)
Object.defineProperty(globalThis, 'Audio', {
configurable: true,
@@ -232,11 +208,6 @@ 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 })
})
@@ -257,11 +228,6 @@ 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,
@@ -290,7 +256,7 @@ describe('AudioPlayer', () => {
expect(audioContext!.connect).toHaveBeenCalledTimes(1)
})
it('should use complete-audio fallback when no MediaSource implementation exists', () => {
it('should notify unsupported browser when no MediaSource implementation exists', () => {
setMediaSourceSupport({ mediaSource: false, managedMediaSource: false })
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
@@ -298,22 +264,12 @@ describe('AudioPlayer', () => {
expect(player.mediaSource).toBeNull()
expect(audio!.src).toBe('')
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()
expect(mockToastNotify).toHaveBeenCalledTimes(1)
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
it('should configure fallback audio controls when ManagedMediaSource is used', () => {
@@ -327,17 +283,6 @@ 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', () => {
@@ -349,7 +294,7 @@ describe('AudioPlayer', () => {
audio!.emit('play')
audio!.emit('ended')
audio!.emit('error')
audio!.emit('pause')
audio!.emit('paused')
audio!.emit('loaded')
audio!.emit('timeupdate')
audio!.emit('loadeddate')
@@ -409,7 +354,6 @@ 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 }]),
@@ -422,171 +366,25 @@ 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 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 () => {
it('should resume and play immediately when playAudio is called in suspended loaded state', 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()
await Promise.resolve()
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(callback).not.toHaveBeenCalledWith('play')
resolveResume?.()
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
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 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', async () => {
it('should play ended audio when data is already loaded', () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
const audio = testState.audios[0]
@@ -597,13 +395,11 @@ describe('AudioPlayer', () => {
audio!.ended = true
player.playAudio()
await waitFor(() => {
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
})
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
})
it('should report loaded audio that is already playing without replaying it', () => {
it('should only emit play callback without replaying when loaded audio is already playing', () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
const audio = testState.audios[0]
@@ -611,7 +407,6 @@ describe('AudioPlayer', () => {
player.isLoadData = true
audioContext!.state = 'running'
audio!.paused = false
audio!.ended = false
player.playAudio()
@@ -656,20 +451,22 @@ describe('AudioPlayer', () => {
})
it('should end stream without playback when playAudioWithAudio receives empty content', async () => {
const callback = vi.fn()
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
const mediaSource = testState.mediaSources[0]
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]
await player.playAudioWithAudio('', true)
await player.playAudioWithAudio('', true)
await vi.advanceTimersByTimeAsync(40)
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')
expect(player.isLoadData).toBe(false)
expect(player.cacheBuffers).toHaveLength(0)
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
expect(callback).not.toHaveBeenCalledWith('play')
} finally {
vi.useRealTimers()
}
})
it('should decode base64 and start playback when playAudioWithAudio is called with playable content', async () => {
@@ -682,8 +479,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)
@@ -691,11 +488,9 @@ describe('AudioPlayer', () => {
const appendedAudioData = mediaSource!.sourceBuffer.appendBuffer.mock.calls[0]![0]
expect(appendedAudioData).toBeInstanceOf(ArrayBuffer)
expect(appendedAudioData.byteLength).toBeGreaterThan(0)
await waitFor(() => {
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
expect(audio!.play).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('play')
})
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 () => {
@@ -712,88 +507,6 @@ 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)
@@ -804,7 +517,7 @@ describe('AudioPlayer', () => {
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
expect(audio!.play).toHaveBeenCalledTimes(1)
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
expect(callback).toHaveBeenCalledWith('play')
})
it('should not replay when played list exists in playAudioWithAudio', async () => {
@@ -821,63 +534,18 @@ describe('AudioPlayer', () => {
expect(callback).not.toHaveBeenCalledWith('play')
})
it('should report a play failure and retry without requesting audio again', async () => {
it('should replay when paused is false and played list is empty in playAudioWithAudio', 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'))
mediaSource!.emit('sourceopen')
player.playAudio()
audio!.paused = false
audio!.ended = false
audio!.played = null
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
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(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)
expect(callback).toHaveBeenCalledWith('play')
})
})
@@ -894,7 +562,7 @@ describe('AudioPlayer', () => {
expect(finishStream).toHaveBeenCalledTimes(1)
})
it('should finish stream when receiveAudioData gets empty bytes', () => {
it('should finish stream when receiveAudioData gets empty bytes while source is open', () => {
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')
@@ -918,52 +586,6 @@ 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]
@@ -981,68 +603,19 @@ describe('AudioPlayer', () => {
expect(player.cacheBuffers.length).toBe(1)
})
it('should end the stream only after the final queued buffer is appended', () => {
it('should append cache chunks and end stream when finishStream drains buffers', () => {
vi.useFakeTimers()
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
player.cacheBuffers = [new ArrayBuffer(3)]
;(player as unknown as { finishStream: () => void }).finishStream()
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
mediaSource!.sourceBuffer.updating = false
mediaSource!.sourceBuffer.emit('updateend')
player.cacheBuffers = [new ArrayBuffer(3)]
;(player as unknown as { finishStream: () => void }).finishStream()
vi.advanceTimersByTime(50)
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
mediaSource!.sourceBuffer.emit('updateend')
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
})
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')
vi.useRealTimers()
})
})
})
@@ -35,7 +35,9 @@ export class AudioPlayerManager {
} else {
if (this.audioPlayers) {
try {
this.audioPlayers.destroy()
this.audioPlayers.pauseAudio()
this.audioPlayers.cacheBuffers = []
this.audioPlayers.sourceBuffer?.abort()
} catch {}
}
+86 -174
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?: typeof MediaSource
ManagedMediaSource: any
}
}
export default class AudioPlayer {
mediaSource: MediaSource | null
audio: HTMLAudioElement
audioContext: AudioContext
sourceBuffer?: SourceBuffer
sourceBuffer?: any
cacheBuffers: ArrayBuffer[] = []
pauseTimer: number | null = null
msgId: string | undefined
msgContent: string | null | undefined = null
voice: string | undefined = undefined
@@ -21,13 +21,6 @@ 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,
@@ -44,26 +37,25 @@ export default class AudioPlayer {
this.voice = voice
this.callback = callback
// Compatible with iphone ios17 ManagedMediaSource
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
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
this.audio = new Audio()
this.setCallback(callback)
if (this.mediaSource && isManagedMediaSource) {
if (!window.MediaSource) {
// if use ManagedMediaSource
this.audio.disableRemotePlayback = true
this.audio.controls = 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)
this.audio.src = this.mediaSource ? URL.createObjectURL(this.mediaSource) : ''
this.audio.autoplay = true
const source = this.audioContext.createMediaElementSource(this.audio)
source.connect(this.audioContext.destination)
this.listenMediaSource('audio/mpeg')
}
public resetMsgId(msgId: string) {
@@ -71,77 +63,10 @@ export default class AudioPlayer {
}
private listenMediaSource(contentType: string) {
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
}
this.mediaSource?.addEventListener('sourceopen', () => {
if (this.sourceBuffer) return
this.sourceBuffer = this.mediaSource?.addSourceBuffer(contentType)
})
}
public setCallback(callback: ((event: string) => void) | null) {
@@ -155,7 +80,7 @@ export default class AudioPlayer {
false,
)
this.audio.addEventListener(
'pause',
'paused',
() => {
callback('paused')
},
@@ -208,7 +133,7 @@ export default class AudioPlayer {
private async loadAudio() {
try {
const audioResponse = (await textToAudioStream(
const audioResponse: any = await textToAudioStream(
this.url,
this.isPublic ? AppSourceType.webApp : AppSourceType.installedApp,
{ content_type: 'audio/mpeg' },
@@ -218,21 +143,19 @@ export default class AudioPlayer {
voice: this.voice,
text: this.msgContent,
},
)) as Response
)
if (audioResponse.status !== 200) {
this.isLoadData = false
this.callback?.('error')
return
if (this.callback) this.callback('error')
}
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.finishStream()
this.receiveAudioData(value)
break
}
this.receiveAudioData(value)
}
} catch {
this.isLoadData = false
@@ -243,29 +166,46 @@ export default class AudioPlayer {
// play audio
public playAudio() {
if (this.isLoadData) {
if (!this.mediaSource && !this.objectUrl) {
this.playWhenReady = true
return
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')
}
this.requestPlayback(true)
this.callback?.('play')
} else {
this.isLoadData = true
this.playWhenReady = true
if (this.mediaSource) this.requestPlayback(true)
else if (this.isAudioContextPaused()) void this.audioContext.resume().catch(() => {})
this.audioContext.resume().then((_) => {
this.audio.play()
this.callback?.('play')
})
this.loadAudio()
}
}
private finishStream() {
if (this.destroyed) return
this.streamEnded = true
if (this.mediaSource) {
this.flushBuffers()
return
}
private theEndOfStream() {
const endTimer = setInterval(() => {
if (!this.sourceBuffer?.updating) {
this.mediaSource?.endOfStream()
clearInterval(endTimer)
}
}, 10)
}
this.finishBlobAudio()
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)
}
public async playAudioWithAudio(audio: string, play = true) {
@@ -273,82 +213,54 @@ export default class AudioPlayer {
this.finishStream()
return
}
const audioContent = Uint8Array.from(atob(audio), (char) => char.charCodeAt(0))
this.receiveAudioData(audioContent)
const audioContent = Buffer.from(audio, 'base64')
this.receiveAudioData(new Uint8Array(audioContent))
if (play) {
this.isLoadData = true
this.playWhenReady = true
if (this.mediaSource) this.requestPlayback()
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')
}
}
}
public pauseAudio() {
this.playWhenReady = false
this.callback?.('paused')
this.audio.pause()
void this.audioContext.suspend().catch(() => {})
this.audioContext.suspend()
}
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
private receiveAudioData(unit8Array: Uint8Array) {
if (!unit8Array) {
this.finishStream()
return
}
const audioData = this.byteArrayToArrayBuffer(unit8Array)
if (!audioData.byteLength) {
this.finishStream()
if (this.mediaSource?.readyState === 'open') this.finishStream()
return
}
this.cacheBuffers.push(audioData)
this.flushBuffers()
}
private finishBlobAudio() {
if (!this.cacheBuffers.length) {
if (!this.objectUrl) this.isLoadData = false
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)
}
}
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,27 +46,6 @@ 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-describedby' | 'aria-label' | 'autoFocus' | 'disabled'>
} & Pick<InputProps, 'aria-label' | 'autoFocus'>
export function SearchInput({
ref,
@@ -20,8 +20,6 @@ export function SearchInput({
value,
onValueChange,
autoFocus,
disabled,
'aria-describedby': ariaDescribedBy,
'aria-label': ariaLabel,
}: SearchInputProps) {
const { t } = useTranslation()
@@ -50,7 +48,6 @@ export function SearchInput({
ref={inputRef}
type="search"
name="query"
aria-describedby={ariaDescribedBy}
aria-label={ariaLabel ?? t(($) => $['operation.search'], { ns: 'common' })}
className={cn(
'ps-7',
@@ -59,7 +56,6 @@ export function SearchInput({
)}
placeholder={placeholder ?? t(($) => $['operation.search'], { ns: 'common' })}
value={inputValue}
disabled={disabled}
onValueChange={(nextValue) => {
if (isComposingRef.current) {
setCompositionValue(nextValue)
@@ -94,7 +90,7 @@ export function SearchInput({
autoFocus={autoFocus}
enterKeyHint="search"
/>
{!!inputValue && !disabled && (
{!!inputValue && (
<button
type="button"
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
@@ -347,6 +347,7 @@ const ownerWorkspacePermissionKeys = [
'dataset.external.connect',
'tool.manage',
'mcp.manage',
'agent.manage',
]
const datasetOperatorWorkspacePermissionKeys = [
@@ -567,6 +568,23 @@ 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 })
@@ -721,7 +739,7 @@ describe('MainNav', () => {
isCurrentWorkspaceEditor: false,
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
workspacePermissionKeys: ['app_library.access', 'tool.manage'],
workspacePermissionKeys: ['app_library.access', 'tool.manage', 'agent.manage'],
}
renderMainNav({ branding: { enabled: false }, enable_app_deploy: true })
@@ -178,41 +178,23 @@ describe('MainNavLayout', () => {
},
)
it('ignores a retained legacy detail sidebar on New Knowledge routes', () => {
;(usePathname as Mock).mockReturnValue('/datasets/new/knowledge-1/sources')
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)
render(
<MainNavLayout
detailSidebar={<aside aria-label="Legacy dataset sidebar">Legacy dataset sidebar</aside>}
>
<div>new knowledge detail</div>
</MainNavLayout>,
)
render(
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
<div>content</div>
</MainNavLayout>,
)
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()
})
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
expect(
screen.queryByRole('complementary', { name: 'Detail sidebar' }),
).not.toBeInTheDocument()
},
)
it.each([
{
+4
View File
@@ -16,6 +16,7 @@ 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'
@@ -37,6 +38,7 @@ 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'
@@ -47,6 +49,7 @@ export function MainNav({ className }: MainNavProps) {
MAIN_NAV_ROUTES.filter((route) =>
isMainNavRouteVisible(route, {
agentV2Enabled,
canManageAgents,
canUseAppDeploy,
isCurrentWorkspaceDatasetOperator,
marketplaceEnabled: systemFeatures.enable_marketplace,
@@ -60,6 +63,7 @@ export function MainNav({ className }: MainNavProps) {
})),
[
agentV2Enabled,
canManageAgents,
canUseAppDeploy,
isCurrentWorkspaceDatasetOperator,
systemFeatures.enable_marketplace,
+3 -4
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 { shouldHideMainNavigation, shouldUseDetailSidebar } from './routes'
import { shouldUseDetailSidebar } from './routes'
import { MAIN_CONTENT_ID, SkipNav } from './skip-nav'
type MainNavLayoutProps = {
@@ -47,8 +47,7 @@ const MainNavLayout = ({ children, detailSidebar }: MainNavLayoutProps) => {
const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom)
const isCurrentWorkspaceEditor = useAtomValue(isCurrentWorkspaceEditorAtom)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const hideMainNavigation = shouldHideMainNavigation(pathname)
const useDetailSidebar = shouldUseDetailSidebar(pathname, {
const shouldHideMainNav = shouldUseDetailSidebar(pathname, {
agentV2Enabled: isAgentV2Enabled(),
canUseAppDeploy: isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy,
isCurrentWorkspaceDatasetOperator,
@@ -58,7 +57,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 />
{hideMainNavigation ? null : useDetailSidebar ? detailSidebar : <MainNav />}
{shouldHideMainNav ? detailSidebar : <MainNav />}
<main
id={MAIN_CONTENT_ID}
tabIndex={-1}
+14 -26
View File
@@ -1,6 +1,6 @@
import { buildIntegrationPath } from '@/app/components/integrations/routes'
type MainNavRouteVisibility = 'all' | 'notDatasetOperator' | 'appDeployEditor'
type MainNavRouteVisibility = (options: MainNavRouteVisibilityOptions) => boolean
const DATASET_COLLECTION_ROUTES = new Set(['create', 'create-from-pipeline', 'connect'])
const DATASET_DOCUMENT_CREATION_ROUTES = new Set(['create', 'create-from-pipeline'])
@@ -18,6 +18,7 @@ export type MainNavRouteConfig = {
export type MainNavRouteVisibilityOptions = {
agentV2Enabled: boolean
canManageAgents: boolean
canUseAppDeploy: boolean
isCurrentWorkspaceDatasetOperator: boolean
marketplaceEnabled: boolean
@@ -28,6 +29,10 @@ 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}/`)
}
@@ -40,7 +45,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: 'all',
visibility: VISIBLE_TO_ALL,
},
{
key: 'apps',
@@ -52,7 +57,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: 'all',
visibility: VISIBLE_TO_ALL,
},
{
key: 'roster',
@@ -61,7 +66,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: 'notDatasetOperator',
visibility: CAN_MANAGE_AGENTS,
feature: 'agentV2',
},
{
@@ -71,7 +76,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: 'all',
visibility: VISIBLE_TO_ALL,
},
{
key: 'integrations',
@@ -81,7 +86,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: 'all',
visibility: VISIBLE_TO_ALL,
},
{
key: 'marketplace',
@@ -91,7 +96,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: 'all',
visibility: VISIBLE_TO_ALL,
feature: 'marketplace',
},
{
@@ -101,7 +106,7 @@ export const MAIN_NAV_ROUTES = [
active: (path: string) => isPathUnderRoute(path, '/deployments'),
icon: 'i-ri-rocket-line',
activeIcon: 'i-ri-rocket-fill',
visibility: 'appDeployEditor',
visibility: CAN_USE_APP_DEPLOY,
},
] as const satisfies readonly MainNavRouteConfig[]
@@ -113,11 +118,7 @@ export function isMainNavRouteVisible(
if (route.feature === 'marketplace' && !options.marketplaceEnabled) return false
if (route.visibility === 'all') return true
if (route.visibility === 'notDatasetOperator') return !options.isCurrentWorkspaceDatasetOperator
return options.canUseAppDeploy
return route.visibility(options)
}
function isAppDetailPathname(pathname: string) {
@@ -131,25 +132,12 @@ 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)
@@ -0,0 +1,90 @@
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,6 +50,12 @@ 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,6 +21,7 @@ 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'
@@ -60,9 +61,13 @@ export function AgentSelectorContent({
staleTime: 0,
})
const agents = agentsQuery.data?.data ?? []
const actionOptions: AgentSelectorActionOption[] = onStartFromScratch
? ['start-from-scratch', 'manage-in-agent-console']
: ['manage-in-agent-console']
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 options: AgentSelectorOption[] = [...agents, ...actionOptions]
const getOptionLabel = (option: AgentSelectorOption) => {
if (isAgentSelectorActionOption(option)) {
@@ -150,11 +155,13 @@ export function AgentSelectorContent({
</>
)}
</div>
<div role="presentation" className="border-t border-divider-subtle p-1">
{actionOptions.map((option) => (
<AgentSelectorActionItem key={option} option={option} />
))}
</div>
{actionOptions.length > 0 && (
<div role="presentation" className="border-t border-divider-subtle p-1">
{actionOptions.map((option) => (
<AgentSelectorActionItem key={option} option={option} />
))}
</div>
)}
</ComboboxList>
</Combobox>
</div>
@@ -147,6 +147,10 @@ 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,6 +22,12 @@ 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,
@@ -411,6 +417,7 @@ function createInlineComposerState({
describe('WorkflowInlineAgentConfigureWorkspace', () => {
beforeEach(() => {
vi.clearAllMocks()
permission.canManageAgents = true
mocks.loadBuildDraft.mockRejectedValue(new Response(null, { status: 404 }))
mocks.checkoutBuildDraft.mockResolvedValue({
agent_soul: {},
@@ -508,6 +515,19 @@ 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,10 +3,39 @@ 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)
@@ -32,6 +61,37 @@ 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()
@@ -0,0 +1,21 @@
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,6 +62,7 @@ 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'
@@ -731,6 +732,9 @@ 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 { getAgentDetailPath } from '@/features/agent-v2/agent-detail/routes'
import Link from '@/next/link'
import { useCanManageAgents } from '@/features/agent-v2/permissions'
import { EditInConsoleLink } from './edit-in-console-link'
const i18nPrefix = 'nodes.agent'
type AgentRosterDrawerMode = 'setup' | 'detail'
@@ -122,6 +122,7 @@ function AgentRosterDrawer({
onClose: () => void
}) {
const { t } = useTranslation()
const canManageAgents = useCanManageAgents()
const isSetup = mode === 'setup'
const title = isInlineSetup
? t(($) => $[`${i18nPrefix}.roster.inlineSetup.name`], { ns: 'workflow' })
@@ -129,7 +130,7 @@ function AgentRosterDrawer({
const description = isSetup
? t(($) => $[`${i18nPrefix}.roster.inlineSetup.description`], { ns: 'workflow' })
: agent.role
const showInlineActions = isInlineSetup && !!onSaveInlineToRoster
const showInlineActions = isInlineSetup && !!onSaveInlineToRoster && canManageAgents
return (
<Drawer
@@ -251,17 +252,7 @@ function AgentRosterDrawer({
{!isSetup && showDetailActions && (
<div className="flex h-8 gap-2 pl-1">
{showConsoleLink && (
<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>
<EditInConsoleLink agentId={agent.id} canManageAgents={canManageAgents} />
)}
<Button
variant="secondary"
@@ -0,0 +1,56 @@
'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
@@ -0,0 +1,59 @@
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,6 +29,7 @@ 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,6 +14,7 @@ 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,6 +82,10 @@ 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.
@@ -254,6 +258,9 @@ 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.
@@ -0,0 +1,33 @@
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()
})
})
@@ -0,0 +1,53 @@
'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,31 +27,7 @@ export function AgentOrchestrateHeader({
<h2 id={headingId} className="truncate title-xl-semi-bold text-text-primary">
{t(($) => $['agentDetail.configure.title'])}
</h2>
<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>
<CommunityEditionTip tip={communityEditionIsolationTip} popupClassName="max-w-[320px]" />
{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,31 +53,11 @@ function AgentBuildChatEmptyState() {
<div className="min-w-0 truncate system-md-medium text-text-secondary">
{t(($) => $['agentDetail.configure.build.empty.title'])}
</div>
<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>
<CommunityEditionTip
tip={communityEditionBuildModeTip}
placement="top"
popupClassName="max-w-[340px]"
/>
</div>
<p className="mt-1 max-w-full body-md-regular text-text-tertiary">
{t(($) => $['agentDetail.configure.build.empty.description'])}
+13
View File
@@ -0,0 +1,13 @@
'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)
}
+2 -4
View File
@@ -4,17 +4,15 @@ 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/base/corner-label`
- `app/components/apps/first-empty-state/action-card`
- `app/components/base/infotip`
- `app/components/base/search-input`
- `app/components/base/skeleton`
- `app/components/datasets/external-api/external-api-panel`
@@ -1,536 +0,0 @@
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',
)
})
})
@@ -1,60 +0,0 @@
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'))
})
})
@@ -1,154 +0,0 @@
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('links real knowledge spaces to the new detail shell', () => {
it('renders real knowledge spaces as unavailable until the detail contract is supported', () => {
setResolvedPage([
{
createdAt: '2026-07-15T00:00:00Z',
@@ -172,16 +172,15 @@ describe('NewKnowledgeList', () => {
renderWithNuqs(<NewKnowledgeList view="new" onViewChange={vi.fn()} />)
const list = screen.getByRole('list', { name: 'dataset.knowledge' })
const supportCard = within(list).getByRole('link', {
name: 'Support knowledge',
const supportCard = within(list).getByRole('article', {
name: 'Support knowledge. dataset.cornerLabel.unavailable',
})
expect(supportCard).toHaveAttribute('href', '/datasets/new/space-1/sources')
expect(supportCard).toBeInTheDocument()
expect(
within(list).getByRole('link', {
name: 'Engineering handbook',
within(list).getByRole('article', {
name: 'Engineering handbook. dataset.cornerLabel.unavailable',
}),
).toHaveAttribute('href', '/datasets/new/space-2/sources')
).toBeInTheDocument()
expect(within(list).getByText('Answers for customer support')).toBeInTheDocument()
expect(within(list).getByText('dataset.newKnowledge.noDescription')).toBeInTheDocument()
expect(within(supportCard).getByLabelText('camera')).toBeInTheDocument()
@@ -189,6 +188,7 @@ 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,21 +225,22 @@ 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('link', { name: 'common.operation.create' })
const create = screen.getByRole('button', { 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).toHaveAttribute('href', '/datasets/new/create')
expect(create).toBeDisabled()
expect(create).toHaveAccessibleDescription('dataset.cornerLabel.unavailable')
await user.type(search, 'customer support')
expect(screen.getByText('Support knowledge')).toBeInTheDocument()
expect(screen.queryByText('Engineering handbook')).not.toBeInTheDocument()
})
it('keeps stacked creation modes disabled while start empty remains available', () => {
it('shows unavailable empty-state creation entries to authorized users', () => {
setResolvedPage()
renderWithNuqs(<NewKnowledgeList view="new" onViewChange={vi.fn()} />)
@@ -250,7 +251,7 @@ describe('NewKnowledgeList', () => {
const uploadFiles = screen.getByRole('button', {
name: 'dataset.newKnowledge.uploadFiles',
})
const startEmpty = screen.getByRole('link', {
const startEmpty = screen.getByRole('button', {
name: 'dataset.newKnowledge.startEmpty',
})
@@ -262,8 +263,10 @@ describe('NewKnowledgeList', () => {
expect(uploadFiles).toHaveAccessibleDescription(
'dataset.newKnowledge.uploadFilesDescription dataset.cornerLabel.unavailable',
)
expect(startEmpty).toHaveAttribute('href', '/datasets/new/create?start=empty')
expect(startEmpty).toHaveAccessibleDescription('dataset.newKnowledge.startEmptyDescription')
expect(startEmpty).toBeDisabled()
expect(startEmpty).toHaveAccessibleDescription(
'dataset.newKnowledge.startEmptyDescription dataset.cornerLabel.unavailable',
)
expect(screen.getByText('dataset.newKnowledge.connectSourceDescription')).toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.uploadFilesDescription')).toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.startEmptyDescription')).toBeInTheDocument()
@@ -271,19 +274,6 @@ 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()
@@ -291,10 +281,10 @@ describe('NewKnowledgeList', () => {
renderWithNuqs(<NewKnowledgeList view="new" onViewChange={vi.fn()} />)
expect(
screen.queryByRole('link', { name: /^dataset\.newKnowledge\.startEmpty/ }),
screen.queryByRole('button', { name: /^dataset\.newKnowledge\.startEmpty/ }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('link', { name: /common\.operation\.create/ }),
screen.queryByRole('button', { name: /common\.operation\.create/ }),
).not.toBeInTheDocument()
expect(screen.getByText('dataset.newKnowledge.readOnlyEmpty')).toBeInTheDocument()
})
@@ -1,95 +0,0 @@
'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>
)
}
@@ -1,71 +0,0 @@
'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,11 +20,15 @@ export function KnowledgeSpaceCard({ knowledgeSpace }: { knowledgeSpace: Knowled
return (
<li>
<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"
<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"
>
<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'])}
@@ -39,12 +43,14 @@ 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">
<div className="min-w-0 flex-1 py-px pr-16">
<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>
@@ -80,7 +86,7 @@ export function KnowledgeSpaceCard({ knowledgeSpace }: { knowledgeSpace: Knowled
})}
</span>
</div>
</Link>
</article>
</li>
)
}
@@ -1,13 +1,8 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
import CornerLabel from '@/app/components/base/corner-label'
import FirstEmptyActionCard from '@/app/components/apps/first-empty-state/action-card'
import { Infotip } from '@/app/components/base/infotip'
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
import Link from '@/next/link'
import { newKnowledgeCreatePathWithStartMode } from '../routes'
const LOADING_CARD_IDS = [
'loading-card-1',
@@ -25,6 +20,22 @@ 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')
@@ -75,13 +86,11 @@ export function NewKnowledgePageState({
function EmptyAction({
description,
href,
iconClassName,
recommended = false,
title,
}: {
description: string
href?: string
iconClassName: string
recommended?: boolean
title: string
@@ -89,88 +98,18 @@ 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 (
<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>
<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"
/>
)
}
@@ -211,7 +150,7 @@ export function NewKnowledgeEmptyState({
canCreate: boolean
}) {
const { t } = useTranslation('dataset')
const canStart = canCreate
const canStart = canConnect || 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">
@@ -258,7 +197,6 @@ export function NewKnowledgeEmptyState({
iconClassName="i-ri-folder-6-line"
title={t(($) => $['newKnowledge.startEmpty'])}
description={t(($) => $['newKnowledge.startEmptyDescription'])}
href={newKnowledgeCreatePathWithStartMode('empty')}
/>
</>
)}
@@ -1,434 +0,0 @@
'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>
)
}
@@ -1,90 +0,0 @@
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
}
@@ -1,20 +0,0 @@
'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