Merge branch 'main' into tp

This commit is contained in:
JzoNg 2026-05-08 15:25:47 +08:00
commit c8d6ad117e
26 changed files with 270 additions and 70 deletions

View File

@ -19,7 +19,7 @@
"name": "Website Generator" "name": "Website Generator"
}, },
"app_id": "b53545b1-79ea-4da3-b31a-c39391c6f041", "app_id": "b53545b1-79ea-4da3-b31a-c39391c6f041",
"category": "Programming", "categories": ["Programming"],
"copyright": null, "copyright": null,
"description": null, "description": null,
"is_listed": true, "is_listed": true,
@ -35,7 +35,7 @@
"name": "Investment Analysis Report Copilot" "name": "Investment Analysis Report Copilot"
}, },
"app_id": "a23b57fa-85da-49c0-a571-3aff375976c1", "app_id": "a23b57fa-85da-49c0-a571-3aff375976c1",
"category": "Agent", "categories": ["Agent"],
"copyright": "Dify.AI", "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", "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, "is_listed": true,
@ -51,7 +51,7 @@
"name": "Workflow Planning Assistant " "name": "Workflow Planning Assistant "
}, },
"app_id": "f3303a7d-a81c-404e-b401-1f8711c998c1", "app_id": "f3303a7d-a81c-404e-b401-1f8711c998c1",
"category": "Workflow", "categories": ["Workflow"],
"copyright": null, "copyright": null,
"description": "An assistant that helps you plan and select the right node for a workflow (V0.6.0). ", "description": "An assistant that helps you plan and select the right node for a workflow (V0.6.0). ",
"is_listed": true, "is_listed": true,
@ -67,7 +67,7 @@
"name": "Automated Email Reply " "name": "Automated Email Reply "
}, },
"app_id": "e9d92058-7d20-4904-892f-75d90bef7587", "app_id": "e9d92058-7d20-4904-892f-75d90bef7587",
"category": "Workflow", "categories": ["Workflow"],
"copyright": null, "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. ", "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, "is_listed": true,
@ -83,7 +83,7 @@
"name": "Book Translation " "name": "Book Translation "
}, },
"app_id": "98b87f88-bd22-4d86-8b74-86beba5e0ed4", "app_id": "98b87f88-bd22-4d86-8b74-86beba5e0ed4",
"category": "Workflow", "categories": ["Workflow"],
"copyright": null, "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. ", "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, "is_listed": true,
@ -99,7 +99,7 @@
"name": "Python bug fixer" "name": "Python bug fixer"
}, },
"app_id": "cae337e6-aec5-4c7b-beca-d6f1a808bd5e", "app_id": "cae337e6-aec5-4c7b-beca-d6f1a808bd5e",
"category": "Programming", "categories": ["Programming"],
"copyright": null, "copyright": null,
"description": null, "description": null,
"is_listed": true, "is_listed": true,
@ -115,7 +115,7 @@
"name": "Code Interpreter" "name": "Code Interpreter"
}, },
"app_id": "d077d587-b072-4f2c-b631-69ed1e7cdc0f", "app_id": "d077d587-b072-4f2c-b631-69ed1e7cdc0f",
"category": "Programming", "categories": ["Programming"],
"copyright": "Copyright 2023 Dify", "copyright": "Copyright 2023 Dify",
"description": "Code interpreter, clarifying the syntax and semantics of the code.", "description": "Code interpreter, clarifying the syntax and semantics of the code.",
"is_listed": true, "is_listed": true,
@ -131,7 +131,7 @@
"name": "SVG Logo Design " "name": "SVG Logo Design "
}, },
"app_id": "73fbb5f1-c15d-4d74-9cc8-46d9db9b2cca", "app_id": "73fbb5f1-c15d-4d74-9cc8-46d9db9b2cca",
"category": "Agent", "categories": ["Agent"],
"copyright": "Dify.AI", "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. ", "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, "is_listed": true,
@ -147,7 +147,7 @@
"name": "Long Story Generator (Iteration) " "name": "Long Story Generator (Iteration) "
}, },
"app_id": "5efb98d7-176b-419c-b6ef-50767391ab62", "app_id": "5efb98d7-176b-419c-b6ef-50767391ab62",
"category": "Workflow", "categories": ["Workflow"],
"copyright": null, "copyright": null,
"description": "A workflow demonstrating how to use Iteration node to generate long article that is longer than the context length of LLMs. ", "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, "is_listed": true,
@ -163,7 +163,7 @@
"name": "Text Summarization Workflow" "name": "Text Summarization Workflow"
}, },
"app_id": "f00c4531-6551-45ee-808f-1d7903099515", "app_id": "f00c4531-6551-45ee-808f-1d7903099515",
"category": "Workflow", "categories": ["Workflow"],
"copyright": null, "copyright": null,
"description": "Based on users' choice, retrieve external knowledge to more accurately summarize articles.", "description": "Based on users' choice, retrieve external knowledge to more accurately summarize articles.",
"is_listed": true, "is_listed": true,
@ -179,7 +179,7 @@
"name": "YouTube Channel Data Analysis" "name": "YouTube Channel Data Analysis"
}, },
"app_id": "be591209-2ca8-410f-8f3b-ca0e530dd638", "app_id": "be591209-2ca8-410f-8f3b-ca0e530dd638",
"category": "Agent", "categories": ["Agent"],
"copyright": "Dify.AI", "copyright": "Dify.AI",
"description": "I am a YouTube Channel Data Analysis Copilot, I am here to provide expert data analysis tailored to your needs. ", "description": "I am a YouTube Channel Data Analysis Copilot, I am here to provide expert data analysis tailored to your needs. ",
"is_listed": true, "is_listed": true,
@ -195,7 +195,7 @@
"name": "Article Grading Bot" "name": "Article Grading Bot"
}, },
"app_id": "a747f7b4-c48b-40d6-b313-5e628232c05f", "app_id": "a747f7b4-c48b-40d6-b313-5e628232c05f",
"category": "Writing", "categories": ["Writing"],
"copyright": null, "copyright": null,
"description": "Assess the quality of articles and text based on user defined criteria. ", "description": "Assess the quality of articles and text based on user defined criteria. ",
"is_listed": true, "is_listed": true,
@ -211,7 +211,7 @@
"name": "SEO Blog Generator" "name": "SEO Blog Generator"
}, },
"app_id": "18f3bd03-524d-4d7a-8374-b30dbe7c69d5", "app_id": "18f3bd03-524d-4d7a-8374-b30dbe7c69d5",
"category": "Workflow", "categories": ["Workflow"],
"copyright": null, "copyright": null,
"description": "Workflow for retrieving information from the internet, followed by segmented generation of SEO blogs.", "description": "Workflow for retrieving information from the internet, followed by segmented generation of SEO blogs.",
"is_listed": true, "is_listed": true,
@ -227,7 +227,7 @@
"name": "SQL Creator" "name": "SQL Creator"
}, },
"app_id": "050ef42e-3e0c-40c1-a6b6-a64f2c49d744", "app_id": "050ef42e-3e0c-40c1-a6b6-a64f2c49d744",
"category": "Programming", "categories": ["Programming"],
"copyright": "Copyright 2023 Dify", "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.", "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, "is_listed": true,
@ -243,7 +243,7 @@
"name": "Sentiment Analysis " "name": "Sentiment Analysis "
}, },
"app_id": "f06bf86b-d50c-4895-a942-35112dbe4189", "app_id": "f06bf86b-d50c-4895-a942-35112dbe4189",
"category": "Workflow", "categories": ["Workflow"],
"copyright": null, "copyright": null,
"description": "Batch sentiment analysis of text, followed by JSON output of sentiment classification along with scores.", "description": "Batch sentiment analysis of text, followed by JSON output of sentiment classification along with scores.",
"is_listed": true, "is_listed": true,
@ -259,7 +259,7 @@
"name": "Strategic Consulting Expert" "name": "Strategic Consulting Expert"
}, },
"app_id": "7e8ca1ae-02f2-4b5f-979e-62d19133bee2", "app_id": "7e8ca1ae-02f2-4b5f-979e-62d19133bee2",
"category": "Assistant", "categories": ["Assistant"],
"copyright": "Copyright 2023 Dify", "copyright": "Copyright 2023 Dify",
"description": "I can answer your questions related to strategic marketing.", "description": "I can answer your questions related to strategic marketing.",
"is_listed": true, "is_listed": true,
@ -275,7 +275,7 @@
"name": "Code Converter" "name": "Code Converter"
}, },
"app_id": "4006c4b2-0735-4f37-8dbb-fb1a8c5bd87a", "app_id": "4006c4b2-0735-4f37-8dbb-fb1a8c5bd87a",
"category": "Programming", "categories": ["Programming"],
"copyright": "Copyright 2023 Dify", "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.", "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, "is_listed": true,
@ -291,7 +291,7 @@
"name": "Question Classifier + Knowledge + Chatbot " "name": "Question Classifier + Knowledge + Chatbot "
}, },
"app_id": "d9f6b733-e35d-4a40-9f38-ca7bbfa009f7", "app_id": "d9f6b733-e35d-4a40-9f38-ca7bbfa009f7",
"category": "Workflow", "categories": ["Workflow"],
"copyright": null, "copyright": null,
"description": "Basic Workflow Template, a chatbot capable of identifying intents alongside with a knowledge base.", "description": "Basic Workflow Template, a chatbot capable of identifying intents alongside with a knowledge base.",
"is_listed": true, "is_listed": true,
@ -307,7 +307,7 @@
"name": "AI Front-end interviewer" "name": "AI Front-end interviewer"
}, },
"app_id": "127efead-8944-4e20-ba9d-12402eb345e0", "app_id": "127efead-8944-4e20-ba9d-12402eb345e0",
"category": "HR", "categories": ["HR"],
"copyright": "Copyright 2023 Dify", "copyright": "Copyright 2023 Dify",
"description": "A simulated front-end interviewer that tests the skill level of front-end development through questioning.", "description": "A simulated front-end interviewer that tests the skill level of front-end development through questioning.",
"is_listed": true, "is_listed": true,
@ -323,7 +323,7 @@
"name": "Knowledge Retrieval + Chatbot " "name": "Knowledge Retrieval + Chatbot "
}, },
"app_id": "e9870913-dd01-4710-9f06-15d4180ca1ce", "app_id": "e9870913-dd01-4710-9f06-15d4180ca1ce",
"category": "Workflow", "categories": ["Workflow"],
"copyright": null, "copyright": null,
"description": "Basic Workflow Template, A chatbot with a knowledge base. ", "description": "Basic Workflow Template, A chatbot with a knowledge base. ",
"is_listed": true, "is_listed": true,
@ -339,7 +339,7 @@
"name": "Email Assistant Workflow " "name": "Email Assistant Workflow "
}, },
"app_id": "dd5b6353-ae9b-4bce-be6a-a681a12cf709", "app_id": "dd5b6353-ae9b-4bce-be6a-a681a12cf709",
"category": "Workflow", "categories": ["Workflow"],
"copyright": null, "copyright": null,
"description": "A multifunctional email assistant capable of summarizing, replying, composing, proofreading, and checking grammar.", "description": "A multifunctional email assistant capable of summarizing, replying, composing, proofreading, and checking grammar.",
"is_listed": true, "is_listed": true,
@ -355,7 +355,7 @@
"name": "Customer Review Analysis Workflow " "name": "Customer Review Analysis Workflow "
}, },
"app_id": "9c0cd31f-4b62-4005-adf5-e3888d08654a", "app_id": "9c0cd31f-4b62-4005-adf5-e3888d08654a",
"category": "Workflow", "categories": ["Workflow"],
"copyright": null, "copyright": null,
"description": "Utilize LLM (Large Language Models) to classify customer reviews and forward them to the internal system.", "description": "Utilize LLM (Large Language Models) to classify customer reviews and forward them to the internal system.",
"is_listed": true, "is_listed": true,

