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/pyproject.toml b/api/pyproject.toml index 2587d9e0bf..f47a389f31 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dify-api" -version = "1.13.3" +version = "1.14.0" requires-python = "~=3.12.0" dependencies = [ 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/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)) 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): diff --git a/api/uv.lock b/api/uv.lock index 1b52f8b53f..aacca7f4ab 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1289,7 +1289,7 @@ wheels = [ [[package]] name = "dify-api" -version = "1.13.3" +version = "1.14.0" source = { virtual = "." } dependencies = [ { name = "aliyun-log-python-sdk" }, diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 888f96332c..87fa01f671 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -21,7 +21,7 @@ services: # API service api: - image: langgenius/dify-api:1.13.3 + image: langgenius/dify-api:1.14.0 restart: always environment: # Use the shared environment variables. @@ -69,7 +69,7 @@ services: # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: - image: langgenius/dify-api:1.13.3 + image: langgenius/dify-api:1.14.0 restart: always environment: # Use the shared environment variables. @@ -115,7 +115,7 @@ services: # worker_beat service # Celery beat for scheduling periodic tasks. worker_beat: - image: langgenius/dify-api:1.13.3 + image: langgenius/dify-api:1.14.0 restart: always environment: # Use the shared environment variables. @@ -152,7 +152,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.13.3 + image: langgenius/dify-web:1.14.0 restart: always environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} @@ -268,7 +268,7 @@ services: # The DifySandbox sandbox: - image: langgenius/dify-sandbox:0.2.14 + image: langgenius/dify-sandbox:0.2.15 restart: always environment: # The DifySandbox configurations @@ -292,7 +292,7 @@ services: # plugin daemon plugin_daemon: - image: langgenius/dify-plugin-daemon:0.5.3-local + image: langgenius/dify-plugin-daemon:0.6.0-local restart: always environment: # Use the shared environment variables. diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml index af3d54dfb3..23c26c6695 100644 --- a/docker/docker-compose.middleware.yaml +++ b/docker/docker-compose.middleware.yaml @@ -103,7 +103,7 @@ services: # The DifySandbox sandbox: - image: langgenius/dify-sandbox:0.2.14 + image: langgenius/dify-sandbox:0.2.15 restart: always env_file: - ./middleware.env @@ -129,7 +129,7 @@ services: # plugin daemon plugin_daemon: - image: langgenius/dify-plugin-daemon:0.5.3-local + image: langgenius/dify-plugin-daemon:0.6.0-local restart: always env_file: - ./middleware.env diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 60ba510f44..a72136049d 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -745,7 +745,7 @@ services: # API service api: - image: langgenius/dify-api:1.13.3 + image: langgenius/dify-api:1.14.0 restart: always environment: # Use the shared environment variables. @@ -793,7 +793,7 @@ services: # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: - image: langgenius/dify-api:1.13.3 + image: langgenius/dify-api:1.14.0 restart: always environment: # Use the shared environment variables. @@ -839,7 +839,7 @@ services: # worker_beat service # Celery beat for scheduling periodic tasks. worker_beat: - image: langgenius/dify-api:1.13.3 + image: langgenius/dify-api:1.14.0 restart: always environment: # Use the shared environment variables. @@ -876,7 +876,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.13.3 + image: langgenius/dify-web:1.14.0 restart: always environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} @@ -992,7 +992,7 @@ services: # The DifySandbox sandbox: - image: langgenius/dify-sandbox:0.2.14 + image: langgenius/dify-sandbox:0.2.15 restart: always environment: # The DifySandbox configurations @@ -1016,7 +1016,7 @@ services: # plugin daemon plugin_daemon: - image: langgenius/dify-plugin-daemon:0.5.3-local + image: langgenius/dify-plugin-daemon:0.6.0-local restart: always environment: # Use the shared environment variables. 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/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({}) 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' })}
-