From 29f34848cd90f09af19d64ad155b149987992cb1 Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Thu, 7 May 2026 22:08:23 -0700 Subject: [PATCH 1/3] fix(tools): scope builtin tool default-credential clear to tenant (#35887) --- .../console/workspace/tool_providers.py | 4 ++-- .../tools/builtin_tools_manage_service.py | 5 ++--- .../test_builtin_tools_manage_service.py | 22 +++++++++++++++++-- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index 34c9534de8..e653c9064c 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -876,10 +876,10 @@ class ToolBuiltinProviderSetDefaultApi(Resource): @login_required @account_initialization_required def post(self, provider): - current_user, current_tenant_id = current_account_with_tenant() + _, current_tenant_id = current_account_with_tenant() payload = BuiltinProviderDefaultCredentialPayload.model_validate(console_ns.payload or {}) return BuiltinToolManageService.set_default_provider( - tenant_id=current_tenant_id, user_id=current_user.id, provider=provider, id=payload.id + tenant_id=current_tenant_id, provider=provider, id=payload.id ) diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py index b8242ab3a5..20de1f4058 100644 --- a/api/services/tools/builtin_tools_manage_service.py +++ b/api/services/tools/builtin_tools_manage_service.py @@ -408,7 +408,7 @@ class BuiltinToolManageService: return {"result": "success"} @staticmethod - def set_default_provider(tenant_id: str, user_id: str, provider: str, id: str): + def set_default_provider(tenant_id: str, provider: str, id: str): """ set default provider """ @@ -422,12 +422,11 @@ class BuiltinToolManageService: if target_provider is None: raise ValueError("provider not found") - # clear default provider + # clear default provider (tenant-scoped: only one default per provider per workspace) session.execute( update(BuiltinToolProvider) .where( BuiltinToolProvider.tenant_id == tenant_id, - BuiltinToolProvider.user_id == user_id, BuiltinToolProvider.provider == provider, BuiltinToolProvider.is_default.is_(True), ) diff --git a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py index ce0d94398d..c210db580e 100644 --- a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py +++ b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py @@ -180,7 +180,7 @@ class TestSetDefaultProvider: session.scalar.return_value = None with pytest.raises(ValueError, match="provider not found"): - BuiltinToolManageService.set_default_provider("t", "u", "p", "id") + BuiltinToolManageService.set_default_provider("t", "p", "id") @patch(f"{MODULE}.sessionmaker") @patch(f"{MODULE}.db") @@ -189,11 +189,29 @@ class TestSetDefaultProvider: target = MagicMock() session.scalar.return_value = target - result = BuiltinToolManageService.set_default_provider("t", "u", "p", "id") + result = BuiltinToolManageService.set_default_provider("t", "p", "id") assert result == {"result": "success"} assert target.is_default is True + @patch(f"{MODULE}.sessionmaker") + @patch(f"{MODULE}.db") + def test_clear_default_is_tenant_scoped_not_user_scoped(self, mock_db, mock_sm_cls): + # Regression: clearing prior defaults must NOT filter by user_id, otherwise + # two workspace members can each leave their own credential as default at + # the same time (the default flag is tenant-scoped, not per-user). + session = _mock_sessionmaker(mock_sm_cls) + session.scalar.return_value = MagicMock() + + BuiltinToolManageService.set_default_provider("tenant-1", "google", "cred-id") + + session.execute.assert_called_once() + update_stmt = session.execute.call_args.args[0] + compiled = str(update_stmt.compile(compile_kwargs={"literal_binds": True})) + assert "user_id" not in compiled + assert "tenant_id" in compiled + assert "provider" in compiled + class TestUpdateBuiltinToolProvider: @patch(f"{MODULE}.sessionmaker") From 927a17804bc00023da7edf14345d7a4ed40bbb00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Fri, 8 May 2026 14:04:07 +0800 Subject: [PATCH 2/3] feat: support configurable explore app categories (#35723) --- api/constants/recommended_apps.json | 44 ++++++++--------- .../console/explore/recommended_app.py | 2 +- ...d8c9b731_add_recommended_app_categories.py | 26 ++++++++++ api/models/model.py | 1 + api/services/recommend_app/category_order.py | 49 +++++++++++++++++++ .../database/database_retrieval.py | 13 +++-- .../recommend_app/test_database_retrieval.py | 49 +++++++++++++++++++ .../console/explore/test_recommended_app.py | 3 +- .../recommend_app/test_category_order.py | 26 ++++++++++ .../explore/explore-app-list-flow.test.tsx | 32 ++++++++++-- .../app-card/__tests__/index.spec.tsx | 4 +- .../app/create-app-dialog/app-card/index.tsx | 2 +- .../app-list/__tests__/index.spec.tsx | 2 +- .../app/create-app-dialog/app-list/index.tsx | 2 +- .../components/apps/__tests__/index.spec.tsx | 2 +- web/app/components/apps/index.tsx | 2 +- .../explore/app-card/__tests__/index.spec.tsx | 4 +- web/app/components/explore/app-card/index.tsx | 2 +- .../explore/app-list/__tests__/index.spec.tsx | 6 +-- web/app/components/explore/app-list/index.tsx | 7 ++- web/app/components/explore/category.tsx | 7 ++- .../explore/try-app/__tests__/index.spec.tsx | 18 +++---- .../try-app/app-info/__tests__/index.spec.tsx | 9 ++-- .../explore/try-app/app-info/index.tsx | 18 +++++-- web/app/components/explore/try-app/index.tsx | 6 +-- web/models/explore.ts | 4 +- 26 files changed, 270 insertions(+), 70 deletions(-) create mode 100644 api/migrations/versions/2026_04_29_1200-a4f2d8c9b731_add_recommended_app_categories.py create mode 100644 api/services/recommend_app/category_order.py create mode 100644 api/tests/unit_tests/services/recommend_app/test_category_order.py diff --git a/api/constants/recommended_apps.json b/api/constants/recommended_apps.json index 3779fb0180..3d728f1b2e 100644 --- a/api/constants/recommended_apps.json +++ b/api/constants/recommended_apps.json @@ -19,7 +19,7 @@ "name": "Website Generator" }, "app_id": "b53545b1-79ea-4da3-b31a-c39391c6f041", - "category": "Programming", + "categories": ["Programming"], "copyright": null, "description": null, "is_listed": true, @@ -35,7 +35,7 @@ "name": "Investment Analysis Report Copilot" }, "app_id": "a23b57fa-85da-49c0-a571-3aff375976c1", - "category": "Agent", + "categories": ["Agent"], "copyright": "Dify.AI", "description": "Welcome to your personalized Investment Analysis Copilot service, where we delve into the depths of stock analysis to provide you with comprehensive insights. \n", "is_listed": true, @@ -51,7 +51,7 @@ "name": "Workflow Planning Assistant " }, "app_id": "f3303a7d-a81c-404e-b401-1f8711c998c1", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "An assistant that helps you plan and select the right node for a workflow (V0.6.0). ", "is_listed": true, @@ -67,7 +67,7 @@ "name": "Automated Email Reply " }, "app_id": "e9d92058-7d20-4904-892f-75d90bef7587", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Reply emails using Gmail API. It will automatically retrieve email in your inbox and create a response in Gmail. \nConfigure your Gmail API in Google Cloud Console. ", "is_listed": true, @@ -83,7 +83,7 @@ "name": "Book Translation " }, "app_id": "98b87f88-bd22-4d86-8b74-86beba5e0ed4", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "A workflow designed to translate a full book up to 15000 tokens per run. Uses Code node to separate text into chunks and Iteration to translate each chunk. ", "is_listed": true, @@ -99,7 +99,7 @@ "name": "Python bug fixer" }, "app_id": "cae337e6-aec5-4c7b-beca-d6f1a808bd5e", - "category": "Programming", + "categories": ["Programming"], "copyright": null, "description": null, "is_listed": true, @@ -115,7 +115,7 @@ "name": "Code Interpreter" }, "app_id": "d077d587-b072-4f2c-b631-69ed1e7cdc0f", - "category": "Programming", + "categories": ["Programming"], "copyright": "Copyright 2023 Dify", "description": "Code interpreter, clarifying the syntax and semantics of the code.", "is_listed": true, @@ -131,7 +131,7 @@ "name": "SVG Logo Design " }, "app_id": "73fbb5f1-c15d-4d74-9cc8-46d9db9b2cca", - "category": "Agent", + "categories": ["Agent"], "copyright": "Dify.AI", "description": "Hello, I am your creative partner in bringing ideas to vivid life! I can assist you in creating stunning designs by leveraging abilities of DALLĀ·E 3. ", "is_listed": true, @@ -147,7 +147,7 @@ "name": "Long Story Generator (Iteration) " }, "app_id": "5efb98d7-176b-419c-b6ef-50767391ab62", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "A workflow demonstrating how to use Iteration node to generate long article that is longer than the context length of LLMs. ", "is_listed": true, @@ -163,7 +163,7 @@ "name": "Text Summarization Workflow" }, "app_id": "f00c4531-6551-45ee-808f-1d7903099515", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Based on users' choice, retrieve external knowledge to more accurately summarize articles.", "is_listed": true, @@ -179,7 +179,7 @@ "name": "YouTube Channel Data Analysis" }, "app_id": "be591209-2ca8-410f-8f3b-ca0e530dd638", - "category": "Agent", + "categories": ["Agent"], "copyright": "Dify.AI", "description": "I am a YouTube Channel Data Analysis Copilot, I am here to provide expert data analysis tailored to your needs. ", "is_listed": true, @@ -195,7 +195,7 @@ "name": "Article Grading Bot" }, "app_id": "a747f7b4-c48b-40d6-b313-5e628232c05f", - "category": "Writing", + "categories": ["Writing"], "copyright": null, "description": "Assess the quality of articles and text based on user defined criteria. ", "is_listed": true, @@ -211,7 +211,7 @@ "name": "SEO Blog Generator" }, "app_id": "18f3bd03-524d-4d7a-8374-b30dbe7c69d5", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Workflow for retrieving information from the internet, followed by segmented generation of SEO blogs.", "is_listed": true, @@ -227,7 +227,7 @@ "name": "SQL Creator" }, "app_id": "050ef42e-3e0c-40c1-a6b6-a64f2c49d744", - "category": "Programming", + "categories": ["Programming"], "copyright": "Copyright 2023 Dify", "description": "Write SQL from natural language by pasting in your schema with the request.Please describe your query requirements in natural language and select the target database type.", "is_listed": true, @@ -243,7 +243,7 @@ "name": "Sentiment Analysis " }, "app_id": "f06bf86b-d50c-4895-a942-35112dbe4189", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Batch sentiment analysis of text, followed by JSON output of sentiment classification along with scores.", "is_listed": true, @@ -259,7 +259,7 @@ "name": "Strategic Consulting Expert" }, "app_id": "7e8ca1ae-02f2-4b5f-979e-62d19133bee2", - "category": "Assistant", + "categories": ["Assistant"], "copyright": "Copyright 2023 Dify", "description": "I can answer your questions related to strategic marketing.", "is_listed": true, @@ -275,7 +275,7 @@ "name": "Code Converter" }, "app_id": "4006c4b2-0735-4f37-8dbb-fb1a8c5bd87a", - "category": "Programming", + "categories": ["Programming"], "copyright": "Copyright 2023 Dify", "description": "This is an application that provides the ability to convert code snippets in multiple programming languages. You can input the code you wish to convert, select the target programming language, and get the desired output.", "is_listed": true, @@ -291,7 +291,7 @@ "name": "Question Classifier + Knowledge + Chatbot " }, "app_id": "d9f6b733-e35d-4a40-9f38-ca7bbfa009f7", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Basic Workflow Template, a chatbot capable of identifying intents alongside with a knowledge base.", "is_listed": true, @@ -307,7 +307,7 @@ "name": "AI Front-end interviewer" }, "app_id": "127efead-8944-4e20-ba9d-12402eb345e0", - "category": "HR", + "categories": ["HR"], "copyright": "Copyright 2023 Dify", "description": "A simulated front-end interviewer that tests the skill level of front-end development through questioning.", "is_listed": true, @@ -323,7 +323,7 @@ "name": "Knowledge Retrieval + Chatbot " }, "app_id": "e9870913-dd01-4710-9f06-15d4180ca1ce", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Basic Workflow Template, A chatbot with a knowledge base. ", "is_listed": true, @@ -339,7 +339,7 @@ "name": "Email Assistant Workflow " }, "app_id": "dd5b6353-ae9b-4bce-be6a-a681a12cf709", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "A multifunctional email assistant capable of summarizing, replying, composing, proofreading, and checking grammar.", "is_listed": true, @@ -355,7 +355,7 @@ "name": "Customer Review Analysis Workflow " }, "app_id": "9c0cd31f-4b62-4005-adf5-e3888d08654a", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Utilize LLM (Large Language Models) to classify customer reviews and forward them to the internal system.", "is_listed": true, diff --git a/api/controllers/console/explore/recommended_app.py b/api/controllers/console/explore/recommended_app.py index 55bd679b48..fa65c8daf1 100644 --- a/api/controllers/console/explore/recommended_app.py +++ b/api/controllers/console/explore/recommended_app.py @@ -52,7 +52,7 @@ class RecommendedAppResponse(ResponseModel): copyright: str | None = None privacy_policy: str | None = None custom_disclaimer: str | None = None - category: str | None = None + categories: list[str] = Field(default_factory=list) position: int | None = None is_listed: bool | None = None can_trial: bool | None = None diff --git a/api/migrations/versions/2026_04_29_1200-a4f2d8c9b731_add_recommended_app_categories.py b/api/migrations/versions/2026_04_29_1200-a4f2d8c9b731_add_recommended_app_categories.py new file mode 100644 index 0000000000..eee58b6310 --- /dev/null +++ b/api/migrations/versions/2026_04_29_1200-a4f2d8c9b731_add_recommended_app_categories.py @@ -0,0 +1,26 @@ +"""add recommended app categories + +Revision ID: a4f2d8c9b731 +Revises: 227822d22895 +Create Date: 2026-04-29 12:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a4f2d8c9b731" +down_revision = "227822d22895" +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("categories", sa.JSON(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table("recommended_apps", schema=None) as batch_op: + batch_op.drop_column("categories") diff --git a/api/models/model.py b/api/models/model.py index 25c330b062..f7f90465cf 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -878,6 +878,7 @@ class RecommendedApp(TypeBase): copyright: Mapped[str] = mapped_column(String(255), nullable=False) privacy_policy: Mapped[str] = mapped_column(String(255), nullable=False) category: Mapped[str] = mapped_column(String(255), nullable=False) + categories: Mapped[list[str] | None] = mapped_column(sa.JSON, nullable=True, default=None) custom_disclaimer: Mapped[str] = mapped_column(LongText, default="") position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) is_listed: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True) diff --git a/api/services/recommend_app/category_order.py b/api/services/recommend_app/category_order.py new file mode 100644 index 0000000000..be6b112aa4 --- /dev/null +++ b/api/services/recommend_app/category_order.py @@ -0,0 +1,49 @@ +"""Apply Redis-backed category ordering for DB-backed Explore apps.""" + +import json +import logging +from collections.abc import Collection +from typing import Any + +from extensions.ext_redis import redis_client + +logger = logging.getLogger(__name__) + +EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX = "explore:apps:category_order" + + +def _category_order_key(language: str) -> str: + return f"{EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX}:{language}" + + +def get_explore_app_category_order(language: str) -> list[str]: + try: + raw_categories = redis_client.get(_category_order_key(language)) + except Exception: + logger.exception("Failed to read explore app category order from Redis.") + return [] + + if not raw_categories: + return [] + + if isinstance(raw_categories, bytes): + raw_categories = raw_categories.decode("utf-8") + + try: + categories: Any = json.loads(raw_categories) + except (TypeError, json.JSONDecodeError): + logger.warning("Invalid explore app category order payload for language %s.", language) + return [] + + if not isinstance(categories, list): + return [] + + return [category for category in categories if isinstance(category, str)] + + +def order_categories(categories: Collection[str], language: str) -> list[str]: + configured_order = get_explore_app_category_order(language) + if configured_order: + return configured_order + + return sorted(categories) diff --git a/api/services/recommend_app/database/database_retrieval.py b/api/services/recommend_app/database/database_retrieval.py index 1df5fd13b6..ac870f0700 100644 --- a/api/services/recommend_app/database/database_retrieval.py +++ b/api/services/recommend_app/database/database_retrieval.py @@ -6,6 +6,7 @@ from constants.languages import languages from extensions.ext_database import db from models.model import App, RecommendedApp from services.app_dsl_service import AppDslService +from services.recommend_app.category_order import order_categories from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase from services.recommend_app.recommend_app_type import RecommendAppType @@ -18,7 +19,7 @@ class RecommendedAppItemDict(TypedDict): copyright: Any privacy_policy: Any custom_disclaimer: str - category: str + categories: list[str] position: int is_listed: bool @@ -80,6 +81,7 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): if not site: continue + app_categories = recommended_app.categories or [] recommended_app_result: RecommendedAppItemDict = { "id": recommended_app.id, "app": recommended_app.app, @@ -88,15 +90,18 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): "copyright": site.copyright, "privacy_policy": site.privacy_policy, "custom_disclaimer": site.custom_disclaimer, - "category": recommended_app.category, + "categories": app_categories, "position": recommended_app.position, "is_listed": recommended_app.is_listed, } recommended_apps_result.append(recommended_app_result) - categories.add(recommended_app.category) + categories.update(app_categories) - return RecommendedAppsResultDict(recommended_apps=recommended_apps_result, categories=sorted(categories)) + return RecommendedAppsResultDict( + recommended_apps=recommended_apps_result, + categories=order_categories(categories, language), + ) @classmethod def fetch_recommended_app_detail_from_db(cls, app_id: str) -> RecommendedAppDetailDict | None: diff --git a/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py b/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py index 724dd19f92..11e864176a 100644 --- a/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py +++ b/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py @@ -47,6 +47,7 @@ def _create_recommended_app( *, app_id: str, category: str = "chat", + categories: list[str] | None = None, language: str = "en-US", is_listed: bool = True, position: int = 1, @@ -57,6 +58,7 @@ def _create_recommended_app( copyright="copy", privacy_policy="pp", category=category, + categories=[category] if categories is None else categories, language=language, is_listed=is_listed, position=position, @@ -113,6 +115,53 @@ class TestFetchRecommendedAppsFromDb: assert "assistant" in result["categories"] assert "writing" in result["categories"] + def test_returns_multiple_categories_for_one_app( + self, flask_app_with_containers, db_session_with_containers: Session + ): + tenant_id = str(uuid4()) + created_app = _create_app(db_session_with_containers, tenant_id=tenant_id) + _create_site(db_session_with_containers, app_id=created_app.id) + _create_recommended_app( + db_session_with_containers, + app_id=created_app.id, + category="writing", + categories=["writing", "assistant"], + ) + + db_session_with_containers.expire_all() + + result = DatabaseRecommendAppRetrieval.fetch_recommended_apps_from_db("en-US") + + recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == created_app.id) + assert recommended_app["categories"] == ["writing", "assistant"] + assert "writing" in result["categories"] + assert "assistant" in result["categories"] + + def test_ignores_legacy_category_when_categories_are_empty( + self, + flask_app_with_containers, + db_session_with_containers: Session, + ): + legacy_category = f"legacy-empty-{uuid4()}" + tenant_id = str(uuid4()) + created_app = _create_app(db_session_with_containers, tenant_id=tenant_id) + _create_site(db_session_with_containers, app_id=created_app.id) + _create_recommended_app( + db_session_with_containers, + app_id=created_app.id, + category=legacy_category, + categories=[], + ) + + db_session_with_containers.expire_all() + + result = DatabaseRecommendAppRetrieval.fetch_recommended_apps_from_db("en-US") + + recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == created_app.id) + assert "category" not in recommended_app + assert recommended_app["categories"] == [] + assert legacy_category not in result["categories"] + def test_falls_back_to_default_language_when_empty( self, flask_app_with_containers, db_session_with_containers: Session ): diff --git a/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py b/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py index 557fded37e..89cbea5ddc 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py +++ b/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py @@ -126,7 +126,7 @@ class TestRecommendedAppResponseModels: }, "app_id": "app-1", "description": "desc", - "category": "cat", + "categories": ["cat", "other"], "position": 1, "is_listed": True, "can_trial": False, @@ -137,4 +137,5 @@ class TestRecommendedAppResponseModels: ).model_dump(mode="json") assert response["recommended_apps"][0]["app_id"] == "app-1" + assert response["recommended_apps"][0]["categories"] == ["cat", "other"] assert response["categories"] == ["cat"] diff --git a/api/tests/unit_tests/services/recommend_app/test_category_order.py b/api/tests/unit_tests/services/recommend_app/test_category_order.py new file mode 100644 index 0000000000..3b94021f26 --- /dev/null +++ b/api/tests/unit_tests/services/recommend_app/test_category_order.py @@ -0,0 +1,26 @@ +import json +from unittest.mock import patch + +from services.recommend_app.category_order import get_explore_app_category_order, order_categories + + +@patch("services.recommend_app.category_order.redis_client.get") +def test_get_explore_app_category_order_returns_redis_list(mock_get): + mock_get.return_value = json.dumps(["C", "A", "B"]).encode() + + assert get_explore_app_category_order("en-US") == ["C", "A", "B"] + mock_get.assert_called_once_with("explore:apps:category_order:en-US") + + +@patch("services.recommend_app.category_order.redis_client.get") +def test_order_categories_uses_redis_order_as_source_of_truth(mock_get): + mock_get.return_value = json.dumps(["C", "A", "B"]).encode() + + assert order_categories({"A", "B", "C", "D"}, "en-US") == ["C", "A", "B"] + + +@patch("services.recommend_app.category_order.redis_client.get") +def test_order_categories_falls_back_to_sorted_categories_without_redis_order(mock_get): + mock_get.return_value = None + + assert order_categories({"B", "A", "C"}, "en-US") == ["A", "B", "C"] diff --git a/web/__tests__/explore/explore-app-list-flow.test.tsx b/web/__tests__/explore/explore-app-list-flow.test.tsx index 6af17119be..96798ac6a9 100644 --- a/web/__tests__/explore/explore-app-list-flow.test.tsx +++ b/web/__tests__/explore/explore-app-list-flow.test.tsx @@ -127,7 +127,7 @@ const createApp = (overrides: Partial = {}): App => ({ copyright: overrides.copyright ?? '', privacy_policy: overrides.privacy_policy ?? null, custom_disclaimer: overrides.custom_disclaimer ?? null, - category: overrides.category ?? 'Writing', + categories: overrides.categories ?? ['Writing'], position: overrides.position ?? 1, is_listed: overrides.is_listed ?? true, install_count: overrides.install_count ?? 0, @@ -165,9 +165,9 @@ describe('Explore App List Flow', () => { mockExploreData = { categories: ['Writing', 'Translate', 'Programming'], allList: [ - createApp({ app_id: 'app-1', app: { ...createApp().app, name: 'Writer Bot' }, category: 'Writing' }), - createApp({ app_id: 'app-2', app: { ...createApp().app, id: 'app-id-2', name: 'Translator' }, category: 'Translate' }), - createApp({ app_id: 'app-3', app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, category: 'Programming' }), + createApp({ app_id: 'app-1', app: { ...createApp().app, name: 'Writer Bot' }, categories: ['Writing'] }), + createApp({ app_id: 'app-2', app: { ...createApp().app, id: 'app-id-2', name: 'Translator' }, categories: ['Translate'] }), + createApp({ app_id: 'app-3', app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, categories: ['Programming'] }), ], } }) @@ -190,6 +190,30 @@ describe('Explore App List Flow', () => { expect(screen.queryByText('Code Helper')).not.toBeInTheDocument() }) + it('should only use categories when filtering by selected category', () => { + mockTabValue = 'Writing' + mockExploreData = { + categories: ['Writing', 'Translate'], + allList: [ + createApp({ + app_id: 'app-1', + app: { ...createApp().app, name: 'Active Writer' }, + categories: ['Writing'], + }), + createApp({ + app_id: 'app-2', + app: { ...createApp().app, id: 'app-id-2', name: 'Legacy Writer' }, + categories: [], + }), + ], + } + + renderAppList() + + expect(screen.getByText('Active Writer')).toBeInTheDocument() + expect(screen.queryByText('Legacy Writer')).not.toBeInTheDocument() + }) + it('should filter apps by search keyword', async () => { renderAppList() diff --git a/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx index 16971f77d5..d1b7dedac3 100644 --- a/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx @@ -35,7 +35,7 @@ const mockApp: App = { copyright: 'Test Corp', privacy_policy: null, custom_disclaimer: null, - category: 'Assistant', + categories: ['Assistant'], position: 1, is_listed: true, install_count: 100, @@ -253,7 +253,7 @@ describe('AppCard', () => { template_id: mockApp.app_id, template_name: mockApp.app.name, template_mode: mockApp.app.mode, - template_category: mockApp.category, + template_categories: mockApp.categories, page: 'studio', }) expect(mockSetShowTryAppPanel).toHaveBeenCalledWith(true, { diff --git a/web/app/components/app/create-app-dialog/app-card/index.tsx b/web/app/components/app/create-app-dialog/app-card/index.tsx index e710e21436..27232b0350 100644 --- a/web/app/components/app/create-app-dialog/app-card/index.tsx +++ b/web/app/components/app/create-app-dialog/app-card/index.tsx @@ -35,7 +35,7 @@ const AppCard = ({ template_id: app.app_id, template_name: appBasicInfo.name, template_mode: appBasicInfo.mode, - template_category: app.category, + template_categories: app.categories, page: 'studio', }) setShowTryAppPanel?.(true, { appId: app.app_id, app }) diff --git a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx index 0c6462c2f9..cd6c6b57eb 100644 --- a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx @@ -115,7 +115,7 @@ vi.mock('@/next/navigation', () => ({ const createAppEntry = (name: string, category: string) => ({ app_id: name, - category, + categories: [category], app: { id: name, name, diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index 1924de3893..b0f0b8ca59 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -74,7 +74,7 @@ const Apps = ({ const filteredByCategory = allList.filter((item) => { if (currCategory === allCategoriesEn) return true - return item.category === currCategory + return item.categories?.includes(currCategory) ?? false }) if (currentType.length === 0) return filteredByCategory diff --git a/web/app/components/apps/__tests__/index.spec.tsx b/web/app/components/apps/__tests__/index.spec.tsx index 94fa9f3484..7085852e8f 100644 --- a/web/app/components/apps/__tests__/index.spec.tsx +++ b/web/app/components/apps/__tests__/index.spec.tsx @@ -31,7 +31,7 @@ const mockFetchAppDetail = vi.mocked(fetchAppDetail) const mockTemplateApp: App = { app_id: 'template-1', - category: 'Assistant', + categories: ['Assistant'], app: { id: 'template-1', mode: AppModeEnum.CHAT, diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx index 9d74968605..c42132e890 100644 --- a/web/app/components/apps/index.tsx +++ b/web/app/components/apps/index.tsx @@ -151,7 +151,7 @@ const Apps = () => { diff --git a/web/app/components/explore/app-card/__tests__/index.spec.tsx b/web/app/components/explore/app-card/__tests__/index.spec.tsx index 43b8e4fde2..68cb802acf 100644 --- a/web/app/components/explore/app-card/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-card/__tests__/index.spec.tsx @@ -22,7 +22,7 @@ const createApp = (overrides?: Partial): App => ({ copyright: '2024', privacy_policy: null, custom_disclaimer: null, - category: 'Assistant', + categories: ['Assistant'], position: 1, is_listed: true, install_count: 0, @@ -167,7 +167,7 @@ describe('AppCard', () => { template_id: app.app_id, template_name: app.app.name, template_mode: app.app.mode, - template_category: app.category, + template_categories: app.categories, page: 'explore', }) }) diff --git a/web/app/components/explore/app-card/index.tsx b/web/app/components/explore/app-card/index.tsx index afa46f6ed4..3a6d4962e2 100644 --- a/web/app/components/explore/app-card/index.tsx +++ b/web/app/components/explore/app-card/index.tsx @@ -37,7 +37,7 @@ const AppCard = ({ template_id: app.app_id, template_name: appBasicInfo.name, template_mode: appBasicInfo.mode, - template_category: app.category, + template_categories: app.categories, page: 'explore', }) onTry({ appId: app.app_id, app }) diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx index 4da92dd568..7b5c02be4c 100644 --- a/web/app/components/explore/app-list/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -115,7 +115,7 @@ const createApp = (overrides: Partial = {}): App => ({ copyright: overrides.copyright ?? '', privacy_policy: overrides.privacy_policy ?? null, custom_disclaimer: overrides.custom_disclaimer ?? null, - category: overrides.category ?? 'Writing', + categories: overrides.categories ?? ['Writing'], position: overrides.position ?? 1, is_listed: overrides.is_listed ?? true, install_count: overrides.install_count ?? 0, @@ -185,7 +185,7 @@ describe('AppList', () => { it('should render app cards when data is available', () => { mockExploreData = { categories: ['Writing', 'Translate'], - allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })], + allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, categories: ['Translate'] })], } renderAppList() @@ -199,7 +199,7 @@ describe('AppList', () => { it('should filter apps by selected category', () => { mockExploreData = { categories: ['Writing', 'Translate'], - allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })], + allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, categories: ['Translate'] })], } renderAppList(false, undefined, { category: 'Writing' }) diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index 0cb0e30d2e..86a0bbc6c0 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -77,7 +77,10 @@ const Apps = ({ const filteredList = useMemo(() => { if (!data) return [] - return data.allList.filter(item => currCategory === allCategoriesEn || item.category === currCategory) + return data.allList.filter(item => ( + currCategory === allCategoriesEn + || item.categories?.includes(currCategory) + )) }, [data, currCategory, allCategoriesEn]) const searchFilteredList = useMemo(() => { @@ -277,7 +280,7 @@ const Apps = ({ diff --git a/web/app/components/explore/category.tsx b/web/app/components/explore/category.tsx index de0f5967e4..ef7ac13cd5 100644 --- a/web/app/components/explore/category.tsx +++ b/web/app/components/explore/category.tsx @@ -33,6 +33,11 @@ const Category: FC = ({ isSelected && 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active text-components-main-nav-nav-button-text-active shadow-xs', ) + const renderCategoryName = (name: AppCategory) => { + const categoryKey = `category.${name}` as keyof typeof exploreI18n + return categoryKey in exploreI18n ? t(categoryKey, { ns: 'explore' }) : name + } + return (
= ({ className={itemClassName(name === value)} onClick={() => onChange(name)} > - {`category.${name}` in exploreI18n ? t(`category.${name}`, { ns: 'explore' }) : name} + {renderCategoryName(name)}
))}
diff --git a/web/app/components/explore/try-app/__tests__/index.spec.tsx b/web/app/components/explore/try-app/__tests__/index.spec.tsx index a84989dea9..b93598b64a 100644 --- a/web/app/components/explore/try-app/__tests__/index.spec.tsx +++ b/web/app/components/explore/try-app/__tests__/index.spec.tsx @@ -39,14 +39,14 @@ vi.mock('../app-info', () => ({ default: ({ appId, appDetail, - category, + categories, className, onCreate, - }: { appId: string, appDetail: TryAppInfo, category?: string, className?: string, onCreate: () => void }) => ( + }: { appId: string, appDetail: TryAppInfo, categories?: string[], className?: string, onCreate: () => void }) => (
@@ -283,12 +283,12 @@ describe('TryApp (main index.tsx)', () => { }) }) - describe('category prop', () => { - it('passes category to AppInfo when provided', async () => { + describe('categories prop', () => { + it('passes categories to AppInfo when provided', async () => { render( , @@ -296,11 +296,11 @@ describe('TryApp (main index.tsx)', () => { await waitFor(() => { const appInfo = document.body.querySelector('[data-testid="app-info-component"]') - expect(appInfo).toHaveAttribute('data-category', 'AI Assistant') + expect(appInfo).toHaveAttribute('data-categories', 'AI Assistant,Workflow') }) }) - it('does not pass category to AppInfo when not provided', async () => { + it('does not pass categories to AppInfo when not provided', async () => { render( { await waitFor(() => { const appInfo = document.body.querySelector('[data-testid="app-info-component"]') - expect(appInfo).not.toHaveAttribute('data-category', expect.any(String)) + expect(appInfo).not.toHaveAttribute('data-categories', expect.any(String)) }) }) }) diff --git a/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx b/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx index 599d66596b..e517dbb980 100644 --- a/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx +++ b/web/app/components/explore/try-app/app-info/__tests__/index.spec.tsx @@ -235,8 +235,8 @@ describe('AppInfo', () => { }) }) - describe('category', () => { - it('renders category when provided', () => { + describe('categories', () => { + it('renders categories when provided', () => { const appDetail = createMockAppDetail('chat') const mockOnCreate = vi.fn() @@ -244,16 +244,17 @@ describe('AppInfo', () => { , ) expect(screen.getByText('explore.tryApp.category')).toBeInTheDocument() expect(screen.getByText('AI Assistant')).toBeInTheDocument() + expect(screen.getByText('Workflow')).toBeInTheDocument() }) - it('does not render category section when not provided', () => { + it('does not render categories section when not provided', () => { const appDetail = createMockAppDetail('chat') const mockOnCreate = vi.fn() diff --git a/web/app/components/explore/try-app/app-info/index.tsx b/web/app/components/explore/try-app/app-info/index.tsx index a2c8c26e9f..c5749bee7c 100644 --- a/web/app/components/explore/try-app/app-info/index.tsx +++ b/web/app/components/explore/try-app/app-info/index.tsx @@ -12,7 +12,7 @@ import useGetRequirements from './use-get-requirements' type Props = { appId: string appDetail: TryAppInfo - category?: string + categories?: string[] className?: string onCreate: () => void } @@ -52,12 +52,13 @@ const RequirementIcon: FC = ({ iconUrl }) => { const AppInfo: FC = ({ appId, className, - category, + categories, appDetail, onCreate, }) => { const { t } = useTranslation() const mode = appDetail?.mode + const visibleCategories = Array.from(new Set(categories?.filter(Boolean) ?? [])) const { requirements } = useGetRequirements({ appDetail, appId }) return (
@@ -98,10 +99,19 @@ const AppInfo: FC = ({ {t('tryApp.createFromSampleApp', { ns: 'explore' })} - {category && ( + {visibleCategories.length > 0 && (
{t('tryApp.category', { ns: 'explore' })}
-
{category}
+
+ {visibleCategories.map(category => ( + + {category} + + ))} +
)} {requirements.length > 0 && ( diff --git a/web/app/components/explore/try-app/index.tsx b/web/app/components/explore/try-app/index.tsx index 8bb27d9086..eb5ea952da 100644 --- a/web/app/components/explore/try-app/index.tsx +++ b/web/app/components/explore/try-app/index.tsx @@ -20,7 +20,7 @@ import Tab, { TypeEnum } from './tab' type Props = { appId: string app?: AppType - category?: string + categories?: string[] onClose: () => void onCreate: () => void } @@ -28,7 +28,7 @@ type Props = { const TryApp: FC = ({ appId, app, - category, + categories, onClose, onCreate, }) => { @@ -81,7 +81,7 @@ const TryApp: FC = ({ className="w-[360px] shrink-0" appDetail={appDetail} appId={appId} - category={category} + categories={categories} onCreate={onCreate} />
diff --git a/web/models/explore.ts b/web/models/explore.ts index c05dd1eca1..3a82c589b2 100644 --- a/web/models/explore.ts +++ b/web/models/explore.ts @@ -12,7 +12,7 @@ type AppBasicInfo = { use_icon_as_answer_icon: boolean } -export type AppCategory = 'Writing' | 'Translate' | 'HR' | 'Programming' | 'Assistant' | 'Agent' | 'Recommended' | 'Workflow' +export type AppCategory = string export type App = { app: AppBasicInfo @@ -21,7 +21,7 @@ export type App = { copyright: string privacy_policy: string | null custom_disclaimer: string | null - category: AppCategory + categories: AppCategory[] position: number is_listed: boolean install_count: number From 82f24b336d74585e10b2b14f2f56d2d9f7f52a39 Mon Sep 17 00:00:00 2001 From: shawnYJ Date: Fri, 8 May 2026 15:55:46 +0800 Subject: [PATCH 3/3] fix(workflow): handle file-preview URLs in node output display (#34150) --- web/app/components/workflow/run/node.tsx | 2 ++ web/app/components/workflow/run/result-panel.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/web/app/components/workflow/run/node.tsx b/web/app/components/workflow/run/node.tsx index 9895868887..a87922eb54 100644 --- a/web/app/components/workflow/run/node.tsx +++ b/web/app/components/workflow/run/node.tsx @@ -271,6 +271,7 @@ const NodePanel: FC = ({
{processDataTitle}
} language={CodeLanguage.json} value={nodeInfo.process_data} @@ -282,6 +283,7 @@ const NodePanel: FC = ({
{outputTitle}
} language={CodeLanguage.json} value={nodeInfo.outputs} diff --git a/web/app/components/workflow/run/result-panel.tsx b/web/app/components/workflow/run/result-panel.tsx index 0304037d27..699aceae78 100644 --- a/web/app/components/workflow/run/result-panel.tsx +++ b/web/app/components/workflow/run/result-panel.tsx @@ -143,6 +143,7 @@ const ResultPanel: FC = ({ {process_data && ( {t('common.processData', { ns: 'workflow' }).toLocaleUpperCase()}
} language={CodeLanguage.json} value={process_data} @@ -153,6 +154,7 @@ const ResultPanel: FC = ({ {(outputs || status === 'running') && ( {t('common.output', { ns: 'workflow' }).toLocaleUpperCase()}} language={CodeLanguage.json} value={outputs}