View File

@ -52,7 +52,7 @@ class RecommendedAppResponse(ResponseModel):
copyright: str | None = None copyright: str | None = None
privacy_policy: str | None = None privacy_policy: str | None = None
custom_disclaimer: str | None = None custom_disclaimer: str | None = None
category: str | None = None categories: list[str] = Field(default_factory=list)
position: int | None = None position: int | None = None
is_listed: bool | None = None is_listed: bool | None = None
can_trial: bool | None = None can_trial: bool | None = None

View File

@ -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")

View File

@ -878,6 +878,7 @@ class RecommendedApp(TypeBase):
copyright: Mapped[str] = mapped_column(String(255), nullable=False) copyright: Mapped[str] = mapped_column(String(255), nullable=False)
privacy_policy: 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) 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="") custom_disclaimer: Mapped[str] = mapped_column(LongText, default="")
position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
is_listed: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True) is_listed: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True)

View File

@ -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)

View File

@ -6,6 +6,7 @@ from constants.languages import languages
from extensions.ext_database import db from extensions.ext_database import db
from models.model import App, RecommendedApp from models.model import App, RecommendedApp
from services.app_dsl_service import AppDslService 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_base import RecommendAppRetrievalBase
from services.recommend_app.recommend_app_type import RecommendAppType from services.recommend_app.recommend_app_type import RecommendAppType
@ -18,7 +19,7 @@ class RecommendedAppItemDict(TypedDict):
copyright: Any copyright: Any
privacy_policy: Any privacy_policy: Any
custom_disclaimer: str custom_disclaimer: str
category: str categories: list[str]
position: int position: int
is_listed: bool is_listed: bool
@ -80,6 +81,7 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase):
if not site: if not site:
continue continue
app_categories = recommended_app.categories or []
recommended_app_result: RecommendedAppItemDict = { recommended_app_result: RecommendedAppItemDict = {
"id": recommended_app.id, "id": recommended_app.id,
"app": recommended_app.app, "app": recommended_app.app,
@ -88,15 +90,18 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase):
"copyright": site.copyright, "copyright": site.copyright,
"privacy_policy": site.privacy_policy, "privacy_policy": site.privacy_policy,
"custom_disclaimer": site.custom_disclaimer, "custom_disclaimer": site.custom_disclaimer,
"category": recommended_app.category, "categories": app_categories,
"position": recommended_app.position, "position": recommended_app.position,
"is_listed": recommended_app.is_listed, "is_listed": recommended_app.is_listed,
} }
recommended_apps_result.append(recommended_app_result) 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 @classmethod
def fetch_recommended_app_detail_from_db(cls, app_id: str) -> RecommendedAppDetailDict | None: def fetch_recommended_app_detail_from_db(cls, app_id: str) -> RecommendedAppDetailDict | None:

