fix: add is_cloud_only for templates (#37846)

Co-authored-by: Joel <iamjoel007@gmail.com>
This commit is contained in:
非法操作 2026-06-24 16:29:54 +08:00 committed by GitHub
parent ce6297bed2
commit fe62177ba5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 447 additions and 67 deletions

View File

@ -36,6 +36,9 @@ FILES_ACCESS_TIMEOUT=300
# Collaboration mode toggle
ENABLE_COLLABORATION_MODE=true
# Learn app feature toggle
ENABLE_LEARN_APP=true
# Access token expiration time in minutes
ACCESS_TOKEN_EXPIRE_MINUTES=60

View File

@ -1073,6 +1073,12 @@ class MailConfig(BaseSettings):
default=None,
)
class HomepageConfig(BaseSettings):
"""
Configuration for homepage feature toggles exposed through system features.
"""
ENABLE_TRIAL_APP: bool = Field(
description="Enable trial app",
default=False,
@ -1083,6 +1089,11 @@ class MailConfig(BaseSettings):
default=False,
)
ENABLE_LEARN_APP: bool = Field(
description="Enable Learn App",
default=True,
)
class RagEtlConfig(BaseSettings):
"""
@ -1489,6 +1500,7 @@ class FeatureConfig(
EndpointConfig,
FileAccessConfig,
FileUploadConfig,
HomepageConfig,
HttpConfig,
InnerAPIConfig,
IndexingConfig,

View File

@ -0,0 +1,26 @@
"""add cloud only flag to recommended apps
Revision ID: d9e8f7a6b5c4
Revises: c8f4a6b2d3e1
Create Date: 2026-06-23 18:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "d9e8f7a6b5c4"
down_revision = "c8f4a6b2d3e1"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("recommended_apps", schema=None) as batch_op:
batch_op.add_column(sa.Column("is_cloud_only", sa.Boolean(), server_default=sa.text("false"), nullable=False))
def downgrade():
with op.batch_alter_table("recommended_apps", schema=None) as batch_op:
batch_op.drop_column("is_cloud_only")

View File

@ -925,6 +925,9 @@ class RecommendedApp(TypeBase):
is_learn_dify: Mapped[bool] = mapped_column(
sa.Boolean, nullable=False, server_default=sa.text("false"), default=False
)
is_cloud_only: Mapped[bool] = mapped_column(
sa.Boolean, nullable=False, server_default=sa.text("false"), default=False
)
install_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
language: Mapped[str] = mapped_column(
String(255),

View File

@ -19605,6 +19605,7 @@ Model class for provider system configuration response.
| enable_email_code_login | boolean | | Yes |
| enable_email_password_login | boolean, <br>**Default:** true | | Yes |
| enable_explore_banner | boolean | | Yes |
| enable_learn_app | boolean, <br>**Default:** true | | Yes |
| enable_marketplace | boolean | | Yes |
| enable_social_oauth_login | boolean | | Yes |
| enable_trial_app | boolean | | Yes |

View File

@ -1603,6 +1603,7 @@ Default configuration for form inputs.
| enable_email_code_login | boolean | | Yes |
| enable_email_password_login | boolean, <br>**Default:** true | | Yes |
| enable_explore_banner | boolean | | Yes |
| enable_learn_app | boolean, <br>**Default:** true | | Yes |
| enable_marketplace | boolean | | Yes |
| enable_social_oauth_login | boolean | | Yes |
| enable_trial_app | boolean | | Yes |

View File

@ -181,6 +181,7 @@ class SystemFeatureModel(FeatureResponseModel):
enable_creators_platform: bool = False
enable_trial_app: bool = False
enable_explore_banner: bool = False
enable_learn_app: bool = True
rbac_enabled: bool = False
@ -282,6 +283,7 @@ class FeatureService:
system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != ""
system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP
system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER
system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP
@classmethod
def _fulfill_trial_models_from_env(cls) -> list[str]:

View File

@ -5,6 +5,7 @@ from typing import Any, override
from flask import current_app
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
from services.recommend_app.recommend_app_type import RecommendAppType
@ -25,6 +26,11 @@ class BuildInRecommendAppRetrieval(RecommendAppRetrievalBase):
result = self.fetch_recommended_apps_from_builtin(language)
return result
@override
def get_learn_dify_apps(self, language: str):
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language)
return result
@override
def get_recommend_app_detail(self, app_id: str):
result = self.fetch_recommended_app_detail_from_builtin(app_id)

View File

@ -49,6 +49,11 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase):
result = self.fetch_recommended_apps_from_db(language)
return result
@override
def get_learn_dify_apps(self, language: str) -> RecommendedAppsResultDict:
result = self.fetch_learn_dify_apps_from_db(language)
return result
@override
def get_recommend_app_detail(self, app_id: str) -> RecommendedAppDetailDict | None:
result = self.fetch_recommended_app_detail_from_db(app_id)

View File

@ -6,6 +6,8 @@ class RecommendAppRetrievalBase(Protocol):
def get_recommended_apps_and_categories(self, language: str) -> Any: ...
def get_learn_dify_apps(self, language: str) -> Any: ...
def get_recommend_app_detail(self, app_id: str) -> Any: ...
def get_type(self) -> str: ...

