diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index e042272bca..76fbd18f47 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -116,10 +116,6 @@ jobs: if: github.event_name != 'merge_group' uses: ./.github/actions/setup-web - - name: Generate API contracts - if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true' - run: pnpm --filter @dify/contracts gen-api-contract - - name: ESLint autofix if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true' run: | diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index 5f244f1144..7bb6fc1bbd 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -158,7 +158,7 @@ jobs: - name: Run Claude Code for Translation Sync if: steps.context.outputs.CHANGED_FILES != '' - uses: anthropics/claude-code-action@ef50f123a3a9be95b60040d042717517407c7256 # v1.0.110 + uses: anthropics/claude-code-action@fefa07e9c665b7320f08c3b525980457f22f58aa # v1.0.111 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/api/controllers/common/helpers.py b/api/controllers/common/helpers.py index ef89e66980..84903733b5 100644 --- a/api/controllers/common/helpers.py +++ b/api/controllers/common/helpers.py @@ -41,7 +41,8 @@ def guess_file_info_from_response(response: httpx.Response): # Try to extract filename from URL parsed_url = urllib.parse.urlparse(url) url_path = parsed_url.path - filename = os.path.basename(url_path) + # Decode percent-encoded characters in the path segment + filename = urllib.parse.unquote(os.path.basename(url_path)) # If filename couldn't be extracted, use Content-Disposition header if not filename: diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index c01286cc59..d69a59ecb7 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -8,6 +8,7 @@ from flask import request from flask_restx import Resource from pydantic import BaseModel, Field, field_validator, model_validator from sqlalchemy import select +from werkzeug.exceptions import NotFound from configs import dify_config from constants.languages import supported_language @@ -45,6 +46,8 @@ from libs.helper import EmailStr, extract_remote_ip, timezone from libs.login import current_account_with_tenant, login_required from models import AccountIntegrate, InvitationCode from models.account import AccountStatus, InvitationCodeStatus +from models.enums import CreatorUserRole +from models.model import UploadFile from services.account_service import AccountService from services.billing_service import BillingService from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError @@ -322,9 +325,24 @@ class AccountAvatarApi(Resource): @login_required @account_initialization_required def get(self): + current_user, current_tenant_id = current_account_with_tenant() args = AccountAvatarQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + avatar = args.avatar - avatar_url = file_helpers.get_signed_file_url(args.avatar) + if avatar.startswith(("http://", "https://")): + return {"avatar_url": avatar} + + upload_file = db.session.scalar(select(UploadFile).where(UploadFile.id == avatar).limit(1)) + if upload_file is None: + raise NotFound("Avatar file not found") + + if upload_file.tenant_id != current_tenant_id: + raise NotFound("Avatar file not found") + + if upload_file.created_by_role != CreatorUserRole.ACCOUNT or upload_file.created_by != current_user.id: + raise NotFound("Avatar file not found") + + avatar_url = file_helpers.get_signed_file_url(upload_file_id=upload_file.id) return {"avatar_url": avatar_url} @console_ns.expect(console_ns.models[AccountAvatarPayload.__name__]) diff --git a/api/core/rag/datasource/vdb/vector_factory.py b/api/core/rag/datasource/vdb/vector_factory.py index 9575377174..1f82f7a081 100644 --- a/api/core/rag/datasource/vdb/vector_factory.py +++ b/api/core/rag/datasource/vdb/vector_factory.py @@ -144,8 +144,20 @@ class Vector: def get_vector_factory(vector_type: str) -> type[AbstractVectorFactory]: return get_vector_factory_class(vector_type) + @staticmethod + def _filter_empty_text_documents(documents: list[Document]) -> list[Document]: + filtered_documents = [document for document in documents if document.page_content.strip()] + skipped_count = len(documents) - len(filtered_documents) + if skipped_count: + logger.warning("skip %d empty documents before vector embedding", skipped_count) + return filtered_documents + def create(self, texts: list | None = None, **kwargs): if texts: + texts = self._filter_empty_text_documents(texts) + if not texts: + return + start = time.time() logger.info("start embedding %s texts %s", len(texts), start) batch_size = 1000 @@ -203,8 +215,14 @@ class Vector: logger.info("Embedding %s files took %s s", len(file_documents), time.time() - start) def add_texts(self, documents: list[Document], **kwargs): + documents = self._filter_empty_text_documents(documents) + if not documents: + return + if kwargs.get("duplicate_check", False): documents = self._filter_duplicate_texts(documents) + if not documents: + return embeddings = self._embeddings.embed_documents([document.page_content for document in documents]) self._vector_processor.create(texts=documents, embeddings=embeddings, **kwargs) diff --git a/api/factories/file_factory/remote.py b/api/factories/file_factory/remote.py index e5a7186007..9b8f94b1f3 100644 --- a/api/factories/file_factory/remote.py +++ b/api/factories/file_factory/remote.py @@ -19,8 +19,13 @@ from werkzeug.http import parse_options_header from core.helper import ssrf_proxy -def extract_filename(url_path: str, content_disposition: str | None) -> str | None: - """Extract a safe filename from Content-Disposition or the request URL path.""" +def extract_filename(url_or_path: str, content_disposition: str | None) -> str | None: + """Extract a safe filename from Content-Disposition or the request URL path. + + Handles full URLs, paths with query strings, hash fragments, and percent-encoded segments. + Query strings and hash fragments are stripped from the URL before extracting the basename. + Percent-encoded characters in the path are decoded safely. + """ filename: str | None = None if content_disposition: filename_star_match = re.search(r"filename\*=([^;]+)", content_disposition) @@ -47,8 +52,13 @@ def extract_filename(url_path: str, content_disposition: str | None) -> str | No filename = urllib.parse.unquote(raw) if not filename: - candidate = os.path.basename(url_path) - filename = urllib.parse.unquote(candidate) if candidate else None + # Parse the URL to extract just the path, stripping query strings and fragments + # This handles both full URLs and bare paths + parsed = urllib.parse.urlparse(url_or_path) + path = parsed.path + candidate = os.path.basename(path) + # Decode percent-encoded characters, with safe fallback for malformed input + filename = urllib.parse.unquote(candidate, errors="replace") if candidate else None if filename: filename = os.path.basename(filename) diff --git a/api/pyproject.toml b/api/pyproject.toml index f47a389f31..20eb54c9da 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "gevent>=26.4.0", "gevent-websocket>=0.10.1", "gmpy2>=2.3.0", - "google-api-python-client>=2.194.0", + "google-api-python-client>=2.195.0", "gunicorn>=25.3.0", "psycogreen>=1.0.2", "psycopg2-binary>=2.9.12", @@ -31,7 +31,7 @@ dependencies = [ "flask-migrate>=4.1.0,<5.0.0", "flask-orjson>=2.0.0,<3.0.0", "flask-restx>=1.3.2,<2.0.0", - "google-cloud-aiplatform>=1.148.1,<2.0.0", + "google-cloud-aiplatform>=1.149.0,<2.0.0", "httpx[socks]>=0.28.1,<1.0.0", "opentelemetry-distro>=0.62b1,<1.0.0", "opentelemetry-instrumentation-celery>=0.62b0,<1.0.0", diff --git a/api/services/entities/knowledge_entities/knowledge_entities.py b/api/services/entities/knowledge_entities/knowledge_entities.py index b1fe352861..910f54bebc 100644 --- a/api/services/entities/knowledge_entities/knowledge_entities.py +++ b/api/services/entities/knowledge_entities/knowledge_entities.py @@ -3,6 +3,7 @@ from typing import Any, Literal from pydantic import BaseModel, field_validator from core.rag.entities import Rule +from core.rag.entities.metadata_entities import MetadataFilteringCondition from core.rag.index_processor.constant.index_type import IndexStructureType from core.rag.retrieval.retrieval_methods import RetrievalMethod @@ -83,6 +84,7 @@ class RetrievalModel(BaseModel): score_threshold_enabled: bool score_threshold: float | None = None weights: WeightModel | None = None + metadata_filtering_conditions: MetadataFilteringCondition | None = None class MetaDataConfig(BaseModel): diff --git a/api/tests/test_containers_integration_tests/factories/test_storage_key_loader.py b/api/tests/test_containers_integration_tests/factories/test_storage_key_loader.py index 35e41035df..26b80cebbb 100644 --- a/api/tests/test_containers_integration_tests/factories/test_storage_key_loader.py +++ b/api/tests/test_containers_integration_tests/factories/test_storage_key_loader.py @@ -1,4 +1,5 @@ -import unittest +from __future__ import annotations + from datetime import UTC, datetime from unittest.mock import patch from uuid import uuid4 @@ -16,7 +17,7 @@ from models.enums import CreatorUserRole @pytest.mark.usefixtures("flask_req_ctx_with_containers") -class TestStorageKeyLoader(unittest.TestCase): +class TestStorageKeyLoader: """ Integration tests for StorageKeyLoader class. @@ -24,110 +25,82 @@ class TestStorageKeyLoader(unittest.TestCase): with different transfer methods: LOCAL_FILE, REMOTE_URL, and TOOL_FILE. """ - def setUp(self): - """Set up test data before each test method.""" - self.session = db.session() - self.tenant_id = str(uuid4()) - self.user_id = str(uuid4()) - self.conversation_id = str(uuid4()) - - # Create test data that will be cleaned up after each test - self.test_upload_files = [] - self.test_tool_files = [] - - # Create StorageKeyLoader instance - self.loader = StorageKeyLoader( - self.session, - self.tenant_id, - access_controller=DatabaseFileAccessController(), - ) - - def tearDown(self): - """Clean up test data after each test method.""" - self.session.rollback() + # ------------------------------------------------------------------ + # Per-test helpers (use db_session_with_containers as parameter) + # ------------------------------------------------------------------ + @staticmethod def _create_upload_file( - self, file_id: str | None = None, storage_key: str | None = None, tenant_id: str | None = None + session: Session, + tenant_id: str, + user_id: str, + *, + file_id: str | None = None, + storage_key: str | None = None, + override_tenant_id: str | None = None, ) -> UploadFile: - """Helper method to create an UploadFile record for testing.""" - if file_id is None: - file_id = str(uuid4()) - if storage_key is None: - storage_key = f"test_storage_key_{uuid4()}" - if tenant_id is None: - tenant_id = self.tenant_id - + """Create and flush an UploadFile record for testing.""" upload_file = UploadFile( - tenant_id=tenant_id, + tenant_id=override_tenant_id if override_tenant_id is not None else tenant_id, storage_type=StorageType.LOCAL, - key=storage_key, + key=storage_key or f"test_storage_key_{uuid4()}", name="test_file.txt", size=1024, extension=".txt", mime_type="text/plain", created_by_role=CreatorUserRole.ACCOUNT, - created_by=self.user_id, + created_by=user_id, created_at=datetime.now(UTC), used=False, ) - upload_file.id = file_id - - self.session.add(upload_file) - self.session.flush() - self.test_upload_files.append(upload_file) - + upload_file.id = file_id or str(uuid4()) + session.add(upload_file) + session.flush() return upload_file + @staticmethod def _create_tool_file( - self, file_id: str | None = None, file_key: str | None = None, tenant_id: str | None = None + session: Session, + tenant_id: str, + user_id: str, + conversation_id: str, + *, + file_id: str | None = None, + file_key: str | None = None, + override_tenant_id: str | None = None, ) -> ToolFile: - """Helper method to create a ToolFile record for testing.""" - if file_id is None: - file_id = str(uuid4()) - if file_key is None: - file_key = f"test_file_key_{uuid4()}" - if tenant_id is None: - tenant_id = self.tenant_id - + """Create and flush a ToolFile record for testing.""" tool_file = ToolFile( - user_id=self.user_id, - tenant_id=tenant_id, - conversation_id=self.conversation_id, - file_key=file_key, + user_id=user_id, + tenant_id=override_tenant_id if override_tenant_id is not None else tenant_id, + conversation_id=conversation_id, + file_key=file_key or f"test_file_key_{uuid4()}", mimetype="text/plain", original_url="http://example.com/file.txt", name="test_tool_file.txt", size=2048, ) - tool_file.id = file_id - - self.session.add(tool_file) - self.session.flush() - self.test_tool_files.append(tool_file) - + tool_file.id = file_id or str(uuid4()) + session.add(tool_file) + session.flush() return tool_file - def _create_file(self, related_id: str, transfer_method: FileTransferMethod, tenant_id: str | None = None) -> File: - """Helper method to create a File object for testing.""" - if tenant_id is None: - tenant_id = self.tenant_id - - # Set related_id for LOCAL_FILE and TOOL_FILE transfer methods - file_related_id = None - remote_url = None - - if transfer_method in (FileTransferMethod.LOCAL_FILE, FileTransferMethod.TOOL_FILE): - file_related_id = related_id - elif transfer_method == FileTransferMethod.REMOTE_URL: - remote_url = "https://example.com/test_file.txt" - file_related_id = related_id - + @staticmethod + def _create_file( + tenant_id: str, + related_id: str, + transfer_method: FileTransferMethod, + *, + override_tenant_id: str | None = None, + ) -> File: + """Build a File value-object for testing.""" + remote_url = "https://example.com/test_file.txt" if transfer_method == FileTransferMethod.REMOTE_URL else None return File( - file_id=str(uuid4()), # Generate new UUID for File.id - tenant_id=tenant_id, + file_id=str(uuid4()), + tenant_id=override_tenant_id if override_tenant_id is not None else tenant_id, file_type=FileType.DOCUMENT, transfer_method=transfer_method, - related_id=file_related_id, + related_id=related_id, remote_url=remote_url, filename="test_file.txt", extension=".txt", @@ -136,240 +109,280 @@ class TestStorageKeyLoader(unittest.TestCase): storage_key="initial_key", ) - def test_load_storage_keys_local_file(self): + # ------------------------------------------------------------------ + # Tests + # ------------------------------------------------------------------ + + def test_load_storage_keys_local_file(self, db_session_with_containers: Session): """Test loading storage keys for LOCAL_FILE transfer method.""" - # Create test data - upload_file = self._create_upload_file() - file = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE) + tenant_id = str(uuid4()) + user_id = str(uuid4()) - # Load storage keys - self.loader.load_storage_keys([file]) + upload_file = self._create_upload_file(db_session_with_containers, tenant_id, user_id) + file = self._create_file(tenant_id, related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE) + + loader = StorageKeyLoader( + db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController() + ) + loader.load_storage_keys([file]) - # Verify storage key was loaded correctly assert file._storage_key == upload_file.key - def test_load_storage_keys_remote_url(self): + def test_load_storage_keys_remote_url(self, db_session_with_containers: Session): """Test loading storage keys for REMOTE_URL transfer method.""" - # Create test data - upload_file = self._create_upload_file() - file = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.REMOTE_URL) + tenant_id = str(uuid4()) + user_id = str(uuid4()) - # Load storage keys - self.loader.load_storage_keys([file]) + upload_file = self._create_upload_file(db_session_with_containers, tenant_id, user_id) + file = self._create_file(tenant_id, related_id=upload_file.id, transfer_method=FileTransferMethod.REMOTE_URL) + + loader = StorageKeyLoader( + db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController() + ) + loader.load_storage_keys([file]) - # Verify storage key was loaded correctly assert file._storage_key == upload_file.key - def test_load_storage_keys_tool_file(self): + def test_load_storage_keys_tool_file(self, db_session_with_containers: Session): """Test loading storage keys for TOOL_FILE transfer method.""" - # Create test data - tool_file = self._create_tool_file() - file = self._create_file(related_id=tool_file.id, transfer_method=FileTransferMethod.TOOL_FILE) + tenant_id = str(uuid4()) + user_id = str(uuid4()) + conversation_id = str(uuid4()) - # Load storage keys - self.loader.load_storage_keys([file]) + tool_file = self._create_tool_file(db_session_with_containers, tenant_id, user_id, conversation_id) + file = self._create_file(tenant_id, related_id=tool_file.id, transfer_method=FileTransferMethod.TOOL_FILE) + + loader = StorageKeyLoader( + db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController() + ) + loader.load_storage_keys([file]) - # Verify storage key was loaded correctly assert file._storage_key == tool_file.file_key - def test_load_storage_keys_mixed_methods(self): + def test_load_storage_keys_mixed_methods(self, db_session_with_containers: Session): """Test batch loading with mixed transfer methods.""" - # Create test data for different transfer methods - upload_file1 = self._create_upload_file() - upload_file2 = self._create_upload_file() - tool_file = self._create_tool_file() + tenant_id = str(uuid4()) + user_id = str(uuid4()) + conversation_id = str(uuid4()) - file1 = self._create_file(related_id=upload_file1.id, transfer_method=FileTransferMethod.LOCAL_FILE) - file2 = self._create_file(related_id=upload_file2.id, transfer_method=FileTransferMethod.REMOTE_URL) - file3 = self._create_file(related_id=tool_file.id, transfer_method=FileTransferMethod.TOOL_FILE) + upload_file1 = self._create_upload_file(db_session_with_containers, tenant_id, user_id) + upload_file2 = self._create_upload_file(db_session_with_containers, tenant_id, user_id) + tool_file = self._create_tool_file(db_session_with_containers, tenant_id, user_id, conversation_id) - files = [file1, file2, file3] + file1 = self._create_file(tenant_id, related_id=upload_file1.id, transfer_method=FileTransferMethod.LOCAL_FILE) + file2 = self._create_file(tenant_id, related_id=upload_file2.id, transfer_method=FileTransferMethod.REMOTE_URL) + file3 = self._create_file(tenant_id, related_id=tool_file.id, transfer_method=FileTransferMethod.TOOL_FILE) - # Load storage keys - self.loader.load_storage_keys(files) + loader = StorageKeyLoader( + db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController() + ) + loader.load_storage_keys([file1, file2, file3]) - # Verify all storage keys were loaded correctly assert file1._storage_key == upload_file1.key assert file2._storage_key == upload_file2.key assert file3._storage_key == tool_file.file_key - def test_load_storage_keys_empty_list(self): - """Test with empty file list.""" - # Should not raise any exceptions - self.loader.load_storage_keys([]) + def test_load_storage_keys_empty_list(self, db_session_with_containers: Session): + """Test with empty file list — should not raise.""" + tenant_id = str(uuid4()) + loader = StorageKeyLoader( + db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController() + ) + loader.load_storage_keys([]) - def test_load_storage_keys_ignores_legacy_file_tenant_id(self): + def test_load_storage_keys_ignores_legacy_file_tenant_id(self, db_session_with_containers: Session): """Legacy file tenant_id should not override the loader tenant scope.""" - upload_file = self._create_upload_file() + tenant_id = str(uuid4()) + user_id = str(uuid4()) + + upload_file = self._create_upload_file(db_session_with_containers, tenant_id, user_id) file = self._create_file( - related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE, tenant_id=str(uuid4()) + tenant_id, + related_id=upload_file.id, + transfer_method=FileTransferMethod.LOCAL_FILE, + override_tenant_id=str(uuid4()), ) - self.loader.load_storage_keys([file]) + loader = StorageKeyLoader( + db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController() + ) + loader.load_storage_keys([file]) assert file._storage_key == upload_file.key - def test_load_storage_keys_missing_file_id(self): - """Test with None file.related_id.""" - # Create a file with valid parameters first, then manually set related_id to None - file = self._create_file(related_id=str(uuid4()), transfer_method=FileTransferMethod.LOCAL_FILE) + def test_load_storage_keys_missing_file_id(self, db_session_with_containers: Session): + """Test with None file.related_id — should raise ValueError.""" + tenant_id = str(uuid4()) + user_id = str(uuid4()) + + upload_file = self._create_upload_file(db_session_with_containers, tenant_id, user_id) + file = self._create_file(tenant_id, related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE) file.related_id = None - # Should raise ValueError for None file related_id - with pytest.raises(ValueError) as context: - self.loader.load_storage_keys([file]) + loader = StorageKeyLoader( + db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController() + ) + with pytest.raises(ValueError, match="file id should not be None."): + loader.load_storage_keys([file]) - assert str(context.value) == "file id should not be None." + def test_load_storage_keys_nonexistent_upload_file_records(self, db_session_with_containers: Session): + """Test with missing UploadFile database records — should raise ValueError.""" + tenant_id = str(uuid4()) + file = self._create_file(tenant_id, related_id=str(uuid4()), transfer_method=FileTransferMethod.LOCAL_FILE) - def test_load_storage_keys_nonexistent_upload_file_records(self): - """Test with missing UploadFile database records.""" - # Create file with non-existent upload file id - non_existent_id = str(uuid4()) - file = self._create_file(related_id=non_existent_id, transfer_method=FileTransferMethod.LOCAL_FILE) - - # Should raise ValueError for missing record + loader = StorageKeyLoader( + db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController() + ) with pytest.raises(ValueError): - self.loader.load_storage_keys([file]) + loader.load_storage_keys([file]) - def test_load_storage_keys_nonexistent_tool_file_records(self): - """Test with missing ToolFile database records.""" - # Create file with non-existent tool file id - non_existent_id = str(uuid4()) - file = self._create_file(related_id=non_existent_id, transfer_method=FileTransferMethod.TOOL_FILE) + def test_load_storage_keys_nonexistent_tool_file_records(self, db_session_with_containers: Session): + """Test with missing ToolFile database records — should raise ValueError.""" + tenant_id = str(uuid4()) + file = self._create_file(tenant_id, related_id=str(uuid4()), transfer_method=FileTransferMethod.TOOL_FILE) - # Should raise ValueError for missing record + loader = StorageKeyLoader( + db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController() + ) with pytest.raises(ValueError): - self.loader.load_storage_keys([file]) + loader.load_storage_keys([file]) - def test_load_storage_keys_invalid_uuid(self): - """Test with invalid UUID format.""" - # Create a file with valid parameters first, then manually set invalid related_id - file = self._create_file(related_id=str(uuid4()), transfer_method=FileTransferMethod.LOCAL_FILE) + def test_load_storage_keys_invalid_uuid(self, db_session_with_containers: Session): + """Test with invalid UUID format — should raise ValueError.""" + tenant_id = str(uuid4()) + user_id = str(uuid4()) + + upload_file = self._create_upload_file(db_session_with_containers, tenant_id, user_id) + file = self._create_file(tenant_id, related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE) file.related_id = "invalid-uuid-format" - # Should raise ValueError for invalid UUID + loader = StorageKeyLoader( + db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController() + ) with pytest.raises(ValueError): - self.loader.load_storage_keys([file]) + loader.load_storage_keys([file]) - def test_load_storage_keys_batch_efficiency(self): - """Test batched operations use efficient queries.""" - # Create multiple files of different types - upload_files = [self._create_upload_file() for _ in range(3)] - tool_files = [self._create_tool_file() for _ in range(2)] + def test_load_storage_keys_batch_efficiency(self, db_session_with_containers: Session): + """Batched operations should issue exactly 2 queries for mixed file types.""" + tenant_id = str(uuid4()) + user_id = str(uuid4()) + conversation_id = str(uuid4()) - files = [] - files.extend( - [self._create_file(related_id=uf.id, transfer_method=FileTransferMethod.LOCAL_FILE) for uf in upload_files] + upload_files = [self._create_upload_file(db_session_with_containers, tenant_id, user_id) for _ in range(3)] + tool_files = [ + self._create_tool_file(db_session_with_containers, tenant_id, user_id, conversation_id) for _ in range(2) + ] + + files = [ + self._create_file(tenant_id, related_id=uf.id, transfer_method=FileTransferMethod.LOCAL_FILE) + for uf in upload_files + ] + [ + self._create_file(tenant_id, related_id=tf.id, transfer_method=FileTransferMethod.TOOL_FILE) + for tf in tool_files + ] + + loader = StorageKeyLoader( + db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController() ) - files.extend( - [self._create_file(related_id=tf.id, transfer_method=FileTransferMethod.TOOL_FILE) for tf in tool_files] - ) - - # Mock the session to count queries - with patch.object(self.session, "scalars", wraps=self.session.scalars) as mock_scalars: - self.loader.load_storage_keys(files) - - # Should make exactly 2 queries (one for upload_files, one for tool_files) + with patch.object( + db_session_with_containers, "scalars", wraps=db_session_with_containers.scalars + ) as mock_scalars: + loader.load_storage_keys(files) + # Exactly 2 DB round-trips: one for UploadFile, one for ToolFile assert mock_scalars.call_count == 2 - # Verify all storage keys were loaded correctly for i, file in enumerate(files[:3]): assert file._storage_key == upload_files[i].key for i, file in enumerate(files[3:]): assert file._storage_key == tool_files[i].file_key - def test_load_storage_keys_tenant_isolation(self): - """Test that tenant isolation works correctly.""" - # Create files for different tenants + def test_load_storage_keys_tenant_isolation(self, db_session_with_containers: Session): + """Loader should not surface records belonging to a different tenant.""" + tenant_id = str(uuid4()) other_tenant_id = str(uuid4()) + user_id = str(uuid4()) - # Create upload file for current tenant - upload_file_current = self._create_upload_file() + upload_file_current = self._create_upload_file(db_session_with_containers, tenant_id, user_id) file_current = self._create_file( - related_id=upload_file_current.id, transfer_method=FileTransferMethod.LOCAL_FILE + tenant_id, related_id=upload_file_current.id, transfer_method=FileTransferMethod.LOCAL_FILE ) - # Create upload file for other tenant (but don't add to cleanup list) - upload_file_other = UploadFile( - tenant_id=other_tenant_id, - storage_type=StorageType.LOCAL, - key="other_tenant_key", - name="other_file.txt", - size=1024, - extension=".txt", - mime_type="text/plain", - created_by_role=CreatorUserRole.ACCOUNT, - created_by=self.user_id, - created_at=datetime.now(UTC), - used=False, + upload_file_other = self._create_upload_file( + db_session_with_containers, + tenant_id, + user_id, + override_tenant_id=other_tenant_id, ) - upload_file_other.id = str(uuid4()) - self.session.add(upload_file_other) - self.session.flush() - - # Create file for other tenant but try to load with current tenant's loader file_other = self._create_file( - related_id=upload_file_other.id, transfer_method=FileTransferMethod.LOCAL_FILE, tenant_id=other_tenant_id + tenant_id, + related_id=upload_file_other.id, + transfer_method=FileTransferMethod.LOCAL_FILE, + override_tenant_id=other_tenant_id, ) - # Should raise ValueError due to tenant mismatch - with pytest.raises(ValueError) as context: - self.loader.load_storage_keys([file_other]) + loader = StorageKeyLoader( + db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController() + ) - assert "Upload file not found for id:" in str(context.value) + with pytest.raises(ValueError, match="Upload file not found for id:"): + loader.load_storage_keys([file_other]) - # Current tenant's file should still work - self.loader.load_storage_keys([file_current]) + # Current-tenant file still resolves correctly + loader.load_storage_keys([file_current]) assert file_current._storage_key == upload_file_current.key - def test_load_storage_keys_mixed_tenant_batch(self): - """Test batch with mixed tenant files (should fail on first mismatch).""" - # Create files for current tenant - upload_file_current = self._create_upload_file() + def test_load_storage_keys_mixed_tenant_batch(self, db_session_with_containers: Session): + """A batch containing a foreign-tenant file should fail on the mismatch.""" + tenant_id = str(uuid4()) + user_id = str(uuid4()) + + upload_file_current = self._create_upload_file(db_session_with_containers, tenant_id, user_id) file_current = self._create_file( - related_id=upload_file_current.id, transfer_method=FileTransferMethod.LOCAL_FILE + tenant_id, related_id=upload_file_current.id, transfer_method=FileTransferMethod.LOCAL_FILE ) - - # Create file for different tenant - other_tenant_id = str(uuid4()) file_other = self._create_file( - related_id=str(uuid4()), transfer_method=FileTransferMethod.LOCAL_FILE, tenant_id=other_tenant_id + tenant_id, + related_id=str(uuid4()), + transfer_method=FileTransferMethod.LOCAL_FILE, + override_tenant_id=str(uuid4()), ) - # Should raise ValueError on tenant mismatch - with pytest.raises(ValueError) as context: - self.loader.load_storage_keys([file_current, file_other]) + loader = StorageKeyLoader( + db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController() + ) + with pytest.raises(ValueError, match="Upload file not found for id:"): + loader.load_storage_keys([file_current, file_other]) - assert "Upload file not found for id:" in str(context.value) + def test_load_storage_keys_duplicate_file_ids(self, db_session_with_containers: Session): + """Duplicate file IDs in the batch should be handled gracefully.""" + tenant_id = str(uuid4()) + user_id = str(uuid4()) - def test_load_storage_keys_duplicate_file_ids(self): - """Test handling of duplicate file IDs in the batch.""" - # Create upload file - upload_file = self._create_upload_file() + upload_file = self._create_upload_file(db_session_with_containers, tenant_id, user_id) + file1 = self._create_file(tenant_id, related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE) + file2 = self._create_file(tenant_id, related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE) - # Create two File objects with same related_id - file1 = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE) - file2 = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE) + loader = StorageKeyLoader( + db_session_with_containers, tenant_id, access_controller=DatabaseFileAccessController() + ) + loader.load_storage_keys([file1, file2]) - # Should handle duplicates gracefully - self.loader.load_storage_keys([file1, file2]) - - # Both files should have the same storage key assert file1._storage_key == upload_file.key assert file2._storage_key == upload_file.key - def test_load_storage_keys_session_isolation(self): - """Test that the loader uses the provided session correctly.""" - # Create test data - upload_file = self._create_upload_file() - file = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE) + def test_load_storage_keys_session_isolation(self, db_session_with_containers: Session): + """A loader backed by an uncommitted session should not see data from another session.""" + tenant_id = str(uuid4()) + user_id = str(uuid4()) - # Create loader with different session (same underlying connection) + upload_file = self._create_upload_file(db_session_with_containers, tenant_id, user_id) + file = self._create_file(tenant_id, related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE) + # A loader with a fresh, separate session cannot see uncommitted rows from db_session_with_containers with Session(bind=db.engine) as other_session: other_loader = StorageKeyLoader( other_session, - self.tenant_id, + tenant_id, access_controller=DatabaseFileAccessController(), ) with pytest.raises(ValueError): diff --git a/api/tests/unit_tests/controllers/console/workspace/test_accounts.py b/api/tests/unit_tests/controllers/console/workspace/test_accounts.py index 42be02cdaf..bbe9d09521 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_accounts.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_accounts.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, PropertyMock, patch import pytest +from werkzeug.exceptions import NotFound from controllers.console import console_ns from controllers.console.auth.error import ( @@ -29,6 +30,7 @@ from controllers.console.workspace.error import ( CurrentPasswordIncorrectError, InvalidAccountDeletionCodeError, ) +from models.enums import CreatorUserRole from services.errors.account import CurrentPasswordIncorrectError as ServicePwdError @@ -135,6 +137,131 @@ class TestAccountUpdateApis: assert result["id"] == "u1" +class TestAccountAvatarApiGet: + """GET /account/avatar must not sign arbitrary upload_file IDs (IDOR).""" + + def test_get_avatar_signed_url_when_upload_owned_by_current_account(self, app): + api = AccountAvatarApi() + method = unwrap(api.get) + + user = MagicMock() + user.id = "acc-owner" + tenant_id = "tenant-1" + file_id = "550e8400-e29b-41d4-a716-446655440000" + + upload_file = MagicMock() + upload_file.id = file_id + upload_file.tenant_id = tenant_id + upload_file.created_by = user.id + upload_file.created_by_role = CreatorUserRole.ACCOUNT + + with ( + app.test_request_context(f"/account/avatar?avatar={file_id}"), + patch( + "controllers.console.workspace.account.current_account_with_tenant", + return_value=(user, tenant_id), + ), + patch("controllers.console.workspace.account.db.session.scalar", return_value=upload_file), + patch( + "controllers.console.workspace.account.file_helpers.get_signed_file_url", + return_value="https://signed/example", + ) as sign_mock, + ): + result = method(api) + + assert result == {"avatar_url": "https://signed/example"} + sign_mock.assert_called_once_with(upload_file_id=file_id) + + def test_get_avatar_not_found_when_upload_created_by_other_account_same_tenant(self, app): + api = AccountAvatarApi() + method = unwrap(api.get) + + user = MagicMock() + user.id = "acc-a" + tenant_id = "tenant-1" + file_id = "550e8400-e29b-41d4-a716-446655440001" + + upload_file = MagicMock() + upload_file.id = file_id + upload_file.tenant_id = tenant_id + upload_file.created_by = "acc-b" + upload_file.created_by_role = CreatorUserRole.ACCOUNT + + with ( + app.test_request_context(f"/account/avatar?avatar={file_id}"), + patch( + "controllers.console.workspace.account.current_account_with_tenant", + return_value=(user, tenant_id), + ), + patch("controllers.console.workspace.account.db.session.scalar", return_value=upload_file), + patch( + "controllers.console.workspace.account.file_helpers.get_signed_file_url", + return_value="https://signed/leak", + ) as sign_mock, + ): + with pytest.raises(NotFound): + method(api) + + sign_mock.assert_not_called() + + def test_get_avatar_not_found_when_upload_belongs_to_other_tenant(self, app): + api = AccountAvatarApi() + method = unwrap(api.get) + + user = MagicMock() + user.id = "acc-owner" + tenant_id = "tenant-1" + file_id = "550e8400-e29b-41d4-a716-446655440002" + + upload_file = MagicMock() + upload_file.id = file_id + upload_file.tenant_id = "tenant-other" + upload_file.created_by = user.id + upload_file.created_by_role = CreatorUserRole.ACCOUNT + + with ( + app.test_request_context(f"/account/avatar?avatar={file_id}"), + patch( + "controllers.console.workspace.account.current_account_with_tenant", + return_value=(user, tenant_id), + ), + patch("controllers.console.workspace.account.db.session.scalar", return_value=upload_file), + patch( + "controllers.console.workspace.account.file_helpers.get_signed_file_url", + return_value="https://signed/leak", + ) as sign_mock, + ): + with pytest.raises(NotFound): + method(api) + + sign_mock.assert_not_called() + + def test_get_avatar_https_pass_through_without_signing(self, app): + api = AccountAvatarApi() + method = unwrap(api.get) + + user = MagicMock() + user.id = "acc-owner" + tenant_id = "tenant-1" + external = "https://cdn.example/avatar.png" + + with ( + app.test_request_context(f"/account/avatar?avatar={external}"), + patch( + "controllers.console.workspace.account.current_account_with_tenant", + return_value=(user, tenant_id), + ), + patch( + "controllers.console.workspace.account.file_helpers.get_signed_file_url", + return_value="https://signed/should-not-use", + ) as sign_mock, + ): + result = method(api) + + assert result == {"avatar_url": external} + sign_mock.assert_not_called() + + class TestAccountPasswordApi: def test_password_success(self, app): api = AccountPasswordApi() 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 9be8e56f56..a26cdf6563 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,62 @@ 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_preserves_retrieval_model_metadata_filtering_conditions( + self, + mock_current_user, + mock_dataset_svc, + mock_hit_svc, + mock_marshal, + mock_ns, + app, + ): + """Service API retrieval payload should not drop metadata filters.""" + 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": "filtered query", "records": []} + mock_hit_svc.hit_testing_args_check.return_value = None + mock_marshal.return_value = [] + + metadata_filtering_conditions = { + "logical_operator": "and", + "conditions": [ + { + "name": "category", + "comparison_operator": "is", + "value": "finance", + } + ], + } + mock_ns.payload = { + "query": "filtered query", + "retrieval_model": { + "search_method": "semantic_search", + "reranking_enable": False, + "score_threshold_enabled": False, + "top_k": 4, + "metadata_filtering_conditions": metadata_filtering_conditions, + }, + } + + with app.test_request_context(): + api = HitTestingApi() + HitTestingApi.post.__wrapped__(api, tenant_id, dataset_id) + + passed_retrieval_model = mock_hit_svc.retrieve.call_args.kwargs.get("retrieval_model") + assert passed_retrieval_model is not None + assert passed_retrieval_model["metadata_filtering_conditions"] == metadata_filtering_conditions + @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") diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py b/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py index f84ce2771f..7b6ee97f1c 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py +++ b/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py @@ -316,6 +316,33 @@ def test_create_batches_texts_and_skips_empty_input(vector_factory_module): vector._vector_processor.create.assert_not_called() +def test_create_skips_empty_text_documents_before_embedding(vector_factory_module): + vector = vector_factory_module.Vector.__new__(vector_factory_module.Vector) + vector._embeddings = MagicMock() + vector._embeddings.embed_documents.return_value = [[0.1], [0.2]] + vector._vector_processor = MagicMock() + + docs = [ + Document(page_content="foo", metadata={"doc_id": "id-1"}), + Document(page_content="", metadata={"doc_id": "id-empty"}), + Document(page_content=" \n", metadata={"doc_id": "id-blank"}), + Document(page_content="bar", metadata={"doc_id": "id-2"}), + ] + + vector.create(texts=docs, request_id="r-1") + + vector._embeddings.embed_documents.assert_called_once_with(["foo", "bar"]) + vector._vector_processor.create.assert_called_once_with( + texts=[docs[0], docs[3]], embeddings=[[0.1], [0.2]], request_id="r-1" + ) + + vector._embeddings.embed_documents.reset_mock() + vector._vector_processor.create.reset_mock() + vector.create(texts=[docs[1], docs[2]]) + vector._embeddings.embed_documents.assert_not_called() + vector._vector_processor.create.assert_not_called() + + def test_create_multimodal_filters_missing_uploads(vector_factory_module, monkeypatch): class _Field: def in_(self, value): @@ -396,6 +423,48 @@ def test_add_texts_with_optional_duplicate_check(vector_factory_module): vector._vector_processor.create.assert_called_once() +def test_add_texts_skips_empty_text_documents(vector_factory_module): + vector = vector_factory_module.Vector.__new__(vector_factory_module.Vector) + vector._embeddings = MagicMock() + vector._embeddings.embed_documents.return_value = [[0.1]] + vector._vector_processor = MagicMock() + + docs = [ + Document(page_content="keep", metadata={"doc_id": "id-1"}), + Document(page_content="", metadata={"doc_id": "id-empty"}), + ] + + vector.add_texts(docs, source="api") + + vector._embeddings.embed_documents.assert_called_once_with(["keep"]) + vector._vector_processor.create.assert_called_once_with(texts=[docs[0]], embeddings=[[0.1]], source="api") + + vector._embeddings.embed_documents.reset_mock() + vector._vector_processor.create.reset_mock() + vector.add_texts([docs[1]]) + vector._embeddings.embed_documents.assert_not_called() + vector._vector_processor.create.assert_not_called() + + +def test_add_texts_filters_empty_documents_before_duplicate_check(vector_factory_module): + vector = vector_factory_module.Vector.__new__(vector_factory_module.Vector) + vector._embeddings = MagicMock() + vector._embeddings.embed_documents.return_value = [[0.1]] + vector._vector_processor = MagicMock() + vector._filter_duplicate_texts = MagicMock(return_value=[]) + + docs = [ + Document(page_content="keep", metadata={"doc_id": "id-1"}), + Document(page_content=" ", metadata={"doc_id": "id-empty"}), + ] + + vector.add_texts(docs, duplicate_check=True) + + vector._filter_duplicate_texts.assert_called_once_with([docs[0]]) + vector._embeddings.embed_documents.assert_not_called() + vector._vector_processor.create.assert_not_called() + + def test_vector_delegation_methods(vector_factory_module): vector = vector_factory_module.Vector.__new__(vector_factory_module.Vector) vector._embeddings = MagicMock() diff --git a/api/tests/unit_tests/factories/test_file_factory.py b/api/tests/unit_tests/factories/test_file_factory.py index 5b105d6084..c2835c4124 100644 --- a/api/tests/unit_tests/factories/test_file_factory.py +++ b/api/tests/unit_tests/factories/test_file_factory.py @@ -230,3 +230,64 @@ class TestExtractFilename: "http://example.com/", 'attachment; filename="file%20with%20quotes%20%26%20encoding.txt"' ) assert result == "file with quotes & encoding.txt" + + def test_url_with_query_string(self): + """Test that query strings are stripped from URL basename.""" + result = extract_filename("http://example.com/path/file.txt?signature=abc123&expires=12345", None) + assert result == "file.txt" + + def test_url_with_hash_fragment(self): + """Test that hash fragments are stripped from URL basename.""" + result = extract_filename("http://example.com/path/file.txt#section", None) + assert result == "file.txt" + + def test_url_with_query_and_fragment(self): + """Test that both query strings and hash fragments are stripped.""" + result = extract_filename("http://example.com/path/file.txt?token=xyz#section", None) + assert result == "file.txt" + + def test_signed_url_preserves_filename(self): + """Test that signed URL parameters don't affect filename extraction.""" + result = extract_filename( + "http://storage.example.com/bucket/documents/report.pdf?AWSAccessKeyId=xxx&Signature=yyy&Expires=12345", + None, + ) + assert result == "report.pdf" + + def test_percent_encoded_filename_with_query_string(self): + """Test percent-encoded filename with query string is decoded correctly.""" + result = extract_filename("http://example.com/path/my%20file.txt?download=true", None) + assert result == "my file.txt" + + def test_percent_encoded_filename_with_fragment(self): + """Test percent-encoded filename with fragment is decoded correctly.""" + result = extract_filename("http://example.com/path/my%20file.txt#page=1", None) + assert result == "my file.txt" + + def test_complex_percent_encoding_with_query(self): + """Test complex percent-encoded filename with query parameters.""" + result = extract_filename("http://example.com/docs/%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6.pdf?v=1", None) + assert result == "中文文件.pdf" + + def test_url_with_special_chars_in_query(self): + """Test that special characters in query string don't affect filename.""" + result = extract_filename("http://example.com/file.bin?name=test&path=/some/path", None) + assert result == "file.bin" + + def test_malformed_percent_encoding_safe_fallback(self): + """Test that malformed percent-encoding is handled safely.""" + result = extract_filename("http://example.com/path/file%20name%GG.txt?x=1", None) + # %GG is invalid, should be replaced with replacement character + + assert "file" in result + assert ".txt" in result + + def test_empty_path_with_query_returns_none(self): + """Test that empty path with query string returns None.""" + result = extract_filename("http://example.com/?query=value", None) + assert result is None + + def test_path_only_with_query_string(self): + """Test bare path (not full URL) with query string.""" + result = extract_filename("/path/to/file.txt?extra=params", None) + assert result == "file.txt" diff --git a/api/uv.lock b/api/uv.lock index aacca7f4ab..dd09fc7579 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1592,8 +1592,8 @@ requires-dist = [ { name = "gevent", specifier = ">=26.4.0" }, { name = "gevent-websocket", specifier = ">=0.10.1" }, { name = "gmpy2", specifier = ">=2.3.0" }, - { name = "google-api-python-client", specifier = ">=2.194.0" }, - { name = "google-cloud-aiplatform", specifier = ">=1.148.1,<2.0.0" }, + { name = "google-api-python-client", specifier = ">=2.195.0" }, + { name = "google-cloud-aiplatform", specifier = ">=1.149.0,<2.0.0" }, { name = "graphon", specifier = "~=0.2.2" }, { name = "gunicorn", specifier = ">=25.3.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1.0.0" }, @@ -2719,7 +2719,7 @@ grpc = [ [[package]] name = "google-api-python-client" -version = "2.194.0" +version = "2.195.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -2728,9 +2728,9 @@ dependencies = [ { name = "httplib2" }, { name = "uritemplate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/ab/e83af0eb043e4ccc49571ca7a6a49984e9d00f4e9e6e6f1238d60bc84dce/google_api_python_client-2.194.0.tar.gz", hash = "sha256:db92647bd1a90f40b79c9618461553c2b20b6a43ce7395fa6de07132dc14f023", size = 14443469, upload-time = "2026-04-08T23:07:35.757Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/07/08d759b9cb10f48af14b25262dd0d6685ca8cda6c1f9e8a8109f57457205/google_api_python_client-2.195.0.tar.gz", hash = "sha256:c72cf2661c3addf01c880ce60541e83e1df354644b874f7f9d8d5ed2070446ae", size = 14584819, upload-time = "2026-04-30T21:51:50.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/34/5a624e49f179aa5b0cb87b2ce8093960299030ff40423bfbde09360eb908/google_api_python_client-2.194.0-py3-none-any.whl", hash = "sha256:61eaaac3b8fc8fdf11c08af87abc3d1342d1b37319cc1b57405f86ef7697e717", size = 15016514, upload-time = "2026-04-08T23:07:33.093Z" }, + { url = "https://files.pythonhosted.org/packages/21/b9/2c71095e31fff57668fec7c07ac897df065f15521d070e63229e13689590/google_api_python_client-2.195.0-py3-none-any.whl", hash = "sha256:753e62057f23049a89534bea0162b60fe391b85fb86d80bcdf884d05ec91c5bf", size = 15162418, upload-time = "2026-04-30T21:51:47.444Z" }, ] [[package]] @@ -2766,7 +2766,7 @@ wheels = [ [[package]] name = "google-cloud-aiplatform" -version = "1.148.1" +version = "1.149.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docstring-parser" }, @@ -2782,9 +2782,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/f3/b2a9417014c93858a2e3266134f931eefd972c2d410b25d7b8782fc6f143/google_cloud_aiplatform-1.148.1.tar.gz", hash = "sha256:75d605fba34e68714bd08e1e482755d0a6e3ae972805f809d088e686c30879e7", size = 10278758, upload-time = "2026-04-17T23:45:26.738Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/2c/fba4adc56f74c0ee0fbd91a39d414ca2c3588dd8b71f9be8a507015ca886/google_cloud_aiplatform-1.149.0.tar.gz", hash = "sha256:a4d73485bf1d727a9e1bbbd13d08d7031490686bbf7d125eb905c1a6c1559a35", size = 10451466, upload-time = "2026-04-27T23:11:54.513Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/5b/e3515d7bbba602c2b0f6a0da5431785e897252443682e4735d0e6873dc8f/google_cloud_aiplatform-1.148.1-py2.py3-none-any.whl", hash = "sha256:035101e2d8e65c6a706cc3930b2452de7ddcbde50dd130320fcea0d8b03b0c5a", size = 8434481, upload-time = "2026-04-17T23:45:22.919Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/27719ba23967ef62e52a1d54e013e0fc174bdab8dd84fb300bab9bf0d4a3/google_cloud_aiplatform-1.149.0-py2.py3-none-any.whl", hash = "sha256:e6b5299fa5d303e971cb29a19f03fdbb7b1e3b9d2faa3a788ca933341fba2f2e", size = 8570410, upload-time = "2026-04-27T23:11:50.495Z" }, ] [[package]] diff --git a/eslint-suppressions.json b/eslint-suppressions.json index a4cd74de56..7f8b697cc9 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -197,21 +197,11 @@ "count": 4 } }, - "web/app/components/app-sidebar/basic.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app-sidebar/index.tsx": { "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/app-sidebar/toggle-button.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -351,16 +341,6 @@ "count": 1 } }, - "web/app/components/app/configuration/config-vision/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/app/configuration/config-vision/param-config-content.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/configuration/config/agent/agent-setting/index.tsx": { "react/set-state-in-effect": { "count": 1 @@ -445,21 +425,6 @@ "count": 2 } }, - "web/app/components/app/configuration/config/config-audio.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/app/configuration/config/config-document.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/app/configuration/dataset-config/context-var/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/configuration/dataset-config/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -470,11 +435,6 @@ "count": 1 } }, - "web/app/components/app/configuration/dataset-config/params-config/config-content.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/configuration/dataset-config/params-config/index.tsx": { "no-restricted-imports": { "count": 1 @@ -1726,11 +1686,6 @@ "count": 1 } }, - "web/app/components/base/param-item/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/prompt-editor/index.stories.tsx": { "no-console": { "count": 1 @@ -2047,11 +2002,6 @@ "count": 1 } }, - "web/app/components/billing/priority-label/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/billing/type.ts": { "erasable-syntax-only/enums": { "count": 4 @@ -2077,11 +2027,6 @@ "count": 3 } }, - "web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/common/image-uploader/store.tsx": { "react-refresh/only-export-components": { "count": 3 @@ -2092,11 +2037,6 @@ "count": 1 } }, - "web/app/components/datasets/common/retrieval-param-config/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/dsl-confirm-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -2115,11 +2055,6 @@ "count": 1 } }, - "web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/create-from-pipeline/list/template-card/details/types.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -2130,11 +2065,6 @@ "count": 1 } }, - "web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx": { "no-restricted-imports": { "count": 1 @@ -2165,16 +2095,6 @@ "count": 5 } }, - "web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, - "web/app/components/datasets/create/step-two/components/inputs.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/create/step-two/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 6 @@ -2209,16 +2129,6 @@ "count": 1 } }, - "web/app/components/datasets/create/website/base/checkbox-with-label.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/datasets/create/website/base/field.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/create/website/firecrawl/index.tsx": { "no-console": { "count": 1 @@ -2327,11 +2237,6 @@ "count": 4 } }, - "web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/checkbox-with-label.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -2477,11 +2382,6 @@ "count": 3 } }, - "web/app/components/datasets/documents/status-item/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx": { "react/set-state-in-effect": { "count": 1 @@ -2565,11 +2465,6 @@ "count": 1 } }, - "web/app/components/datasets/metadata/metadata-document/info-group.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/metadata/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -2590,11 +2485,6 @@ "count": 1 } }, - "web/app/components/datasets/settings/summary-index-setting.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/develop/code.tsx": { "ts/no-empty-object-type": { "count": 1 @@ -2778,9 +2668,6 @@ } }, "web/app/components/header/account-setting/key-validator/declarations.ts": { - "erasable-syntax-only/enums": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -3336,11 +3223,6 @@ "count": 1 } }, - "web/app/components/rag-pipeline/components/panel/input-field/label-right-content/global-inputs.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -3927,11 +3809,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/field.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/input-support-select-var.tsx": { "ts/no-explicit-any": { "count": 1 @@ -3975,11 +3852,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/option-card.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/prompt/editor.tsx": { "ts/no-explicit-any": { "count": 4 @@ -4041,9 +3913,6 @@ } }, "web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 3 }, @@ -4534,14 +4403,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 3 - } - }, "web/app/components/workflow/nodes/llm/components/config-prompt.tsx": { "react/unsupported-syntax": { "count": 1 diff --git a/scripts/stress-test/common/config_helper.py b/scripts/stress-test/common/config_helper.py index d7ea8d0fea..fffb5e00d8 100644 --- a/scripts/stress-test/common/config_helper.py +++ b/scripts/stress-test/common/config_helper.py @@ -7,6 +7,7 @@ from typing import NotRequired, TypedDict class AdminConfig(TypedDict): """Configuration for admin section.""" + username: str password: str base_url: str @@ -14,6 +15,7 @@ class AdminConfig(TypedDict): class AuthConfig(TypedDict): """Configuration for authentication section.""" + access_token: str refresh_token: NotRequired[str] expires_at: NotRequired[int] @@ -21,6 +23,7 @@ class AuthConfig(TypedDict): class AppConfig(TypedDict): """Configuration for app section.""" + app_id: str app_name: NotRequired[str] description: NotRequired[str] @@ -28,6 +31,7 @@ class AppConfig(TypedDict): class ApiKeyConfig(TypedDict): """Configuration for API key section.""" + token: str key_name: NotRequired[str] expires_at: NotRequired[int] @@ -35,6 +39,7 @@ class ApiKeyConfig(TypedDict): class StressTestState(TypedDict): """Complete stress test state structure.""" + admin: NotRequired[AdminConfig] auth: NotRequired[AuthConfig] app: NotRequired[AppConfig] diff --git a/web/app/components/app-sidebar/__tests__/basic.spec.tsx b/web/app/components/app-sidebar/__tests__/basic.spec.tsx index 67e708eb02..1abb56d7c6 100644 --- a/web/app/components/app-sidebar/__tests__/basic.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/basic.spec.tsx @@ -7,12 +7,6 @@ vi.mock('@/app/components/base/icons/src/vender/workflow', () => ({ WindowCursor: (props: React.SVGProps) => , })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ popupContent }: { popupContent: React.ReactNode }) => ( -
{popupContent}
- ), -})) - vi.mock('../../base/app-icon', () => ({ default: ({ icon, background, innerIcon, className }: { icon?: string @@ -75,13 +69,12 @@ describe('AppBasic', () => { it('should show hover tip when provided', () => { render() - expect(screen.getByTestId('tooltip')).toBeInTheDocument() - expect(screen.getByText('Some tip')).toBeInTheDocument() + expect(screen.getByLabelText('Some tip')).toBeInTheDocument() }) it('should not show hover tip when not provided', () => { render() - expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Some tip')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/app-sidebar/basic.tsx b/web/app/components/app-sidebar/basic.tsx index 2814072860..3c7a7d2098 100644 --- a/web/app/components/app-sidebar/basic.tsx +++ b/web/app/components/app-sidebar/basic.tsx @@ -4,7 +4,7 @@ import { ApiAggregate, WindowCursor, } from '@/app/components/base/icons/src/vender/workflow' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import AppIcon from '../base/app-icon' type IAppBasicProps = { @@ -82,16 +82,9 @@ export default function AppBasic({ icon, icon_background, name, isExternal, type {hoverTip && ( - - {hoverTip} - - )} - popupClassName="ml-1" - triggerClassName="w-4 h-4 ml-1" - position="top" - /> + + {hoverTip} + )} {!hideType && isExtraInLine && ( diff --git a/web/app/components/app-sidebar/toggle-button.tsx b/web/app/components/app-sidebar/toggle-button.tsx index 6aca77fb4f..9dd5a58ef2 100644 --- a/web/app/components/app-sidebar/toggle-button.tsx +++ b/web/app/components/app-sidebar/toggle-button.tsx @@ -1,20 +1,20 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '../base/tooltip' import ShortcutsName from '../workflow/shortcuts-name' -type TooltipContentProps = { +type ToggleTooltipContentProps = { expand: boolean } const TOGGLE_SHORTCUT = ['ctrl', 'B'] -const TooltipContent = ({ +const ToggleTooltipContent = ({ expand, -}: TooltipContentProps) => { +}: ToggleTooltipContentProps) => { const { t } = useTranslation() return ( @@ -37,22 +37,21 @@ const ToggleButton = ({ className, }: ToggleButtonProps) => { return ( - } - popupClassName="p-1.5 rounded-lg" - position="right" - > - + {expand ? : } + + + + ) } diff --git a/web/app/components/app/configuration/config-vision/index.tsx b/web/app/components/app/configuration/config-vision/index.tsx index b9cb54cc34..81c0ac8450 100644 --- a/web/app/components/app/configuration/config-vision/index.tsx +++ b/web/app/components/app/configuration/config-vision/index.tsx @@ -11,7 +11,7 @@ import { useContext } from 'use-context-selector' // import { Resolution } from '@/types/app' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' import { Vision } from '@/app/components/base/icons/src/vender/features' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card' import { SupportUploadFileTypes } from '@/app/components/workflow/types' // import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card' @@ -70,13 +70,12 @@ const ConfigVision: FC = () => {
{t('vision.name', { ns: 'appDebug' })}
- - {t('vision.description', { ns: 'appDebug' })} -
- )} - /> + + {t('vision.description', { ns: 'appDebug' })} +
{readonly @@ -84,15 +83,14 @@ const ConfigVision: FC = () => { <>
{t('vision.visionSettings.resolution', { ns: 'appDebug' })}
- - {t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => ( -
{item}
- ))} -
- )} - /> + + {t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => ( +
{item}
+ ))} +
{
{t('vision.visionSettings.resolution', { ns: 'appDebug' })}
- - {t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => ( -
{item}
- ))} -
- )} - /> + + {t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => ( +
{item}
+ ))} +
{
{t('feature.audioUpload.title', { ns: 'appDebug' })}
- - {t('feature.audioUpload.description', { ns: 'appDebug' })} -
- )} - /> + + {t('feature.audioUpload.description', { ns: 'appDebug' })} +
{!readonly && (
diff --git a/web/app/components/app/configuration/config/config-document.tsx b/web/app/components/app/configuration/config/config-document.tsx index 156c605267..107e21b36e 100644 --- a/web/app/components/app/configuration/config/config-document.tsx +++ b/web/app/components/app/configuration/config/config-document.tsx @@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' import { Document } from '@/app/components/base/icons/src/vender/features' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import ConfigContext from '@/context/debug-configuration' @@ -57,13 +57,12 @@ const ConfigDocument: FC = () => {
{t('feature.documentUpload.title', { ns: 'appDebug' })}
- - {t('feature.documentUpload.description', { ns: 'appDebug' })} -
- )} - /> + + {t('feature.documentUpload.description', { ns: 'appDebug' })} + {!readonly && (
diff --git a/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx index 91fe47d83d..e8b1583171 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/__tests__/index.spec.tsx @@ -275,7 +275,7 @@ describe('ContextVar', () => { // Act render() - const varPickerTrigger = screen.getByTestId('popover-trigger') + const varPickerTrigger = screen.getAllByTestId('popover-trigger').at(-1)! await user.click(varPickerTrigger!) expect(screen.getByTestId('popover-content'))!.toBeInTheDocument() @@ -296,7 +296,7 @@ describe('ContextVar', () => { // Act render() - const varPickerTrigger = screen.getByTestId('popover-trigger') + const varPickerTrigger = screen.getAllByTestId('popover-trigger').at(-1)! // Open dropdown await user.click(varPickerTrigger!) diff --git a/web/app/components/app/configuration/dataset-config/context-var/index.tsx b/web/app/components/app/configuration/dataset-config/context-var/index.tsx index 634277c469..60d81548cf 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/index.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/index.tsx @@ -5,7 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { useTranslation } from 'react-i18next' import { BracketsX } from '@/app/components/base/icons/src/vender/line/development' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import VarPicker from './var-picker' const ContextVar: FC = (props) => { @@ -20,13 +20,12 @@ const ContextVar: FC = (props) => {
{t('feature.dataSet.queryVariable.title', { ns: 'appDebug' })}
- - {t('feature.dataSet.queryVariable.tip', { ns: 'appDebug' })} - - )} - /> + + {t('feature.dataSet.queryVariable.tip', { ns: 'appDebug' })} + diff --git a/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx b/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx index d0e6b2fe9f..9c50196dcf 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx @@ -15,9 +15,9 @@ import { toast } from '@langgenius/dify-ui/toast' import { memo, useCallback, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' +import { Infotip } from '@/app/components/base/infotip' import ScoreThresholdItem from '@/app/components/base/param-item/score-threshold-item' import TopKItem from '@/app/components/base/param-item/top-k-item' -import Tooltip from '@/app/components/base/tooltip' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' @@ -244,15 +244,14 @@ const ConfigContent: FC = ({ onClick={() => handleRerankModeChange(option.value)} >
{option.label}
- - {option.tips} - - )} - popupClassName="ml-0.5" - triggerClassName="ml-0.5 w-3.5 h-3.5" - /> + + {option.tips} + )) } @@ -273,15 +272,13 @@ const ConfigContent: FC = ({ ) }
{t('modelProvider.rerankModel.key', { ns: 'common' })}
- - {t('modelProvider.rerankModel.tip', { ns: 'common' })} - - )} - popupClassName="ml-1" - triggerClassName="ml-1 w-4 h-4" - /> + + {t('modelProvider.rerankModel.tip', { ns: 'common' })} + { showRerankModel && ( @@ -363,9 +360,9 @@ const ConfigContent: FC = ({
{t('modelProvider.systemReasoningModel.key', { ns: 'common' })}
- + + {t('modelProvider.systemReasoningModel.tip', { ns: 'common' })} +
{ it('should call setTheme with light when light option is clicked', () => { render() fireEvent.click(screen.getByRole('button')) - const lightButton = screen.getByText(/light/i).closest('button')! - fireEvent.click(lightButton) + fireEvent.click(screen.getByText(/light/i)) expect(mockSetTheme).toHaveBeenCalledWith('light') }) it('should call setTheme with dark when dark option is clicked', () => { render() fireEvent.click(screen.getByRole('button')) - const darkButton = screen.getByText(/dark/i).closest('button')! - fireEvent.click(darkButton) + fireEvent.click(screen.getByText(/dark/i)) expect(mockSetTheme).toHaveBeenCalledWith('dark') }) it('should call setTheme with system when system option is clicked', () => { render() fireEvent.click(screen.getByRole('button')) - const systemButton = screen.getByText(/auto/i).closest('button')! - fireEvent.click(systemButton) + fireEvent.click(screen.getByText(/auto/i)) expect(mockSetTheme).toHaveBeenCalledWith('system') }) }) diff --git a/web/app/components/base/checkbox/index.tsx b/web/app/components/base/checkbox/index.tsx index 1abf142a70..7457dc9016 100644 --- a/web/app/components/base/checkbox/index.tsx +++ b/web/app/components/base/checkbox/index.tsx @@ -8,6 +8,8 @@ type CheckboxProps = { className?: string disabled?: boolean indeterminate?: boolean + ariaLabel?: string + ariaLabelledBy?: string } const Checkbox = ({ @@ -17,6 +19,8 @@ const Checkbox = ({ className, disabled, indeterminate, + ariaLabel, + ariaLabelledBy, }: CheckboxProps) => { const checkClassName = (checked || indeterminate) ? 'bg-components-checkbox-bg text-components-checkbox-icon hover:bg-components-checkbox-bg-hover' @@ -52,6 +56,8 @@ const Checkbox = ({ role="checkbox" aria-checked={indeterminate ? 'mixed' : !!checked} aria-disabled={!!disabled} + aria-label={ariaLabel} + aria-labelledby={ariaLabelledBy} tabIndex={disabled ? -1 : 0} > {!checked && indeterminate && } diff --git a/web/app/components/base/param-item/__tests__/index.spec.tsx b/web/app/components/base/param-item/__tests__/index.spec.tsx index 889662c87d..01d28953fa 100644 --- a/web/app/components/base/param-item/__tests__/index.spec.tsx +++ b/web/app/components/base/param-item/__tests__/index.spec.tsx @@ -27,17 +27,15 @@ describe('ParamItem', () => { }) it('should render a tooltip trigger by default', () => { - const { container } = render() + render() - // Tooltip trigger icon should be rendered (the data-state div) - expect(container.querySelector('[data-state]')).toBeInTheDocument() + expect(screen.getByLabelText('Some tip text')).toBeInTheDocument() }) it('should not render tooltip trigger when noTooltip is true', () => { - const { container } = render() + render() - // No tooltip trigger icon should be rendered - expect(container.querySelector('[data-state]')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Hidden tip')).not.toBeInTheDocument() }) it('should render a switch when hasSwitch is true', () => { diff --git a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx b/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx index f32a707a52..0fbe727f42 100644 --- a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx +++ b/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx @@ -24,10 +24,9 @@ describe('ScoreThresholdItem', () => { }) it('should render tooltip trigger', () => { - const { container } = render() + render() - // Tooltip trigger icon should be rendered - expect(container.querySelector('[data-state]')).toBeInTheDocument() + expect(screen.getByLabelText('appDebug.datasetConfig.score_thresholdTip')).toBeInTheDocument() }) it('should render InputNumber and Slider', () => { diff --git a/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx b/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx index c84fd50518..c843883285 100644 --- a/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx +++ b/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx @@ -29,10 +29,9 @@ describe('TopKItem', () => { }) it('should render tooltip trigger', () => { - const { container } = render() + render() - // Tooltip trigger icon should be rendered - expect(container.querySelector('[data-state]')).toBeInTheDocument() + expect(screen.getByLabelText('appDebug.datasetConfig.top_kTip')).toBeInTheDocument() }) it('should render InputNumber and Slider', () => { diff --git a/web/app/components/base/param-item/index.tsx b/web/app/components/base/param-item/index.tsx index 93eb878551..a44176bf18 100644 --- a/web/app/components/base/param-item/index.tsx +++ b/web/app/components/base/param-item/index.tsx @@ -10,7 +10,7 @@ import { } from '@langgenius/dify-ui/number-field' import { Slider } from '@langgenius/dify-ui/slider' import { Switch } from '@langgenius/dify-ui/switch' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' type Props = { className?: string @@ -44,11 +44,10 @@ const ParamItem: FC = ({ className, id, name, noTooltip, tip, step = 0.1, /> )} {name} - {!noTooltip && ( - {tip}
} - /> + {!noTooltip && tip && ( + + {tip} + )} diff --git a/web/app/components/base/theme-selector.tsx b/web/app/components/base/theme-selector.tsx index 1f173aa885..1676dfff72 100644 --- a/web/app/components/base/theme-selector.tsx +++ b/web/app/components/base/theme-selector.tsx @@ -1,25 +1,25 @@ 'use client' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuRadioItemIndicator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' import { useTheme } from 'next-themes' -import { useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' export type Theme = 'light' | 'dark' | 'system' export default function ThemeSelector() { const { t } = useTranslation() const { theme, setTheme } = useTheme() - const [open, setOpen] = useState(false) const handleThemeChange = (newTheme: Theme) => { setTheme(newTheme) - setOpen(false) } const getCurrentIcon = () => { @@ -31,70 +31,36 @@ export default function ThemeSelector() { } return ( - - setOpen(!open)} + + + )} > - - {getCurrentIcon()} - - - -
- - - -
-
-
+ {t('theme.auto', { ns: 'common' })} + + + + + ) } diff --git a/web/app/components/billing/priority-label/__tests__/index.spec.tsx b/web/app/components/billing/priority-label/__tests__/index.spec.tsx index ef613d76b8..2b6201fdff 100644 --- a/web/app/components/billing/priority-label/__tests__/index.spec.tsx +++ b/web/app/components/billing/priority-label/__tests__/index.spec.tsx @@ -1,5 +1,6 @@ import type { Mock } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' +import { TooltipProvider } from '@langgenius/dify-ui/tooltip' +import { render, screen } from '@testing-library/react' import { createMockPlan } from '@/__mocks__/provider-context' import { useProviderContext } from '@/context/provider-context' import { Plan } from '../../type' @@ -15,6 +16,14 @@ const setupPlan = (planType: Plan) => { useProviderContextMock.mockReturnValue(createMockPlan(planType)) } +const renderPriorityLabel = (className?: string) => { + return render( + + + , + ) +} + describe('PriorityLabel', () => { beforeEach(() => { vi.clearAllMocks() @@ -24,7 +33,7 @@ describe('PriorityLabel', () => { it('should render the standard priority label when plan is sandbox', () => { setupPlan(Plan.sandbox) - render() + renderPriorityLabel() expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument() }) @@ -35,7 +44,7 @@ describe('PriorityLabel', () => { it('should apply custom className to the label container', () => { setupPlan(Plan.sandbox) - render() + renderPriorityLabel('custom-class') const label = screen.getByText('billing.plansCommon.priority.standard').closest('div') expect(label).toHaveClass('custom-class') @@ -47,7 +56,7 @@ describe('PriorityLabel', () => { it('should render priority label and icon when plan is professional', () => { setupPlan(Plan.professional) - const { container } = render() + const { container } = renderPriorityLabel() expect(screen.getByText('billing.plansCommon.priority.priority')).toBeInTheDocument() expect(container.querySelector('svg')).toBeInTheDocument() @@ -56,7 +65,7 @@ describe('PriorityLabel', () => { it('should render top priority label and icon when plan is team', () => { setupPlan(Plan.team) - const { container } = render() + const { container } = renderPriorityLabel() expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument() expect(container.querySelector('svg')).toBeInTheDocument() @@ -65,7 +74,7 @@ describe('PriorityLabel', () => { it('should render standard label without icon when plan is sandbox', () => { setupPlan(Plan.sandbox) - const { container } = render() + const { container } = renderPriorityLabel() expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument() expect(container.querySelector('svg')).not.toBeInTheDocument() @@ -77,7 +86,7 @@ describe('PriorityLabel', () => { it('should render top-priority label with icon for enterprise plan', () => { setupPlan(Plan.enterprise) - const { container } = render() + const { container } = renderPriorityLabel() expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument() expect(container.querySelector('svg')).toBeInTheDocument() @@ -85,29 +94,21 @@ describe('PriorityLabel', () => { }) describe('Edge Cases', () => { - it('should show the tip text when priority is not top priority', async () => { + it('should render a non-top priority trigger without mounting tooltip content by default', () => { setupPlan(Plan.sandbox) - render() - const label = screen.getByText('billing.plansCommon.priority.standard').closest('div') - fireEvent.mouseEnter(label as HTMLElement) + renderPriorityLabel() - expect(await screen.findByText( - 'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.standard', - )).toBeInTheDocument() - expect(screen.getByText('billing.plansCommon.documentProcessingPriorityTip')).toBeInTheDocument() + expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument() + expect(screen.queryByText('billing.plansCommon.documentProcessingPriority')).not.toBeInTheDocument() }) - it('should hide the tip text when priority is top priority', async () => { + it('should render a top priority trigger without mounting upgrade tip by default', () => { setupPlan(Plan.enterprise) - render() - const label = screen.getByText('billing.plansCommon.priority.top-priority').closest('div') - fireEvent.mouseEnter(label as HTMLElement) + renderPriorityLabel() - expect(await screen.findByText( - 'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.top-priority', - )).toBeInTheDocument() + expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument() expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityTip')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/billing/priority-label/index.tsx b/web/app/components/billing/priority-label/index.tsx index 8940b38382..c7b7d17e87 100644 --- a/web/app/components/billing/priority-label/index.tsx +++ b/web/app/components/billing/priority-label/index.tsx @@ -1,8 +1,8 @@ import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiAedFill } from '@remixicon/react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' import { useProviderContext } from '@/context/provider-context' import { DocumentProcessingPriority, @@ -31,8 +31,25 @@ const PriorityLabel = ({ className }: PriorityLabelProps) => { }, [plan]) return ( - + + + )} + > + { + (plan.type === Plan.professional || plan.type === Plan.team || plan.type === Plan.enterprise) && ( + + ) + } + {t(`plansCommon.priority.${priority}`, { ns: 'billing' })} + +
{t('plansCommon.documentProcessingPriority', { ns: 'billing' })} : @@ -44,22 +61,7 @@ const PriorityLabel = ({ className }: PriorityLabelProps) => {
{t('plansCommon.documentProcessingPriorityTip', { ns: 'billing' })}
) } -
- )} - > -
- { - (plan.type === Plan.professional || plan.type === Plan.team || plan.type === Plan.enterprise) && ( - - ) - } - {t(`plansCommon.priority.${priority}`, { ns: 'billing' })} -
+
) } diff --git a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.tsx b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.tsx index e5b7e79d55..5d43f0b687 100644 --- a/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.tsx +++ b/web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.tsx @@ -1,7 +1,7 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiImageAddLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' import { ACCEPT_TYPES } from '../constants' import { useUpload } from '../hooks/use-upload' import { useFileStoreWithSelector } from '../store' @@ -29,20 +29,14 @@ const ImageUploader = () => { onChange={fileChangeHandle} />
- -
+ + )} >
@@ -56,7 +50,14 @@ const ImageUploader = () => { })} )} -
+
+ + {t('imageUploader.tooltip', { + ns: 'datasetHitTesting', + size: fileUploadConfig.imageFileSizeLimit, + batchCount: fileUploadConfig.imageFileBatchLimit, + })} +
diff --git a/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx b/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx index 1b208c549d..ac5112ad22 100644 --- a/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/retrieval-param-config/__tests__/index.spec.tsx @@ -134,12 +134,6 @@ vi.mock('@langgenius/dify-ui/switch', () => ({ ), })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ popupContent }: { popupContent: React.ReactNode }) => ( -
{popupContent}
- ), -})) - describe('RetrievalParamConfig', () => { const createDefaultConfig = (overrides?: Partial): RetrievalConfig => ({ search_method: RETRIEVE_METHOD.semantic, @@ -799,7 +793,7 @@ describe('RetrievalParamConfig', () => { />, ) - expect(screen.getByTestId('tooltip'))!.toBeInTheDocument() + expect(screen.getByLabelText('common.modelProvider.rerankModel.tip'))!.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/common/retrieval-param-config/index.tsx b/web/app/components/datasets/common/retrieval-param-config/index.tsx index 8514e1fae2..93392f2821 100644 --- a/web/app/components/datasets/common/retrieval-param-config/index.tsx +++ b/web/app/components/datasets/common/retrieval-param-config/index.tsx @@ -10,10 +10,10 @@ import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import WeightedScore from '@/app/components/app/configuration/dataset-config/params-config/weighted-score' import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' +import { Infotip } from '@/app/components/base/infotip' import ScoreThresholdItem from '@/app/components/base/param-item/score-threshold-item' import TopKItem from '@/app/components/base/param-item/top-k-item' import RadioCard from '@/app/components/base/radio-card' -import Tooltip from '@/app/components/base/tooltip' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useCurrentProviderAndModel, useModelListAndDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' @@ -127,11 +127,12 @@ const RetrievalParamConfig: FC = ({ )}
{t('modelProvider.rerankModel.key', { ns: 'common' })} - {t('modelProvider.rerankModel.tip', { ns: 'common' })}
- } - /> + + {t('modelProvider.rerankModel.tip', { ns: 'common' })} + { diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx index 22866b0b9e..d3538d3349 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/details/index.tsx @@ -5,8 +5,8 @@ import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' +import { Infotip } from '@/app/components/base/infotip' import Loading from '@/app/components/base/loading' -import Tooltip from '@/app/components/base/tooltip' import WorkflowPreview from '@/app/components/workflow/workflow-preview' import { usePipelineTemplateById } from '@/service/use-pipeline' import ChunkStructureCard from './chunk-structure-card' @@ -111,10 +111,12 @@ const Details = ({ {t('details.structure', { ns: 'datasetPipeline' })} - + > + {t('details.structureTooltip', { ns: 'datasetPipeline' })} + diff --git a/web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx b/web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx index e0ae25a80f..f71c4a0cc1 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx +++ b/web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx @@ -1,4 +1,3 @@ -import type { ReactNode } from 'react' import type { IndexingStatusResponse } from '@/models/datasets' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -14,12 +13,6 @@ vi.mock('../../../common/document-file-icon', () => ({ vi.mock('@/app/components/base/notion-icon', () => ({ default: ({ src }: { src?: string }) => {src}, })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children, popupContent }: { children?: ReactNode, popupContent?: ReactNode }) => ( -
{children}
- ), -})) - describe('IndexingProgressItem', () => { beforeEach(() => { vi.clearAllMocks() @@ -100,7 +93,7 @@ describe('IndexingProgressItem', () => { />, ) - expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'Parse failed') + expect(screen.getByLabelText('Parse failed')).toBeInTheDocument() }) it('should show priority label when billing is enabled', () => { diff --git a/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx b/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx index ef54bddfc5..b98d9e7e10 100644 --- a/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx +++ b/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx @@ -1,12 +1,12 @@ import type { FC } from 'react' import type { IndexingStatusResponse } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiCheckboxCircleFill, RiErrorWarningFill, } from '@remixicon/react' import NotionIcon from '@/app/components/base/notion-icon' -import Tooltip from '@/app/components/base/tooltip' import PriorityLabel from '@/app/components/billing/priority-label' import { DataSourceType } from '@/models/datasets' import DocumentFileIcon from '../../common/document-file-icon' @@ -27,14 +27,16 @@ const StatusIcon: FC<{ status: string, error?: string }> = ({ status, error }) = if (status === 'error') { return ( - - + + }> - + + + {error} + ) } diff --git a/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx index 64011c1ebe..3931478f27 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx @@ -165,6 +165,13 @@ describe('IndexingModeSection', () => { const economicalText = screen.getByText(`${ns}.stepTwo.economical`) const card = economicalText.closest('[class*="rounded-xl"]') expect(card)!.toHaveClass('pointer-events-none') + expect(screen.getByText(`${ns}.stepTwo.notAvailableForQA`))!.toBeInTheDocument() + }) + + it('should show parent-child disabled reason inline on economical option', () => { + render() + + expect(screen.getByText(`${ns}.stepTwo.notAvailableForParentChild`))!.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx index f1ab5392ce..0e6cc10076 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx @@ -30,9 +30,7 @@ describe('DelimiterInput', () => { it('should render tooltip content', () => { render() - // Tooltip triggers render; component mounts without error - // Tooltip triggers render; component mounts without error - expect(screen.getByText(`${ns}.stepTwo.separator`))!.toBeInTheDocument() + expect(screen.getByLabelText(`${ns}.stepTwo.separatorTip`))!.toBeInTheDocument() }) it('should suppress onChange during IME composition', () => { @@ -103,6 +101,7 @@ describe('OverlapInput', () => { it('should render overlap label', () => { render() expect(screen.getAllByText(new RegExp(`${ns}.stepTwo.overlap`)).length).toBeGreaterThan(0) + expect(screen.getByLabelText(`${ns}.stepTwo.overlapTip`))!.toBeInTheDocument() }) it('should render number input', () => { diff --git a/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx b/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx index 1639df2320..2ddcc81d5e 100644 --- a/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx +++ b/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx @@ -3,14 +3,20 @@ import type { FC } from 'react' import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { RetrievalConfig } from '@/types/app' -import { Button } from '@langgenius/dify-ui/button' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' import { cn } from '@langgenius/dify-ui/cn' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' -import CustomDialog from '@/app/components/base/dialog' import Divider from '@/app/components/base/divider' import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' -import Tooltip from '@/app/components/base/tooltip' import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config' import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' @@ -65,6 +71,13 @@ export const IndexingModeSection: FC = ({ const docLink = useDocLink() const getIndexingTechnique = () => indexType + const economicalDisabledReason = (() => { + if (docForm === ChunkingMode.qa) + return t('stepTwo.notAvailableForQA', { ns: 'datasetCreation' }) + + if (docForm !== ChunkingMode.text) + return t('stepTwo.notAvailableForParentChild', { ns: 'datasetCreation' }) + })() return ( <> @@ -72,7 +85,33 @@ export const IndexingModeSection: FC = ({
{t('stepTwo.indexMode', { ns: 'datasetCreation' })}
-
+ { + if (!open) + onQAConfirmDialogClose() + }} + > + +
+ + {t('stepTwo.qaSwitchHighQualityTipTitle', { ns: 'datasetCreation' })} + + + {t('stepTwo.qaSwitchHighQualityTipContent', { ns: 'datasetCreation' })} + +
+ + + {t('stepTwo.cancel', { ns: 'datasetCreation' })} + + + {t('stepTwo.switch', { ns: 'datasetCreation' })} + + +
+
+
{/* Qualified option */} {(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.QUALIFIED)) && ( = ({ {/* Economical option */} {(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.ECONOMICAL)) && ( - <> - -
-

- {t('stepTwo.qaSwitchHighQualityTipTitle', { ns: 'datasetCreation' })} -

-

- {t('stepTwo.qaSwitchHighQualityTipContent', { ns: 'datasetCreation' })} -

-
-
- - -
-
- - {docForm === ChunkingMode.qa - ? t('stepTwo.notAvailableForQA', { ns: 'datasetCreation' }) - : t('stepTwo.notAvailableForParentChild', { ns: 'datasetCreation' })} -
- )} - noDecoration - position="top" - asChild={false} - triggerClassName="flex-1 self-stretch" - > - } - isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL} - disabled={hasSetIndexType || docForm !== ChunkingMode.text} - onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)} - /> - - + } + isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL} + disabled={hasSetIndexType || !!economicalDisabledReason} + onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)} + /> )}
diff --git a/web/app/components/datasets/create/step-two/components/inputs.tsx b/web/app/components/datasets/create/step-two/components/inputs.tsx index 12af1de1aa..635056f1b2 100644 --- a/web/app/components/datasets/create/step-two/components/inputs.tsx +++ b/web/app/components/datasets/create/step-two/components/inputs.tsx @@ -12,8 +12,8 @@ import { } from '@langgenius/dify-ui/number-field' import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { Infotip } from '@/app/components/base/infotip' import Input from '@/app/components/base/input' -import Tooltip from '@/app/components/base/tooltip' import { env } from '@/env' const TextLabel: FC = (props) => { @@ -38,13 +38,9 @@ export const DelimiterInput: FC = ({ tooltip, {t('stepTwo.separator', { ns: 'datasetCreation' })} - - {tooltip || t('stepTwo.separatorTip', { ns: 'datasetCreation' })} - - )} - /> + + {tooltip || t('stepTwo.separatorTip', { ns: 'datasetCreation' })} + )} > @@ -154,13 +150,9 @@ export const OverlapInput: FC = (props) => { {t('stepTwo.overlap', { ns: 'datasetCreation' })} - - {t('stepTwo.overlapTip', { ns: 'datasetCreation' })} - - )} - /> + + {t('stepTwo.overlapTip', { ns: 'datasetCreation' })} + )} > diff --git a/web/app/components/datasets/create/step-two/components/option-card.tsx b/web/app/components/datasets/create/step-two/components/option-card.tsx index 5334feaac8..43f0924d92 100644 --- a/web/app/components/datasets/create/step-two/components/option-card.tsx +++ b/web/app/components/datasets/create/step-two/components/option-card.tsx @@ -20,7 +20,7 @@ type OptionCardHeaderProps = { export const OptionCardHeader: FC = (props) => { const { icon, title, description, isActive, activeClassName, effectImg, disabled } = props return ( -
+
{isActive && effectImg && }
@@ -63,7 +63,7 @@ export const OptionCard: FC = ( const { icon, className, title, description, isActive, children, actions, activeHeaderClassName, style, effectImg, onSwitched, noHighlight, disabled, ...rest } = props return (
({ - default: ({ popupContent }: { popupContent?: React.ReactNode }) =>
{popupContent}
, -})) - describe('CheckboxWithLabel', () => { const onChange = vi.fn() @@ -27,12 +23,12 @@ describe('CheckboxWithLabel', () => { tooltip="Help text" />, ) - expect(screen.getByTestId('tooltip')).toBeInTheDocument() + expect(screen.getByLabelText('Help text')).toBeInTheDocument() }) it('should not render tooltip when not provided', () => { render() - expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Help text')).not.toBeInTheDocument() }) it('should toggle checked state on checkbox click', () => { diff --git a/web/app/components/datasets/create/website/base/__tests__/field.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/field.spec.tsx index 8a2e147d60..a362562437 100644 --- a/web/app/components/datasets/create/website/base/__tests__/field.spec.tsx +++ b/web/app/components/datasets/create/website/base/__tests__/field.spec.tsx @@ -2,10 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import Field from '../field' -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ popupContent }: { popupContent?: React.ReactNode }) =>
{popupContent}
, -})) - describe('WebsiteField', () => { const onChange = vi.fn() @@ -30,7 +26,7 @@ describe('WebsiteField', () => { it('should render tooltip when provided', () => { render() - expect(screen.getByTestId('tooltip')).toBeInTheDocument() + expect(screen.getByLabelText('Enter full URL')).toBeInTheDocument() }) it('should pass value and onChange to Input', () => { diff --git a/web/app/components/datasets/create/website/base/checkbox-with-label.tsx b/web/app/components/datasets/create/website/base/checkbox-with-label.tsx index 686976826d..117a16f401 100644 --- a/web/app/components/datasets/create/website/base/checkbox-with-label.tsx +++ b/web/app/components/datasets/create/website/base/checkbox-with-label.tsx @@ -2,8 +2,9 @@ import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' +import { useId } from 'react' import Checkbox from '@/app/components/base/checkbox' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' type Props = { className?: string @@ -24,19 +25,28 @@ const CheckboxWithLabel: FC = ({ tooltip, testId, }) => { + const labelId = useId() + const handleToggle = () => onChange(!isChecked) + return ( -
- } - triggerClassName="ml-0.5 w-4 h-4" - /> - )} - +
+ +
+ + {tooltip && ( + + {tooltip} + + )} +
+
) } export default React.memo(CheckboxWithLabel) diff --git a/web/app/components/datasets/create/website/base/field.tsx b/web/app/components/datasets/create/website/base/field.tsx index 860f934da2..423fa54823 100644 --- a/web/app/components/datasets/create/website/base/field.tsx +++ b/web/app/components/datasets/create/website/base/field.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import Input from './input' type Props = { @@ -37,12 +37,9 @@ const Field: FC = ({
{isRequired && *} {tooltip && ( - {tooltip}
- } - triggerClassName="ml-0.5 w-4 h-4" - /> + + {tooltip} + )}
({ ), })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ popupContent }: { popupContent: string }) =>
{popupContent}
, -})) - describe('CheckboxWithLabel', () => { const defaultProps = { isChecked: false, @@ -35,12 +31,12 @@ describe('CheckboxWithLabel', () => { it('should render tooltip when provided', () => { render() - expect(screen.getByTestId('tooltip')).toBeInTheDocument() + expect(screen.getByLabelText('Help text')).toBeInTheDocument() }) it('should not render tooltip when not provided', () => { render() - expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Help text')).not.toBeInTheDocument() }) it('should apply custom className', () => { diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx index fa5633e2df..db3e369722 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx @@ -62,16 +62,13 @@ describe('CheckboxWithLabel', () => { it('should render tooltip when provided', () => { render() - // Assert - Tooltip trigger should be present - const tooltipTrigger = document.querySelector('[class*="ml-0.5"]') - expect(tooltipTrigger)!.toBeInTheDocument() + expect(screen.getByLabelText('Helpful tooltip text'))!.toBeInTheDocument() }) it('should not render tooltip when not provided', () => { render() - const tooltipTrigger = document.querySelector('[class*="ml-0.5"]') - expect(tooltipTrigger).not.toBeInTheDocument() + expect(screen.queryByLabelText('Helpful tooltip text')).not.toBeInTheDocument() }) }) @@ -81,8 +78,7 @@ describe('CheckboxWithLabel', () => { , ) - const label = container.querySelector('label') - expect(label)!.toHaveClass('custom-class') + expect(container.firstChild)!.toHaveClass('custom-class') }) it('should apply custom labelClassName', () => { @@ -114,16 +110,14 @@ describe('CheckboxWithLabel', () => { expect(mockOnChange).toHaveBeenCalledWith(false) }) - it('should not trigger onChange when clicking label text due to custom checkbox', () => { + it('should trigger onChange when clicking label text', () => { const mockOnChange = vi.fn() render() - // Act - Click on the label text element const labelText = screen.getByText('Test Label') fireEvent.click(labelText) - // Assert - Custom checkbox does not support native label-input click forwarding - expect(mockOnChange).not.toHaveBeenCalled() + expect(mockOnChange).toHaveBeenCalledWith(true) }) }) }) @@ -386,15 +380,14 @@ describe('CrawledResult', () => { it('should pass showPreview to items', () => { render() - // Assert - Preview buttons should be visible - const buttons = screen.getAllByRole('button') + const buttons = screen.getAllByRole('button', { name: 'datasetCreation.stepOne.website.preview' }) expect(buttons.length).toBe(3) }) it('should not show preview buttons when showPreview is false', () => { render() - expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'datasetCreation.stepOne.website.preview' })).not.toBeInTheDocument() }) }) @@ -507,7 +500,7 @@ describe('CrawledResult', () => { />, ) - const buttons = screen.getAllByRole('button') + const buttons = screen.getAllByRole('button', { name: 'datasetCreation.stepOne.website.preview' }) fireEvent.click(buttons[1]!) // Second item's preview button expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1) @@ -796,7 +789,7 @@ describe('Base Components Integration', () => { expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0]]) // Act - Preview second item - const previewButtons = screen.getAllByRole('button') + const previewButtons = screen.getAllByRole('button', { name: 'datasetCreation.stepOne.website.preview' }) fireEvent.click(previewButtons[1]!) expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/checkbox-with-label.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/checkbox-with-label.tsx index 23dbea51a9..99c6625793 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/checkbox-with-label.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/checkbox-with-label.tsx @@ -1,8 +1,9 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' +import { useId } from 'react' import Checkbox from '@/app/components/base/checkbox' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' type CheckboxWithLabelProps = { className?: string @@ -21,19 +22,28 @@ const CheckboxWithLabel = ({ labelClassName, tooltip, }: CheckboxWithLabelProps) => { + const labelId = useId() + const handleToggle = () => onChange(!isChecked) + return ( -
- } - triggerClassName="ml-0.5 w-4 h-4" - /> - )} - +
+ +
+ + {tooltip && ( + + {tooltip} + + )} +
+
) } export default React.memo(CheckboxWithLabel) diff --git a/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx b/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx index b69835d7f2..1e651929fe 100644 --- a/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx @@ -140,12 +140,12 @@ describe('StatusItem', () => { describe('error message tooltip', () => { it('should show tooltip trigger when error message is provided', () => { render() - expect(screen.getByTestId('error-tooltip-trigger')).toBeInTheDocument() + expect(screen.getByLabelText('Test error message')).toBeInTheDocument() }) it('should not show tooltip trigger when no error message', () => { render() - expect(screen.queryByTestId('error-tooltip-trigger')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Test error message')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/status-item/index.tsx b/web/app/components/datasets/documents/status-item/index.tsx index 9970c6bca8..911b1df55e 100644 --- a/web/app/components/datasets/documents/status-item/index.tsx +++ b/web/app/components/datasets/documents/status-item/index.tsx @@ -5,11 +5,12 @@ import type { DocumentDisplayStatus } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' import { Switch } from '@langgenius/dify-ui/switch' import { toast } from '@langgenius/dify-ui/toast' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useDebounceFn } from 'ahooks' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import Indicator from '@/app/components/header/indicator' import { useDocumentDelete, useDocumentDisable, useDocumentEnable } from '@/service/knowledge/use-document' import { asyncRunSafe } from '@/utils' @@ -81,11 +82,33 @@ const StatusItem = ({ status, reverse = false, scene = 'list', textCls = '', err {DOC_INDEX_STATUS_MAP[localStatus]?.text} - {errorMessage && ({errorMessage}} triggerClassName="ml-1 w-4 h-4" triggerTestId="error-tooltip-trigger" />)} + {errorMessage && ( + + {errorMessage} + + )} {scene === 'detail' && (
- - !archived && handleSwitch(v ? 'enable' : 'disable')} disabled={embedding || archived} size="md" /> + + + !archived && handleSwitch(v ? 'enable' : 'disable')} + disabled={embedding || archived} + size="md" + /> + + )} + /> + + {t('list.action.enableWarning', { ns: 'datasetDocuments' })} +
)} diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx index c176979a5a..c4dc89cafc 100644 --- a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx @@ -124,9 +124,7 @@ describe('InfoGroup', () => { titleTooltip="This is a tooltip" />, ) - // Tooltip icon should be present - const tooltipIcon = screen.getByText('Test').closest('.flex')?.querySelector('svg') - expect(tooltipIcon)!.toBeInTheDocument() + expect(screen.getByLabelText('This is a tooltip'))!.toBeInTheDocument() }) it('should render headerRight content', () => { diff --git a/web/app/components/datasets/metadata/metadata-document/info-group.tsx b/web/app/components/datasets/metadata/metadata-document/info-group.tsx index 6447185758..16f5e573fb 100644 --- a/web/app/components/datasets/metadata/metadata-document/info-group.tsx +++ b/web/app/components/datasets/metadata/metadata-document/info-group.tsx @@ -2,11 +2,11 @@ import type { FC } from 'react' import type { MetadataItemWithValue } from '../types' import { cn } from '@langgenius/dify-ui/cn' -import { RiDeleteBinLine, RiQuestionLine } from '@remixicon/react' +import { RiDeleteBinLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import useTimestamp from '@/hooks/use-timestamp' import { useRouter } from '@/next/navigation' import AddMetadataButton from '../add-metadata-button' @@ -64,9 +64,9 @@ const InfoGroup: FC = ({
{title}
{titleTooltip && ( - {titleTooltip}
}> -
-
+ + {titleTooltip} + )} {headerRight} diff --git a/web/app/components/datasets/settings/summary-index-setting.tsx b/web/app/components/datasets/settings/summary-index-setting.tsx index 3bd47bf4b9..0944afc2e1 100644 --- a/web/app/components/datasets/settings/summary-index-setting.tsx +++ b/web/app/components/datasets/settings/summary-index-setting.tsx @@ -8,8 +8,8 @@ import { useMemo, } from 'react' import { useTranslation } from 'react-i18next' +import { Infotip } from '@/app/components/base/infotip' import Textarea from '@/app/components/base/textarea' -import Tooltip from '@/app/components/base/tooltip' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' @@ -65,11 +65,12 @@ const SummaryIndexSetting = ({
{t('form.summaryAutoGen', { ns: 'datasetSettings' })} - - + {t('form.summaryAutoGenTip', { ns: 'datasetSettings' })} +
({ - default: ({ - popupContent, - }: { - popupContent: React.ReactNode - }) =>
{popupContent}
, -})) - describe('GlobalInputs', () => { beforeEach(() => { vi.clearAllMocks() @@ -18,6 +10,6 @@ describe('GlobalInputs', () => { render() expect(screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')).toBeInTheDocument() - expect(screen.getByTestId('tooltip')).toHaveTextContent('datasetPipeline.inputFieldPanel.globalInputs.tooltip') + expect(screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip')).toBeInTheDocument() }) }) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/index.spec.tsx index ba9390a028..ab01bdba3b 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/index.spec.tsx @@ -20,12 +20,6 @@ vi.mock('@/app/components/workflow/hooks', () => ({ useToolIcon: (nodeData: DataSourceNodeType) => nodeData.provider_name || 'default-icon', })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ popupContent, popupClassName }: { popupContent: string, popupClassName?: string }) => ( -
- ), -})) - afterEach(() => { cleanup() vi.clearAllMocks() @@ -161,21 +155,19 @@ describe('GlobalInputs', () => { it('should render tooltip component', () => { render() - expect(screen.getByTestId('tooltip')).toBeInTheDocument() + expect(screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip')).toBeInTheDocument() }) it('should pass correct tooltip content', () => { render() - const tooltip = screen.getByTestId('tooltip') - expect(tooltip).toHaveAttribute('data-content', 'datasetPipeline.inputFieldPanel.globalInputs.tooltip') + expect(screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip')).toBeInTheDocument() }) - it('should have correct tooltip className', () => { + it('should render the tooltip trigger as an icon-sized button', () => { render() - const tooltip = screen.getByTestId('tooltip') - expect(tooltip).toHaveClass('w-[240px]') + expect(screen.getByLabelText('datasetPipeline.inputFieldPanel.globalInputs.tooltip')).toHaveClass('h-4', 'w-4') }) it('should have correct container layout', () => { diff --git a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/global-inputs.tsx b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/global-inputs.tsx index 763d1ce631..23d56722a6 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/global-inputs.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/global-inputs.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' const GlobalInputs = () => { const { t } = useTranslation() @@ -10,10 +10,9 @@ const GlobalInputs = () => { {t('inputFieldPanel.globalInputs.title', { ns: 'datasetPipeline' })} - + + {t('inputFieldPanel.globalInputs.tooltip', { ns: 'datasetPipeline' })} +
) } diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx index cc2f96aa6e..afd7c04ed1 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx @@ -330,27 +330,6 @@ describe('publisher', () => { }) expect(mockSetShowPricingModal).toHaveBeenCalled() }) - - it('should keep confirm dialog mounted when first publish opens follow-up overlay', async () => { - mockPublishedAt.mockReturnValue(null) - renderWithQueryClient() - - fireEvent.click(screen.getByText('workflow.common.publish')) - - await waitFor(() => { - expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByRole('button', { name: /workflow.common.publishUpdate/i })) - - await waitFor(() => { - expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument() - }) - - fireEvent.mouseDown(document.body) - - expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument() - }) }) }) diff --git a/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx index 7418b7f313..a34e655652 100644 --- a/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx @@ -1,15 +1,24 @@ import type { Node } from '../types' import { fireEvent, render, screen } from '@testing-library/react' -import NodeContextmenu from '../node-contextmenu' +import { NodeContextmenu } from '../node-contextmenu' -const mockUseClickAway = vi.hoisted(() => vi.fn()) const mockUseNodes = vi.hoisted(() => vi.fn()) const mockUsePanelInteractions = vi.hoisted(() => vi.fn()) const mockUseStore = vi.hoisted(() => vi.fn()) -const mockPanelOperatorPopup = vi.hoisted(() => vi.fn()) +const mockNodeActionsContextMenuContent = vi.hoisted(() => vi.fn()) +const mockContextMenuContent = vi.hoisted(() => vi.fn()) -vi.mock('ahooks', () => ({ - useClickAway: (...args: unknown[]) => mockUseClickAway(...args), +vi.mock('@langgenius/dify-ui/context-menu', () => ({ + ContextMenu: ({ children, onOpenChange }: { children: React.ReactNode, onOpenChange: (open: boolean) => void }) => ( +
+ {children} + +
+ ), + ContextMenuContent: ({ children, positionerProps, popupClassName }: { children: React.ReactNode, positionerProps?: { anchor?: unknown }, popupClassName?: string }) => { + mockContextMenuContent({ positionerProps, popupClassName }) + return
{children}
+ }, })) vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ @@ -22,20 +31,19 @@ vi.mock('@/app/components/workflow/hooks', () => ({ })) vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { nodeMenu?: { nodeId: string, left: number, top: number } }) => unknown) => mockUseStore(selector), + useStore: (selector: (state: { nodeMenu?: { nodeId: string, clientX: number, clientY: number } }) => unknown) => mockUseStore(selector), })) -vi.mock('@/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup', () => ({ - __esModule: true, - default: (props: { +vi.mock('@/app/components/workflow/node-actions-menu/context-menu-content', () => ({ + NodeActionsContextMenuContent: (props: { id: string data: Node['data'] showHelpLink: boolean - onClosePopup: () => void + onClose: () => void }) => { - mockPanelOperatorPopup(props) + mockNodeActionsContextMenuContent(props) return ( - +
+ ), +})) + +vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useAvailableBlocks: vi.fn(), + useIsChatMode: vi.fn(), + useNodeDataUpdate: vi.fn(), + useNodeMetaData: vi.fn(), + useNodesInteractions: vi.fn(), + useNodesReadOnly: vi.fn(), + useNodesSyncDraft: vi.fn(), + } +}) + +vi.mock('@/app/components/workflow/hooks-store', () => ({ + useHooksStore: vi.fn(), +})) + +vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ + default: vi.fn(), +})) + +vi.mock('@/service/use-tools', () => ({ + useAllWorkflowTools: vi.fn(), +})) + +const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks) +const mockUseIsChatMode = vi.mocked(useIsChatMode) +const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate) +const mockUseNodeMetaData = vi.mocked(useNodeMetaData) +const mockUseNodesInteractions = vi.mocked(useNodesInteractions) +const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly) +const mockUseNodesSyncDraft = vi.mocked(useNodesSyncDraft) +const mockUseHooksStore = vi.mocked(useHooksStore) +const mockUseNodes = vi.mocked(useNodes) +const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools) + +function renderDropdownContent({ + showHelpLink = true, + onClose = vi.fn(), +}: { + showHelpLink?: boolean + onClose?: () => void +} = {}) { + return renderWorkflowFlowComponent( + + open} /> + + + + , + { + nodes: [], + edges: [{ id: 'edge-1', source: 'node-0', target: 'node-1', sourceHandle: 'branch-a' }], + }, + ) +} + +describe('node actions menu details', () => { + const handleNodeChange = vi.fn() + const handleNodeDelete = vi.fn() + const handleNodesDuplicate = vi.fn() + const handleNodeSelect = vi.fn() + const handleNodesCopy = vi.fn() + const handleNodeDataUpdate = vi.fn() + const handleSyncWorkflowDraft = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockUseAvailableBlocks.mockReturnValue({ + getAvailableBlocks: vi.fn(() => ({ + availablePrevBlocks: [BlockEnum.HttpRequest], + availableNextBlocks: [BlockEnum.HttpRequest], + })), + availablePrevBlocks: [BlockEnum.HttpRequest], + availableNextBlocks: [BlockEnum.HttpRequest], + } as ReturnType) + mockUseIsChatMode.mockReturnValue(false) + mockUseNodeDataUpdate.mockReturnValue({ + handleNodeDataUpdate, + handleNodeDataUpdateWithSyncDraft: vi.fn(), + }) + mockUseNodeMetaData.mockReturnValue({ + isTypeFixed: false, + isSingleton: false, + isUndeletable: false, + description: 'Node description', + author: 'Dify', + helpLinkUri: 'https://docs.example.com/node', + } as ReturnType) + mockUseNodesInteractions.mockReturnValue({ + handleNodeChange, + handleNodeDelete, + handleNodesDuplicate, + handleNodeSelect, + handleNodesCopy, + } as unknown as ReturnType) + mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType) + mockUseNodesSyncDraft.mockReturnValue({ + doSyncWorkflowDraft: vi.fn(), + handleSyncWorkflowDraft, + syncWorkflowDraftWhenPageClose: vi.fn(), + } as ReturnType) + mockUseHooksStore.mockImplementation((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } })) + mockUseNodes.mockReturnValue([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any) + mockUseAllWorkflowTools.mockReturnValue({ data: [] } as any) + }) + + it('should select a replacement block through ChangeBlockMenuTrigger', async () => { + const user = userEvent.setup() + render( + , + ) + + await user.click(screen.getByText('select-http')) + + expect(screen.getByText('available:http-request')).toBeInTheDocument() + expect(screen.getByText('show-start:true')).toBeInTheDocument() + expect(screen.getByText('ignore:')).toBeInTheDocument() + expect(screen.getByText('force-start:false')).toBeInTheDocument() + expect(screen.getByText('allow-start:false')).toBeInTheDocument() + expect(handleNodeChange).toHaveBeenCalledWith('node-1', BlockEnum.HttpRequest, 'source', undefined) + }) + + it('should expose trigger and start-node specific block selector options', () => { + mockUseAvailableBlocks.mockReturnValueOnce({ + getAvailableBlocks: vi.fn(() => ({ + availablePrevBlocks: [], + availableNextBlocks: [BlockEnum.HttpRequest], + })), + availablePrevBlocks: [], + availableNextBlocks: [BlockEnum.HttpRequest], + } as ReturnType) + mockUseIsChatMode.mockReturnValueOnce(true) + mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } })) + mockUseNodes.mockReturnValueOnce([] as any) + + const { rerender } = render( + , + ) + + expect(screen.getByText('available:http-request')).toBeInTheDocument() + expect(screen.getByText('show-start:true')).toBeInTheDocument() + expect(screen.getByText('ignore:trigger-node')).toBeInTheDocument() + expect(screen.getByText('allow-start:true')).toBeInTheDocument() + + mockUseAvailableBlocks.mockReturnValueOnce({ + getAvailableBlocks: vi.fn(() => ({ + availablePrevBlocks: [BlockEnum.Code], + availableNextBlocks: [], + })), + availablePrevBlocks: [BlockEnum.Code], + availableNextBlocks: [], + } as ReturnType) + mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.ragPipeline } })) + mockUseNodes.mockReturnValueOnce([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any) + + rerender( + , + ) + + expect(screen.getByText('available:code')).toBeInTheDocument() + expect(screen.getByText('show-start:false')).toBeInTheDocument() + expect(screen.getByText('ignore:start-node')).toBeInTheDocument() + expect(screen.getByText('force-start:true')).toBeInTheDocument() + }) + + it('should run, copy, duplicate, delete, and expose the help link', async () => { + const user = userEvent.setup() + renderDropdownContent() + + await user.click(screen.getByText('workflow.panel.runThisStep')) + await user.click(screen.getByText('workflow.common.copy')) + await user.click(screen.getByText('workflow.common.duplicate')) + await user.click(screen.getByText('common.operation.delete')) + + expect(handleNodeSelect).toHaveBeenCalledWith('node-1') + expect(handleNodeDataUpdate).toHaveBeenCalledWith({ id: 'node-1', data: { _isSingleRun: true } }) + expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true) + expect(handleNodesCopy).toHaveBeenCalledWith('node-1') + expect(handleNodesDuplicate).toHaveBeenCalledWith('node-1') + expect(handleNodeDelete).toHaveBeenCalledWith('node-1') + expect(screen.getByRole('menuitem', { name: 'workflow.panel.helpLink' })).toHaveAttribute('href', 'https://docs.example.com/node') + }) + + it('should hide change action when node is undeletable', () => { + mockUseNodeMetaData.mockReturnValueOnce({ + isTypeFixed: false, + isSingleton: true, + isUndeletable: true, + description: 'Undeletable node', + author: 'Dify', + } as ReturnType) + + renderDropdownContent({ showHelpLink: false }) + + expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument() + expect(screen.queryByText('workflow.panel.change')).not.toBeInTheDocument() + expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() + }) + + it('should render workflow-tool and readonly variants', () => { + mockUseAllWorkflowTools.mockReturnValueOnce({ + data: [{ id: 'workflow-tool', workflow_app_id: 'app-123' }], + } as any) + + const { rerender } = renderWorkflowFlowComponent( + + open} /> + + + + , + { + nodes: [], + edges: [], + }, + ) + + expect(screen.getByRole('menuitem', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow') + + mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType) + mockUseNodeMetaData.mockReturnValueOnce({ + isTypeFixed: true, + isSingleton: true, + isUndeletable: true, + description: 'Read only node', + author: 'Dify', + } as ReturnType) + + rerender( + + open} /> + + + + , + ) + + expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument() + expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument() + expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/index.spec.tsx b/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx similarity index 62% rename from web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/index.spec.tsx rename to web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx index 6dab0f33a5..5624de9c16 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/index.spec.tsx +++ b/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx @@ -12,7 +12,7 @@ import { } from '@/app/components/workflow/hooks' import { BlockEnum } from '@/app/components/workflow/types' import { useAllWorkflowTools } from '@/service/use-tools' -import PanelOperator from '../index' +import { NodeActionsDropdown } from '../index' vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { const actual = await importOriginal() @@ -30,8 +30,8 @@ vi.mock('@/service/use-tools', () => ({ useAllWorkflowTools: vi.fn(), })) -vi.mock('../change-block', () => ({ - default: () =>
, +vi.mock('../change-block-menu-trigger', () => ({ + ChangeBlockMenuTrigger: () =>
, })) const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate) @@ -73,18 +73,16 @@ const createQueryResult = (data: T): UseQueryResult => ({ const renderComponent = ( showHelpLink: boolean = true, onOpenChange?: (open: boolean) => void, - offset?: { mainAxis: number, crossAxis: number } | number, ) => renderWorkflowFlowComponent( - , @@ -94,7 +92,7 @@ const renderComponent = ( }, ) -describe('PanelOperator', () => { +describe('NodeActionsDropdown', () => { const handleNodeSelect = vi.fn() const handleNodeDataUpdate = vi.fn() const handleSyncWorkflowDraft = vi.fn() @@ -131,47 +129,34 @@ describe('PanelOperator', () => { mockUseAllWorkflowTools.mockReturnValue(createQueryResult([])) }) - // The operator should open the real popup, expose actionable items, and respect help-link visibility. - describe('Popup Interaction', () => { - it('should open the popup and trigger single-run actions', async () => { - const user = userEvent.setup() - const onOpenChange = vi.fn() - const { container } = renderComponent(true, onOpenChange) + it('should open the dropdown and trigger single-run actions', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + renderComponent(true, onOpenChange) - await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement) + await user.click(screen.getByRole('button', { name: 'common.operation.more' })) - expect(onOpenChange).toHaveBeenCalledWith(true) - expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument() - expect(screen.getByText('Node description')).toBeInTheDocument() + expect(onOpenChange).toHaveBeenCalledWith(true) + expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument() + expect(screen.getByText('Node description')).toBeInTheDocument() - await user.click(screen.getByText('workflow.panel.runThisStep')) + await user.click(screen.getByText('workflow.panel.runThisStep')) - expect(handleNodeSelect).toHaveBeenCalledWith('node-1') - expect(handleNodeDataUpdate).toHaveBeenCalledWith({ - id: 'node-1', - data: { _isSingleRun: true }, - }) - expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true) + expect(handleNodeSelect).toHaveBeenCalledWith('node-1') + expect(handleNodeDataUpdate).toHaveBeenCalledWith({ + id: 'node-1', + data: { _isSingleRun: true }, }) + expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true) + }) - it('should hide the help link when showHelpLink is false', async () => { - const user = userEvent.setup() - const { container } = renderComponent(false) + it('should hide the help link when showHelpLink is false', async () => { + const user = userEvent.setup() + renderComponent(false) - await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement) + await user.click(screen.getByRole('button', { name: 'common.operation.more' })) - expect(screen.queryByText('workflow.panel.helpLink')).not.toBeInTheDocument() - expect(screen.getByText('Node description')).toBeInTheDocument() - }) - - it('should still open the popup when using a numeric offset and no open-change callback', async () => { - const user = userEvent.setup() - const { container } = renderComponent(true, undefined, 0) - - await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement) - - expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument() - expect(screen.getByText('Node description')).toBeInTheDocument() - }) + expect(screen.queryByText('workflow.panel.helpLink')).not.toBeInTheDocument() + expect(screen.getByText('Node description')).toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx b/web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx similarity index 82% rename from web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx rename to web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx index 7dcb7c1efa..183d515cf4 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx +++ b/web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx @@ -4,11 +4,7 @@ import type { OnSelectBlock, } from '@/app/components/workflow/types' import { intersection } from 'es-toolkit/array' -import { - memo, - useCallback, - useMemo, -} from 'react' +import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import BlockSelector from '@/app/components/workflow/block-selector' import { @@ -19,19 +15,19 @@ import { import { useHooksStore } from '@/app/components/workflow/hooks-store' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types' - import { FlowType } from '@/types/common' -type ChangeBlockProps = { +type ChangeBlockMenuTriggerProps = { nodeId: string nodeData: Node['data'] sourceHandle: string } -const ChangeBlock = ({ + +export function ChangeBlockMenuTrigger({ nodeId, nodeData, sourceHandle, -}: ChangeBlockProps) => { +}: ChangeBlockMenuTriggerProps) { const { t } = useTranslation() const { handleNodeChange } = useNodesInteractions() const { @@ -55,10 +51,9 @@ const ChangeBlock = ({ const availableNodes = useMemo(() => { if (availablePrevBlocks.length && availableNextBlocks.length) return intersection(availablePrevBlocks, availableNextBlocks) - else if (availablePrevBlocks.length) + if (availablePrevBlocks.length) return availablePrevBlocks - else - return availableNextBlocks + return availableNextBlocks }, [availablePrevBlocks, availableNextBlocks]) const handleSelect = useCallback((type, pluginDefaultValue) => { @@ -67,19 +62,17 @@ const ChangeBlock = ({ const renderTrigger = useCallback(() => { return ( -
+
+ ) }, [t]) return ( ) } - -export default memo(ChangeBlock) diff --git a/web/app/components/workflow/node-actions-menu/context-menu-content.tsx b/web/app/components/workflow/node-actions-menu/context-menu-content.tsx new file mode 100644 index 0000000000..8548450d59 --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/context-menu-content.tsx @@ -0,0 +1,101 @@ +import type { NodeActionsMenuProps } from './types' +import { + ContextMenuGroup, + ContextMenuItem, + ContextMenuLinkItem, + ContextMenuSeparator, +} from '@langgenius/dify-ui/context-menu' +import { useTranslation } from 'react-i18next' +import { ChangeBlockMenuTrigger } from './change-block-menu-trigger' +import { + NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME, + NodeActionsMenuAbout, + NodeActionsMenuItemContent, +} from './shared' +import { useNodeActionsMenuModel } from './use-node-actions-menu-model' + +export function NodeActionsContextMenuContent(props: NodeActionsMenuProps) { + const { t } = useTranslation() + const model = useNodeActionsMenuModel(props) + const hasRunGroup = model.canRun || model.canChangeBlock + const hasEditGroup = !model.nodesReadOnly && !model.isSingleton + const hasDeleteGroup = !model.nodesReadOnly && !model.isUndeletable + + return ( + <> + {hasRunGroup && ( + + {model.canRun && ( + + {t('panel.runThisStep', { ns: 'workflow' })} + + )} + {model.canChangeBlock && ( + + )} + + )} + {hasRunGroup && (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && } + {hasEditGroup && ( + + + + {t('common.copy', { ns: 'workflow' })} + + + + + {t('common.duplicate', { ns: 'workflow' })} + + + + )} + {hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && } + {hasDeleteGroup && ( + + + + {t('operation.delete', { ns: 'common' })} + + + + )} + {hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && } + {model.workflowAppHref && ( + + + {t('panel.openWorkflow', { ns: 'workflow' })} + + + )} + {model.workflowAppHref && model.helpLinkUri && } + {model.helpLinkUri && ( + + + {t('panel.helpLink', { ns: 'workflow' })} + + + )} + + + + ) +} diff --git a/web/app/components/workflow/node-actions-menu/dropdown-content.tsx b/web/app/components/workflow/node-actions-menu/dropdown-content.tsx new file mode 100644 index 0000000000..25d793c5f3 --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/dropdown-content.tsx @@ -0,0 +1,101 @@ +import type { NodeActionsMenuProps } from './types' +import { + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLinkItem, + DropdownMenuSeparator, +} from '@langgenius/dify-ui/dropdown-menu' +import { useTranslation } from 'react-i18next' +import { ChangeBlockMenuTrigger } from './change-block-menu-trigger' +import { + NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME, + NodeActionsMenuAbout, + NodeActionsMenuItemContent, +} from './shared' +import { useNodeActionsMenuModel } from './use-node-actions-menu-model' + +export function NodeActionsDropdownContent(props: NodeActionsMenuProps) { + const { t } = useTranslation() + const model = useNodeActionsMenuModel(props) + const hasRunGroup = model.canRun || model.canChangeBlock + const hasEditGroup = !model.nodesReadOnly && !model.isSingleton + const hasDeleteGroup = !model.nodesReadOnly && !model.isUndeletable + + return ( + <> + {hasRunGroup && ( + + {model.canRun && ( + + {t('panel.runThisStep', { ns: 'workflow' })} + + )} + {model.canChangeBlock && ( + + )} + + )} + {hasRunGroup && (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && } + {hasEditGroup && ( + + + + {t('common.copy', { ns: 'workflow' })} + + + + + {t('common.duplicate', { ns: 'workflow' })} + + + + )} + {hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && } + {hasDeleteGroup && ( + + + + {t('operation.delete', { ns: 'common' })} + + + + )} + {hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && } + {model.workflowAppHref && ( + + + {t('panel.openWorkflow', { ns: 'workflow' })} + + + )} + {model.workflowAppHref && model.helpLinkUri && } + {model.helpLinkUri && ( + + + {t('panel.helpLink', { ns: 'workflow' })} + + + )} + + + + ) +} diff --git a/web/app/components/workflow/node-actions-menu/index.tsx b/web/app/components/workflow/node-actions-menu/index.tsx new file mode 100644 index 0000000000..3f813b93ba --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/index.tsx @@ -0,0 +1,75 @@ +import type { Node } from '@/app/components/workflow/types' +import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { NodeActionsDropdownContent } from './dropdown-content' +import { NODE_ACTIONS_MENU_WIDTH_CLASS_NAME } from './shared' + +type NodeActionsDropdownProps = { + id: string + data: Node['data'] + triggerClassName?: string + onOpenChange?: (open: boolean) => void + showHelpLink?: boolean +} + +export function NodeActionsDropdown({ + id, + data, + triggerClassName, + onOpenChange, + showHelpLink = true, +}: NodeActionsDropdownProps) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + + const handleOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen) + onOpenChange?.(nextOpen) + }, [onOpenChange]) + + const closeMenu = useCallback(() => { + setOpen(false) + onOpenChange?.(false) + }, [onOpenChange]) + + return ( + + + + + )} + /> + + + + + ) +} diff --git a/web/app/components/workflow/node-actions-menu/shared.tsx b/web/app/components/workflow/node-actions-menu/shared.tsx new file mode 100644 index 0000000000..a150025575 --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/shared.tsx @@ -0,0 +1,44 @@ +import type { RegisterableHotkey } from '@tanstack/react-hotkeys' +import type { ReactNode } from 'react' +import type { WorkflowShortcutId } from '@/app/components/workflow/shortcuts/definitions' +import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd' + +export const NODE_ACTIONS_MENU_WIDTH_CLASS_NAME = 'w-[240px] rounded-lg' +export const NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME = 'w-auto justify-between gap-4' + +export function NodeActionsMenuItemContent({ + children, + hotkey, + shortcut, +}: { + children: ReactNode + hotkey?: RegisterableHotkey | (string & {}) + shortcut?: WorkflowShortcutId +}) { + return ( + <> + {children} + {(shortcut || hotkey) && } + + ) +} + +export function NodeActionsMenuAbout({ + author, + description, + title, +}: { + author?: string + description?: string + title: string +}) { + return ( +
+
+ {title.toLocaleUpperCase()} +
+
{description}
+
{author}
+
+ ) +} diff --git a/web/app/components/workflow/node-actions-menu/types.ts b/web/app/components/workflow/node-actions-menu/types.ts new file mode 100644 index 0000000000..565ccdcbca --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/types.ts @@ -0,0 +1,8 @@ +import type { Node } from '@/app/components/workflow/types' + +export type NodeActionsMenuProps = { + id: string + data: Node['data'] + onClose: () => void + showHelpLink?: boolean +} diff --git a/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts b/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts new file mode 100644 index 0000000000..45e34e6bb2 --- /dev/null +++ b/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts @@ -0,0 +1,104 @@ +import type { Node } from '@/app/components/workflow/types' +import { useCallback, useMemo } from 'react' +import { useEdges } from 'reactflow' +import { CollectionType } from '@/app/components/tools/types' +import { + useNodeDataUpdate, + useNodeMetaData, + useNodesInteractions, + useNodesReadOnly, + useNodesSyncDraft, +} from '@/app/components/workflow/hooks' +import { BlockEnum } from '@/app/components/workflow/types' +import { canRunBySingle } from '@/app/components/workflow/utils' +import { useAllWorkflowTools } from '@/service/use-tools' +import { canFindTool } from '@/utils' + +type UseNodeActionsMenuModelParams = { + id: string + data: Node['data'] + onClose: () => void + showHelpLink?: boolean +} + +export function useNodeActionsMenuModel({ + id, + data, + onClose, + showHelpLink = true, +}: UseNodeActionsMenuModelParams) { + const edges = useEdges() + const { + handleNodeDelete, + handleNodesDuplicate, + handleNodeSelect, + handleNodesCopy, + } = useNodesInteractions() + const { handleNodeDataUpdate } = useNodeDataUpdate() + const { handleSyncWorkflowDraft } = useNodesSyncDraft() + const { nodesReadOnly } = useNodesReadOnly() + const nodeMetaData = useNodeMetaData({ id, data } as Node) + const { data: workflowTools } = useAllWorkflowTools() + + const isChildNode = !!(data.isInIteration || data.isInLoop) + const canRun = canRunBySingle(data.type, isChildNode) + const canChangeBlock = !nodeMetaData.isTypeFixed && !nodeMetaData.isUndeletable && !nodesReadOnly + const sourceHandle = useMemo(() => { + return edges.find(edge => edge.target === id)?.sourceHandle || 'source' + }, [edges, id]) + + const workflowAppHref = useMemo(() => { + const isWorkflowTool = data.type === BlockEnum.Tool && data.provider_type === CollectionType.workflow + if (!isWorkflowTool || !workflowTools || !data.provider_id) + return undefined + + const workflowTool = workflowTools.find(item => canFindTool(item.id, data.provider_id)) + if (!workflowTool?.workflow_app_id) + return undefined + + return `/app/${workflowTool.workflow_app_id}/workflow` + }, [data.provider_id, data.provider_type, data.type, workflowTools]) + + const handleRun = useCallback(() => { + handleNodeSelect(id) + handleNodeDataUpdate({ id, data: { _isSingleRun: true } }) + handleSyncWorkflowDraft(true) + onClose() + }, [handleNodeDataUpdate, handleNodeSelect, handleSyncWorkflowDraft, id, onClose]) + + const handleCopy = useCallback(() => { + onClose() + handleNodesCopy(id) + }, [handleNodesCopy, id, onClose]) + + const handleDuplicate = useCallback(() => { + onClose() + handleNodesDuplicate(id) + }, [handleNodesDuplicate, id, onClose]) + + const handleDelete = useCallback(() => { + onClose() + handleNodeDelete(id) + }, [handleNodeDelete, id, onClose]) + + return { + about: { + author: nodeMetaData.author, + description: nodeMetaData.description, + }, + canChangeBlock, + canRun, + data, + handleCopy, + handleDelete, + handleDuplicate, + handleRun, + helpLinkUri: showHelpLink ? nodeMetaData.helpLinkUri : undefined, + id, + isSingleton: nodeMetaData.isSingleton, + isUndeletable: nodeMetaData.isUndeletable, + nodesReadOnly, + sourceHandle, + workflowAppHref, + } +} diff --git a/web/app/components/workflow/node-contextmenu.tsx b/web/app/components/workflow/node-contextmenu.tsx index 27ef4029fc..e5bd7c933f 100644 --- a/web/app/components/workflow/node-contextmenu.tsx +++ b/web/app/components/workflow/node-contextmenu.tsx @@ -1,45 +1,54 @@ import type { Node } from './types' -import { useClickAway } from 'ahooks' import { - memo, - useRef, -} from 'react' + ContextMenu, + ContextMenuContent, +} from '@langgenius/dify-ui/context-menu' +import { useMemo } from 'react' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' import { usePanelInteractions } from './hooks' -import PanelOperatorPopup from './nodes/_base/components/panel-operator/panel-operator-popup' +import { NodeActionsContextMenuContent } from './node-actions-menu/context-menu-content' +import { NODE_ACTIONS_MENU_WIDTH_CLASS_NAME } from './node-actions-menu/shared' import { useStore } from './store' -const NodeContextmenu = () => { - const ref = useRef(null) +export function NodeContextmenu() { const nodes = useNodes() const { handleNodeContextmenuCancel } = usePanelInteractions() const nodeMenu = useStore(s => s.nodeMenu) const currentNode = nodes.find(node => node.id === nodeMenu?.nodeId) as Node - useClickAway(() => { - handleNodeContextmenuCancel() - }, ref) + const anchor = useMemo(() => { + if (!nodeMenu || !currentNode) + return undefined - if (!nodeMenu || !currentNode) + return { + getBoundingClientRect: () => DOMRect.fromRect({ + width: 0, + height: 0, + x: nodeMenu.clientX, + y: nodeMenu.clientY, + }), + } + }, [currentNode, nodeMenu]) + + if (!nodeMenu || !currentNode || !anchor) return null return ( -
!open && handleNodeContextmenuCancel()} > - handleNodeContextmenuCancel()} - showHelpLink - /> -
+ + + + ) } - -export default memo(NodeContextmenu) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/field.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/field.spec.tsx index e16ed108f7..a40d03603d 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/field.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/field.spec.tsx @@ -1,10 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import Field from '../field' -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ popupContent }: { popupContent: React.ReactNode }) =>
{popupContent}
, -})) - describe('Field', () => { it('should render subtitle styling, tooltip, operations, warning dot and required marker', () => { const { container } = render( @@ -19,7 +15,7 @@ describe('Field', () => { ) expect(screen.getByText('Knowledge')).toBeInTheDocument() - expect(screen.getByText('tooltip text')).toBeInTheDocument() + expect(screen.getByLabelText('tooltip text')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'operation' })).toBeInTheDocument() expect(screen.getByText('*')).toBeInTheDocument() expect(container.querySelector('.system-xs-medium-uppercase')).not.toBeNull() diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx index 82650a61f4..62d74b64da 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx @@ -32,8 +32,8 @@ vi.mock('../../../../utils', async () => { } }) -vi.mock('../panel-operator', () => ({ - default: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => ( +vi.mock('@/app/components/workflow/node-actions-menu', () => ({ + NodeActionsDropdown: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => ( <> diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx index d20acd7f19..72fffd3c86 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx @@ -266,6 +266,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => { href={docLink('/use-dify/nodes/agent')} className="text-text-accent-secondary" target="_blank" + rel="noopener noreferrer" > {t('nodes.agent.learnMore', { ns: 'workflow' })} diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx index 62fcb5352c..f89f6d30fa 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx +++ b/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx @@ -21,6 +21,7 @@ const FailBranchCard = () => { {t('common.learnMore', { ns: 'workflow' })} diff --git a/web/app/components/workflow/nodes/_base/components/field.tsx b/web/app/components/workflow/nodes/_base/components/field.tsx index 6c8e26758a..11bb778ce5 100644 --- a/web/app/components/workflow/nodes/_base/components/field.tsx +++ b/web/app/components/workflow/nodes/_base/components/field.tsx @@ -6,7 +6,7 @@ import { } from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' type Props = { className?: string @@ -21,6 +21,17 @@ type Props = { warningDot?: boolean } +const getTextFromNode = (node: ReactNode): string | undefined => { + if (typeof node === 'string' || typeof node === 'number') + return `${node}` + + if (Array.isArray(node)) + return node.map(getTextFromNode).filter(Boolean).join(' ') + + if (React.isValidElement<{ children?: ReactNode }>(node)) + return getTextFromNode(node.props.children) +} + const Field: FC = ({ className, title, @@ -36,6 +47,8 @@ const Field: FC = ({ const [fold, { toggle: toggleFold, }] = useBoolean(true) + const tooltipLabel = tooltip ? getTextFromNode(tooltip) || getTextFromNode(title) || 'Help' : undefined + return (
= ({ {' '} {required && *}
- {!!tooltip && ( - + {!!tooltip && !!tooltipLabel && ( + + {tooltip} + )}
diff --git a/web/app/components/workflow/nodes/_base/components/help-link.tsx b/web/app/components/workflow/nodes/_base/components/help-link.tsx index 298f50738f..2f6e5fa4b9 100644 --- a/web/app/components/workflow/nodes/_base/components/help-link.tsx +++ b/web/app/components/workflow/nodes/_base/components/help-link.tsx @@ -26,7 +26,7 @@ const HelpLink = ({ aria-label={label} href={link} target="_blank" - rel="noreferrer" + rel="noopener noreferrer" className="mr-1 flex h-6 w-6 items-center justify-center rounded-md hover:bg-state-base-hover" > diff --git a/web/app/components/workflow/nodes/_base/components/node-control.tsx b/web/app/components/workflow/nodes/_base/components/node-control.tsx index 439e097bc9..5731ab27a7 100644 --- a/web/app/components/workflow/nodes/_base/components/node-control.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-control.tsx @@ -11,13 +11,13 @@ import { useTranslation } from 'react-i18next' import { Stop, } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' +import { NodeActionsDropdown } from '@/app/components/workflow/node-actions-menu' import { useWorkflowStore } from '@/app/components/workflow/store' import { useNodesInteractions, } from '../../../hooks' import { NodeRunningStatus } from '../../../types' import { canRunBySingle } from '../../../utils' -import PanelOperator from './panel-operator' type NodeControlProps = Pick & { pluginInstallLocked?: boolean @@ -82,10 +82,9 @@ const NodeControl: FC = ({ ) } -
diff --git a/web/app/components/workflow/nodes/_base/components/option-card.tsx b/web/app/components/workflow/nodes/_base/components/option-card.tsx index dd8ad51437..e23ae22204 100644 --- a/web/app/components/workflow/nodes/_base/components/option-card.tsx +++ b/web/app/components/workflow/nodes/_base/components/option-card.tsx @@ -5,7 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { cva } from 'class-variance-authority' import * as React from 'react' import { useCallback } from 'react' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' const variants = cva([], { variants: { @@ -60,13 +60,9 @@ const OptionCard: FC = ({ {title} {tooltip && ( - - {tooltip} -
- )} - /> + + {tooltip} + )}
) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/details.spec.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/details.spec.tsx deleted file mode 100644 index eeb6e48900..0000000000 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/__tests__/details.spec.tsx +++ /dev/null @@ -1,295 +0,0 @@ -/* eslint-disable ts/no-explicit-any */ -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' -import { - useAvailableBlocks, - useIsChatMode, - useNodeDataUpdate, - useNodeMetaData, - useNodesInteractions, - useNodesReadOnly, - useNodesSyncDraft, -} from '@/app/components/workflow/hooks' -import { useHooksStore } from '@/app/components/workflow/hooks-store' -import useNodes from '@/app/components/workflow/store/workflow/use-nodes' -import { BlockEnum } from '@/app/components/workflow/types' -import { useAllWorkflowTools } from '@/service/use-tools' -import { FlowType } from '@/types/common' -import ChangeBlock from '../change-block' -import PanelOperatorPopup from '../panel-operator-popup' - -vi.mock('@/app/components/workflow/block-selector', () => ({ - default: ({ trigger, onSelect, availableBlocksTypes, showStartTab, ignoreNodeIds, forceEnableStartTab, allowUserInputSelection }: any) => ( -
-
{trigger()}
-
{`available:${(availableBlocksTypes || []).join(',')}`}
-
{`show-start:${String(showStartTab)}`}
-
{`ignore:${(ignoreNodeIds || []).join(',')}`}
-
{`force-start:${String(forceEnableStartTab)}`}
-
{`allow-start:${String(allowUserInputSelection)}`}
- -
- ), -})) - -vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useAvailableBlocks: vi.fn(), - useIsChatMode: vi.fn(), - useNodeDataUpdate: vi.fn(), - useNodeMetaData: vi.fn(), - useNodesInteractions: vi.fn(), - useNodesReadOnly: vi.fn(), - useNodesSyncDraft: vi.fn(), - } -}) - -vi.mock('@/app/components/workflow/hooks-store', () => ({ - useHooksStore: vi.fn(), -})) - -vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ - default: vi.fn(), -})) - -vi.mock('@/service/use-tools', () => ({ - useAllWorkflowTools: vi.fn(), -})) - -const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks) -const mockUseIsChatMode = vi.mocked(useIsChatMode) -const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate) -const mockUseNodeMetaData = vi.mocked(useNodeMetaData) -const mockUseNodesInteractions = vi.mocked(useNodesInteractions) -const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly) -const mockUseNodesSyncDraft = vi.mocked(useNodesSyncDraft) -const mockUseHooksStore = vi.mocked(useHooksStore) -const mockUseNodes = vi.mocked(useNodes) -const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools) - -describe('panel-operator details', () => { - const handleNodeChange = vi.fn() - const handleNodeDelete = vi.fn() - const handleNodesDuplicate = vi.fn() - const handleNodeSelect = vi.fn() - const handleNodesCopy = vi.fn() - const handleNodeDataUpdate = vi.fn() - const handleSyncWorkflowDraft = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() - mockUseAvailableBlocks.mockReturnValue({ - getAvailableBlocks: vi.fn(() => ({ - availablePrevBlocks: [BlockEnum.HttpRequest], - availableNextBlocks: [BlockEnum.HttpRequest], - })), - availablePrevBlocks: [BlockEnum.HttpRequest], - availableNextBlocks: [BlockEnum.HttpRequest], - } as ReturnType) - mockUseIsChatMode.mockReturnValue(false) - mockUseNodeDataUpdate.mockReturnValue({ - handleNodeDataUpdate, - handleNodeDataUpdateWithSyncDraft: vi.fn(), - }) - mockUseNodeMetaData.mockReturnValue({ - isTypeFixed: false, - isSingleton: false, - isUndeletable: false, - description: 'Node description', - author: 'Dify', - helpLinkUri: 'https://docs.example.com/node', - } as ReturnType) - mockUseNodesInteractions.mockReturnValue({ - handleNodeChange, - handleNodeDelete, - handleNodesDuplicate, - handleNodeSelect, - handleNodesCopy, - } as unknown as ReturnType) - mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType) - mockUseNodesSyncDraft.mockReturnValue({ - doSyncWorkflowDraft: vi.fn(), - handleSyncWorkflowDraft, - syncWorkflowDraftWhenPageClose: vi.fn(), - } as ReturnType) - mockUseHooksStore.mockImplementation((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } })) - mockUseNodes.mockReturnValue([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any) - mockUseAllWorkflowTools.mockReturnValue({ data: [] } as any) - }) - - // The panel operator internals should expose block-change and popup actions using the real workflow popup composition. - describe('Internal Actions', () => { - it('should select a replacement block through ChangeBlock', async () => { - const user = userEvent.setup() - render( - , - ) - - await user.click(screen.getByText('select-http')) - - expect(screen.getByText('available:http-request')).toBeInTheDocument() - expect(screen.getByText('show-start:true')).toBeInTheDocument() - expect(screen.getByText('ignore:')).toBeInTheDocument() - expect(screen.getByText('force-start:false')).toBeInTheDocument() - expect(screen.getByText('allow-start:false')).toBeInTheDocument() - expect(handleNodeChange).toHaveBeenCalledWith('node-1', BlockEnum.HttpRequest, 'source', undefined) - }) - - it('should expose trigger and start-node specific block selector options', () => { - mockUseAvailableBlocks.mockReturnValueOnce({ - getAvailableBlocks: vi.fn(() => ({ - availablePrevBlocks: [], - availableNextBlocks: [BlockEnum.HttpRequest], - })), - availablePrevBlocks: [], - availableNextBlocks: [BlockEnum.HttpRequest], - } as ReturnType) - mockUseIsChatMode.mockReturnValueOnce(true) - mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } })) - mockUseNodes.mockReturnValueOnce([] as any) - - const { rerender } = render( - , - ) - - expect(screen.getByText('available:http-request')).toBeInTheDocument() - expect(screen.getByText('show-start:true')).toBeInTheDocument() - expect(screen.getByText('ignore:trigger-node')).toBeInTheDocument() - expect(screen.getByText('allow-start:true')).toBeInTheDocument() - - mockUseAvailableBlocks.mockReturnValueOnce({ - getAvailableBlocks: vi.fn(() => ({ - availablePrevBlocks: [BlockEnum.Code], - availableNextBlocks: [], - })), - availablePrevBlocks: [BlockEnum.Code], - availableNextBlocks: [], - } as ReturnType) - mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.ragPipeline } })) - mockUseNodes.mockReturnValueOnce([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any) - - rerender( - , - ) - - expect(screen.getByText('available:code')).toBeInTheDocument() - expect(screen.getByText('show-start:false')).toBeInTheDocument() - expect(screen.getByText('ignore:start-node')).toBeInTheDocument() - expect(screen.getByText('force-start:true')).toBeInTheDocument() - }) - - it('should run, copy, duplicate, delete, and expose the help link in the popup', async () => { - const user = userEvent.setup() - renderWorkflowFlowComponent( - , - { - nodes: [], - edges: [{ id: 'edge-1', source: 'node-0', target: 'node-1', sourceHandle: 'branch-a' }], - }, - ) - - await user.click(screen.getByText('workflow.panel.runThisStep')) - await user.click(screen.getByText('workflow.common.copy')) - await user.click(screen.getByText('workflow.common.duplicate')) - await user.click(screen.getByText('common.operation.delete')) - - expect(handleNodeSelect).toHaveBeenCalledWith('node-1') - expect(handleNodeDataUpdate).toHaveBeenCalledWith({ id: 'node-1', data: { _isSingleRun: true } }) - expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true) - expect(handleNodesCopy).toHaveBeenCalledWith('node-1') - expect(handleNodesDuplicate).toHaveBeenCalledWith('node-1') - expect(handleNodeDelete).toHaveBeenCalledWith('node-1') - expect(screen.getByRole('link', { name: 'workflow.panel.helpLink' })).toHaveAttribute('href', 'https://docs.example.com/node') - }) - - it('should hide change action when node is undeletable', () => { - mockUseNodeMetaData.mockReturnValueOnce({ - isTypeFixed: false, - isSingleton: true, - isUndeletable: true, - description: 'Undeletable node', - author: 'Dify', - } as ReturnType) - - renderWorkflowFlowComponent( - , - { - nodes: [], - edges: [], - }, - ) - - expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument() - expect(screen.queryByText('workflow.panel.change')).not.toBeInTheDocument() - expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() - }) - - it('should render workflow-tool and readonly popup variants', () => { - mockUseAllWorkflowTools.mockReturnValueOnce({ - data: [{ id: 'workflow-tool', workflow_app_id: 'app-123' }], - } as any) - - const { rerender } = renderWorkflowFlowComponent( - , - { - nodes: [], - edges: [], - }, - ) - - expect(screen.getByRole('link', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow') - - mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType) - mockUseNodeMetaData.mockReturnValueOnce({ - isTypeFixed: true, - isSingleton: true, - isUndeletable: true, - description: 'Read only node', - author: 'Dify', - } as ReturnType) - - rerender( - , - ) - - expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument() - expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument() - expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() - }) - }) -}) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx deleted file mode 100644 index ee16fd0c06..0000000000 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type { OffsetOptions } from '@floating-ui/react' -import type { Node } from '@/app/components/workflow/types' -import { cn } from '@langgenius/dify-ui/cn' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from '@langgenius/dify-ui/dropdown-menu' -import { - memo, - useCallback, - useState, -} from 'react' -import { useTranslation } from 'react-i18next' -import PanelOperatorPopup from './panel-operator-popup' - -type PanelOperatorProps = { - id: string - data: Node['data'] - triggerClassName?: string - offset?: OffsetOptions | number - onOpenChange?: (open: boolean) => void - showHelpLink?: boolean -} -const PanelOperator = ({ - id, - data, - triggerClassName, - offset = { - mainAxis: 4, - crossAxis: 53, - }, - onOpenChange, - showHelpLink = true, -}: PanelOperatorProps) => { - const { t } = useTranslation() - const [open, setOpen] = useState(false) - const sideOffset = typeof offset === 'number' - ? offset - : typeof offset === 'object' && offset && 'mainAxis' in offset && typeof offset.mainAxis === 'number' - ? offset.mainAxis - : 4 - const alignOffset = typeof offset === 'object' && offset && 'crossAxis' in offset && typeof offset.crossAxis === 'number' - ? offset.crossAxis - : 0 - - const handleOpenChange = useCallback((nextOpen: boolean) => { - setOpen(nextOpen) - onOpenChange?.(nextOpen) - }, [onOpenChange]) - - return ( - - } - aria-label={t('operation.more', { ns: 'common' })} - className={cn( - 'nodrag nopan nowheel flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover', - 'data-[popup-open]:bg-state-base-hover', - triggerClassName, - )} - > - - - - setOpen(false)} - showHelpLink={showHelpLink} - /> - - - ) -} - -export default memo(PanelOperator) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx deleted file mode 100644 index 8445677c1e..0000000000 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import type { Node } from '@/app/components/workflow/types' -import { - memo, - useMemo, -} from 'react' -import { useTranslation } from 'react-i18next' -import { useEdges } from 'reactflow' -import { CollectionType } from '@/app/components/tools/types' -import { - useNodeDataUpdate, - useNodeMetaData, - useNodesInteractions, - useNodesReadOnly, - useNodesSyncDraft, -} from '@/app/components/workflow/hooks' -import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd' -import { BlockEnum } from '@/app/components/workflow/types' -import { - canRunBySingle, -} from '@/app/components/workflow/utils' -import { useAllWorkflowTools } from '@/service/use-tools' -import { canFindTool } from '@/utils' -import ChangeBlock from './change-block' - -type PanelOperatorPopupProps = { - id: string - data: Node['data'] - onClosePopup: () => void - showHelpLink?: boolean -} -const PanelOperatorPopup = ({ - id, - data, - onClosePopup, - showHelpLink, -}: PanelOperatorPopupProps) => { - const { t } = useTranslation() - const edges = useEdges() - const { - handleNodeDelete, - handleNodesDuplicate, - handleNodeSelect, - handleNodesCopy, - } = useNodesInteractions() - const { handleNodeDataUpdate } = useNodeDataUpdate() - const { handleSyncWorkflowDraft } = useNodesSyncDraft() - const { nodesReadOnly } = useNodesReadOnly() - const edge = edges.find(edge => edge.target === id) - const nodeMetaData = useNodeMetaData({ id, data } as Node) - const showChangeBlock = !nodeMetaData.isTypeFixed && !nodeMetaData.isUndeletable && !nodesReadOnly - const isChildNode = !!(data.isInIteration || data.isInLoop) - - const { data: workflowTools } = useAllWorkflowTools() - const isWorkflowTool = data.type === BlockEnum.Tool && data.provider_type === CollectionType.workflow - const workflowAppId = useMemo(() => { - if (!isWorkflowTool || !workflowTools || !data.provider_id) - return undefined - const workflowTool = workflowTools.find(item => canFindTool(item.id, data.provider_id)) - return workflowTool?.workflow_app_id - }, [isWorkflowTool, workflowTools, data.provider_id]) - - return ( -
- { - (showChangeBlock || canRunBySingle(data.type, isChildNode)) && ( - <> -
- { - canRunBySingle(data.type, isChildNode) && ( - - ) - } - { - showChangeBlock && ( - - ) - } -
-
- - ) - } - { - !nodesReadOnly && ( - <> - { - !nodeMetaData.isSingleton && ( - <> -
- - -
-
- - ) - } - { - !nodeMetaData.isUndeletable && ( - <> -
- -
-
- - ) - } - - ) - } - { - isWorkflowTool && workflowAppId && ( - <> -
-
- - ) - } - { - showHelpLink && nodeMetaData.helpLinkUri && ( - <> - -
- - ) - } -
-
-
- {t('panel.about', { ns: 'workflow' }).toLocaleUpperCase()} -
-
{nodeMetaData.description}
-
- {t('panel.createdBy', { ns: 'workflow' })} - {' '} - {nodeMetaData.author} -
-
-
-
- ) -} - -export default memo(PanelOperatorPopup) diff --git a/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx b/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx index ccd92f7296..141323e5b3 100644 --- a/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx +++ b/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx @@ -95,6 +95,7 @@ export const SwitchPluginVersion: FC = (props) => { className="flex items-center justify-center gap-1" href={getMarketplaceUrl(`/plugins/${pluginDetail.declaration.author}/${pluginDetail.declaration.name}`)} target="_blank" + rel="noopener noreferrer" > {t('nodes.agent.installPlugin.changelog', { ns: 'workflow' })} diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx index 17485c4344..793e773e59 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx @@ -241,8 +241,8 @@ vi.mock('../next-step', () => ({ default: () =>
next-step
, })) -vi.mock('../panel-operator', () => ({ - default: () =>
panel-operator
, +vi.mock('@/app/components/workflow/node-actions-menu', () => ({ + NodeActionsDropdown: () =>
node-actions-menu
, })) vi.mock('../retry/retry-on-panel', () => ({ @@ -349,8 +349,9 @@ describe('workflow-panel index', () => { expect(mockSaveStateToHistory).toHaveBeenCalled() fireEvent.click(screen.getByText('authorized-in-node')) + fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.runThisStep' })) + const clickableItems = container.querySelectorAll('.cursor-pointer') - fireEvent.click(clickableItems[0] as HTMLElement) fireEvent.click(clickableItems[clickableItems.length - 1] as HTMLElement) expect(mockHandleSingleRun).toHaveBeenCalledTimes(1) @@ -587,8 +588,7 @@ describe('workflow-panel index', () => { expect(root.style.right).toBe('240px') expect(root.className).toContain('absolute') - const clickableItems = container.querySelectorAll('.cursor-pointer') - fireEvent.click(clickableItems[0] as HTMLElement) + fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.trigger.stop' })) expect(mockHandleStop).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index 5ebf5ae09b..928e1e5ee7 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -2,6 +2,11 @@ import type { FC, ReactNode } from 'react' import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list' import type { Node } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@langgenius/dify-ui/tooltip' import { RiCloseLine, RiPlayLargeLine, @@ -21,7 +26,6 @@ import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' -import Tooltip from '@/app/components/base/tooltip' import { UserAvatarList } from '@/app/components/base/user-avatar-list' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' @@ -49,6 +53,7 @@ import { } from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store' import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' +import { NodeActionsDropdown } from '@/app/components/workflow/node-actions-menu' import Split from '@/app/components/workflow/nodes/_base/components/split' import { useLogs } from '@/app/components/workflow/run/hooks' import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel' @@ -71,7 +76,6 @@ import PanelWrap from '../before-run-form/panel-wrap' import ErrorHandleOnPanel from '../error-handle/error-handle-on-panel' import HelpLink from '../help-link' import NextStep from '../next-step' -import PanelOperator from '../panel-operator' import RetryOnPanel from '../retry/retry-on-panel' import { DescriptionInput, TitleInput } from '../title-description-input' import { @@ -469,6 +473,11 @@ const BasePanel: FC = ({ ) } + const runThisStepLabel = t('panel.runThisStep', { ns: 'workflow' }) + const singleRunActionLabel = isSingleRunning + ? t('debug.variableInspect.trigger.stop', { ns: 'workflow' }) + : runThisStepLabel + return (
= ({
{ isSupportSingleRun && !nodesReadOnly && ( - -
{ - if (isSingleRunning) - handleStop() - else - handleSingleRun() - }} - > - { - isSingleRunning - ? - : - } -
+ + { + if (isSingleRunning) + handleStop() + else + handleSingleRun() + }} + > + { + isSingleRunning + ? + : + } + + )} + /> + + {runThisStepLabel} + ) } - +
> = ({ />
{t(`${i18nPrefix}.supportFileTypes`, { ns: 'workflow', types: supportTypesShowNames })} - {t(`${i18nPrefix}.learnMore`, { ns: 'workflow' })} + {t(`${i18nPrefix}.learnMore`, { ns: 'workflow' })}
diff --git a/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx b/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx index ddc15b5fdb..c9a55d42ef 100644 --- a/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx +++ b/web/app/components/workflow/nodes/llm/components/config-prompt-item.tsx @@ -1,10 +1,10 @@ 'use client' import type { FC } from 'react' -import type { ModelConfig, PromptItem, Variable } from '../../../types' +import type { ModelConfig, Node, NodeOutPutVar, PromptItem, Variable } from '../../../types' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor' import TypeSelector from '@/app/components/workflow/nodes/_base/components/selector' import { PromptRole } from '@/models/debug' @@ -35,10 +35,10 @@ type Props = { history: boolean query: boolean } - availableVars: any - availableNodes: any + availableVars: NodeOutPutVar[] + availableNodes: Node[] varList: Variable[] - handleAddVariable: (payload: any) => void + handleAddVariable: (payload: Variable) => void modelConfig?: ModelConfig } @@ -119,12 +119,14 @@ const ConfigPromptItem: FC = ({ /> )} - {!!payload.role && t(`${i18nPrefix}.roleDescription.${payload.role}`, { ns: 'workflow' })}
- } - triggerClassName="w-4 h-4" - /> + {!!payload.role && ( + + {t(`${i18nPrefix}.roleDescription.${payload.role}`, { ns: 'workflow' })} + + )}
)} value={payload.edition_type === EditionType.jinja2 ? (payload.jinja2_text || '') : payload.text} diff --git a/web/app/components/workflow/nodes/llm/components/panel-memory-section.tsx b/web/app/components/workflow/nodes/llm/components/panel-memory-section.tsx index 59b38b41db..d9b7612bff 100644 --- a/web/app/components/workflow/nodes/llm/components/panel-memory-section.tsx +++ b/web/app/components/workflow/nodes/llm/components/panel-memory-section.tsx @@ -1,9 +1,9 @@ import type { FC } from 'react' import type { LLMNodeType } from '../types' import type { Memory, Node, NodeOutPutVar } from '@/app/components/workflow/types' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { Infotip } from '@/app/components/base/infotip' import MemoryConfig from '@/app/components/workflow/nodes/_base/components/memory-config' import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor' @@ -50,18 +50,9 @@ const PanelMemorySection: FC = ({
{t('nodes.common.memories.title', { ns: 'workflow' })}
- - - - - )} - /> - - {t('nodes.common.memories.tip', { ns: 'workflow' })} - - + + {t('nodes.common.memories.tip', { ns: 'workflow' })} +
{t('nodes.common.memories.builtIn', { ns: 'workflow' })} @@ -72,18 +63,12 @@ const PanelMemorySection: FC = ({ title={(
user
- - - - - )} - /> - -
{t('nodes.llm.roleDescription.user', { ns: 'workflow' })}
-
-
+ + {t('nodes.llm.roleDescription.user', { ns: 'workflow' })} +
)} value={inputs.memory.query_prompt_template || '{{#sys.query#}}'} diff --git a/web/app/components/workflow/nodes/llm/components/panel-output-section.tsx b/web/app/components/workflow/nodes/llm/components/panel-output-section.tsx index bd333b6b1f..53f7161fba 100644 --- a/web/app/components/workflow/nodes/llm/components/panel-output-section.tsx +++ b/web/app/components/workflow/nodes/llm/components/panel-output-section.tsx @@ -2,9 +2,9 @@ import type { FC } from 'react' import type { LLMNodeType, StructuredOutput } from '../types' import { Switch } from '@langgenius/dify-ui/switch' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { RiAlertFill, RiQuestionLine } from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { Infotip } from '@/app/components/base/infotip' import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' import Split from '@/app/components/workflow/nodes/_base/components/split' import StructureOutput from './structure-output' @@ -45,31 +45,23 @@ const PanelOutputSection: FC = ({ - +
)} /> - -
-
{t('structOutput.modelNotSupported', { ns: 'app' })}
-
{t('structOutput.modelNotSupportedTip', { ns: 'app' })}
-
+ +
{t('structOutput.modelNotSupported', { ns: 'app' })}
+
{t('structOutput.modelNotSupportedTip', { ns: 'app' })}
)}
{t('structOutput.structured', { ns: 'app' })}
- - - -
- )} - /> - -
{t('structOutput.structuredTip', { ns: 'app' })}
-
-
+ + {t('structOutput.structuredTip', { ns: 'app' })} + > = ({ className="flex h-[18px] items-center space-x-0.5 text-xs font-normal text-text-tertiary" href="https://jinja.palletsprojects.com/en/3.1.x/templates/" target="_blank" + rel="noopener noreferrer" > {t(`${i18nPrefix}.codeSupportTip`, { ns: 'workflow' })} diff --git a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx index 7e5b6c586c..804f7c6e8d 100644 --- a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx +++ b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx @@ -135,7 +135,7 @@ const LinkEditorComponent = ({ className="flex h-6 items-center rounded-md px-2 hover:bg-state-base-hover" href={escape(url)} target="_blank" - rel="noreferrer" + rel="noopener noreferrer" >
diff --git a/web/app/components/workflow/run/node.tsx b/web/app/components/workflow/run/node.tsx index 3f7e9e9999..9895868887 100644 --- a/web/app/components/workflow/run/node.tsx +++ b/web/app/components/workflow/run/node.tsx @@ -232,6 +232,7 @@ const NodePanel: FC = ({ {t('common.learnMore', { ns: 'workflow' })} diff --git a/web/app/components/workflow/run/status.tsx b/web/app/components/workflow/run/status.tsx index 667e0236a3..a13b52816d 100644 --- a/web/app/components/workflow/run/status.tsx +++ b/web/app/components/workflow/run/status.tsx @@ -212,6 +212,7 @@ const StatusPanel: FC = ({ {t('common.learnMore', { ns: 'workflow' })} @@ -244,6 +245,7 @@ const StatusPanel: FC = ({ key={url} href={url} target="_blank" + rel="noopener noreferrer" className="system-xs-medium text-text-accent" > {url} diff --git a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts index 6cebc4e23c..47819c049f 100644 --- a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts +++ b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts @@ -88,7 +88,7 @@ describe('createWorkflowStore', () => { ['showSingleRunPanel', 'setShowSingleRunPanel', true], ['nodeAnimation', 'setNodeAnimation', true], ['candidateNode', 'setCandidateNode', undefined], - ['nodeMenu', 'setNodeMenu', { top: 100, left: 200, nodeId: 'n1' }], + ['nodeMenu', 'setNodeMenu', { clientX: 200, clientY: 100, nodeId: 'n1' }], ['showAssignVariablePopup', 'setShowAssignVariablePopup', undefined], ['hoveringAssignVariableGroupId', 'setHoveringAssignVariableGroupId', 'group-1'], ['connectingNodePayload', 'setConnectingNodePayload', { nodeId: 'n1', nodeType: 'llm', handleType: 'source', handleId: 'h1' }], diff --git a/web/app/components/workflow/store/workflow/node-slice.ts b/web/app/components/workflow/store/workflow/node-slice.ts index eb16388ef4..fc96635acb 100644 --- a/web/app/components/workflow/store/workflow/node-slice.ts +++ b/web/app/components/workflow/store/workflow/node-slice.ts @@ -19,8 +19,8 @@ export type NodeSliceShape = { candidateNode?: Node setCandidateNode: (candidateNode?: Node) => void nodeMenu?: { - top: number - left: number + clientX: number + clientY: number nodeId: string } setNodeMenu: (nodeMenu: NodeSliceShape['nodeMenu']) => void diff --git a/web/app/components/workflow/variable-inspect/right.tsx b/web/app/components/workflow/variable-inspect/right.tsx index a57ac00ca8..db58b5e557 100644 --- a/web/app/components/workflow/variable-inspect/right.tsx +++ b/web/app/components/workflow/variable-inspect/right.tsx @@ -241,6 +241,7 @@ const Right = ({ diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx index 93259c1ccb..7998af6e66 100644 --- a/web/app/education-apply/education-apply-page.tsx +++ b/web/app/education-apply/education-apply-page.tsx @@ -123,11 +123,11 @@ const EducationApplyAge = () => {
{t('form.terms.desc.front', { ns: 'education' })}   - {t('form.terms.desc.termsOfService', { ns: 'education' })} + {t('form.terms.desc.termsOfService', { ns: 'education' })}   {t('form.terms.desc.and', { ns: 'education' })}   - {t('form.terms.desc.privacyPolicy', { ns: 'education' })} + {t('form.terms.desc.privacyPolicy', { ns: 'education' })} {t('form.terms.desc.end', { ns: 'education' })}
@@ -161,6 +161,7 @@ const EducationApplyAge = () => { className="flex items-center system-xs-regular text-text-accent" href={docLink('/use-dify/workspace/subscription-management#dify-for-education')} target="_blank" + rel="noopener noreferrer" > {t('learn', { ns: 'education' })} diff --git a/web/app/education-apply/verify-state-modal.tsx b/web/app/education-apply/verify-state-modal.tsx index bb7439352e..d51a815297 100644 --- a/web/app/education-apply/verify-state-modal.tsx +++ b/web/app/education-apply/verify-state-modal.tsx @@ -101,7 +101,7 @@ function Confirm({
{showLink && ( <> - {t('learn', { ns: 'education' })} + {t('learn', { ns: 'education' })} )} diff --git a/web/eslint.constants.mjs b/web/eslint.constants.mjs index fb9c67c454..240d3ab06c 100644 --- a/web/eslint.constants.mjs +++ b/web/eslint.constants.mjs @@ -68,20 +68,11 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ ] export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [ - 'app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx', - 'app/components/base/chat/chat-with-history/header/operation.tsx', - 'app/components/base/chat/chat-with-history/sidebar/operation.tsx', - 'app/components/base/chat/chat/citation/popup.tsx', 'app/components/base/chat/chat/citation/progress-tooltip.tsx', 'app/components/base/chat/chat/citation/tooltip.tsx', 'app/components/base/chip/index.tsx', - 'app/components/base/date-and-time-picker/date-picker/index.tsx', - 'app/components/base/date-and-time-picker/time-picker/index.tsx', 'app/components/base/modal/modal.tsx', - 'app/components/base/prompt-editor/plugins/context-block/component.tsx', - 'app/components/base/prompt-editor/plugins/history-block/component.tsx', 'app/components/base/sort/index.tsx', - 'app/components/base/theme-selector.tsx', 'app/components/base/tooltip/index.tsx', ]