View File

@ -47,6 +47,7 @@ def _create_recommended_app(
*, *,
app_id: str, app_id: str,
category: str = "chat", category: str = "chat",
categories: list[str] | None = None,
language: str = "en-US", language: str = "en-US",
is_listed: bool = True, is_listed: bool = True,
position: int = 1, position: int = 1,
@ -57,6 +58,7 @@ def _create_recommended_app(
copyright="copy", copyright="copy",
privacy_policy="pp", privacy_policy="pp",
category=category, category=category,
categories=[category] if categories is None else categories,
language=language, language=language,
is_listed=is_listed, is_listed=is_listed,
position=position, position=position,
@ -113,6 +115,53 @@ class TestFetchRecommendedAppsFromDb:
assert "assistant" in result["categories"] assert "assistant" in result["categories"]
assert "writing" 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( def test_falls_back_to_default_language_when_empty(
self, flask_app_with_containers, db_session_with_containers: Session self, flask_app_with_containers, db_session_with_containers: Session
): ):

View File

@ -126,7 +126,7 @@ class TestRecommendedAppResponseModels:
}, },
"app_id": "app-1", "app_id": "app-1",
"description": "desc", "description": "desc",
"category": "cat", "categories": ["cat", "other"],
"position": 1, "position": 1,
"is_listed": True, "is_listed": True,
"can_trial": False, "can_trial": False,
@ -137,4 +137,5 @@ class TestRecommendedAppResponseModels:
).model_dump(mode="json") ).model_dump(mode="json")
assert response["recommended_apps"][0]["app_id"] == "app-1" assert response["recommended_apps"][0]["app_id"] == "app-1"
assert response["recommended_apps"][0]["categories"] == ["cat", "other"]
assert response["categories"] == ["cat"] assert response["categories"] == ["cat"]