View File

@ -2,15 +2,28 @@ import logging
from typing import Any, override
import httpx
from flask import has_request_context, request
from configs import dify_config
from services.recommend_app.buildin.buildin_retrieval import BuildInRecommendAppRetrieval
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
from services.recommend_app.recommend_app_type import RecommendAppType
logger = logging.getLogger(__name__)
def _current_origin_headers() -> dict[str, str]:
origin = request.headers.get("Origin") if has_request_context() else None
if origin:
return {"Origin": origin}
console_web_url = getattr(dify_config, "CONSOLE_WEB_URL", "")
if not isinstance(console_web_url, str) or not console_web_url:
return {}
return {"Origin": console_web_url}
class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase):
"""
Retrieval recommended app from dify official.
@ -37,6 +50,15 @@ class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase):
result = BuildInRecommendAppRetrieval.fetch_recommended_apps_from_builtin(language)
return result
@override
def get_learn_dify_apps(self, language: str):
try:
result = self.fetch_learn_dify_apps_from_dify_official(language)
except Exception as e:
logger.warning("fetch learn dify apps from dify official failed: %s, switch to database.", e)
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language)
return result
@override
def get_type(self) -> str:
return RecommendAppType.REMOTE
@ -50,7 +72,7 @@ class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase):
"""
domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN
url = f"{domain}/apps/{app_id}"
response = httpx.get(url, timeout=httpx.Timeout(10.0, connect=3.0))
response = httpx.get(url, headers=_current_origin_headers(), timeout=httpx.Timeout(10.0, connect=3.0))
if response.status_code != 200:
return None
data: dict[str, Any] = response.json()
@ -65,9 +87,25 @@ class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase):
"""
domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN
url = f"{domain}/apps?language={language}"
response = httpx.get(url, timeout=httpx.Timeout(10.0, connect=3.0))
response = httpx.get(url, headers=_current_origin_headers(), timeout=httpx.Timeout(10.0, connect=3.0))
if response.status_code != 200:
raise ValueError(f"fetch recommended apps failed, status code: {response.status_code}")
result: dict[str, Any] = response.json()
return result
@classmethod
def fetch_learn_dify_apps_from_dify_official(cls, language: str):
"""
Fetch Learn Dify apps from dify official.
:param language: language
:return:
"""
domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN
url = f"{domain}/apps/learn-dify?language={language}"
response = httpx.get(url, headers=_current_origin_headers(), timeout=httpx.Timeout(10.0, connect=3.0))
if response.status_code != 200:
raise ValueError(f"fetch learn dify apps failed, status code: {response.status_code}")
result: dict[str, Any] = response.json()
return result

View File

@ -6,7 +6,6 @@ from sqlalchemy.orm import scoped_session
from configs import dify_config
from models.model import AccountTrialAppRecord, TrialApp
from services.feature_service import FeatureService
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory
@ -38,11 +37,13 @@ class RecommendedAppService:
@classmethod
def get_learn_dify_apps(cls, session: scoped_session, language: str) -> dict[str, Any]:
"""
Get database-backed recommended apps marked as Learn Dify.
Get recommended apps marked for the Learn Dify section.
:param language: language
:return:
"""
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language)
mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE
retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)()
result = retrieval_instance.get_learn_dify_apps(language)
if FeatureService.get_system_features().enable_trial_app:
for app in result["recommended_apps"]:

View File

@ -267,36 +267,45 @@ class TestRecommendedAppServiceGetDetail:
class TestRecommendedAppServiceGetLearnDifyApps:
def test_returns_database_learn_dify_apps_without_remote_factory(self, monkeypatch: pytest.MonkeyPatch) -> None:
@patch("services.recommended_app_service.FeatureService", autospec=True)
@patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
@patch("services.recommended_app_service.dify_config")
def test_uses_configured_retrieval_source(
self, mock_config: MagicMock, mock_factory_class: MagicMock, mock_feature_service: MagicMock
) -> None:
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
mock_feature_service.get_system_features.return_value = SimpleNamespace(enable_trial_app=False)
expected_app = RecommendedAppPayload(app_id="app-1", category="Workflow")
mock_database_retrieval = MagicMock()
mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = {
mock_instance = MagicMock()
mock_instance.get_learn_dify_apps.return_value = {
"recommended_apps": [expected_app],
"categories": ["Workflow"],
}
monkeypatch.setattr(service_module, "DatabaseRecommendAppRetrieval", mock_database_retrieval)
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(return_value=SimpleNamespace(enable_trial_app=False)),
)
factory_mock = MagicMock()
monkeypatch.setattr(service_module.RecommendAppRetrievalFactory, "get_recommend_app_factory", factory_mock)
mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance)
result = RecommendedAppService.get_learn_dify_apps(db.session, "en-US")
assert result == {"recommended_apps": [expected_app]}
mock_database_retrieval.fetch_learn_dify_apps_from_db.assert_called_once_with("en-US")
factory_mock.assert_not_called()
mock_factory_class.get_recommend_app_factory.assert_called_once_with("remote")
mock_instance.get_learn_dify_apps.assert_called_once_with("en-US")
def test_sets_can_trial_when_trial_feature_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None:
@patch("services.recommended_app_service.dify_config")
def test_sets_can_trial_when_trial_feature_enabled(
self, mock_config: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "db"
app = RecommendedAppPayload(app_id="app-1", category="Workflow")
mock_database_retrieval = MagicMock()
mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = {
mock_retrieval_instance = MagicMock()
mock_retrieval_instance.get_learn_dify_apps.return_value = {
"recommended_apps": [app],
"categories": ["Workflow"],
}
monkeypatch.setattr(service_module, "DatabaseRecommendAppRetrieval", mock_database_retrieval)
mock_retrieval_factory = MagicMock(return_value=mock_retrieval_instance)
monkeypatch.setattr(
service_module.RecommendAppRetrievalFactory,
"get_recommend_app_factory",
MagicMock(return_value=mock_retrieval_factory),
)
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",

View File

@ -94,7 +94,7 @@ class TestSystemFeatureApi:
"controllers.console.feature.current_account_with_tenant_optional",
return_value=(account, "tenant-123"),
)
system_features = SystemFeatureModel(is_allow_register=True)
system_features = SystemFeatureModel(is_allow_register=True, enable_learn_app=True)
get_system_features = mocker.patch(
"controllers.console.feature.FeatureService.get_system_features",
return_value=system_features,
@ -104,6 +104,7 @@ class TestSystemFeatureApi:
result = api.get()
assert result == system_features.model_dump()
assert result["enable_learn_app"] is True
current_account.assert_called_once_with()
get_system_features.assert_called_once_with(is_authenticated=True)

View File

@ -42,6 +42,16 @@ class TestBuildInRecommendAppRetrieval:
mock_fetch.assert_called_once_with("en-US")
assert result == {"apps": []}
@patch("services.recommend_app.buildin.buildin_retrieval.DatabaseRecommendAppRetrieval")
def test_get_learn_dify_apps_delegates_to_database(self, mock_database_retrieval):
expected = {"recommended_apps": [{"id": "learn-dify-app"}]}
mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = expected
result = BuildInRecommendAppRetrieval().get_learn_dify_apps("en-US")
assert result == expected
mock_database_retrieval.fetch_learn_dify_apps_from_db.assert_called_once_with("en-US")
def test_get_recommend_app_detail_delegates(self):
with patch.object(
BuildInRecommendAppRetrieval,

View File

@ -1,6 +1,7 @@
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from services.recommend_app.recommend_app_type import RecommendAppType
from services.recommend_app.remote.remote_retrieval import RemoteRecommendAppRetrieval
@ -58,6 +59,32 @@ class TestRemoteRecommendAppRetrieval:
result = RemoteRecommendAppRetrieval().get_recommended_apps_and_categories("en-US")
assert result == {"recommended_apps": [{"id": "builtin"}]}
@patch.object(
RemoteRecommendAppRetrieval,
"fetch_learn_dify_apps_from_dify_official",
return_value={"recommended_apps": [{"id": "learn-dify-app"}]},
)
def test_get_learn_dify_apps_success(self, mock_fetch):
result = RemoteRecommendAppRetrieval().get_learn_dify_apps("en-US")
assert result == {"recommended_apps": [{"id": "learn-dify-app"}]}
mock_fetch.assert_called_once_with("en-US")
@patch(
"services.recommend_app.remote.remote_retrieval.DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db",
return_value={"recommended_apps": [{"id": "db-fallback"}]},
)
@patch.object(
RemoteRecommendAppRetrieval,
"fetch_learn_dify_apps_from_dify_official",
side_effect=ValueError("server error"),
)
def test_get_learn_dify_apps_falls_back_to_database_on_error(self, mock_fetch, mock_database):
result = RemoteRecommendAppRetrieval().get_learn_dify_apps("en-US")
assert result == {"recommended_apps": [{"id": "db-fallback"}]}
mock_database.assert_called_once_with("en-US")
class TestFetchFromDifyOfficial:
@patch("services.recommend_app.remote.remote_retrieval.dify_config")
@ -118,3 +145,84 @@ class TestFetchFromDifyOfficial:
result = RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US")
assert "categories" not in result
assert mock_get.call_args.kwargs["headers"] == {}
@patch("services.recommend_app.remote.remote_retrieval.dify_config")
@patch("services.recommend_app.remote.remote_retrieval.httpx.get")
def test_apps_forwards_request_origin_header(self, mock_get, mock_config):
mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com"
mock_config.CONSOLE_WEB_URL = "https://saas.dify.dev"
mock_response = MagicMock(status_code=200)
mock_response.json.return_value = {"recommended_apps": []}
mock_get.return_value = mock_response
flask_app = Flask(__name__)
with flask_app.test_request_context(headers={"Origin": "https://cloud.example.com"}):
RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US")
assert mock_get.call_args.kwargs["headers"] == {"Origin": "https://cloud.example.com"}
@patch("services.recommend_app.remote.remote_retrieval.dify_config")
@patch("services.recommend_app.remote.remote_retrieval.httpx.get")
def test_apps_falls_back_to_console_web_url_origin(self, mock_get, mock_config):
mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com"
mock_config.CONSOLE_WEB_URL = "https://saas.dify.dev/console"
mock_response = MagicMock(status_code=200)
mock_response.json.return_value = {"recommended_apps": []}
mock_get.return_value = mock_response
flask_app = Flask(__name__)
with flask_app.test_request_context():
RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US")
assert mock_get.call_args.kwargs["headers"] == {"Origin": "https://saas.dify.dev/console"}
@patch("services.recommend_app.remote.remote_retrieval.dify_config")
@patch("services.recommend_app.remote.remote_retrieval.httpx.get")
def test_apps_falls_back_to_console_web_url_without_request_context(self, mock_get, mock_config):
mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com"
mock_config.CONSOLE_WEB_URL = "http://localhost:3000/console"
mock_response = MagicMock(status_code=200)
mock_response.json.return_value = {"recommended_apps": []}
mock_get.return_value = mock_response
RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US")
assert mock_get.call_args.kwargs["headers"] == {"Origin": "http://localhost:3000/console"}
@patch("services.recommend_app.remote.remote_retrieval.dify_config")
@patch("services.recommend_app.remote.remote_retrieval.httpx.get")
def test_apps_uses_console_web_url_without_scheme(self, mock_get, mock_config):
mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com"
mock_config.CONSOLE_WEB_URL = "saas.dify.dev"
mock_response = MagicMock(status_code=200)
mock_response.json.return_value = {"recommended_apps": []}
mock_get.return_value = mock_response
flask_app = Flask(__name__)
with flask_app.test_request_context():
RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US")
assert mock_get.call_args.kwargs["headers"] == {"Origin": "saas.dify.dev"}
@patch("services.recommend_app.remote.remote_retrieval.dify_config")
@patch("services.recommend_app.remote.remote_retrieval.httpx.get")
def test_learn_dify_apps_returns_json_on_200(self, mock_get, mock_config):
mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com"
mock_response = MagicMock(status_code=200)
mock_response.json.return_value = {"recommended_apps": [{"id": "learn-dify-app"}]}
mock_get.return_value = mock_response
result = RemoteRecommendAppRetrieval.fetch_learn_dify_apps_from_dify_official("en-US")
assert result == {"recommended_apps": [{"id": "learn-dify-app"}]}
assert mock_get.call_args.args[0] == "https://example.com/apps/learn-dify?language=en-US"
@patch("services.recommend_app.remote.remote_retrieval.dify_config")
@patch("services.recommend_app.remote.remote_retrieval.httpx.get")
def test_learn_dify_apps_raises_on_non_200(self, mock_get, mock_config):
mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com"
mock_get.return_value = MagicMock(status_code=500)
with pytest.raises(ValueError, match="fetch learn dify apps failed"):
RemoteRecommendAppRetrieval.fetch_learn_dify_apps_from_dify_official("en-US")

View File

@ -0,0 +1,17 @@
import pytest
from services import feature_service as feature_service_module
from services.feature_service import FeatureService, SystemFeatureModel
def test_system_feature_model_defaults_enable_learn_app():
assert SystemFeatureModel().enable_learn_app is True
@pytest.mark.parametrize("enabled", [True, False])
def test_get_system_features_reads_enable_learn_app(monkeypatch: pytest.MonkeyPatch, enabled: bool):
monkeypatch.setattr(feature_service_module.dify_config, "ENABLE_LEARN_APP", enabled)
result = FeatureService.get_system_features()
assert result.enable_learn_app is enabled

View File

@ -41,6 +41,9 @@ FILES_ACCESS_TIMEOUT=300
# Remove `collaboration` from COMPOSE_PROFILES to stop the dedicated websocket service.
ENABLE_COLLABORATION_MODE=true
# Learn app feature toggle
ENABLE_LEARN_APP=true
# Logging and server workers
LOG_LEVEL=INFO
LOG_OUTPUT_FORMAT=text

View File

@ -18,6 +18,9 @@ MIGRATION_ENABLED=true
FILES_ACCESS_TIMEOUT=300
# Remove `collaboration` from COMPOSE_PROFILES to stop the dedicated websocket service.
ENABLE_COLLABORATION_MODE=true
# Learn app feature toggle
ENABLE_LEARN_APP=true
CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1
CELERY_TASK_ANNOTATIONS=null
AZURE_BLOB_ACCOUNT_URL=https://<your_account_name>.blob.core.windows.net

View File

@ -13,6 +13,7 @@ export type SystemFeatureModel = {
enable_email_code_login: boolean
enable_email_password_login: boolean
enable_explore_banner: boolean
enable_learn_app: boolean
enable_marketplace: boolean
enable_social_oauth_login: boolean
enable_trial_app: boolean

View File

@ -105,6 +105,7 @@ export const zSystemFeatureModel = z.object({
enable_email_code_login: z.boolean().default(false),
enable_email_password_login: z.boolean().default(true),
enable_explore_banner: z.boolean().default(false),
enable_learn_app: z.boolean().default(true),
enable_marketplace: z.boolean().default(false),
enable_social_oauth_login: z.boolean().default(false),
enable_trial_app: z.boolean().default(false),

View File

@ -556,6 +556,7 @@ export type SystemFeatureModel = {
enable_email_code_login: boolean
enable_email_password_login: boolean
enable_explore_banner: boolean
enable_learn_app: boolean
enable_marketplace: boolean
enable_social_oauth_login: boolean
enable_trial_app: boolean

View File

@ -840,6 +840,7 @@ export const zSystemFeatureModel = z.object({
enable_email_code_login: z.boolean().default(false),
enable_email_password_login: z.boolean().default(true),
enable_explore_banner: z.boolean().default(false),
enable_learn_app: z.boolean().default(true),
enable_marketplace: z.boolean().default(false),
enable_social_oauth_login: z.boolean().default(false),
enable_trial_app: z.boolean().default(false),

View File

@ -379,6 +379,22 @@ describe('Apps', () => {
})
})
it('should hide categories without templates even when the API returns them', () => {
mockUseExploreAppList.mockReturnValueOnce({
data: {
categories: ['Cat A', 'v'],
allList: [createAppEntry('Alpha', 'Cat A')],
},
isLoading: false,
})
render(<Apps />)
expect(screen.getByText('Cat A'))!.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'v' })).not.toBeInTheDocument()
expect(screen.getByText('Alpha'))!.toBeInTheDocument()
})
it('should clear the search, hide the sidebar during search, and close the modal when requested', async () => {
render(<Apps />)

View File

@ -77,14 +77,30 @@ const Apps = ({
isLoading,
} = useExploreAppList()
const visibleCategories = useMemo(() => {
if (!data)
return []
const categoriesWithApps = new Set<string>()
data.allList.forEach((app) => {
app.categories.forEach(category => categoriesWithApps.add(category))
})
return data.categories.filter(category => categoriesWithApps.has(category))
}, [data])
const activeCategory = visibleCategories.includes(currCategory)
? currCategory
: allCategoriesEn
const filteredList = useMemo(() => {
if (!data)
return []
const { allList } = data
const filteredByCategory = allList.filter((item) => {
if (currCategory === allCategoriesEn)
if (activeCategory === allCategoriesEn)
return true
return item.categories?.includes(currCategory) ?? false
return item.categories?.includes(activeCategory) ?? false
})
if (currentType.length === 0)
return filteredByCategory
@ -101,7 +117,7 @@ const Apps = ({
return true
return false
})
}, [currentType, currCategory, allCategoriesEn, data])
}, [currentType, activeCategory, allCategoriesEn, data])
const searchFilteredList = useMemo(() => {
if (!searchKeywords || !filteredList || filteredList.length === 0)
@ -189,7 +205,7 @@ const Apps = ({
<div className="relative flex flex-1 overflow-y-auto">
{!searchKeywords && (
<div className="h-full w-[200px] p-4">
<Sidebar current={currCategory as AppCategories} categories={data?.categories || []} onClick={(category) => { setCurrCategory(category) }} onCreateFromBlank={onCreateFromBlank} />
<Sidebar current={activeCategory as AppCategories} categories={visibleCategories} onClick={(category) => { setCurrCategory(category) }} onCreateFromBlank={onCreateFromBlank} />
</div>
)}
<div className="h-full flex-1 shrink-0 grow overflow-auto border-l border-divider-burn p-6 pt-2">
@ -200,7 +216,7 @@ const Apps = ({
? <p className="title-md-semi-bold text-text-tertiary">{searchFilteredList.length > 1 ? t('newApp.foundResults', { ns: 'app', count: searchFilteredList.length }) : t('newApp.foundResult', { ns: 'app', count: searchFilteredList.length })}</p>
: (
<div className="flex h-[22px] items-center">
<AppCategoryLabel category={currCategory as AppCategories} className="title-md-semi-bold text-text-primary" />
<AppCategoryLabel category={activeCategory as AppCategories} className="title-md-semi-bold text-text-primary" />
</div>
)}
</div>

View File

@ -1,3 +1,4 @@
import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
@ -324,10 +325,14 @@ beforeAll(() => {
// Render helper wrapping with shared nuqs testing helper plus a seeded
// systemFeatures cache so List can resolve its useSuspenseQuery.
const renderList = (searchParams = '') => {
type RenderListOptions = {
systemFeatures?: Partial<GetSystemFeaturesResponse>
}
const renderList = (searchParams = '', options: RenderListOptions = {}) => {
mockSearchParams = new URLSearchParams(searchParams)
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { branding: { enabled: false } },
systemFeatures: { branding: { enabled: false }, ...options.systemFeatures },
})
return renderWithNuqs(<SystemFeaturesWrapper><List /></SystemFeaturesWrapper>, { searchParams })
}
@ -502,7 +507,7 @@ describe('List', () => {
it('should render first empty state when there are no apps and no active filters', () => {
mockAppData = { pages: [{ data: [], total: 0 }] }
renderList()
renderList('', { systemFeatures: { enable_learn_app: true } })
expect(screen.getByText('app.firstEmpty.title'))!.toBeInTheDocument()
expect(screen.getByText('app.firstEmpty.learnDifyTitle'))!.toBeInTheDocument()
@ -512,6 +517,15 @@ describe('List', () => {
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
})
it('should hide learn dify in first empty state when learn app is disabled', () => {
mockAppData = { pages: [{ data: [], total: 0 }] }
renderList('', { systemFeatures: { enable_learn_app: false } })
expect(screen.getByText('app.firstEmpty.title'))!.toBeInTheDocument()
expect(screen.queryByText('app.firstEmpty.learnDifyTitle')).not.toBeInTheDocument()
})
it('should not render first empty state before the first app list page resolves', () => {
mockAppData = { pages: [] }

View File

@ -19,12 +19,14 @@ type Props = {
onCreateBlank: () => void
onCreateTemplate: () => void
onImportDSL: () => void
showLearnDify: boolean
}
function FirstEmptyState({
onCreateBlank,
onCreateTemplate,
onImportDSL,
showLearnDify,
}: Props) {
const { t } = useTranslation()
@ -102,13 +104,15 @@ function FirstEmptyState({
</div>
</section>
</div>
<LearnDify
className="px-4 pt-2 pb-0 [&_div.grid]:gap-3 [&>div]:mx-0 [&>div]:rounded-t-2xl [&>div]:rounded-b-none [&>div]:px-5 [&>div]:pt-4 [&>div]:pb-5"
dismissible={false}
itemLimit={4}
showDescription
title={t('firstEmpty.learnDifyTitle', { ns: 'app' })}
/>
{showLearnDify && (
<LearnDify
className="px-4 pt-2 pb-0 [&_div.grid]:gap-3 [&>div]:mx-0 [&>div]:rounded-t-2xl [&>div]:rounded-b-none [&>div]:px-5 [&>div]:pt-4 [&>div]:pb-5"
dismissible={false}
itemLimit={4}
showDescription
title={t('firstEmpty.learnDifyTitle', { ns: 'app' })}
/>
)}
</div>
)
}

View File

@ -263,6 +263,7 @@ function List({
onCreateBlank={openCreateBlankModal}
onCreateTemplate={openCreateTemplateDialog}
onImportDSL={openCreateFromDSLModal}
showLearnDify={systemFeatures.enable_learn_app}
/>
)
: (

View File

@ -286,6 +286,7 @@ const mockAppCreatePermission = (hasEditPermission: boolean) => {
type RenderOptions = {
enableExploreBanner?: boolean
enableLearnApp?: boolean
isCloudEdition?: boolean
}
@ -302,7 +303,10 @@ const renderAppList = (
mockConfig.isCloudEdition = options.isCloudEdition ?? false
mockAppCreatePermission(hasEditPermission)
const { wrapper: SystemFeaturesWrapper, queryClient } = createSystemFeaturesWrapper({
systemFeatures: { enable_explore_banner: options.enableExploreBanner ?? false },
systemFeatures: {
enable_explore_banner: options.enableExploreBanner ?? false,
enable_learn_app: options.enableLearnApp ?? true,
},
})
if (!mockIsLoading && !mockIsError && mockExploreData)
queryClient.setQueryData(exploreAppListQueryKey, mockExploreData)
@ -542,6 +546,18 @@ describe('AppList', () => {
expect(screen.queryByText('3 min')).not.toBeInTheDocument()
})
it('should hide learn dify templates when learn app is disabled', () => {
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
renderAppList(false, undefined, undefined, { enableLearnApp: false })
expect(screen.queryByRole('heading', { name: 'explore.learnDify.title' })).not.toBeInTheDocument()
expect(screen.queryByText('Learn Workflow Basics')).not.toBeInTheDocument()
})
it('should collapse learn dify and persist hidden state when hide is clicked', async () => {
mockExploreData = {
categories: ['Writing'],
@ -578,6 +594,18 @@ describe('AppList', () => {
expect(screen.queryByText('Beta')).not.toBeInTheDocument()
})
it('should hide categories without apps even when the API returns them', () => {
mockExploreData = {
categories: ['Writing', 'c'],
allList: [createApp()],
}
renderAppList(false, undefined, { category: 'c' })
expect(screen.queryByRole('radio', { name: 'c' })).not.toBeInTheDocument()
expect(screen.getByText('Alpha')).toBeInTheDocument()
})
it('should keep selected category when clearing search text', async () => {
mockExploreData = {
categories: ['Writing', 'Translate'],

View File

@ -142,15 +142,31 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
defaultValue: allCategoriesEn,
})
const visibleCategories = useMemo(() => {
if (!homeQueries.appListData)
return []
const categoriesWithApps = new Set<string>()
homeQueries.appListData.allList.forEach((app) => {
app.categories.forEach(category => categoriesWithApps.add(category))
})
return homeQueries.appListData.categories.filter(category => categoriesWithApps.has(category))
}, [homeQueries.appListData])
const activeCategory = visibleCategories.includes(currCategory)
? currCategory
: allCategoriesEn
const filteredList = useMemo(() => {
if (!homeQueries.appListData)
return []
return homeQueries.appListData.allList.filter(
item =>
currCategory === allCategoriesEn
|| item.categories?.includes(currCategory),
activeCategory === allCategoriesEn
|| item.categories?.includes(activeCategory),
)
}, [homeQueries.appListData, currCategory, allCategoriesEn])
}, [homeQueries.appListData, activeCategory, allCategoriesEn])
const searchFilteredList = useMemo(() => {
if (!searchKeywords || !filteredList || filteredList.length === 0)
@ -292,8 +308,8 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
<ExploreAppListHeader
allCategoriesEn={allCategoriesEn}
categories={homeQueries.appListData?.categories ?? []}
currCategory={currCategory}
categories={visibleCategories}
currCategory={activeCategory}
keywords={keywords}
onCategoryChange={setCurrCategory}
onKeywordsChange={handleKeywordsChange}

View File

@ -3,9 +3,11 @@
import type { App } from '@/models/explore'
import type { TryAppSelection } from '@/types/try-app'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useLearnDifyAppList } from '@/service/use-explore'
import LearnDifyItem from './item'
import { useLearnDifyHiddenValue, useSetLearnDifyHidden } from './storage'
@ -142,6 +144,11 @@ const DismissibleLearnDify = (props: LearnDifyProps) => {
}
const LearnDify = (props: LearnDifyProps) => {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
if (!systemFeatures.enable_learn_app)
return null
if (props.dismissible === false)
return <LearnDifyContent {...props} />

View File

@ -905,7 +905,7 @@ describe('MainNav', () => {
it('shows Learn Dify switch in help menu and restores it from localStorage', async () => {
localStorage.setItem(LEARN_DIFY_HIDDEN_STORAGE_KEY, 'true')
renderMainNav()
renderMainNav({ enable_learn_app: true })
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' }))
const learnDifyItem = await screen.findByRole('menuitemcheckbox', { name: 'common.mainNav.help.learnDify' })
@ -919,8 +919,17 @@ describe('MainNav', () => {
expect(mockPush).not.toHaveBeenCalled()
})
it('hides Learn Dify switch in help menu when learn app is disabled', async () => {
renderMainNav({ enable_learn_app: false })
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' }))
await screen.findByText('common.mainNav.help.docs')
expect(screen.queryByRole('menuitemcheckbox', { name: 'common.mainNav.help.learnDify' })).not.toBeInTheDocument()
})
it('orders help menu items to match the nav shell design', async () => {
renderMainNav()
renderMainNav({ enable_learn_app: true })
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' }))

View File

@ -56,6 +56,7 @@ const HelpMenu = ({
const setLearnDifyHidden = useSetLearnDifyHidden()
const [aboutVisible, setAboutVisible] = useState(false)
const [open, setOpen] = useState(false)
const shouldShowLearnDifySwitch = systemFeatures.enable_learn_app
if (systemFeatures.branding.enabled)
return null
@ -95,31 +96,33 @@ const HelpMenu = ({
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuLinkItem>
<DropdownMenuCheckboxItem
checked={!learnDifyHidden}
closeOnClick={false}
className="mx-0 h-8 gap-1 px-0 py-1 pr-2 pl-3"
onCheckedChange={checked => setLearnDifyHidden(!checked)}
>
<span aria-hidden className="i-custom-vender-workflow-docs-extractor size-4 shrink-0 text-text-tertiary" />
<span className="min-w-0 flex-1 truncate px-1 py-0.5 system-md-regular text-text-secondary">
{t('mainNav.help.learnDify', { ns: 'common' })}
</span>
<span
aria-hidden
className={cn(
'relative inline-flex h-4 w-7 shrink-0 items-center rounded-[5px] p-0.5 transition-colors',
!learnDifyHidden ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked',
)}
{shouldShowLearnDifySwitch && (
<DropdownMenuCheckboxItem
checked={!learnDifyHidden}
closeOnClick={false}
className="mx-0 h-8 gap-1 px-0 py-1 pr-2 pl-3"
onCheckedChange={checked => setLearnDifyHidden(!checked)}
>
<span aria-hidden className="i-custom-vender-workflow-docs-extractor size-4 shrink-0 text-text-tertiary" />
<span className="min-w-0 flex-1 truncate px-1 py-0.5 system-md-regular text-text-secondary">
{t('mainNav.help.learnDify', { ns: 'common' })}
</span>
<span
aria-hidden
className={cn(
'block h-3 w-2.5 rounded-[3px] bg-components-toggle-knob shadow-sm transition-transform',
!learnDifyHidden && 'translate-x-3.5',
'relative inline-flex h-4 w-7 shrink-0 items-center rounded-[5px] p-0.5 transition-colors',
!learnDifyHidden ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked',
)}
/>
</span>
</DropdownMenuCheckboxItem>
>
<span
className={cn(
'block h-3 w-2.5 rounded-[3px] bg-components-toggle-knob shadow-sm transition-transform',
!learnDifyHidden && 'translate-x-3.5',
)}
/>
</span>
</DropdownMenuCheckboxItem>
)}
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}
</DropdownMenuGroup>
<DropdownMenuSeparator className="my-0!" />

View File

@ -42,6 +42,7 @@ export NEXT_PUBLIC_ENABLE_CHANGE_EMAIL=${NEXT_PUBLIC_ENABLE_CHANGE_EMAIL:-${ENAB
export NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED=${NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED:-${CREATORS_PLATFORM_FEATURES_ENABLED}}
export NEXT_PUBLIC_ENABLE_TRIAL_APP=${NEXT_PUBLIC_ENABLE_TRIAL_APP:-${ENABLE_TRIAL_APP}}
export NEXT_PUBLIC_ENABLE_EXPLORE_BANNER=${NEXT_PUBLIC_ENABLE_EXPLORE_BANNER:-${ENABLE_EXPLORE_BANNER}}
export NEXT_PUBLIC_ENABLE_LEARN_APP=${NEXT_PUBLIC_ENABLE_LEARN_APP:-${ENABLE_LEARN_APP}}
export NEXT_PUBLIC_RBAC_ENABLED=${NEXT_PUBLIC_RBAC_ENABLED:-${RBAC_ENABLED}}
export NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS=${TEXT_GENERATION_TIMEOUT_MS}

View File

@ -87,6 +87,7 @@ const clientSchema = {
NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED: coercedBoolean.default(true),
NEXT_PUBLIC_ENABLE_TRIAL_APP: coercedBoolean.default(true),
NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: coercedBoolean.default(true),
NEXT_PUBLIC_ENABLE_LEARN_APP: coercedBoolean.default(true),
NEXT_PUBLIC_RBAC_ENABLED: coercedBoolean.default(false),
/**
@ -217,6 +218,7 @@ export const env = createEnv({
NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED: isServer ? process.env.NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED : getRuntimeEnvFromBody('creatorsPlatformFeaturesEnabled'),
NEXT_PUBLIC_ENABLE_TRIAL_APP: isServer ? process.env.NEXT_PUBLIC_ENABLE_TRIAL_APP : getRuntimeEnvFromBody('enableTrialApp'),
NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: isServer ? process.env.NEXT_PUBLIC_ENABLE_EXPLORE_BANNER : getRuntimeEnvFromBody('enableExploreBanner'),
NEXT_PUBLIC_ENABLE_LEARN_APP: isServer ? process.env.NEXT_PUBLIC_ENABLE_LEARN_APP : getRuntimeEnvFromBody('enableLearnApp'),
NEXT_PUBLIC_RBAC_ENABLED: isServer ? process.env.NEXT_PUBLIC_RBAC_ENABLED : getRuntimeEnvFromBody('rbacEnabled'),
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX: isServer ? process.env.NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX : getRuntimeEnvFromBody('enableSingleDollarLatex'),

View File

@ -18,6 +18,7 @@ const defaultCloudEnv = {
NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN: true,
NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN: false,
NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: true,
NEXT_PUBLIC_ENABLE_LEARN_APP: true,
NEXT_PUBLIC_ENABLE_MARKETPLACE: true,
NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN: true,
NEXT_PUBLIC_ENABLE_TRIAL_APP: true,
@ -140,6 +141,7 @@ describe('systemFeaturesQueryOptions', () => {
enable_email_password_login: false,
enable_social_oauth_login: true,
enable_trial_app: true,
enable_learn_app: true,
rbac_enabled: false,
})
})
@ -153,6 +155,7 @@ describe('systemFeaturesQueryOptions', () => {
NEXT_PUBLIC_ENABLE_COLLABORATION_MODE: true,
NEXT_PUBLIC_ALLOW_REGISTER: false,
NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: false,
NEXT_PUBLIC_ENABLE_LEARN_APP: true,
NEXT_PUBLIC_RBAC_ENABLED: true,
},
})
@ -166,6 +169,7 @@ describe('systemFeaturesQueryOptions', () => {
enable_collaboration_mode: true,
is_allow_register: false,
enable_explore_banner: false,
enable_learn_app: true,
rbac_enabled: true,
branding: {
enabled: false,
@ -219,6 +223,7 @@ describe('serverSystemFeaturesQueryOptions', () => {
cloudEnv: {
NEXT_PUBLIC_ENABLE_MARKETPLACE: false,
NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN: true,
NEXT_PUBLIC_ENABLE_LEARN_APP: true,
},
})
@ -231,6 +236,7 @@ describe('serverSystemFeaturesQueryOptions', () => {
expect(data).toMatchObject({
enable_marketplace: false,
enable_email_password_login: true,
enable_learn_app: true,
})
})

View File

@ -52,6 +52,7 @@ export const defaultSystemFeatures = {
enable_creators_platform: false,
enable_trial_app: false,
enable_explore_banner: false,
enable_learn_app: true,
} satisfies GetSystemFeaturesResponse
export const cloudSystemFeatures = {
@ -101,5 +102,6 @@ export const cloudSystemFeatures = {
enable_creators_platform: env.NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED,
enable_trial_app: env.NEXT_PUBLIC_ENABLE_TRIAL_APP,
enable_explore_banner: env.NEXT_PUBLIC_ENABLE_EXPLORE_BANNER,
enable_learn_app: env.NEXT_PUBLIC_ENABLE_LEARN_APP,
rbac_enabled: env.NEXT_PUBLIC_RBAC_ENABLED,
} satisfies GetSystemFeaturesResponse