From 38eb04dc98c8b21684cb10167bfe567c5b6f77e9 Mon Sep 17 00:00:00 2001 From: FFXN <31929997+FFXN@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:37:13 +0800 Subject: [PATCH 01/18] fix: hit-testing response failed because of Pydantic check. (#35640) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../console/datasets/hit_testing_base.py | 49 +++++++++++++++++- .../console/datasets/test_hit_testing_base.py | 36 +++++++++++++ .../service_api/dataset/test_hit_testing.py | 51 +++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/api/controllers/console/datasets/hit_testing_base.py b/api/controllers/console/datasets/hit_testing_base.py index 699fa599c8..71ab1513ed 100644 --- a/api/controllers/console/datasets/hit_testing_base.py +++ b/api/controllers/console/datasets/hit_testing_base.py @@ -38,6 +38,48 @@ class HitTestingPayload(BaseModel): class DatasetsHitTestingBase: + @staticmethod + def _normalize_hit_testing_query(query: Any) -> str: + """Return the user-visible query string from legacy and current response shapes.""" + if isinstance(query, str): + return query + + if isinstance(query, dict): + content = query.get("content") + if isinstance(content, str): + return content + + raise ValueError("Invalid hit testing query response") + + @staticmethod + def _normalize_hit_testing_records(records: Any) -> list[dict[str, Any]]: + """Coerce nullable collection fields into lists before response validation.""" + if not isinstance(records, list): + return [] + + normalized_records: list[dict[str, Any]] = [] + for record in records: + if not isinstance(record, dict): + continue + + normalized_record = dict(record) + segment = normalized_record.get("segment") + if isinstance(segment, dict): + normalized_segment = dict(segment) + if normalized_segment.get("keywords") is None: + normalized_segment["keywords"] = [] + normalized_record["segment"] = normalized_segment + + if normalized_record.get("child_chunks") is None: + normalized_record["child_chunks"] = [] + + if normalized_record.get("files") is None: + normalized_record["files"] = [] + + normalized_records.append(normalized_record) + + return normalized_records + @staticmethod def get_and_validate_dataset(dataset_id: str): assert isinstance(current_user, Account) @@ -75,7 +117,12 @@ class DatasetsHitTestingBase: attachment_ids=args.get("attachment_ids"), limit=10, ) - return {"query": response["query"], "records": marshal(response["records"], hit_testing_record_fields)} + return { + "query": DatasetsHitTestingBase._normalize_hit_testing_query(response.get("query")), + "records": DatasetsHitTestingBase._normalize_hit_testing_records( + marshal(response.get("records", []), hit_testing_record_fields) + ), + } except services.errors.index.IndexNotInitializedError: raise DatasetNotInitializedError() except ProviderTokenNotInitError as ex: diff --git a/api/tests/unit_tests/controllers/console/datasets/test_hit_testing_base.py b/api/tests/unit_tests/controllers/console/datasets/test_hit_testing_base.py index e4acd91b76..d29b34beb2 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_hit_testing_base.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_hit_testing_base.py @@ -134,6 +134,42 @@ class TestPerformHitTesting: assert result["query"] == "hello" assert result["records"] == [] + def test_success_normalizes_legacy_query_and_nullable_list_fields(self, dataset): + response = { + "query": {"content": "hello"}, + "records": [ + { + "segment": {"id": "segment-1", "keywords": None}, + "child_chunks": None, + "files": None, + "score": 0.8, + } + ], + } + + with ( + patch.object( + HitTestingService, + "retrieve", + return_value=response, + ), + patch( + "controllers.console.datasets.hit_testing_base.marshal", + return_value=response["records"], + ), + ): + result = DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"}) + + assert result["query"] == "hello" + assert result["records"] == [ + { + "segment": {"id": "segment-1", "keywords": []}, + "child_chunks": [], + "files": [], + "score": 0.8, + } + ] + def test_index_not_initialized(self, dataset): with patch.object( HitTestingService, diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py b/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py index 95c2f5cf92..9be8e56f56 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py @@ -171,6 +171,57 @@ class TestHitTestingApiPost: assert passed_retrieval_model["search_method"] == "semantic_search" assert passed_retrieval_model["top_k"] == 10 + @patch("controllers.service_api.dataset.hit_testing.service_api_ns") + @patch("controllers.console.datasets.hit_testing_base.marshal") + @patch("controllers.console.datasets.hit_testing_base.HitTestingService") + @patch("controllers.console.datasets.hit_testing_base.DatasetService") + @patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account)) + def test_post_normalizes_legacy_query_and_nullable_list_fields( + self, + mock_current_user, + mock_dataset_svc, + mock_hit_svc, + mock_marshal, + mock_ns, + app, + ): + """Test service API normalizes legacy query shape and nullable list fields.""" + dataset_id = str(uuid.uuid4()) + tenant_id = str(uuid.uuid4()) + + mock_dataset = Mock() + mock_dataset.id = dataset_id + + mock_dataset_svc.get_dataset.return_value = mock_dataset + mock_dataset_svc.check_dataset_permission.return_value = None + + mock_hit_svc.retrieve.return_value = {"query": {"content": "legacy query"}, "records": ["placeholder"]} + mock_hit_svc.hit_testing_args_check.return_value = None + mock_marshal.return_value = [ + { + "segment": {"id": "segment-1", "keywords": None}, + "child_chunks": None, + "files": None, + "score": 0.9, + } + ] + + mock_ns.payload = {"query": "legacy query"} + + with app.test_request_context(): + api = HitTestingApi() + response = HitTestingApi.post.__wrapped__(api, tenant_id, dataset_id) + + assert response["query"] == "legacy query" + assert response["records"] == [ + { + "segment": {"id": "segment-1", "keywords": []}, + "child_chunks": [], + "files": [], + "score": 0.9, + } + ] + @patch("controllers.service_api.dataset.hit_testing.service_api_ns") @patch("controllers.console.datasets.hit_testing_base.DatasetService") @patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account)) From d0956039e7a4ca66dc3e20873a4d84fb025572cd Mon Sep 17 00:00:00 2001 From: knyazz Date: Wed, 29 Apr 2026 04:59:17 +0300 Subject: [PATCH 02/18] chore: correction of ru translation (#35645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Смирнов Евгений Владимирович --- web/i18n/ru-RU/app-overview.json | 4 +-- web/i18n/ru-RU/dataset-documents.json | 46 +++++++++++++-------------- web/i18n/ru-RU/workflow.json | 10 +++--- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/web/i18n/ru-RU/app-overview.json b/web/i18n/ru-RU/app-overview.json index 20f6a2b768..c361441ceb 100644 --- a/web/i18n/ru-RU/app-overview.json +++ b/web/i18n/ru-RU/app-overview.json @@ -10,7 +10,7 @@ "analysis.ms": "мс", "analysis.title": "Анализ", "analysis.tokenPS": "Токен/с", - "analysis.tokenUsage.consumed": "Потрачено", + "analysis.tokenUsage.consumed": "Потреблено", "analysis.tokenUsage.explanation": "Отражает ежедневное использование токенов языковой модели для приложения, полезно для целей контроля затрат.", "analysis.tokenUsage.title": "Использование токенов", "analysis.totalConversations.explanation": "Ежедневное количество чатов с LLM; проектирование/отладка не учитываются.", @@ -62,7 +62,7 @@ "overview.appInfo.enableTooltip.description": "Чтобы включить эту функцию, добавьте на холст узел ввода пользователя. (Может уже существовать в черновике, вступает в силу после публикации)", "overview.appInfo.enableTooltip.learnMore": "Узнать больше", "overview.appInfo.explanation": "Готовое к использованию веб-приложение ИИ", - "overview.appInfo.launch": "Баркас", + "overview.appInfo.launch": "Запустить", "overview.appInfo.preUseReminder": "Пожалуйста, включите веб-приложение перед продолжением.", "overview.appInfo.preview": "Предварительный просмотр", "overview.appInfo.qrcode.download": "Скачать QR-код", diff --git a/web/i18n/ru-RU/dataset-documents.json b/web/i18n/ru-RU/dataset-documents.json index 8d5b1a656b..d2b77c1a74 100644 --- a/web/i18n/ru-RU/dataset-documents.json +++ b/web/i18n/ru-RU/dataset-documents.json @@ -1,13 +1,13 @@ { "embedding.automatic": "Автоматически", - "embedding.childMaxTokens": "Ребёнок", + "embedding.childMaxTokens": "Наследник", "embedding.completed": "Встраивание завершено", "embedding.custom": "Пользовательский", - "embedding.docName": "Предварительная обработка документа", + "embedding.docName": "Имя документа", "embedding.economy": "Экономичный режим", "embedding.error": "Ошибка расчета эмбеддингов", - "embedding.estimate": "Оценочное потребление", - "embedding.hierarchical": "Родитель-дочерний", + "embedding.estimate": "Оценка", + "embedding.hierarchical": "Иерархический", "embedding.highQuality": "Режим высокого качества", "embedding.mode": "Правило сегментации", "embedding.parentMaxTokens": "Родитель", @@ -16,7 +16,7 @@ "embedding.previewTip": "Предварительный просмотр абзацев будет доступен после завершения расчета эмбеддингов", "embedding.processing": "Расчет эмбеддингов...", "embedding.resume": "Возобновить обработку", - "embedding.segmentLength": "Длина фрагментов", + "embedding.segmentLength": "Длина сегментов", "embedding.segments": "Абзацы", "embedding.stop": "Остановить обработку", "embedding.textCleaning": "Предварительная очистка текста", @@ -279,25 +279,25 @@ "metadata.type.webPage": "Веб-страница", "metadata.type.wikipediaEntry": "Статья в Википедии", "segment.addAnother": "Добавить еще один", - "segment.addChildChunk": "Добавить дочерний чанк", - "segment.addChunk": "Добавить чанк", + "segment.addChildChunk": "Добавить дочерний фрагмент", + "segment.addChunk": "Добавить фрагмент", "segment.addKeyWord": "Добавить ключевое слово", "segment.allFilesUploaded": "Все файлы должны быть загружены перед сохранением", "segment.answerEmpty": "Ответ не может быть пустым", "segment.answerPlaceholder": "добавьте ответ здесь", - "segment.characters_one": "характер", - "segment.characters_other": "письмена", - "segment.childChunk": "Чайлд-Чанк", - "segment.childChunkAdded": "Добавлен 1 дочерний чанк", - "segment.childChunks_one": "ДОЧЕРНИЙ ЧАНК", - "segment.childChunks_other": "ДЕТСКИЕ КУСОЧКИ", - "segment.chunk": "Ломоть", - "segment.chunkAdded": "Добавлен 1 блок", - "segment.chunkDetail": "Деталь Чанка", - "segment.chunks_one": "ЛОМОТЬ", - "segment.chunks_other": "КУСКИ", + "segment.characters_one": "символ", + "segment.characters_other": "символы", + "segment.childChunk": "Дочерний фрагмент", + "segment.childChunkAdded": "Добавлен 1 дочерний фрагмент", + "segment.childChunks_one": "ДОЧЕРНИЙ ФРАГМЕНТ", + "segment.childChunks_other": "ДОЧЕРНИЕ ФРАГМЕНТЫ", + "segment.chunk": "Фрагмент", + "segment.chunkAdded": "Добавлен 1 фрагмент", + "segment.chunkDetail": "Детали фрагмента", + "segment.chunks_one": "ФРАГМЕНТ", + "segment.chunks_other": "ФРАГМЕНТЫ", "segment.clearFilter": "Очистить фильтр", - "segment.collapseChunks": "Сворачивание кусков", + "segment.collapseChunks": "Свернуть фрагменты", "segment.contentEmpty": "Содержимое не может быть пустым", "segment.contentPlaceholder": "добавьте содержимое здесь", "segment.dateTimeFormat": "MM/DD/YYYY HH:mm", @@ -307,15 +307,15 @@ "segment.editParentChunk": "Редактирование родительского блока", "segment.edited": "ОТРЕДАКТИРОВАНЫ", "segment.editedAt": "Отредактировано в", - "segment.empty": "Чанк не найден", - "segment.expandChunks": "Развернуть чанки", + "segment.empty": "Фрагмент не найден", + "segment.expandChunks": "Развернуть фрагменты", "segment.hitCount": "Количество обращений", "segment.keywordDuplicate": "Ключевое слово уже существует", "segment.keywordEmpty": "Ключевое слово не может быть пустым", "segment.keywordError": "Максимальная длина ключевого слова - 20", "segment.keywords": "Ключевые слова", - "segment.newChildChunk": "Новый дочерний чанк", - "segment.newChunk": "Новый чанк", + "segment.newChildChunk": "Новый дочерний фрагмент", + "segment.newChunk": "Новый фрагмент", "segment.newQaSegment": "Новый сегмент вопрос-ответ", "segment.newTextSegment": "Новый текстовый сегмент", "segment.paragraphs": "Абзацы", diff --git a/web/i18n/ru-RU/workflow.json b/web/i18n/ru-RU/workflow.json index 89d2657208..aa5292f1cc 100644 --- a/web/i18n/ru-RU/workflow.json +++ b/web/i18n/ru-RU/workflow.json @@ -1,7 +1,7 @@ { "blocks.agent": "Агент", "blocks.answer": "Ответ", - "blocks.assigner": "Назначение переменной", + "blocks.assigner": "Назначение переменных", "blocks.code": "Код", "blocks.datasource": "Источник данных", "blocks.datasource-empty": "Пустой источник данных", @@ -17,10 +17,10 @@ "blocks.list-operator": "Оператор списка", "blocks.llm": "LLM", "blocks.loop": "Цикл", - "blocks.loop-end": "Выйти из цикла", + "blocks.loop-end": "Конец цикла", "blocks.loop-start": "Начало цикла", "blocks.originalStartNode": "исходный начальный узел", - "blocks.parameter-extractor": "Извлечение параметров", + "blocks.parameter-extractor": "Экстрактор параметров", "blocks.question-classifier": "Классификатор вопросов", "blocks.start": "Начало", "blocks.template-transform": "Шаблон", @@ -29,7 +29,7 @@ "blocks.trigger-schedule": "Триггер расписания", "blocks.trigger-webhook": "Вебхук-триггер", "blocks.variable-aggregator": "Агрегатор переменных", - "blocks.variable-assigner": "Агрегатор переменных", + "blocks.variable-assigner": "Назначение переменных", "blocksAbout.agent": "Вызов больших языковых моделей для ответа на вопросы или обработки естественного языка", "blocksAbout.answer": "Определите содержимое ответа в чате", "blocksAbout.assigner": "Узел назначения переменной используется для назначения значений записываемым переменным (например, переменным разговора).", @@ -485,7 +485,7 @@ "nodes.common.pluginNotInstalled": "Плагин не установлен", "nodes.common.pluginsNotInstalled": "{{count}} плагинов не установлено", "nodes.common.retry.maxRetries": "максимальное количество повторных попыток", - "nodes.common.retry.ms": "госпожа", + "nodes.common.retry.ms": "мс", "nodes.common.retry.retries": "{{num}} Повторных попыток", "nodes.common.retry.retry": "Снова пробовать", "nodes.common.retry.retryFailed": "Повторная попытка не удалась", From 0536549f735d25ced0e15b9f755bceef8002bd70 Mon Sep 17 00:00:00 2001 From: kenwoodjw Date: Wed, 29 Apr 2026 10:27:02 +0800 Subject: [PATCH 03/18] fix: flaky WordExtractor close test in CI (#35652) Signed-off-by: kenwoodjw --- .../core/rag/extractor/test_word_extractor.py | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py index 0220fb6d4a..b9f2449cfb 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py @@ -1,14 +1,12 @@ """Primarily used for testing merged cell scenarios""" -import gc import io import os import tempfile -import warnings from collections import UserDict from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest from docx import Document @@ -377,23 +375,21 @@ def test_close_is_idempotent(): extractor.temp_file.close.assert_called_once() -def test_close_handles_async_close_mock(): +async def _async_close() -> None: + return None + + +def test_close_closes_awaitable_close_result(): extractor = object.__new__(WordExtractor) extractor._closed = False extractor.temp_file = MagicMock() - extractor.temp_file.close = AsyncMock() + close_result = _async_close() + extractor.temp_file.close = MagicMock(return_value=close_result) - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - extractor.close() - gc.collect() + extractor.close() + assert close_result.cr_frame is None extractor.temp_file.close.assert_called_once() - assert not [ - warning - for warning in caught - if issubclass(warning.category, RuntimeWarning) and "AsyncMockMixin._execute_mock_call" in str(warning.message) - ] def test_extract_images_handles_invalid_external_cases(monkeypatch): From 16d408d908911fc3ee37b92370727102272cf885 Mon Sep 17 00:00:00 2001 From: hyl64 <78853927+hyl64@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:41:15 +0800 Subject: [PATCH 04/18] fix: refresh MCP tool metadata after updates and align App DSL test stubs (#35354) Co-authored-by: Stephen Zhou --- .../services/test_app_dsl_service.py | 91 +++++++------------ .../mcp/detail/__tests__/content.spec.tsx | 5 + .../components/tools/mcp/detail/content.tsx | 5 +- 3 files changed, 41 insertions(+), 60 deletions(-) diff --git a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py index 77ce28b999..1835650c42 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 import json from types import SimpleNamespace +from typing import Any, cast from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -17,7 +18,7 @@ from core.trigger.constants import ( ) from extensions.ext_redis import redis_client from graphon.enums import BuiltinNodeTypes -from models import Account, AppMode +from models import Account, App, AppMode from models.model import AppModelConfig, IconType from services import app_dsl_service from services.account_service import AccountService, TenantService @@ -67,6 +68,22 @@ def _pending_yaml_content(version: str = "99.0.0") -> bytes: return (f'version: "{version}"\nkind: app\napp:\n name: Loop Test\n mode: workflow\n').encode() +def _app_stub(**overrides: Any) -> App: + defaults = { + "id": str(uuid4()), + "tenant_id": _DEFAULT_TENANT_ID, + "mode": AppMode.WORKFLOW.value, + "name": "n", + "description": "d", + "icon_type": IconType.EMOJI, + "icon": "i", + "icon_background": "#fff", + "use_icon_as_answer_icon": False, + "app_model_config": None, + } + return cast(App, SimpleNamespace(**(defaults | overrides))) + + class TestAppDslService: """Integration tests for AppDslService using testcontainers.""" @@ -585,7 +602,7 @@ class TestAppDslService: def test_check_dependencies_returns_empty_when_no_redis_data(self, db_session_with_containers): service = AppDslService(db_session_with_containers) - app_model = SimpleNamespace(id=str(uuid4()), tenant_id=_DEFAULT_TENANT_ID) + app_model = _app_stub() result = service.check_dependencies(app_model=app_model) assert result.leaked_dependencies == [] @@ -614,7 +631,7 @@ class TestAppDslService: ) service = AppDslService(db_session_with_containers) - result = service.check_dependencies(app_model=SimpleNamespace(id=app_id, tenant_id=_DEFAULT_TENANT_ID)) + result = service.check_dependencies(app_model=_app_stub(id=app_id)) assert len(result.leaked_dependencies) == 1 def test_check_dependencies_with_real_app(self, db_session_with_containers, mock_external_service_dependencies): @@ -656,9 +673,7 @@ class TestAppDslService: lambda _m: SimpleNamespace(kind="conv"), ) - app = SimpleNamespace( - id=str(uuid4()), - tenant_id=_DEFAULT_TENANT_ID, + app = _app_stub( mode=AppMode.WORKFLOW.value, name="old", description="old-desc", @@ -667,7 +682,6 @@ class TestAppDslService: icon_background="#111111", updated_by=None, updated_at=None, - app_model_config=None, ) service = AppDslService(db_session_with_containers) updated = service._create_or_update_app( @@ -745,15 +759,7 @@ class TestAppDslService: service = AppDslService(db_session_with_containers) with pytest.raises(ValueError, match="Missing workflow data"): service._create_or_update_app( - app=SimpleNamespace( - id=str(uuid4()), - tenant_id=_DEFAULT_TENANT_ID, - mode=AppMode.WORKFLOW.value, - name="n", - description="d", - icon_background="#fff", - app_model_config=None, - ), + app=_app_stub(mode=AppMode.WORKFLOW.value), data={"app": {"mode": AppMode.WORKFLOW.value}}, account=_account_mock(), ) @@ -762,15 +768,7 @@ class TestAppDslService: service = AppDslService(db_session_with_containers) with pytest.raises(ValueError, match="Missing model_config"): service._create_or_update_app( - app=SimpleNamespace( - id=str(uuid4()), - tenant_id=_DEFAULT_TENANT_ID, - mode=AppMode.CHAT.value, - name="n", - description="d", - icon_background="#fff", - app_model_config=None, - ), + app=_app_stub(mode=AppMode.CHAT.value), data={"app": {"mode": AppMode.CHAT.value}}, account=_account_mock(), ) @@ -799,15 +797,7 @@ class TestAppDslService: service = AppDslService(db_session_with_containers) with pytest.raises(ValueError, match="Invalid app mode"): service._create_or_update_app( - app=SimpleNamespace( - id=str(uuid4()), - tenant_id=_DEFAULT_TENANT_ID, - mode=AppMode.RAG_PIPELINE.value, - name="n", - description="d", - icon_background="#fff", - app_model_config=None, - ), + app=_app_stub(mode=AppMode.RAG_PIPELINE.value), data={"app": {"mode": AppMode.RAG_PIPELINE.value}}, account=_account_mock(), ) @@ -828,29 +818,16 @@ class TestAppDslService: lambda *_args, **_kwargs: model_calls.append(True), ) - workflow_app = SimpleNamespace( + workflow_app = _app_stub( mode=AppMode.WORKFLOW.value, - tenant_id=_DEFAULT_TENANT_ID, - name="n", - icon="i", icon_type="emoji", - icon_background="#fff", - description="d", - use_icon_as_answer_icon=False, - app_model_config=None, ) AppDslService.export_dsl(workflow_app) assert workflow_calls == [True] - chat_app = SimpleNamespace( + chat_app = _app_stub( mode=AppMode.CHAT.value, - tenant_id=_DEFAULT_TENANT_ID, - name="n", - icon="i", icon_type="emoji", - icon_background="#fff", - description="d", - use_icon_as_answer_icon=False, app_model_config=SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": []}}), ) AppDslService.export_dsl(chat_app) @@ -863,16 +840,14 @@ class TestAppDslService: lambda **_kwargs: None, ) - emoji_app = SimpleNamespace( + emoji_app = _app_stub( mode=AppMode.WORKFLOW.value, - tenant_id=_DEFAULT_TENANT_ID, name="Emoji App", icon="🎨", icon_type=IconType.EMOJI, icon_background="#FF5733", description="App with emoji icon", use_icon_as_answer_icon=True, - app_model_config=None, ) yaml_output = AppDslService.export_dsl(emoji_app) data = yaml.safe_load(yaml_output) @@ -880,16 +855,14 @@ class TestAppDslService: assert data["app"]["icon_type"] == "emoji" assert data["app"]["icon_background"] == "#FF5733" - image_app = SimpleNamespace( + image_app = _app_stub( mode=AppMode.WORKFLOW.value, - tenant_id=_DEFAULT_TENANT_ID, name="Image App", icon="https://example.com/icon.png", icon_type=IconType.IMAGE, icon_background="#FFEAD5", description="App with image icon", use_icon_as_answer_icon=False, - app_model_config=None, ) yaml_output = AppDslService.export_dsl(image_app) data = yaml.safe_load(yaml_output) @@ -1106,7 +1079,7 @@ class TestAppDslService: export_data: dict = {} AppDslService._append_workflow_export_data( export_data=export_data, - app_model=SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID), + app_model=_app_stub(), include_secret=False, workflow_id=None, ) @@ -1132,7 +1105,7 @@ class TestAppDslService: with pytest.raises(ValueError, match="Missing draft workflow configuration"): AppDslService._append_workflow_export_data( export_data={}, - app_model=SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID), + app_model=_app_stub(), include_secret=False, workflow_id=None, ) @@ -1160,7 +1133,7 @@ class TestAppDslService: monkeypatch.setattr(app_dsl_service, "jsonable_encoder", lambda x: x) app_model_config = SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": [{"credential_id": "secret"}]}}) - app_model = SimpleNamespace(tenant_id=_DEFAULT_TENANT_ID, app_model_config=app_model_config) + app_model = _app_stub(app_model_config=app_model_config) export_data: dict = {} AppDslService._append_model_config_export_data(export_data, app_model) @@ -1169,7 +1142,7 @@ class TestAppDslService: def test_append_model_config_export_data_requires_app_config(self): with pytest.raises(ValueError, match="Missing app configuration"): - AppDslService._append_model_config_export_data({}, SimpleNamespace(app_model_config=None)) + AppDslService._append_model_config_export_data({}, _app_stub(app_model_config=None)) # ── Dependency Extraction ───────────────────────────────────────── diff --git a/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx index f7bf8181ed..a7d5225348 100644 --- a/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx @@ -12,6 +12,7 @@ const mockAuthorizeMcp = vi.fn().mockResolvedValue({ result: 'success' }) const mockUpdateMCP = vi.fn().mockResolvedValue({ result: 'success' }) const mockDeleteMCP = vi.fn().mockResolvedValue({ result: 'success' }) const mockInvalidateMCPTools = vi.fn() +const mockInvalidateAllMCPTools = vi.fn() const mockOpenOAuthPopup = vi.fn() // Mutable mock state @@ -33,6 +34,7 @@ vi.mock('@/service/use-tools', () => ({ isFetching: mockIsFetching, }), useInvalidateMCPTools: () => mockInvalidateMCPTools, + useInvalidateAllMCPTools: () => mockInvalidateAllMCPTools, useUpdateMCPTools: () => ({ mutateAsync: mockUpdateTools, isPending: mockIsUpdating, @@ -180,6 +182,7 @@ describe('MCPDetailContent', () => { mockUpdateMCP.mockClear() mockDeleteMCP.mockClear() mockInvalidateMCPTools.mockClear() + mockInvalidateAllMCPTools.mockClear() mockOpenOAuthPopup.mockClear() // Reset mock return values @@ -513,6 +516,7 @@ describe('MCPDetailContent', () => { await waitFor(() => { expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1') expect(mockInvalidateMCPTools).toHaveBeenCalledWith('mcp-1') + expect(mockInvalidateAllMCPTools).toHaveBeenCalled() expect(onUpdate).toHaveBeenCalled() }) }) @@ -530,6 +534,7 @@ describe('MCPDetailContent', () => { await waitFor(() => { expect(mockUpdateTools).toHaveBeenCalledWith('mcp-1') + expect(mockInvalidateAllMCPTools).toHaveBeenCalled() }) }) }) diff --git a/web/app/components/tools/mcp/detail/content.tsx b/web/app/components/tools/mcp/detail/content.tsx index 35c8a35a6f..c785516eee 100644 --- a/web/app/components/tools/mcp/detail/content.tsx +++ b/web/app/components/tools/mcp/detail/content.tsx @@ -26,6 +26,7 @@ import { openOAuthPopup } from '@/hooks/use-oauth' import { useAuthorizeMCP, useDeleteMCP, + useInvalidateAllMCPTools, useInvalidateMCPTools, useMCPTools, useUpdateMCP, @@ -61,6 +62,7 @@ const MCPDetailContent: FC = ({ const { data, isFetching: isGettingTools } = useMCPTools(detail.is_team_authorization ? detail.id : '') const invalidateMCPTools = useInvalidateMCPTools() + const invalidateAllMCPTools = useInvalidateAllMCPTools() const { mutateAsync: updateTools, isPending: isUpdating } = useUpdateMCPTools() const { mutateAsync: authorizeMcp, isPending: isAuthorizing } = useAuthorizeMCP() const toolList = data?.tools || [] @@ -76,8 +78,9 @@ const MCPDetailContent: FC = ({ return await updateTools(detail.id) invalidateMCPTools(detail.id) + invalidateAllMCPTools() onUpdate() - }, [detail, hideUpdateConfirm, invalidateMCPTools, onUpdate, updateTools]) + }, [detail, hideUpdateConfirm, invalidateAllMCPTools, invalidateMCPTools, onUpdate, updateTools]) const { mutateAsync: updateMCP } = useUpdateMCP({}) const { mutateAsync: deleteMCP } = useDeleteMCP({}) From d23cefe005b1e4507cb517fbd32f021c60225df9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Wed, 29 Apr 2026 11:00:06 +0800 Subject: [PATCH 05/18] fix: improve workflow as tool overlays (#35661) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 15 - .../__tests__/configure-button.spec.tsx | 38 +- .../workflow-tool/__tests__/index.spec.tsx | 8 +- .../__tests__/method-selector.spec.tsx | 23 +- .../confirm-modal/__tests__/index.spec.tsx | 11 +- .../workflow-tool/confirm-modal/index.tsx | 47 +- .../__tests__/use-configure-button.spec.ts | 1 - .../hooks/use-configure-button.ts | 1 - .../components/tools/workflow-tool/index.tsx | 490 +++++++++++------- .../tools/workflow-tool/method-selector.tsx | 70 +-- 10 files changed, 404 insertions(+), 300 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index e1c8bda126..c724609e88 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -3820,21 +3820,6 @@ "count": 4 } }, - "web/app/components/tools/workflow-tool/confirm-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/tools/workflow-tool/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/tools/workflow-tool/method-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow-app/components/workflow-children.tsx": { "ts/no-explicit-any": { "count": 3 diff --git a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx index 646a095622..7060e29f95 100644 --- a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx @@ -9,6 +9,8 @@ import WorkflowToolConfigureButton from '../configure-button' import WorkflowToolAsModal from '../index' import MethodSelector from '../method-selector' +vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) + // Mock Next.js navigation const mockPush = vi.fn() vi.mock('@/next/navigation', () => ({ @@ -83,12 +85,11 @@ vi.mock('@/app/components/base/drawer-plus', () => ({ }, })) -// Mock EmojiPicker - simplified for testing -vi.mock('@/app/components/base/emoji-picker', () => ({ - default: ({ onSelect, onClose }: { onSelect: (icon: string, background: string) => void, onClose: () => void }) => ( +// Mock EmojiPickerInner - simplified for testing +vi.mock('@/app/components/base/emoji-picker/Inner', () => ({ + default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => (
-
), })) @@ -978,6 +979,7 @@ describe('WorkflowToolAsModal', () => { // Select emoji await user.click(screen.getByTestId('select-emoji')) + await user.click(screen.getByRole('button', { name: 'app.iconPicker.ok' })) // Assert const updatedIcon = screen.getByTestId('app-icon') @@ -1002,7 +1004,7 @@ describe('WorkflowToolAsModal', () => { expect(screen.getByTestId('emoji-picker'))!.toBeInTheDocument() - await user.click(screen.getByTestId('close-emoji-picker')) + await user.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' })) // Assert // Assert @@ -1501,7 +1503,7 @@ describe('MethodSelector', () => { // Assert // Assert - expect(screen.getByTestId('portal-trigger'))!.toBeInTheDocument() + expect(screen.getByTestId('popover-trigger'))!.toBeInTheDocument() }) it('should display parameter method text when value is llm', () => { @@ -1562,11 +1564,11 @@ describe('MethodSelector', () => { // Act render() - await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByTestId('popover-trigger')) // Assert // Assert - expect(screen.getByTestId('portal-content'))!.toBeInTheDocument() + expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() }) it('should call onChange with llm when parameter option clicked', async () => { @@ -1580,7 +1582,7 @@ describe('MethodSelector', () => { // Act render() - await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByTestId('popover-trigger')) const paramOption = screen.getAllByText('tools.createTool.toolInput.methodParameter')[0] await user.click(paramOption!) @@ -1600,7 +1602,7 @@ describe('MethodSelector', () => { // Act render() - await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByTestId('popover-trigger')) const settingOption = screen.getByText('tools.createTool.toolInput.methodSetting') await user.click(settingOption) @@ -1621,12 +1623,12 @@ describe('MethodSelector', () => { render() // First click - open - await user.click(screen.getByTestId('portal-trigger')) - expect(screen.getByTestId('portal-content'))!.toBeInTheDocument() + await user.click(screen.getByTestId('popover-trigger')) + expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() // Second click - close - await user.click(screen.getByTestId('portal-trigger')) - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + await user.click(screen.getByTestId('popover-trigger')) + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() }) }) @@ -1642,10 +1644,10 @@ describe('MethodSelector', () => { // Act render() - await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByTestId('popover-trigger')) // Assert - the first option (llm) should have a check icon container - const content = screen.getByTestId('portal-content') + const content = screen.getByTestId('popover-content') expect(content)!.toBeInTheDocument() }) @@ -1659,10 +1661,10 @@ describe('MethodSelector', () => { // Act render() - await user.click(screen.getByTestId('portal-trigger')) + await user.click(screen.getByTestId('popover-trigger')) // Assert - const content = screen.getByTestId('portal-content') + const content = screen.getByTestId('popover-content') expect(content)!.toBeInTheDocument() }) }) diff --git a/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx index 2ec289fcf6..9f5532f1f7 100644 --- a/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx @@ -18,11 +18,10 @@ vi.mock('@/app/components/base/drawer-plus', () => ({ ), })) -vi.mock('@/app/components/base/emoji-picker', () => ({ - default: ({ onSelect, onClose }: { onSelect: (icon: string, background: string) => void, onClose: () => void }) => ( +vi.mock('@/app/components/base/emoji-picker/Inner', () => ({ + default: ({ onSelect }: { onSelect: (icon: string, background: string) => void }) => (
-
), })) @@ -129,6 +128,7 @@ describe('WorkflowToolAsModal', () => { await user.click(screen.getByTestId('append-label')) await user.click(screen.getByTestId('app-icon')) await user.click(screen.getByTestId('select-emoji')) + await user.click(screen.getByRole('button', { name: 'app.iconPicker.ok' })) await user.click(screen.getByRole('button', { name: 'common.operation.save' })) expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ @@ -195,6 +195,6 @@ describe('WorkflowToolAsModal', () => { />, ) - expect(screen.getAllByText('tools.createTool.toolOutput.reservedParameterDuplicateTip').length).toBeGreaterThan(0) + expect(screen.getAllByTestId('reserved-output-warning').length).toBeGreaterThan(0) }) }) diff --git a/web/app/components/tools/workflow-tool/__tests__/method-selector.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/method-selector.spec.tsx index d1126bf762..19b796f2db 100644 --- a/web/app/components/tools/workflow-tool/__tests__/method-selector.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/method-selector.spec.tsx @@ -4,6 +4,8 @@ import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import MethodSelector from '../method-selector' +vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) + // Test utilities const defaultProps: ComponentProps = { value: 'llm', @@ -139,6 +141,24 @@ describe('MethodSelector', () => { expect(onChange).toHaveBeenCalledWith('form') }) + it('should close dropdown after an option is clicked', async () => { + const user = userEvent.setup() + renderComponent({ value: 'llm' }) + + const trigger = screen.getByText('tools.createTool.toolInput.methodParameter') + await user.click(trigger) + + await waitFor(() => { + expect(screen.getByText('tools.createTool.toolInput.methodSettingTip'))!.toBeInTheDocument() + }) + + await user.click(screen.getByText('tools.createTool.toolInput.methodSettingTip')) + + await waitFor(() => { + expect(screen.queryByText('tools.createTool.toolInput.methodSettingTip')).not.toBeInTheDocument() + }) + }) + it('should toggle dropdown open state', async () => { const user = userEvent.setup() renderComponent() @@ -235,10 +255,9 @@ describe('MethodSelector', () => { await user.click(trigger) await waitFor(() => { + expect(screen.getByTestId('popover-content')).toBeInTheDocument() const dropdown = document.querySelector('.w-\\[320px\\]') expect(dropdown)!.toBeInTheDocument() - expect(dropdown)!.toHaveClass('rounded-lg') - expect(dropdown)!.toHaveClass('shadow-lg') }) }) diff --git a/web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx b/web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx index c5bce8b663..6535564a32 100644 --- a/web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx +++ b/web/app/components/tools/workflow-tool/confirm-modal/__tests__/index.spec.tsx @@ -93,13 +93,12 @@ describe('ConfirmModal', () => { // Arrange & Act renderComponent() - // Assert - Check for the dialog panel with modal content - // The real modal structure has nested divs, we need to find the one with our classes - const dialogContent = document.querySelector('.relative.rounded-2xl') + // Assert + const dialogContent = screen.getByRole('dialog') expect(dialogContent).toBeInTheDocument() - expect(dialogContent).toHaveClass('w-[600px]') - expect(dialogContent).toHaveClass('max-w-[600px]') - expect(dialogContent).toHaveClass('p-8') + expect(dialogContent).toHaveClass('w-[600px]!') + expect(dialogContent).toHaveClass('max-w-[600px]!') + expect(dialogContent).toHaveClass('p-8!') }) }) diff --git a/web/app/components/tools/workflow-tool/confirm-modal/index.tsx b/web/app/components/tools/workflow-tool/confirm-modal/index.tsx index ba45387731..4f17862c1a 100644 --- a/web/app/components/tools/workflow-tool/confirm-modal/index.tsx +++ b/web/app/components/tools/workflow-tool/confirm-modal/index.tsx @@ -2,11 +2,9 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/function' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { useTranslation } from 'react-i18next' import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' -import Modal from '@/app/components/base/modal' type ConfirmModalProps = { show: boolean @@ -18,28 +16,29 @@ const ConfirmModal = ({ show, onConfirm, onClose }: ConfirmModalProps) => { const { t } = useTranslation() return ( - -
- -
-
- -
-
{t('createTool.confirmTitle', { ns: 'tools' })}
-
- {t('createTool.confirmTip', { ns: 'tools' })} -
-
-
- - + + +
+
-
- +
+ +
+ {t('createTool.confirmTitle', { ns: 'tools' })} +
+ {t('createTool.confirmTip', { ns: 'tools' })} +
+
+
+ + +
+
+ + ) } diff --git a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts index efbf16d590..8bc3db95da 100644 --- a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts +++ b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts @@ -437,7 +437,6 @@ describe('useConfigureButton', () => { expect(onRefreshData).toHaveBeenCalled() expect(mockInvalidateAllWorkflowTools).toHaveBeenCalled() expect(mockInvalidateWorkflowToolDetailByAppID).toHaveBeenCalledWith('app-123') - expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: expect.any(String) }) expect(result.current.showModal).toBe(false) }) diff --git a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts index 9f0a43635c..33965aa5ee 100644 --- a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts +++ b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts @@ -206,7 +206,6 @@ export function useConfigureButton(options: UseConfigureButtonOptions) { onRefreshData?.() invalidateAllWorkflowTools() invalidateDetail(workflowAppId) - toast.success(t('api.actionSuccess', { ns: 'common' })) setShowModal(false) } catch (e) { diff --git a/web/app/components/tools/workflow-tool/index.tsx b/web/app/components/tools/workflow-tool/index.tsx index 353e85beba..6f8258f185 100644 --- a/web/app/components/tools/workflow-tool/index.tsx +++ b/web/app/components/tools/workflow-tool/index.tsx @@ -3,18 +3,18 @@ import type { FC } from 'react' import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' -import { RiErrorWarningLine } from '@remixicon/react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { produce } from 'immer' import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' -import Drawer from '@/app/components/base/drawer-plus' -import EmojiPicker from '@/app/components/base/emoji-picker' +import Divider from '@/app/components/base/divider' +import EmojiPickerInner from '@/app/components/base/emoji-picker/Inner' import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' -import Tooltip from '@/app/components/base/tooltip' import LabelSelector from '@/app/components/tools/labels/selector' import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal' import MethodSelector from '@/app/components/tools/workflow-tool/method-selector' @@ -53,6 +53,111 @@ type Props = { workflow_tool_id: string }>) => void } + +type WorkflowToolDrawerProps = { + title: string + onHide: () => void + children: React.ReactNode +} + +const InfoTooltip = ({ children }: { children: React.ReactNode }) => { + return ( + + + )} + /> + +
+ {children} +
+
+
+ ) +} + +const WorkflowToolDrawer = ({ title, onHide, children }: WorkflowToolDrawerProps) => { + return ( + + +
+
+
+ + {title} + + +
+
+
+ {children} +
+
+
+
+ ) +} + +type WorkflowToolEmojiPickerProps = { + onSelect: (icon: string, background: string) => void + onClose: () => void +} + +const WorkflowToolEmojiPicker = ({ onSelect, onClose }: WorkflowToolEmojiPickerProps) => { + const { t } = useTranslation() + const [selectedEmoji, setSelectedEmoji] = useState('') + const [selectedBackground, setSelectedBackground] = useState() + + return ( + + + + {t('iconPicker.emoji', { ns: 'app' })} + + { + setSelectedEmoji(emoji) + setSelectedBackground(background) + }} + /> + +
+ + +
+
+
+ ) +} + // Add and Edit const WorkflowToolAsModal: FC = ({ isAdd, @@ -138,210 +243,201 @@ const WorkflowToolAsModal: FC = ({ return ( <> - -
- {/* name & icon */} -
-
- {t('createTool.name', { ns: 'tools' })} - {' '} - * -
-
- { setShowEmojiPicker(true) }} className="cursor-pointer" iconType="emoji" icon={emoji.content} background={emoji.background} /> - setLabel(e.target.value)} - /> -
+ > +
+
+ {/* name & icon */} +
+
+ {t('createTool.name', { ns: 'tools' })} + {' '} + *
- {/* name for tool call */} -
-
- {t('createTool.nameForToolCall', { ns: 'tools' })} - {' '} - * - - {t('createTool.nameForToolCallPlaceHolder', { ns: 'tools' })} -
- )} - /> -
+
+ { setShowEmojiPicker(true) }} className="cursor-pointer" iconType="emoji" icon={emoji.content} background={emoji.background} /> setName(e.target.value)} - /> - {!isWorkflowToolNameValid(name) && ( -
{t('createTool.nameForToolCallTip', { ns: 'tools' })}
- )} -
- {/* description */} -
-
{t('createTool.description', { ns: 'tools' })}
-