Compare commits

..
Author SHA1 Message Date
Stephen Zhou 7d1e583978 Merge branch '0721-document-detail' into 0721-happy-path-smoke 2026-07-23 15:56:51 +08:00
Stephen Zhou 825b749501 Merge commit 'fc5dcb10d96d84a970abe4e7e2a5bf010dff9839' into HEAD 2026-07-23 15:50:52 +08:00
Stephen Zhou fc5dcb10d9 Merge commit '7c20436611b8e30a87da1e2d1a9cf0385f33b350' into HEAD 2026-07-23 15:50:29 +08:00
Stephen Zhou 50915d4b57 Merge branch '0720-crawl-selection' into 0720-documents-tasks 2026-07-23 15:50:28 +08:00
Stephen Zhou 7c20436611 fix(dataset): restore New Knowledge list path 2026-07-23 15:48:16 +08:00
Stephen Zhou bde17dc8b0 Merge branch '0720-crawl-preview' into 0720-crawl-selection 2026-07-23 15:39:42 +08:00
Stephen Zhou a489dd48cf Merge branch '0720-source-connections' into 0720-crawl-preview 2026-07-23 15:39:04 +08:00
Stephen Zhou f71e31351d Merge branch 'main' into 0720-source-connections 2026-07-23 15:38:26 +08:00
Stephen ZhouGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2a0661a769 feat(dataset): add New Knowledge creation flow (#39315)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-23 07:25:14 +00:00
euxGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
00fe96325d fix(web): support TTS playback in Safari and Firefox (#39444)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-23 06:36:52 +00:00
非法操作andGitHub a3c18c561e chore: improve lambda plugin runtime error display (#39437) 2026-07-23 06:05:14 +00:00
Stephen Zhou 2a12c2fa29 Merge branch '0721-document-detail' into 0721-happy-path-smoke 2026-07-22 17:59:55 +08:00
Stephen Zhou 84d9399093 fix(dataset): align New RAG document detail states
Fixes WTA-1459
2026-07-22 17:59:33 +08:00
Stephen Zhou 041960d9c7 Merge branch '0720-documents-tasks' into 0721-document-detail
# Conflicts:
#	web/features/new-rag/routes.ts
#	web/i18n/ar-TN/dataset.json
#	web/i18n/de-DE/dataset.json
#	web/i18n/en-US/dataset.json
#	web/i18n/es-ES/dataset.json
#	web/i18n/fa-IR/dataset.json
#	web/i18n/fr-FR/dataset.json
#	web/i18n/hi-IN/dataset.json
#	web/i18n/id-ID/dataset.json
#	web/i18n/it-IT/dataset.json
#	web/i18n/ja-JP/dataset.json
#	web/i18n/ko-KR/dataset.json
#	web/i18n/nl-NL/dataset.json
#	web/i18n/pl-PL/dataset.json
#	web/i18n/pt-BR/dataset.json
#	web/i18n/ro-RO/dataset.json
#	web/i18n/ru-RU/dataset.json
#	web/i18n/sl-SI/dataset.json
#	web/i18n/th-TH/dataset.json
#	web/i18n/tr-TR/dataset.json
#	web/i18n/uk-UA/dataset.json
#	web/i18n/vi-VN/dataset.json
#	web/i18n/zh-Hans/dataset.json
#	web/i18n/zh-Hant/dataset.json
2026-07-22 17:56:25 +08:00
Stephen Zhou 35a5bf21ee fix(dataset): complete New RAG upload and document states
Fixes WTA-1459
2026-07-22 17:55:33 +08:00
Stephen Zhou 4b4b109bbb Merge branch '0720-crawl-selection' into 0720-documents-tasks
# Conflicts:
#	web/i18n/ar-TN/dataset.json
#	web/i18n/de-DE/dataset.json
#	web/i18n/en-US/dataset.json
#	web/i18n/es-ES/dataset.json
#	web/i18n/fa-IR/dataset.json
#	web/i18n/fr-FR/dataset.json
#	web/i18n/hi-IN/dataset.json
#	web/i18n/id-ID/dataset.json
#	web/i18n/it-IT/dataset.json
#	web/i18n/ja-JP/dataset.json
#	web/i18n/ko-KR/dataset.json
#	web/i18n/nl-NL/dataset.json
#	web/i18n/pl-PL/dataset.json
#	web/i18n/pt-BR/dataset.json
#	web/i18n/ro-RO/dataset.json
#	web/i18n/ru-RU/dataset.json
#	web/i18n/sl-SI/dataset.json
#	web/i18n/th-TH/dataset.json
#	web/i18n/tr-TR/dataset.json
#	web/i18n/uk-UA/dataset.json
#	web/i18n/vi-VN/dataset.json
#	web/i18n/zh-Hans/dataset.json
#	web/i18n/zh-Hant/dataset.json
2026-07-22 17:55:07 +08:00
Stephen Zhou 8e6eb0eaae fix(dataset): preserve New RAG source setup states
Fixes WTA-1459
2026-07-22 17:54:17 +08:00
Stephen Zhou fe67e094aa Merge branch '0720-crawl-preview' into 0720-crawl-selection 2026-07-22 17:53:30 +08:00
Stephen Zhou 919a8090c3 Merge branch '0720-source-connections' into 0720-crawl-preview 2026-07-22 17:53:20 +08:00
Stephen Zhou d7e1b2a996 Merge branch '0720-create-knowledge' into 0720-source-connections 2026-07-22 17:53:09 +08:00
Stephen Zhou 3f99f16bd0 Merge branch '0720-new-rag-contracts' into 0720-create-knowledge 2026-07-22 17:53:00 +08:00
Stephen Zhou 2da74263d7 Merge branch '0720-new-rag-list' into 0720-new-rag-contracts 2026-07-22 17:52:51 +08:00
Stephen Zhou bc8ae85b88 test(dataset): clarify New Knowledge filter coverage 2026-07-22 17:52:39 +08:00
Stephen Zhou e3a53c8822 Merge branch '0720-new-rag-contracts' into 0720-create-knowledge
# Conflicts:
#	web/features/new-rag/new-knowledge-list.tsx
2026-07-22 17:50:09 +08:00
Stephen Zhou 0ad1631c4a Merge branch '0720-new-rag-list' into 0720-new-rag-contracts 2026-07-22 17:49:31 +08:00
Stephen Zhou 513ea8b5fa fix(dataset): clarify New Knowledge filter boundary 2026-07-22 17:49:12 +08:00
Stephen Zhou 2107c9b9c7 Merge branch '0721-document-detail' into 0721-happy-path-smoke 2026-07-22 16:48:34 +08:00
Stephen Zhou 6e34915c7a fix(dataset): guard missing document revisions
Fixes WTA-1459
2026-07-22 16:48:16 +08:00
Stephen Zhou cea92da293 Merge branch '0721-document-detail' into 0721-happy-path-smoke 2026-07-22 11:58:34 +08:00
Stephen Zhou 5a6532c74e Merge branch '0720-documents-tasks' into 0721-document-detail 2026-07-22 11:58:24 +08:00
Stephen Zhou e6cc04fc36 Merge branch '0720-crawl-selection' into 0720-documents-tasks 2026-07-22 11:58:15 +08:00
Stephen Zhou 3738920820 Merge branch '0720-crawl-preview' into 0720-crawl-selection 2026-07-22 11:57:59 +08:00
Stephen Zhou e98bbea93a Merge branch '0720-source-connections' into 0720-crawl-preview
# Conflicts:
#	web/features/new-rag/add-source-page.tsx
2026-07-22 11:57:50 +08:00
Stephen Zhou a86d413ed9 fix(dataset): clarify unavailable source feedback 2026-07-22 11:57:18 +08:00
Stephen Zhou e9c1147146 Merge branch '0721-document-detail' into 0721-happy-path-smoke 2026-07-22 11:49:37 +08:00
Stephen Zhou 9d922114d5 Merge branch '0720-documents-tasks' into 0721-document-detail 2026-07-22 11:48:37 +08:00
Stephen Zhou ccdb1249d2 fix(dataset): keep document bulk actions interactive
Fixes WTA-1459
2026-07-22 11:48:14 +08:00
Stephen Zhou 831acbb34d Merge branch '0720-crawl-selection' into 0720-documents-tasks 2026-07-22 11:46:30 +08:00
Stephen Zhou 8239658259 Merge branch '0720-crawl-preview' into 0720-crawl-selection
# Conflicts:
#	web/features/new-rag/__tests__/add-source-page.spec.tsx
#	web/features/new-rag/__tests__/sources-page.spec.tsx
#	web/features/new-rag/add-source-page.tsx
2026-07-22 11:46:08 +08:00
Stephen Zhou 6ac0aeeb2a Merge branch '0720-source-connections' into 0720-crawl-preview
# Conflicts:
#	web/features/new-rag/add-source-page.tsx
2026-07-22 11:44:01 +08:00
Stephen Zhou 7c8037f496 fix(dataset): keep source controls interactive
Fixes WTA-1459
2026-07-22 11:39:31 +08:00
Stephen Zhou 5b14e29c08 Merge branch '0720-create-knowledge' into 0720-source-connections 2026-07-22 11:36:41 +08:00
Stephen Zhou 566359fefc fix(dataset): keep New Knowledge creation modes interactive
Fixes WTA-1459
2026-07-22 11:36:10 +08:00
Stephen Zhou b65c51f5f5 Merge branch '0720-new-rag-contracts' into 0720-create-knowledge
# Conflicts:
#	web/features/new-rag/__tests__/new-knowledge-list.spec.tsx
#	web/features/new-rag/new-knowledge-list.tsx
2026-07-22 11:33:22 +08:00
Stephen Zhou 68a5c97baf Merge branch '0720-new-rag-list' into 0720-new-rag-contracts 2026-07-22 11:32:06 +08:00
Stephen Zhou f60a42f781 fix(dataset): keep New Knowledge list interactive
Fixes WTA-1459
2026-07-22 11:31:43 +08:00
Stephen Zhou 54a334303f fix(dataset): align New Knowledge creation modal 2026-07-22 10:43:19 +08:00
Stephen Zhou 6190202a4e fix(api): allow KnowledgeFS console headers 2026-07-22 09:27:51 +08:00
Stephen Zhou f0961f7484 Merge branch '0721-document-detail' into 0721-happy-path-smoke 2026-07-22 09:27:16 +08:00
Stephen Zhou a002efaac5 fix(dataset): hide internal source identifiers 2026-07-22 09:26:46 +08:00
Stephen Zhou d7aa59e72d Merge branch '0720-documents-tasks' into 0721-document-detail 2026-07-22 09:26:33 +08:00
Stephen Zhou 75a9a67947 fix(dataset): make document list responsive 2026-07-22 09:26:06 +08:00
Stephen Zhou 10495a7103 Merge branch '0720-crawl-selection' into 0720-documents-tasks 2026-07-22 09:25:53 +08:00
Stephen Zhou de146c4efe fix(dataset): finalize source imports before navigation 2026-07-22 09:25:27 +08:00
Stephen Zhou 6a7aa2222c Merge branch '0720-crawl-preview' into 0720-crawl-selection 2026-07-22 09:25:04 +08:00
Stephen Zhou aeafe69626 fix(dataset): recognize crawl preview readiness 2026-07-22 09:24:37 +08:00
Stephen Zhou 1fc1ac16d0 Merge branch '0720-source-connections' into 0720-crawl-preview 2026-07-22 09:24:22 +08:00
Stephen Zhou 9fb67fade9 fix(dataset): harden source connection states 2026-07-22 09:23:54 +08:00
Stephen Zhou de56f03784 Merge branch '0720-create-knowledge' into 0720-source-connections 2026-07-22 09:23:45 +08:00
Stephen Zhou 558d0a3af3 fix(dataset): align New Knowledge shell 2026-07-22 09:23:18 +08:00
Stephen Zhou 301a32e7c0 Merge branch '0720-new-rag-contracts' into 0720-create-knowledge 2026-07-22 09:23:03 +08:00
Stephen Zhou cff84914bd Merge branch '0720-new-rag-list' into 0720-new-rag-contracts 2026-07-22 09:22:40 +08:00
Stephen Zhou 833bdc4b70 fix(dataset): polish New Knowledge cards 2026-07-22 09:22:15 +08:00
Stephen Zhou e50daa9081 fix(e2e): preserve New RAG smoke state 2026-07-21 19:23:27 +08:00
Stephen Zhou 01f03c5f7a test(e2e): expand New RAG release smoke 2026-07-21 19:06:29 +08:00
Stephen Zhou f3cbfacd3e fix(e2e): isolate New RAG smoke runs 2026-07-21 18:44:45 +08:00
Stephen Zhou 268b8e91d2 test(e2e): add New RAG release smoke 2026-07-21 18:36:01 +08:00
Stephen Zhou 0a31eb298b feat(dataset): add document detail 2026-07-21 17:02:56 +08:00
Stephen Zhou 18a888b6bb fix(dataset): scope task guard retirement 2026-07-21 14:48:38 +08:00
Stephen Zhou 49634eb302 fix(dataset): retire confirmed task read guards 2026-07-21 14:41:03 +08:00
Stephen Zhou 9daa6a876b fix(dataset): keep guarded task recovery live 2026-07-21 14:36:41 +08:00
Stephen Zhou 828a6f02dd fix(dataset): reconcile guarded task lifecycles 2026-07-21 14:26:03 +08:00
Stephen Zhou c68e2bdc60 fix(dataset): keep task read versions monotonic 2026-07-21 14:07:48 +08:00
Stephen Zhou d9ab09c303 fix(dataset): preserve task stream version ordering 2026-07-21 13:52:00 +08:00
Stephen Zhou a6bc237d34 fix(dataset): block current task stream version 2026-07-21 13:44:21 +08:00
Stephen Zhou 2df867ad1d fix(dataset): unify task read denial recovery 2026-07-21 13:37:38 +08:00
Stephen Zhou 5ffe4d8ab7 fix(dataset): guard auxiliary task reads 2026-07-21 13:24:34 +08:00
Stephen Zhou ef050944cf fix(dataset): recover document write access 2026-07-21 13:06:50 +08:00
Stephen Zhou 225c6e5d30 fix(dataset): clarify unavailable document actions 2026-07-21 12:52:52 +08:00
Stephen Zhou 0f77618fcc fix(dataset): preserve task access feedback 2026-07-21 12:44:04 +08:00
Stephen Zhou 018640d069 fix(dataset): harden task reconciliation 2026-07-21 12:29:44 +08:00
Stephen Zhou 1f8a8dfc4d fix(dataset): reconcile permission mutations 2026-07-21 12:14:50 +08:00
Stephen Zhou ca9a771985 fix(dataset): scope permission recovery 2026-07-21 12:00:54 +08:00
Stephen Zhou f6e5cdaf28 fix(dataset): retain recovery focus state 2026-07-21 11:41:43 +08:00
Stephen Zhou 2c88ae3ea2 fix(dataset): preserve recovery focus 2026-07-21 11:32:32 +08:00
Stephen Zhou ca36ba5585 fix(dataset): stabilize recovery controls 2026-07-21 11:19:37 +08:00
Stephen Zhou 8a0438825c fix(dataset): harden permission lifecycle 2026-07-21 11:06:32 +08:00
Stephen Zhou 737ce40c0d fix(dataset): isolate task lifecycle feedback 2026-07-21 10:54:24 +08:00
Stephen Zhou 23946300ae fix(dataset): complete task recovery feedback 2026-07-21 10:43:50 +08:00
Stephen Zhou 9ea92a2528 fix(dataset): synchronize task recovery state 2026-07-21 10:30:23 +08:00
Stephen Zhou 7b74b0434c fix(dataset): preserve incomplete task history 2026-07-21 10:19:08 +08:00
Stephen Zhou 4aaf347798 fix(dataset): complete task history recovery 2026-07-21 10:03:19 +08:00
Stephen Zhou 63673e2243 fix(dataset): reject obsolete task updates 2026-07-21 09:42:13 +08:00
Stephen Zhou 3c512de30d fix(dataset): continue task pagination in drawer 2026-07-21 09:38:14 +08:00
Stephen Zhou 66e3afb850 fix(dataset): cancel obsolete task reconciliation 2026-07-21 09:35:56 +08:00
Stephen Zhou 9f43e962ef fix(dataset): preserve task snapshot authority 2026-07-21 09:33:10 +08:00
Stephen Zhou 05e06c88ee fix(dataset): keep task actions reachable 2026-07-21 09:29:18 +08:00
Stephen Zhou 1753d256c2 fix(dataset): preserve unresolved source state 2026-07-21 09:18:44 +08:00
Stephen Zhou c684679ce9 fix(dataset): harden document task recovery 2026-07-21 09:16:49 +08:00
Stephen Zhou 2231289f6a fix(dataset): retry document cursor failures 2026-07-21 09:03:34 +08:00
Stephen Zhou cbd48e9d7e fix(dataset): stabilize document task monitoring 2026-07-21 08:59:20 +08:00
Stephen Zhou f6b19fbfa8 fix(dataset): bound document task refreshes 2026-07-21 08:45:20 +08:00
Stephen Zhou 518a4a7226 fix(dataset): clarify document task failures 2026-07-21 08:30:29 +08:00
Stephen Zhou 6444ff8d93 fix(dataset): rotate document task monitoring 2026-07-21 08:30:18 +08:00
Stephen Zhou f60575bd1d fix(dataset): bound document task observers 2026-07-21 08:12:59 +08:00
Stephen Zhou 5bd7664d1e fix(dataset): localize document task feedback 2026-07-21 08:01:01 +08:00
Stephen Zhou e6d0e71f89 fix(dataset): reconcile delayed task retries 2026-07-21 08:00:49 +08:00
Stephen Zhou 1e539101e9 fix(dataset): reconcile exact task snapshots 2026-07-21 07:44:34 +08:00
Stephen Zhou 6732cadc0f fix(dataset): align terminal task contracts 2026-07-21 07:28:59 +08:00
Stephen Zhou 551885dc10 fix(dataset): honor task and cursor ordering 2026-07-21 07:17:35 +08:00
Stephen Zhou ccf62b17f1 fix(dataset): reconcile external task retries 2026-07-21 07:05:27 +08:00
Stephen Zhou 942b7a4796 fix(dataset): align document authorization states 2026-07-21 06:57:36 +08:00
Stephen Zhou 7481cd2e7f fix(dataset): stabilize document task hydration 2026-07-21 06:42:01 +08:00
Stephen Zhou ab249cf248 fix(dataset): harden document task states 2026-07-21 06:26:10 +08:00
Stephen Zhou c4ee6d3c77 refactor(dataset): split document list owners 2026-07-21 06:06:58 +08:00
Stephen Zhou c2f23edc6a feat(dataset): add document processing tasks 2026-07-21 06:00:56 +08:00
Stephen Zhou 445c36c356 fix(dataset): surface filtered source loading 2026-07-21 05:27:10 +08:00
Stephen Zhou fcb0de7747 fix(dataset): announce source list states 2026-07-21 05:24:50 +08:00
Stephen Zhou 98869def82 fix(dataset): continue past hidden source drafts 2026-07-21 05:21:56 +08:00
Stephen Zhou bc1de7a5d0 fix(dataset): separate policy submission uncertainty 2026-07-21 05:17:26 +08:00
Stephen Zhou 1ffb081a05 fix(dataset): preserve crawl page limit edits 2026-07-21 05:15:56 +08:00
Stephen Zhou 4b658b2028 fix(dataset): coordinate crawl selection transactions 2026-07-21 05:12:51 +08:00
Stephen Zhou 63dc57a70b fix(dataset): describe crawl page availability 2026-07-21 05:04:43 +08:00
Stephen Zhou 532025c46b fix(dataset): separate crawl submission uncertainty 2026-07-21 05:03:05 +08:00
Stephen Zhou e26c85b07e fix(dataset): guard uncertain crawl submissions 2026-07-21 05:01:30 +08:00
Stephen Zhou 5e6382b20e fix(dataset): preserve crawl workflow ordering 2026-07-21 04:45:50 +08:00
Stephen Zhou 1f418ad5d2 fix(dataset): reconcile uncertain crawl workflows 2026-07-21 04:43:41 +08:00
Stephen Zhou cf98fc7ed2 fix(dataset): coordinate crawl retry discard 2026-07-21 04:31:36 +08:00
Stephen Zhou 28762fef0a fix(dataset): close crawl cancellation races 2026-07-21 04:24:43 +08:00
Stephen Zhou f14b666127 fix(dataset): harden crawl source submission 2026-07-21 04:16:08 +08:00
Stephen Zhou 2a43b5a168 feat(dataset): add crawl source selection 2026-07-21 03:59:52 +08:00
Stephen Zhou 5012d4643d test(dataset): assert final crawl ordering 2026-07-21 03:40:31 +08:00
Stephen Zhou fb1a9901d4 test(dataset): cover final crawl pagination 2026-07-21 03:37:47 +08:00
Stephen Zhou ee0a0a5af3 fix(dataset): reconcile final crawl snapshot 2026-07-21 03:34:26 +08:00
Stephen Zhou 820eaa1a63 fix(dataset): finalize crawl preview safeguards 2026-07-21 03:29:40 +08:00
Stephen Zhou 3d3ecb73f6 fix(dataset): harden crawl preview recovery 2026-07-21 03:20:22 +08:00
Stephen Zhou 457247c159 fix(dataset): address crawl preview review 2026-07-21 03:10:55 +08:00
Stephen Zhou 3dbf6b00e3 feat(dataset): add website crawl preview 2026-07-21 02:59:44 +08:00
Stephen Zhou 9c36aed1f8 fix(dataset): surface connection refetch errors 2026-07-21 02:35:29 +08:00
Stephen Zhou b0c8a791ba fix(dataset): keep connection versions monotonic 2026-07-21 02:32:46 +08:00
Stephen Zhou 6a3dcd179a fix(dataset): preserve source error recovery 2026-07-21 02:29:47 +08:00
Stephen Zhou ee867d53ad fix(dataset): resolve source reconciliation edges 2026-07-21 02:27:29 +08:00
Stephen Zhou a440659408 fix(dataset): harden source connection states 2026-07-21 02:22:47 +08:00
Stephen Zhou ad0b90e0d7 fix(dataset): address source connection review 2026-07-21 02:09:16 +08:00
Stephen Zhou 2529973c21 feat(dataset): add source connections 2026-07-21 01:51:01 +08:00
Stephen Zhou 619920c1df fix(dataset): finalize New Knowledge terminal states 2026-07-21 01:29:05 +08:00
Stephen Zhou e8679d1d23 fix(dataset): complete New Knowledge accessibility review 2026-07-21 01:24:17 +08:00
Stephen Zhou 5c99fd1b6b fix(dataset): align New Knowledge recovery and design 2026-07-21 01:16:54 +08:00
Stephen Zhou 45d2f2761b fix(dataset): address New Knowledge review 2026-07-21 01:04:07 +08:00
Stephen Zhou 7101bd46db feat(dataset): add New Knowledge creation flow 2026-07-21 00:49:47 +08:00
Stephen Zhou 897b79980a fix(dataset): harden KnowledgeFS task streaming 2026-07-21 00:34:36 +08:00
Stephen Zhou a9f78ea568 fix(dataset): address KnowledgeFS contract review 2026-07-21 00:26:57 +08:00
Stephen Zhou 0857172091 feat(dataset): expose New RAG KnowledgeFS contracts 2026-07-21 00:07:02 +08:00
Stephen Zhou d5e95cf989 fix(dataset): address new knowledge review findings
Fixes WTA-1460
2026-07-20 23:46:14 +08:00
Stephen Zhou eca1af1b9d fix(dataset): complete new knowledge list acceptance 2026-07-20 23:14:25 +08:00
Stephen Zhou f3f8eb655d fix(dataset): guard unavailable new knowledge actions
Fixes WTA-1459

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

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