View File

@ -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"]

View File

@ -127,7 +127,7 @@ const createApp = (overrides: Partial<App> = {}): App => ({
copyright: overrides.copyright ?? '', copyright: overrides.copyright ?? '',
privacy_policy: overrides.privacy_policy ?? null, privacy_policy: overrides.privacy_policy ?? null,
custom_disclaimer: overrides.custom_disclaimer ?? null, custom_disclaimer: overrides.custom_disclaimer ?? null,
category: overrides.category ?? 'Writing', categories: overrides.categories ?? ['Writing'],
position: overrides.position ?? 1, position: overrides.position ?? 1,
is_listed: overrides.is_listed ?? true, is_listed: overrides.is_listed ?? true,
install_count: overrides.install_count ?? 0, install_count: overrides.install_count ?? 0,
@ -165,9 +165,9 @@ describe('Explore App List Flow', () => {
mockExploreData = { mockExploreData = {
categories: ['Writing', 'Translate', 'Programming'], categories: ['Writing', 'Translate', 'Programming'],
allList: [ allList: [
createApp({ app_id: 'app-1', app: { ...createApp().app, name: 'Writer Bot' }, category: 'Writing' }), 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' }, category: 'Translate' }), 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' }, category: 'Programming' }), 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() 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 () => { it('should filter apps by search keyword', async () => {
renderAppList() renderAppList()

View File

@ -35,7 +35,7 @@ const mockApp: App = {
copyright: 'Test Corp', copyright: 'Test Corp',
privacy_policy: null, privacy_policy: null,
custom_disclaimer: null, custom_disclaimer: null,
category: 'Assistant', categories: ['Assistant'],
position: 1, position: 1,
is_listed: true, is_listed: true,
install_count: 100, install_count: 100,
@ -253,7 +253,7 @@ describe('AppCard', () => {
template_id: mockApp.app_id, template_id: mockApp.app_id,
template_name: mockApp.app.name, template_name: mockApp.app.name,
template_mode: mockApp.app.mode, template_mode: mockApp.app.mode,
template_category: mockApp.category, template_categories: mockApp.categories,
page: 'studio', page: 'studio',
}) })
expect(mockSetShowTryAppPanel).toHaveBeenCalledWith(true, { expect(mockSetShowTryAppPanel).toHaveBeenCalledWith(true, {

View File

@ -35,7 +35,7 @@ const AppCard = ({
template_id: app.app_id, template_id: app.app_id,
template_name: appBasicInfo.name, template_name: appBasicInfo.name,
template_mode: appBasicInfo.mode, template_mode: appBasicInfo.mode,
template_category: app.category, template_categories: app.categories,
page: 'studio', page: 'studio',
}) })
setShowTryAppPanel?.(true, { appId: app.app_id, app }) setShowTryAppPanel?.(true, { appId: app.app_id, app })

View File

@ -115,7 +115,7 @@ vi.mock('@/next/navigation', () => ({
const createAppEntry = (name: string, category: string) => ({ const createAppEntry = (name: string, category: string) => ({
app_id: name, app_id: name,
category, categories: [category],
app: { app: {
id: name, id: name,
name, name,

View File

@ -74,7 +74,7 @@ const Apps = ({
const filteredByCategory = allList.filter((item) => { const filteredByCategory = allList.filter((item) => {
if (currCategory === allCategoriesEn) if (currCategory === allCategoriesEn)
return true return true
return item.category === currCategory return item.categories?.includes(currCategory) ?? false
}) })
if (currentType.length === 0) if (currentType.length === 0)
return filteredByCategory return filteredByCategory

View File

@ -31,7 +31,7 @@ const mockFetchAppDetail = vi.mocked(fetchAppDetail)
const mockTemplateApp: App = { const mockTemplateApp: App = {
app_id: 'template-1', app_id: 'template-1',
category: 'Assistant', categories: ['Assistant'],
app: { app: {
id: 'template-1', id: 'template-1',
mode: AppModeEnum.CHAT, mode: AppModeEnum.CHAT,

View File

@ -151,7 +151,7 @@ const Apps = () => {
<TryApp <TryApp
appId={currentTryAppParams?.appId || ''} appId={currentTryAppParams?.appId || ''}
app={currentTryAppParams?.app} app={currentTryAppParams?.app}
category={currentTryAppParams?.app?.category} categories={currentTryAppParams?.app?.categories}
onClose={hideTryAppPanel} onClose={hideTryAppPanel}
onCreate={handleShowFromTryApp} onCreate={handleShowFromTryApp}
/> />

View File

@ -22,7 +22,7 @@ const createApp = (overrides?: Partial<App>): App => ({
copyright: '2024', copyright: '2024',
privacy_policy: null, privacy_policy: null,
custom_disclaimer: null, custom_disclaimer: null,
category: 'Assistant', categories: ['Assistant'],
position: 1, position: 1,
is_listed: true, is_listed: true,
install_count: 0, install_count: 0,
@ -167,7 +167,7 @@ describe('AppCard', () => {
template_id: app.app_id, template_id: app.app_id,
template_name: app.app.name, template_name: app.app.name,
template_mode: app.app.mode, template_mode: app.app.mode,
template_category: app.category, template_categories: app.categories,
page: 'explore', page: 'explore',
}) })
}) })

View File

@ -37,7 +37,7 @@ const AppCard = ({
template_id: app.app_id, template_id: app.app_id,
template_name: appBasicInfo.name, template_name: appBasicInfo.name,
template_mode: appBasicInfo.mode, template_mode: appBasicInfo.mode,
template_category: app.category, template_categories: app.categories,
page: 'explore', page: 'explore',
}) })
onTry({ appId: app.app_id, app }) onTry({ appId: app.app_id, app })

View File

@ -115,7 +115,7 @@ const createApp = (overrides: Partial<App> = {}): App => ({
copyright: overrides.copyright ?? '', copyright: overrides.copyright ?? '',
privacy_policy: overrides.privacy_policy ?? null, privacy_policy: overrides.privacy_policy ?? null,
custom_disclaimer: overrides.custom_disclaimer ?? null, custom_disclaimer: overrides.custom_disclaimer ?? null,
category: overrides.category ?? 'Writing', categories: overrides.categories ?? ['Writing'],
position: overrides.position ?? 1, position: overrides.position ?? 1,
is_listed: overrides.is_listed ?? true, is_listed: overrides.is_listed ?? true,
install_count: overrides.install_count ?? 0, install_count: overrides.install_count ?? 0,
@ -185,7 +185,7 @@ describe('AppList', () => {
it('should render app cards when data is available', () => { it('should render app cards when data is available', () => {
mockExploreData = { mockExploreData = {
categories: ['Writing', 'Translate'], 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() renderAppList()
@ -199,7 +199,7 @@ describe('AppList', () => {
it('should filter apps by selected category', () => { it('should filter apps by selected category', () => {
mockExploreData = { mockExploreData = {
categories: ['Writing', 'Translate'], 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' }) renderAppList(false, undefined, { category: 'Writing' })

View File

@ -77,7 +77,10 @@ const Apps = ({
const filteredList = useMemo(() => { const filteredList = useMemo(() => {
if (!data) if (!data)
return [] 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]) }, [data, currCategory, allCategoriesEn])
const searchFilteredList = useMemo(() => { const searchFilteredList = useMemo(() => {
@ -277,7 +280,7 @@ const Apps = ({
<TryApp <TryApp
appId={currentTryApp?.appId || ''} appId={currentTryApp?.appId || ''}
app={currentTryApp?.app} app={currentTryApp?.app}
category={currentTryApp?.app?.category} categories={currentTryApp?.app?.categories}
onClose={hideTryAppPanel} onClose={hideTryAppPanel}
onCreate={handleShowFromTryApp} onCreate={handleShowFromTryApp}
/> />

View File

@ -33,6 +33,11 @@ const Category: FC<ICategoryProps> = ({
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', 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 ( return (
<div className={cn(className, 'flex flex-wrap gap-1 text-[13px]')}> <div className={cn(className, 'flex flex-wrap gap-1 text-[13px]')}>
<div <div
@ -48,7 +53,7 @@ const Category: FC<ICategoryProps> = ({
className={itemClassName(name === value)} className={itemClassName(name === value)}
onClick={() => onChange(name)} onClick={() => onChange(name)}
> >
{`category.${name}` in exploreI18n ? t(`category.${name}`, { ns: 'explore' }) : name} {renderCategoryName(name)}
</div> </div>
))} ))}
</div> </div>

View File

@ -39,14 +39,14 @@ vi.mock('../app-info', () => ({
default: ({ default: ({
appId, appId,
appDetail, appDetail,
category, categories,
className, className,
onCreate, onCreate,
}: { appId: string, appDetail: TryAppInfo, category?: string, className?: string, onCreate: () => void }) => ( }: { appId: string, appDetail: TryAppInfo, categories?: string[], className?: string, onCreate: () => void }) => (
<div <div
data-testid="app-info-component" data-testid="app-info-component"
data-app-id={appId} data-app-id={appId}
data-category={category} data-categories={categories?.join(',')}
className={className} className={className}
> >
<button data-testid="create-button" onClick={onCreate}>Create</button> <button data-testid="create-button" onClick={onCreate}>Create</button>
@ -283,12 +283,12 @@ describe('TryApp (main index.tsx)', () => {
}) })
}) })
describe('category prop', () => { describe('categories prop', () => {
it('passes category to AppInfo when provided', async () => { it('passes categories to AppInfo when provided', async () => {
render( render(
<TryApp <TryApp
appId="test-app-id" appId="test-app-id"
category="AI Assistant" categories={['AI Assistant', 'Workflow']}
onClose={vi.fn()} onClose={vi.fn()}
onCreate={vi.fn()} onCreate={vi.fn()}
/>, />,
@ -296,11 +296,11 @@ describe('TryApp (main index.tsx)', () => {
await waitFor(() => { await waitFor(() => {
const appInfo = document.body.querySelector('[data-testid="app-info-component"]') 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( render(
<TryApp <TryApp
appId="test-app-id" appId="test-app-id"
@ -311,7 +311,7 @@ describe('TryApp (main index.tsx)', () => {
await waitFor(() => { await waitFor(() => {
const appInfo = document.body.querySelector('[data-testid="app-info-component"]') 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))
}) })
}) })
}) })

View File

@ -235,8 +235,8 @@ describe('AppInfo', () => {
}) })
}) })
describe('category', () => { describe('categories', () => {
it('renders category when provided', () => { it('renders categories when provided', () => {
const appDetail = createMockAppDetail('chat') const appDetail = createMockAppDetail('chat')
const mockOnCreate = vi.fn() const mockOnCreate = vi.fn()
@ -244,16 +244,17 @@ describe('AppInfo', () => {
<AppInfo <AppInfo
appId="test-app-id" appId="test-app-id"
appDetail={appDetail} appDetail={appDetail}
category="AI Assistant" categories={['AI Assistant', 'Workflow']}
onCreate={mockOnCreate} onCreate={mockOnCreate}
/>, />,
) )
expect(screen.getByText('explore.tryApp.category')).toBeInTheDocument() expect(screen.getByText('explore.tryApp.category')).toBeInTheDocument()
expect(screen.getByText('AI Assistant')).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 appDetail = createMockAppDetail('chat')
const mockOnCreate = vi.fn() const mockOnCreate = vi.fn()

View File

@ -12,7 +12,7 @@ import useGetRequirements from './use-get-requirements'
type Props = { type Props = {
appId: string appId: string
appDetail: TryAppInfo appDetail: TryAppInfo
category?: string categories?: string[]
className?: string className?: string
onCreate: () => void onCreate: () => void
} }
@ -52,12 +52,13 @@ const RequirementIcon: FC<RequirementIconProps> = ({ iconUrl }) => {
const AppInfo: FC<Props> = ({ const AppInfo: FC<Props> = ({
appId, appId,
className, className,
category, categories,
appDetail, appDetail,
onCreate, onCreate,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const mode = appDetail?.mode const mode = appDetail?.mode
const visibleCategories = Array.from(new Set(categories?.filter(Boolean) ?? []))
const { requirements } = useGetRequirements({ appDetail, appId }) const { requirements } = useGetRequirements({ appDetail, appId })
return ( return (
<div className={cn('flex h-full flex-col px-4 pt-2', className)}> <div className={cn('flex h-full flex-col px-4 pt-2', className)}>
@ -98,10 +99,19 @@ const AppInfo: FC<Props> = ({
<span className="truncate">{t('tryApp.createFromSampleApp', { ns: 'explore' })}</span> <span className="truncate">{t('tryApp.createFromSampleApp', { ns: 'explore' })}</span>
</Button> </Button>
{category && ( {visibleCategories.length > 0 && (
<div className="mt-6 shrink-0"> <div className="mt-6 shrink-0">
<div className={headerClassName}>{t('tryApp.category', { ns: 'explore' })}</div> <div className={headerClassName}>{t('tryApp.category', { ns: 'explore' })}</div>
<div className="system-md-regular text-text-secondary">{category}</div> <div className="flex flex-wrap gap-1.5">
{visibleCategories.map(category => (
<span
key={category}
className="rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-2 py-0.5 system-xs-medium text-text-secondary shadow-xs"
>
{category}
</span>
))}
</div>
</div> </div>
)} )}
{requirements.length > 0 && ( {requirements.length > 0 && (

View File

@ -20,7 +20,7 @@ import Tab, { TypeEnum } from './tab'
type Props = { type Props = {
appId: string appId: string
app?: AppType app?: AppType
category?: string categories?: string[]
onClose: () => void onClose: () => void
onCreate: () => void onCreate: () => void
} }
@ -28,7 +28,7 @@ type Props = {
const TryApp: FC<Props> = ({ const TryApp: FC<Props> = ({
appId, appId,
app, app,
category, categories,
onClose, onClose,
onCreate, onCreate,
}) => { }) => {
@ -81,7 +81,7 @@ const TryApp: FC<Props> = ({
className="w-[360px] shrink-0" className="w-[360px] shrink-0"
appDetail={appDetail} appDetail={appDetail}
appId={appId} appId={appId}
category={category} categories={categories}
onCreate={onCreate} onCreate={onCreate}
/> />
</div> </div>

View File

@ -12,7 +12,7 @@ type AppBasicInfo = {
use_icon_as_answer_icon: boolean use_icon_as_answer_icon: boolean
} }
export type AppCategory = 'Writing' | 'Translate' | 'HR' | 'Programming' | 'Assistant' | 'Agent' | 'Recommended' | 'Workflow' export type AppCategory = string
export type App = { export type App = {
app: AppBasicInfo app: AppBasicInfo
@ -21,7 +21,7 @@ export type App = {
copyright: string copyright: string
privacy_policy: string | null privacy_policy: string | null
custom_disclaimer: string | null custom_disclaimer: string | null
category: AppCategory categories: AppCategory[]
position: number position: number
is_listed: boolean is_listed: boolean
install_count: number install_count: number