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/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/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py index 7bab3f7bff..4a741d3154 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -842,24 +842,24 @@ class WorkflowResponseConverter: return [] files: list[Mapping[str, Any]] = [] - if isinstance(value, FileSegment): - files.append(value.value.to_dict()) - elif isinstance(value, ArrayFileSegment): - files.extend([i.to_dict() for i in value.value]) - elif isinstance(value, File): - files.append(value.to_dict()) - elif isinstance(value, list): - for item in value: - file = cls._get_file_var_from_value(item) + match value: + case FileSegment(): + files.append(value.value.to_dict()) + case ArrayFileSegment(): + files.extend([i.to_dict() for i in value.value]) + case File(): + files.append(value.to_dict()) + case list(): + for item in value: + file = cls._get_file_var_from_value(item) + if file: + files.append(file) + case dict(): + file = cls._get_file_var_from_value(value) if file: files.append(file) - elif isinstance( - value, - dict, - ): - file = cls._get_file_var_from_value(value) - if file: - files.append(file) + case _: + pass return files diff --git a/api/core/prompt/utils/prompt_message_util.py b/api/core/prompt/utils/prompt_message_util.py index ba76eb0c4e..11414832e3 100644 --- a/api/core/prompt/utils/prompt_message_util.py +++ b/api/core/prompt/utils/prompt_message_util.py @@ -53,24 +53,27 @@ class PromptMessageUtil: files = [] if isinstance(prompt_message.content, list): for content in prompt_message.content: - if isinstance(content, TextPromptMessageContent): - text += content.data - elif isinstance(content, ImagePromptMessageContent): - files.append( - { - "type": "image", - "data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:], - "detail": content.detail.value, - } - ) - elif isinstance(content, AudioPromptMessageContent): - files.append( - { - "type": "audio", - "data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:], - "format": content.format, - } - ) + match content: + case TextPromptMessageContent(): + text += content.data + case ImagePromptMessageContent(): + files.append( + { + "type": "image", + "data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:], + "detail": content.detail.value, + } + ) + case AudioPromptMessageContent(): + files.append( + { + "type": "audio", + "data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:], + "format": content.format, + } + ) + case _: + continue else: text = cast(str, prompt_message.content) diff --git a/api/core/tools/utils/message_transformer.py b/api/core/tools/utils/message_transformer.py index 5679466cbc..4c6e647335 100644 --- a/api/core/tools/utils/message_transformer.py +++ b/api/core/tools/utils/message_transformer.py @@ -23,36 +23,37 @@ _TOOL_FILE_URL_PATTERN = re.compile(r"(?:^|/+)files/tools/(?P[^/?# def safe_json_value(v): - if isinstance(v, datetime): - tz_name = "UTC" - if isinstance(current_user, Account) and current_user.timezone is not None: - tz_name = current_user.timezone - return v.astimezone(pytz.timezone(tz_name)).isoformat() - elif isinstance(v, date): - return v.isoformat() - elif isinstance(v, UUID): - return str(v) - elif isinstance(v, Decimal): - return float(v) - elif isinstance(v, bytes): - try: - return v.decode("utf-8") - except UnicodeDecodeError: - return v.hex() - elif isinstance(v, memoryview): - return v.tobytes().hex() - elif isinstance(v, np.integer): - return int(v) - elif isinstance(v, np.floating): - return float(v) - elif isinstance(v, np.ndarray): - return v.tolist() - elif isinstance(v, dict): - return safe_json_dict(v) - elif isinstance(v, list | tuple | set): - return [safe_json_value(i) for i in v] - else: - return v + match v: + case datetime(): + tz_name = "UTC" + if isinstance(current_user, Account) and current_user.timezone is not None: + tz_name = current_user.timezone + return v.astimezone(pytz.timezone(tz_name)).isoformat() + case date(): + return v.isoformat() + case UUID(): + return str(v) + case Decimal(): + return float(v) + case bytes(): + try: + return v.decode("utf-8") + except UnicodeDecodeError: + return v.hex() + case memoryview(): + return v.tobytes().hex() + case np.integer(): + return int(v) + case np.floating(): + return float(v) + case np.ndarray(): + return v.tolist() + case dict(): + return safe_json_dict(v) + case list() | tuple() | set(): + return [safe_json_value(i) for i in v] + case _: + return v def safe_json_dict(d: dict[str, Any]): 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/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/services/variable_truncator.py b/api/services/variable_truncator.py index 1529c2b98f..5dd5f6873f 100644 --- a/api/services/variable_truncator.py +++ b/api/services/variable_truncator.py @@ -194,14 +194,15 @@ class VariableTruncator(BaseTruncator): result: _PartResult[Any] # Apply type-specific truncation with target size - if isinstance(segment, ArraySegment): - result = self._truncate_array(segment.value, target_size) - elif isinstance(segment, StringSegment): - result = self._truncate_string(segment.value, target_size) - elif isinstance(segment, ObjectSegment): - result = self._truncate_object(segment.value, target_size) - else: - raise AssertionError("this should be unreachable.") + match segment: + case ArraySegment(): + result = self._truncate_array(segment.value, target_size) + case StringSegment(): + result = self._truncate_string(segment.value, target_size) + case ObjectSegment(): + result = self._truncate_object(segment.value, target_size) + case _: + raise AssertionError("this should be unreachable.") return _PartResult( value=segment.model_copy(update={"value": result.value}), @@ -219,40 +220,41 @@ class VariableTruncator(BaseTruncator): return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1) if depth > _MAX_DEPTH: raise MaxDepthExceededError() - if isinstance(value, str): - # Ideally, the size of strings should be calculated based on their utf-8 encoded length. - # However, this adds complexity as we would need to compute encoded sizes consistently - # throughout the code. Therefore, we approximate the size using the string's length. - # Rough estimate: number of characters, plus 2 for quotes - return len(value) + 2 - elif isinstance(value, (int, float)): - return len(str(value)) - elif isinstance(value, bool): - return 4 if value else 5 # "true" or "false" - elif value is None: - return 4 # "null" - elif isinstance(value, list): - # Size = sum of elements + separators + brackets - total = 2 # "[]" - for i, item in enumerate(value): - if i > 0: - total += 1 # "," - total += VariableTruncator.calculate_json_size(item, depth=depth + 1) - return total - elif isinstance(value, dict): - # Size = sum of keys + values + separators + brackets - total = 2 # "{}" - for index, key in enumerate(value.keys()): - if index > 0: - total += 1 # "," - total += VariableTruncator.calculate_json_size(str(key), depth=depth + 1) # Key as string - total += 1 # ":" - total += VariableTruncator.calculate_json_size(value[key], depth=depth + 1) - return total - elif isinstance(value, File): - return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1) - else: - raise UnknownTypeError(f"got unknown type {type(value)}") + match value: + case str(): + # Ideally, the size of strings should be calculated based on their utf-8 encoded length. + # However, this adds complexity as we would need to compute encoded sizes consistently + # throughout the code. Therefore, we approximate the size using the string's length. + # Rough estimate: number of characters, plus 2 for quotes + return len(value) + 2 + case bool(): + return 4 if value else 5 # "true" or "false" + case int() | float(): + return len(str(value)) + case None: + return 4 # "null" + case list(): + # Size = sum of elements + separators + brackets + total = 2 # "[]" + for i, item in enumerate(value): + if i > 0: + total += 1 # "," + total += VariableTruncator.calculate_json_size(item, depth=depth + 1) + return total + case dict(): + # Size = sum of keys + values + separators + brackets + total = 2 # "{}" + for index, key in enumerate(value.keys()): + if index > 0: + total += 1 # "," + total += VariableTruncator.calculate_json_size(str(key), depth=depth + 1) # Key as string + total += 1 # ":" + total += VariableTruncator.calculate_json_size(value[key], depth=depth + 1) + return total + case File(): + return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1) + case _: + raise UnknownTypeError(f"got unknown type {type(value)}") def _truncate_string(self, value: str, target_size: int) -> _PartResult[str]: if (size := self.calculate_json_size(value)) < target_size: @@ -419,22 +421,23 @@ class VariableTruncator(BaseTruncator): target_size: int, ) -> _PartResult[Any]: """Truncate a value within an object to fit within budget.""" - if isinstance(val, UpdatedVariable): - # TODO(Workflow): push UpdatedVariable normalization closer to its producer. - return self._truncate_object(val.model_dump(), target_size) - elif isinstance(val, str): - return self._truncate_string(val, target_size) - elif isinstance(val, list): - return self._truncate_array(val, target_size) - elif isinstance(val, dict): - return self._truncate_object(val, target_size) - elif isinstance(val, File): - # File objects should not be truncated, return as-is - return _PartResult(val, self.calculate_json_size(val), False) - elif val is None or isinstance(val, (bool, int, float)): - return _PartResult(val, self.calculate_json_size(val), False) - else: - raise AssertionError("this statement should be unreachable.") + match val: + case UpdatedVariable(): + # TODO(Workflow): push UpdatedVariable normalization closer to its producer. + return self._truncate_object(val.model_dump(), target_size) + case str(): + return self._truncate_string(val, target_size) + case list(): + return self._truncate_array(val, target_size) + case dict(): + return self._truncate_object(val, target_size) + case File(): + # File objects should not be truncated, return as-is + return _PartResult(val, self.calculate_json_size(val), False) + case None | bool() | int() | float(): + return _PartResult(val, self.calculate_json_size(val), False) + case _: + raise AssertionError("this statement should be unreachable.") class DummyVariableTruncator(BaseTruncator): 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/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") diff --git a/eslint-suppressions.json b/eslint-suppressions.json index b5e67df509..cb41ef5f83 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -202,6 +202,11 @@ "count": 1 } }, + "web/app/components/app/annotation/add-annotation-modal/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/app/annotation/batch-add-annotation-modal/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -230,6 +235,11 @@ "count": 1 } }, + "web/app/components/app/annotation/edit-annotation-modal/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/app/annotation/header-opts/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -252,6 +262,9 @@ "erasable-syntax-only/enums": { "count": 1 }, + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 5 }, @@ -269,11 +282,6 @@ "count": 4 } }, - "web/app/components/app/app-publisher/index.tsx": { - "ts/no-explicit-any": { - "count": 5 - } - }, "web/app/components/app/app-publisher/version-info-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -344,6 +352,9 @@ } }, "web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react-hooks/exhaustive-deps": { "count": 1 }, @@ -401,6 +412,16 @@ "count": 2 } }, + "web/app/components/app/configuration/configuration-view.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/app/components/app/configuration/dataset-config/card-item/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/app/configuration/dataset-config/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -531,6 +552,9 @@ } }, "web/app/components/app/log/list.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 6 }, @@ -580,6 +604,9 @@ } }, "web/app/components/app/workflow-log/list.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 2 } @@ -904,6 +931,11 @@ "count": 1 } }, + "web/app/components/base/drawer-plus/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/base/emoji-picker/index.tsx": { "no-restricted-imports": { "count": 1 @@ -1029,6 +1061,11 @@ "count": 3 } }, + "web/app/components/base/float-right-container/index.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, "web/app/components/base/form/components/base/base-form.tsx": { "ts/no-explicit-any": { "count": 6 @@ -1233,7 +1270,7 @@ }, "web/app/components/base/icons/src/vender/line/development/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 2 + "count": 1 } }, "web/app/components/base/icons/src/vender/line/editor/index.ts": { @@ -2144,14 +2181,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/batch-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/completed/common/chunk-content.tsx": { "react/set-state-in-effect": { "count": 1 @@ -2162,11 +2191,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/completed/components/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 3 - } - }, "web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx": { "ts/no-non-null-asserted-optional-chain": { "count": 1 @@ -2231,14 +2255,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/segment-add/index.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - }, - "react-refresh/only-export-components": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx": { "ts/no-explicit-any": { "count": 6 @@ -2280,6 +2296,9 @@ } }, "web/app/components/datasets/hit-testing/index.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/unsupported-syntax": { "count": 1 } @@ -2319,7 +2338,7 @@ }, "web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx": { "no-restricted-imports": { - "count": 2 + "count": 3 } }, "web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx": { @@ -2813,10 +2832,18 @@ } }, "web/app/components/plugins/plugin-detail-panel/endpoint-modal.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 7 } }, + "web/app/components/plugins/plugin-detail-panel/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/plugins/plugin-detail-panel/model-list.tsx": { "ts/no-explicit-any": { "count": 1 @@ -2838,6 +2865,9 @@ } }, "web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 2 } @@ -2896,6 +2926,9 @@ } }, "web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 5 } @@ -2933,16 +2966,6 @@ "count": 1 } }, - "web/app/components/plugins/readme-panel/index.tsx": { - "react/unsupported-syntax": { - "count": 1 - } - }, - "web/app/components/plugins/readme-panel/store.ts": { - "erasable-syntax-only/enums": { - "count": 1 - } - }, "web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -3170,7 +3193,7 @@ }, "web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 } }, "web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": { @@ -3179,6 +3202,9 @@ } }, "web/app/components/tools/edit-custom-collection-modal/index.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 4 }, @@ -3187,6 +3213,9 @@ } }, "web/app/components/tools/edit-custom-collection-modal/test-api.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 1 } @@ -3196,6 +3225,11 @@ "count": 1 } }, + "web/app/components/tools/mcp/detail/provider-detail.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/tools/mcp/mcp-server-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -3224,12 +3258,20 @@ "count": 1 } }, + "web/app/components/tools/provider/detail.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/tools/provider/empty.tsx": { "ts/no-explicit-any": { "count": 1 } }, "web/app/components/tools/setting/build-in/config-credentials.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 3 } @@ -4061,6 +4103,11 @@ "count": 1 } }, + "web/app/components/workflow/nodes/knowledge-retrieval/components/dataset-item.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-item.tsx": { "ts/no-explicit-any": { "count": 1 diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index bdeeec33cb..2915fe5db7 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -28,6 +28,7 @@ Always import from a **subpath export** — there is no barrel: import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog' +import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import '@langgenius/dify-ui/styles.css' // once, in the app root ``` @@ -36,12 +37,12 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported ## Primitives -| Category | Subpath | Notes | -| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. | -| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. | -| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. | -| Media | `./avatar`, `./button` | Button exposes `cva` variants. | +| Category | Subpath | Notes | +| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. | +| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. | +| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. | +| Media | `./avatar`, `./button` | Button exposes `cva` variants. | Utilities: @@ -65,7 +66,7 @@ If a consumer uses Dify UI source files through the workspace, add an explicit s ## Overlay & portal contract -All overlay primitives (`dialog`, `alert-dialog`, `autocomplete`, `combobox`, `popover`, `dropdown-menu`, `context-menu`, `select`, `tooltip`, `toast`) render their content inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Consumers **do not** need to wrap anything in a portal manually. +Overlay primitives render their floating surfaces inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Convenience content components such as `DialogContent`, `PopoverContent`, and `SelectContent` own their portal internally; primitives with explicit portal anatomy such as `Drawer` expose the matching `DrawerPortal` part so consumers can compose the full Base UI structure. ### Root isolation requirement @@ -83,19 +84,19 @@ Equivalent: any root element with `isolation: isolate` in CSS. Without it, overl Every overlay primitive uses a single, shared z-index. Do **not** override it at call sites. -| Layer | z-index | Where | -| ----------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- | -| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop | -| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. | +| Layer | z-index | Where | +| ------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- | +| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Drawer, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop | +| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. | -Rationale: during Dify's migration from legacy `base/modal` / `base/dialog` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins. +Rationale: during Dify's migration from legacy `base/modal` / `base/dialog` / `base/drawer` / `base/drawer-plus` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins. See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for the Dify-web migration history. Once the legacy overlays are gone, the values in this table can drop back to `z-50` / `z-51`. ### Rules - Never add `z-1003` / `z-9999` / etc. overrides on primitives from this package. If something is getting clipped, the **parent** overlay (typically a legacy one) is the problem and should be migrated. -- Never portal an overlay manually on top of our primitives — use `DialogTrigger`, `PopoverTrigger`, etc. Base UI handles focus management, scroll-locking, and dismissal. +- Never create an extra manual portal on top of our primitives — use the exported content / portal parts such as `DialogContent`, `PopoverContent`, and `DrawerPortal`. Base UI handles focus management, scroll-locking, and dismissal. - When a primitive needs additional presentation chrome (e.g. a custom backdrop), add it **inside** the exported component, not at call sites. ## Development diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index 20e94c7dee..894e92bfd6 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -37,6 +37,10 @@ "types": "./src/dialog/index.tsx", "import": "./src/dialog/index.tsx" }, + "./drawer": { + "types": "./src/drawer/index.tsx", + "import": "./src/drawer/index.tsx" + }, "./dropdown-menu": { "types": "./src/dropdown-menu/index.tsx", "import": "./src/dropdown-menu/index.tsx" diff --git a/packages/dify-ui/src/drawer/__tests__/index.spec.tsx b/packages/dify-ui/src/drawer/__tests__/index.spec.tsx new file mode 100644 index 0000000000..8c3a93f02c --- /dev/null +++ b/packages/dify-ui/src/drawer/__tests__/index.spec.tsx @@ -0,0 +1,61 @@ +import { render } from 'vitest-browser-react' +import { + Drawer, + DrawerBackdrop, + DrawerCloseButton, + DrawerContent, + DrawerDescription, + DrawerPopup, + DrawerPortal, + DrawerTitle, + DrawerTrigger, + DrawerViewport, +} from '../index' + +const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement + +describe('Drawer wrapper', () => { + describe('User Interactions', () => { + it('should open a portalled drawer and close it with the default close button', async () => { + const screen = await render( + + Open settings + + + + + Settings + Configure the current workspace. + +

Workspace controls

+ +
+
+
+
+
, + ) + + expect(document.body.querySelector('[role="dialog"]')).not.toBeInTheDocument() + + asHTMLElement(screen.getByRole('button', { name: 'Open settings' }).element()).click() + + await vi.waitFor(() => { + expect(document.body.querySelector('[role="dialog"]')).toBeInTheDocument() + }) + + const dialog = asHTMLElement(document.body.querySelector('[role="dialog"]')!) + expect(document.body).toContainElement(dialog) + expect(screen.container).not.toContainElement(dialog) + await expect.element(dialog).toHaveTextContent('Workspace controls') + await expect.element(screen.getByText('Configure the current workspace.')).toBeInTheDocument() + await expect.element(screen.getByTestId('drawer-backdrop')).toHaveClass('z-1002') + + asHTMLElement(screen.getByRole('button', { name: 'Close drawer' }).element()).click() + + await vi.waitFor(() => { + expect(document.body.querySelector('[role="dialog"]')).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/packages/dify-ui/src/drawer/index.tsx b/packages/dify-ui/src/drawer/index.tsx new file mode 100644 index 0000000000..c63bc8174e --- /dev/null +++ b/packages/dify-ui/src/drawer/index.tsx @@ -0,0 +1,116 @@ +'use client' + +import type { ReactNode } from 'react' +import { Drawer as BaseDrawer } from '@base-ui/react/drawer' +import { cn } from '../cn' + +export const Drawer = BaseDrawer.Root +export const DrawerProvider = BaseDrawer.Provider +export const DrawerIndent = BaseDrawer.Indent +export const DrawerIndentBackground = BaseDrawer.IndentBackground +export const DrawerTrigger = BaseDrawer.Trigger +export const DrawerSwipeArea = BaseDrawer.SwipeArea +export const DrawerPortal = BaseDrawer.Portal +export const DrawerTitle = BaseDrawer.Title +export const DrawerDescription = BaseDrawer.Description +export const DrawerClose = BaseDrawer.Close +export const createDrawerHandle = BaseDrawer.createHandle + +export type DrawerRootProps = BaseDrawer.Root.Props +export type DrawerRootActions = BaseDrawer.Root.Actions +export type DrawerRootChangeEventDetails = BaseDrawer.Root.ChangeEventDetails +export type DrawerRootChangeEventReason = BaseDrawer.Root.ChangeEventReason +export type DrawerRootSnapPoint = BaseDrawer.Root.SnapPoint +export type DrawerRootSnapPointChangeEventDetails = BaseDrawer.Root.SnapPointChangeEventDetails +export type DrawerRootSnapPointChangeEventReason = BaseDrawer.Root.SnapPointChangeEventReason +export type DrawerTriggerProps = BaseDrawer.Trigger.Props + +export function DrawerBackdrop({ + className, + ...props +}: BaseDrawer.Backdrop.Props) { + return ( + + ) +} + +export function DrawerViewport({ + className, + ...props +}: BaseDrawer.Viewport.Props) { + return ( + + ) +} + +export function DrawerPopup({ + className, + ...props +}: BaseDrawer.Popup.Props) { + return ( + + ) +} + +export function DrawerContent({ + className, + ...props +}: BaseDrawer.Content.Props) { + return ( + + ) +} + +type DrawerCloseButtonProps = Omit & { + children?: ReactNode +} + +export function DrawerCloseButton({ + className, + children, + type = 'button', + 'aria-label': ariaLabel = 'Close drawer', + ...props +}: DrawerCloseButtonProps) { + return ( + + {children ?? + ) +} 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/__tests__/tools/tool-provider-detail-flow.test.tsx b/web/__tests__/tools/tool-provider-detail-flow.test.tsx index 9cf4772152..c0dd6da1c5 100644 --- a/web/__tests__/tools/tool-provider-detail-flow.test.tsx +++ b/web/__tests__/tools/tool-provider-detail-flow.test.tsx @@ -205,7 +205,7 @@ vi.mock('@/app/components/tools/setting/build-in/config-credentials', () => ({ })) vi.mock('@/app/components/tools/workflow-tool', () => ({ - default: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => ( + WorkflowToolDrawer: ({ onHide, onSave, onRemove }: { payload: unknown, onHide: () => void, onSave: (d: unknown) => void, onRemove: () => void }) => (
diff --git a/web/app/(shareLayout)/components/authenticated-layout.tsx b/web/app/(shareLayout)/components/authenticated-layout.tsx index a7b65f33fe..3ee5d52603 100644 --- a/web/app/(shareLayout)/components/authenticated-layout.tsx +++ b/web/app/(shareLayout)/components/authenticated-layout.tsx @@ -39,7 +39,9 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => { const getSigninUrl = useCallback(() => { const params = new URLSearchParams(searchParams) params.delete('message') - params.set('redirect_url', pathname) + const query = params.toString() + const fullPath = query ? `${pathname}?${query}` : pathname + params.set('redirect_url', fullPath) return `/webapp-signin?${params.toString()}` }, [searchParams, pathname]) diff --git a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx b/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx index 9afa0063dc..3dabb2a91e 100644 --- a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx @@ -97,7 +97,7 @@ const AppInfoDetailPanel = ({
diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index cbfd679ace..0dfb4347e4 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -20,6 +20,7 @@ const mockOpenAsyncWindow = vi.fn() const mockFetchInstalledAppList = vi.fn() const mockFetchAppDetailDirect = vi.fn() const mockToastError = vi.fn() +const mockWindowOpen = vi.fn() const mockInvalidateAppWorkflow = vi.fn() const sectionProps = vi.hoisted(() => ({ @@ -37,6 +38,7 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, }), + Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null, })) vi.mock('ahooks', async () => { @@ -91,6 +93,21 @@ vi.mock('@/service/use-workflow', () => ({ useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow, })) +vi.mock('@/service/use-tools', () => ({ + useWorkflowToolDetailByAppID: () => ({ + data: undefined, + isLoading: false, + }), + useInvalidateAllWorkflowTools: () => vi.fn(), + useInvalidateWorkflowToolDetailByAppID: () => vi.fn(), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: true, + }), +})) + vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (...args: unknown[]) => mockToastError(...args), @@ -121,6 +138,15 @@ vi.mock('../../app-access-control', () => ({ ), })) +vi.mock('@/app/components/tools/workflow-tool', () => ({ + WorkflowToolDrawer: ({ onHide }: { onHide: () => void }) => ( +
+ workflow tool drawer + +
+ ), +})) + vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) vi.mock('../sections', () => ({ @@ -143,6 +169,13 @@ vi.mock('../sections', () => ({
+ {props.handleOpenRunConfig && ( + <> + + + + )} +
) }, @@ -175,6 +208,10 @@ describe('AppPublisher', () => { mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise) => { await resolver() }) + Object.defineProperty(window, 'open', { + writable: true, + value: mockWindowOpen, + }) }) it('should open the publish popover and refetch access permission data', async () => { @@ -231,6 +268,94 @@ describe('AppPublisher', () => { expect(screen.getByTestId('embedded-modal'))!.toBeInTheDocument() }) + it('should collect hidden inputs before opening published run links from config actions', async () => { + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-run-config')) + + expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Secret'), { + target: { value: 'top-secret' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' })) + + await waitFor(() => { + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/chat/token-1?secret=${encodeURIComponent('top-secret')}`, + '_blank', + ) + }) + }) + + it('should open batch run config links with the configured hidden inputs', async () => { + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-batch-run-config')) + + fireEvent.change(screen.getByLabelText('Batch Secret'), { + target: { value: 'batch-value' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' })) + + await waitFor(() => { + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/workflow/token-1?mode=batch&batch_secret=${encodeURIComponent('batch-value')}`, + '_blank', + ) + }) + }) + + it('should keep workflow tool drawer mounted after closing the publish popover', () => { + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-workflow-tool')) + + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() + expect(screen.getByTestId('workflow-tool-drawer')).toBeInTheDocument() + }) + it('should close embedded and access control panels through child callbacks', async () => { render( ({ })) vi.mock('../suggested-action', () => ({ - default: ({ children, onClick, link, disabled }: { children: ReactNode, onClick?: () => void, link?: string, disabled?: boolean }) => ( - + default: ({ + children, + onClick, + link, + disabled, + actionButton, + }: { + children: ReactNode + onClick?: () => void + link?: string + disabled?: boolean + actionButton?: { ariaLabel: string, onClick: () => void } + }) => ( +
+ + {actionButton && ( + + )} +
), })) @@ -170,9 +194,25 @@ describe('app-publisher sections', () => { expect(render().container).toBeEmptyDOMElement() }) + it('should hide access control content when enabled is false', () => { + render( + , + ) + + expect(screen.queryByText('publishApp.title')).not.toBeInTheDocument() + expect(screen.queryByText('accessControlDialog.accessItems.anyone')).not.toBeInTheDocument() + }) + it('should render workflow actions, batch run links, and workflow tool configuration', () => { const handleOpenInExplore = vi.fn() const handleEmbed = vi.fn() + const handleOpenRunConfig = vi.fn() const { rerender } = render( { disabledFunctionTooltip="disabled" handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} + handleOpenRunConfig={handleOpenRunConfig} handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode={false} - inputs={[]} missingStartNode={false} - onRefreshData={vi.fn()} - outputs={[]} - published={true} + published={false} publishedAt={Date.now()} + showBatchRunConfig + showRunConfig toolPublished workflowToolAvailable={false} + workflowToolIsLoading={false} + workflowToolOutdated={false} + workflowToolIsCurrentWorkspaceManager workflowToolMessage="workflow-disabled" + onConfigureWorkflowTool={vi.fn()} />, ) expect(screen.getByText('common.batchRunApp')).toHaveAttribute('data-link', 'https://example.com/app?mode=batch') + fireEvent.click(screen.getAllByRole('button', { name: 'operation.config' })[0]!) + expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app') + fireEvent.click(screen.getAllByRole('button', { name: 'operation.config' })[1]!) + expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app?mode=batch') fireEvent.click(screen.getByText('common.openInExplore')) expect(handleOpenInExplore).toHaveBeenCalled() expect(screen.getByText('workflow-tool-configure')).toBeInTheDocument() @@ -223,17 +271,19 @@ describe('app-publisher sections', () => { disabledFunctionTooltip="disabled" handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} + handleOpenRunConfig={handleOpenRunConfig} handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode={false} - inputs={[]} missingStartNode - onRefreshData={vi.fn()} - outputs={[]} published={false} publishedAt={Date.now()} toolPublished={false} workflowToolAvailable + workflowToolIsLoading={false} + workflowToolOutdated={false} + workflowToolIsCurrentWorkspaceManager + onConfigureWorkflowTool={vi.fn()} />, ) @@ -248,16 +298,19 @@ describe('app-publisher sections', () => { disabledFunctionButton={false} handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} + handleOpenRunConfig={handleOpenRunConfig} handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode - inputs={[]} missingStartNode={false} - outputs={[]} published={false} publishedAt={undefined} toolPublished={false} workflowToolAvailable + workflowToolIsLoading={false} + workflowToolOutdated={false} + workflowToolIsCurrentWorkspaceManager + onConfigureWorkflowTool={vi.fn()} />, ) diff --git a/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx b/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx index ea199dfb78..2ca9e77abf 100644 --- a/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx @@ -46,4 +46,47 @@ describe('SuggestedAction', () => { expect(handleClick).toHaveBeenCalledTimes(1) }) + + it('should render and trigger the trailing action button when configured', () => { + const handleActionClick = vi.fn() + + render( + config, + onClick: handleActionClick, + }} + > + Configurable action + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Configure action' })) + + expect(screen.getByRole('link', { name: 'Configurable action' })).toHaveAttribute('href', 'https://example.com/docs') + expect(handleActionClick).toHaveBeenCalledTimes(1) + }) + + it('should block action button clicks when disabled', () => { + const handleActionClick = vi.fn() + + render( + config, + onClick: handleActionClick, + }} + > + Disabled with action + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Configure action' })) + expect(handleActionClick).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index fe6fe5806f..f5b2c80ae8 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -1,28 +1,40 @@ +import type { FormEvent } from 'react' import type { ModelAndParameter } from '../configuration/debug/types' +import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '@/app/components/app/overview/app-card-utils' import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration' import type { InputVar, Variable } from '@/app/components/workflow/types' import type { PublishWorkflowParams } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' -import { RiStoreLine } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' import { useKeyPress } from 'ahooks' import { + memo, + use, useCallback, - useContext, useEffect, useMemo, useState, } from 'react' import { useTranslation } from 'react-i18next' +import { WorkflowLaunchDialog } from '@/app/components/app/overview/app-card-sections' +import { + buildWorkflowLaunchUrl, + createWorkflowLaunchInitialValues, + isWorkflowLaunchInputSupported, + +} from '@/app/components/app/overview/app-card-utils' import EmbeddedModal from '@/app/components/app/overview/embedded' import { useStore as useAppStore } from '@/app/components/app/store' import { trackEvent } from '@/app/components/base/amplitude' +import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool' +import { useConfigureButton } from '@/app/components/tools/workflow-tool/hooks/use-configure-button' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' import { WorkflowContext } from '@/app/components/workflow/context' +import { appDefaultIconBackground } from '@/config' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { AccessMode } from '@/models/access-control' @@ -57,8 +69,8 @@ export type AppPublisherProps = { debugWithMultipleModel?: boolean multipleModelConfigs?: ModelAndParameter[] /** modelAndParameter is passed when debugWithMultipleModel is true */ - onPublish?: (params?: any) => Promise | any - onRestore?: () => Promise | any + onPublish?: AppPublisherPublishHandler + onRestore?: AppPublisherRestoreHandler onToggle?: (state: boolean) => void crossAxisOffset?: number toolPublished?: boolean @@ -74,6 +86,12 @@ export type AppPublisherProps = { const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P'] +type AppPublisherPublishHandler + = | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise | unknown) + | ((params?: unknown) => Promise | unknown) + +type AppPublisherRestoreHandler = () => Promise | unknown + const AppPublisher = ({ disabled = false, publishDisabled = false, @@ -100,11 +118,15 @@ const AppPublisher = ({ const [published, setPublished] = useState(false) const [open, setOpen] = useState(false) const [showAppAccessControl, setShowAppAccessControl] = useState(false) + const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false) const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) + const [workflowLaunchDialogOpen, setWorkflowLaunchDialogOpen] = useState(false) + const [workflowLaunchTargetUrl, setWorkflowLaunchTargetUrl] = useState('') + const [workflowLaunchValues, setWorkflowLaunchValues] = useState>({}) const [publishingToMarketplace, setPublishingToMarketplace] = useState(false) - const workflowStore = useContext(WorkflowContext) + const workflowStore = use(WorkflowContext) const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(s => s.setAppDetail) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) @@ -113,6 +135,22 @@ const AppPublisher = ({ const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode }) const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT) + const hiddenLaunchVariables = useMemo( + () => (inputs ?? []).filter(input => input.hide === true), + [inputs], + ) + const supportedWorkflowLaunchVariables = useMemo( + () => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported), + [hiddenLaunchVariables], + ) + const unsupportedWorkflowLaunchVariables = useMemo( + () => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)), + [hiddenLaunchVariables], + ) + const initialWorkflowLaunchValues = useMemo( + () => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables), + [supportedWorkflowLaunchVariables], + ) const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false }) const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS) @@ -222,6 +260,31 @@ const AppPublisher = ({ } }, [appDetail, setAppDetail]) + const handleOpenWorkflowLaunchDialog = useCallback((targetUrl: string) => { + setWorkflowLaunchValues(initialWorkflowLaunchValues) + setWorkflowLaunchTargetUrl(targetUrl) + setWorkflowLaunchDialogOpen(true) + }, [initialWorkflowLaunchValues]) + + const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => { + setWorkflowLaunchValues(prev => ({ + ...prev, + [variable]: value, + })) + }, []) + + const handleWorkflowLaunchConfirm = useCallback(async (event: FormEvent) => { + event.preventDefault() + + const targetUrl = await buildWorkflowLaunchUrl({ + accessibleUrl: workflowLaunchTargetUrl, + variables: supportedWorkflowLaunchVariables, + values: workflowLaunchValues, + }) + + window.open(targetUrl, '_blank') + setWorkflowLaunchDialogOpen(false) + }, [supportedWorkflowLaunchVariables, workflowLaunchTargetUrl, workflowLaunchValues]) const handlePublishToMarketplace = useCallback(async () => { if (!appDetail?.id || publishingToMarketplace) return @@ -273,6 +336,31 @@ const AppPublisher = ({ const workflowToolMessage = !hasPublishedVersion || !workflowToolAvailable ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) : undefined + const workflowToolVisible = appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode + const workflowToolPublished = !!toolPublished + const closeWorkflowToolDrawer = useCallback(() => setWorkflowToolDrawerOpen(false), []) + const workflowToolIcon = useMemo(() => ({ + content: (appDetail?.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖', + background: (appDetail?.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground, + }), [appDetail?.icon, appDetail?.icon_background, appDetail?.icon_type]) + const workflowTool = useConfigureButton({ + enabled: workflowToolVisible, + published: workflowToolPublished, + detailNeedUpdate: workflowToolPublished && published, + workflowAppId: appDetail?.id ?? '', + icon: workflowToolIcon, + name: appDetail?.name ?? '', + description: appDetail?.description ?? '', + inputs, + outputs, + handlePublish, + onRefreshData, + onConfigured: closeWorkflowToolDrawer, + }) + const openWorkflowToolDrawer = useCallback(() => { + handleOpenChange(false) + setWorkflowToolDrawerOpen(true) + }, [handleOpenChange]) const upgradeHighlightStyle = useMemo(() => ({ background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)', WebkitBackgroundClip: 'text', @@ -343,23 +431,27 @@ const AppPublisher = ({ handleOpenChange(false) handleOpenInExplore() }} + handleOpenRunConfig={handleOpenWorkflowLaunchDialog} handlePublish={handlePublish} hasHumanInputNode={hasHumanInputNode} hasTriggerNode={hasTriggerNode} - inputs={inputs} missingStartNode={missingStartNode} - onRefreshData={onRefreshData} - outputs={outputs} published={published} publishedAt={publishedAt} + showBatchRunConfig={hiddenLaunchVariables.length > 0 && (appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION)} + showRunConfig={hiddenLaunchVariables.length > 0} toolPublished={toolPublished} workflowToolAvailable={workflowToolAvailable} + workflowToolIsLoading={workflowTool.isLoading} + workflowToolOutdated={workflowTool.outdated} + workflowToolIsCurrentWorkspaceManager={workflowTool.isCurrentWorkspaceManager} workflowToolMessage={workflowToolMessage} + onConfigureWorkflowTool={openWorkflowToolDrawer} /> {systemFeatures.enable_creators_platform && (
} + icon={} disabled={!publishedAt || publishingToMarketplace} onClick={handlePublishToMarketplace} > @@ -377,9 +469,29 @@ const AppPublisher = ({ onClose={() => setEmbeddingModalOpen(false)} appBaseUrl={appBaseURL} accessToken={accessToken} + hiddenInputs={hiddenLaunchVariables} /> {showAppAccessControl && { setShowAppAccessControl(false) }} />} + + {workflowToolDrawerOpen && ( + + )} ) } diff --git a/web/app/components/app/app-publisher/sections.tsx b/web/app/components/app/app-publisher/sections.tsx index 57522095ae..712312b744 100644 --- a/web/app/components/app/app-publisher/sections.tsx +++ b/web/app/components/app/app-publisher/sections.tsx @@ -8,13 +8,12 @@ import { TooltipContent, TooltipTrigger, } from '@langgenius/dify-ui/tooltip' +import { RiSettings2Line } from '@remixicon/react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' -import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development' import Loading from '@/app/components/base/loading' import UpgradeBtn from '@/app/components/billing/upgrade-btn' import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button' -import { appDefaultIconBackground } from '@/config' import { AppModeEnum } from '@/types/app' import ShortcutsName from '../../workflow/shortcuts-name' import PublishWithMultipleModel from './publish-with-multiple-model' @@ -46,11 +45,8 @@ type AccessSectionProps = { type ActionsSectionProps = Pick & { appDetail: { @@ -67,9 +63,16 @@ type ActionsSectionProps = Pick void handleOpenInExplore: () => void + handleOpenRunConfig?: (url: string) => void handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise published: boolean + showBatchRunConfig?: boolean + showRunConfig?: boolean + workflowToolIsLoading: boolean + workflowToolOutdated: boolean + workflowToolIsCurrentWorkspaceManager: boolean workflowToolMessage?: string + onConfigureWorkflowTool: () => void } export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => { @@ -256,18 +259,20 @@ export const PublisherActionsSection = ({ disabledFunctionTooltip, handleEmbed, handleOpenInExplore, - handlePublish, + handleOpenRunConfig, hasHumanInputNode = false, hasTriggerNode = false, - inputs, missingStartNode = false, - onRefreshData, - outputs, - published, publishedAt, + showBatchRunConfig = false, + showRunConfig = false, toolPublished, workflowToolAvailable = true, + workflowToolIsLoading, + workflowToolOutdated, + workflowToolIsCurrentWorkspaceManager, workflowToolMessage, + onConfigureWorkflowTool, }: ActionsSectionProps) => { const { t } = useTranslation() @@ -284,6 +289,13 @@ export const PublisherActionsSection = ({ disabled={disabledFunctionButton} link={appURL} icon={} + actionButton={showRunConfig + ? { + ariaLabel: t('operation.config', { ns: 'common' }), + icon: , + onClick: () => handleOpenRunConfig?.(appURL), + } + : undefined} > {t('common.runApp', { ns: 'workflow' })} @@ -296,6 +308,13 @@ export const PublisherActionsSection = ({ disabled={disabledFunctionButton} link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`} icon={} + actionButton={showBatchRunConfig + ? { + ariaLabel: t('operation.config', { ns: 'common' }), + icon: , + onClick: () => handleOpenRunConfig?.(`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`), + } + : undefined} > {t('common.batchRunApp', { ns: 'workflow' })} @@ -305,7 +324,7 @@ export const PublisherActionsSection = ({ } + icon={} > {t('common.embedIntoSite', { ns: 'workflow' })} @@ -340,18 +359,10 @@ export const PublisherActionsSection = ({ )} diff --git a/web/app/components/app/app-publisher/suggested-action.tsx b/web/app/components/app/app-publisher/suggested-action.tsx index db13364eb9..c1cec6f819 100644 --- a/web/app/components/app/app-publisher/suggested-action.tsx +++ b/web/app/components/app/app-publisher/suggested-action.tsx @@ -1,33 +1,93 @@ -import type { HTMLProps, PropsWithChildren } from 'react' +import type { HTMLProps, PropsWithChildren, MouseEvent as ReactMouseEvent } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowRightUpLine } from '@remixicon/react' +type SuggestedActionButton = { + ariaLabel: string + icon: React.ReactNode + onClick: (event: ReactMouseEvent) => void +} + type SuggestedActionProps = PropsWithChildren & { icon?: React.ReactNode link?: string disabled?: boolean + actionButton?: SuggestedActionButton }> -const SuggestedAction = ({ icon, link, disabled, children, className, onClick, ...props }: SuggestedActionProps) => { - const handleClick = (e: React.MouseEvent) => { - if (disabled) +const SuggestedAction = ({ + icon, + link, + disabled, + children, + className, + onClick, + actionButton, + ...props +}: SuggestedActionProps) => { + const handleClick = (event: ReactMouseEvent) => { + if (disabled) { + event.preventDefault() return - onClick?.(e) + } + + onClick?.(event) } - return ( + + const handleActionClick = (event: ReactMouseEvent) => { + if (disabled) { + event.preventDefault() + return + } + + actionButton?.onClick(event) + } + + const mainAction = ( -
{icon}
+
{icon}
{children}
- +
) + + if (!actionButton) + return mainAction + + return ( +
+ {mainAction} + +
+ ) } export default SuggestedAction diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx index 7a63df3350..cdb1d17833 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx @@ -4,6 +4,29 @@ import { fireEvent, render, screen } from '@testing-library/react' import { InputVarType } from '@/app/components/workflow/types' import ConfigModalFormFields from '../form-fields' +vi.mock('react-i18next', async () => { + const React = await import('react') + return { + useTranslation: () => ({ + t: (key: string, options?: Record) => { + const ns = options?.ns as string | undefined + return ns ? `${ns}.${key}` : key + }, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), + Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record }) => ( + + {i18nKey} + {components?.docLink} + + ), + } +}) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path?: string) => `https://docs.example.com${path || ''}`, +})) + vi.mock('@/app/components/base/file-uploader', () => ({ FileUploaderInAttachmentWrapper: ({ onChange, @@ -74,6 +97,12 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => { } }) +vi.mock('@langgenius/dify-ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
, +})) + vi.mock('../field', () => ({ default: ({ children, title }: { children: ReactNode, title: string }) => (
@@ -176,7 +205,18 @@ describe('ConfigModalFormFields', () => { expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith('beta') }) - it('should wire file, json schema, and visibility controls', () => { + it('should wire file, json schema, and visibility controls', async () => { + const textInputProps = createBaseProps() + const textInputView = render() + expect(screen.getByText('variableConfig.hidden')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'variableConfig.hiddenDescription' })) + expect(await screen.findByText('variableConfig.hiddenDescription')).toBeInTheDocument() + const docLink = await screen.findByRole('link') + expect(docLink).toHaveAttribute('href', 'https://docs.example.com/use-dify/nodes/user-input#hide-and-pre-fill-input-fields') + expect(docLink).toHaveAttribute('target', '_blank') + expect(docLink).toHaveAttribute('rel', 'noopener noreferrer') + textInputView.unmount() + const singleFileProps = createBaseProps() singleFileProps.tempPayload = { ...singleFileProps.tempPayload, @@ -185,18 +225,20 @@ describe('ConfigModalFormFields', () => { allowed_file_extensions: [], allowed_file_upload_methods: ['remote_url'], } - render() + const singleFileView = render() + expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument() + expect(screen.queryByText('variableConfig.hiddenDescription')).not.toBeInTheDocument() fireEvent.click(screen.getByText('single-file-setting')) fireEvent.click(screen.getByText('upload-file')) fireEvent.click(screen.getAllByText('unchecked')[0]!) - fireEvent.click(screen.getAllByText('unchecked')[1]!) expect(singleFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 1 }) expect(singleFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith(expect.objectContaining({ fileId: 'file-1', })) expect(singleFileProps.payloadChangeHandlers.required).toHaveBeenCalledWith(true) - expect(singleFileProps.payloadChangeHandlers.hide).toHaveBeenCalledWith(true) + expect(singleFileProps.payloadChangeHandlers.hide).not.toHaveBeenCalled() + singleFileView.unmount() const multiFileProps = createBaseProps() multiFileProps.tempPayload = { @@ -207,8 +249,9 @@ describe('ConfigModalFormFields', () => { allowed_file_upload_methods: ['remote_url'], } render() + expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument() fireEvent.click(screen.getByText('multi-file-setting')) - fireEvent.click(screen.getAllByText('upload-file')[1]!) + fireEvent.click(screen.getAllByText('upload-file')[0]!) expect(multiFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 3 }) expect(multiFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith([ expect.objectContaining({ fileId: 'file-1' }), @@ -367,4 +410,23 @@ describe('ConfigModalFormFields', () => { expect(screen.getByRole('spinbutton')).toHaveValue(null) }) + + it('should disable hide checkbox when required is true and disable required when hide is true', () => { + const requiredProps = createBaseProps() + requiredProps.tempPayload = { ...requiredProps.tempPayload, type: InputVarType.textInput, required: true, hide: false } + const { unmount } = render() + + const buttons = screen.getAllByRole('button') + const hideButton = buttons.find(btn => btn.textContent === 'unchecked' && btn !== buttons[0]) + expect(hideButton).toBeDefined() + unmount() + + const hideProps = createBaseProps() + hideProps.tempPayload = { ...hideProps.tempPayload, type: InputVarType.textInput, required: false, hide: true } + render() + + const allButtons = screen.getAllByRole('button') + const checkedHideButton = allButtons.find(btn => btn.textContent === 'checked') + expect(checkedHideButton).toBeDefined() + }) }) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx index e6cb56f490..d32bcec755 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx @@ -25,6 +25,7 @@ vi.mock('../form-fields', () => ({ return (
{String(props.tempPayload.type)}
+
{String(props.tempPayload.hide)}
{String(props.tempPayload.label ?? '')}
{String(props.tempPayload.json_schema ?? '')}
{String(props.tempPayload.default ?? '')}
@@ -115,7 +116,7 @@ describe('ConfigModal logic', () => { }) it('should derive payload fields from mocked form-field callbacks', async () => { - renderConfigModal() + renderConfigModal(createPayload({ hide: true })) fireEvent.click(screen.getByTestId('valid-key-blur')) await waitFor(() => { @@ -138,6 +139,7 @@ describe('ConfigModal logic', () => { fireEvent.click(screen.getByTestId('type-change')) await waitFor(() => { expect(screen.getByTestId('payload-type')).toHaveTextContent(InputVarType.singleFile) + expect(screen.getByTestId('payload-hide')).toHaveTextContent('false') }) fireEvent.click(screen.getByTestId('file-payload-change')) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts b/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts index 1c00e1c5b2..2317868004 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts @@ -49,11 +49,13 @@ describe('config-modal utils', () => { const payload = createInputVar({ type: InputVarType.textInput, default: 'hello', + hide: true, }) const nextPayload = createPayloadForType(payload, InputVarType.multiFiles) expect(nextPayload.type).toBe(InputVarType.multiFiles) + expect(nextPayload.hide).toBe(false) expect(nextPayload.max_length).toBe(DEFAULT_FILE_UPLOAD_SETTING.max_length) expect(nextPayload.allowed_file_types).toEqual(DEFAULT_FILE_UPLOAD_SETTING.allowed_file_types) expect(nextPayload.default).toBe('hello') @@ -249,6 +251,24 @@ describe('config-modal utils', () => { }) }) + it('should force file inputs to stay visible when saving', () => { + const result = validateConfigModalPayload({ + tempPayload: createInputVar({ + type: InputVarType.singleFile, + hide: true, + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_extensions: [], + }), + payload: createInputVar(), + checkVariableName: () => true, + t, + }) + + expect(result.payloadToSave).toEqual(expect.objectContaining({ + hide: false, + })) + }) + it('should stop validation when the variable name checker rejects the payload', () => { const result = validateConfigModalPayload({ tempPayload: createInputVar({ diff --git a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx index 748108e19a..4bd938c3f6 100644 --- a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx @@ -13,14 +13,17 @@ import { SelectValue, } from '@langgenius/dify-ui/select' import * as React from 'react' +import { Trans } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' +import { Infotip } from '@/app/components/base/infotip' import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { useDocLink } from '@/context/i18n' import { TransferMethod } from '@/types/app' import ConfigSelect from '../config-select' import ConfigString from '../config-string' @@ -68,6 +71,9 @@ const ConfigModalFormFields: FC = ({ t, }) => { const { type, label, variable } = tempPayload + const isFileInput = [InputVarType.singleFile, InputVarType.multiFiles].includes(type) + const docLink = useDocLink() + const hiddenDescriptionAriaLabel = t('variableConfig.hiddenDescription', { ns: 'appDebug' }).replace(/<[^>]+>/g, '') return (
@@ -105,7 +111,7 @@ const ConfigModalFormFields: FC = ({ {type === InputVarType.textInput && ( onPayloadChange('default')(e.target.value || undefined)} placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })} /> @@ -126,7 +132,7 @@ const ConfigModalFormFields: FC = ({ onPayloadChange('default')(e.target.value || undefined)} placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })} /> @@ -186,7 +192,7 @@ const ConfigModalFormFields: FC = ({ )} - {[InputVarType.singleFile, InputVarType.multiFiles].includes(type) && ( + {isFileInput && ( <> = ({ )}
- onPayloadChange('required')(!tempPayload.required)} /> + onPayloadChange('required')(!tempPayload.required)} /> {t('variableConfig.required', { ns: 'appDebug' })}
-
- onPayloadChange('hide')(!tempPayload.hide)} /> - {t('variableConfig.hide', { ns: 'appDebug' })} -
+ {!isFileInput && ( +
+ onPayloadChange('hide')(!tempPayload.hide)} /> +
+ {t('variableConfig.hidden', { ns: 'appDebug' })} + + + ), + }} + /> + +
+
+ )}
) } diff --git a/web/app/components/app/configuration/config-var/config-modal/utils.ts b/web/app/components/app/configuration/config-var/config-modal/utils.ts index fdc0ac3501..e24e4b6593 100644 --- a/web/app/components/app/configuration/config-var/config-modal/utils.ts +++ b/web/app/components/app/configuration/config-var/config-modal/utils.ts @@ -88,7 +88,9 @@ export const createPayloadForType = (payload: InputVar, type: InputVarType) => { draft.default = undefined if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) { - (Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array).forEach((key) => { + draft.hide = false + const fileUploadSettingKeys = Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array + fileUploadSettingKeys.forEach((key) => { if (key !== 'max_length') draft[key] = DEFAULT_FILE_UPLOAD_SETTING[key] as never }) @@ -158,38 +160,41 @@ export const validateConfigModalPayload = ({ checkVariableName, t, }: ValidateConfigModalPayloadOptions): ValidateConfigModalPayloadResult => { + const normalizedTempPayload = [InputVarType.singleFile, InputVarType.multiFiles].includes(tempPayload.type) + ? { ...tempPayload, hide: false } + : tempPayload const jsonSchemaValue = tempPayload.json_schema const schemaEmpty = isJsonSchemaEmpty(jsonSchemaValue) const normalizedJsonSchema = schemaEmpty ? undefined : jsonSchemaValue - const payloadToSave = tempPayload.type === InputVarType.jsonObject && schemaEmpty - ? { ...tempPayload, json_schema: undefined } - : tempPayload + const payloadToSave = normalizedTempPayload.type === InputVarType.jsonObject && schemaEmpty + ? { ...normalizedTempPayload, json_schema: undefined } + : normalizedTempPayload - const moreInfo = tempPayload.variable === payload?.variable + const moreInfo = normalizedTempPayload.variable === payload?.variable ? undefined : { type: ChangeType.changeVarName, - payload: { beforeKey: payload?.variable || '', afterKey: tempPayload.variable }, + payload: { beforeKey: payload?.variable || '', afterKey: normalizedTempPayload.variable }, } - if (!checkVariableName(tempPayload.variable)) + if (!checkVariableName(normalizedTempPayload.variable)) return {} - if (!tempPayload.label) { + if (!normalizedTempPayload.label) { return { errorMessage: t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }), } } - if (tempPayload.type === InputVarType.select) { - if (!tempPayload.options?.length) { + if (normalizedTempPayload.type === InputVarType.select) { + if (!normalizedTempPayload.options?.length) { return { errorMessage: t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }), } } const duplicated = new Set() - const hasRepeatedItem = tempPayload.options.some((option) => { + const hasRepeatedItem = normalizedTempPayload.options.some((option) => { if (duplicated.has(option)) return true @@ -204,8 +209,8 @@ export const validateConfigModalPayload = ({ } } - if ([InputVarType.singleFile, InputVarType.multiFiles].includes(tempPayload.type)) { - if (!tempPayload.allowed_file_types?.length) { + if ([InputVarType.singleFile, InputVarType.multiFiles].includes(normalizedTempPayload.type)) { + if (!normalizedTempPayload.allowed_file_types?.length) { return { errorMessage: t('errorMsg.fieldRequired', { ns: 'workflow', @@ -214,7 +219,7 @@ export const validateConfigModalPayload = ({ } } - if (tempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !tempPayload.allowed_file_extensions?.length) { + if (normalizedTempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !normalizedTempPayload.allowed_file_extensions?.length) { return { errorMessage: t('errorMsg.fieldRequired', { ns: 'workflow', @@ -224,7 +229,7 @@ export const validateConfigModalPayload = ({ } } - if (tempPayload.type === InputVarType.jsonObject && !schemaEmpty && typeof normalizedJsonSchema === 'string') { + if (normalizedTempPayload.type === InputVarType.jsonObject && !schemaEmpty && typeof normalizedJsonSchema === 'string') { try { const schema = JSON.parse(normalizedJsonSchema) if (schema?.type !== 'object') { 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/app/overview/__tests__/app-card-sections.spec.tsx b/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx index 9820e15ad8..d3f83d5d9c 100644 --- a/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx @@ -1,8 +1,38 @@ +import type { FormEvent } from 'react' import type { AppDetailResponse } from '@/models/app' import { fireEvent, render, screen, within } from '@testing-library/react' +import { InputVarType } from '@/app/components/workflow/types' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' -import { AppCardAccessControlSection, AppCardOperations, AppCardUrlSection, createAppCardOperations } from '../app-card-sections' +import { AppCardAccessControlSection, AppCardDialogs, AppCardOperations, AppCardUrlSection, createAppCardOperations, WorkflowLaunchDialog } from '../app-card-sections' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), + Trans: ({ i18nKey }: { i18nKey: string }) => {i18nKey}, +})) + +vi.mock('../settings', () => ({ + default: () =>
, +})) + +vi.mock('../embedded', () => ({ + default: () =>
, +})) + +vi.mock('../customize', () => ({ + default: () =>
, +})) + +vi.mock('../../app-access-control', () => ({ + default: ({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) => ( +
+ + +
+ ), +})) describe('app-card-sections', () => { const t = (key: string) => key @@ -52,6 +82,7 @@ describe('app-card-sections', () => { it('should render operation buttons and execute enabled actions', () => { const onLaunch = vi.fn() + const onLaunchConfig = vi.fn() const operations = createAppCardOperations({ operationKeys: ['launch', 'embedded'], t: t as never, @@ -68,12 +99,19 @@ describe('app-card-sections', () => { , ) fireEvent.click(screen.getByRole('button', { name: /overview\.appInfo\.launch/i })) + fireEvent.click(screen.getByRole('button', { name: /operation\.config/i })) expect(onLaunch).toHaveBeenCalledTimes(1) + expect(onLaunchConfig).toHaveBeenCalledTimes(1) expect(screen.getByRole('button', { name: /overview\.appInfo\.embedded\.entry/i })).toBeInTheDocument() }) @@ -127,4 +165,127 @@ describe('app-card-sections', () => { fireEvent.click(within(dialog).getByRole('button', { name: /operation\.confirm/i })) expect(onRegenerate).toHaveBeenCalledTimes(1) }) + + it('should disable all operations when triggerModeDisabled is true', () => { + const operations = createAppCardOperations({ + operationKeys: ['launch', 'settings'], + t: t as never, + runningStatus: true, + triggerModeDisabled: true, + onLaunch: vi.fn(), + onEmbedded: vi.fn(), + onCustomize: vi.fn(), + onSettings: vi.fn(), + onDevelop: vi.fn(), + }) + + expect(operations[0]!.disabled).toBe(true) + expect(operations[1]!.disabled).toBe(true) + }) + + it('should render WorkflowLaunchDialog and submit values', () => { + const onOpenChange = vi.fn() + const onValueChange = vi.fn() + const onSubmit = vi.fn((event: FormEvent) => { + event.preventDefault() + }) + + render( + , + ) + + expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument() + fireEvent.submit(screen.getByRole('button', { name: /overview\.appInfo\.launch/i }).closest('form')!) + expect(onSubmit).toHaveBeenCalled() + }) + + it('should return null for WorkflowLaunchDialog when no variables are provided', () => { + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('should render AppCardDialogs with all modals for web apps', () => { + const appInfo = { + id: 'app-1', + mode: AppModeEnum.CHAT, + enable_site: true, + enable_api: false, + site: { app_base_url: 'https://example.com', access_token: 'token-1' }, + api_base_url: 'https://api.example.com', + } as never + + render( + , + ) + + expect(screen.getByTestId('settings-modal')).toBeInTheDocument() + expect(screen.getByTestId('embedded-modal')).toBeInTheDocument() + expect(screen.getByTestId('customize-modal')).toBeInTheDocument() + expect(screen.getByTestId('access-control')).toBeInTheDocument() + }) + + it('should return null for AppCardDialogs when not an app', () => { + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + }) }) diff --git a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts index fbfcdaf955..0a6d7f7dd7 100644 --- a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts +++ b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts @@ -1,9 +1,22 @@ import type { AppDetailResponse } from '@/models/app' -import { BlockEnum } from '@/app/components/workflow/types' +import { BlockEnum, InputVarType } from '@/app/components/workflow/types' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' -import { getAppCardDisplayState, getAppCardOperationKeys, hasWorkflowStartNode, isAppAccessConfigured } from '../app-card-utils' +import { + buildWorkflowLaunchUrl, + compressAndEncodeBase64, + createWorkflowLaunchInitialValues, + getAppCardDisplayState, + getAppCardOperationKeys, + getAppHiddenLaunchVariables, + getEmbeddedIframeSnippet, + getEmbeddedScriptSnippet, + getWorkflowHiddenStartVariables, + hasWorkflowStartNode, + isAppAccessConfigured, + isWorkflowLaunchInputSupported, +} from '../app-card-utils' describe('app-card-utils', () => { const baseAppInfo = { @@ -33,6 +46,108 @@ describe('app-card-utils', () => { })).toBe(false) }) + it('should return hidden workflow start variables and their initial launch values', () => { + const hiddenVariables = getWorkflowHiddenStartVariables({ + graph: { + nodes: [{ + data: { + type: BlockEnum.Start, + variables: [ + { + variable: 'visible', + label: 'Visible', + type: InputVarType.textInput, + hide: false, + required: false, + }, + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + default: 'prefilled', + required: false, + }, + { + variable: 'enabled', + label: 'Enabled', + type: InputVarType.checkbox, + hide: true, + default: true, + required: false, + }, + ], + }, + }], + }, + }) + + expect(hiddenVariables.map(variable => variable.variable)).toEqual(['secret', 'enabled']) + expect(createWorkflowLaunchInitialValues(hiddenVariables)).toEqual({ + secret: 'prefilled', + enabled: true, + }) + }) + + it('should return hidden advanced-chat launch variables from the workflow start node first', () => { + const hiddenVariables = getAppHiddenLaunchVariables({ + appInfo: { + ...baseAppInfo, + mode: AppModeEnum.ADVANCED_CHAT, + model_config: { + user_input_form: [ + { + 'text-input': { + label: 'Visible', + variable: 'visible', + required: true, + max_length: 48, + default: '', + hide: false, + }, + }, + { + checkbox: { + label: 'Hidden Toggle', + variable: 'hidden_toggle', + required: false, + default: true, + hide: true, + }, + }, + ], + }, + } as AppDetailResponse, + currentWorkflow: { + graph: { + nodes: [{ + data: { + type: BlockEnum.Start, + variables: [ + { + variable: 'start_secret', + label: 'Start Secret', + type: InputVarType.textInput, + hide: true, + default: 'from-start', + required: false, + }, + ], + }, + }], + }, + }, + }) + + expect(hiddenVariables).toEqual([ + expect.objectContaining({ + variable: 'start_secret', + type: InputVarType.textInput, + default: 'from-start', + }), + ]) + }) + it('should build the display state for a published web app', () => { const state = getAppCardDisplayState({ appInfo: baseAppInfo, @@ -104,4 +219,108 @@ describe('app-card-utils', () => { isCurrentWorkspaceEditor: false, })).toEqual(['launch', 'embedded', 'customize']) }) + + it('should build a workflow launch URL with serialized parameters', async () => { + const url = await buildWorkflowLaunchUrl({ + accessibleUrl: 'https://example.com/app/workflow/token-1', + variables: [ + { variable: 'name', label: 'Name', type: InputVarType.textInput, hide: true, required: false }, + { variable: 'enabled', label: 'Enabled', type: InputVarType.checkbox, hide: true, required: false }, + ], + values: { name: 'Alice', enabled: true }, + }) + + const parsed = new URL(url) + expect(parsed.searchParams.get('name')).toBe('Alice') + expect(parsed.searchParams.get('enabled')).toBe('true') + }) + + it('should serialize checkbox false and empty string values in launch URL', async () => { + const url = await buildWorkflowLaunchUrl({ + accessibleUrl: 'https://example.com/app/workflow/token-1', + variables: [ + { variable: 'flag', label: 'Flag', type: InputVarType.checkbox, hide: true, required: false }, + { variable: 'empty', label: 'Empty', type: InputVarType.textInput, hide: true, required: false }, + ], + values: { flag: false, empty: '' }, + }) + + const parsed = new URL(url) + expect(parsed.searchParams.get('flag')).toBe('false') + expect(parsed.searchParams.get('empty')).toBe('') + }) + + it('should generate an iframe snippet with the provided URL', () => { + const snippet = getEmbeddedIframeSnippet('https://example.com/chatbot/token-1') + expect(snippet).toContain('src="https://example.com/chatbot/token-1"') + expect(snippet).toContain('frameborder="0"') + expect(snippet).toContain('allow="microphone"') + }) + + it('should generate an embedded script snippet with inputs', () => { + const snippet = getEmbeddedScriptSnippet({ + url: 'https://example.com', + token: 'abc123', + primaryColor: '#FF0000', + isTestEnv: true, + inputValues: { name: 'Alice', count: '5' }, + }) + + expect(snippet).toContain('token: \'abc123\'') + expect(snippet).toContain('isDev: true') + expect(snippet).toContain('name: "Alice"') + expect(snippet).toContain('count: "5"') + expect(snippet).toContain('background-color: #FF0000') + }) + + it('should generate an embedded script snippet with empty inputs comment', () => { + const snippet = getEmbeddedScriptSnippet({ + url: 'https://example.com', + token: 'abc123', + primaryColor: '#1C64F2', + inputValues: {}, + }) + + expect(snippet).toContain('// You can define the inputs from the Start node here') + expect(snippet).not.toContain('isDev: true') + }) + + it('should compress and encode base64 using CompressionStream when available', async () => { + const result = await compressAndEncodeBase64('hello') + expect(typeof result).toBe('string') + expect(result.length).toBeGreaterThan(0) + }) + + it('should fallback to plain base64 when CompressionStream is unavailable', async () => { + const original = globalThis.CompressionStream + // @ts-expect-error remove for test + delete globalThis.CompressionStream + + const result = await compressAndEncodeBase64('hello') + expect(result).toBe(btoa('hello')) + + globalThis.CompressionStream = original + }) + + it('should identify supported workflow launch input types', () => { + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.textInput, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.paragraph, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.select, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.number, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.checkbox, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.json, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.jsonObject, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.url, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.files, hide: true, required: false })).toBe(false) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.singleFile, hide: true, required: false })).toBe(false) + }) + + it('should coerce numeric defaults to string in createWorkflowLaunchInitialValues', () => { + const result = createWorkflowLaunchInitialValues([ + { variable: 'count', label: 'Count', type: InputVarType.number, hide: true, required: false, default: 42 }, + { variable: 'empty', label: 'Empty', type: InputVarType.textInput, hide: true, required: false }, + ]) + + expect(result).toEqual({ count: '42', empty: '' }) + }) }) diff --git a/web/app/components/app/overview/__tests__/app-card.spec.tsx b/web/app/components/app/overview/__tests__/app-card.spec.tsx index 2f730ad278..a6bacce887 100644 --- a/web/app/components/app/overview/__tests__/app-card.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-card.spec.tsx @@ -2,6 +2,7 @@ import type { ReactElement, ReactNode } from 'react' import type { AppDetailResponse } from '@/models/app' import { fireEvent, screen, waitFor } from '@testing-library/react' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' +import { InputVarType } from '@/app/components/workflow/types' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' @@ -17,7 +18,7 @@ const mockSetAppDetail = vi.fn() const mockOnChangeStatus = vi.fn() const mockOnGenerateCode = vi.fn() -let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string } }> } } | null = null +let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string, variables?: Array> } }> } } | null = null let mockAccessSubjects: { groups?: unknown[], members?: unknown[] } = { groups: [], members: [] } let mockAppDetail: AppDetailResponse | undefined @@ -25,6 +26,7 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, }), + Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null, })) vi.mock('@/context/app-context', () => ({ @@ -164,6 +166,182 @@ describe('AppCard', () => { expect(mockWindowOpen).toHaveBeenCalledWith(`https://example.com${basePath}/chat/access-token`, '_blank') }) + it('should open the workflow web app directly when launch is clicked even with hidden inputs', () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByText('overview.appInfo.launch')) + + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/workflow/access-token`, + '_blank', + ) + expect(screen.queryByText('overview.appInfo.workflowLaunchHiddenInputs.title')).not.toBeInTheDocument() + }) + + it('should collect hidden workflow inputs from the config action before launching the workflow web app', async () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.config' })) + + expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Secret'), { + target: { value: 'top-secret' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' })) + + await waitFor(() => { + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/workflow/access-token?secret=${encodeURIComponent('top-secret')}`, + '_blank', + ) + }) + }) + + it('should open the chat web app directly when launch is clicked even with hidden inputs', () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'chat_secret', + label: 'Chat Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByText('overview.appInfo.launch')) + + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/chat/access-token`, + '_blank', + ) + expect(screen.queryByText('overview.appInfo.workflowLaunchHiddenInputs.title')).not.toBeInTheDocument() + }) + + it('should collect hidden chatflow inputs from the config action before launching the chat web app', async () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'chat_secret', + label: 'Chat Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.config' })) + + expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Chat Secret'), { + target: { value: 'chat-secret' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' })) + + await waitFor(() => { + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/chat/access-token?chat_secret=${encodeURIComponent('chat-secret')}`, + '_blank', + ) + }) + }) + it('should show the access-control not-set badge when specific access has no subjects', () => { render( { }) it('should report refresh failures from access control updates', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) mockFetchAppDetailDirect.mockRejectedValueOnce(new Error('refresh failed')) render( diff --git a/web/app/components/app/overview/__tests__/workflow-hidden-input-fields.spec.tsx b/web/app/components/app/overview/__tests__/workflow-hidden-input-fields.spec.tsx new file mode 100644 index 0000000000..309df540a6 --- /dev/null +++ b/web/app/components/app/overview/__tests__/workflow-hidden-input-fields.spec.tsx @@ -0,0 +1,214 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { InputVarType } from '@/app/components/workflow/types' +import WorkflowHiddenInputFields from '../workflow-hidden-input-fields' + +describe('WorkflowHiddenInputFields', () => { + const onValueChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render a text input with label and placeholder', () => { + render( + , + ) + + const input = screen.getByLabelText('Full Name') + expect(input).toHaveValue('Alice') + + fireEvent.change(input, { target: { value: 'Bob' } }) + expect(onValueChange).toHaveBeenCalledWith('name', 'Bob') + }) + + it('should render a number input for number-typed variables', () => { + render( + , + ) + + const input = screen.getByLabelText('Count') + expect(input).toHaveAttribute('type', 'number') + + fireEvent.change(input, { target: { value: '10' } }) + expect(onValueChange).toHaveBeenCalledWith('count', '10') + }) + + it('should render a checkbox input without a separate label element above', () => { + render( + , + ) + + const checkbox = screen.getByRole('checkbox') + expect(checkbox).toBeChecked() + expect(screen.getByText('Enable Feature')).toBeInTheDocument() + + fireEvent.click(checkbox) + expect(onValueChange).toHaveBeenCalledWith('enabled', false) + }) + + it('should render a select dropdown for select-typed variables', () => { + render( + , + ) + + expect(screen.getByRole('combobox', { name: 'Color' })).toBeInTheDocument() + }) + + it('should render a textarea for paragraph-typed variables', () => { + render( + , + ) + + const textarea = screen.getByPlaceholderText('Description') + expect(textarea).toHaveValue('Hello world') + + fireEvent.change(textarea, { target: { value: 'Updated' } }) + expect(onValueChange).toHaveBeenCalledWith('description', 'Updated') + }) + + it('should render a textarea for json-typed variables', () => { + render( + , + ) + + const textarea = screen.getByPlaceholderText('Config JSON') + expect(textarea).toHaveValue('{"key": "value"}') + }) + + it('should render a textarea for jsonObject-typed variables', () => { + render( + , + ) + + const textarea = screen.getByPlaceholderText('Schema') + expect(textarea).toHaveValue('{}') + }) + + it('should use the variable key as label when label is not a string', () => { + render( + , + ) + + expect(screen.getByText('my_var')).toBeInTheDocument() + }) + + it('should use the custom fieldIdPrefix for element ids', () => { + const { container } = render( + , + ) + + expect(container.querySelector('#custom-prefix-token')).toBeInTheDocument() + }) + + it('should render empty string for non-string fieldValue in text inputs', () => { + render( + , + ) + + const input = screen.getByLabelText('Flag') + expect(input).toHaveValue('') + }) +}) diff --git a/web/app/components/app/overview/app-card-sections.tsx b/web/app/components/app/overview/app-card-sections.tsx index 8fef355f34..8db5193f2d 100644 --- a/web/app/components/app/overview/app-card-sections.tsx +++ b/web/app/components/app/overview/app-card-sections.tsx @@ -1,7 +1,11 @@ /* eslint-disable react-refresh/only-export-components */ import type { TFunction } from 'i18next' -import type { ComponentType, ReactNode } from 'react' -import type { OverviewOperationKey } from './app-card-utils' +import type { ComponentType, FormEvent, ReactNode } from 'react' +import type { + OverviewOperationKey, + WorkflowHiddenStartVariable, + WorkflowLaunchInputValue, +} from './app-card-utils' import type { ConfigParams } from './settings' import type { AppDetailResponse } from '@/models/app' import type { AppSSO } from '@/types/app' @@ -15,12 +19,19 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import { Tooltip, TooltipContent, TooltipTrigger, } from '@langgenius/dify-ui/tooltip' -import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react' +import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiSettings2Line, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react' +import { Trans } from 'react-i18next' import CopyFeedback from '@/app/components/base/copy-feedback' import Divider from '@/app/components/base/divider' import ShareQRCode from '@/app/components/base/qrcode' @@ -31,6 +42,7 @@ import CustomizeModal from './customize' import EmbeddedModal from './embedded' import SettingsModal from './settings' import style from './style.module.css' +import WorkflowHiddenInputFields from './workflow-hidden-input-fields' type AppInfo = AppDetailResponse & Partial @@ -50,6 +62,12 @@ type AppCardOperation = { onClick: () => void } +type LaunchConfigAction = { + label: string + disabled: boolean + onClick: () => void +} + const OPERATION_ICON_MAP: Record = { launch: RiExternalLinkLine, embedded: RiWindowLine, @@ -96,6 +114,65 @@ const MaybeTooltip = ({ ) } +export const WorkflowLaunchDialog = ({ + t, + open, + hiddenVariables, + unsupportedVariables, + values, + onOpenChange, + onValueChange, + onSubmit, +}: { + t: TFunction + open: boolean + hiddenVariables: WorkflowHiddenStartVariable[] + unsupportedVariables: WorkflowHiddenStartVariable[] + values: Record + onOpenChange: (open: boolean) => void + onValueChange: (variable: string, value: WorkflowLaunchInputValue) => void + onSubmit: (event: FormEvent) => void +}) => { + if (!hiddenVariables.length && !unsupportedVariables.length) + return null + + return ( + + +
+ + {t('overview.appInfo.workflowLaunchHiddenInputs.title', { ns: 'appOverview' })} + + + }} + /> + +
+
+
+ +
+
+ + +
+
+
+
+ ) +} + export const createAppCardOperations = ({ operationKeys, t, @@ -251,20 +328,15 @@ export const AppCardAccessControlSection = ({ export const AppCardOperations = ({ t, operations, + launchConfigAction, }: { t: TFunction operations: AppCardOperation[] + launchConfigAction?: LaunchConfigAction }) => ( <> - {operations.map(({ key, label, Icon, disabled, onClick }) => ( -
- - ))} + ) + + if (key === 'launch' && launchConfigAction) { + return ( + + + + ) + } + + return ( + + ) + })} ) @@ -295,6 +431,7 @@ export const AppCardDialogs = ({ onCloseAccessControl, onSaveSiteConfig, onConfirmAccessControl, + hiddenInputs, }: { isApp: boolean appInfo: AppInfo @@ -310,6 +447,7 @@ export const AppCardDialogs = ({ onCloseAccessControl: () => void onSaveSiteConfig?: (params: ConfigParams) => Promise onConfirmAccessControl: () => Promise + hiddenInputs?: WorkflowHiddenStartVariable[] }) => { if (!isApp) return null @@ -329,6 +467,7 @@ export const AppCardDialogs = ({ onClose={onCloseEmbedded} appBaseUrl={appInfo.site?.app_base_url} accessToken={appInfo.site?.access_token} + hiddenInputs={hiddenInputs} /> type AppInfo = AppDetailResponse & Partial @@ -16,6 +23,7 @@ type WorkflowLike = { nodes?: Array<{ data?: { type?: string + variables?: InputVar[] } }> } @@ -42,10 +50,173 @@ const getCardAppMode = (mode: AppModeEnum) => { return (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : mode } +const SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES = new Set([ + InputVarType.textInput, + InputVarType.paragraph, + InputVarType.select, + InputVarType.number, + InputVarType.checkbox, + InputVarType.json, + InputVarType.jsonObject, + InputVarType.url, +]) + +const coerceWorkflowLaunchDefaultValue = (variable: WorkflowHiddenStartVariable): WorkflowLaunchInputValue => { + if (variable.type === InputVarType.checkbox) { + if (typeof variable.default === 'boolean') + return variable.default + + return String(variable.default).toLowerCase() === 'true' + } + + if (typeof variable.default === 'number') + return String(variable.default) + + return String(variable.default ?? '') +} + export const hasWorkflowStartNode = (currentWorkflow: WorkflowLike) => { return currentWorkflow?.graph?.nodes?.some(node => node.data?.type === BlockEnum.Start) ?? false } +export const getWorkflowHiddenStartVariables = (currentWorkflow: WorkflowLike): WorkflowHiddenStartVariable[] => { + const startNode = currentWorkflow?.graph?.nodes?.find(node => node.data?.type === BlockEnum.Start) + return (startNode?.data?.variables ?? []).filter(variable => variable.hide === true) +} + +export const getAppHiddenLaunchVariables = ({ + appInfo, + currentWorkflow, +}: { + appInfo: AppInfo + currentWorkflow: WorkflowLike +}) => { + if ([AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT].includes(appInfo.mode)) + return getWorkflowHiddenStartVariables(currentWorkflow) +} + +export const isWorkflowLaunchInputSupported = (variable: WorkflowHiddenStartVariable) => { + return SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES.has(variable.type) +} + +export const createWorkflowLaunchInitialValues = (variables: WorkflowHiddenStartVariable[]) => { + return variables.reduce>((acc, variable) => { + acc[variable.variable] = coerceWorkflowLaunchDefaultValue(variable) + return acc + }, {}) +} + +export const buildWorkflowLaunchUrl = async ({ + accessibleUrl, + variables, + values, +}: { + accessibleUrl: string + variables: WorkflowHiddenStartVariable[] + values: Record +}) => { + const targetUrl = new URL(accessibleUrl, window.location.origin) + variables.forEach((variable) => { + const rawValue = values[variable.variable] + const serializedValue = variable.type === InputVarType.checkbox + ? String(Boolean(rawValue)) + : String(rawValue ?? '') + + targetUrl.searchParams.set(variable.variable, serializedValue) + }) + + return targetUrl.toString() +} + +export const getEmbeddedIframeSnippet = (iframeUrl: string) => + `` + +const getScriptInputsContent = (values: Record) => { + const entries = Object.entries(values) + + if (!entries.length) { + return `{ + // You can define the inputs from the Start node here + // key is the variable name + // e.g. + // name: "NAME" + }` + } + + return `{ +${entries.map(([key, value]) => ` ${key}: ${JSON.stringify(value)},`).join('\n')} + }` +} + +export const getEmbeddedScriptSnippet = ({ + url, + token, + primaryColor, + isTestEnv, + inputValues, +}: { + url: string + token: string + primaryColor: string + isTestEnv?: boolean + inputValues: Record +}) => + ` + +` + +export const getChromePluginContent = (iframeUrl: string) => `ChatBot URL: ${iframeUrl}` + +export const compressAndEncodeBase64 = async (input: string) => { + const uint8Array = new TextEncoder().encode(input) + if (typeof CompressionStream === 'undefined') + return btoa(String.fromCharCode(...uint8Array)) + + const compressedStream = new Response( + new Blob([uint8Array]) + .stream() + .pipeThrough(new CompressionStream('gzip')), + ).arrayBuffer() + const compressedUint8Array = new Uint8Array(await compressedStream) + return btoa(String.fromCharCode(...compressedUint8Array)) +} + export const getAppCardDisplayState = ({ appInfo, cardType, diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx index b7ec4a2d81..9b1fc3a032 100644 --- a/web/app/components/app/overview/app-card.tsx +++ b/web/app/components/app/overview/app-card.tsx @@ -1,4 +1,5 @@ 'use client' +import type { WorkflowLaunchInputValue } from './app-card-utils' import type { ConfigParams } from './settings' import type { AppDetailResponse } from '@/models/app' import type { AppSSO } from '@/types/app' @@ -28,11 +29,16 @@ import { AppCardOperations, AppCardUrlSection, createAppCardOperations, + WorkflowLaunchDialog, } from './app-card-sections' import { + buildWorkflowLaunchUrl, + createWorkflowLaunchInitialValues, getAppCardDisplayState, getAppCardOperationKeys, + getAppHiddenLaunchVariables, isAppAccessConfigured, + isWorkflowLaunchInputSupported, } from './app-card-utils' export type IAppCardProps = { @@ -63,7 +69,8 @@ function AppCard({ const router = useRouter() const pathname = usePathname() const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext() - const { data: currentWorkflow } = useAppWorkflow(appInfo.mode === AppModeEnum.WORKFLOW ? appInfo.id : '') + const shouldFetchWorkflow = appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT + const { data: currentWorkflow } = useAppWorkflow(shouldFetchWorkflow ? appInfo.id : '') const docLink = useDocLink() const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(state => state.setAppDetail) @@ -73,6 +80,8 @@ function AppCard({ const [genLoading, setGenLoading] = useState(false) const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [showAccessControl, setShowAccessControl] = useState(false) + const [showWorkflowLaunchDialog, setShowWorkflowLaunchDialog] = useState(false) + const [workflowLaunchValues, setWorkflowLaunchValues] = useState>({}) const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: appAccessSubjects } = useAppWhiteListSubjects( @@ -98,6 +107,25 @@ function AppCard({ () => isAppAccessConfigured(appDetail, appAccessSubjects), [appAccessSubjects, appDetail], ) + const hiddenLaunchVariables = useMemo( + () => getAppHiddenLaunchVariables({ + appInfo, + currentWorkflow, + }) || [], + [appInfo, currentWorkflow], + ) + const supportedWorkflowLaunchVariables = useMemo( + () => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported), + [hiddenLaunchVariables], + ) + const unsupportedWorkflowLaunchVariables = useMemo( + () => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)), + [hiddenLaunchVariables], + ) + const initialWorkflowLaunchValues = useMemo( + () => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables), + [supportedWorkflowLaunchVariables], + ) const onGenCode = async () => { if (!onGenerateCode) @@ -139,6 +167,31 @@ function AppCard({ window.open(cardState.accessibleUrl, '_blank') }, [cardState.accessibleUrl]) + const handleOpenWorkflowLaunchDialog = useCallback(() => { + setWorkflowLaunchValues(initialWorkflowLaunchValues) + setShowWorkflowLaunchDialog(true) + }, [initialWorkflowLaunchValues]) + + const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => { + setWorkflowLaunchValues(prev => ({ + ...prev, + [variable]: value, + })) + }, []) + + const handleWorkflowLaunchConfirm = useCallback(async (event: React.FormEvent) => { + event.preventDefault() + + const targetUrl = await buildWorkflowLaunchUrl({ + accessibleUrl: cardState.accessibleUrl, + variables: supportedWorkflowLaunchVariables, + values: workflowLaunchValues, + }) + + window.open(targetUrl, '_blank') + setShowWorkflowLaunchDialog(false) + }, [cardState.accessibleUrl, supportedWorkflowLaunchVariables, workflowLaunchValues]) + const handleOpenCustomize = useCallback(() => { setShowCustomizeModal(true) }, []) @@ -304,7 +357,17 @@ function AppCard({ {!cardState.isMinimalState && (
{!isApp && } - + 0 + ? { + label: t('operation.config', { ns: 'common' }), + disabled: triggerModeDisabled || !cardState.runningStatus, + onClick: handleOpenWorkflowLaunchDialog, + } + : undefined} + />
)}
@@ -323,6 +386,17 @@ function AppCard({ onCloseAccessControl={() => setShowAccessControl(false)} onSaveSiteConfig={onSaveSiteConfig} onConfirmAccessControl={handleAccessControlUpdate} + hiddenInputs={hiddenLaunchVariables} + /> +
) diff --git a/web/app/components/app/overview/embedded/__tests__/index.spec.tsx b/web/app/components/app/overview/embedded/__tests__/index.spec.tsx index 0a843c26fd..a6e391cb0e 100644 --- a/web/app/components/app/overview/embedded/__tests__/index.spec.tsx +++ b/web/app/components/app/overview/embedded/__tests__/index.spec.tsx @@ -1,10 +1,11 @@ import type { SiteInfo } from '@/models/share' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import copy from 'copy-to-clipboard' import * as React from 'react' - import { act } from 'react' -import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' + +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { InputVarType } from '@/app/components/workflow/types' import Embedded from '../index' vi.mock('../style.module.css', () => ({ @@ -46,6 +47,7 @@ vi.mock('@/context/app-context', () => ({ })) const mockWindowOpen = vi.spyOn(window, 'open').mockImplementation(() => null) const mockedCopy = vi.mocked(copy) +const originalCompressionStream = globalThis.CompressionStream const siteInfo: SiteInfo = { title: 'test site', @@ -70,6 +72,22 @@ const getCopyButton = () => { } describe('Embedded', () => { + beforeAll(() => { + class MockCompressionStream { + readable: ReadableStream + writable: WritableStream + + constructor() { + const transformStream = new TransformStream() + this.readable = transformStream.readable + this.writable = transformStream.writable + } + } + + // @ts-expect-error test polyfill + globalThis.CompressionStream = MockCompressionStream + }) + afterEach(() => { vi.clearAllMocks() mockWindowOpen.mockClear() @@ -77,6 +95,7 @@ describe('Embedded', () => { afterAll(() => { mockWindowOpen.mockRestore() + globalThis.CompressionStream = originalCompressionStream }) it('builds theme and copies iframe snippet', async () => { @@ -84,14 +103,20 @@ describe('Embedded', () => { render() }) + await waitFor(() => { + expect(screen.getByText((content, node) => node?.tagName.toLowerCase() === 'pre' && content.includes('/chatbot/token'))).toBeInTheDocument() + }) + const actionButton = getCopyButton() const innerDiv = actionButton.querySelector('div') - act(() => { + await act(async () => { fireEvent.click(innerDiv ?? actionButton) }) expect(mockThemeBuilder.buildTheme).toHaveBeenCalledWith(siteInfo.chat_color_theme, siteInfo.chat_color_theme_inverted) - expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token')) + await waitFor(() => { + expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token')) + }) }) it('opens chrome plugin store link when chrome option selected', async () => { @@ -116,4 +141,106 @@ describe('Embedded', () => { 'noopener,noreferrer', ) }) + + it('keeps hidden inputs collapsed by default and updates iframe and script content when values change', async () => { + render( + , + ) + + expect(screen.queryByLabelText('Secret')).not.toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByText('appOverview.overview.appInfo.embedded.hiddenInputs.title').closest('button')!) + }) + + await waitFor(() => { + expect(screen.getByLabelText('Secret')).toBeInTheDocument() + }) + + await act(async () => { + fireEvent.change(screen.getByLabelText('Secret'), { + target: { value: 'top-secret' }, + }) + }) + + expect(document.querySelector('pre')?.textContent ?? '').toContain('/chatbot/token') + + await waitFor(() => { + const codeBlock = document.querySelector('pre') + expect(codeBlock?.textContent ?? '').toContain('/chatbot/token?secret=dG9wLXNlY3JldA%3D%3D') + }) + + const optionButtons = document.body.querySelectorAll('[class*="option"]') + act(() => { + fireEvent.click(optionButtons[1]!) + }) + + await waitFor(() => { + const codeBlock = document.querySelector('pre') + expect(codeBlock?.textContent ?? '').toContain('secret: "top-secret"') + }) + }) + + it('copies script content when scripts option is selected', async () => { + await act(async () => { + render() + }) + + const optionButtons = document.body.querySelectorAll('[class*="option"]') + act(() => { + fireEvent.click(optionButtons[1]!) + }) + + await waitFor(() => { + const codeBlock = document.querySelector('pre') + expect(codeBlock?.textContent ?? '').toContain('token: \'token\'') + }) + + const actionButton = getCopyButton() + const innerDiv = actionButton.querySelector('div') + await act(async () => { + fireEvent.click(innerDiv ?? actionButton) + }) + + await waitFor(() => { + expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('token: \'token\'')) + }) + }) + + it('copies chrome plugin URL (without prefix) when chromePlugin option is selected', async () => { + await act(async () => { + render() + }) + + const optionButtons = document.body.querySelectorAll('[class*="option"]') + act(() => { + fireEvent.click(optionButtons[2]!) + }) + + await waitFor(() => { + const codeBlock = document.querySelector('pre') + expect(codeBlock?.textContent ?? '').toContain('ChatBot URL:') + }) + + const actionButton = getCopyButton() + const innerDiv = actionButton.querySelector('div') + await act(async () => { + fireEvent.click(innerDiv ?? actionButton) + }) + + await waitFor(() => { + expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token')) + expect(mockedCopy).not.toHaveBeenCalledWith(expect.stringContaining('ChatBot URL:')) + }) + }) }) diff --git a/web/app/components/app/overview/embedded/index.tsx b/web/app/components/app/overview/embedded/index.tsx index 12203178f1..112848760b 100644 --- a/web/app/components/app/overview/embedded/index.tsx +++ b/web/app/components/app/overview/embedded/index.tsx @@ -1,88 +1,46 @@ +import type { MutableRefObject } from 'react' +import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '../app-card-utils' import type { SiteInfo } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { + RiArrowDownSLine, + RiArrowRightSLine, +} from '@remixicon/react' import copy from 'copy-to-clipboard' -import * as React from 'react' -import { useState } from 'react' +import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context' -import { IS_CE_EDITION } from '@/config' +import { InputVarType } from '@/app/components/workflow/types' import { useAppContext } from '@/context/app-context' import { basePath } from '@/utils/var' +import { + compressAndEncodeBase64, + createWorkflowLaunchInitialValues, + getChromePluginContent, + getEmbeddedIframeSnippet, + getEmbeddedScriptSnippet, + isWorkflowLaunchInputSupported, +} from '../app-card-utils' +import WorkflowHiddenInputFields from '../workflow-hidden-input-fields' import style from './style.module.css' type Props = { siteInfo?: SiteInfo isShow: boolean onClose: () => void - accessToken: string - appBaseUrl: string + accessToken?: string + appBaseUrl?: string + hiddenInputs?: WorkflowHiddenStartVariable[] className?: string } -const OPTION_MAP = { - iframe: { - getContent: (url: string, token: string) => - ``, - }, - scripts: { - getContent: (url: string, token: string, primaryColor: string, isTestEnv?: boolean) => - ` - -`, - }, - chromePlugin: { - getContent: (url: string, token: string) => `ChatBot URL: ${url}${basePath}/chatbot/${token}`, - }, -} +const OPTION_KEYS = ['iframe', 'scripts', 'chromePlugin'] as const const prefixEmbedded = 'overview.appInfo.embedded' -type Option = keyof typeof OPTION_MAP - -const OPTIONS: Option[] = ['iframe', 'scripts', 'chromePlugin'] +type Option = typeof OPTION_KEYS[number] const optionIconClassName: Record = { iframe: style.iframeIcon!, @@ -90,38 +48,274 @@ const optionIconClassName: Record = { chromePlugin: style.chromePluginIcon!, } -const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, className }: Props) => { +const getSerializedHiddenInputValue = ( + variable: WorkflowHiddenStartVariable, + values: Record, +) => { + const rawValue = values[variable.variable] + if (variable.type === InputVarType.checkbox) + return String(Boolean(rawValue)) + + return String(rawValue ?? '') +} + +const buildEmbeddedIframeUrl = async ({ + appBaseUrl, + accessToken, + variables, + values, +}: { + appBaseUrl: string + accessToken: string + variables: WorkflowHiddenStartVariable[] + values: Record +}) => { + const iframeUrl = new URL(`${appBaseUrl}${basePath}/chatbot/${accessToken}`, window.location.origin) + + await Promise.all(variables.map(async (variable) => { + iframeUrl.searchParams.set(variable.variable, await compressAndEncodeBase64(getSerializedHiddenInputValue(variable, values))) + })) + + return iframeUrl.toString() +} + +const AsyncEmbeddedOptionContent = ({ + option, + iframeUrlPromise, + latestResolvedIframeUrlRef, +}: { + option: Option + iframeUrlPromise: Promise + latestResolvedIframeUrlRef: MutableRefObject +}) => { + const iframeUrl = use(iframeUrlPromise) + latestResolvedIframeUrlRef.current = iframeUrl + + if (option === 'chromePlugin') + return getChromePluginContent(iframeUrl) + + return getEmbeddedIframeSnippet(iframeUrl) +} + +const EmbeddedContent = ({ + siteInfo, + appBaseUrl, + accessToken, + hiddenInputs, +}: Required> & Pick) => { const { t } = useTranslation() + const supportedHiddenInputs = useMemo( + () => (hiddenInputs ?? []).filter(isWorkflowLaunchInputSupported), + [hiddenInputs], + ) + const initialHiddenInputValues = useMemo( + () => createWorkflowLaunchInitialValues(supportedHiddenInputs), + [supportedHiddenInputs], + ) const [option, setOption] = useState