diff --git a/.github/workflows/deploy-trigger-dev.yml b/.github/workflows/deploy-agent-dev.yml similarity index 75% rename from .github/workflows/deploy-trigger-dev.yml rename to .github/workflows/deploy-agent-dev.yml index 2d9a904fc5..dff48b5510 100644 --- a/.github/workflows/deploy-trigger-dev.yml +++ b/.github/workflows/deploy-agent-dev.yml @@ -1,4 +1,4 @@ -name: Deploy Trigger Dev +name: Deploy Agent Dev permissions: contents: read @@ -7,7 +7,7 @@ on: workflow_run: workflows: ["Build and Push API & Web"] branches: - - "deploy/trigger-dev" + - "deploy/agent-dev" types: - completed @@ -16,12 +16,12 @@ jobs: runs-on: ubuntu-latest if: | github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_branch == 'deploy/trigger-dev' + github.event.workflow_run.head_branch == 'deploy/agent-dev' steps: - name: Deploy to server uses: appleboy/ssh-action@v0.1.8 with: - host: ${{ secrets.TRIGGER_SSH_HOST }} + host: ${{ secrets.AGENT_DEV_SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | diff --git a/api/core/app/app_config/entities.py b/api/core/app/app_config/entities.py index 307af3747c..13c51529cc 100644 --- a/api/core/app/app_config/entities.py +++ b/api/core/app/app_config/entities.py @@ -1,4 +1,3 @@ -import json from collections.abc import Sequence from enum import StrEnum, auto from typing import Any, Literal @@ -121,7 +120,7 @@ class VariableEntity(BaseModel): allowed_file_types: Sequence[FileType] | None = Field(default_factory=list) allowed_file_extensions: Sequence[str] | None = Field(default_factory=list) allowed_file_upload_methods: Sequence[FileTransferMethod] | None = Field(default_factory=list) - json_schema: str | None = Field(default=None) + json_schema: dict | None = Field(default=None) @field_validator("description", mode="before") @classmethod @@ -135,17 +134,11 @@ class VariableEntity(BaseModel): @field_validator("json_schema") @classmethod - def validate_json_schema(cls, schema: str | None) -> str | None: + def validate_json_schema(cls, schema: dict | None) -> dict | None: if schema is None: return None - try: - json_schema = json.loads(schema) - except json.JSONDecodeError: - raise ValueError(f"invalid json_schema value {schema}") - - try: - Draft7Validator.check_schema(json_schema) + Draft7Validator.check_schema(schema) except SchemaError as e: raise ValueError(f"Invalid JSON schema: {e.message}") return schema diff --git a/api/core/app/apps/advanced_chat/app_config_manager.py b/api/core/app/apps/advanced_chat/app_config_manager.py index e4b308a6f6..c21c494efe 100644 --- a/api/core/app/apps/advanced_chat/app_config_manager.py +++ b/api/core/app/apps/advanced_chat/app_config_manager.py @@ -26,7 +26,6 @@ class AdvancedChatAppConfigManager(BaseAppConfigManager): @classmethod def get_app_config(cls, app_model: App, workflow: Workflow) -> AdvancedChatAppConfig: features_dict = workflow.features_dict - app_mode = AppMode.value_of(app_model.mode) app_config = AdvancedChatAppConfig( tenant_id=app_model.tenant_id, diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py index 4dd95be52d..da1e9f19b6 100644 --- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py +++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py @@ -358,25 +358,6 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): if node_finish_resp: yield node_finish_resp - # For ANSWER nodes, check if we need to send a message_replace event - # Only send if the final output differs from the accumulated task_state.answer - # This happens when variables were updated by variable_assigner during workflow execution - if event.node_type == NodeType.ANSWER and event.outputs: - final_answer = event.outputs.get("answer") - if final_answer is not None and final_answer != self._task_state.answer: - logger.info( - "ANSWER node final output '%s' differs from accumulated answer '%s', sending message_replace event", - final_answer, - self._task_state.answer, - ) - # Update the task state answer - self._task_state.answer = str(final_answer) - # Send message_replace event to update the UI - yield self._message_cycle_manager.message_replace_to_stream_response( - answer=str(final_answer), - reason="variable_update", - ) - def _handle_node_failed_events( self, event: Union[QueueNodeFailedEvent, QueueNodeExceptionEvent], diff --git a/api/core/app/apps/base_app_generator.py b/api/core/app/apps/base_app_generator.py index a6aace168e..e4486e892c 100644 --- a/api/core/app/apps/base_app_generator.py +++ b/api/core/app/apps/base_app_generator.py @@ -1,4 +1,3 @@ -import json from collections.abc import Generator, Mapping, Sequence from typing import TYPE_CHECKING, Any, Union, final @@ -76,12 +75,24 @@ class BaseAppGenerator: user_inputs = {**user_inputs, **files_inputs, **file_list_inputs} # Check if all files are converted to File - if any(filter(lambda v: isinstance(v, dict), user_inputs.values())): - raise ValueError("Invalid input type") - if any( - filter(lambda v: isinstance(v, dict), filter(lambda item: isinstance(item, list), user_inputs.values())) - ): - raise ValueError("Invalid input type") + invalid_dict_keys = [ + k + for k, v in user_inputs.items() + if isinstance(v, dict) + and entity_dictionary[k].type not in {VariableEntityType.FILE, VariableEntityType.JSON_OBJECT} + ] + if invalid_dict_keys: + raise ValueError(f"Invalid input type for {invalid_dict_keys}") + + invalid_list_dict_keys = [ + k + for k, v in user_inputs.items() + if isinstance(v, list) + and any(isinstance(item, dict) for item in v) + and entity_dictionary[k].type != VariableEntityType.FILE_LIST + ] + if invalid_list_dict_keys: + raise ValueError(f"Invalid input type for {invalid_list_dict_keys}") return user_inputs @@ -178,12 +189,8 @@ class BaseAppGenerator: elif value == 0: value = False case VariableEntityType.JSON_OBJECT: - if not isinstance(value, str): - raise ValueError(f"{variable_entity.variable} in input form must be a string") - try: - json.loads(value) - except json.JSONDecodeError: - raise ValueError(f"{variable_entity.variable} in input form must be a valid JSON object") + if not isinstance(value, dict): + raise ValueError(f"{variable_entity.variable} in input form must be a dict") case _: raise AssertionError("this statement should be unreachable.") diff --git a/api/core/workflow/nodes/start/start_node.py b/api/core/workflow/nodes/start/start_node.py index 36fc5078c5..53c1b4ee6b 100644 --- a/api/core/workflow/nodes/start/start_node.py +++ b/api/core/workflow/nodes/start/start_node.py @@ -1,4 +1,3 @@ -import json from typing import Any from jsonschema import Draft7Validator, ValidationError @@ -43,25 +42,22 @@ class StartNode(Node[StartNodeData]): if value is None and variable.required: raise ValueError(f"{key} is required in input form") + # If no value provided, skip further processing for this key + if not value: + continue + + if not isinstance(value, dict): + raise ValueError(f"JSON object for '{key}' must be an object") + + # Overwrite with normalized dict to ensure downstream consistency + node_inputs[key] = value + + # If schema exists, then validate against it schema = variable.json_schema if not schema: continue - if not value: - continue - try: - json_schema = json.loads(schema) - except json.JSONDecodeError as e: - raise ValueError(f"{schema} must be a valid JSON object") - - try: - json_value = json.loads(value) - except json.JSONDecodeError as e: - raise ValueError(f"{value} must be a valid JSON object") - - try: - Draft7Validator(json_schema).validate(json_value) + Draft7Validator(schema).validate(value) except ValidationError as e: raise ValueError(f"JSON object for '{key}' does not match schema: {e.message}") - node_inputs[key] = json_value diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_answer_node.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_answer_node.py deleted file mode 100644 index 205b157542..0000000000 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_answer_node.py +++ /dev/null @@ -1,390 +0,0 @@ -""" -Tests for AdvancedChatAppGenerateTaskPipeline._handle_node_succeeded_event method, -specifically testing the ANSWER node message_replace logic. -""" - -from datetime import datetime -from types import SimpleNamespace -from unittest.mock import MagicMock, Mock, patch - -import pytest - -from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity -from core.app.entities.queue_entities import QueueNodeSucceededEvent -from core.workflow.enums import NodeType -from models import EndUser -from models.model import AppMode - - -class TestAnswerNodeMessageReplace: - """Test cases for ANSWER node message_replace event logic.""" - - @pytest.fixture - def mock_application_generate_entity(self): - """Create a mock application generate entity.""" - entity = Mock(spec=AdvancedChatAppGenerateEntity) - entity.task_id = "test-task-id" - entity.app_id = "test-app-id" - entity.workflow_run_id = "test-workflow-run-id" - # minimal app_config used by pipeline internals - entity.app_config = SimpleNamespace( - tenant_id="test-tenant-id", - app_id="test-app-id", - app_mode=AppMode.ADVANCED_CHAT, - app_model_config_dict={}, - additional_features=None, - sensitive_word_avoidance=None, - ) - entity.query = "test query" - entity.files = [] - entity.extras = {} - entity.trace_manager = None - entity.inputs = {} - entity.invoke_from = "debugger" - return entity - - @pytest.fixture - def mock_workflow(self): - """Create a mock workflow.""" - workflow = Mock() - workflow.id = "test-workflow-id" - workflow.features_dict = {} - return workflow - - @pytest.fixture - def mock_queue_manager(self): - """Create a mock queue manager.""" - manager = Mock() - manager.listen.return_value = [] - manager.graph_runtime_state = None - return manager - - @pytest.fixture - def mock_conversation(self): - """Create a mock conversation.""" - conversation = Mock() - conversation.id = "test-conversation-id" - conversation.mode = "advanced_chat" - return conversation - - @pytest.fixture - def mock_message(self): - """Create a mock message.""" - message = Mock() - message.id = "test-message-id" - message.query = "test query" - message.created_at = Mock() - message.created_at.timestamp.return_value = 1234567890 - return message - - @pytest.fixture - def mock_user(self): - """Create a mock end user.""" - user = MagicMock(spec=EndUser) - user.id = "test-user-id" - user.session_id = "test-session-id" - return user - - @pytest.fixture - def mock_draft_var_saver_factory(self): - """Create a mock draft variable saver factory.""" - return Mock() - - @pytest.fixture - def pipeline( - self, - mock_application_generate_entity, - mock_workflow, - mock_queue_manager, - mock_conversation, - mock_message, - mock_user, - mock_draft_var_saver_factory, - ): - """Create an AdvancedChatAppGenerateTaskPipeline instance with mocked dependencies.""" - from core.app.apps.advanced_chat.generate_task_pipeline import AdvancedChatAppGenerateTaskPipeline - - with patch("core.app.apps.advanced_chat.generate_task_pipeline.db"): - pipeline = AdvancedChatAppGenerateTaskPipeline( - application_generate_entity=mock_application_generate_entity, - workflow=mock_workflow, - queue_manager=mock_queue_manager, - conversation=mock_conversation, - message=mock_message, - user=mock_user, - stream=True, - dialogue_count=1, - draft_var_saver_factory=mock_draft_var_saver_factory, - ) - # Initialize workflow run id to avoid validation errors - pipeline._workflow_run_id = "test-workflow-run-id" - # Mock the message cycle manager methods we need to track - pipeline._message_cycle_manager.message_replace_to_stream_response = Mock() - return pipeline - - def test_answer_node_with_different_output_sends_message_replace(self, pipeline, mock_application_generate_entity): - """ - Test that when an ANSWER node's final output differs from accumulated answer, - a message_replace event is sent. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "initial answer" - - # Create ANSWER node succeeded event with different final output - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={"answer": "updated final answer"}, - ) - - # Mock the workflow response converter to avoid extra processing - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - responses = list(pipeline._handle_node_succeeded_event(event)) - - # Assert - assert pipeline._task_state.answer == "updated final answer" - # Verify message_replace was called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_called_once_with( - answer="updated final answer", reason="variable_update" - ) - - def test_answer_node_with_same_output_does_not_send_message_replace(self, pipeline): - """ - Test that when an ANSWER node's final output is the same as accumulated answer, - no message_replace event is sent. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "same answer" - - # Create ANSWER node succeeded event with same output - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={"answer": "same answer"}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "same answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_answer_node_with_none_output_does_not_send_message_replace(self, pipeline): - """ - Test that when an ANSWER node's output is None or missing 'answer' key, - no message_replace event is sent. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "existing answer" - - # Create ANSWER node succeeded event with None output - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={"answer": None}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "existing answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_answer_node_with_empty_outputs_does_not_send_message_replace(self, pipeline): - """ - Test that when an ANSWER node has empty outputs dict, - no message_replace event is sent. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "existing answer" - - # Create ANSWER node succeeded event with empty outputs - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "existing answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_answer_node_with_no_answer_key_in_outputs(self, pipeline): - """ - Test that when an ANSWER node's outputs don't contain 'answer' key, - no message_replace event is sent. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "existing answer" - - # Create ANSWER node succeeded event without 'answer' key in outputs - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={"other_key": "some value"}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "existing answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_non_answer_node_does_not_send_message_replace(self, pipeline): - """ - Test that non-ANSWER nodes (e.g., LLM, END) don't trigger message_replace events. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "existing answer" - - # Test with LLM node - llm_event = QueueNodeSucceededEvent( - node_execution_id="test-llm-execution-id", - node_id="test-llm-node", - node_type=NodeType.LLM, - start_at=datetime.now(), - outputs={"answer": "different answer"}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(llm_event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "existing answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_end_node_does_not_send_message_replace(self, pipeline): - """ - Test that END nodes don't trigger message_replace events even with 'answer' output. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "existing answer" - - # Create END node succeeded event with answer output - event = QueueNodeSucceededEvent( - node_execution_id="test-end-execution-id", - node_id="test-end-node", - node_type=NodeType.END, - start_at=datetime.now(), - outputs={"answer": "different answer"}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should remain unchanged - assert pipeline._task_state.answer == "existing answer" - # Verify message_replace was NOT called - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called() - - def test_answer_node_with_numeric_output_converts_to_string(self, pipeline): - """ - Test that when an ANSWER node's final output is numeric, - it gets converted to string properly. - """ - # Arrange: Set initial accumulated answer - pipeline._task_state.answer = "text answer" - - # Create ANSWER node succeeded event with numeric output - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={"answer": 12345}, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: answer should be converted to string - assert pipeline._task_state.answer == "12345" - # Verify message_replace was called with string - pipeline._message_cycle_manager.message_replace_to_stream_response.assert_called_once_with( - answer="12345", reason="variable_update" - ) - - def test_answer_node_files_are_recorded(self, pipeline): - """ - Test that ANSWER nodes properly record files from outputs. - """ - # Arrange - pipeline._task_state.answer = "existing answer" - - # Create ANSWER node succeeded event with files - event = QueueNodeSucceededEvent( - node_execution_id="test-node-execution-id", - node_id="test-answer-node", - node_type=NodeType.ANSWER, - start_at=datetime.now(), - outputs={ - "answer": "same answer", - "files": [ - {"type": "image", "transfer_method": "remote_url", "remote_url": "http://example.com/img.png"} - ], - }, - ) - - # Mock the workflow response converter - pipeline._workflow_response_converter.fetch_files_from_node_outputs = Mock(return_value=event.outputs["files"]) - pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None) - pipeline._save_output_for_event = Mock() - - # Act - list(pipeline._handle_node_succeeded_event(event)) - - # Assert: files should be recorded - assert len(pipeline._recorded_files) == 1 - assert pipeline._recorded_files[0] == event.outputs["files"][0] diff --git a/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py b/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py index 539e72edb5..16b432bae6 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py @@ -58,6 +58,8 @@ def test_json_object_valid_schema(): } ) + schema = json.loads(schema) + variables = [ VariableEntity( variable="profile", @@ -68,7 +70,7 @@ def test_json_object_valid_schema(): ) ] - user_inputs = {"profile": json.dumps({"age": 20, "name": "Tom"})} + user_inputs = {"profile": {"age": 20, "name": "Tom"}} node = make_start_node(user_inputs, variables) result = node._run() @@ -87,6 +89,8 @@ def test_json_object_invalid_json_string(): "required": ["age", "name"], } ) + + schema = json.loads(schema) variables = [ VariableEntity( variable="profile", @@ -97,12 +101,12 @@ def test_json_object_invalid_json_string(): ) ] - # Missing closing brace makes this invalid JSON + # Providing a string instead of an object should raise a type error user_inputs = {"profile": '{"age": 20, "name": "Tom"'} node = make_start_node(user_inputs, variables) - with pytest.raises(ValueError, match='{"age": 20, "name": "Tom" must be a valid JSON object'): + with pytest.raises(ValueError, match="JSON object for 'profile' must be an object"): node._run() @@ -118,6 +122,8 @@ def test_json_object_does_not_match_schema(): } ) + schema = json.loads(schema) + variables = [ VariableEntity( variable="profile", @@ -129,7 +135,7 @@ def test_json_object_does_not_match_schema(): ] # age is a string, which violates the schema (expects number) - user_inputs = {"profile": json.dumps({"age": "twenty", "name": "Tom"})} + user_inputs = {"profile": {"age": "twenty", "name": "Tom"}} node = make_start_node(user_inputs, variables) @@ -149,6 +155,8 @@ def test_json_object_missing_required_schema_field(): } ) + schema = json.loads(schema) + variables = [ VariableEntity( variable="profile", @@ -160,7 +168,7 @@ def test_json_object_missing_required_schema_field(): ] # Missing required field "name" - user_inputs = {"profile": json.dumps({"age": 20})} + user_inputs = {"profile": {"age": 20}} node = make_start_node(user_inputs, variables) diff --git a/web/app/(commonLayout)/plugins/page.tsx b/web/app/(commonLayout)/plugins/page.tsx index 81bda3a8a3..f366200cf9 100644 --- a/web/app/(commonLayout)/plugins/page.tsx +++ b/web/app/(commonLayout)/plugins/page.tsx @@ -2,11 +2,11 @@ import Marketplace from '@/app/components/plugins/marketplace' import PluginPage from '@/app/components/plugins/plugin-page' import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel' -const PluginList = async () => { +const PluginList = () => { return ( } - marketplace={} + marketplace={} /> ) } diff --git a/web/app/components/app-sidebar/app-info.tsx b/web/app/components/app-sidebar/app-info.tsx index cbd1640295..255feaccdf 100644 --- a/web/app/components/app-sidebar/app-info.tsx +++ b/web/app/components/app-sidebar/app-info.tsx @@ -26,6 +26,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' +import { useInvalidateAppList } from '@/service/use-apps' import { fetchWorkflowDraft } from '@/service/workflow' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' @@ -66,6 +67,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx const { onPlanInfoChanged } = useProviderContext() const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(state => state.setAppDetail) + const invalidateAppList = useInvalidateAppList() const [open, setOpen] = useState(openState) const [showEditModal, setShowEditModal] = useState(false) const [showDuplicateModal, setShowDuplicateModal] = useState(false) @@ -191,6 +193,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx try { await deleteApp(appDetail.id) notify({ type: 'success', message: t('appDeleted', { ns: 'app' }) }) + invalidateAppList() onPlanInfoChanged() setAppDetail() replace('/apps') @@ -202,7 +205,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx }) } setShowConfirmDelete(false) - }, [appDetail, notify, onPlanInfoChanged, replace, setAppDetail, t]) + }, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, setAppDetail, t]) const { isCurrentWorkspaceEditor } = useAppContext() diff --git a/web/app/components/app/configuration/config-var/config-modal/index.tsx b/web/app/components/app/configuration/config-var/config-modal/index.tsx index 782744882e..5ffa87375c 100644 --- a/web/app/components/app/configuration/config-var/config-modal/index.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/index.tsx @@ -83,7 +83,7 @@ const ConfigModal: FC = ({ if (!isJsonObject || !tempPayload.json_schema) return '' try { - return JSON.stringify(JSON.parse(tempPayload.json_schema), null, 2) + return tempPayload.json_schema } catch { return '' diff --git a/web/app/components/apps/list.spec.tsx b/web/app/components/apps/list.spec.tsx index e5854f68b4..07c30cd588 100644 --- a/web/app/components/apps/list.spec.tsx +++ b/web/app/components/apps/list.spec.tsx @@ -10,6 +10,7 @@ const mockReplace = vi.fn() const mockRouter = { replace: mockReplace } vi.mock('next/navigation', () => ({ useRouter: () => mockRouter, + useSearchParams: () => new URLSearchParams(''), })) // Mock app context diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 290a73fc7c..e5c9954626 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -12,6 +12,7 @@ import { useDebounceFn } from 'ahooks' import dynamic from 'next/dynamic' import { useRouter, + useSearchParams, } from 'next/navigation' import { parseAsString, useQueryState } from 'nuqs' import { useCallback, useEffect, useRef, useState } from 'react' @@ -36,6 +37,16 @@ import useAppsQueryState from './hooks/use-apps-query-state' import { useDSLDragDrop } from './hooks/use-dsl-drag-drop' import NewAppCard from './new-app-card' +// Define valid tabs at module scope to avoid re-creation on each render and stale closures +const validTabs = new Set([ + 'all', + AppModeEnum.WORKFLOW, + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.CHAT, + AppModeEnum.AGENT_CHAT, + AppModeEnum.COMPLETION, +]) + const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), { ssr: false, }) @@ -47,12 +58,41 @@ const List = () => { const { t } = useTranslation() const { systemFeatures } = useGlobalPublicStore() const router = useRouter() + const searchParams = useSearchParams() const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext() const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const [activeTab, setActiveTab] = useQueryState( 'category', parseAsString.withDefault('all').withOptions({ history: 'push' }), ) + + // valid tabs for apps list; anything else should fallback to 'all' + + // 1) Normalize legacy/incorrect query params like ?mode=discover -> ?category=all + useEffect(() => { + // avoid running on server + if (typeof window === 'undefined') + return + const mode = searchParams.get('mode') + if (!mode) + return + const url = new URL(window.location.href) + url.searchParams.delete('mode') + if (validTabs.has(mode)) { + // migrate to category key + url.searchParams.set('category', mode) + } + else { + url.searchParams.set('category', 'all') + } + router.replace(url.pathname + url.search) + }, [router, searchParams]) + + // 2) If category has an invalid value (e.g., 'discover'), reset to 'all' + useEffect(() => { + if (!validTabs.has(activeTab)) + setActiveTab('all') + }, [activeTab, setActiveTab]) const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState() const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe) const [tagFilterValue, setTagFilterValue] = useState(tagIDs) diff --git a/web/app/components/base/chat/chat/utils.ts b/web/app/components/base/chat/chat/utils.ts index ab150f3e61..a64c8162dc 100644 --- a/web/app/components/base/chat/chat/utils.ts +++ b/web/app/components/base/chat/chat/utils.ts @@ -37,7 +37,7 @@ export const getProcessedInputs = (inputs: Record, inputsForm: Inpu return } - if (!inputValue) + if (inputValue == null) return if (item.type === InputVarType.singleFile) { @@ -52,6 +52,20 @@ export const getProcessedInputs = (inputs: Record, inputsForm: Inpu else processedInputs[item.variable] = getProcessedFiles(inputValue) } + else if (item.type === InputVarType.jsonObject) { + // Prefer sending an object if the user entered valid JSON; otherwise keep the raw string. + try { + const v = typeof inputValue === 'string' ? JSON.parse(inputValue) : inputValue + if (v && typeof v === 'object' && !Array.isArray(v)) + processedInputs[item.variable] = v + else + processedInputs[item.variable] = inputValue + } + catch { + // keep original string; backend will parse/validate + processedInputs[item.variable] = inputValue + } + } }) return processedInputs diff --git a/web/app/components/datasets/create/embedding-process/index.spec.tsx b/web/app/components/datasets/create/embedding-process/index.spec.tsx new file mode 100644 index 0000000000..8d2bae03cd --- /dev/null +++ b/web/app/components/datasets/create/embedding-process/index.spec.tsx @@ -0,0 +1,1562 @@ +import type { FullDocumentDetail, IndexingStatusResponse, ProcessRuleResponse } from '@/models/datasets' +import { act, render, renderHook, screen } from '@testing-library/react' +import { DataSourceType, ProcessMode } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' +import IndexingProgressItem from './indexing-progress-item' +import RuleDetail from './rule-detail' +import UpgradeBanner from './upgrade-banner' +import { useIndexingStatusPolling } from './use-indexing-status-polling' +import { + createDocumentLookup, + getFileType, + getSourcePercent, + isLegacyDataSourceInfo, + isSourceEmbedding, +} from './utils' + +// ============================================================================= +// Mock External Dependencies +// ============================================================================= + +// Mock next/navigation +const mockPush = vi.fn() +const mockRouter = { push: mockPush } +vi.mock('next/navigation', () => ({ + useRouter: () => mockRouter, +})) + +// Mock next/image +vi.mock('next/image', () => ({ + default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => ( + // eslint-disable-next-line next/no-img-element + {alt} + ), +})) + +// Mock API service +const mockFetchIndexingStatusBatch = vi.fn() +vi.mock('@/service/datasets', () => ({ + fetchIndexingStatusBatch: (params: { datasetId: string, batchId: string }) => + mockFetchIndexingStatusBatch(params), +})) + +// Mock service hooks +const mockProcessRuleData: ProcessRuleResponse | undefined = undefined +vi.mock('@/service/knowledge/use-dataset', () => ({ + useProcessRule: vi.fn(() => ({ data: mockProcessRuleData })), +})) + +const mockInvalidDocumentList = vi.fn() +vi.mock('@/service/knowledge/use-document', () => ({ + useInvalidDocumentList: () => mockInvalidDocumentList, +})) + +// Mock useDatasetApiAccessUrl hook +vi.mock('@/hooks/use-api-access-url', () => ({ + useDatasetApiAccessUrl: () => 'https://api.example.com/docs', +})) + +// Mock provider context +let mockEnableBilling = false +let mockPlanType = 'sandbox' +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + enableBilling: mockEnableBilling, + plan: { type: mockPlanType }, + }), +})) + +// Mock icons +vi.mock('../icons', () => ({ + indexMethodIcon: { + economical: '/icons/economical.svg', + high_quality: '/icons/high-quality.svg', + }, + retrievalIcon: { + fullText: '/icons/full-text.svg', + hybrid: '/icons/hybrid.svg', + vector: '/icons/vector.svg', + }, +})) + +// Mock IndexingType enum from step-two +vi.mock('../step-two', () => ({ + IndexingType: { + QUALIFIED: 'high_quality', + ECONOMICAL: 'economy', + }, +})) + +// ============================================================================= +// Factory Functions for Test Data +// ============================================================================= + +/** + * Create a mock IndexingStatusResponse + */ +const createMockIndexingStatus = ( + overrides: Partial = {}, +): IndexingStatusResponse => ({ + id: 'doc-1', + indexing_status: 'completed', + processing_started_at: Date.now(), + parsing_completed_at: Date.now(), + cleaning_completed_at: Date.now(), + splitting_completed_at: Date.now(), + completed_at: Date.now(), + paused_at: null, + error: null, + stopped_at: null, + completed_segments: 10, + total_segments: 10, + ...overrides, +}) + +/** + * Create a mock FullDocumentDetail + */ +const createMockDocument = ( + overrides: Partial = {}, +): FullDocumentDetail => ({ + id: 'doc-1', + name: 'test-document.txt', + data_source_type: DataSourceType.FILE, + data_source_info: { + upload_file: { + id: 'file-1', + name: 'test-document.txt', + extension: 'txt', + mime_type: 'text/plain', + size: 1024, + created_by: 'user-1', + created_at: Date.now(), + }, + }, + batch: 'batch-1', + created_api_request_id: 'req-1', + processing_started_at: Date.now(), + parsing_completed_at: Date.now(), + cleaning_completed_at: Date.now(), + splitting_completed_at: Date.now(), + tokens: 100, + indexing_latency: 5000, + completed_at: Date.now(), + paused_by: '', + paused_at: 0, + stopped_at: 0, + indexing_status: 'completed', + disabled_at: 0, + ...overrides, +} as FullDocumentDetail) + +/** + * Create a mock ProcessRuleResponse + */ +const createMockProcessRule = ( + overrides: Partial = {}, +): ProcessRuleResponse => ({ + mode: ProcessMode.general, + rules: { + segmentation: { + separator: '\n', + max_tokens: 500, + chunk_overlap: 50, + }, + pre_processing_rules: [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, + ], + }, + ...overrides, +} as ProcessRuleResponse) + +// ============================================================================= +// Utils Tests +// ============================================================================= + +describe('utils', () => { + // Test utility functions for document handling + + describe('isLegacyDataSourceInfo', () => { + it('should return true for legacy data source with upload_file object', () => { + // Arrange + const info = { + upload_file: { id: 'file-1', name: 'test.txt' }, + } + + // Act & Assert + expect(isLegacyDataSourceInfo(info as Parameters[0])).toBe(true) + }) + + it('should return false for null', () => { + expect(isLegacyDataSourceInfo(null as unknown as Parameters[0])).toBe(false) + }) + + it('should return false for undefined', () => { + expect(isLegacyDataSourceInfo(undefined as unknown as Parameters[0])).toBe(false) + }) + + it('should return false when upload_file is not an object', () => { + // Arrange + const info = { upload_file: 'string-value' } + + // Act & Assert + expect(isLegacyDataSourceInfo(info as unknown as Parameters[0])).toBe(false) + }) + }) + + describe('isSourceEmbedding', () => { + it.each([ + ['indexing', true], + ['splitting', true], + ['parsing', true], + ['cleaning', true], + ['waiting', true], + ['completed', false], + ['error', false], + ['paused', false], + ])('should return %s for status "%s"', (status, expected) => { + // Arrange + const detail = createMockIndexingStatus({ indexing_status: status as IndexingStatusResponse['indexing_status'] }) + + // Act & Assert + expect(isSourceEmbedding(detail)).toBe(expected) + }) + }) + + describe('getSourcePercent', () => { + it('should return 0 when total_segments is 0', () => { + // Arrange + const detail = createMockIndexingStatus({ + completed_segments: 0, + total_segments: 0, + }) + + // Act & Assert + expect(getSourcePercent(detail)).toBe(0) + }) + + it('should calculate correct percentage', () => { + // Arrange + const detail = createMockIndexingStatus({ + completed_segments: 5, + total_segments: 10, + }) + + // Act & Assert + expect(getSourcePercent(detail)).toBe(50) + }) + + it('should cap percentage at 100', () => { + // Arrange + const detail = createMockIndexingStatus({ + completed_segments: 15, + total_segments: 10, + }) + + // Act & Assert + expect(getSourcePercent(detail)).toBe(100) + }) + + it('should handle undefined values', () => { + // Arrange + const detail = { indexing_status: 'indexing' } as IndexingStatusResponse + + // Act & Assert + expect(getSourcePercent(detail)).toBe(0) + }) + + it('should round to nearest integer', () => { + // Arrange + const detail = createMockIndexingStatus({ + completed_segments: 1, + total_segments: 3, + }) + + // Act & Assert + expect(getSourcePercent(detail)).toBe(33) + }) + }) + + describe('getFileType', () => { + it('should extract extension from filename', () => { + expect(getFileType('document.pdf')).toBe('pdf') + expect(getFileType('file.name.txt')).toBe('txt') + expect(getFileType('archive.tar.gz')).toBe('gz') + }) + + it('should return "txt" for undefined', () => { + expect(getFileType(undefined)).toBe('txt') + }) + + it('should return filename without extension', () => { + expect(getFileType('filename')).toBe('filename') + }) + }) + + describe('createDocumentLookup', () => { + it('should create lookup functions for documents', () => { + // Arrange + const documents = [ + createMockDocument({ id: 'doc-1', name: 'file1.txt' }), + createMockDocument({ id: 'doc-2', name: 'file2.pdf', data_source_type: DataSourceType.NOTION }), + ] + + // Act + const lookup = createDocumentLookup(documents) + + // Assert + expect(lookup.getName('doc-1')).toBe('file1.txt') + expect(lookup.getName('doc-2')).toBe('file2.pdf') + expect(lookup.getName('non-existent')).toBeUndefined() + }) + + it('should return source type correctly', () => { + // Arrange + const documents = [ + createMockDocument({ id: 'doc-1', data_source_type: DataSourceType.FILE }), + createMockDocument({ id: 'doc-2', data_source_type: DataSourceType.NOTION }), + ] + const lookup = createDocumentLookup(documents) + + // Assert + expect(lookup.getSourceType('doc-1')).toBe(DataSourceType.FILE) + expect(lookup.getSourceType('doc-2')).toBe(DataSourceType.NOTION) + }) + + it('should return notion icon for legacy data source', () => { + // Arrange + const documents = [ + createMockDocument({ + id: 'doc-1', + data_source_info: { + upload_file: { id: 'f1' }, + notion_page_icon: '📄', + } as FullDocumentDetail['data_source_info'], + }), + ] + const lookup = createDocumentLookup(documents) + + // Assert + expect(lookup.getNotionIcon('doc-1')).toBe('📄') + }) + + it('should return undefined for non-legacy notion icon', () => { + // Arrange + const documents = [ + createMockDocument({ + id: 'doc-1', + data_source_info: { some_other_field: 'value' } as unknown as FullDocumentDetail['data_source_info'], + }), + ] + const lookup = createDocumentLookup(documents) + + // Assert + expect(lookup.getNotionIcon('doc-1')).toBeUndefined() + }) + + it('should memoize lookups with Map for performance', () => { + // Arrange + const documents = Array.from({ length: 1000 }, (_, i) => + createMockDocument({ id: `doc-${i}`, name: `file${i}.txt` })) + + // Act + const lookup = createDocumentLookup(documents) + const startTime = performance.now() + for (let i = 0; i < 1000; i++) + lookup.getName(`doc-${i}`) + + const duration = performance.now() - startTime + + // Assert - should be very fast due to Map lookup + expect(duration).toBeLessThan(50) + }) + }) +}) + +// ============================================================================= +// useIndexingStatusPolling Hook Tests +// ============================================================================= + +describe('useIndexingStatusPolling', () => { + // Test the polling hook for indexing status + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should fetch status on mount', async () => { + // Arrange + const mockStatus = [createMockIndexingStatus({ indexing_status: 'completed' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + const { result } = renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(mockFetchIndexingStatusBatch).toHaveBeenCalledWith({ + datasetId: 'ds-1', + batchId: 'batch-1', + }) + expect(result.current.statusList).toEqual(mockStatus) + }) + + it('should stop polling when all statuses are completed', async () => { + // Arrange + const mockStatus = [createMockIndexingStatus({ indexing_status: 'completed' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert - should only be called once since status is completed + expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(1) + }) + + it('should continue polling when status is indexing', async () => { + // Arrange + const indexingStatus = [createMockIndexingStatus({ indexing_status: 'indexing' })] + const completedStatus = [createMockIndexingStatus({ indexing_status: 'completed' })] + + mockFetchIndexingStatusBatch + .mockResolvedValueOnce({ data: indexingStatus }) + .mockResolvedValueOnce({ data: completedStatus }) + + // Act + renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + // First poll + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Advance timer for next poll (2500ms) + await act(async () => { + await vi.advanceTimersByTimeAsync(2500) + }) + + // Assert + expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(2) + }) + + it('should stop polling when status is error', async () => { + // Arrange + const mockStatus = [createMockIndexingStatus({ indexing_status: 'error', error: 'Some error' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + const { result } = renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(result.current.isEmbeddingCompleted).toBe(true) + expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(1) + }) + + it('should stop polling when status is paused', async () => { + // Arrange + const mockStatus = [createMockIndexingStatus({ indexing_status: 'paused' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + const { result } = renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(result.current.isEmbeddingCompleted).toBe(true) + }) + + it('should continue polling on API error', async () => { + // Arrange + mockFetchIndexingStatusBatch + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ data: [createMockIndexingStatus({ indexing_status: 'completed' })] }) + + // Act + renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(2500) + }) + + // Assert - should retry after error + expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(2) + }) + + it('should return correct isEmbedding state', async () => { + // Arrange + const mockStatus = [createMockIndexingStatus({ indexing_status: 'indexing' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + const { result } = renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(result.current.isEmbedding).toBe(true) + expect(result.current.isEmbeddingCompleted).toBe(false) + }) + + it('should cleanup timeout on unmount', async () => { + // Arrange + const mockStatus = [createMockIndexingStatus({ indexing_status: 'indexing' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + const { unmount } = renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + const callCountBeforeUnmount = mockFetchIndexingStatusBatch.mock.calls.length + + unmount() + + // Advance timers - should not trigger more calls after unmount + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + + // Assert - no additional calls after unmount + expect(mockFetchIndexingStatusBatch).toHaveBeenCalledTimes(callCountBeforeUnmount) + }) + + it('should handle multiple documents with mixed statuses', async () => { + // Arrange + const mockStatus = [ + createMockIndexingStatus({ id: 'doc-1', indexing_status: 'completed' }), + createMockIndexingStatus({ id: 'doc-2', indexing_status: 'indexing' }), + ] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + const { result } = renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(result.current.isEmbedding).toBe(true) + expect(result.current.isEmbeddingCompleted).toBe(false) + expect(result.current.statusList).toHaveLength(2) + }) + + it('should return empty statusList initially', () => { + // Arrange & Act + const { result } = renderHook(() => + useIndexingStatusPolling({ datasetId: 'ds-1', batchId: 'batch-1' }), + ) + + // Assert + expect(result.current.statusList).toEqual([]) + expect(result.current.isEmbedding).toBe(false) + expect(result.current.isEmbeddingCompleted).toBe(false) + }) +}) + +// ============================================================================= +// UpgradeBanner Component Tests +// ============================================================================= + +describe('UpgradeBanner', () => { + // Test the upgrade banner component + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render upgrade message', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i)).toBeInTheDocument() + }) + + it('should render ZapFast icon', () => { + // Arrange & Act + const { container } = render() + + // Assert + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('should render UpgradeBtn component', () => { + // Arrange & Act + render() + + // Assert - UpgradeBtn should be rendered + const upgradeContainer = screen.getByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i).parentElement + expect(upgradeContainer).toBeInTheDocument() + }) +}) + +// ============================================================================= +// IndexingProgressItem Component Tests +// ============================================================================= + +describe('IndexingProgressItem', () => { + // Test the progress item component for individual documents + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render document name', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render() + + // Assert + expect(screen.getByText('test-document.txt')).toBeInTheDocument() + }) + + it('should render progress percentage when embedding', () => { + // Arrange + const detail = createMockIndexingStatus({ + indexing_status: 'indexing', + completed_segments: 5, + total_segments: 10, + }) + + // Act + render() + + // Assert + expect(screen.getByText('50%')).toBeInTheDocument() + }) + + it('should not render progress percentage when completed', () => { + // Arrange + const detail = createMockIndexingStatus({ indexing_status: 'completed' }) + + // Act + render() + + // Assert + expect(screen.queryByText('%')).not.toBeInTheDocument() + }) + }) + + describe('Status Icons', () => { + it('should render success icon for completed status', () => { + // Arrange + const detail = createMockIndexingStatus({ indexing_status: 'completed' }) + + // Act + const { container } = render() + + // Assert + expect(container.querySelector('.text-text-success')).toBeInTheDocument() + }) + + it('should render error icon for error status', () => { + // Arrange + const detail = createMockIndexingStatus({ + indexing_status: 'error', + error: 'Processing failed', + }) + + // Act + const { container } = render() + + // Assert + expect(container.querySelector('.text-text-destructive')).toBeInTheDocument() + }) + + it('should not render status icon for indexing status', () => { + // Arrange + const detail = createMockIndexingStatus({ indexing_status: 'indexing' }) + + // Act + const { container } = render() + + // Assert + expect(container.querySelector('.text-text-success')).not.toBeInTheDocument() + expect(container.querySelector('.text-text-destructive')).not.toBeInTheDocument() + }) + }) + + describe('Source Type Icons', () => { + it('should render file icon for FILE source type', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render( + , + ) + + // Assert - DocumentFileIcon should be rendered + expect(screen.getByText('document.pdf')).toBeInTheDocument() + }) + + // DocumentFileIcon branch coverage: different file extensions + describe('DocumentFileIcon file extensions', () => { + it.each([ + ['document.pdf', 'pdf'], + ['data.json', 'json'], + ['page.html', 'html'], + ['readme.txt', 'txt'], + ['notes.markdown', 'markdown'], + ['readme.md', 'md'], + ['spreadsheet.xlsx', 'xlsx'], + ['legacy.xls', 'xls'], + ['data.csv', 'csv'], + ['letter.doc', 'doc'], + ['report.docx', 'docx'], + ])('should render file icon for %s (%s extension)', (filename) => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render( + , + ) + + // Assert + expect(screen.getByText(filename)).toBeInTheDocument() + }) + + it('should handle unknown file extension with default icon', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render( + , + ) + + // Assert - should still render with default document icon + expect(screen.getByText('archive.zip')).toBeInTheDocument() + }) + + it('should handle uppercase extension', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('REPORT.PDF')).toBeInTheDocument() + }) + + it('should handle mixed case extension', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('Document.Docx')).toBeInTheDocument() + }) + + it('should handle filename with multiple dots', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render( + , + ) + + // Assert - should extract "pdf" as extension + expect(screen.getByText('my.file.name.pdf')).toBeInTheDocument() + }) + + it('should handle filename without extension', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render( + , + ) + + // Assert - should use filename itself as fallback + expect(screen.getByText('noextension')).toBeInTheDocument() + }) + }) + + it('should render notion icon for NOTION source type', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('Notion Page')).toBeInTheDocument() + }) + }) + + describe('Progress Bar', () => { + it('should render progress bar when embedding', () => { + // Arrange + const detail = createMockIndexingStatus({ + indexing_status: 'indexing', + completed_segments: 30, + total_segments: 100, + }) + + // Act + const { container } = render() + + // Assert + const progressBar = container.querySelector('[style*="width: 30%"]') + expect(progressBar).toBeInTheDocument() + }) + + it('should not render progress bar when completed', () => { + // Arrange + const detail = createMockIndexingStatus({ indexing_status: 'completed' }) + + // Act + const { container } = render() + + // Assert + const progressBar = container.querySelector('.bg-components-progress-bar-progress') + expect(progressBar).not.toBeInTheDocument() + }) + + it('should apply error styling for error status', () => { + // Arrange + const detail = createMockIndexingStatus({ indexing_status: 'error' }) + + // Act + const { container } = render() + + // Assert + expect(container.querySelector('.bg-state-destructive-hover-alt')).toBeInTheDocument() + }) + }) + + describe('Billing', () => { + it('should render PriorityLabel when enableBilling is true', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render() + + // Assert - PriorityLabel component should be in the DOM + const container = screen.getByText('test.txt').parentElement + expect(container).toBeInTheDocument() + }) + + it('should not render PriorityLabel when enableBilling is false', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render() + + // Assert + expect(screen.getByText('test.txt')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle undefined name', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render() + + // Assert - should not crash + expect(document.body).toBeInTheDocument() + }) + + it('should handle undefined sourceType', () => { + // Arrange + const detail = createMockIndexingStatus() + + // Act + render() + + // Assert - should render without source icon + expect(screen.getByText('test.txt')).toBeInTheDocument() + }) + }) +}) + +// ============================================================================= +// RuleDetail Component Tests +// ============================================================================= + +describe('RuleDetail', () => { + // Test the rule detail component for process configuration display + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/datasetDocuments\.embedding\.mode/i)).toBeInTheDocument() + }) + + it('should render all field labels', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/datasetDocuments\.embedding\.mode/i)).toBeInTheDocument() + expect(screen.getByText(/datasetDocuments\.embedding\.segmentLength/i)).toBeInTheDocument() + expect(screen.getByText(/datasetDocuments\.embedding\.textCleaning/i)).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepTwo\.indexMode/i)).toBeInTheDocument() + expect(screen.getByText(/datasetSettings\.form\.retrievalSetting\.title/i)).toBeInTheDocument() + }) + }) + + describe('Mode Display', () => { + it('should show "-" when sourceData is undefined', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getAllByText('-')).toHaveLength(3) // mode, segmentLength, textCleaning + }) + + it('should show "custom" for general process mode', () => { + // Arrange + const sourceData = createMockProcessRule({ mode: ProcessMode.general }) + + // Act + render() + + // Assert + expect(screen.getByText(/datasetDocuments\.embedding\.custom/i)).toBeInTheDocument() + }) + + it('should show hierarchical mode with paragraph parent', () => { + // Arrange + const sourceData = createMockProcessRule({ + mode: ProcessMode.parentChild, + rules: { + parent_mode: 'paragraph', + segmentation: { max_tokens: 500 }, + }, + } as Partial) + + // Act + render() + + // Assert + expect(screen.getByText(/datasetDocuments\.embedding\.hierarchical/i)).toBeInTheDocument() + }) + }) + + describe('Segment Length Display', () => { + it('should show max_tokens for general mode', () => { + // Arrange + const sourceData = createMockProcessRule({ + mode: ProcessMode.general, + rules: { + segmentation: { max_tokens: 500 }, + }, + } as Partial) + + // Act + render() + + // Assert + expect(screen.getByText('500')).toBeInTheDocument() + }) + + it('should show parent and child tokens for hierarchical mode', () => { + // Arrange + const sourceData = createMockProcessRule({ + mode: ProcessMode.parentChild, + rules: { + segmentation: { max_tokens: 1000 }, + subchunk_segmentation: { max_tokens: 200 }, + }, + } as Partial) + + // Act + render() + + // Assert + expect(screen.getByText(/1000/)).toBeInTheDocument() + expect(screen.getByText(/200/)).toBeInTheDocument() + }) + }) + + describe('Text Cleaning Rules', () => { + it('should show enabled rule names', () => { + // Arrange + const sourceData = createMockProcessRule({ + mode: ProcessMode.general, + rules: { + pre_processing_rules: [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: true }, + { id: 'remove_stopwords', enabled: false }, + ], + }, + } as Partial) + + // Act + render() + + // Assert + expect(screen.getByText(/removeExtraSpaces/i)).toBeInTheDocument() + expect(screen.getByText(/removeUrlEmails/i)).toBeInTheDocument() + }) + + it('should show "-" when no rules are enabled', () => { + // Arrange + const sourceData = createMockProcessRule({ + mode: ProcessMode.general, + rules: { + pre_processing_rules: [ + { id: 'remove_extra_spaces', enabled: false }, + ], + }, + } as Partial) + + // Act + render() + + // Assert - textCleaning should show "-" + const dashElements = screen.getAllByText('-') + expect(dashElements.length).toBeGreaterThan(0) + }) + }) + + describe('Indexing Type', () => { + it('should show qualified for high_quality indexing', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/datasetCreation\.stepTwo\.qualified/i)).toBeInTheDocument() + }) + + it('should show economical for economy indexing', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/datasetCreation\.stepTwo\.economical/i)).toBeInTheDocument() + }) + + it('should render correct icon for indexing type', () => { + // Arrange & Act + render() + + // Assert + const images = screen.getAllByTestId('next-image') + expect(images.length).toBeGreaterThan(0) + }) + }) + + describe('Retrieval Method', () => { + it('should show semantic search by default', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/dataset\.retrieval\.semantic_search\.title/i)).toBeInTheDocument() + }) + + it('should show keyword search for economical indexing', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/dataset\.retrieval\.keyword_search\.title/i)).toBeInTheDocument() + }) + + it.each([ + [RETRIEVE_METHOD.fullText, 'full_text_search'], + [RETRIEVE_METHOD.hybrid, 'hybrid_search'], + [RETRIEVE_METHOD.semantic, 'semantic_search'], + ])('should show correct label for %s retrieval method', (method, expectedKey) => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(new RegExp(`dataset\\.retrieval\\.${expectedKey}\\.title`, 'i'))).toBeInTheDocument() + }) + }) +}) + +// ============================================================================= +// EmbeddingProcess Integration Tests +// ============================================================================= + +describe('EmbeddingProcess', () => { + // Integration tests for the main EmbeddingProcess component + + // Import the main component after mocks are set up + let EmbeddingProcess: typeof import('./index').default + + beforeEach(async () => { + vi.clearAllMocks() + vi.useFakeTimers() + mockEnableBilling = false + mockPlanType = 'sandbox' + + // Dynamically import to get fresh component with mocks + const embeddingModule = await import('./index') + EmbeddingProcess = embeddingModule.default + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('Rendering', () => { + it('should render without crashing', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(document.body).toBeInTheDocument() + }) + + it('should render status header', async () => { + // Arrange + const mockStatus = [createMockIndexingStatus({ indexing_status: 'indexing' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.getByText(/datasetDocuments\.embedding\.processing/i)).toBeInTheDocument() + }) + + it('should show completed status when all documents are done', async () => { + // Arrange + const mockStatus = [createMockIndexingStatus({ indexing_status: 'completed' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.getByText(/datasetDocuments\.embedding\.completed/i)).toBeInTheDocument() + }) + }) + + describe('Progress Items', () => { + it('should render progress items for each document', async () => { + // Arrange + const documents = [ + createMockDocument({ id: 'doc-1', name: 'file1.txt' }), + createMockDocument({ id: 'doc-2', name: 'file2.pdf' }), + ] + const mockStatus = [ + createMockIndexingStatus({ id: 'doc-1' }), + createMockIndexingStatus({ id: 'doc-2' }), + ] + mockFetchIndexingStatusBatch.mockResolvedValue({ data: mockStatus }) + + // Act + render( + , + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.getByText('file1.txt')).toBeInTheDocument() + expect(screen.getByText('file2.pdf')).toBeInTheDocument() + }) + }) + + describe('Upgrade Banner', () => { + it('should show upgrade banner when billing is enabled and not team plan', async () => { + // Arrange + mockEnableBilling = true + mockPlanType = 'sandbox' + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Re-import to get updated mock values + const embeddingModule = await import('./index') + EmbeddingProcess = embeddingModule.default + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.getByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i)).toBeInTheDocument() + }) + + it('should not show upgrade banner when billing is disabled', async () => { + // Arrange + mockEnableBilling = false + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.queryByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i)).not.toBeInTheDocument() + }) + + it('should not show upgrade banner for team plan', async () => { + // Arrange + mockEnableBilling = true + mockPlanType = 'team' + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Re-import to get updated mock values + const embeddingModule = await import('./index') + EmbeddingProcess = embeddingModule.default + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.queryByText(/billing\.plansCommon\.documentProcessingPriorityUpgrade/i)).not.toBeInTheDocument() + }) + }) + + describe('Action Buttons', () => { + it('should render API access button with correct link', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + const apiButton = screen.getByText('Access the API') + expect(apiButton).toBeInTheDocument() + expect(apiButton.closest('a')).toHaveAttribute('href', 'https://api.example.com/docs') + }) + + it('should render navigation button', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.getByText(/datasetCreation\.stepThree\.navTo/i)).toBeInTheDocument() + }) + + it('should navigate to documents list when nav button clicked', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + const navButton = screen.getByText(/datasetCreation\.stepThree\.navTo/i) + + await act(async () => { + navButton.click() + }) + + // Assert + expect(mockInvalidDocumentList).toHaveBeenCalled() + expect(mockPush).toHaveBeenCalledWith('/datasets/ds-1/documents') + }) + }) + + describe('Rule Detail', () => { + it('should render RuleDetail component', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render( + , + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.getByText(/datasetDocuments\.embedding\.mode/i)).toBeInTheDocument() + }) + + it('should pass indexingType to RuleDetail', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render( + , + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert + expect(screen.getByText(/datasetCreation\.stepTwo\.economical/i)).toBeInTheDocument() + }) + }) + + describe('Document Lookup Memoization', () => { + it('should memoize document lookup based on documents array', async () => { + // Arrange + const documents = [createMockDocument({ id: 'doc-1', name: 'test.txt' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ + data: [createMockIndexingStatus({ id: 'doc-1' })], + }) + + // Act + const { rerender } = render( + , + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Rerender with same documents reference + rerender( + , + ) + + // Assert - component should render without issues + expect(screen.getByText('test.txt')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty documents array', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert - should render without crashing + expect(document.body).toBeInTheDocument() + }) + + it('should handle undefined documents', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert - should render without crashing + expect(document.body).toBeInTheDocument() + }) + + it('should handle status with missing document', async () => { + // Arrange + const documents = [createMockDocument({ id: 'doc-1', name: 'test.txt' })] + mockFetchIndexingStatusBatch.mockResolvedValue({ + data: [ + createMockIndexingStatus({ id: 'doc-1' }), + createMockIndexingStatus({ id: 'doc-unknown' }), // No matching document + ], + }) + + // Act + render( + , + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert - should render known document and handle unknown gracefully + expect(screen.getByText('test.txt')).toBeInTheDocument() + }) + + it('should handle undefined retrievalMethod', async () => { + // Arrange + mockFetchIndexingStatusBatch.mockResolvedValue({ data: [] }) + + // Act + render( + , + ) + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + }) + + // Assert - should use default semantic search + expect(screen.getByText(/dataset\.retrieval\.semantic_search\.title/i)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/create/embedding-process/index.tsx b/web/app/components/datasets/create/embedding-process/index.tsx index aa1f6cee50..e9cea84f00 100644 --- a/web/app/components/datasets/create/embedding-process/index.tsx +++ b/web/app/components/datasets/create/embedding-process/index.tsx @@ -1,47 +1,29 @@ import type { FC } from 'react' -import type { - DataSourceInfo, - FullDocumentDetail, - IndexingStatusResponse, - LegacyDataSourceInfo, - ProcessRuleResponse, -} from '@/models/datasets' +import type { FullDocumentDetail } from '@/models/datasets' +import type { RETRIEVE_METHOD } from '@/types/app' import { RiArrowRightLine, - RiCheckboxCircleFill, - RiErrorWarningFill, RiLoader2Fill, RiTerminalBoxLine, } from '@remixicon/react' -import Image from 'next/image' import Link from 'next/link' import { useRouter } from 'next/navigation' -import * as React from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Divider from '@/app/components/base/divider' -import { ZapFast } from '@/app/components/base/icons/src/vender/solid/general' -import NotionIcon from '@/app/components/base/notion-icon' -import Tooltip from '@/app/components/base/tooltip' -import PriorityLabel from '@/app/components/billing/priority-label' import { Plan } from '@/app/components/billing/type' -import UpgradeBtn from '@/app/components/billing/upgrade-btn' -import { FieldInfo } from '@/app/components/datasets/documents/detail/metadata' import { useProviderContext } from '@/context/provider-context' import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' -import { DataSourceType, ProcessMode } from '@/models/datasets' -import { fetchIndexingStatusBatch as doFetchIndexingStatus } from '@/service/datasets' import { useProcessRule } from '@/service/knowledge/use-dataset' import { useInvalidDocumentList } from '@/service/knowledge/use-document' -import { RETRIEVE_METHOD } from '@/types/app' -import { sleep } from '@/utils' -import { cn } from '@/utils/classnames' -import DocumentFileIcon from '../../common/document-file-icon' -import { indexMethodIcon, retrievalIcon } from '../icons' -import { IndexingType } from '../step-two' +import IndexingProgressItem from './indexing-progress-item' +import RuleDetail from './rule-detail' +import UpgradeBanner from './upgrade-banner' +import { useIndexingStatusPolling } from './use-indexing-status-polling' +import { createDocumentLookup } from './utils' -type Props = { +type EmbeddingProcessProps = { datasetId: string batchId: string documents?: FullDocumentDetail[] @@ -49,333 +31,121 @@ type Props = { retrievalMethod?: RETRIEVE_METHOD } -const RuleDetail: FC<{ - sourceData?: ProcessRuleResponse - indexingType?: string - retrievalMethod?: RETRIEVE_METHOD -}> = ({ sourceData, indexingType, retrievalMethod }) => { +// Status header component +const StatusHeader: FC<{ isEmbedding: boolean, isCompleted: boolean }> = ({ + isEmbedding, + isCompleted, +}) => { const { t } = useTranslation() - const segmentationRuleMap = { - mode: t('embedding.mode', { ns: 'datasetDocuments' }), - segmentLength: t('embedding.segmentLength', { ns: 'datasetDocuments' }), - textCleaning: t('embedding.textCleaning', { ns: 'datasetDocuments' }), - } - - const getRuleName = (key: string) => { - if (key === 'remove_extra_spaces') - return t('stepTwo.removeExtraSpaces', { ns: 'datasetCreation' }) - - if (key === 'remove_urls_emails') - return t('stepTwo.removeUrlEmails', { ns: 'datasetCreation' }) - - if (key === 'remove_stopwords') - return t('stepTwo.removeStopwords', { ns: 'datasetCreation' }) - } - - const isNumber = (value: unknown) => { - return typeof value === 'number' - } - - const getValue = useCallback((field: string) => { - let value: string | number | undefined = '-' - const maxTokens = isNumber(sourceData?.rules?.segmentation?.max_tokens) - ? sourceData.rules.segmentation.max_tokens - : value - const childMaxTokens = isNumber(sourceData?.rules?.subchunk_segmentation?.max_tokens) - ? sourceData.rules.subchunk_segmentation.max_tokens - : value - switch (field) { - case 'mode': - value = !sourceData?.mode - ? value - : sourceData.mode === ProcessMode.general - ? (t('embedding.custom', { ns: 'datasetDocuments' }) as string) - : `${t('embedding.hierarchical', { ns: 'datasetDocuments' })} · ${sourceData?.rules?.parent_mode === 'paragraph' - ? t('parentMode.paragraph', { ns: 'dataset' }) - : t('parentMode.fullDoc', { ns: 'dataset' })}` - break - case 'segmentLength': - value = !sourceData?.mode - ? value - : sourceData.mode === ProcessMode.general - ? maxTokens - : `${t('embedding.parentMaxTokens', { ns: 'datasetDocuments' })} ${maxTokens}; ${t('embedding.childMaxTokens', { ns: 'datasetDocuments' })} ${childMaxTokens}` - break - default: - value = !sourceData?.mode - ? value - : sourceData?.rules?.pre_processing_rules?.filter(rule => - rule.enabled).map(rule => getRuleName(rule.id)).join(',') - break - } - return value - }, [sourceData]) - return ( -
- {Object.keys(segmentationRuleMap).map((field) => { - return ( - - ) - })} - - )} - /> - - )} - /> +
+ {isEmbedding && ( + <> + + {t('embedding.processing', { ns: 'datasetDocuments' })} + + )} + {isCompleted && t('embedding.completed', { ns: 'datasetDocuments' })}
) } -const EmbeddingProcess: FC = ({ datasetId, batchId, documents = [], indexingType, retrievalMethod }) => { +// Action buttons component +const ActionButtons: FC<{ + apiReferenceUrl: string + onNavToDocuments: () => void +}> = ({ apiReferenceUrl, onNavToDocuments }) => { const { t } = useTranslation() + + return ( +
+ + + + +
+ ) +} + +const EmbeddingProcess: FC = ({ + datasetId, + batchId, + documents = [], + indexingType, + retrievalMethod, +}) => { const { enableBilling, plan } = useProviderContext() - - const getFirstDocument = documents[0] - - const [indexingStatusBatchDetail, setIndexingStatusDetail] = useState([]) - const fetchIndexingStatus = async () => { - const status = await doFetchIndexingStatus({ datasetId, batchId }) - setIndexingStatusDetail(status.data) - return status.data - } - - const [isStopQuery, setIsStopQuery] = useState(false) - const isStopQueryRef = useRef(isStopQuery) - useEffect(() => { - isStopQueryRef.current = isStopQuery - }, [isStopQuery]) - const stopQueryStatus = () => { - setIsStopQuery(true) - } - - const startQueryStatus = async () => { - if (isStopQueryRef.current) - return - - try { - const indexingStatusBatchDetail = await fetchIndexingStatus() - const isCompleted = indexingStatusBatchDetail.every(indexingStatusDetail => ['completed', 'error', 'paused'].includes(indexingStatusDetail.indexing_status)) - if (isCompleted) { - stopQueryStatus() - return - } - await sleep(2500) - await startQueryStatus() - } - catch { - await sleep(2500) - await startQueryStatus() - } - } - - useEffect(() => { - setIsStopQuery(false) - startQueryStatus() - return () => { - stopQueryStatus() - } - }, []) - - // get rule - const { data: ruleDetail } = useProcessRule(getFirstDocument?.id) - const router = useRouter() const invalidDocumentList = useInvalidDocumentList() - const navToDocumentList = () => { + const apiReferenceUrl = useDatasetApiAccessUrl() + + // Polling hook for indexing status + const { statusList, isEmbedding, isEmbeddingCompleted } = useIndexingStatusPolling({ + datasetId, + batchId, + }) + + // Get process rule for the first document + const firstDocumentId = documents[0]?.id + const { data: ruleDetail } = useProcessRule(firstDocumentId) + + // Document lookup utilities - memoized for performance + const documentLookup = useMemo( + () => createDocumentLookup(documents), + [documents], + ) + + const handleNavToDocuments = () => { invalidDocumentList() router.push(`/datasets/${datasetId}/documents`) } - const apiReferenceUrl = useDatasetApiAccessUrl() - const isEmbedding = useMemo(() => { - return indexingStatusBatchDetail.some(indexingStatusDetail => ['indexing', 'splitting', 'parsing', 'cleaning'].includes(indexingStatusDetail?.indexing_status || '')) - }, [indexingStatusBatchDetail]) - const isEmbeddingCompleted = useMemo(() => { - return indexingStatusBatchDetail.every(indexingStatusDetail => ['completed', 'error', 'paused'].includes(indexingStatusDetail?.indexing_status || '')) - }, [indexingStatusBatchDetail]) - - const getSourceName = (id: string) => { - const doc = documents.find(document => document.id === id) - return doc?.name - } - const getFileType = (name?: string) => name?.split('.').pop() || 'txt' - const getSourcePercent = (detail: IndexingStatusResponse) => { - const completedCount = detail.completed_segments || 0 - const totalCount = detail.total_segments || 0 - if (totalCount === 0) - return 0 - const percent = Math.round(completedCount * 100 / totalCount) - return percent > 100 ? 100 : percent - } - const getSourceType = (id: string) => { - const doc = documents.find(document => document.id === id) - return doc?.data_source_type as DataSourceType - } - - const isLegacyDataSourceInfo = (info: DataSourceInfo): info is LegacyDataSourceInfo => { - return info != null && typeof (info as LegacyDataSourceInfo).upload_file === 'object' - } - - const getIcon = (id: string) => { - const doc = documents.find(document => document.id === id) - const info = doc?.data_source_info - if (info && isLegacyDataSourceInfo(info)) - return info.notion_page_icon - return undefined - } - const isSourceEmbedding = (detail: IndexingStatusResponse) => - ['indexing', 'splitting', 'parsing', 'cleaning', 'waiting'].includes(detail.indexing_status || '') + const showUpgradeBanner = enableBilling && plan.type !== Plan.team return ( <>
-
- {isEmbedding && ( - <> - - {t('embedding.processing', { ns: 'datasetDocuments' })} - - )} - {isEmbeddingCompleted && t('embedding.completed', { ns: 'datasetDocuments' })} -
- { - enableBilling && plan.type !== Plan.team && ( -
-
- -
-
- {t('plansCommon.documentProcessingPriorityUpgrade', { ns: 'billing' })} -
- -
- ) - } + + + {showUpgradeBanner && } +
- {indexingStatusBatchDetail.map(indexingStatusDetail => ( -
- {isSourceEmbedding(indexingStatusDetail) && ( -
- )} -
- {getSourceType(indexingStatusDetail.id) === DataSourceType.FILE && ( - - )} - {getSourceType(indexingStatusDetail.id) === DataSourceType.NOTION && ( - - )} -
-
- {getSourceName(indexingStatusDetail.id)} -
- { - enableBilling && ( - - ) - } -
- {isSourceEmbedding(indexingStatusDetail) && ( -
{`${getSourcePercent(indexingStatusDetail)}%`}
- )} - {indexingStatusDetail.indexing_status === 'error' && ( - - - - - - )} - {indexingStatusDetail.indexing_status === 'completed' && ( - - )} -
-
+ {statusList.map(detail => ( + ))}
+ +
-
- - - - -
+ + ) } 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 new file mode 100644 index 0000000000..b7c085cff9 --- /dev/null +++ b/web/app/components/datasets/create/embedding-process/indexing-progress-item.tsx @@ -0,0 +1,120 @@ +import type { FC } from 'react' +import type { IndexingStatusResponse } from '@/models/datasets' +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 { cn } from '@/utils/classnames' +import DocumentFileIcon from '../../common/document-file-icon' +import { getFileType, getSourcePercent, isSourceEmbedding } from './utils' + +type IndexingProgressItemProps = { + detail: IndexingStatusResponse + name?: string + sourceType?: DataSourceType + notionIcon?: string + enableBilling?: boolean +} + +// Status icon component for completed/error states +const StatusIcon: FC<{ status: string, error?: string }> = ({ status, error }) => { + if (status === 'completed') + return + + if (status === 'error') { + return ( + + + + + + ) + } + + return null +} + +// Source type icon component +const SourceTypeIcon: FC<{ + sourceType?: DataSourceType + name?: string + notionIcon?: string +}> = ({ sourceType, name, notionIcon }) => { + if (sourceType === DataSourceType.FILE) { + return ( + + ) + } + + if (sourceType === DataSourceType.NOTION) { + return ( + + ) + } + + return null +} + +const IndexingProgressItem: FC = ({ + detail, + name, + sourceType, + notionIcon, + enableBilling, +}) => { + const isEmbedding = isSourceEmbedding(detail) + const percent = getSourcePercent(detail) + const isError = detail.indexing_status === 'error' + + return ( +
+ {isEmbedding && ( +
+ )} +
+ +
+
+ {name} +
+ {enableBilling && } +
+ {isEmbedding && ( +
{`${percent}%`}
+ )} + +
+
+ ) +} + +export default IndexingProgressItem diff --git a/web/app/components/datasets/create/embedding-process/rule-detail.tsx b/web/app/components/datasets/create/embedding-process/rule-detail.tsx new file mode 100644 index 0000000000..dff35100cb --- /dev/null +++ b/web/app/components/datasets/create/embedding-process/rule-detail.tsx @@ -0,0 +1,133 @@ +import type { FC } from 'react' +import type { ProcessRuleResponse } from '@/models/datasets' +import Image from 'next/image' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { FieldInfo } from '@/app/components/datasets/documents/detail/metadata' +import { ProcessMode } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' +import { indexMethodIcon, retrievalIcon } from '../icons' +import { IndexingType } from '../step-two' + +type RuleDetailProps = { + sourceData?: ProcessRuleResponse + indexingType?: string + retrievalMethod?: RETRIEVE_METHOD +} + +// Lookup table for pre-processing rule names +const PRE_PROCESSING_RULE_KEYS = { + remove_extra_spaces: 'stepTwo.removeExtraSpaces', + remove_urls_emails: 'stepTwo.removeUrlEmails', + remove_stopwords: 'stepTwo.removeStopwords', +} as const + +// Lookup table for retrieval method icons +const RETRIEVAL_ICON_MAP: Partial> = { + [RETRIEVE_METHOD.fullText]: retrievalIcon.fullText, + [RETRIEVE_METHOD.hybrid]: retrievalIcon.hybrid, + [RETRIEVE_METHOD.semantic]: retrievalIcon.vector, + [RETRIEVE_METHOD.invertedIndex]: retrievalIcon.fullText, + [RETRIEVE_METHOD.keywordSearch]: retrievalIcon.fullText, +} + +const isNumber = (value: unknown): value is number => typeof value === 'number' + +const RuleDetail: FC = ({ sourceData, indexingType, retrievalMethod }) => { + const { t } = useTranslation() + + const segmentationRuleLabels = { + mode: t('embedding.mode', { ns: 'datasetDocuments' }), + segmentLength: t('embedding.segmentLength', { ns: 'datasetDocuments' }), + textCleaning: t('embedding.textCleaning', { ns: 'datasetDocuments' }), + } + + const getRuleName = useCallback((key: string): string | undefined => { + const translationKey = PRE_PROCESSING_RULE_KEYS[key as keyof typeof PRE_PROCESSING_RULE_KEYS] + return translationKey ? t(translationKey, { ns: 'datasetCreation' }) : undefined + }, [t]) + + const getModeValue = useCallback((): string => { + if (!sourceData?.mode) + return '-' + + if (sourceData.mode === ProcessMode.general) + return t('embedding.custom', { ns: 'datasetDocuments' }) + + const parentModeLabel = sourceData.rules?.parent_mode === 'paragraph' + ? t('parentMode.paragraph', { ns: 'dataset' }) + : t('parentMode.fullDoc', { ns: 'dataset' }) + + return `${t('embedding.hierarchical', { ns: 'datasetDocuments' })} · ${parentModeLabel}` + }, [sourceData, t]) + + const getSegmentLengthValue = useCallback((): string | number => { + if (!sourceData?.mode) + return '-' + + const maxTokens = isNumber(sourceData.rules?.segmentation?.max_tokens) + ? sourceData.rules.segmentation.max_tokens + : '-' + + if (sourceData.mode === ProcessMode.general) + return maxTokens + + const childMaxTokens = isNumber(sourceData.rules?.subchunk_segmentation?.max_tokens) + ? sourceData.rules.subchunk_segmentation.max_tokens + : '-' + + return `${t('embedding.parentMaxTokens', { ns: 'datasetDocuments' })} ${maxTokens}; ${t('embedding.childMaxTokens', { ns: 'datasetDocuments' })} ${childMaxTokens}` + }, [sourceData, t]) + + const getTextCleaningValue = useCallback((): string => { + if (!sourceData?.mode) + return '-' + + const enabledRules = sourceData.rules?.pre_processing_rules?.filter(rule => rule.enabled) || [] + const ruleNames = enabledRules + .map((rule) => { + const name = getRuleName(rule.id) + return typeof name === 'string' ? name : '' + }) + .filter(name => name) + return ruleNames.length > 0 ? ruleNames.join(',') : '-' + }, [sourceData, getRuleName]) + + const fieldValueGetters: Record string | number> = { + mode: getModeValue, + segmentLength: getSegmentLengthValue, + textCleaning: getTextCleaningValue, + } + + const isEconomical = indexingType === IndexingType.ECONOMICAL + const indexMethodIconSrc = isEconomical ? indexMethodIcon.economical : indexMethodIcon.high_quality + const indexModeLabel = t(`stepTwo.${isEconomical ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) + + const effectiveRetrievalMethod = isEconomical ? 'keyword_search' : (retrievalMethod ?? 'semantic_search') + const retrievalLabel = t(`retrieval.${effectiveRetrievalMethod}.title`, { ns: 'dataset' }) + const retrievalIconSrc = RETRIEVAL_ICON_MAP[retrievalMethod as keyof typeof RETRIEVAL_ICON_MAP] ?? retrievalIcon.vector + + return ( +
+ {Object.keys(segmentationRuleLabels).map(field => ( + + ))} + } + /> + } + /> +
+ ) +} + +export default RuleDetail diff --git a/web/app/components/datasets/create/embedding-process/upgrade-banner.tsx b/web/app/components/datasets/create/embedding-process/upgrade-banner.tsx new file mode 100644 index 0000000000..49e5fe99a1 --- /dev/null +++ b/web/app/components/datasets/create/embedding-process/upgrade-banner.tsx @@ -0,0 +1,22 @@ +import type { FC } from 'react' +import { useTranslation } from 'react-i18next' +import { ZapFast } from '@/app/components/base/icons/src/vender/solid/general' +import UpgradeBtn from '@/app/components/billing/upgrade-btn' + +const UpgradeBanner: FC = () => { + const { t } = useTranslation() + + return ( +
+
+ +
+
+ {t('plansCommon.documentProcessingPriorityUpgrade', { ns: 'billing' })} +
+ +
+ ) +} + +export default UpgradeBanner diff --git a/web/app/components/datasets/create/embedding-process/use-indexing-status-polling.ts b/web/app/components/datasets/create/embedding-process/use-indexing-status-polling.ts new file mode 100644 index 0000000000..f8e69e47af --- /dev/null +++ b/web/app/components/datasets/create/embedding-process/use-indexing-status-polling.ts @@ -0,0 +1,90 @@ +import type { IndexingStatusResponse } from '@/models/datasets' +import { useEffect, useRef, useState } from 'react' +import { fetchIndexingStatusBatch } from '@/service/datasets' + +const POLLING_INTERVAL = 2500 +const COMPLETED_STATUSES = ['completed', 'error', 'paused'] as const +const EMBEDDING_STATUSES = ['indexing', 'splitting', 'parsing', 'cleaning', 'waiting'] as const + +type IndexingStatusPollingParams = { + datasetId: string + batchId: string +} + +type IndexingStatusPollingResult = { + statusList: IndexingStatusResponse[] + isEmbedding: boolean + isEmbeddingCompleted: boolean +} + +const isStatusCompleted = (status: string): boolean => + COMPLETED_STATUSES.includes(status as typeof COMPLETED_STATUSES[number]) + +const isAllCompleted = (statusList: IndexingStatusResponse[]): boolean => + statusList.every(item => isStatusCompleted(item.indexing_status)) + +/** + * Custom hook for polling indexing status with automatic stop on completion. + * Handles the polling lifecycle and provides derived states for UI rendering. + */ +export const useIndexingStatusPolling = ({ + datasetId, + batchId, +}: IndexingStatusPollingParams): IndexingStatusPollingResult => { + const [statusList, setStatusList] = useState([]) + const isStopPollingRef = useRef(false) + + useEffect(() => { + // Reset polling state on mount + isStopPollingRef.current = false + let timeoutId: ReturnType | null = null + + const fetchStatus = async (): Promise => { + const response = await fetchIndexingStatusBatch({ datasetId, batchId }) + setStatusList(response.data) + return response.data + } + + const poll = async (): Promise => { + if (isStopPollingRef.current) + return + + try { + const data = await fetchStatus() + if (isAllCompleted(data)) { + isStopPollingRef.current = true + return + } + } + catch { + // Continue polling on error + } + + if (!isStopPollingRef.current) { + timeoutId = setTimeout(() => { + poll() + }, POLLING_INTERVAL) + } + } + + poll() + + return () => { + isStopPollingRef.current = true + if (timeoutId) + clearTimeout(timeoutId) + } + }, [datasetId, batchId]) + + const isEmbedding = statusList.some(item => + EMBEDDING_STATUSES.includes(item?.indexing_status as typeof EMBEDDING_STATUSES[number]), + ) + + const isEmbeddingCompleted = statusList.length > 0 && isAllCompleted(statusList) + + return { + statusList, + isEmbedding, + isEmbeddingCompleted, + } +} diff --git a/web/app/components/datasets/create/embedding-process/utils.ts b/web/app/components/datasets/create/embedding-process/utils.ts new file mode 100644 index 0000000000..6fbefb0230 --- /dev/null +++ b/web/app/components/datasets/create/embedding-process/utils.ts @@ -0,0 +1,64 @@ +import type { + DataSourceInfo, + DataSourceType, + FullDocumentDetail, + IndexingStatusResponse, + LegacyDataSourceInfo, +} from '@/models/datasets' + +const EMBEDDING_STATUSES = ['indexing', 'splitting', 'parsing', 'cleaning', 'waiting'] as const + +/** + * Type guard for legacy data source info with upload_file property + */ +export const isLegacyDataSourceInfo = (info: DataSourceInfo): info is LegacyDataSourceInfo => { + return info != null && typeof (info as LegacyDataSourceInfo).upload_file === 'object' +} + +/** + * Check if a status indicates the source is being embedded + */ +export const isSourceEmbedding = (detail: IndexingStatusResponse): boolean => + EMBEDDING_STATUSES.includes(detail.indexing_status as typeof EMBEDDING_STATUSES[number]) + +/** + * Calculate the progress percentage for a document + */ +export const getSourcePercent = (detail: IndexingStatusResponse): number => { + const completedCount = detail.completed_segments || 0 + const totalCount = detail.total_segments || 0 + + if (totalCount === 0) + return 0 + + const percent = Math.round(completedCount * 100 / totalCount) + return Math.min(percent, 100) +} + +/** + * Get file extension from filename, defaults to 'txt' + */ +export const getFileType = (name?: string): string => + name?.split('.').pop() || 'txt' + +/** + * Document lookup utilities - provides document info by ID from a list + */ +export const createDocumentLookup = (documents: FullDocumentDetail[]) => { + const documentMap = new Map(documents.map(doc => [doc.id, doc])) + + return { + getDocument: (id: string) => documentMap.get(id), + + getName: (id: string) => documentMap.get(id)?.name, + + getSourceType: (id: string) => documentMap.get(id)?.data_source_type as DataSourceType | undefined, + + getNotionIcon: (id: string) => { + const info = documentMap.get(id)?.data_source_info + if (info && isLegacyDataSourceInfo(info)) + return info.notion_page_icon + return undefined + }, + } +} diff --git a/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx b/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx new file mode 100644 index 0000000000..5140c902f5 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx @@ -0,0 +1,199 @@ +'use client' + +import type { FC } from 'react' +import type { PreProcessingRule } from '@/models/datasets' +import { + RiAlertFill, + RiSearchEyeLine, +} from '@remixicon/react' +import Image from 'next/image' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import Checkbox from '@/app/components/base/checkbox' +import Divider from '@/app/components/base/divider' +import Tooltip from '@/app/components/base/tooltip' +import { IS_CE_EDITION } from '@/config' +import { ChunkingMode } from '@/models/datasets' +import SettingCog from '../../assets/setting-gear-mod.svg' +import s from '../index.module.css' +import LanguageSelect from '../language-select' +import { DelimiterInput, MaxLengthInput, OverlapInput } from './inputs' +import { OptionCard } from './option-card' + +type TextLabelProps = { + children: React.ReactNode +} + +const TextLabel: FC = ({ children }) => { + return +} + +type GeneralChunkingOptionsProps = { + // State + segmentIdentifier: string + maxChunkLength: number + overlap: number + rules: PreProcessingRule[] + currentDocForm: ChunkingMode + docLanguage: string + // Flags + isActive: boolean + isInUpload: boolean + isNotUploadInEmptyDataset: boolean + hasCurrentDatasetDocForm: boolean + // Actions + onSegmentIdentifierChange: (value: string) => void + onMaxChunkLengthChange: (value: number) => void + onOverlapChange: (value: number) => void + onRuleToggle: (id: string) => void + onDocFormChange: (form: ChunkingMode) => void + onDocLanguageChange: (lang: string) => void + onPreview: () => void + onReset: () => void + // Locale + locale: string +} + +export const GeneralChunkingOptions: FC = ({ + segmentIdentifier, + maxChunkLength, + overlap, + rules, + currentDocForm, + docLanguage, + isActive, + isInUpload, + isNotUploadInEmptyDataset, + hasCurrentDatasetDocForm, + onSegmentIdentifierChange, + onMaxChunkLengthChange, + onOverlapChange, + onRuleToggle, + onDocFormChange, + onDocLanguageChange, + onPreview, + onReset, + locale, +}) => { + const { t } = useTranslation() + + const getRuleName = (key: string): string => { + const ruleNameMap: Record = { + remove_extra_spaces: t('stepTwo.removeExtraSpaces', { ns: 'datasetCreation' }), + remove_urls_emails: t('stepTwo.removeUrlEmails', { ns: 'datasetCreation' }), + remove_stopwords: t('stepTwo.removeStopwords', { ns: 'datasetCreation' }), + } + return ruleNameMap[key] ?? key + } + + return ( + } + activeHeaderClassName="bg-dataset-option-card-blue-gradient" + description={t('stepTwo.generalTip', { ns: 'datasetCreation' })} + isActive={isActive} + onSwitched={() => onDocFormChange(ChunkingMode.text)} + actions={( + <> + + + + )} + noHighlight={isInUpload && isNotUploadInEmptyDataset} + > +
+
+ onSegmentIdentifierChange(e.target.value)} + /> + + +
+
+
+
+ {t('stepTwo.rules', { ns: 'datasetCreation' })} +
+ +
+
+ {rules.map(rule => ( +
onRuleToggle(rule.id)} + > + + +
+ ))} + {IS_CE_EDITION && ( + <> + +
+
{ + if (hasCurrentDatasetDocForm) + return + if (currentDocForm === ChunkingMode.qa) + onDocFormChange(ChunkingMode.text) + else + onDocFormChange(ChunkingMode.qa) + }} + > + + +
+ + +
+ {currentDocForm === ChunkingMode.qa && ( +
+ + + {t('stepTwo.QATip', { ns: 'datasetCreation' })} + +
+ )} + + )} +
+
+
+
+ ) +} diff --git a/web/app/components/datasets/create/step-two/components/index.ts b/web/app/components/datasets/create/step-two/components/index.ts new file mode 100644 index 0000000000..d5382e0c4b --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/index.ts @@ -0,0 +1,5 @@ +export { GeneralChunkingOptions } from './general-chunking-options' +export { IndexingModeSection } from './indexing-mode-section' +export { ParentChildOptions } from './parent-child-options' +export { PreviewPanel } from './preview-panel' +export { StepTwoFooter } from './step-two-footer' 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 new file mode 100644 index 0000000000..ee49f42903 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx @@ -0,0 +1,253 @@ +'use client' + +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 Image from 'next/image' +import Link from 'next/link' +import { useTranslation } from 'react-i18next' +import Badge from '@/app/components/base/badge' +import Button from '@/app/components/base/button' +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' +import { useDocLink } from '@/context/i18n' +import { ChunkingMode } from '@/models/datasets' +import { cn } from '@/utils/classnames' +import { indexMethodIcon } from '../../icons' +import { IndexingType } from '../hooks' +import s from '../index.module.css' +import { OptionCard } from './option-card' + +type IndexingModeSectionProps = { + // State + indexType: IndexingType + hasSetIndexType: boolean + docForm: ChunkingMode + embeddingModel: DefaultModel + embeddingModelList?: Model[] + retrievalConfig: RetrievalConfig + showMultiModalTip: boolean + // Flags + isModelAndRetrievalConfigDisabled: boolean + datasetId?: string + // Modal state + isQAConfirmDialogOpen: boolean + // Actions + onIndexTypeChange: (type: IndexingType) => void + onEmbeddingModelChange: (model: DefaultModel) => void + onRetrievalConfigChange: (config: RetrievalConfig) => void + onQAConfirmDialogClose: () => void + onQAConfirmDialogConfirm: () => void +} + +export const IndexingModeSection: FC = ({ + indexType, + hasSetIndexType, + docForm, + embeddingModel, + embeddingModelList, + retrievalConfig, + showMultiModalTip, + isModelAndRetrievalConfigDisabled, + datasetId, + isQAConfirmDialogOpen, + onIndexTypeChange, + onEmbeddingModelChange, + onRetrievalConfigChange, + onQAConfirmDialogClose, + onQAConfirmDialogConfirm, +}) => { + const { t } = useTranslation() + const docLink = useDocLink() + + const getIndexingTechnique = () => indexType + + return ( + <> + {/* Index Mode */} +
+ {t('stepTwo.indexMode', { ns: 'datasetCreation' })} +
+
+ {/* Qualified option */} + {(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.QUALIFIED)) && ( + + {t('stepTwo.qualified', { ns: 'datasetCreation' })} + + {t('stepTwo.recommend', { ns: 'datasetCreation' })} + + + {!hasSetIndexType && } + +
+ )} + description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })} + icon={} + isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED} + disabled={hasSetIndexType} + onSwitched={() => onIndexTypeChange(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)} + /> + + + )} +
+ + {/* High quality tip */} + {!hasSetIndexType && indexType === IndexingType.QUALIFIED && ( +
+
+
+ +
+ + {t('stepTwo.highQualityTip', { ns: 'datasetCreation' })} + +
+ )} + + {/* Economical index setting tip */} + {hasSetIndexType && indexType === IndexingType.ECONOMICAL && ( +
+ {t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })} + + {t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })} + +
+ )} + + {/* Embedding model */} + {indexType === IndexingType.QUALIFIED && ( +
+
+ {t('form.embeddingModel', { ns: 'datasetSettings' })} +
+ + {isModelAndRetrievalConfigDisabled && ( +
+ {t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })} + + {t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })} + +
+ )} +
+ )} + + + + {/* Retrieval Method Config */} +
+ {!isModelAndRetrievalConfigDisabled + ? ( +
+
+ {t('form.retrievalSetting.title', { ns: 'datasetSettings' })} +
+
+ + {t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })} + + {t('form.retrievalSetting.longDescription', { ns: 'datasetSettings' })} +
+
+ ) + : ( +
+
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
+
+ )} + +
+ {getIndexingTechnique() === IndexingType.QUALIFIED + ? ( + + ) + : ( + + )} +
+
+ + ) +} diff --git a/web/app/components/datasets/create/step-two/inputs.tsx b/web/app/components/datasets/create/step-two/components/inputs.tsx similarity index 100% rename from web/app/components/datasets/create/step-two/inputs.tsx rename to web/app/components/datasets/create/step-two/components/inputs.tsx diff --git a/web/app/components/datasets/create/step-two/option-card.tsx b/web/app/components/datasets/create/step-two/components/option-card.tsx similarity index 100% rename from web/app/components/datasets/create/step-two/option-card.tsx rename to web/app/components/datasets/create/step-two/components/option-card.tsx diff --git a/web/app/components/datasets/create/step-two/components/parent-child-options.tsx b/web/app/components/datasets/create/step-two/components/parent-child-options.tsx new file mode 100644 index 0000000000..e46aa5817b --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/parent-child-options.tsx @@ -0,0 +1,191 @@ +'use client' + +import type { FC } from 'react' +import type { ParentChildConfig } from '../hooks' +import type { ParentMode, PreProcessingRule } from '@/models/datasets' +import { RiSearchEyeLine } from '@remixicon/react' +import Image from 'next/image' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import Checkbox from '@/app/components/base/checkbox' +import Divider from '@/app/components/base/divider' +import { ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge' +import RadioCard from '@/app/components/base/radio-card' +import { ChunkingMode } from '@/models/datasets' +import FileList from '../../assets/file-list-3-fill.svg' +import Note from '../../assets/note-mod.svg' +import BlueEffect from '../../assets/option-card-effect-blue.svg' +import s from '../index.module.css' +import { DelimiterInput, MaxLengthInput } from './inputs' +import { OptionCard } from './option-card' + +type TextLabelProps = { + children: React.ReactNode +} + +const TextLabel: FC = ({ children }) => { + return +} + +type ParentChildOptionsProps = { + // State + parentChildConfig: ParentChildConfig + rules: PreProcessingRule[] + currentDocForm: ChunkingMode + // Flags + isActive: boolean + isInUpload: boolean + isNotUploadInEmptyDataset: boolean + // Actions + onDocFormChange: (form: ChunkingMode) => void + onChunkForContextChange: (mode: ParentMode) => void + onParentDelimiterChange: (value: string) => void + onParentMaxLengthChange: (value: number) => void + onChildDelimiterChange: (value: string) => void + onChildMaxLengthChange: (value: number) => void + onRuleToggle: (id: string) => void + onPreview: () => void + onReset: () => void +} + +export const ParentChildOptions: FC = ({ + parentChildConfig, + rules, + currentDocForm: _currentDocForm, + isActive, + isInUpload, + isNotUploadInEmptyDataset, + onDocFormChange, + onChunkForContextChange, + onParentDelimiterChange, + onParentMaxLengthChange, + onChildDelimiterChange, + onChildMaxLengthChange, + onRuleToggle, + onPreview, + onReset, +}) => { + const { t } = useTranslation() + + const getRuleName = (key: string): string => { + const ruleNameMap: Record = { + remove_extra_spaces: t('stepTwo.removeExtraSpaces', { ns: 'datasetCreation' }), + remove_urls_emails: t('stepTwo.removeUrlEmails', { ns: 'datasetCreation' }), + remove_stopwords: t('stepTwo.removeStopwords', { ns: 'datasetCreation' }), + } + return ruleNameMap[key] ?? key + } + + return ( + } + effectImg={BlueEffect.src} + className="text-util-colors-blue-light-blue-light-500" + activeHeaderClassName="bg-dataset-option-card-blue-gradient" + description={t('stepTwo.parentChildTip', { ns: 'datasetCreation' })} + isActive={isActive} + onSwitched={() => onDocFormChange(ChunkingMode.parentChild)} + actions={( + <> + + + + )} + noHighlight={isInUpload && isNotUploadInEmptyDataset} + > +
+ {/* Parent chunk for context */} +
+
+
+ {t('stepTwo.parentChunkForContext', { ns: 'datasetCreation' })} +
+ +
+ } + title={t('stepTwo.paragraph', { ns: 'datasetCreation' })} + description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })} + isChosen={parentChildConfig.chunkForContext === 'paragraph'} + onChosen={() => onChunkForContextChange('paragraph')} + chosenConfig={( +
+ onParentDelimiterChange(e.target.value)} + /> + +
+ )} + /> + } + title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })} + description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })} + onChosen={() => onChunkForContextChange('full-doc')} + isChosen={parentChildConfig.chunkForContext === 'full-doc'} + /> +
+ + {/* Child chunk for retrieval */} +
+
+
+ {t('stepTwo.childChunkForRetrieval', { ns: 'datasetCreation' })} +
+ +
+
+ onChildDelimiterChange(e.target.value)} + /> + +
+
+ + {/* Rules */} +
+
+
+ {t('stepTwo.rules', { ns: 'datasetCreation' })} +
+ +
+
+ {rules.map(rule => ( +
onRuleToggle(rule.id)} + > + + +
+ ))} +
+
+
+
+ ) +} diff --git a/web/app/components/datasets/create/step-two/components/preview-panel.tsx b/web/app/components/datasets/create/step-two/components/preview-panel.tsx new file mode 100644 index 0000000000..4f25cee5bd --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/preview-panel.tsx @@ -0,0 +1,171 @@ +'use client' + +import type { FC } from 'react' +import type { ParentChildConfig } from '../hooks' +import type { DataSourceType, FileIndexingEstimateResponse } from '@/models/datasets' +import { RiSearchEyeLine } from '@remixicon/react' +import { noop } from 'es-toolkit/function' +import { useTranslation } from 'react-i18next' +import Badge from '@/app/components/base/badge' +import FloatRightContainer from '@/app/components/base/float-right-container' +import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' +import { FULL_DOC_PREVIEW_LENGTH } from '@/config' +import { ChunkingMode } from '@/models/datasets' +import { cn } from '@/utils/classnames' +import { ChunkContainer, QAPreview } from '../../../chunk' +import PreviewDocumentPicker from '../../../common/document-picker/preview-document-picker' +import { PreviewSlice } from '../../../formatted-text/flavours/preview-slice' +import { FormattedText } from '../../../formatted-text/formatted' +import PreviewContainer from '../../../preview/container' +import { PreviewHeader } from '../../../preview/header' + +type PreviewPanelProps = { + // State + isMobile: boolean + dataSourceType: DataSourceType + currentDocForm: ChunkingMode + estimate?: FileIndexingEstimateResponse + parentChildConfig: ParentChildConfig + isSetting?: boolean + // Picker + pickerFiles: Array<{ id: string, name: string, extension: string }> + pickerValue: { id: string, name: string, extension: string } + // Mutation state + isIdle: boolean + isPending: boolean + // Actions + onPickerChange: (selected: { id: string, name: string }) => void +} + +export const PreviewPanel: FC = ({ + isMobile, + dataSourceType: _dataSourceType, + currentDocForm, + estimate, + parentChildConfig, + isSetting, + pickerFiles, + pickerValue, + isIdle, + isPending, + onPickerChange, +}) => { + const { t } = useTranslation() + + return ( + + +
+ >} + onChange={onPickerChange} + value={isSetting ? pickerFiles[0] : pickerValue} + /> + {currentDocForm !== ChunkingMode.qa && ( + + )} +
+ + )} + className={cn('relative flex h-full w-1/2 shrink-0 p-4 pr-0', isMobile && 'w-full max-w-[524px]')} + mainClassName="space-y-6" + > + {/* QA Preview */} + {currentDocForm === ChunkingMode.qa && estimate?.qa_preview && ( + estimate.qa_preview.map((item, index) => ( + + + + )) + )} + + {/* Text Preview */} + {currentDocForm === ChunkingMode.text && estimate?.preview && ( + estimate.preview.map((item, index) => ( + + {item.content} + + )) + )} + + {/* Parent-Child Preview */} + {currentDocForm === ChunkingMode.parentChild && estimate?.preview && ( + estimate.preview.map((item, index) => { + const indexForLabel = index + 1 + const childChunks = parentChildConfig.chunkForContext === 'full-doc' + ? item.child_chunks.slice(0, FULL_DOC_PREVIEW_LENGTH) + : item.child_chunks + return ( + + + {childChunks.map((child, childIndex) => { + const childIndexForLabel = childIndex + 1 + return ( + + ) + })} + + + ) + }) + )} + + {/* Idle State */} + {isIdle && ( +
+
+ +

+ {t('stepTwo.previewChunkTip', { ns: 'datasetCreation' })} +

+
+
+ )} + + {/* Loading State */} + {isPending && ( +
+ {Array.from({ length: 10 }, (_, i) => ( + + + + + + + + + + + ))} +
+ )} +
+
+ ) +} diff --git a/web/app/components/datasets/create/step-two/components/step-two-footer.tsx b/web/app/components/datasets/create/step-two/components/step-two-footer.tsx new file mode 100644 index 0000000000..a22be64a75 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/step-two-footer.tsx @@ -0,0 +1,58 @@ +'use client' + +import type { FC } from 'react' +import { RiArrowLeftLine } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' + +type StepTwoFooterProps = { + isSetting?: boolean + isCreating: boolean + onPrevious: () => void + onCreate: () => void + onCancel?: () => void +} + +export const StepTwoFooter: FC = ({ + isSetting, + isCreating, + onPrevious, + onCreate, + onCancel, +}) => { + const { t } = useTranslation() + + if (!isSetting) { + return ( +
+ + +
+ ) + } + + return ( +
+ + +
+ ) +} diff --git a/web/app/components/datasets/create/step-two/escape.ts b/web/app/components/datasets/create/step-two/hooks/escape.ts similarity index 100% rename from web/app/components/datasets/create/step-two/escape.ts rename to web/app/components/datasets/create/step-two/hooks/escape.ts diff --git a/web/app/components/datasets/create/step-two/hooks/index.ts b/web/app/components/datasets/create/step-two/hooks/index.ts new file mode 100644 index 0000000000..f16daaaea5 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/index.ts @@ -0,0 +1,14 @@ +export { useDocumentCreation } from './use-document-creation' +export type { DocumentCreation, ValidationParams } from './use-document-creation' + +export { IndexingType, useIndexingConfig } from './use-indexing-config' +export type { IndexingConfig } from './use-indexing-config' + +export { useIndexingEstimate } from './use-indexing-estimate' +export type { IndexingEstimate } from './use-indexing-estimate' + +export { usePreviewState } from './use-preview-state' +export type { PreviewState } from './use-preview-state' + +export { DEFAULT_MAXIMUM_CHUNK_LENGTH, DEFAULT_OVERLAP, DEFAULT_SEGMENT_IDENTIFIER, defaultParentChildConfig, MAXIMUM_CHUNK_TOKEN_LENGTH, useSegmentationState } from './use-segmentation-state' +export type { ParentChildConfig, SegmentationState } from './use-segmentation-state' diff --git a/web/app/components/datasets/create/step-two/unescape.ts b/web/app/components/datasets/create/step-two/hooks/unescape.ts similarity index 100% rename from web/app/components/datasets/create/step-two/unescape.ts rename to web/app/components/datasets/create/step-two/hooks/unescape.ts diff --git a/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts b/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts new file mode 100644 index 0000000000..fd132b38ef --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/use-document-creation.ts @@ -0,0 +1,279 @@ +import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { NotionPage } from '@/models/common' +import type { + ChunkingMode, + CrawlOptions, + CrawlResultItem, + CreateDocumentReq, + createDocumentResponse, + CustomFile, + FullDocumentDetail, + ProcessRule, +} from '@/models/datasets' +import type { RetrievalConfig, RETRIEVE_METHOD } from '@/types/app' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { trackEvent } from '@/app/components/base/amplitude' +import Toast from '@/app/components/base/toast' +import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model' +import { DataSourceProvider } from '@/models/common' +import { + DataSourceType, +} from '@/models/datasets' +import { getNotionInfo, getWebsiteInfo, useCreateDocument, useCreateFirstDocument } from '@/service/knowledge/use-create-dataset' +import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' +import { IndexingType } from './use-indexing-config' +import { MAXIMUM_CHUNK_TOKEN_LENGTH } from './use-segmentation-state' + +export type UseDocumentCreationOptions = { + datasetId?: string + isSetting?: boolean + documentDetail?: FullDocumentDetail + dataSourceType: DataSourceType + files: CustomFile[] + notionPages: NotionPage[] + notionCredentialId: string + websitePages: CrawlResultItem[] + crawlOptions?: CrawlOptions + websiteCrawlProvider?: DataSourceProvider + websiteCrawlJobId?: string + // Callbacks + onStepChange?: (delta: number) => void + updateIndexingTypeCache?: (type: string) => void + updateResultCache?: (res: createDocumentResponse) => void + updateRetrievalMethodCache?: (method: RETRIEVE_METHOD | '') => void + onSave?: () => void + mutateDatasetRes?: () => void +} + +export type ValidationParams = { + segmentationType: string + maxChunkLength: number + limitMaxChunkLength: number + overlap: number + indexType: IndexingType + embeddingModel: DefaultModel + rerankModelList: Model[] + retrievalConfig: RetrievalConfig +} + +export const useDocumentCreation = (options: UseDocumentCreationOptions) => { + const { t } = useTranslation() + const { + datasetId, + isSetting, + documentDetail, + dataSourceType, + files, + notionPages, + notionCredentialId, + websitePages, + crawlOptions, + websiteCrawlProvider = DataSourceProvider.jinaReader, + websiteCrawlJobId = '', + onStepChange, + updateIndexingTypeCache, + updateResultCache, + updateRetrievalMethodCache, + onSave, + mutateDatasetRes, + } = options + + const createFirstDocumentMutation = useCreateFirstDocument() + const createDocumentMutation = useCreateDocument(datasetId!) + const invalidDatasetList = useInvalidDatasetList() + + const isCreating = createFirstDocumentMutation.isPending || createDocumentMutation.isPending + + // Validate creation params + const validateParams = useCallback((params: ValidationParams): boolean => { + const { + segmentationType, + maxChunkLength, + limitMaxChunkLength, + overlap, + indexType, + embeddingModel, + rerankModelList, + retrievalConfig, + } = params + + if (segmentationType === 'general' && overlap > maxChunkLength) { + Toast.notify({ type: 'error', message: t('stepTwo.overlapCheck', { ns: 'datasetCreation' }) }) + return false + } + + if (segmentationType === 'general' && maxChunkLength > limitMaxChunkLength) { + Toast.notify({ + type: 'error', + message: t('stepTwo.maxLengthCheck', { ns: 'datasetCreation', limit: limitMaxChunkLength }), + }) + return false + } + + if (!isSetting) { + if (indexType === IndexingType.QUALIFIED && (!embeddingModel.model || !embeddingModel.provider)) { + Toast.notify({ + type: 'error', + message: t('datasetConfig.embeddingModelRequired', { ns: 'appDebug' }), + }) + return false + } + + if (!isReRankModelSelected({ + rerankModelList, + retrievalConfig, + indexMethod: indexType, + })) { + Toast.notify({ type: 'error', message: t('datasetConfig.rerankModelRequired', { ns: 'appDebug' }) }) + return false + } + } + + return true + }, [t, isSetting]) + + // Build creation params + const buildCreationParams = useCallback(( + currentDocForm: ChunkingMode, + docLanguage: string, + processRule: ProcessRule, + retrievalConfig: RetrievalConfig, + embeddingModel: DefaultModel, + indexingTechnique: string, + ): CreateDocumentReq | null => { + if (isSetting) { + return { + original_document_id: documentDetail?.id, + doc_form: currentDocForm, + doc_language: docLanguage, + process_rule: processRule, + retrieval_model: retrievalConfig, + embedding_model: embeddingModel.model, + embedding_model_provider: embeddingModel.provider, + indexing_technique: indexingTechnique, + } as CreateDocumentReq + } + + const params: CreateDocumentReq = { + data_source: { + type: dataSourceType, + info_list: { + data_source_type: dataSourceType, + }, + }, + indexing_technique: indexingTechnique, + process_rule: processRule, + doc_form: currentDocForm, + doc_language: docLanguage, + retrieval_model: retrievalConfig, + embedding_model: embeddingModel.model, + embedding_model_provider: embeddingModel.provider, + } as CreateDocumentReq + + // Add data source specific info + if (dataSourceType === DataSourceType.FILE) { + params.data_source!.info_list.file_info_list = { + file_ids: files.map(file => file.id || '').filter(Boolean), + } + } + if (dataSourceType === DataSourceType.NOTION) + params.data_source!.info_list.notion_info_list = getNotionInfo(notionPages, notionCredentialId) + + if (dataSourceType === DataSourceType.WEB) { + params.data_source!.info_list.website_info_list = getWebsiteInfo({ + websiteCrawlProvider, + websiteCrawlJobId, + websitePages, + crawlOptions, + }) + } + + return params + }, [ + isSetting, + documentDetail, + dataSourceType, + files, + notionPages, + notionCredentialId, + websitePages, + websiteCrawlProvider, + websiteCrawlJobId, + crawlOptions, + ]) + + // Execute creation + const executeCreation = useCallback(async ( + params: CreateDocumentReq, + indexType: IndexingType, + retrievalConfig: RetrievalConfig, + ) => { + if (!datasetId) { + await createFirstDocumentMutation.mutateAsync(params, { + onSuccess(data) { + updateIndexingTypeCache?.(indexType) + updateResultCache?.(data) + updateRetrievalMethodCache?.(retrievalConfig.search_method as RETRIEVE_METHOD) + }, + }) + } + else { + await createDocumentMutation.mutateAsync(params, { + onSuccess(data) { + updateIndexingTypeCache?.(indexType) + updateResultCache?.(data) + updateRetrievalMethodCache?.(retrievalConfig.search_method as RETRIEVE_METHOD) + }, + }) + } + + mutateDatasetRes?.() + invalidDatasetList() + + trackEvent('create_datasets', { + data_source_type: dataSourceType, + indexing_technique: indexType, + }) + + onStepChange?.(+1) + + if (isSetting) + onSave?.() + }, [ + datasetId, + createFirstDocumentMutation, + createDocumentMutation, + updateIndexingTypeCache, + updateResultCache, + updateRetrievalMethodCache, + mutateDatasetRes, + invalidDatasetList, + dataSourceType, + onStepChange, + isSetting, + onSave, + ]) + + // Validate preview params + const validatePreviewParams = useCallback((maxChunkLength: number): boolean => { + if (maxChunkLength > MAXIMUM_CHUNK_TOKEN_LENGTH) { + Toast.notify({ + type: 'error', + message: t('stepTwo.maxLengthCheck', { ns: 'datasetCreation', limit: MAXIMUM_CHUNK_TOKEN_LENGTH }), + }) + return false + } + return true + }, [t]) + + return { + isCreating, + validateParams, + buildCreationParams, + executeCreation, + validatePreviewParams, + } +} + +export type DocumentCreation = ReturnType diff --git a/web/app/components/datasets/create/step-two/hooks/use-indexing-config.ts b/web/app/components/datasets/create/step-two/hooks/use-indexing-config.ts new file mode 100644 index 0000000000..97fc9c260f --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/use-indexing-config.ts @@ -0,0 +1,143 @@ +import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { RetrievalConfig } from '@/types/app' +import { useEffect, useMemo, useState } from 'react' +import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils' +import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { useDefaultModel, useModelList, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { RETRIEVE_METHOD } from '@/types/app' + +export enum IndexingType { + QUALIFIED = 'high_quality', + ECONOMICAL = 'economy', +} + +const DEFAULT_RETRIEVAL_CONFIG: RetrievalConfig = { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, +} + +export type UseIndexingConfigOptions = { + initialIndexType?: IndexingType + initialEmbeddingModel?: DefaultModel + initialRetrievalConfig?: RetrievalConfig + isAPIKeySet: boolean + hasSetIndexType: boolean +} + +export const useIndexingConfig = (options: UseIndexingConfigOptions) => { + const { + initialIndexType, + initialEmbeddingModel, + initialRetrievalConfig, + isAPIKeySet, + hasSetIndexType, + } = options + + // Rerank model + const { + modelList: rerankModelList, + defaultModel: rerankDefaultModel, + currentModel: isRerankDefaultModelValid, + } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) + + // Embedding model list + const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) + const { data: defaultEmbeddingModel } = useDefaultModel(ModelTypeEnum.textEmbedding) + + // Index type state + const [indexType, setIndexType] = useState(() => { + if (initialIndexType) + return initialIndexType + return isAPIKeySet ? IndexingType.QUALIFIED : IndexingType.ECONOMICAL + }) + + // Embedding model state + const [embeddingModel, setEmbeddingModel] = useState( + initialEmbeddingModel ?? { + provider: defaultEmbeddingModel?.provider.provider || '', + model: defaultEmbeddingModel?.model || '', + }, + ) + + // Retrieval config state + const [retrievalConfig, setRetrievalConfig] = useState( + initialRetrievalConfig ?? DEFAULT_RETRIEVAL_CONFIG, + ) + + // Sync retrieval config with rerank model when available + useEffect(() => { + if (initialRetrievalConfig) + return + + setRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: !!isRerankDefaultModelValid, + reranking_model: { + reranking_provider_name: isRerankDefaultModelValid ? rerankDefaultModel?.provider.provider ?? '' : '', + reranking_model_name: isRerankDefaultModelValid ? rerankDefaultModel?.model ?? '' : '', + }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }) + }, [rerankDefaultModel, isRerankDefaultModelValid, initialRetrievalConfig]) + + // Sync index type with props + useEffect(() => { + if (initialIndexType) + setIndexType(initialIndexType) + else + setIndexType(isAPIKeySet ? IndexingType.QUALIFIED : IndexingType.ECONOMICAL) + }, [isAPIKeySet, initialIndexType]) + + // Show multimodal tip + const showMultiModalTip = useMemo(() => { + return checkShowMultiModalTip({ + embeddingModel, + rerankingEnable: retrievalConfig.reranking_enable, + rerankModel: { + rerankingProviderName: retrievalConfig.reranking_model.reranking_provider_name, + rerankingModelName: retrievalConfig.reranking_model.reranking_model_name, + }, + indexMethod: indexType, + embeddingModelList, + rerankModelList, + }) + }, [embeddingModel, retrievalConfig, indexType, embeddingModelList, rerankModelList]) + + // Get effective indexing technique + const getIndexingTechnique = () => initialIndexType || indexType + + return { + // Index type + indexType, + setIndexType, + hasSetIndexType, + getIndexingTechnique, + + // Embedding model + embeddingModel, + setEmbeddingModel, + embeddingModelList, + defaultEmbeddingModel, + + // Retrieval config + retrievalConfig, + setRetrievalConfig, + rerankModelList, + rerankDefaultModel, + isRerankDefaultModelValid, + + // Computed + showMultiModalTip, + } +} + +export type IndexingConfig = ReturnType diff --git a/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts b/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts new file mode 100644 index 0000000000..cc5a2bcf33 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/use-indexing-estimate.ts @@ -0,0 +1,123 @@ +import type { IndexingType } from './use-indexing-config' +import type { NotionPage } from '@/models/common' +import type { ChunkingMode, CrawlOptions, CrawlResultItem, CustomFile, ProcessRule } from '@/models/datasets' +import { useCallback } from 'react' +import { DataSourceProvider } from '@/models/common' +import { DataSourceType } from '@/models/datasets' +import { + useFetchFileIndexingEstimateForFile, + useFetchFileIndexingEstimateForNotion, + useFetchFileIndexingEstimateForWeb, +} from '@/service/knowledge/use-create-dataset' + +export type UseIndexingEstimateOptions = { + dataSourceType: DataSourceType + datasetId?: string + // Document settings + currentDocForm: ChunkingMode + docLanguage: string + // File data source + files: CustomFile[] + previewFileName?: string + // Notion data source + previewNotionPage: NotionPage + notionCredentialId: string + // Website data source + previewWebsitePage: CrawlResultItem + crawlOptions?: CrawlOptions + websiteCrawlProvider?: DataSourceProvider + websiteCrawlJobId?: string + // Processing + indexingTechnique: IndexingType + processRule: ProcessRule +} + +export const useIndexingEstimate = (options: UseIndexingEstimateOptions) => { + const { + dataSourceType, + datasetId, + currentDocForm, + docLanguage, + files, + previewFileName, + previewNotionPage, + notionCredentialId, + previewWebsitePage, + crawlOptions, + websiteCrawlProvider, + websiteCrawlJobId, + indexingTechnique, + processRule, + } = options + + // File indexing estimate + const fileQuery = useFetchFileIndexingEstimateForFile({ + docForm: currentDocForm, + docLanguage, + dataSourceType: DataSourceType.FILE, + files: previewFileName + ? [files.find(file => file.name === previewFileName)!] + : files, + indexingTechnique, + processRule, + dataset_id: datasetId!, + }) + + // Notion indexing estimate + const notionQuery = useFetchFileIndexingEstimateForNotion({ + docForm: currentDocForm, + docLanguage, + dataSourceType: DataSourceType.NOTION, + notionPages: [previewNotionPage], + indexingTechnique, + processRule, + dataset_id: datasetId || '', + credential_id: notionCredentialId, + }) + + // Website indexing estimate + const websiteQuery = useFetchFileIndexingEstimateForWeb({ + docForm: currentDocForm, + docLanguage, + dataSourceType: DataSourceType.WEB, + websitePages: [previewWebsitePage], + crawlOptions, + websiteCrawlProvider: websiteCrawlProvider ?? DataSourceProvider.jinaReader, + websiteCrawlJobId: websiteCrawlJobId ?? '', + indexingTechnique, + processRule, + dataset_id: datasetId || '', + }) + + // Get current mutation based on data source type + const getCurrentMutation = useCallback(() => { + if (dataSourceType === DataSourceType.FILE) + return fileQuery + if (dataSourceType === DataSourceType.NOTION) + return notionQuery + return websiteQuery + }, [dataSourceType, fileQuery, notionQuery, websiteQuery]) + + const currentMutation = getCurrentMutation() + + // Trigger estimate fetch + const fetchEstimate = useCallback(() => { + if (dataSourceType === DataSourceType.FILE) + fileQuery.mutate() + else if (dataSourceType === DataSourceType.NOTION) + notionQuery.mutate() + else + websiteQuery.mutate() + }, [dataSourceType, fileQuery, notionQuery, websiteQuery]) + + return { + currentMutation, + estimate: currentMutation.data, + isIdle: currentMutation.isIdle, + isPending: currentMutation.isPending, + fetchEstimate, + reset: currentMutation.reset, + } +} + +export type IndexingEstimate = ReturnType diff --git a/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts b/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts new file mode 100644 index 0000000000..94171c5947 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/use-preview-state.ts @@ -0,0 +1,127 @@ +import type { NotionPage } from '@/models/common' +import type { CrawlResultItem, CustomFile, DocumentItem, FullDocumentDetail } from '@/models/datasets' +import { useCallback, useState } from 'react' +import { DataSourceType } from '@/models/datasets' + +export type UsePreviewStateOptions = { + dataSourceType: DataSourceType + files: CustomFile[] + notionPages: NotionPage[] + websitePages: CrawlResultItem[] + documentDetail?: FullDocumentDetail + datasetId?: string +} + +export const usePreviewState = (options: UsePreviewStateOptions) => { + const { + dataSourceType, + files, + notionPages, + websitePages, + documentDetail, + datasetId, + } = options + + // File preview state + const [previewFile, setPreviewFile] = useState( + (datasetId && documentDetail) + ? documentDetail.file + : files[0], + ) + + // Notion page preview state + const [previewNotionPage, setPreviewNotionPage] = useState( + (datasetId && documentDetail) + ? documentDetail.notion_page + : notionPages[0], + ) + + // Website page preview state + const [previewWebsitePage, setPreviewWebsitePage] = useState( + (datasetId && documentDetail) + ? documentDetail.website_page + : websitePages[0], + ) + + // Get preview items for document picker based on data source type + const getPreviewPickerItems = useCallback(() => { + if (dataSourceType === DataSourceType.FILE) { + return files as Array> + } + if (dataSourceType === DataSourceType.NOTION) { + return notionPages.map(page => ({ + id: page.page_id, + name: page.page_name, + extension: 'md', + })) + } + if (dataSourceType === DataSourceType.WEB) { + return websitePages.map(page => ({ + id: page.source_url, + name: page.title, + extension: 'md', + })) + } + return [] + }, [dataSourceType, files, notionPages, websitePages]) + + // Get current preview value for picker + const getPreviewPickerValue = useCallback(() => { + if (dataSourceType === DataSourceType.FILE) { + return previewFile as Required + } + if (dataSourceType === DataSourceType.NOTION) { + return { + id: previewNotionPage?.page_id || '', + name: previewNotionPage?.page_name || '', + extension: 'md', + } + } + if (dataSourceType === DataSourceType.WEB) { + return { + id: previewWebsitePage?.source_url || '', + name: previewWebsitePage?.title || '', + extension: 'md', + } + } + return { id: '', name: '', extension: '' } + }, [dataSourceType, previewFile, previewNotionPage, previewWebsitePage]) + + // Handle preview change + const handlePreviewChange = useCallback((selected: { id: string, name: string }) => { + if (dataSourceType === DataSourceType.FILE) { + setPreviewFile(selected as DocumentItem) + } + else if (dataSourceType === DataSourceType.NOTION) { + const selectedPage = notionPages.find(page => page.page_id === selected.id) + if (selectedPage) + setPreviewNotionPage(selectedPage) + } + else if (dataSourceType === DataSourceType.WEB) { + const selectedPage = websitePages.find(page => page.source_url === selected.id) + if (selectedPage) + setPreviewWebsitePage(selectedPage) + } + }, [dataSourceType, notionPages, websitePages]) + + return { + // File preview + previewFile, + setPreviewFile, + + // Notion preview + previewNotionPage, + setPreviewNotionPage, + + // Website preview + previewWebsitePage, + setPreviewWebsitePage, + + // Picker helpers + getPreviewPickerItems, + getPreviewPickerValue, + handlePreviewChange, + } +} + +export type PreviewState = ReturnType diff --git a/web/app/components/datasets/create/step-two/hooks/use-segmentation-state.ts b/web/app/components/datasets/create/step-two/hooks/use-segmentation-state.ts new file mode 100644 index 0000000000..69cc089b4f --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/use-segmentation-state.ts @@ -0,0 +1,222 @@ +import type { ParentMode, PreProcessingRule, ProcessRule, Rules } from '@/models/datasets' +import { useCallback, useState } from 'react' +import { ChunkingMode, ProcessMode } from '@/models/datasets' +import escape from './escape' +import unescape from './unescape' + +// Constants +export const DEFAULT_SEGMENT_IDENTIFIER = '\\n\\n' +export const DEFAULT_MAXIMUM_CHUNK_LENGTH = 1024 +export const DEFAULT_OVERLAP = 50 +export const MAXIMUM_CHUNK_TOKEN_LENGTH = Number.parseInt( + globalThis.document?.body?.getAttribute('data-public-indexing-max-segmentation-tokens-length') || '4000', + 10, +) + +export type ParentChildConfig = { + chunkForContext: ParentMode + parent: { + delimiter: string + maxLength: number + } + child: { + delimiter: string + maxLength: number + } +} + +export const defaultParentChildConfig: ParentChildConfig = { + chunkForContext: 'paragraph', + parent: { + delimiter: '\\n\\n', + maxLength: 1024, + }, + child: { + delimiter: '\\n', + maxLength: 512, + }, +} + +export type UseSegmentationStateOptions = { + initialSegmentationType?: ProcessMode +} + +export const useSegmentationState = (options: UseSegmentationStateOptions = {}) => { + const { initialSegmentationType } = options + + // Segmentation type (general or parent-child) + const [segmentationType, setSegmentationType] = useState( + initialSegmentationType ?? ProcessMode.general, + ) + + // General chunking settings + const [segmentIdentifier, doSetSegmentIdentifier] = useState(DEFAULT_SEGMENT_IDENTIFIER) + const [maxChunkLength, setMaxChunkLength] = useState(DEFAULT_MAXIMUM_CHUNK_LENGTH) + const [limitMaxChunkLength, setLimitMaxChunkLength] = useState(MAXIMUM_CHUNK_TOKEN_LENGTH) + const [overlap, setOverlap] = useState(DEFAULT_OVERLAP) + + // Pre-processing rules + const [rules, setRules] = useState([]) + const [defaultConfig, setDefaultConfig] = useState() + + // Parent-child config + const [parentChildConfig, setParentChildConfig] = useState(defaultParentChildConfig) + + // Escaped segment identifier setter + const setSegmentIdentifier = useCallback((value: string, canEmpty?: boolean) => { + if (value) { + doSetSegmentIdentifier(escape(value)) + } + else { + doSetSegmentIdentifier(canEmpty ? '' : DEFAULT_SEGMENT_IDENTIFIER) + } + }, []) + + // Rule toggle handler + const toggleRule = useCallback((id: string) => { + setRules(prev => prev.map(rule => + rule.id === id ? { ...rule, enabled: !rule.enabled } : rule, + )) + }, []) + + // Reset to defaults + const resetToDefaults = useCallback(() => { + if (defaultConfig) { + setSegmentIdentifier(defaultConfig.segmentation.separator) + setMaxChunkLength(defaultConfig.segmentation.max_tokens) + setOverlap(defaultConfig.segmentation.chunk_overlap!) + setRules(defaultConfig.pre_processing_rules) + } + setParentChildConfig(defaultParentChildConfig) + }, [defaultConfig, setSegmentIdentifier]) + + // Apply config from document detail + const applyConfigFromRules = useCallback((rulesConfig: Rules, isHierarchical: boolean) => { + const separator = rulesConfig.segmentation.separator + const max = rulesConfig.segmentation.max_tokens + const chunkOverlap = rulesConfig.segmentation.chunk_overlap + + setSegmentIdentifier(separator) + setMaxChunkLength(max) + setOverlap(chunkOverlap!) + setRules(rulesConfig.pre_processing_rules) + setDefaultConfig(rulesConfig) + + if (isHierarchical) { + setParentChildConfig({ + chunkForContext: rulesConfig.parent_mode || 'paragraph', + parent: { + delimiter: escape(rulesConfig.segmentation.separator), + maxLength: rulesConfig.segmentation.max_tokens, + }, + child: { + delimiter: escape(rulesConfig.subchunk_segmentation!.separator), + maxLength: rulesConfig.subchunk_segmentation!.max_tokens, + }, + }) + } + }, [setSegmentIdentifier]) + + // Get process rule for API + const getProcessRule = useCallback((docForm: ChunkingMode): ProcessRule => { + if (docForm === ChunkingMode.parentChild) { + return { + rules: { + pre_processing_rules: rules, + segmentation: { + separator: unescape(parentChildConfig.parent.delimiter), + max_tokens: parentChildConfig.parent.maxLength, + }, + parent_mode: parentChildConfig.chunkForContext, + subchunk_segmentation: { + separator: unescape(parentChildConfig.child.delimiter), + max_tokens: parentChildConfig.child.maxLength, + }, + }, + mode: 'hierarchical', + } as ProcessRule + } + + return { + rules: { + pre_processing_rules: rules, + segmentation: { + separator: unescape(segmentIdentifier), + max_tokens: maxChunkLength, + chunk_overlap: overlap, + }, + }, + mode: segmentationType, + } as ProcessRule + }, [rules, parentChildConfig, segmentIdentifier, maxChunkLength, overlap, segmentationType]) + + // Update parent config field + const updateParentConfig = useCallback((field: 'delimiter' | 'maxLength', value: string | number) => { + setParentChildConfig((prev) => { + let newValue: string | number + if (field === 'delimiter') + newValue = value ? escape(value as string) : '' + else + newValue = value + return { + ...prev, + parent: { ...prev.parent, [field]: newValue }, + } + }) + }, []) + + // Update child config field + const updateChildConfig = useCallback((field: 'delimiter' | 'maxLength', value: string | number) => { + setParentChildConfig((prev) => { + let newValue: string | number + if (field === 'delimiter') + newValue = value ? escape(value as string) : '' + else + newValue = value + return { + ...prev, + child: { ...prev.child, [field]: newValue }, + } + }) + }, []) + + // Set chunk for context mode + const setChunkForContext = useCallback((mode: ParentMode) => { + setParentChildConfig(prev => ({ ...prev, chunkForContext: mode })) + }, []) + + return { + // General chunking state + segmentationType, + setSegmentationType, + segmentIdentifier, + setSegmentIdentifier, + maxChunkLength, + setMaxChunkLength, + limitMaxChunkLength, + setLimitMaxChunkLength, + overlap, + setOverlap, + + // Rules + rules, + setRules, + defaultConfig, + setDefaultConfig, + toggleRule, + + // Parent-child config + parentChildConfig, + setParentChildConfig, + updateParentConfig, + updateChildConfig, + setChunkForContext, + + // Actions + resetToDefaults, + applyConfigFromRules, + getProcessRule, + } +} + +export type SegmentationState = ReturnType diff --git a/web/app/components/datasets/create/step-two/index.spec.tsx b/web/app/components/datasets/create/step-two/index.spec.tsx new file mode 100644 index 0000000000..7145920f60 --- /dev/null +++ b/web/app/components/datasets/create/step-two/index.spec.tsx @@ -0,0 +1,2197 @@ +import type { Model } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { DataSourceProvider, NotionPage } from '@/models/common' +import type { + CrawlOptions, + CrawlResultItem, + CustomFile, + FileIndexingEstimateResponse, + FullDocumentDetail, + PreProcessingRule, + Rules, +} from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' +import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' +import { PreviewPanel } from './components/preview-panel' +import { StepTwoFooter } from './components/step-two-footer' +import { + DEFAULT_MAXIMUM_CHUNK_LENGTH, + DEFAULT_OVERLAP, + DEFAULT_SEGMENT_IDENTIFIER, + defaultParentChildConfig, + IndexingType, + useDocumentCreation, + useIndexingConfig, + useIndexingEstimate, + usePreviewState, + useSegmentationState, +} from './hooks' +import escape from './hooks/escape' +import unescape from './hooks/unescape' + +// ============================================ +// Mock external dependencies +// ============================================ + +// Mock dataset detail context +const mockDataset = { + id: 'test-dataset-id', + doc_form: ChunkingMode.text, + data_source_type: DataSourceType.FILE, + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + } as RetrievalConfig, +} + +let mockCurrentDataset: typeof mockDataset | null = null +const mockMutateDatasetRes = vi.fn() + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: { dataset: typeof mockDataset | null, mutateDatasetRes: () => void }) => unknown) => + selector({ dataset: mockCurrentDataset, mutateDatasetRes: mockMutateDatasetRes }), +})) + +// Note: @/context/i18n is globally mocked in vitest.setup.ts, no need to mock here +// Note: @/hooks/use-breakpoints uses real import + +// Mock model hooks +const mockEmbeddingModelList = [ + { provider: 'openai', model: 'text-embedding-ada-002' }, + { provider: 'cohere', model: 'embed-english-v3.0' }, +] +const mockDefaultEmbeddingModel = { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' } +// Model[] type structure for rerank model list (simplified mock) +const mockRerankModelList: Model[] = [{ + provider: 'cohere', + icon_small: { en_US: 'cohere-icon', zh_Hans: 'cohere-icon' }, + label: { en_US: 'Cohere', zh_Hans: 'Cohere' }, + models: [{ + model: 'rerank-english-v3.0', + label: { en_US: 'Rerank English v3.0', zh_Hans: 'Rerank English v3.0' }, + model_type: ModelTypeEnum.rerank, + features: [], + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, + }], + status: ModelStatusEnum.active, +}] +const mockRerankDefaultModel = { provider: { provider: 'cohere' }, model: 'rerank-english-v3.0' } +let mockIsRerankDefaultModelValid = true + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({ + modelList: mockRerankModelList, + defaultModel: mockRerankDefaultModel, + currentModel: mockIsRerankDefaultModelValid, + }), + useModelList: () => ({ data: mockEmbeddingModelList }), + useDefaultModel: () => ({ data: mockDefaultEmbeddingModel }), +})) + +// Mock service hooks +const mockFetchDefaultProcessRuleMutate = vi.fn() +vi.mock('@/service/knowledge/use-create-dataset', () => ({ + useFetchDefaultProcessRule: ({ onSuccess }: { onSuccess: (data: { rules: Rules, limits: { indexing_max_segmentation_tokens_length: number } }) => void }) => ({ + mutate: (url: string) => { + mockFetchDefaultProcessRuleMutate(url) + onSuccess({ + rules: { + segmentation: { separator: '\\n', max_tokens: 500, chunk_overlap: 50 }, + pre_processing_rules: [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, + ], + parent_mode: 'paragraph', + subchunk_segmentation: { separator: '\\n', max_tokens: 256 }, + }, + limits: { indexing_max_segmentation_tokens_length: 4000 }, + }) + }, + isPending: false, + }), + useFetchFileIndexingEstimateForFile: () => ({ + mutate: vi.fn(), + data: undefined, + isIdle: true, + isPending: false, + reset: vi.fn(), + }), + useFetchFileIndexingEstimateForNotion: () => ({ + mutate: vi.fn(), + data: undefined, + isIdle: true, + isPending: false, + reset: vi.fn(), + }), + useFetchFileIndexingEstimateForWeb: () => ({ + mutate: vi.fn(), + data: undefined, + isIdle: true, + isPending: false, + reset: vi.fn(), + }), + useCreateFirstDocument: () => ({ + mutateAsync: vi.fn().mockImplementation(async (params: unknown, options?: { onSuccess?: (data: unknown) => void }) => { + const data = { dataset: { id: 'new-dataset-id' } } + options?.onSuccess?.(data) + return data + }), + isPending: false, + }), + useCreateDocument: () => ({ + mutateAsync: vi.fn().mockImplementation(async (params: unknown, options?: { onSuccess?: (data: unknown) => void }) => { + const data = { document: { id: 'new-doc-id' } } + options?.onSuccess?.(data) + return data + }), + isPending: false, + }), + getNotionInfo: vi.fn().mockReturnValue([{ workspace_id: 'ws-1', pages: [{ page_id: 'page-1' }] }]), + getWebsiteInfo: vi.fn().mockReturnValue({ provider: 'jinaReader', job_id: 'job-123', urls: ['https://test.com'] }), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useInvalidDatasetList: () => vi.fn(), +})) + +// Mock amplitude tracking (external service) +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: vi.fn(), +})) + +// Note: @/app/components/base/toast - uses real import (base component) +// Note: @/app/components/datasets/common/check-rerank-model - uses real import +// Note: @/app/components/base/float-right-container - uses real import (base component) + +// Mock checkShowMultiModalTip - requires complex model list structure +vi.mock('@/app/components/datasets/settings/utils', () => ({ + checkShowMultiModalTip: () => false, +})) + +// ============================================ +// Test data factories +// ============================================ + +const createMockFile = (overrides?: Partial): CustomFile => ({ + id: 'file-1', + name: 'test-file.pdf', + extension: 'pdf', + size: 1024, + type: 'application/pdf', + lastModified: Date.now(), + ...overrides, +} as CustomFile) + +const createMockNotionPage = (overrides?: Partial): NotionPage => ({ + page_id: 'notion-page-1', + page_name: 'Test Notion Page', + page_icon: null, + type: 'page', + ...overrides, +} as NotionPage) + +const createMockWebsitePage = (overrides?: Partial): CrawlResultItem => ({ + source_url: 'https://example.com/page1', + title: 'Test Website Page', + description: 'Test description', + markdown: '# Test Content', + ...overrides, +} as CrawlResultItem) + +const createMockDocumentDetail = (overrides?: Partial): FullDocumentDetail => ({ + id: 'doc-1', + doc_form: ChunkingMode.text, + doc_language: 'English', + file: { id: 'file-1', name: 'test.pdf', extension: 'pdf' }, + notion_page: createMockNotionPage(), + website_page: createMockWebsitePage(), + dataset_process_rule: { + mode: ProcessMode.general, + rules: { + segmentation: { separator: '\\n\\n', max_tokens: 1024, chunk_overlap: 50 }, + pre_processing_rules: [{ id: 'remove_extra_spaces', enabled: true }], + }, + }, + ...overrides, +} as FullDocumentDetail) + +const createMockRules = (overrides?: Partial): Rules => ({ + segmentation: { separator: '\\n\\n', max_tokens: 1024, chunk_overlap: 50 }, + pre_processing_rules: [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, + ], + parent_mode: 'paragraph', + subchunk_segmentation: { separator: '\\n', max_tokens: 512 }, + ...overrides, +}) + +const createMockEstimate = (overrides?: Partial): FileIndexingEstimateResponse => ({ + total_segments: 10, + total_nodes: 10, + tokens: 5000, + total_price: 0.01, + currency: 'USD', + qa_preview: [{ question: 'Q1', answer: 'A1' }], + preview: [{ content: 'Chunk 1 content', child_chunks: ['Child 1', 'Child 2'] }], + ...overrides, +}) + +// ============================================ +// Utility Functions Tests (escape/unescape) +// ============================================ + +describe('escape utility', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Tests for escape function + describe('escape function', () => { + it('should return empty string for null/undefined input', () => { + expect(escape(null as unknown as string)).toBe('') + expect(escape(undefined as unknown as string)).toBe('') + expect(escape('')).toBe('') + }) + + it('should escape newline characters', () => { + expect(escape('\n')).toBe('\\n') + expect(escape('\r')).toBe('\\r') + expect(escape('\n\r')).toBe('\\n\\r') + }) + + it('should escape tab characters', () => { + expect(escape('\t')).toBe('\\t') + }) + + it('should escape other special characters', () => { + expect(escape('\0')).toBe('\\0') + expect(escape('\b')).toBe('\\b') + expect(escape('\f')).toBe('\\f') + expect(escape('\v')).toBe('\\v') + }) + + it('should escape single quotes', () => { + expect(escape('\'')).toBe('\\\'') + }) + + it('should handle mixed content', () => { + expect(escape('Hello\nWorld\t!')).toBe('Hello\\nWorld\\t!') + }) + + it('should not escape regular characters', () => { + expect(escape('Hello World')).toBe('Hello World') + expect(escape('abc123')).toBe('abc123') + }) + + it('should return empty string for non-string input', () => { + expect(escape(123 as unknown as string)).toBe('') + expect(escape({} as unknown as string)).toBe('') + }) + }) +}) + +describe('unescape utility', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Tests for unescape function + describe('unescape function', () => { + it('should unescape newline characters', () => { + expect(unescape('\\n')).toBe('\n') + expect(unescape('\\r')).toBe('\r') + }) + + it('should unescape tab characters', () => { + expect(unescape('\\t')).toBe('\t') + }) + + it('should unescape other special characters', () => { + expect(unescape('\\0')).toBe('\0') + expect(unescape('\\b')).toBe('\b') + expect(unescape('\\f')).toBe('\f') + expect(unescape('\\v')).toBe('\v') + }) + + it('should unescape single and double quotes', () => { + expect(unescape('\\\'')).toBe('\'') + expect(unescape('\\"')).toBe('"') + }) + + it('should unescape backslash', () => { + expect(unescape('\\\\')).toBe('\\') + }) + + it('should unescape hex sequences', () => { + expect(unescape('\\x41')).toBe('A') // 0x41 = 65 = 'A' + expect(unescape('\\x5A')).toBe('Z') // 0x5A = 90 = 'Z' + }) + + it('should unescape short hex (2-digit) sequences', () => { + // Short hex format: \xNN (2 hexadecimal digits) + expect(unescape('\\xA5')).toBe('¥') // Yen sign + expect(unescape('\\x7F')).toBe('\x7F') // Delete character + expect(unescape('\\x00')).toBe('\x00') // Null character via hex + }) + + it('should unescape octal sequences', () => { + expect(unescape('\\101')).toBe('A') // Octal 101 = 65 = 'A' + expect(unescape('\\132')).toBe('Z') // Octal 132 = 90 = 'Z' + expect(unescape('\\7')).toBe('\x07') // Single digit octal + }) + + it('should unescape unicode sequences', () => { + expect(unescape('\\u0041')).toBe('A') + expect(unescape('\\u{41}')).toBe('A') + }) + + it('should unescape Python-style unicode', () => { + expect(unescape('\\U00000041')).toBe('A') + }) + + it('should handle mixed content', () => { + expect(unescape('Hello\\nWorld\\t!')).toBe('Hello\nWorld\t!') + }) + + it('should not modify regular text', () => { + expect(unescape('Hello World')).toBe('Hello World') + }) + }) +}) + +// ============================================ +// useSegmentationState Hook Tests +// ============================================ + +describe('useSegmentationState', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Tests for initial state + describe('Initial State', () => { + it('should initialize with default values', () => { + const { result } = renderHook(() => useSegmentationState()) + + expect(result.current.segmentationType).toBe(ProcessMode.general) + expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER) + expect(result.current.maxChunkLength).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH) + expect(result.current.overlap).toBe(DEFAULT_OVERLAP) + expect(result.current.rules).toEqual([]) + expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig) + }) + + it('should initialize with custom segmentation type', () => { + const { result } = renderHook(() => + useSegmentationState({ initialSegmentationType: ProcessMode.parentChild }), + ) + + expect(result.current.segmentationType).toBe(ProcessMode.parentChild) + }) + }) + + // Tests for state setters + describe('State Management', () => { + it('should update segmentation type', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentationType(ProcessMode.parentChild) + }) + + expect(result.current.segmentationType).toBe(ProcessMode.parentChild) + }) + + it('should update max chunk length', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setMaxChunkLength(2048) + }) + + expect(result.current.maxChunkLength).toBe(2048) + }) + + it('should update overlap', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setOverlap(100) + }) + + expect(result.current.overlap).toBe(100) + }) + + it('should update rules', () => { + const { result } = renderHook(() => useSegmentationState()) + const newRules: PreProcessingRule[] = [{ id: 'test', enabled: true }] + + act(() => { + result.current.setRules(newRules) + }) + + expect(result.current.rules).toEqual(newRules) + }) + }) + + // Tests for setSegmentIdentifier with escape + describe('setSegmentIdentifier', () => { + it('should escape special characters', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentIdentifier('\n\n') + }) + + expect(result.current.segmentIdentifier).toBe('\\n\\n') + }) + + it('should use default when empty and canEmpty is false', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentIdentifier('') + }) + + expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER) + }) + + it('should allow empty when canEmpty is true', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentIdentifier('', true) + }) + + expect(result.current.segmentIdentifier).toBe('') + }) + }) + + // Tests for toggleRule + describe('toggleRule', () => { + it('should toggle rule enabled state', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setRules([ + { id: 'rule1', enabled: true }, + { id: 'rule2', enabled: false }, + ]) + }) + + act(() => { + result.current.toggleRule('rule1') + }) + + expect(result.current.rules.find(r => r.id === 'rule1')?.enabled).toBe(false) + expect(result.current.rules.find(r => r.id === 'rule2')?.enabled).toBe(false) + }) + + it('should not affect other rules', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setRules([ + { id: 'rule1', enabled: true }, + { id: 'rule2', enabled: false }, + ]) + }) + + act(() => { + result.current.toggleRule('rule2') + }) + + expect(result.current.rules.find(r => r.id === 'rule1')?.enabled).toBe(true) + expect(result.current.rules.find(r => r.id === 'rule2')?.enabled).toBe(true) + }) + }) + + // Tests for parent-child config + describe('Parent-Child Configuration', () => { + it('should update parent config delimiter with truthy value', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateParentConfig('delimiter', '\n\n\n') + }) + + expect(result.current.parentChildConfig.parent.delimiter).toBe('\\n\\n\\n') + }) + + it('should update parent config delimiter with empty value', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateParentConfig('delimiter', '') + }) + + expect(result.current.parentChildConfig.parent.delimiter).toBe('') + }) + + it('should update parent config maxLength', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateParentConfig('maxLength', 2048) + }) + + expect(result.current.parentChildConfig.parent.maxLength).toBe(2048) + }) + + it('should update child config delimiter with truthy value', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateChildConfig('delimiter', '\n') + }) + + expect(result.current.parentChildConfig.child.delimiter).toBe('\\n') + }) + + it('should update child config delimiter with empty value', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateChildConfig('delimiter', '') + }) + + expect(result.current.parentChildConfig.child.delimiter).toBe('') + }) + + it('should update child config maxLength', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateChildConfig('maxLength', 256) + }) + + expect(result.current.parentChildConfig.child.maxLength).toBe(256) + }) + + it('should set chunk for context mode', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setChunkForContext('full-doc') + }) + + expect(result.current.parentChildConfig.chunkForContext).toBe('full-doc') + }) + }) + + // Tests for resetToDefaults + describe('resetToDefaults', () => { + it('should reset to default config when available', () => { + const { result } = renderHook(() => useSegmentationState()) + + // Set non-default values and default config + act(() => { + result.current.setMaxChunkLength(2048) + result.current.setOverlap(100) + result.current.setDefaultConfig(createMockRules()) + }) + + // Reset - should use default config values + act(() => { + result.current.resetToDefaults() + }) + + expect(result.current.maxChunkLength).toBe(1024) + expect(result.current.overlap).toBe(50) + expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig) + }) + + it('should only reset parentChildConfig when no default config', () => { + const { result } = renderHook(() => useSegmentationState()) + + // Set non-default values without setting defaultConfig + act(() => { + result.current.setMaxChunkLength(2048) + result.current.setOverlap(100) + result.current.setChunkForContext('full-doc') + }) + + // Reset - should only reset parentChildConfig since no default config + act(() => { + result.current.resetToDefaults() + }) + + // Values stay the same since no defaultConfig + expect(result.current.maxChunkLength).toBe(2048) + expect(result.current.overlap).toBe(100) + // But parentChildConfig is always reset + expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig) + }) + }) + + // Tests for applyConfigFromRules + describe('applyConfigFromRules', () => { + it('should apply general config from rules', () => { + const { result } = renderHook(() => useSegmentationState()) + const rules = createMockRules({ + segmentation: { separator: '---', max_tokens: 512, chunk_overlap: 25 }, + }) + + act(() => { + result.current.applyConfigFromRules(rules, false) + }) + + expect(result.current.maxChunkLength).toBe(512) + expect(result.current.overlap).toBe(25) + }) + + it('should apply hierarchical config from rules', () => { + const { result } = renderHook(() => useSegmentationState()) + const rules = createMockRules({ + parent_mode: 'paragraph', + subchunk_segmentation: { separator: '\n', max_tokens: 256 }, + }) + + act(() => { + result.current.applyConfigFromRules(rules, true) + }) + + expect(result.current.parentChildConfig.chunkForContext).toBe('paragraph') + expect(result.current.parentChildConfig.child.maxLength).toBe(256) + }) + + it('should apply full hierarchical parent-child config from rules', () => { + const { result } = renderHook(() => useSegmentationState()) + const rules = createMockRules({ + segmentation: { separator: '\n\n', max_tokens: 1024, chunk_overlap: 50 }, + parent_mode: 'full-doc', + subchunk_segmentation: { separator: '\n', max_tokens: 128 }, + }) + + act(() => { + result.current.applyConfigFromRules(rules, true) + }) + + // Should set parent config from segmentation + expect(result.current.parentChildConfig.parent.delimiter).toBe('\\n\\n') + expect(result.current.parentChildConfig.parent.maxLength).toBe(1024) + // Should set child config from subchunk_segmentation + expect(result.current.parentChildConfig.child.delimiter).toBe('\\n') + expect(result.current.parentChildConfig.child.maxLength).toBe(128) + // Should set chunkForContext + expect(result.current.parentChildConfig.chunkForContext).toBe('full-doc') + }) + }) + + // Tests for getProcessRule + describe('getProcessRule', () => { + it('should return general process rule', () => { + const { result } = renderHook(() => useSegmentationState()) + + const processRule = result.current.getProcessRule(ChunkingMode.text) + + expect(processRule.mode).toBe(ProcessMode.general) + expect(processRule.rules.segmentation.max_tokens).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH) + }) + + it('should return hierarchical process rule for parent-child', () => { + const { result } = renderHook(() => useSegmentationState()) + + const processRule = result.current.getProcessRule(ChunkingMode.parentChild) + + expect(processRule.mode).toBe('hierarchical') + expect(processRule.rules.parent_mode).toBe('paragraph') + expect(processRule.rules.subchunk_segmentation).toBeDefined() + }) + }) +}) + +// ============================================ +// useIndexingConfig Hook Tests +// ============================================ + +describe('useIndexingConfig', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsRerankDefaultModelValid = true + }) + + // Tests for initial state + // Note: Hook has useEffect that syncs state, so we test the state after effects settle + describe('Initial State', () => { + it('should initialize with QUALIFIED when API key is set', async () => { + const { result } = renderHook(() => + useIndexingConfig({ isAPIKeySet: true, hasSetIndexType: false }), + ) + + // After effects settle, indexType should be QUALIFIED + await vi.waitFor(() => { + expect(result.current.indexType).toBe(IndexingType.QUALIFIED) + }) + }) + + it('should initialize with ECONOMICAL when API key is not set', async () => { + const { result } = renderHook(() => + useIndexingConfig({ isAPIKeySet: false, hasSetIndexType: false }), + ) + + await vi.waitFor(() => { + expect(result.current.indexType).toBe(IndexingType.ECONOMICAL) + }) + }) + + it('should use initial index type when provided', async () => { + const { result } = renderHook(() => + useIndexingConfig({ + isAPIKeySet: false, + hasSetIndexType: true, + initialIndexType: IndexingType.QUALIFIED, + }), + ) + + await vi.waitFor(() => { + expect(result.current.indexType).toBe(IndexingType.QUALIFIED) + }) + }) + }) + + // Tests for state setters + describe('State Management', () => { + it('should update index type', async () => { + const { result } = renderHook(() => + useIndexingConfig({ isAPIKeySet: true, hasSetIndexType: false }), + ) + + // Wait for initial effects to settle + await vi.waitFor(() => { + expect(result.current.indexType).toBeDefined() + }) + + act(() => { + result.current.setIndexType(IndexingType.ECONOMICAL) + }) + + expect(result.current.indexType).toBe(IndexingType.ECONOMICAL) + }) + + it('should update embedding model', async () => { + const { result } = renderHook(() => + useIndexingConfig({ isAPIKeySet: true, hasSetIndexType: false }), + ) + + await vi.waitFor(() => { + expect(result.current.embeddingModel).toBeDefined() + }) + + act(() => { + result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-v3' }) + }) + + expect(result.current.embeddingModel).toEqual({ provider: 'cohere', model: 'embed-v3' }) + }) + + it('should update retrieval config', async () => { + const { result } = renderHook(() => + useIndexingConfig({ isAPIKeySet: true, hasSetIndexType: false }), + ) + + await vi.waitFor(() => { + expect(result.current.retrievalConfig).toBeDefined() + }) + + const newConfig: RetrievalConfig = { + search_method: RETRIEVE_METHOD.hybrid, + reranking_enable: true, + reranking_model: { reranking_provider_name: 'cohere', reranking_model_name: 'rerank-v3' }, + top_k: 5, + score_threshold_enabled: true, + score_threshold: 0.7, + } + + act(() => { + result.current.setRetrievalConfig(newConfig) + }) + + expect(result.current.retrievalConfig).toEqual(newConfig) + }) + }) + + // Tests for getIndexingTechnique + describe('getIndexingTechnique', () => { + it('should return initial type when set', async () => { + const { result } = renderHook(() => + useIndexingConfig({ + isAPIKeySet: true, + hasSetIndexType: true, + initialIndexType: IndexingType.ECONOMICAL, + }), + ) + + await vi.waitFor(() => { + expect(result.current.getIndexingTechnique()).toBe(IndexingType.ECONOMICAL) + }) + }) + + it('should return current type when no initial type', async () => { + const { result } = renderHook(() => + useIndexingConfig({ isAPIKeySet: true, hasSetIndexType: false }), + ) + + await vi.waitFor(() => { + expect(result.current.indexType).toBeDefined() + }) + + act(() => { + result.current.setIndexType(IndexingType.ECONOMICAL) + }) + + expect(result.current.getIndexingTechnique()).toBe(IndexingType.ECONOMICAL) + }) + }) + + // Tests for initialRetrievalConfig handling + describe('initialRetrievalConfig', () => { + it('should skip retrieval config sync when initialRetrievalConfig is provided', async () => { + const customRetrievalConfig: RetrievalConfig = { + search_method: RETRIEVE_METHOD.hybrid, + reranking_enable: true, + reranking_model: { reranking_provider_name: 'custom', reranking_model_name: 'custom-model' }, + top_k: 10, + score_threshold_enabled: true, + score_threshold: 0.8, + } + + const { result } = renderHook(() => + useIndexingConfig({ + isAPIKeySet: true, + hasSetIndexType: false, + initialRetrievalConfig: customRetrievalConfig, + }), + ) + + await vi.waitFor(() => { + expect(result.current.retrievalConfig).toBeDefined() + }) + + // Should use the provided initial config, not the default synced one + expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.hybrid) + expect(result.current.retrievalConfig.top_k).toBe(10) + }) + }) +}) + +// ============================================ +// usePreviewState Hook Tests +// ============================================ + +describe('usePreviewState', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const defaultOptions = { + dataSourceType: DataSourceType.FILE, + files: [createMockFile()], + notionPages: [createMockNotionPage()], + websitePages: [createMockWebsitePage()], + } + + // Tests for initial state + describe('Initial State', () => { + it('should initialize with first file for FILE data source', () => { + const { result } = renderHook(() => usePreviewState(defaultOptions)) + + expect(result.current.previewFile).toEqual(defaultOptions.files[0]) + }) + + it('should initialize with first notion page for NOTION data source', () => { + const { result } = renderHook(() => + usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.NOTION }), + ) + + expect(result.current.previewNotionPage).toEqual(defaultOptions.notionPages[0]) + }) + + it('should initialize with document detail when provided', () => { + const documentDetail = createMockDocumentDetail() + const { result } = renderHook(() => + usePreviewState({ + ...defaultOptions, + documentDetail, + datasetId: 'test-id', + }), + ) + + expect(result.current.previewFile).toEqual(documentDetail.file) + }) + }) + + // Tests for getPreviewPickerItems + describe('getPreviewPickerItems', () => { + it('should return files for FILE data source', () => { + const { result } = renderHook(() => usePreviewState(defaultOptions)) + + const items = result.current.getPreviewPickerItems() + expect(items).toEqual(defaultOptions.files) + }) + + it('should return mapped notion pages for NOTION data source', () => { + const { result } = renderHook(() => + usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.NOTION }), + ) + + const items = result.current.getPreviewPickerItems() + expect(items[0]).toEqual({ + id: 'notion-page-1', + name: 'Test Notion Page', + extension: 'md', + }) + }) + + it('should return mapped website pages for WEB data source', () => { + const { result } = renderHook(() => + usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.WEB }), + ) + + const items = result.current.getPreviewPickerItems() + expect(items[0]).toEqual({ + id: 'https://example.com/page1', + name: 'Test Website Page', + extension: 'md', + }) + }) + + it('should return empty array for unknown data source', () => { + const { result } = renderHook(() => + usePreviewState({ ...defaultOptions, dataSourceType: 'unknown' as DataSourceType }), + ) + + const items = result.current.getPreviewPickerItems() + expect(items).toEqual([]) + }) + }) + + // Tests for getPreviewPickerValue + describe('getPreviewPickerValue', () => { + it('should return file value for FILE data source', () => { + const { result } = renderHook(() => usePreviewState(defaultOptions)) + + const value = result.current.getPreviewPickerValue() + expect(value).toEqual(defaultOptions.files[0]) + }) + + it('should return mapped notion page value for NOTION data source', () => { + const notionPage = createMockNotionPage({ page_id: 'page-123', page_name: 'My Page' }) + const { result } = renderHook(() => + usePreviewState({ + ...defaultOptions, + dataSourceType: DataSourceType.NOTION, + notionPages: [notionPage], + }), + ) + + const value = result.current.getPreviewPickerValue() + expect(value).toEqual({ + id: 'page-123', + name: 'My Page', + extension: 'md', + }) + }) + + it('should return mapped website page value for WEB data source', () => { + const websitePage = createMockWebsitePage({ source_url: 'https://test.com', title: 'Test Title' }) + const { result } = renderHook(() => + usePreviewState({ + ...defaultOptions, + dataSourceType: DataSourceType.WEB, + websitePages: [websitePage], + }), + ) + + const value = result.current.getPreviewPickerValue() + expect(value).toEqual({ + id: 'https://test.com', + name: 'Test Title', + extension: 'md', + }) + }) + + it('should return empty value for unknown data source', () => { + const { result } = renderHook(() => + usePreviewState({ ...defaultOptions, dataSourceType: 'unknown' as DataSourceType }), + ) + + const value = result.current.getPreviewPickerValue() + expect(value).toEqual({ id: '', name: '', extension: '' }) + }) + + it('should handle undefined notion page gracefully', () => { + const { result } = renderHook(() => + usePreviewState({ + ...defaultOptions, + dataSourceType: DataSourceType.NOTION, + notionPages: [], + }), + ) + + const value = result.current.getPreviewPickerValue() + expect(value).toEqual({ + id: '', + name: '', + extension: 'md', + }) + }) + + it('should handle undefined website page gracefully', () => { + const { result } = renderHook(() => + usePreviewState({ + ...defaultOptions, + dataSourceType: DataSourceType.WEB, + websitePages: [], + }), + ) + + const value = result.current.getPreviewPickerValue() + expect(value).toEqual({ + id: '', + name: '', + extension: 'md', + }) + }) + }) + + // Tests for handlePreviewChange + describe('handlePreviewChange', () => { + it('should update preview file for FILE data source', () => { + const files = [createMockFile(), createMockFile({ id: 'file-2', name: 'second.pdf' })] + const { result } = renderHook(() => + usePreviewState({ ...defaultOptions, files }), + ) + + act(() => { + result.current.handlePreviewChange({ id: 'file-2', name: 'second.pdf' }) + }) + + expect(result.current.previewFile).toEqual({ id: 'file-2', name: 'second.pdf' }) + }) + + it('should update preview notion page for NOTION data source', () => { + const notionPages = [ + createMockNotionPage(), + createMockNotionPage({ page_id: 'notion-page-2', page_name: 'Second Page' }), + ] + const { result } = renderHook(() => + usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.NOTION, notionPages }), + ) + + act(() => { + result.current.handlePreviewChange({ id: 'notion-page-2', name: 'Second Page' }) + }) + + expect(result.current.previewNotionPage?.page_id).toBe('notion-page-2') + }) + + it('should update preview website page for WEB data source', () => { + const websitePages = [ + createMockWebsitePage(), + createMockWebsitePage({ source_url: 'https://example.com/page2', title: 'Second Page' }), + ] + const { result } = renderHook(() => + usePreviewState({ ...defaultOptions, dataSourceType: DataSourceType.WEB, websitePages }), + ) + + act(() => { + result.current.handlePreviewChange({ id: 'https://example.com/page2', name: 'Second Page' }) + }) + + expect(result.current.previewWebsitePage?.source_url).toBe('https://example.com/page2') + }) + }) +}) + +// ============================================ +// useDocumentCreation Hook Tests +// ============================================ + +describe('useDocumentCreation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const defaultOptions = { + dataSourceType: DataSourceType.FILE, + files: [createMockFile()], + notionPages: [] as NotionPage[], + notionCredentialId: '', + websitePages: [] as CrawlResultItem[], + } + + // Tests for validateParams + describe('validateParams', () => { + it('should return false when overlap exceeds max chunk length', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + + const isValid = result.current.validateParams({ + segmentationType: 'general', + maxChunkLength: 100, + limitMaxChunkLength: 4000, + overlap: 200, + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' }, + rerankModelList: [], + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + }) + + expect(isValid).toBe(false) + }) + + it('should return false when max chunk length exceeds limit', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + + const isValid = result.current.validateParams({ + segmentationType: 'general', + maxChunkLength: 5000, + limitMaxChunkLength: 4000, + overlap: 50, + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' }, + rerankModelList: [], + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + }) + + expect(isValid).toBe(false) + }) + + it('should return true for valid params', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + + const isValid = result.current.validateParams({ + segmentationType: 'general', + maxChunkLength: 1000, + limitMaxChunkLength: 4000, + overlap: 50, + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' }, + rerankModelList: [], + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + }) + + expect(isValid).toBe(true) + }) + }) + + // Tests for buildCreationParams + describe('buildCreationParams', () => { + it('should build params for file upload', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + { mode: ProcessMode.general, rules: createMockRules() }, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params).toBeDefined() + expect(params?.doc_form).toBe(ChunkingMode.text) + expect(params?.doc_language).toBe('English') + expect(params?.data_source?.type).toBe(DataSourceType.FILE) + }) + + it('should build params for setting mode', () => { + const documentDetail = createMockDocumentDetail() + const { result } = renderHook(() => + useDocumentCreation({ + ...defaultOptions, + isSetting: true, + documentDetail, + }), + ) + + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + { mode: ProcessMode.general, rules: createMockRules() }, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params?.original_document_id).toBe(documentDetail.id) + }) + + it('should build params for notion_import data source', () => { + const { result } = renderHook(() => + useDocumentCreation({ + ...defaultOptions, + dataSourceType: DataSourceType.NOTION, + notionPages: [createMockNotionPage()], + notionCredentialId: 'notion-cred-123', + }), + ) + + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + { mode: ProcessMode.general, rules: createMockRules() }, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params).toBeDefined() + expect(params?.data_source?.type).toBe(DataSourceType.NOTION) + expect(params?.data_source?.info_list.notion_info_list).toBeDefined() + }) + + it('should build params for website_crawl data source', () => { + const { result } = renderHook(() => + useDocumentCreation({ + ...defaultOptions, + dataSourceType: DataSourceType.WEB, + websitePages: [createMockWebsitePage()], + websiteCrawlProvider: 'jinaReader' as DataSourceProvider, + websiteCrawlJobId: 'job-123', + crawlOptions: { max_depth: 2 } as CrawlOptions, + }), + ) + + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + { mode: ProcessMode.general, rules: createMockRules() }, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params).toBeDefined() + expect(params?.data_source?.type).toBe(DataSourceType.WEB) + expect(params?.data_source?.info_list.website_info_list).toBeDefined() + }) + }) + + // Tests for validateParams edge cases + describe('validateParams - additional cases', () => { + it('should return false when embedding model is missing for QUALIFIED index type', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + + const isValid = result.current.validateParams({ + segmentationType: 'general', + maxChunkLength: 500, + limitMaxChunkLength: 4000, + overlap: 50, + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: '', model: '' }, + rerankModelList: mockRerankModelList, + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + }) + + expect(isValid).toBe(false) + }) + + it('should return false when rerank model is required but not selected', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + + // isReRankModelSelected returns false when: + // - indexMethod === 'high_quality' (IndexingType.QUALIFIED) + // - reranking_enable === true + // - rerankModelSelected === false (model not found in list) + const isValid = result.current.validateParams({ + segmentationType: 'general', + maxChunkLength: 500, + limitMaxChunkLength: 4000, + overlap: 50, + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' }, + rerankModelList: [], // Empty list means model won't be found + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: true, // Reranking enabled + reranking_model: { + reranking_provider_name: 'nonexistent', + reranking_model_name: 'nonexistent-model', + }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + }) + + expect(isValid).toBe(false) + }) + }) + + // Tests for executeCreation + describe('executeCreation', () => { + it('should call createFirstDocumentMutation when datasetId is not provided', async () => { + const mockOnStepChange = vi.fn() + const mockUpdateIndexingTypeCache = vi.fn() + const mockUpdateResultCache = vi.fn() + const mockUpdateRetrievalMethodCache = vi.fn() + const mockOnSave = vi.fn() + + const { result } = renderHook(() => + useDocumentCreation({ + ...defaultOptions, + datasetId: undefined, + onStepChange: mockOnStepChange, + updateIndexingTypeCache: mockUpdateIndexingTypeCache, + updateResultCache: mockUpdateResultCache, + updateRetrievalMethodCache: mockUpdateRetrievalMethodCache, + onSave: mockOnSave, + }), + ) + + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + { mode: ProcessMode.general, rules: createMockRules() }, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + await act(async () => { + await result.current.executeCreation(params!, IndexingType.QUALIFIED, { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }) + }) + + expect(mockOnStepChange).toHaveBeenCalledWith(1) + }) + + it('should call createDocumentMutation when datasetId is provided', async () => { + const mockOnStepChange = vi.fn() + const { result } = renderHook(() => + useDocumentCreation({ + ...defaultOptions, + datasetId: 'existing-dataset-id', + onStepChange: mockOnStepChange, + }), + ) + + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + { mode: ProcessMode.general, rules: createMockRules() }, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + await act(async () => { + await result.current.executeCreation(params!, IndexingType.QUALIFIED, { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }) + }) + + expect(mockOnStepChange).toHaveBeenCalledWith(1) + }) + + it('should call onSave when in setting mode', async () => { + const mockOnSave = vi.fn() + const documentDetail = createMockDocumentDetail() + const { result } = renderHook(() => + useDocumentCreation({ + ...defaultOptions, + datasetId: 'existing-dataset-id', + isSetting: true, + documentDetail, + onSave: mockOnSave, + }), + ) + + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + { mode: ProcessMode.general, rules: createMockRules() }, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + await act(async () => { + await result.current.executeCreation(params!, IndexingType.QUALIFIED, { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }) + }) + + expect(mockOnSave).toHaveBeenCalled() + }) + }) + + // Tests for validatePreviewParams + describe('validatePreviewParams', () => { + it('should return true for valid max chunk length', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + + const isValid = result.current.validatePreviewParams(1000) + expect(isValid).toBe(true) + }) + + it('should return false when max chunk length exceeds maximum', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + + const isValid = result.current.validatePreviewParams(10000) + expect(isValid).toBe(false) + }) + }) +}) + +// ============================================ +// useIndexingEstimate Hook Tests +// ============================================ + +describe('useIndexingEstimate', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const defaultOptions = { + dataSourceType: DataSourceType.FILE, + currentDocForm: ChunkingMode.text, + docLanguage: 'English', + files: [createMockFile()], + previewNotionPage: createMockNotionPage(), + notionCredentialId: '', + previewWebsitePage: createMockWebsitePage(), + indexingTechnique: IndexingType.QUALIFIED, + processRule: { mode: ProcessMode.general, rules: createMockRules() }, + } + + // Tests for initial state + describe('Initial State', () => { + it('should initialize with idle state', () => { + const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + + expect(result.current.isIdle).toBe(true) + expect(result.current.isPending).toBe(false) + expect(result.current.estimate).toBeUndefined() + }) + }) + + // Tests for fetchEstimate + describe('fetchEstimate', () => { + it('should have fetchEstimate function', () => { + const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + + expect(typeof result.current.fetchEstimate).toBe('function') + }) + + it('should have reset function', () => { + const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + + expect(typeof result.current.reset).toBe('function') + }) + + it('should call fetchEstimate for FILE data source', () => { + const { result } = renderHook(() => + useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.FILE, + previewFileName: 'test-file.pdf', + }), + ) + + act(() => { + result.current.fetchEstimate() + }) + + // fetchEstimate should be callable without error + expect(result.current.fetchEstimate).toBeDefined() + }) + + it('should call fetchEstimate for NOTION data source', () => { + const { result } = renderHook(() => + useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.NOTION, + previewNotionPage: createMockNotionPage(), + notionCredentialId: 'cred-123', + }), + ) + + act(() => { + result.current.fetchEstimate() + }) + + expect(result.current.fetchEstimate).toBeDefined() + }) + + it('should call fetchEstimate for WEB data source', () => { + const { result } = renderHook(() => + useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.WEB, + previewWebsitePage: createMockWebsitePage(), + websiteCrawlProvider: 'jinaReader' as DataSourceProvider, + websiteCrawlJobId: 'job-123', + crawlOptions: { max_depth: 2 } as CrawlOptions, + }), + ) + + act(() => { + result.current.fetchEstimate() + }) + + expect(result.current.fetchEstimate).toBeDefined() + }) + }) + + // Tests for getCurrentMutation based on data source type + describe('Data Source Selection', () => { + it('should use file query for FILE data source', () => { + const { result } = renderHook(() => + useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.FILE, + }), + ) + + expect(result.current.currentMutation).toBeDefined() + expect(result.current.isIdle).toBe(true) + }) + + it('should use notion query for NOTION data source', () => { + const { result } = renderHook(() => + useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.NOTION, + }), + ) + + expect(result.current.currentMutation).toBeDefined() + expect(result.current.isIdle).toBe(true) + }) + + it('should use website query for WEB data source', () => { + const { result } = renderHook(() => + useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.WEB, + websiteCrawlProvider: 'jinaReader' as DataSourceProvider, + websiteCrawlJobId: 'job-123', + }), + ) + + expect(result.current.currentMutation).toBeDefined() + expect(result.current.isIdle).toBe(true) + }) + }) +}) + +// ============================================ +// StepTwoFooter Component Tests +// ============================================ + +describe('StepTwoFooter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const defaultProps = { + isSetting: false, + isCreating: false, + onPrevious: vi.fn(), + onCreate: vi.fn(), + onCancel: vi.fn(), + } + + // Tests for rendering + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + // Should render Previous and Next buttons with correct text + expect(screen.getByText(/previousStep/i)).toBeInTheDocument() + expect(screen.getByText(/nextStep/i)).toBeInTheDocument() + }) + + it('should render Previous and Next buttons when not in setting mode', () => { + render() + + expect(screen.getByText(/previousStep/i)).toBeInTheDocument() + expect(screen.getByText(/nextStep/i)).toBeInTheDocument() + }) + + it('should render Save and Cancel buttons when in setting mode', () => { + render() + + expect(screen.getByText(/save/i)).toBeInTheDocument() + expect(screen.getByText(/cancel/i)).toBeInTheDocument() + }) + }) + + // Tests for user interactions + describe('User Interactions', () => { + it('should call onPrevious when Previous button is clicked', () => { + const onPrevious = vi.fn() + render() + + fireEvent.click(screen.getByText(/previousStep/i)) + + expect(onPrevious).toHaveBeenCalledTimes(1) + }) + + it('should call onCreate when Next/Save button is clicked', () => { + const onCreate = vi.fn() + render() + + fireEvent.click(screen.getByText(/nextStep/i)) + + expect(onCreate).toHaveBeenCalledTimes(1) + }) + + it('should call onCancel when Cancel button is clicked in setting mode', () => { + const onCancel = vi.fn() + render() + + fireEvent.click(screen.getByText(/cancel/i)) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + }) + + // Tests for loading state + describe('Loading State', () => { + it('should show loading state on Next button when creating', () => { + render() + + const nextButton = screen.getByText(/nextStep/i).closest('button') + // Button has disabled:btn-disabled class which handles the loading state + expect(nextButton).toHaveClass('disabled:btn-disabled') + }) + + it('should show loading state on Save button when creating in setting mode', () => { + render() + + const saveButton = screen.getByText(/save/i).closest('button') + // Button has disabled:btn-disabled class which handles the loading state + expect(saveButton).toHaveClass('disabled:btn-disabled') + }) + }) +}) + +// ============================================ +// PreviewPanel Component Tests +// ============================================ + +describe('PreviewPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const defaultProps = { + isMobile: false, + dataSourceType: DataSourceType.FILE, + currentDocForm: ChunkingMode.text, + estimate: undefined as FileIndexingEstimateResponse | undefined, + parentChildConfig: defaultParentChildConfig, + isSetting: false, + pickerFiles: [{ id: 'file-1', name: 'test.pdf', extension: 'pdf' }], + pickerValue: { id: 'file-1', name: 'test.pdf', extension: 'pdf' }, + isIdle: true, + isPending: false, + onPickerChange: vi.fn(), + } + + // Tests for rendering + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + // Check for the preview header title text + expect(screen.getByText('datasetCreation.stepTwo.preview')).toBeInTheDocument() + }) + + it('should render idle state when isIdle is true', () => { + render() + + expect(screen.getByText(/previewChunkTip/i)).toBeInTheDocument() + }) + + it('should render loading skeleton when isPending is true', () => { + render() + + // Should show skeleton containers + expect(screen.queryByText(/previewChunkTip/i)).not.toBeInTheDocument() + }) + }) + + // Tests for different doc forms + describe('Preview Content', () => { + it('should render text preview when docForm is text', () => { + const estimate = createMockEstimate() + render( + , + ) + + expect(screen.getByText('Chunk 1 content')).toBeInTheDocument() + }) + + it('should render QA preview when docForm is qa', () => { + const estimate = createMockEstimate() + render( + , + ) + + expect(screen.getByText('Q1')).toBeInTheDocument() + expect(screen.getByText('A1')).toBeInTheDocument() + }) + + it('should show chunk count badge for non-QA doc form', () => { + const estimate = createMockEstimate({ total_segments: 25 }) + render( + , + ) + + expect(screen.getByText(/25/)).toBeInTheDocument() + }) + + it('should render parent-child preview when docForm is parentChild', () => { + const estimate = createMockEstimate({ + preview: [ + { content: 'Parent chunk content', child_chunks: ['Child 1', 'Child 2', 'Child 3'] }, + ], + }) + render( + , + ) + + // Should render parent chunk label + expect(screen.getByText('Chunk-1')).toBeInTheDocument() + // Should render child chunks + expect(screen.getByText('Child 1')).toBeInTheDocument() + expect(screen.getByText('Child 2')).toBeInTheDocument() + expect(screen.getByText('Child 3')).toBeInTheDocument() + }) + + it('should limit child chunks when chunkForContext is full-doc', () => { + // FULL_DOC_PREVIEW_LENGTH is 50, so we need more than 50 chunks to test the limit + const manyChildChunks = Array.from({ length: 60 }, (_, i) => `ChildChunk${i + 1}`) + const estimate = createMockEstimate({ + preview: [{ content: 'Parent content', child_chunks: manyChildChunks }], + }) + render( + , + ) + + // Should render parent chunk + expect(screen.getByText('Chunk-1')).toBeInTheDocument() + // full-doc mode limits to FULL_DOC_PREVIEW_LENGTH (50) + expect(screen.getByText('ChildChunk1')).toBeInTheDocument() + expect(screen.getByText('ChildChunk50')).toBeInTheDocument() + // Should not render beyond the limit + expect(screen.queryByText('ChildChunk51')).not.toBeInTheDocument() + }) + + it('should render multiple parent chunks in parent-child mode', () => { + const estimate = createMockEstimate({ + preview: [ + { content: 'Parent 1', child_chunks: ['P1-C1'] }, + { content: 'Parent 2', child_chunks: ['P2-C1'] }, + ], + }) + render( + , + ) + + expect(screen.getByText('Chunk-1')).toBeInTheDocument() + expect(screen.getByText('Chunk-2')).toBeInTheDocument() + expect(screen.getByText('P1-C1')).toBeInTheDocument() + expect(screen.getByText('P2-C1')).toBeInTheDocument() + }) + }) + + // Tests for picker + describe('Document Picker', () => { + it('should call onPickerChange when document is selected', () => { + const onPickerChange = vi.fn() + render() + + // The picker interaction would be tested through the actual component + expect(onPickerChange).not.toHaveBeenCalled() + }) + }) +}) + +// ============================================ +// Edge Cases Tests +// ============================================ + +describe('Edge Cases', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Empty/Null Values', () => { + it('should handle empty files array in usePreviewState', () => { + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.FILE, + files: [], + notionPages: [], + websitePages: [], + }), + ) + + expect(result.current.previewFile).toBeUndefined() + }) + + it('should handle empty notion pages array', () => { + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.NOTION, + files: [], + notionPages: [], + websitePages: [], + }), + ) + + expect(result.current.previewNotionPage).toBeUndefined() + }) + + it('should handle empty website pages array', () => { + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.WEB, + files: [], + notionPages: [], + websitePages: [], + }), + ) + + expect(result.current.previewWebsitePage).toBeUndefined() + }) + }) + + describe('Boundary Conditions', () => { + it('should handle very large chunk length', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setMaxChunkLength(999999) + }) + + expect(result.current.maxChunkLength).toBe(999999) + }) + + it('should handle zero overlap', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setOverlap(0) + }) + + expect(result.current.overlap).toBe(0) + }) + + it('should handle special characters in segment identifier', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentIdentifier('<<>>') + }) + + expect(result.current.segmentIdentifier).toBe('<<>>') + }) + }) + + describe('Callback Stability', () => { + it('should maintain stable setSegmentIdentifier reference', () => { + const { result, rerender } = renderHook(() => useSegmentationState()) + const initialSetter = result.current.setSegmentIdentifier + + rerender() + + expect(result.current.setSegmentIdentifier).toBe(initialSetter) + }) + + it('should maintain stable toggleRule reference', () => { + const { result, rerender } = renderHook(() => useSegmentationState()) + const initialToggle = result.current.toggleRule + + rerender() + + expect(result.current.toggleRule).toBe(initialToggle) + }) + + it('should maintain stable getProcessRule reference', () => { + const { result, rerender } = renderHook(() => useSegmentationState()) + + // Update some state to trigger re-render + act(() => { + result.current.setMaxChunkLength(2048) + }) + + rerender() + + // getProcessRule depends on state, so it may change but should remain a function + expect(typeof result.current.getProcessRule).toBe('function') + }) + }) +}) + +// ============================================ +// Integration Scenarios +// ============================================ + +describe('Integration Scenarios', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCurrentDataset = null + }) + + describe('Document Creation Flow', () => { + it('should build and validate params for file upload workflow', () => { + const files = [createMockFile()] + + const { result: segResult } = renderHook(() => useSegmentationState()) + const { result: creationResult } = renderHook(() => + useDocumentCreation({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + notionCredentialId: '', + websitePages: [], + }), + ) + + // Build params + const params = creationResult.current.buildCreationParams( + ChunkingMode.text, + 'English', + segResult.current.getProcessRule(ChunkingMode.text), + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params).toBeDefined() + expect(params?.data_source?.info_list.file_info_list?.file_ids).toContain('file-1') + }) + + it('should handle parent-child document form', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentationType(ProcessMode.parentChild) + result.current.setChunkForContext('full-doc') + result.current.updateParentConfig('maxLength', 2048) + result.current.updateChildConfig('maxLength', 512) + }) + + const processRule = result.current.getProcessRule(ChunkingMode.parentChild) + + expect(processRule.mode).toBe('hierarchical') + expect(processRule.rules.parent_mode).toBe('full-doc') + expect(processRule.rules.segmentation.max_tokens).toBe(2048) + expect(processRule.rules.subchunk_segmentation?.max_tokens).toBe(512) + }) + }) + + describe('Preview Flow', () => { + it('should handle preview file change flow', () => { + const files = [ + createMockFile({ id: 'file-1', name: 'first.pdf' }), + createMockFile({ id: 'file-2', name: 'second.pdf' }), + ] + + const { result } = renderHook(() => + usePreviewState({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + websitePages: [], + }), + ) + + // Initial state + expect(result.current.getPreviewPickerValue().name).toBe('first.pdf') + + // Change preview + act(() => { + result.current.handlePreviewChange({ id: 'file-2', name: 'second.pdf' }) + }) + + expect(result.current.previewFile).toEqual({ id: 'file-2', name: 'second.pdf' }) + }) + }) + + describe('Escape/Unescape Round Trip', () => { + it('should preserve original string through escape/unescape', () => { + const original = '\n\n' + const escaped = escape(original) + const unescaped = unescape(escaped) + + expect(unescaped).toBe(original) + }) + + it('should handle complex strings without backslashes', () => { + // This string contains control characters but no literal backslashes. + const original = 'Hello\nWorld\t!\r\n' + const escaped = escape(original) + const unescaped = unescape(escaped) + expect(unescaped).toBe(original) + }) + + it('should document behavior for strings with existing backslashes', () => { + // When the original string already contains backslash sequences, + // escape/unescape are not perfectly symmetric because escape() + // does not escape backslashes. + const original = 'Hello\\nWorld' + const escaped = escape(original) + const unescaped = unescape(escaped) + // The unescaped value interprets "\n" as a newline, so it differs from the original. + expect(unescaped).toBe('Hello\nWorld') + expect(unescaped).not.toBe(original) + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/index.tsx b/web/app/components/datasets/create/step-two/index.tsx index 51b5c15178..b4d2c5f6e9 100644 --- a/web/app/components/datasets/create/step-two/index.tsx +++ b/web/app/components/datasets/create/step-two/index.tsx @@ -1,137 +1,30 @@ 'use client' -import type { FC, PropsWithChildren } from 'react' -import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { NotionPage } from '@/models/common' -import type { CrawlOptions, CrawlResultItem, CreateDocumentReq, createDocumentResponse, CustomFile, DocumentItem, FullDocumentDetail, ParentMode, PreProcessingRule, ProcessRule, Rules } from '@/models/datasets' -import type { RetrievalConfig } from '@/types/app' -import { - RiAlertFill, - RiArrowLeftLine, - RiSearchEyeLine, -} from '@remixicon/react' -import { noop } from 'es-toolkit/function' -import Image from 'next/image' -import Link from 'next/link' -import { useCallback, useEffect, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { trackEvent } from '@/app/components/base/amplitude' -import Badge from '@/app/components/base/badge' -import Button from '@/app/components/base/button' -import Checkbox from '@/app/components/base/checkbox' -import CustomDialog from '@/app/components/base/dialog' -import Divider from '@/app/components/base/divider' -import FloatRightContainer from '@/app/components/base/float-right-container' -import { ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge' -import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' -import RadioCard from '@/app/components/base/radio-card' -import { SkeletonContainer, SkeletonPoint, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' -import Toast from '@/app/components/base/toast' -import Tooltip from '@/app/components/base/tooltip' -import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model' -import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config' -import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config' -import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { useDefaultModel, useModelList, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' -import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' -import { FULL_DOC_PREVIEW_LENGTH, IS_CE_EDITION } from '@/config' +import type { FC } from 'react' +import type { StepTwoProps } from './types' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Divider from '@/app/components/base/divider' +import Toast from '@/app/components/base/toast' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' -import { useDocLink, useLocale } from '@/context/i18n' +import { useLocale } from '@/context/i18n' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { LanguagesSupported } from '@/i18n-config/language' import { DataSourceProvider } from '@/models/common' -import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets' -import { getNotionInfo, getWebsiteInfo, useCreateDocument, useCreateFirstDocument, useFetchDefaultProcessRule, useFetchFileIndexingEstimateForFile, useFetchFileIndexingEstimateForNotion, useFetchFileIndexingEstimateForWeb } from '@/service/knowledge/use-create-dataset' -import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' -import { RETRIEVE_METHOD } from '@/types/app' +import { ChunkingMode, ProcessMode } from '@/models/datasets' +import { useFetchDefaultProcessRule } from '@/service/knowledge/use-create-dataset' import { cn } from '@/utils/classnames' -import { ChunkContainer, QAPreview } from '../../chunk' -import PreviewDocumentPicker from '../../common/document-picker/preview-document-picker' -import { PreviewSlice } from '../../formatted-text/flavours/preview-slice' -import { FormattedText } from '../../formatted-text/formatted' -import PreviewContainer from '../../preview/container' -import { PreviewHeader } from '../../preview/header' -import { checkShowMultiModalTip } from '../../settings/utils' -import FileList from '../assets/file-list-3-fill.svg' -import Note from '../assets/note-mod.svg' -import BlueEffect from '../assets/option-card-effect-blue.svg' -import SettingCog from '../assets/setting-gear-mod.svg' -import { indexMethodIcon } from '../icons' -import escape from './escape' -import s from './index.module.css' -import { DelimiterInput, MaxLengthInput, OverlapInput } from './inputs' -import LanguageSelect from './language-select' -import { OptionCard } from './option-card' -import unescape from './unescape' +import { GeneralChunkingOptions, IndexingModeSection, ParentChildOptions, PreviewPanel, StepTwoFooter } from './components' +import { IndexingType, MAXIMUM_CHUNK_TOKEN_LENGTH, useDocumentCreation, useIndexingConfig, useIndexingEstimate, usePreviewState, useSegmentationState } from './hooks' -const TextLabel: FC = (props) => { - return -} +export { IndexingType } -type StepTwoProps = { - isSetting?: boolean - documentDetail?: FullDocumentDetail - isAPIKeySet: boolean - onSetting: () => void - datasetId?: string - indexingType?: IndexingType - retrievalMethod?: string - dataSourceType: DataSourceType - files: CustomFile[] - notionPages?: NotionPage[] - notionCredentialId: string - websitePages?: CrawlResultItem[] - crawlOptions?: CrawlOptions - websiteCrawlProvider?: DataSourceProvider - websiteCrawlJobId?: string - onStepChange?: (delta: number) => void - updateIndexingTypeCache?: (type: string) => void - updateRetrievalMethodCache?: (method: RETRIEVE_METHOD | '') => void - updateResultCache?: (res: createDocumentResponse) => void - onSave?: () => void - onCancel?: () => void -} - -export enum IndexingType { - QUALIFIED = 'high_quality', - ECONOMICAL = 'economy', -} - -const DEFAULT_SEGMENT_IDENTIFIER = '\\n\\n' -const DEFAULT_MAXIMUM_CHUNK_LENGTH = 1024 -const DEFAULT_OVERLAP = 50 -const MAXIMUM_CHUNK_TOKEN_LENGTH = Number.parseInt(globalThis.document?.body?.getAttribute('data-public-indexing-max-segmentation-tokens-length') || '4000', 10) - -type ParentChildConfig = { - chunkForContext: ParentMode - parent: { - delimiter: string - maxLength: number - } - child: { - delimiter: string - maxLength: number - } -} - -const defaultParentChildConfig: ParentChildConfig = { - chunkForContext: 'paragraph', - parent: { - delimiter: '\\n\\n', - maxLength: 1024, - }, - child: { - delimiter: '\\n', - maxLength: 512, - }, -} - -const StepTwo = ({ +const StepTwo: FC = ({ isSetting, documentDetail, isAPIKeySet, datasetId, - indexingType, + indexingType: propsIndexingType, dataSourceType: inCreatePageDataSourceType, files, notionPages = [], @@ -146,1099 +39,238 @@ const StepTwo = ({ onSave, onCancel, updateRetrievalMethodCache, -}: StepTwoProps) => { +}) => { const { t } = useTranslation() - const docLink = useDocLink() const locale = useLocale() - const media = useBreakpoints() - const isMobile = media === MediaType.mobile - - const currentDataset = useDatasetDetailContextWithSelector(state => state.dataset) - const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes) + const isMobile = useBreakpoints() === MediaType.mobile + const currentDataset = useDatasetDetailContextWithSelector(s => s.dataset) + const mutateDatasetRes = useDatasetDetailContextWithSelector(s => s.mutateDatasetRes) + // Computed flags const isInUpload = Boolean(currentDataset) const isUploadInEmptyDataset = isInUpload && !currentDataset?.doc_form const isNotUploadInEmptyDataset = !isUploadInEmptyDataset const isInInit = !isInUpload && !isSetting - const isInCreatePage = !datasetId || (datasetId && !currentDataset?.data_source_type) - const dataSourceType = isInCreatePage ? inCreatePageDataSourceType : currentDataset?.data_source_type - const [segmentationType, setSegmentationType] = useState( - currentDataset?.doc_form === ChunkingMode.parentChild ? ProcessMode.parentChild : ProcessMode.general, - ) - const [segmentIdentifier, doSetSegmentIdentifier] = useState(DEFAULT_SEGMENT_IDENTIFIER) - const setSegmentIdentifier = useCallback((value: string, canEmpty?: boolean) => { - doSetSegmentIdentifier(value ? escape(value) : (canEmpty ? '' : DEFAULT_SEGMENT_IDENTIFIER)) - }, []) - const [maxChunkLength, setMaxChunkLength] = useState(DEFAULT_MAXIMUM_CHUNK_LENGTH) // default chunk length - const [limitMaxChunkLength, setLimitMaxChunkLength] = useState(MAXIMUM_CHUNK_TOKEN_LENGTH) - const [overlap, setOverlap] = useState(DEFAULT_OVERLAP) - const [rules, setRules] = useState([]) - const [defaultConfig, setDefaultConfig] = useState() - const hasSetIndexType = !!indexingType - const [indexType, setIndexType] = useState(() => { - if (hasSetIndexType) - return indexingType - return isAPIKeySet ? IndexingType.QUALIFIED : IndexingType.ECONOMICAL - }) + const dataSourceType = isInCreatePage ? inCreatePageDataSourceType : (currentDataset?.data_source_type ?? inCreatePageDataSourceType) + const hasSetIndexType = !!propsIndexingType + const isModelAndRetrievalConfigDisabled = !!datasetId && !!currentDataset?.data_source_type - const [previewFile, setPreviewFile] = useState( - (datasetId && documentDetail) - ? documentDetail.file - : files[0], - ) - const [previewNotionPage, setPreviewNotionPage] = useState( - (datasetId && documentDetail) - ? documentDetail.notion_page - : notionPages[0], - ) - - const [previewWebsitePage, setPreviewWebsitePage] = useState( - (datasetId && documentDetail) - ? documentDetail.website_page - : websitePages[0], - ) - - // QA Related + // Document form state + const [docForm, setDocForm] = useState((datasetId && documentDetail) ? documentDetail.doc_form as ChunkingMode : ChunkingMode.text) + const [docLanguage, setDocLanguage] = useState(() => (datasetId && documentDetail) ? documentDetail.doc_language : (locale !== LanguagesSupported[1] ? 'English' : 'Chinese Simplified')) const [isQAConfirmDialogOpen, setIsQAConfirmDialogOpen] = useState(false) - const [docForm, setDocForm] = useState( - (datasetId && documentDetail) ? documentDetail.doc_form as ChunkingMode : ChunkingMode.text, - ) - const handleChangeDocform = (value: ChunkingMode) => { - if (value === ChunkingMode.qa && indexType === IndexingType.ECONOMICAL) { - setIsQAConfirmDialogOpen(true) - return - } - if (value === ChunkingMode.parentChild && indexType === IndexingType.ECONOMICAL) - setIndexType(IndexingType.QUALIFIED) - - setDocForm(value) - - if (value === ChunkingMode.parentChild) - setSegmentationType(ProcessMode.parentChild) - else - setSegmentationType(ProcessMode.general) - - // eslint-disable-next-line ts/no-use-before-define - currentEstimateMutation.reset() - } - - const [docLanguage, setDocLanguage] = useState( - (datasetId && documentDetail) ? documentDetail.doc_language : (locale !== LanguagesSupported[1] ? 'English' : 'Chinese Simplified'), - ) - - const [parentChildConfig, setParentChildConfig] = useState(defaultParentChildConfig) - - const getIndexing_technique = () => indexingType || indexType const currentDocForm = currentDataset?.doc_form || docForm - const getProcessRule = (): ProcessRule => { - if (currentDocForm === ChunkingMode.parentChild) { - return { - rules: { - pre_processing_rules: rules, - segmentation: { - separator: unescape( - parentChildConfig.parent.delimiter, - ), - max_tokens: parentChildConfig.parent.maxLength, - }, - parent_mode: parentChildConfig.chunkForContext, - subchunk_segmentation: { - separator: unescape(parentChildConfig.child.delimiter), - max_tokens: parentChildConfig.child.maxLength, - }, - }, - mode: 'hierarchical', - } as ProcessRule - } - return { - rules: { - pre_processing_rules: rules, - segmentation: { - separator: unescape(segmentIdentifier), - max_tokens: maxChunkLength, - chunk_overlap: overlap, - }, - }, // api will check this. It will be removed after api refactored. - mode: segmentationType, - } as ProcessRule - } - - const fileIndexingEstimateQuery = useFetchFileIndexingEstimateForFile({ - docForm: currentDocForm, - docLanguage, - dataSourceType: DataSourceType.FILE, - files: previewFile - ? [files.find(file => file.name === previewFile.name)!] - : files, - indexingTechnique: getIndexing_technique() as any, - processRule: getProcessRule(), - dataset_id: datasetId!, + // Custom hooks + const segmentation = useSegmentationState({ + initialSegmentationType: currentDataset?.doc_form === ChunkingMode.parentChild ? ProcessMode.parentChild : ProcessMode.general, }) - const notionIndexingEstimateQuery = useFetchFileIndexingEstimateForNotion({ - docForm: currentDocForm, - docLanguage, - dataSourceType: DataSourceType.NOTION, - notionPages: [previewNotionPage], - indexingTechnique: getIndexing_technique() as any, - processRule: getProcessRule(), - dataset_id: datasetId || '', - credential_id: notionCredentialId, + const indexing = useIndexingConfig({ + initialIndexType: propsIndexingType, + initialEmbeddingModel: currentDataset?.embedding_model ? { provider: currentDataset.embedding_model_provider, model: currentDataset.embedding_model } : undefined, + initialRetrievalConfig: currentDataset?.retrieval_model_dict, + isAPIKeySet, + hasSetIndexType, }) - - const websiteIndexingEstimateQuery = useFetchFileIndexingEstimateForWeb({ - docForm: currentDocForm, - docLanguage, - dataSourceType: DataSourceType.WEB, - websitePages: [previewWebsitePage], + const preview = usePreviewState({ dataSourceType, files, notionPages, websitePages, documentDetail, datasetId }) + const creation = useDocumentCreation({ + datasetId, + isSetting, + documentDetail, + dataSourceType, + files, + notionPages, + notionCredentialId, + websitePages, crawlOptions, websiteCrawlProvider, websiteCrawlJobId, - indexingTechnique: getIndexing_technique() as any, - processRule: getProcessRule(), - dataset_id: datasetId || '', + onStepChange, + updateIndexingTypeCache, + updateResultCache, + updateRetrievalMethodCache, + onSave, + mutateDatasetRes, + }) + const estimateHook = useIndexingEstimate({ + dataSourceType, + datasetId, + currentDocForm, + docLanguage, + files, + previewFileName: preview.previewFile?.name, + previewNotionPage: preview.previewNotionPage, + notionCredentialId, + previewWebsitePage: preview.previewWebsitePage, + crawlOptions, + websiteCrawlProvider, + websiteCrawlJobId, + indexingTechnique: indexing.getIndexingTechnique() as IndexingType, + processRule: segmentation.getProcessRule(currentDocForm), }) - const currentEstimateMutation = dataSourceType === DataSourceType.FILE - ? fileIndexingEstimateQuery - : dataSourceType === DataSourceType.NOTION - ? notionIndexingEstimateQuery - : websiteIndexingEstimateQuery + // Fetch default process rule + const fetchDefaultProcessRuleMutation = useFetchDefaultProcessRule({ + onSuccess(data) { + segmentation.setSegmentIdentifier(data.rules.segmentation.separator) + segmentation.setMaxChunkLength(data.rules.segmentation.max_tokens) + segmentation.setOverlap(data.rules.segmentation.chunk_overlap!) + segmentation.setRules(data.rules.pre_processing_rules) + segmentation.setDefaultConfig(data.rules) + segmentation.setLimitMaxChunkLength(data.limits.indexing_max_segmentation_tokens_length) + }, + }) - const fetchEstimate = useCallback(() => { - if (dataSourceType === DataSourceType.FILE) - fileIndexingEstimateQuery.mutate() - - if (dataSourceType === DataSourceType.NOTION) - notionIndexingEstimateQuery.mutate() - - if (dataSourceType === DataSourceType.WEB) - websiteIndexingEstimateQuery.mutate() - }, [dataSourceType, fileIndexingEstimateQuery, notionIndexingEstimateQuery, websiteIndexingEstimateQuery]) - - const estimate - = dataSourceType === DataSourceType.FILE - ? fileIndexingEstimateQuery.data - : dataSourceType === DataSourceType.NOTION - ? notionIndexingEstimateQuery.data - : websiteIndexingEstimateQuery.data - - const getRuleName = (key: string) => { - if (key === 'remove_extra_spaces') - return t('stepTwo.removeExtraSpaces', { ns: 'datasetCreation' }) - - if (key === 'remove_urls_emails') - return t('stepTwo.removeUrlEmails', { ns: 'datasetCreation' }) - - if (key === 'remove_stopwords') - return t('stepTwo.removeStopwords', { ns: 'datasetCreation' }) - } - const ruleChangeHandle = (id: string) => { - const newRules = rules.map((rule) => { - if (rule.id === id) { - return { - id: rule.id, - enabled: !rule.enabled, - } - } - return rule - }) - setRules(newRules) - } - const resetRules = () => { - if (defaultConfig) { - setSegmentIdentifier(defaultConfig.segmentation.separator) - setMaxChunkLength(defaultConfig.segmentation.max_tokens) - setOverlap(defaultConfig.segmentation.chunk_overlap!) - setRules(defaultConfig.pre_processing_rules) + // Event handlers + const handleDocFormChange = useCallback((value: ChunkingMode) => { + if (value === ChunkingMode.qa && indexing.indexType === IndexingType.ECONOMICAL) { + setIsQAConfirmDialogOpen(true) + return } - setParentChildConfig(defaultParentChildConfig) - } + if (value === ChunkingMode.parentChild && indexing.indexType === IndexingType.ECONOMICAL) + indexing.setIndexType(IndexingType.QUALIFIED) + setDocForm(value) + segmentation.setSegmentationType(value === ChunkingMode.parentChild ? ProcessMode.parentChild : ProcessMode.general) + estimateHook.reset() + }, [indexing, segmentation, estimateHook]) - const updatePreview = () => { - if (segmentationType === ProcessMode.general && maxChunkLength > MAXIMUM_CHUNK_TOKEN_LENGTH) { + const updatePreview = useCallback(() => { + if (segmentation.segmentationType === ProcessMode.general && segmentation.maxChunkLength > MAXIMUM_CHUNK_TOKEN_LENGTH) { Toast.notify({ type: 'error', message: t('stepTwo.maxLengthCheck', { ns: 'datasetCreation', limit: MAXIMUM_CHUNK_TOKEN_LENGTH }) }) return } - fetchEstimate() - } + estimateHook.fetchEstimate() + }, [segmentation, t, estimateHook]) - const { - modelList: rerankModelList, - defaultModel: rerankDefaultModel, - currentModel: isRerankDefaultModelValid, - } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) - const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding) - const { data: defaultEmbeddingModel } = useDefaultModel(ModelTypeEnum.textEmbedding) - const [embeddingModel, setEmbeddingModel] = useState( - currentDataset?.embedding_model - ? { - provider: currentDataset.embedding_model_provider, - model: currentDataset.embedding_model, - } - : { - provider: defaultEmbeddingModel?.provider.provider || '', - model: defaultEmbeddingModel?.model || '', - }, - ) - const [retrievalConfig, setRetrievalConfig] = useState(currentDataset?.retrieval_model_dict || { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0.5, - } as RetrievalConfig) - - useEffect(() => { - if (currentDataset?.retrieval_model_dict) - return - setRetrievalConfig({ - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: !!isRerankDefaultModelValid, - reranking_model: { - reranking_provider_name: isRerankDefaultModelValid ? rerankDefaultModel?.provider.provider ?? '' : '', - reranking_model_name: isRerankDefaultModelValid ? rerankDefaultModel?.model ?? '' : '', - }, - top_k: 3, - score_threshold_enabled: false, - score_threshold: 0.5, + const handleCreate = useCallback(async () => { + const isValid = creation.validateParams({ + segmentationType: segmentation.segmentationType, + maxChunkLength: segmentation.maxChunkLength, + limitMaxChunkLength: segmentation.limitMaxChunkLength, + overlap: segmentation.overlap, + indexType: indexing.indexType, + embeddingModel: indexing.embeddingModel, + rerankModelList: indexing.rerankModelList, + retrievalConfig: indexing.retrievalConfig, }) - }, [rerankDefaultModel, isRerankDefaultModelValid]) - - const getCreationParams = () => { - let params - if (segmentationType === ProcessMode.general && overlap > maxChunkLength) { - Toast.notify({ type: 'error', message: t('stepTwo.overlapCheck', { ns: 'datasetCreation' }) }) + if (!isValid) return - } - if (segmentationType === ProcessMode.general && maxChunkLength > limitMaxChunkLength) { - Toast.notify({ type: 'error', message: t('stepTwo.maxLengthCheck', { ns: 'datasetCreation', limit: limitMaxChunkLength }) }) - return - } - if (isSetting) { - params = { - original_document_id: documentDetail?.id, - doc_form: currentDocForm, - doc_language: docLanguage, - process_rule: getProcessRule(), - retrieval_model: retrievalConfig, // Readonly. If want to changed, just go to settings page. - embedding_model: embeddingModel.model, // Readonly - embedding_model_provider: embeddingModel.provider, // Readonly - indexing_technique: getIndexing_technique(), - } as CreateDocumentReq - } - else { // create - const indexMethod = getIndexing_technique() - if (indexMethod === IndexingType.QUALIFIED && (!embeddingModel.model || !embeddingModel.provider)) { - Toast.notify({ - type: 'error', - message: t('datasetConfig.embeddingModelRequired', { ns: 'appDebug' }), - }) - return - } - if ( - !isReRankModelSelected({ - rerankModelList, - retrievalConfig, - indexMethod: indexMethod as string, - }) - ) { - Toast.notify({ type: 'error', message: t('datasetConfig.rerankModelRequired', { ns: 'appDebug' }) }) - return - } - params = { - data_source: { - type: dataSourceType, - info_list: { - data_source_type: dataSourceType, - }, - }, - indexing_technique: getIndexing_technique(), - process_rule: getProcessRule(), - doc_form: currentDocForm, - doc_language: docLanguage, - retrieval_model: retrievalConfig, - embedding_model: embeddingModel.model, - embedding_model_provider: embeddingModel.provider, - } as CreateDocumentReq - if (dataSourceType === DataSourceType.FILE) { - params.data_source.info_list.file_info_list = { - file_ids: files.map(file => file.id || '').filter(Boolean), - } - } - if (dataSourceType === DataSourceType.NOTION) - params.data_source.info_list.notion_info_list = getNotionInfo(notionPages, notionCredentialId) - - if (dataSourceType === DataSourceType.WEB) { - params.data_source.info_list.website_info_list = getWebsiteInfo({ - websiteCrawlProvider, - websiteCrawlJobId, - websitePages, - }) - } - } - return params - } - - const fetchDefaultProcessRuleMutation = useFetchDefaultProcessRule({ - onSuccess(data) { - const separator = data.rules.segmentation.separator - setSegmentIdentifier(separator) - setMaxChunkLength(data.rules.segmentation.max_tokens) - setOverlap(data.rules.segmentation.chunk_overlap!) - setRules(data.rules.pre_processing_rules) - setDefaultConfig(data.rules) - setLimitMaxChunkLength(data.limits.indexing_max_segmentation_tokens_length) - }, - }) - - const getRulesFromDetail = () => { - if (documentDetail) { - const rules = documentDetail.dataset_process_rule.rules - const separator = rules.segmentation.separator - const max = rules.segmentation.max_tokens - const overlap = rules.segmentation.chunk_overlap - const isHierarchicalDocument = documentDetail.doc_form === ChunkingMode.parentChild - || (rules.parent_mode && rules.subchunk_segmentation) - setSegmentIdentifier(separator) - setMaxChunkLength(max) - setOverlap(overlap!) - setRules(rules.pre_processing_rules) - setDefaultConfig(rules) - - if (isHierarchicalDocument) { - setParentChildConfig({ - chunkForContext: rules.parent_mode || 'paragraph', - parent: { - delimiter: escape(rules.segmentation.separator), - maxLength: rules.segmentation.max_tokens, - }, - child: { - delimiter: escape(rules.subchunk_segmentation.separator), - maxLength: rules.subchunk_segmentation.max_tokens, - }, - }) - } - } - } - - const getDefaultMode = () => { - if (documentDetail) - setSegmentationType(documentDetail.dataset_process_rule.mode) - } - - const createFirstDocumentMutation = useCreateFirstDocument() - const createDocumentMutation = useCreateDocument(datasetId!) - - const isCreating = createFirstDocumentMutation.isPending || createDocumentMutation.isPending - const invalidDatasetList = useInvalidDatasetList() - - const createHandle = async () => { - const params = getCreationParams() + const params = creation.buildCreationParams(currentDocForm, docLanguage, segmentation.getProcessRule(currentDocForm), indexing.retrievalConfig, indexing.embeddingModel, indexing.getIndexingTechnique()) if (!params) - return false + return + await creation.executeCreation(params, indexing.indexType, indexing.retrievalConfig) + }, [creation, segmentation, indexing, currentDocForm, docLanguage]) - if (!datasetId) { - await createFirstDocumentMutation.mutateAsync( - params, - { - onSuccess(data) { - updateIndexingTypeCache?.(indexType as string) - updateResultCache?.(data) - updateRetrievalMethodCache?.(retrievalConfig.search_method as RETRIEVE_METHOD) - }, - }, - ) - } - else { - await createDocumentMutation.mutateAsync(params, { - onSuccess(data) { - updateIndexingTypeCache?.(indexType as string) - updateResultCache?.(data) - updateRetrievalMethodCache?.(retrievalConfig.search_method as RETRIEVE_METHOD) - }, - }) - } - if (mutateDatasetRes) - mutateDatasetRes() - invalidDatasetList() - trackEvent('create_datasets', { - data_source_type: dataSourceType, - indexing_technique: getIndexing_technique(), - }) - onStepChange?.(+1) - if (isSetting) - onSave?.() - } + const handlePickerChange = useCallback((selected: { id: string, name: string }) => { + estimateHook.reset() + preview.handlePreviewChange(selected) + estimateHook.fetchEstimate() + }, [estimateHook, preview]) + const handleQAConfirm = useCallback(() => { + setIsQAConfirmDialogOpen(false) + indexing.setIndexType(IndexingType.QUALIFIED) + setDocForm(ChunkingMode.qa) + }, [indexing]) + + // Initialize rules useEffect(() => { - // fetch rules if (!isSetting) { fetchDefaultProcessRuleMutation.mutate('/datasets/process-rule') } - else { - getRulesFromDetail() - getDefaultMode() + else if (documentDetail) { + const rules = documentDetail.dataset_process_rule.rules + const isHierarchical = documentDetail.doc_form === ChunkingMode.parentChild || Boolean(rules.parent_mode && rules.subchunk_segmentation) + segmentation.applyConfigFromRules(rules, isHierarchical) + segmentation.setSegmentationType(documentDetail.dataset_process_rule.mode) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - useEffect(() => { - // get indexing type by props - if (indexingType) - setIndexType(indexingType as IndexingType) - else - setIndexType(isAPIKeySet ? IndexingType.QUALIFIED : IndexingType.ECONOMICAL) - }, [isAPIKeySet, indexingType, datasetId]) - - const isModelAndRetrievalConfigDisabled = !!datasetId && !!currentDataset?.data_source_type - - const showMultiModalTip = useMemo(() => { - return checkShowMultiModalTip({ - embeddingModel, - rerankingEnable: retrievalConfig.reranking_enable, - rerankModel: { - rerankingProviderName: retrievalConfig.reranking_model.reranking_provider_name, - rerankingModelName: retrievalConfig.reranking_model.reranking_model_name, - }, - indexMethod: indexType, - embeddingModelList, - rerankModelList, - }) - }, [embeddingModel, retrievalConfig.reranking_enable, retrievalConfig.reranking_model, indexType, embeddingModelList, rerankModelList]) + // Show options conditions + const showGeneralOption = (isInUpload && [ChunkingMode.text, ChunkingMode.qa].includes(currentDataset!.doc_form)) || isUploadInEmptyDataset || isInInit + const showParentChildOption = (isInUpload && currentDataset!.doc_form === ChunkingMode.parentChild) || isUploadInEmptyDataset || isInInit return (
{t('stepTwo.segmentation', { ns: 'datasetCreation' })}
- {((isInUpload && [ChunkingMode.text, ChunkingMode.qa].includes(currentDataset!.doc_form)) - || isUploadInEmptyDataset - || isInInit) - && ( - } - activeHeaderClassName="bg-dataset-option-card-blue-gradient" - description={t('stepTwo.generalTip', { ns: 'datasetCreation' })} - isActive={ - [ChunkingMode.text, ChunkingMode.qa].includes(currentDocForm) - } - onSwitched={() => - handleChangeDocform(ChunkingMode.text)} - actions={( - <> - - - - )} - noHighlight={isInUpload && isNotUploadInEmptyDataset} - > -
-
- setSegmentIdentifier(e.target.value, true)} - /> - - -
-
-
-
- {t('stepTwo.rules', { ns: 'datasetCreation' })} -
- -
-
- {rules.map(rule => ( -
{ - ruleChangeHandle(rule.id) - }} - > - - -
- ))} - {IS_CE_EDITION && ( - <> - -
-
{ - if (currentDataset?.doc_form) - return - if (docForm === ChunkingMode.qa) - handleChangeDocform(ChunkingMode.text) - else - handleChangeDocform(ChunkingMode.qa) - }} - > - - -
- - -
- {currentDocForm === ChunkingMode.qa && ( -
- - - {t('stepTwo.QATip', { ns: 'datasetCreation' })} - -
- )} - - )} -
-
-
-
+ {showGeneralOption && ( + segmentation.setSegmentIdentifier(value, true)} + onMaxChunkLengthChange={segmentation.setMaxChunkLength} + onOverlapChange={segmentation.setOverlap} + onRuleToggle={segmentation.toggleRule} + onDocFormChange={handleDocFormChange} + onDocLanguageChange={setDocLanguage} + onPreview={updatePreview} + onReset={segmentation.resetToDefaults} + locale={locale} + /> )} - { - ( - (isInUpload && currentDataset!.doc_form === ChunkingMode.parentChild) - || isUploadInEmptyDataset - || isInInit - ) - && ( - } - effectImg={BlueEffect.src} - className="text-util-colors-blue-light-blue-light-500" - activeHeaderClassName="bg-dataset-option-card-blue-gradient" - description={t('stepTwo.parentChildTip', { ns: 'datasetCreation' })} - isActive={currentDocForm === ChunkingMode.parentChild} - onSwitched={() => handleChangeDocform(ChunkingMode.parentChild)} - actions={( - <> - - - - )} - noHighlight={isInUpload && isNotUploadInEmptyDataset} - > -
-
-
-
- {t('stepTwo.parentChunkForContext', { ns: 'datasetCreation' })} -
- -
- } - title={t('stepTwo.paragraph', { ns: 'datasetCreation' })} - description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })} - isChosen={parentChildConfig.chunkForContext === 'paragraph'} - onChosen={() => setParentChildConfig( - { - ...parentChildConfig, - chunkForContext: 'paragraph', - }, - )} - chosenConfig={( -
- setParentChildConfig({ - ...parentChildConfig, - parent: { - ...parentChildConfig.parent, - delimiter: e.target.value ? escape(e.target.value) : '', - }, - })} - /> - setParentChildConfig({ - ...parentChildConfig, - parent: { - ...parentChildConfig.parent, - maxLength: value, - }, - })} - /> -
- )} - /> - } - title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })} - description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })} - onChosen={() => setParentChildConfig( - { - ...parentChildConfig, - chunkForContext: 'full-doc', - }, - )} - isChosen={parentChildConfig.chunkForContext === 'full-doc'} - /> -
- -
-
-
- {t('stepTwo.childChunkForRetrieval', { ns: 'datasetCreation' })} -
- -
-
- setParentChildConfig({ - ...parentChildConfig, - child: { - ...parentChildConfig.child, - delimiter: e.target.value ? escape(e.target.value) : '', - }, - })} - /> - setParentChildConfig({ - ...parentChildConfig, - child: { - ...parentChildConfig.child, - maxLength: value, - }, - })} - /> -
-
-
-
-
- {t('stepTwo.rules', { ns: 'datasetCreation' })} -
- -
-
- {rules.map(rule => ( -
{ - ruleChangeHandle(rule.id) - }} - > - - -
- ))} -
-
-
-
- ) - } - -
{t('stepTwo.indexMode', { ns: 'datasetCreation' })}
-
- {(!hasSetIndexType || (hasSetIndexType && indexingType === IndexingType.QUALIFIED)) && ( - - {t('stepTwo.qualified', { ns: 'datasetCreation' })} - - {t('stepTwo.recommend', { ns: 'datasetCreation' })} - - - {!hasSetIndexType && } - -
- )} - description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })} - icon={} - isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED} - disabled={hasSetIndexType} - onSwitched={() => { - setIndexType(IndexingType.QUALIFIED) - }} - /> - )} - - {(!hasSetIndexType || (hasSetIndexType && indexingType === IndexingType.ECONOMICAL)) && ( - <> - setIsQAConfirmDialogOpen(false)} className="w-[432px]"> -
-

- {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={() => { - setIndexType(IndexingType.ECONOMICAL) - }} - /> - - - )} -
- {!hasSetIndexType && indexType === IndexingType.QUALIFIED && ( -
-
-
- -
- {t('stepTwo.highQualityTip', { ns: 'datasetCreation' })} -
- )} - {hasSetIndexType && indexType === IndexingType.ECONOMICAL && ( -
- {t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })} - {t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })} -
- )} - {/* Embedding model */} - {indexType === IndexingType.QUALIFIED && ( -
-
{t('form.embeddingModel', { ns: 'datasetSettings' })}
- { - setEmbeddingModel(model) - }} - /> - {isModelAndRetrievalConfigDisabled && ( -
- {t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })} - {t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })} -
- )} -
+ {showParentChildOption && ( + segmentation.updateParentConfig('delimiter', v)} + onParentMaxLengthChange={v => segmentation.updateParentConfig('maxLength', v)} + onChildDelimiterChange={v => segmentation.updateChildConfig('delimiter', v)} + onChildMaxLengthChange={v => segmentation.updateChildConfig('maxLength', v)} + onRuleToggle={segmentation.toggleRule} + onPreview={updatePreview} + onReset={segmentation.resetToDefaults} + /> )} - {/* Retrieval Method Config */} -
- {!isModelAndRetrievalConfigDisabled - ? ( -
-
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
-
- - {t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })} - - {t('form.retrievalSetting.longDescription', { ns: 'datasetSettings' })} -
-
- ) - : ( -
-
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
-
- )} - -
- { - getIndexing_technique() === IndexingType.QUALIFIED - ? ( - - ) - : ( - - ) - } -
-
- - {!isSetting - ? ( -
- - -
- ) - : ( -
- - -
- )} + setIsQAConfirmDialogOpen(false)} + onQAConfirmDialogConfirm={handleQAConfirm} + /> + onStepChange?.(-1)} onCreate={handleCreate} onCancel={onCancel} />
- - -
- {dataSourceType === DataSourceType.FILE - && ( - >} - onChange={(selected) => { - currentEstimateMutation.reset() - setPreviewFile(selected) - currentEstimateMutation.mutate() - }} - // when it is from setting, it just has one file - value={isSetting ? (files[0]! as Required) : previewFile} - /> - )} - {dataSourceType === DataSourceType.NOTION - && ( - ({ - id: page.page_id, - name: page.page_name, - extension: 'md', - })) - } - onChange={(selected) => { - currentEstimateMutation.reset() - const selectedPage = notionPages.find(page => page.page_id === selected.id) - setPreviewNotionPage(selectedPage!) - currentEstimateMutation.mutate() - }} - value={{ - id: previewNotionPage?.page_id || '', - name: previewNotionPage?.page_name || '', - extension: 'md', - }} - /> - )} - {dataSourceType === DataSourceType.WEB - && ( - ({ - id: page.source_url, - name: page.title, - extension: 'md', - })) - } - onChange={(selected) => { - currentEstimateMutation.reset() - const selectedPage = websitePages.find(page => page.source_url === selected.id) - setPreviewWebsitePage(selectedPage!) - currentEstimateMutation.mutate() - }} - value={ - { - id: previewWebsitePage?.source_url || '', - name: previewWebsitePage?.title || '', - extension: 'md', - } - } - /> - )} - { - currentDocForm !== ChunkingMode.qa - && ( - - ) - } -
- - )} - className={cn('relative flex h-full w-1/2 shrink-0 p-4 pr-0', isMobile && 'w-full max-w-[524px]')} - mainClassName="space-y-6" - > - {currentDocForm === ChunkingMode.qa && estimate?.qa_preview && ( - estimate?.qa_preview.map((item, index) => ( - - - - )) - )} - {currentDocForm === ChunkingMode.text && estimate?.preview && ( - estimate?.preview.map((item, index) => ( - - {item.content} - - )) - )} - {currentDocForm === ChunkingMode.parentChild && currentEstimateMutation.data?.preview && ( - estimate?.preview?.map((item, index) => { - const indexForLabel = index + 1 - const childChunks = parentChildConfig.chunkForContext === 'full-doc' - ? item.child_chunks.slice(0, FULL_DOC_PREVIEW_LENGTH) - : item.child_chunks - return ( - - - {childChunks.map((child, index) => { - const indexForLabel = index + 1 - return ( - - ) - })} - - - ) - }) - )} - {currentEstimateMutation.isIdle && ( -
-
- -

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

-
-
- )} - {currentEstimateMutation.isPending && ( -
- {Array.from({ length: 10 }, (_, i) => ( - - - - - - - - - - - ))} -
- )} -
-
+ } + pickerValue={preview.getPreviewPickerValue()} + isIdle={estimateHook.isIdle} + isPending={estimateHook.isPending} + onPickerChange={handlePickerChange} + /> ) } diff --git a/web/app/components/datasets/create/step-two/types.ts b/web/app/components/datasets/create/step-two/types.ts new file mode 100644 index 0000000000..7f5291fb13 --- /dev/null +++ b/web/app/components/datasets/create/step-two/types.ts @@ -0,0 +1,28 @@ +import type { IndexingType } from './hooks' +import type { DataSourceProvider, NotionPage } from '@/models/common' +import type { CrawlOptions, CrawlResultItem, createDocumentResponse, CustomFile, DataSourceType, FullDocumentDetail } from '@/models/datasets' +import type { RETRIEVE_METHOD } from '@/types/app' + +export type StepTwoProps = { + isSetting?: boolean + documentDetail?: FullDocumentDetail + isAPIKeySet: boolean + onSetting: () => void + datasetId?: string + indexingType?: IndexingType + retrievalMethod?: string + dataSourceType: DataSourceType + files: CustomFile[] + notionPages?: NotionPage[] + notionCredentialId: string + websitePages?: CrawlResultItem[] + crawlOptions?: CrawlOptions + websiteCrawlProvider?: DataSourceProvider + websiteCrawlJobId?: string + onStepChange?: (delta: number) => void + updateIndexingTypeCache?: (type: string) => void + updateRetrievalMethodCache?: (method: RETRIEVE_METHOD | '') => void + updateResultCache?: (res: createDocumentResponse) => void + onSave?: () => void + onCancel?: () => void +} diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts new file mode 100644 index 0000000000..6ca9bd1c05 --- /dev/null +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -0,0 +1,81 @@ +import type { ActivePluginType } from './constants' +import type { PluginsSort, SearchParamsFromCollection } from './types' +import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' +import { useQueryState } from 'nuqs' +import { useCallback } from 'react' +import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' +import { marketplaceSearchParamsParsers } from './search-params' + +const marketplaceSortAtom = atom(DEFAULT_SORT) +export function useMarketplaceSort() { + return useAtom(marketplaceSortAtom) +} +export function useMarketplaceSortValue() { + return useAtomValue(marketplaceSortAtom) +} +export function useSetMarketplaceSort() { + return useSetAtom(marketplaceSortAtom) +} + +/** + * Preserve the state for marketplace + */ +export const preserveSearchStateInQueryAtom = atom(false) + +const searchPluginTextAtom = atom('') +const activePluginTypeAtom = atom('all') +const filterPluginTagsAtom = atom([]) + +export function useSearchPluginText() { + const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom) + const queryState = useQueryState('q', marketplaceSearchParamsParsers.q) + const atomState = useAtom(searchPluginTextAtom) + return preserveSearchStateInQuery ? queryState : atomState +} +export function useActivePluginType() { + const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom) + const queryState = useQueryState('category', marketplaceSearchParamsParsers.category) + const atomState = useAtom(activePluginTypeAtom) + return preserveSearchStateInQuery ? queryState : atomState +} +export function useFilterPluginTags() { + const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom) + const queryState = useQueryState('tags', marketplaceSearchParamsParsers.tags) + const atomState = useAtom(filterPluginTagsAtom) + return preserveSearchStateInQuery ? queryState : atomState +} + +/** + * Not all categories have collections, so we need to + * force the search mode for those categories. + */ +export const searchModeAtom = atom(null) + +export function useMarketplaceSearchMode() { + const [searchPluginText] = useSearchPluginText() + const [filterPluginTags] = useFilterPluginTags() + const [activePluginType] = useActivePluginType() + + const searchMode = useAtomValue(searchModeAtom) + const isSearchMode = !!searchPluginText + || filterPluginTags.length > 0 + || (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType))) + return isSearchMode +} + +export function useMarketplaceMoreClick() { + const [,setQ] = useSearchPluginText() + const setSort = useSetAtom(marketplaceSortAtom) + const setSearchMode = useSetAtom(searchModeAtom) + + return useCallback((searchParams?: SearchParamsFromCollection) => { + if (!searchParams) + return + setQ(searchParams?.query || '') + setSort({ + sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, + sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, + }) + setSearchMode(true) + }, [setQ, setSort, setSearchMode]) +} diff --git a/web/app/components/plugins/marketplace/constants.ts b/web/app/components/plugins/marketplace/constants.ts index 92c3e7278f..6613fbe3de 100644 --- a/web/app/components/plugins/marketplace/constants.ts +++ b/web/app/components/plugins/marketplace/constants.ts @@ -1,6 +1,30 @@ +import { PluginCategoryEnum } from '../types' + export const DEFAULT_SORT = { sortBy: 'install_count', sortOrder: 'DESC', } export const SCROLL_BOTTOM_THRESHOLD = 100 + +export const PLUGIN_TYPE_SEARCH_MAP = { + all: 'all', + model: PluginCategoryEnum.model, + tool: PluginCategoryEnum.tool, + agent: PluginCategoryEnum.agent, + extension: PluginCategoryEnum.extension, + datasource: PluginCategoryEnum.datasource, + trigger: PluginCategoryEnum.trigger, + bundle: 'bundle', +} as const + +type ValueOf = T[keyof T] + +export type ActivePluginType = ValueOf + +export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set( + [ + PLUGIN_TYPE_SEARCH_MAP.all, + PLUGIN_TYPE_SEARCH_MAP.tool, + ], +) diff --git a/web/app/components/plugins/marketplace/context.tsx b/web/app/components/plugins/marketplace/context.tsx deleted file mode 100644 index 31b6a7f592..0000000000 --- a/web/app/components/plugins/marketplace/context.tsx +++ /dev/null @@ -1,332 +0,0 @@ -'use client' - -import type { - ReactNode, -} from 'react' -import type { TagKey } from '../constants' -import type { Plugin } from '../types' -import type { - MarketplaceCollection, - PluginsSort, - SearchParams, - SearchParamsFromCollection, -} from './types' -import { debounce } from 'es-toolkit/compat' -import { noop } from 'es-toolkit/function' -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' -import { - createContext, - useContextSelector, -} from 'use-context-selector' -import { useMarketplaceFilters } from '@/hooks/use-query-params' -import { useInstalledPluginList } from '@/service/use-plugins' -import { - getValidCategoryKeys, - getValidTagKeys, -} from '../utils' -import { DEFAULT_SORT } from './constants' -import { - useMarketplaceCollectionsAndPlugins, - useMarketplaceContainerScroll, - useMarketplacePlugins, -} from './hooks' -import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' -import { - getMarketplaceListCondition, - getMarketplaceListFilterType, -} from './utils' - -export type MarketplaceContextValue = { - searchPluginText: string - handleSearchPluginTextChange: (text: string) => void - filterPluginTags: string[] - handleFilterPluginTagsChange: (tags: string[]) => void - activePluginType: string - handleActivePluginTypeChange: (type: string) => void - page: number - handlePageChange: () => void - plugins?: Plugin[] - pluginsTotal?: number - resetPlugins: () => void - sort: PluginsSort - handleSortChange: (sort: PluginsSort) => void - handleQueryPlugins: () => void - handleMoreClick: (searchParams: SearchParamsFromCollection) => void - marketplaceCollectionsFromClient?: MarketplaceCollection[] - setMarketplaceCollectionsFromClient: (collections: MarketplaceCollection[]) => void - marketplaceCollectionPluginsMapFromClient?: Record - setMarketplaceCollectionPluginsMapFromClient: (map: Record) => void - isLoading: boolean - isSuccessCollections: boolean -} - -export const MarketplaceContext = createContext({ - searchPluginText: '', - handleSearchPluginTextChange: noop, - filterPluginTags: [], - handleFilterPluginTagsChange: noop, - activePluginType: 'all', - handleActivePluginTypeChange: noop, - page: 1, - handlePageChange: noop, - plugins: undefined, - pluginsTotal: 0, - resetPlugins: noop, - sort: DEFAULT_SORT, - handleSortChange: noop, - handleQueryPlugins: noop, - handleMoreClick: noop, - marketplaceCollectionsFromClient: [], - setMarketplaceCollectionsFromClient: noop, - marketplaceCollectionPluginsMapFromClient: {}, - setMarketplaceCollectionPluginsMapFromClient: noop, - isLoading: false, - isSuccessCollections: false, -}) - -type MarketplaceContextProviderProps = { - children: ReactNode - searchParams?: SearchParams - shouldExclude?: boolean - scrollContainerId?: string - showSearchParams?: boolean -} - -export function useMarketplaceContext(selector: (value: MarketplaceContextValue) => any) { - return useContextSelector(MarketplaceContext, selector) -} - -export const MarketplaceContextProvider = ({ - children, - searchParams, - shouldExclude, - scrollContainerId, - showSearchParams, -}: MarketplaceContextProviderProps) => { - // Use nuqs hook for URL-based filter state - const [urlFilters, setUrlFilters] = useMarketplaceFilters() - - const { data, isSuccess } = useInstalledPluginList(!shouldExclude) - const exclude = useMemo(() => { - if (shouldExclude) - return data?.plugins.map(plugin => plugin.plugin_id) - }, [data?.plugins, shouldExclude]) - - // Initialize from URL params (legacy support) or use nuqs state - const queryFromSearchParams = searchParams?.q || urlFilters.q - const tagsFromSearchParams = getValidTagKeys(urlFilters.tags as TagKey[]) - const hasValidTags = !!tagsFromSearchParams.length - const hasValidCategory = getValidCategoryKeys(urlFilters.category) - const categoryFromSearchParams = hasValidCategory || PLUGIN_TYPE_SEARCH_MAP.all - - const [searchPluginText, setSearchPluginText] = useState(queryFromSearchParams) - const searchPluginTextRef = useRef(searchPluginText) - const [filterPluginTags, setFilterPluginTags] = useState(tagsFromSearchParams) - const filterPluginTagsRef = useRef(filterPluginTags) - const [activePluginType, setActivePluginType] = useState(categoryFromSearchParams) - const activePluginTypeRef = useRef(activePluginType) - const [sort, setSort] = useState(DEFAULT_SORT) - const sortRef = useRef(sort) - const { - marketplaceCollections: marketplaceCollectionsFromClient, - setMarketplaceCollections: setMarketplaceCollectionsFromClient, - marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapFromClient, - setMarketplaceCollectionPluginsMap: setMarketplaceCollectionPluginsMapFromClient, - queryMarketplaceCollectionsAndPlugins, - isLoading, - isSuccess: isSuccessCollections, - } = useMarketplaceCollectionsAndPlugins() - const { - plugins, - total: pluginsTotal, - resetPlugins, - queryPlugins, - queryPluginsWithDebounced, - cancelQueryPluginsWithDebounced, - isLoading: isPluginsLoading, - fetchNextPage: fetchNextPluginsPage, - hasNextPage: hasNextPluginsPage, - page: pluginsPage, - } = useMarketplacePlugins() - const page = Math.max(pluginsPage || 0, 1) - - useEffect(() => { - if (queryFromSearchParams || hasValidTags || hasValidCategory) { - queryPlugins({ - query: queryFromSearchParams, - category: hasValidCategory, - tags: hasValidTags ? tagsFromSearchParams : [], - sortBy: sortRef.current.sortBy, - sortOrder: sortRef.current.sortOrder, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - } - else { - if (shouldExclude && isSuccess) { - queryMarketplaceCollectionsAndPlugins({ - exclude, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - } - } - }, [queryPlugins, queryMarketplaceCollectionsAndPlugins, isSuccess, exclude]) - - const handleQueryMarketplaceCollectionsAndPlugins = useCallback(() => { - queryMarketplaceCollectionsAndPlugins({ - category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current, - condition: getMarketplaceListCondition(activePluginTypeRef.current), - exclude, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - resetPlugins() - }, [exclude, queryMarketplaceCollectionsAndPlugins, resetPlugins]) - - const applyUrlFilters = useCallback(() => { - if (!showSearchParams) - return - const nextFilters = { - q: searchPluginTextRef.current, - category: activePluginTypeRef.current, - tags: filterPluginTagsRef.current, - } - const categoryChanged = urlFilters.category !== nextFilters.category - setUrlFilters(nextFilters, { - history: categoryChanged ? 'push' : 'replace', - }) - }, [setUrlFilters, showSearchParams, urlFilters.category]) - - const debouncedUpdateSearchParams = useMemo(() => debounce(() => { - applyUrlFilters() - }, 500), [applyUrlFilters]) - - const handleUpdateSearchParams = useCallback((debounced?: boolean) => { - if (debounced) { - debouncedUpdateSearchParams() - } - else { - applyUrlFilters() - } - }, [applyUrlFilters, debouncedUpdateSearchParams]) - - const handleQueryPlugins = useCallback((debounced?: boolean) => { - handleUpdateSearchParams(debounced) - if (debounced) { - queryPluginsWithDebounced({ - query: searchPluginTextRef.current, - category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current, - tags: filterPluginTagsRef.current, - sortBy: sortRef.current.sortBy, - sortOrder: sortRef.current.sortOrder, - exclude, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - } - else { - queryPlugins({ - query: searchPluginTextRef.current, - category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current, - tags: filterPluginTagsRef.current, - sortBy: sortRef.current.sortBy, - sortOrder: sortRef.current.sortOrder, - exclude, - type: getMarketplaceListFilterType(activePluginTypeRef.current), - }) - } - }, [exclude, queryPluginsWithDebounced, queryPlugins, handleUpdateSearchParams]) - - const handleQuery = useCallback((debounced?: boolean) => { - if (!searchPluginTextRef.current && !filterPluginTagsRef.current.length) { - handleUpdateSearchParams(debounced) - cancelQueryPluginsWithDebounced() - handleQueryMarketplaceCollectionsAndPlugins() - return - } - - handleQueryPlugins(debounced) - }, [handleQueryMarketplaceCollectionsAndPlugins, handleQueryPlugins, cancelQueryPluginsWithDebounced, handleUpdateSearchParams]) - - const handleSearchPluginTextChange = useCallback((text: string) => { - setSearchPluginText(text) - searchPluginTextRef.current = text - - handleQuery(true) - }, [handleQuery]) - - const handleFilterPluginTagsChange = useCallback((tags: string[]) => { - setFilterPluginTags(tags) - filterPluginTagsRef.current = tags - - handleQuery() - }, [handleQuery]) - - const handleActivePluginTypeChange = useCallback((type: string) => { - setActivePluginType(type) - activePluginTypeRef.current = type - - handleQuery() - }, [handleQuery]) - - const handleSortChange = useCallback((sort: PluginsSort) => { - setSort(sort) - sortRef.current = sort - - handleQueryPlugins() - }, [handleQueryPlugins]) - - const handlePageChange = useCallback(() => { - if (hasNextPluginsPage) - fetchNextPluginsPage() - }, [fetchNextPluginsPage, hasNextPluginsPage]) - - const handleMoreClick = useCallback((searchParams: SearchParamsFromCollection) => { - setSearchPluginText(searchParams?.query || '') - searchPluginTextRef.current = searchParams?.query || '' - setSort({ - sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, - sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, - }) - sortRef.current = { - sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, - sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, - } - handleQueryPlugins() - }, [handleQueryPlugins]) - - useMarketplaceContainerScroll(handlePageChange, scrollContainerId) - - return ( - - {children} - - ) -} diff --git a/web/app/components/plugins/marketplace/hooks.ts b/web/app/components/plugins/marketplace/hooks.ts index 11558e8c96..b1e4f50767 100644 --- a/web/app/components/plugins/marketplace/hooks.ts +++ b/web/app/components/plugins/marketplace/hooks.ts @@ -26,6 +26,9 @@ import { getMarketplacePluginsByCollectionId, } from './utils' +/** + * @deprecated Use useMarketplaceCollectionsAndPlugins from query.ts instead + */ export const useMarketplaceCollectionsAndPlugins = () => { const [queryParams, setQueryParams] = useState() const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState() @@ -89,7 +92,9 @@ export const useMarketplacePluginsByCollectionId = ( isSuccess, } } - +/** + * @deprecated Use useMarketplacePlugins from query.ts instead + */ export const useMarketplacePlugins = () => { const queryClient = useQueryClient() const [queryParams, setQueryParams] = useState() diff --git a/web/app/components/plugins/marketplace/hydration-client.tsx b/web/app/components/plugins/marketplace/hydration-client.tsx new file mode 100644 index 0000000000..5698db711f --- /dev/null +++ b/web/app/components/plugins/marketplace/hydration-client.tsx @@ -0,0 +1,15 @@ +'use client' + +import { useHydrateAtoms } from 'jotai/utils' +import { preserveSearchStateInQueryAtom } from './atoms' + +export function HydrateMarketplaceAtoms({ + preserveSearchStateInQuery, + children, +}: { + preserveSearchStateInQuery: boolean + children: React.ReactNode +}) { + useHydrateAtoms([[preserveSearchStateInQueryAtom, preserveSearchStateInQuery]]) + return <>{children} +} diff --git a/web/app/components/plugins/marketplace/hydration-server.tsx b/web/app/components/plugins/marketplace/hydration-server.tsx new file mode 100644 index 0000000000..0aa544cff1 --- /dev/null +++ b/web/app/components/plugins/marketplace/hydration-server.tsx @@ -0,0 +1,45 @@ +import type { SearchParams } from 'nuqs' +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' +import { createLoader } from 'nuqs/server' +import { getQueryClientServer } from '@/context/query-client-server' +import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants' +import { marketplaceKeys } from './query' +import { marketplaceSearchParamsParsers } from './search-params' +import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils' + +// The server side logic should move to marketplace's codebase so that we can get rid of Next.js + +async function getDehydratedState(searchParams?: Promise) { + if (!searchParams) { + return + } + const loadSearchParams = createLoader(marketplaceSearchParamsParsers) + const params = await loadSearchParams(searchParams) + + if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) { + return + } + + const queryClient = getQueryClientServer() + + await queryClient.prefetchQuery({ + queryKey: marketplaceKeys.collections(getCollectionsParams(params.category)), + queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)), + }) + return dehydrate(queryClient) +} + +export async function HydrateQueryClient({ + searchParams, + children, +}: { + searchParams: Promise | undefined + children: React.ReactNode +}) { + const dehydratedState = await getDehydratedState(searchParams) + return ( + + {children} + + ) +} diff --git a/web/app/components/plugins/marketplace/index.spec.tsx b/web/app/components/plugins/marketplace/index.spec.tsx index b3b1d58dd4..1a3cd15b6b 100644 --- a/web/app/components/plugins/marketplace/index.spec.tsx +++ b/web/app/components/plugins/marketplace/index.spec.tsx @@ -1,6 +1,6 @@ -import type { MarketplaceCollection, SearchParams, SearchParamsFromCollection } from './types' +import type { MarketplaceCollection } from './types' import type { Plugin } from '@/app/components/plugins/types' -import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' +import { act, render, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum } from '@/app/components/plugins/types' @@ -9,10 +9,7 @@ import { PluginCategoryEnum } from '@/app/components/plugins/types' // ================================ // Note: Import after mocks are set up -import { DEFAULT_SORT, SCROLL_BOTTOM_THRESHOLD } from './constants' -import { MarketplaceContext, MarketplaceContextProvider, useMarketplaceContext } from './context' -import PluginTypeSwitch, { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' -import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' +import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants' import { getFormattedPlugin, getMarketplaceListCondition, @@ -62,7 +59,7 @@ vi.mock('@/service/use-plugins', () => ({ // Mock tanstack query const mockFetchNextPage = vi.fn() -let mockHasNextPage = false +const mockHasNextPage = false let mockInfiniteQueryData: { pages: Array<{ plugins: unknown[], total: number, page: number, pageSize: number }> } | undefined let capturedInfiniteQueryFn: ((ctx: { pageParam: number, signal: AbortSignal }) => Promise) | null = null let capturedQueryFn: ((ctx: { signal: AbortSignal }) => Promise) | null = null @@ -176,7 +173,7 @@ vi.mock('@/i18n-config/server', () => ({ })) // Mock useTheme hook -let mockTheme = 'light' +const mockTheme = 'light' vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: mockTheme, @@ -367,47 +364,6 @@ const createMockCollection = (overrides?: Partial): Marke ...overrides, }) -// ================================ -// Shared Test Components -// ================================ - -// Search input test component - used in multiple tests -const SearchInputTestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) - - return ( -
- handleChange(e.target.value)} - /> -
{searchText}
-
- ) -} - -// Plugin type change test component -const PluginTypeChangeTestComponent = () => { - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - return ( - - ) -} - -// Page change test component -const PageChangeTestComponent = () => { - const handlePageChange = useMarketplaceContext(v => v.handlePageChange) - return ( - - ) -} - // ================================ // Constants Tests // ================================ @@ -490,7 +446,7 @@ describe('utils', () => { org: 'test-org', name: 'test-plugin', tags: [{ name: 'search' }], - } + } as unknown as Plugin const formatted = getFormattedPlugin(rawPlugin) @@ -504,7 +460,7 @@ describe('utils', () => { name: 'test-bundle', description: 'Bundle description', labels: { 'en-US': 'Test Bundle' }, - } + } as unknown as Plugin const formatted = getFormattedPlugin(rawBundle) @@ -1514,955 +1470,6 @@ describe('flatMap Coverage', () => { }) }) -// ================================ -// Context Tests -// ================================ -describe('MarketplaceContext', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - }) - - describe('MarketplaceContext default values', () => { - it('should have correct default context values', () => { - expect(MarketplaceContext).toBeDefined() - }) - }) - - describe('useMarketplaceContext', () => { - it('should return selected value from context', () => { - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText}
- } - - render( - - - , - ) - - expect(screen.getByTestId('search-text')).toHaveTextContent('') - }) - }) - - describe('MarketplaceContextProvider', () => { - it('should render children', () => { - render( - -
Test Child
-
, - ) - - expect(screen.getByTestId('child')).toBeInTheDocument() - }) - - it('should initialize with default values', () => { - // Reset mock data before this test - mockInfiniteQueryData = undefined - - const TestComponent = () => { - const activePluginType = useMarketplaceContext(v => v.activePluginType) - const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags) - const sort = useMarketplaceContext(v => v.sort) - const page = useMarketplaceContext(v => v.page) - - return ( -
-
{activePluginType}
-
{filterPluginTags.join(',')}
-
{sort.sortBy}
-
{page}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - expect(screen.getByTestId('tags')).toHaveTextContent('') - expect(screen.getByTestId('sort')).toHaveTextContent('install_count') - // Page depends on mock data, could be 0 or 1 depending on query state - expect(screen.getByTestId('page')).toBeInTheDocument() - }) - - it('should initialize with searchParams from props', () => { - const searchParams: SearchParams = { - q: 'test query', - category: 'tool', - } - - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText}
- } - - render( - - - , - ) - - expect(screen.getByTestId('search')).toHaveTextContent('test query') - }) - - it('should provide handleSearchPluginTextChange function', () => { - render( - - - , - ) - - const input = screen.getByTestId('search-input') - fireEvent.change(input, { target: { value: 'new search' } }) - - expect(screen.getByTestId('search-display')).toHaveTextContent('new search') - }) - - it('should provide handleFilterPluginTagsChange function', () => { - const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - const handleChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) - - return ( -
- -
{tags.join(',')}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('add-tag')) - - expect(screen.getByTestId('tags-display')).toHaveTextContent('search,image') - }) - - it('should provide handleActivePluginTypeChange function', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
- -
{activeType}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('change-type')) - - expect(screen.getByTestId('type-display')).toHaveTextContent('tool') - }) - - it('should provide handleSortChange function', () => { - const TestComponent = () => { - const sort = useMarketplaceContext(v => v.sort) - const handleChange = useMarketplaceContext(v => v.handleSortChange) - - return ( -
- -
{`${sort.sortBy}-${sort.sortOrder}`}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('change-sort')) - - expect(screen.getByTestId('sort-display')).toHaveTextContent('created_at-ASC') - }) - - it('should provide handleMoreClick function', () => { - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - const sort = useMarketplaceContext(v => v.sort) - const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick) - - const searchParams: SearchParamsFromCollection = { - query: 'more query', - sort_by: 'version_updated_at', - sort_order: 'DESC', - } - - return ( -
- -
{searchText}
-
{`${sort.sortBy}-${sort.sortOrder}`}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('more-click')) - - expect(screen.getByTestId('search-display')).toHaveTextContent('more query') - expect(screen.getByTestId('sort-display')).toHaveTextContent('version_updated_at-DESC') - }) - - it('should provide resetPlugins function', () => { - const TestComponent = () => { - const resetPlugins = useMarketplaceContext(v => v.resetPlugins) - const plugins = useMarketplaceContext(v => v.plugins) - - return ( -
- -
{plugins ? 'has plugins' : 'no plugins'}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('reset-plugins')) - - // Plugins should remain undefined after reset - expect(screen.getByTestId('plugins-display')).toHaveTextContent('no plugins') - }) - - it('should accept shouldExclude prop', () => { - const TestComponent = () => { - const isLoading = useMarketplaceContext(v => v.isLoading) - return
{isLoading.toString()}
- } - - render( - - - , - ) - - expect(screen.getByTestId('loading')).toBeInTheDocument() - }) - - it('should accept scrollContainerId prop', () => { - render( - -
Child
-
, - ) - - expect(screen.getByTestId('child')).toBeInTheDocument() - }) - - it('should accept showSearchParams prop', () => { - render( - -
Child
-
, - ) - - expect(screen.getByTestId('child')).toBeInTheDocument() - }) - }) -}) - -// ================================ -// PluginTypeSwitch Tests -// ================================ -describe('PluginTypeSwitch', () => { - // Mock context values for PluginTypeSwitch - const mockContextValues = { - activePluginType: 'all', - handleActivePluginTypeChange: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - mockContextValues.activePluginType = 'all' - mockContextValues.handleActivePluginTypeChange = vi.fn() - - vi.doMock('./context', () => ({ - useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), - })) - }) - - // Note: PluginTypeSwitch uses internal context, so we test within the provider - describe('Rendering', () => { - it('should render without crashing', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
-
handleChange('all')} - data-testid="all-option" - > - All -
-
handleChange('tool')} - data-testid="tool-option" - > - Tools -
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('all-option')).toBeInTheDocument() - expect(screen.getByTestId('tool-option')).toBeInTheDocument() - }) - - it('should highlight active plugin type', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
-
handleChange('all')} - data-testid="all-option" - > - All -
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('all-option')).toHaveClass('active') - }) - }) - - describe('User Interactions', () => { - it('should call handleActivePluginTypeChange when option is clicked', () => { - const TestComponent = () => { - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - const activeType = useMarketplaceContext(v => v.activePluginType) - - return ( -
-
handleChange('tool')} - data-testid="tool-option" - > - Tools -
-
{activeType}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('tool-option')) - expect(screen.getByTestId('active-type')).toHaveTextContent('tool') - }) - - it('should update active type when different option is selected', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
-
handleChange('model')} - data-testid="model-option" - > - Models -
-
{activeType}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('model-option')) - - expect(screen.getByTestId('active-display')).toHaveTextContent('model') - }) - }) - - describe('Props', () => { - it('should accept locale prop', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - return
{activeType}
- } - - render( - - - , - ) - - expect(screen.getByTestId('type')).toBeInTheDocument() - }) - - it('should accept className prop', () => { - const { container } = render( - -
- Content -
-
, - ) - - expect(container.querySelector('.custom-class')).toBeInTheDocument() - }) - }) -}) - -// ================================ -// StickySearchAndSwitchWrapper Tests -// ================================ -describe('StickySearchAndSwitchWrapper', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - const { container } = render( - - - , - ) - - expect(container.firstChild).toBeInTheDocument() - }) - - it('should apply default styling', () => { - const { container } = render( - - - , - ) - - const wrapper = container.querySelector('.mt-4.bg-background-body') - expect(wrapper).toBeInTheDocument() - }) - - it('should apply sticky positioning when pluginTypeSwitchClassName contains top-', () => { - const { container } = render( - - - , - ) - - const wrapper = container.querySelector('.sticky.z-10') - expect(wrapper).toBeInTheDocument() - }) - - it('should not apply sticky positioning without top- class', () => { - const { container } = render( - - - , - ) - - const wrapper = container.querySelector('.sticky') - expect(wrapper).toBeNull() - }) - }) - - describe('Props', () => { - it('should accept showSearchParams prop', () => { - render( - - - , - ) - - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() - }) - - it('should pass pluginTypeSwitchClassName to wrapper', () => { - const { container } = render( - - - , - ) - - const wrapper = container.querySelector('.top-16.custom-style') - expect(wrapper).toBeInTheDocument() - }) - }) -}) - -// ================================ -// Integration Tests -// ================================ -describe('Marketplace Integration', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - mockTheme = 'light' - }) - - describe('Context with child components', () => { - it('should share state between multiple consumers', () => { - const SearchDisplay = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText || 'empty'}
- } - - const SearchInput = () => { - const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) - return ( - handleChange(e.target.value)} - /> - ) - } - - render( - - - - , - ) - - expect(screen.getByTestId('search-display')).toHaveTextContent('empty') - - fireEvent.change(screen.getByTestId('search-input'), { target: { value: 'test' } }) - - expect(screen.getByTestId('search-display')).toHaveTextContent('test') - }) - - it('should update tags and reset plugins when search criteria changes', () => { - const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - const handleTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) - const resetPlugins = useMarketplaceContext(v => v.resetPlugins) - - const handleAddTag = () => { - handleTagsChange(['search']) - } - - const handleReset = () => { - handleTagsChange([]) - resetPlugins() - } - - return ( -
- - -
{tags.join(',') || 'none'}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('tags')).toHaveTextContent('none') - - fireEvent.click(screen.getByTestId('add-tag')) - expect(screen.getByTestId('tags')).toHaveTextContent('search') - - fireEvent.click(screen.getByTestId('reset')) - expect(screen.getByTestId('tags')).toHaveTextContent('none') - }) - }) - - describe('Sort functionality', () => { - it('should update sort and trigger query', () => { - const TestComponent = () => { - const sort = useMarketplaceContext(v => v.sort) - const handleSortChange = useMarketplaceContext(v => v.handleSortChange) - - return ( -
- - -
{sort.sortBy}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('current-sort')).toHaveTextContent('install_count') - - fireEvent.click(screen.getByTestId('sort-recent')) - expect(screen.getByTestId('current-sort')).toHaveTextContent('version_updated_at') - - fireEvent.click(screen.getByTestId('sort-popular')) - expect(screen.getByTestId('current-sort')).toHaveTextContent('install_count') - }) - }) - - describe('Plugin type switching', () => { - it('should filter by plugin type', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleTypeChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
- {Object.entries(PLUGIN_TYPE_SEARCH_MAP).map(([key, value]) => ( - - ))} -
{activeType}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - - fireEvent.click(screen.getByTestId('type-tool')) - expect(screen.getByTestId('active-type')).toHaveTextContent('tool') - - fireEvent.click(screen.getByTestId('type-model')) - expect(screen.getByTestId('active-type')).toHaveTextContent('model') - - fireEvent.click(screen.getByTestId('type-bundle')) - expect(screen.getByTestId('active-type')).toHaveTextContent('bundle') - }) - }) -}) - -// ================================ -// Edge Cases Tests -// ================================ -describe('Edge Cases', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - }) - - describe('Empty states', () => { - it('should handle empty search text', () => { - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - return
{searchText || 'empty'}
- } - - render( - - - , - ) - - expect(screen.getByTestId('search')).toHaveTextContent('empty') - }) - - it('should handle empty tags array', () => { - const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - return
{tags.length === 0 ? 'no tags' : tags.join(',')}
- } - - render( - - - , - ) - - expect(screen.getByTestId('tags')).toHaveTextContent('no tags') - }) - - it('should handle undefined plugins', () => { - const TestComponent = () => { - const plugins = useMarketplaceContext(v => v.plugins) - return
{plugins === undefined ? 'undefined' : 'defined'}
- } - - render( - - - , - ) - - expect(screen.getByTestId('plugins')).toHaveTextContent('undefined') - }) - }) - - describe('Special characters in search', () => { - it('should handle special characters in search text', () => { - render( - - - , - ) - - const input = screen.getByTestId('search-input') - - // Test with special characters - fireEvent.change(input, { target: { value: 'test@#$%^&*()' } }) - expect(screen.getByTestId('search-display')).toHaveTextContent('test@#$%^&*()') - - // Test with unicode characters - fireEvent.change(input, { target: { value: '测试中文' } }) - expect(screen.getByTestId('search-display')).toHaveTextContent('测试中文') - - // Test with emojis - fireEvent.change(input, { target: { value: '🔍 search' } }) - expect(screen.getByTestId('search-display')).toHaveTextContent('🔍 search') - }) - }) - - describe('Rapid state changes', () => { - it('should handle rapid search text changes', async () => { - render( - - - , - ) - - const input = screen.getByTestId('search-input') - - // Rapidly change values - fireEvent.change(input, { target: { value: 'a' } }) - fireEvent.change(input, { target: { value: 'ab' } }) - fireEvent.change(input, { target: { value: 'abc' } }) - fireEvent.change(input, { target: { value: 'abcd' } }) - fireEvent.change(input, { target: { value: 'abcde' } }) - - // Final value should be the last one - expect(screen.getByTestId('search-display')).toHaveTextContent('abcde') - }) - - it('should handle rapid type changes', () => { - const TestComponent = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - - return ( -
- - - -
{activeType}
-
- ) - } - - render( - - - , - ) - - // Rapidly click different types - fireEvent.click(screen.getByTestId('type-tool')) - fireEvent.click(screen.getByTestId('type-model')) - fireEvent.click(screen.getByTestId('type-all')) - fireEvent.click(screen.getByTestId('type-tool')) - - expect(screen.getByTestId('active-type')).toHaveTextContent('tool') - }) - }) - - describe('Boundary conditions', () => { - it('should handle very long search text', () => { - const longText = 'a'.repeat(1000) - - const TestComponent = () => { - const searchText = useMarketplaceContext(v => v.searchPluginText) - const handleChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) - - return ( -
- handleChange(e.target.value)} - /> -
{searchText.length}
-
- ) - } - - render( - - - , - ) - - fireEvent.change(screen.getByTestId('search-input'), { target: { value: longText } }) - - expect(screen.getByTestId('search-length')).toHaveTextContent('1000') - }) - - it('should handle large number of tags', () => { - const manyTags = Array.from({ length: 100 }, (_, i) => `tag-${i}`) - - const TestComponent = () => { - const tags = useMarketplaceContext(v => v.filterPluginTags) - const handleChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) - - return ( -
- -
{tags.length}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('add-many-tags')) - - expect(screen.getByTestId('tags-count')).toHaveTextContent('100') - }) - }) - - describe('Sort edge cases', () => { - it('should handle same sort selection', () => { - const TestComponent = () => { - const sort = useMarketplaceContext(v => v.sort) - const handleSortChange = useMarketplaceContext(v => v.handleSortChange) - - return ( -
- -
{`${sort.sortBy}-${sort.sortOrder}`}
-
- ) - } - - render( - - - , - ) - - // Initial sort should be install_count-DESC - expect(screen.getByTestId('sort-display')).toHaveTextContent('install_count-DESC') - - // Click same sort - should not cause issues - fireEvent.click(screen.getByTestId('select-same-sort')) - - expect(screen.getByTestId('sort-display')).toHaveTextContent('install_count-DESC') - }) - }) -}) - // ================================ // Async Utils Tests // ================================ @@ -2685,338 +1692,6 @@ describe('useMarketplaceContainerScroll', () => { }) }) -// ================================ -// Plugin Type Switch Component Tests -// ================================ -describe('PluginTypeSwitch Component', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - }) - - describe('Rendering actual component', () => { - it('should render all plugin type options', () => { - render( - - - , - ) - - // Note: The global mock returns the key with namespace prefix (plugin.) - expect(screen.getByText('plugin.category.all')).toBeInTheDocument() - expect(screen.getByText('plugin.category.models')).toBeInTheDocument() - expect(screen.getByText('plugin.category.tools')).toBeInTheDocument() - expect(screen.getByText('plugin.category.datasources')).toBeInTheDocument() - expect(screen.getByText('plugin.category.triggers')).toBeInTheDocument() - expect(screen.getByText('plugin.category.agents')).toBeInTheDocument() - expect(screen.getByText('plugin.category.extensions')).toBeInTheDocument() - expect(screen.getByText('plugin.category.bundles')).toBeInTheDocument() - }) - - it('should apply className prop', () => { - const { container } = render( - - - , - ) - - expect(container.querySelector('.custom-class')).toBeInTheDocument() - }) - - it('should call handleActivePluginTypeChange on option click', () => { - const TestWrapper = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - return ( -
- -
{activeType}
-
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByText('plugin.category.tools')) - expect(screen.getByTestId('active-type-display')).toHaveTextContent('tool') - }) - - it('should highlight active option with correct classes', () => { - const TestWrapper = () => { - const handleChange = useMarketplaceContext(v => v.handleActivePluginTypeChange) - return ( -
- - -
- ) - } - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('set-model')) - const modelOption = screen.getByText('plugin.category.models').closest('div') - expect(modelOption).toHaveClass('shadow-xs') - }) - }) - - describe('Popstate handling', () => { - it('should handle popstate event when showSearchParams is true', () => { - const originalHref = window.location.href - - const TestWrapper = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - return ( -
- -
{activeType}
-
- ) - } - - render( - - - , - ) - - const popstateEvent = new PopStateEvent('popstate') - window.dispatchEvent(popstateEvent) - - expect(screen.getByTestId('active-type')).toBeInTheDocument() - expect(window.location.href).toBe(originalHref) - }) - - it('should not handle popstate when showSearchParams is false', () => { - const TestWrapper = () => { - const activeType = useMarketplaceContext(v => v.activePluginType) - return ( -
- -
{activeType}
-
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - - const popstateEvent = new PopStateEvent('popstate') - window.dispatchEvent(popstateEvent) - - expect(screen.getByTestId('active-type')).toHaveTextContent('all') - }) - }) -}) - -// ================================ -// Context Advanced Tests -// ================================ -describe('Context Advanced', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPortalOpenState = false - mockSetUrlFilters.mockClear() - mockHasNextPage = false - }) - - describe('URL filter synchronization', () => { - it('should update URL filters when showSearchParams is true and type changes', () => { - render( - - - , - ) - - fireEvent.click(screen.getByTestId('change-type')) - expect(mockSetUrlFilters).toHaveBeenCalled() - }) - - it('should not update URL filters when showSearchParams is false', () => { - render( - - - , - ) - - fireEvent.click(screen.getByTestId('change-type')) - expect(mockSetUrlFilters).not.toHaveBeenCalled() - }) - }) - - describe('handlePageChange', () => { - it('should invoke fetchNextPage when hasNextPage is true', () => { - mockHasNextPage = true - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('next-page')) - expect(mockFetchNextPage).toHaveBeenCalled() - }) - - it('should not invoke fetchNextPage when hasNextPage is false', () => { - mockHasNextPage = false - - render( - - - , - ) - - fireEvent.click(screen.getByTestId('next-page')) - expect(mockFetchNextPage).not.toHaveBeenCalled() - }) - }) - - describe('setMarketplaceCollectionsFromClient', () => { - it('should provide setMarketplaceCollectionsFromClient function', () => { - const TestComponent = () => { - const setCollections = useMarketplaceContext(v => v.setMarketplaceCollectionsFromClient) - - return ( -
- -
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('set-collections')).toBeInTheDocument() - // The function should be callable without throwing - expect(() => fireEvent.click(screen.getByTestId('set-collections'))).not.toThrow() - }) - }) - - describe('setMarketplaceCollectionPluginsMapFromClient', () => { - it('should provide setMarketplaceCollectionPluginsMapFromClient function', () => { - const TestComponent = () => { - const setPluginsMap = useMarketplaceContext(v => v.setMarketplaceCollectionPluginsMapFromClient) - - return ( -
- -
- ) - } - - render( - - - , - ) - - expect(screen.getByTestId('set-plugins-map')).toBeInTheDocument() - // The function should be callable without throwing - expect(() => fireEvent.click(screen.getByTestId('set-plugins-map'))).not.toThrow() - }) - }) - - describe('handleQueryPlugins', () => { - it('should provide handleQueryPlugins function that can be called', () => { - const TestComponent = () => { - const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins) - return ( - - ) - } - - render( - - - , - ) - - expect(screen.getByTestId('query-plugins')).toBeInTheDocument() - fireEvent.click(screen.getByTestId('query-plugins')) - expect(screen.getByTestId('query-plugins')).toBeInTheDocument() - }) - }) - - describe('isLoading state', () => { - it('should expose isLoading state', () => { - const TestComponent = () => { - const isLoading = useMarketplaceContext(v => v.isLoading) - return
{isLoading.toString()}
- } - - render( - - - , - ) - - expect(screen.getByTestId('loading')).toHaveTextContent('false') - }) - }) - - describe('isSuccessCollections state', () => { - it('should expose isSuccessCollections state', () => { - const TestComponent = () => { - const isSuccess = useMarketplaceContext(v => v.isSuccessCollections) - return
{isSuccess.toString()}
- } - - render( - - - , - ) - - expect(screen.getByTestId('success')).toHaveTextContent('false') - }) - }) - - describe('pluginsTotal', () => { - it('should expose plugins total count', () => { - const TestComponent = () => { - const total = useMarketplaceContext(v => v.pluginsTotal) - return
{total || 0}
- } - - render( - - - , - ) - - expect(screen.getByTestId('total')).toHaveTextContent('0') - }) - }) -}) - // ================================ // Test Data Factory Tests // ================================ diff --git a/web/app/components/plugins/marketplace/index.tsx b/web/app/components/plugins/marketplace/index.tsx index 08d1bc833f..1f32ee4d29 100644 --- a/web/app/components/plugins/marketplace/index.tsx +++ b/web/app/components/plugins/marketplace/index.tsx @@ -1,55 +1,39 @@ -import type { MarketplaceCollection, SearchParams } from './types' -import type { Plugin } from '@/app/components/plugins/types' +import type { SearchParams } from 'nuqs' import { TanstackQueryInitializer } from '@/context/query-client' -import { MarketplaceContextProvider } from './context' import Description from './description' +import { HydrateMarketplaceAtoms } from './hydration-client' +import { HydrateQueryClient } from './hydration-server' import ListWrapper from './list/list-wrapper' import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' -import { getMarketplaceCollectionsAndPlugins } from './utils' type MarketplaceProps = { showInstallButton?: boolean - shouldExclude?: boolean - searchParams?: SearchParams pluginTypeSwitchClassName?: string - scrollContainerId?: string - showSearchParams?: boolean + /** + * Pass the search params from the request to prefetch data on the server + * and preserve the search params in the URL. + */ + searchParams?: Promise } + const Marketplace = async ({ showInstallButton = true, - shouldExclude, - searchParams, pluginTypeSwitchClassName, - scrollContainerId, - showSearchParams = true, + searchParams, }: MarketplaceProps) => { - let marketplaceCollections: MarketplaceCollection[] = [] - let marketplaceCollectionPluginsMap: Record = {} - if (!shouldExclude) { - const marketplaceCollectionsAndPluginsData = await getMarketplaceCollectionsAndPlugins() - marketplaceCollections = marketplaceCollectionsAndPluginsData.marketplaceCollections - marketplaceCollectionPluginsMap = marketplaceCollectionsAndPluginsData.marketplaceCollectionPluginsMap - } - return ( - - - - - + + + + + + + ) } diff --git a/web/app/components/plugins/marketplace/list/index.spec.tsx b/web/app/components/plugins/marketplace/list/index.spec.tsx index c8fc6309a4..81616f5958 100644 --- a/web/app/components/plugins/marketplace/list/index.spec.tsx +++ b/web/app/components/plugins/marketplace/list/index.spec.tsx @@ -1,6 +1,6 @@ import type { MarketplaceCollection, SearchParamsFromCollection } from '../types' import type { Plugin } from '@/app/components/plugins/types' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum } from '@/app/components/plugins/types' import List from './index' @@ -30,23 +30,27 @@ vi.mock('#i18n', () => ({ useLocale: () => 'en-US', })) -// Mock useMarketplaceContext with controllable values -const mockContextValues = { - plugins: undefined as Plugin[] | undefined, - pluginsTotal: 0, - marketplaceCollectionsFromClient: undefined as MarketplaceCollection[] | undefined, - marketplaceCollectionPluginsMapFromClient: undefined as Record | undefined, - isLoading: false, - isSuccessCollections: false, - handleQueryPlugins: vi.fn(), - searchPluginText: '', - filterPluginTags: [] as string[], - page: 1, - handleMoreClick: vi.fn(), -} +// Mock marketplace state hooks with controllable values +const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => { + return { + mockMarketplaceData: { + plugins: undefined as Plugin[] | undefined, + pluginsTotal: 0, + marketplaceCollections: undefined as MarketplaceCollection[] | undefined, + marketplaceCollectionPluginsMap: undefined as Record | undefined, + isLoading: false, + page: 1, + }, + mockMoreClick: vi.fn(), + } +}) -vi.mock('../context', () => ({ - useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), +vi.mock('../state', () => ({ + useMarketplaceData: () => mockMarketplaceData, +})) + +vi.mock('../atoms', () => ({ + useMarketplaceMoreClick: () => mockMoreClick, })) // Mock useLocale context @@ -578,7 +582,7 @@ describe('ListWithCollection', () => { // View More Button Tests // ================================ describe('View More Button', () => { - it('should render View More button when collection is searchable and onMoreClick is provided', () => { + it('should render View More button when collection is searchable', () => { const collections = [createMockCollection({ name: 'collection-0', searchable: true, @@ -587,14 +591,12 @@ describe('ListWithCollection', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - const onMoreClick = vi.fn() render( , ) @@ -609,42 +611,19 @@ describe('ListWithCollection', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - const onMoreClick = vi.fn() render( , ) expect(screen.queryByText('View More')).not.toBeInTheDocument() }) - it('should not render View More button when onMoreClick is not provided', () => { - const collections = [createMockCollection({ - name: 'collection-0', - searchable: true, - })] - const pluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - - render( - , - ) - - expect(screen.queryByText('View More')).not.toBeInTheDocument() - }) - - it('should call onMoreClick with search_params when View More is clicked', () => { + it('should call moreClick hook with search_params when View More is clicked', () => { const searchParams: SearchParamsFromCollection = { query: 'test-query', sort_by: 'install_count' } const collections = [createMockCollection({ name: 'collection-0', @@ -654,21 +633,19 @@ describe('ListWithCollection', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - const onMoreClick = vi.fn() render( , ) fireEvent.click(screen.getByText('View More')) - expect(onMoreClick).toHaveBeenCalledTimes(1) - expect(onMoreClick).toHaveBeenCalledWith(searchParams) + expect(mockMoreClick).toHaveBeenCalledTimes(1) + expect(mockMoreClick).toHaveBeenCalledWith(searchParams) }) }) @@ -802,24 +779,15 @@ describe('ListWithCollection', () => { // ListWrapper Component Tests // ================================ describe('ListWrapper', () => { - const defaultProps = { - marketplaceCollections: [] as MarketplaceCollection[], - marketplaceCollectionPluginsMap: {} as Record, - showInstallButton: false, - } - beforeEach(() => { vi.clearAllMocks() - // Reset context values - mockContextValues.plugins = undefined - mockContextValues.pluginsTotal = 0 - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined - mockContextValues.isLoading = false - mockContextValues.isSuccessCollections = false - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] - mockContextValues.page = 1 + // Reset mock data + mockMarketplaceData.plugins = undefined + mockMarketplaceData.pluginsTotal = 0 + mockMarketplaceData.marketplaceCollections = undefined + mockMarketplaceData.marketplaceCollectionPluginsMap = undefined + mockMarketplaceData.isLoading = false + mockMarketplaceData.page = 1 }) // ================================ @@ -827,32 +795,32 @@ describe('ListWrapper', () => { // ================================ describe('Rendering', () => { it('should render without crashing', () => { - render() + render() expect(document.body).toBeInTheDocument() }) it('should render with scrollbarGutter style', () => { - const { container } = render() + const { container } = render() const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveStyle({ scrollbarGutter: 'stable' }) }) it('should render Loading component when isLoading is true and page is 1', () => { - mockContextValues.isLoading = true - mockContextValues.page = 1 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 1 - render() + render() expect(screen.getByTestId('loading-component')).toBeInTheDocument() }) it('should not render Loading component when page > 1', () => { - mockContextValues.isLoading = true - mockContextValues.page = 2 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 - render() + render() expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() }) @@ -863,26 +831,26 @@ describe('ListWrapper', () => { // ================================ describe('Plugins Header', () => { it('should render plugins result count when plugins are present', () => { - mockContextValues.plugins = createMockPluginList(5) - mockContextValues.pluginsTotal = 5 + mockMarketplaceData.plugins = createMockPluginList(5) + mockMarketplaceData.pluginsTotal = 5 - render() + render() expect(screen.getByText('5 plugins found')).toBeInTheDocument() }) it('should render SortDropdown when plugins are present', () => { - mockContextValues.plugins = createMockPluginList(1) + mockMarketplaceData.plugins = createMockPluginList(1) - render() + render() expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument() }) it('should not render plugins header when plugins is undefined', () => { - mockContextValues.plugins = undefined + mockMarketplaceData.plugins = undefined - render() + render() expect(screen.queryByTestId('sort-dropdown')).not.toBeInTheDocument() }) @@ -892,197 +860,60 @@ describe('ListWrapper', () => { // List Rendering Logic Tests // ================================ describe('List Rendering Logic', () => { - it('should render List when not loading', () => { - mockContextValues.isLoading = false - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + it('should render collections when not loading', () => { + mockMarketplaceData.isLoading = false + mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - render( - , - ) + render() expect(screen.getByText('Collection 0')).toBeInTheDocument() }) it('should render List when loading but page > 1', () => { - mockContextValues.isLoading = true - mockContextValues.page = 2 - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 + mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - render( - , - ) + render() expect(screen.getByText('Collection 0')).toBeInTheDocument() }) - - it('should use client collections when available', () => { - const serverCollections = createMockCollectionList(1) - serverCollections[0].label = { 'en-US': 'Server Collection' } - const clientCollections = createMockCollectionList(1) - clientCollections[0].label = { 'en-US': 'Client Collection' } - - const serverPluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - const clientPluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - - mockContextValues.marketplaceCollectionsFromClient = clientCollections - mockContextValues.marketplaceCollectionPluginsMapFromClient = clientPluginsMap - - render( - , - ) - - expect(screen.getByText('Client Collection')).toBeInTheDocument() - expect(screen.queryByText('Server Collection')).not.toBeInTheDocument() - }) - - it('should use server collections when client collections are not available', () => { - const serverCollections = createMockCollectionList(1) - serverCollections[0].label = { 'en-US': 'Server Collection' } - const serverPluginsMap: Record = { - 'collection-0': createMockPluginList(1), - } - - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined - - render( - , - ) - - expect(screen.getByText('Server Collection')).toBeInTheDocument() - }) }) // ================================ - // Context Integration Tests + // Data Integration Tests // ================================ - describe('Context Integration', () => { - it('should pass plugins from context to List', () => { - const plugins = createMockPluginList(2) - mockContextValues.plugins = plugins + describe('Data Integration', () => { + it('should pass plugins from state to List', () => { + mockMarketplaceData.plugins = createMockPluginList(2) - render() + render() expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument() expect(screen.getByTestId('card-plugin-1')).toBeInTheDocument() }) - it('should pass handleMoreClick from context to List', () => { - const mockHandleMoreClick = vi.fn() - mockContextValues.handleMoreClick = mockHandleMoreClick - - const collections = [createMockCollection({ + it('should show View More button and call moreClick hook', () => { + mockMarketplaceData.marketplaceCollections = [createMockCollection({ name: 'collection-0', searchable: true, search_params: { query: 'test' }, })] - const pluginsMap: Record = { + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - render( - , - ) + render() fireEvent.click(screen.getByText('View More')) - expect(mockHandleMoreClick).toHaveBeenCalled() - }) - }) - - // ================================ - // Effect Tests (handleQueryPlugins) - // ================================ - describe('handleQueryPlugins Effect', () => { - it('should call handleQueryPlugins when conditions are met', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] - - render() - - await waitFor(() => { - expect(mockHandleQueryPlugins).toHaveBeenCalled() - }) - }) - - it('should not call handleQueryPlugins when client collections exist', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = createMockCollectionList(1) - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] - - render() - - // Give time for effect to run - await waitFor(() => { - expect(mockHandleQueryPlugins).not.toHaveBeenCalled() - }) - }) - - it('should not call handleQueryPlugins when search text exists', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.searchPluginText = 'search text' - mockContextValues.filterPluginTags = [] - - render() - - await waitFor(() => { - expect(mockHandleQueryPlugins).not.toHaveBeenCalled() - }) - }) - - it('should not call handleQueryPlugins when filter tags exist', async () => { - const mockHandleQueryPlugins = vi.fn() - mockContextValues.handleQueryPlugins = mockHandleQueryPlugins - mockContextValues.isSuccessCollections = true - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = ['tag1'] - - render() - - await waitFor(() => { - expect(mockHandleQueryPlugins).not.toHaveBeenCalled() - }) + expect(mockMoreClick).toHaveBeenCalled() }) }) @@ -1090,32 +921,32 @@ describe('ListWrapper', () => { // Edge Cases Tests // ================================ describe('Edge Cases', () => { - it('should handle empty plugins array from context', () => { - mockContextValues.plugins = [] - mockContextValues.pluginsTotal = 0 + it('should handle empty plugins array', () => { + mockMarketplaceData.plugins = [] + mockMarketplaceData.pluginsTotal = 0 - render() + render() expect(screen.getByText('0 plugins found')).toBeInTheDocument() expect(screen.getByTestId('empty-component')).toBeInTheDocument() }) it('should handle large pluginsTotal', () => { - mockContextValues.plugins = createMockPluginList(10) - mockContextValues.pluginsTotal = 10000 + mockMarketplaceData.plugins = createMockPluginList(10) + mockMarketplaceData.pluginsTotal = 10000 - render() + render() expect(screen.getByText('10000 plugins found')).toBeInTheDocument() }) it('should handle both loading and has plugins', () => { - mockContextValues.isLoading = true - mockContextValues.page = 2 - mockContextValues.plugins = createMockPluginList(5) - mockContextValues.pluginsTotal = 50 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 + mockMarketplaceData.plugins = createMockPluginList(5) + mockMarketplaceData.pluginsTotal = 50 - render() + render() // Should show plugins header and list expect(screen.getByText('50 plugins found')).toBeInTheDocument() @@ -1428,106 +1259,72 @@ describe('CardWrapper (via List integration)', () => { describe('Combined Workflows', () => { beforeEach(() => { vi.clearAllMocks() - mockContextValues.plugins = undefined - mockContextValues.pluginsTotal = 0 - mockContextValues.isLoading = false - mockContextValues.page = 1 - mockContextValues.marketplaceCollectionsFromClient = undefined - mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined + mockMarketplaceData.plugins = undefined + mockMarketplaceData.pluginsTotal = 0 + mockMarketplaceData.isLoading = false + mockMarketplaceData.page = 1 + mockMarketplaceData.marketplaceCollections = undefined + mockMarketplaceData.marketplaceCollectionPluginsMap = undefined }) it('should transition from loading to showing collections', async () => { - mockContextValues.isLoading = true - mockContextValues.page = 1 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 1 - const { rerender } = render( - , - ) + const { rerender } = render() expect(screen.getByTestId('loading-component')).toBeInTheDocument() // Simulate loading complete - mockContextValues.isLoading = false - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + mockMarketplaceData.isLoading = false + mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - mockContextValues.marketplaceCollectionsFromClient = collections - mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap - rerender( - , - ) + rerender() expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() expect(screen.getByText('Collection 0')).toBeInTheDocument() }) it('should transition from collections to search results', async () => { - const collections = createMockCollectionList(1) - const pluginsMap: Record = { + mockMarketplaceData.marketplaceCollections = createMockCollectionList(1) + mockMarketplaceData.marketplaceCollectionPluginsMap = { 'collection-0': createMockPluginList(1), } - mockContextValues.marketplaceCollectionsFromClient = collections - mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap - const { rerender } = render( - , - ) + const { rerender } = render() expect(screen.getByText('Collection 0')).toBeInTheDocument() // Simulate search results - mockContextValues.plugins = createMockPluginList(5) - mockContextValues.pluginsTotal = 5 + mockMarketplaceData.plugins = createMockPluginList(5) + mockMarketplaceData.pluginsTotal = 5 - rerender( - , - ) + rerender() expect(screen.queryByText('Collection 0')).not.toBeInTheDocument() expect(screen.getByText('5 plugins found')).toBeInTheDocument() }) it('should handle empty search results', () => { - mockContextValues.plugins = [] - mockContextValues.pluginsTotal = 0 + mockMarketplaceData.plugins = [] + mockMarketplaceData.pluginsTotal = 0 - render( - , - ) + render() expect(screen.getByTestId('empty-component')).toBeInTheDocument() expect(screen.getByText('0 plugins found')).toBeInTheDocument() }) it('should support pagination (page > 1)', () => { - mockContextValues.plugins = createMockPluginList(40) - mockContextValues.pluginsTotal = 80 - mockContextValues.isLoading = true - mockContextValues.page = 2 + mockMarketplaceData.plugins = createMockPluginList(40) + mockMarketplaceData.pluginsTotal = 80 + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 - render( - , - ) + render() // Should show existing results while loading more expect(screen.getByText('80 plugins found')).toBeInTheDocument() @@ -1542,9 +1339,9 @@ describe('Combined Workflows', () => { describe('Accessibility', () => { beforeEach(() => { vi.clearAllMocks() - mockContextValues.plugins = undefined - mockContextValues.isLoading = false - mockContextValues.page = 1 + mockMarketplaceData.plugins = undefined + mockMarketplaceData.isLoading = false + mockMarketplaceData.page = 1 }) it('should have semantic structure with collections', () => { @@ -1573,13 +1370,11 @@ describe('Accessibility', () => { const pluginsMap: Record = { 'collection-0': createMockPluginList(1), } - const onMoreClick = vi.fn() render( , ) diff --git a/web/app/components/plugins/marketplace/list/index.tsx b/web/app/components/plugins/marketplace/list/index.tsx index 80b33d0ffd..4ce7272e80 100644 --- a/web/app/components/plugins/marketplace/list/index.tsx +++ b/web/app/components/plugins/marketplace/list/index.tsx @@ -13,7 +13,6 @@ type ListProps = { showInstallButton?: boolean cardContainerClassName?: string cardRender?: (plugin: Plugin) => React.JSX.Element | null - onMoreClick?: () => void emptyClassName?: string } const List = ({ @@ -23,7 +22,6 @@ const List = ({ showInstallButton, cardContainerClassName, cardRender, - onMoreClick, emptyClassName, }: ListProps) => { return ( @@ -36,7 +34,6 @@ const List = ({ showInstallButton={showInstallButton} cardContainerClassName={cardContainerClassName} cardRender={cardRender} - onMoreClick={onMoreClick} /> ) } diff --git a/web/app/components/plugins/marketplace/list/list-with-collection.tsx b/web/app/components/plugins/marketplace/list/list-with-collection.tsx index c17715e71e..264227b666 100644 --- a/web/app/components/plugins/marketplace/list/list-with-collection.tsx +++ b/web/app/components/plugins/marketplace/list/list-with-collection.tsx @@ -1,12 +1,12 @@ 'use client' import type { MarketplaceCollection } from '../types' -import type { SearchParamsFromCollection } from '@/app/components/plugins/marketplace/types' import type { Plugin } from '@/app/components/plugins/types' import { useLocale, useTranslation } from '#i18n' import { RiArrowRightSLine } from '@remixicon/react' import { getLanguage } from '@/i18n-config/language' import { cn } from '@/utils/classnames' +import { useMarketplaceMoreClick } from '../atoms' import CardWrapper from './card-wrapper' type ListWithCollectionProps = { @@ -15,7 +15,6 @@ type ListWithCollectionProps = { showInstallButton?: boolean cardContainerClassName?: string cardRender?: (plugin: Plugin) => React.JSX.Element | null - onMoreClick?: (searchParams?: SearchParamsFromCollection) => void } const ListWithCollection = ({ marketplaceCollections, @@ -23,10 +22,10 @@ const ListWithCollection = ({ showInstallButton, cardContainerClassName, cardRender, - onMoreClick, }: ListWithCollectionProps) => { const { t } = useTranslation() const locale = useLocale() + const onMoreClick = useMarketplaceMoreClick() return ( <> @@ -44,10 +43,10 @@ const ListWithCollection = ({
{collection.description[getLanguage(locale)]}
{ - collection.searchable && onMoreClick && ( + collection.searchable && (
onMoreClick?.(collection.search_params)} + onClick={() => onMoreClick(collection.search_params)} > {t('marketplace.viewMore', { ns: 'plugin' })} diff --git a/web/app/components/plugins/marketplace/list/list-wrapper.tsx b/web/app/components/plugins/marketplace/list/list-wrapper.tsx index 84fcf92daf..a1b0c2529a 100644 --- a/web/app/components/plugins/marketplace/list/list-wrapper.tsx +++ b/web/app/components/plugins/marketplace/list/list-wrapper.tsx @@ -1,46 +1,26 @@ 'use client' -import type { Plugin } from '../../types' -import type { MarketplaceCollection } from '../types' import { useTranslation } from '#i18n' -import { useEffect } from 'react' import Loading from '@/app/components/base/loading' -import { useMarketplaceContext } from '../context' import SortDropdown from '../sort-dropdown' +import { useMarketplaceData } from '../state' import List from './index' type ListWrapperProps = { - marketplaceCollections: MarketplaceCollection[] - marketplaceCollectionPluginsMap: Record showInstallButton?: boolean } const ListWrapper = ({ - marketplaceCollections, - marketplaceCollectionPluginsMap, showInstallButton, }: ListWrapperProps) => { const { t } = useTranslation() - const plugins = useMarketplaceContext(v => v.plugins) - const pluginsTotal = useMarketplaceContext(v => v.pluginsTotal) - const marketplaceCollectionsFromClient = useMarketplaceContext(v => v.marketplaceCollectionsFromClient) - const marketplaceCollectionPluginsMapFromClient = useMarketplaceContext(v => v.marketplaceCollectionPluginsMapFromClient) - const isLoading = useMarketplaceContext(v => v.isLoading) - const isSuccessCollections = useMarketplaceContext(v => v.isSuccessCollections) - const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins) - const searchPluginText = useMarketplaceContext(v => v.searchPluginText) - const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags) - const page = useMarketplaceContext(v => v.page) - const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick) - useEffect(() => { - if ( - !marketplaceCollectionsFromClient?.length - && isSuccessCollections - && !searchPluginText - && !filterPluginTags.length - ) { - handleQueryPlugins() - } - }, [handleQueryPlugins, marketplaceCollections, marketplaceCollectionsFromClient, isSuccessCollections, searchPluginText, filterPluginTags]) + const { + plugins, + pluginsTotal, + marketplaceCollections, + marketplaceCollectionPluginsMap, + isLoading, + page, + } = useMarketplaceData() return (
1) && ( ) } diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx index b9572413ed..6e56a288d8 100644 --- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx +++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx @@ -1,4 +1,5 @@ 'use client' +import type { ActivePluginType } from './constants' import { useTranslation } from '#i18n' import { RiArchive2Line, @@ -8,35 +9,27 @@ import { RiPuzzle2Line, RiSpeakAiLine, } from '@remixicon/react' -import { useCallback, useEffect } from 'react' +import { useSetAtom } from 'jotai' import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin' import { cn } from '@/utils/classnames' -import { PluginCategoryEnum } from '../types' -import { useMarketplaceContext } from './context' +import { searchModeAtom, useActivePluginType } from './atoms' +import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants' -export const PLUGIN_TYPE_SEARCH_MAP = { - all: 'all', - model: PluginCategoryEnum.model, - tool: PluginCategoryEnum.tool, - agent: PluginCategoryEnum.agent, - extension: PluginCategoryEnum.extension, - datasource: PluginCategoryEnum.datasource, - trigger: PluginCategoryEnum.trigger, - bundle: 'bundle', -} type PluginTypeSwitchProps = { className?: string - showSearchParams?: boolean } const PluginTypeSwitch = ({ className, - showSearchParams, }: PluginTypeSwitchProps) => { const { t } = useTranslation() - const activePluginType = useMarketplaceContext(s => s.activePluginType) - const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange) + const [activePluginType, handleActivePluginTypeChange] = useActivePluginType() + const setSearchMode = useSetAtom(searchModeAtom) - const options = [ + const options: Array<{ + value: ActivePluginType + text: string + icon: React.ReactNode | null + }> = [ { value: PLUGIN_TYPE_SEARCH_MAP.all, text: t('category.all', { ns: 'plugin' }), @@ -79,23 +72,6 @@ const PluginTypeSwitch = ({ }, ] - const handlePopState = useCallback(() => { - if (!showSearchParams) - return - // nuqs handles popstate automatically - const url = new URL(window.location.href) - const category = url.searchParams.get('category') || PLUGIN_TYPE_SEARCH_MAP.all - handleActivePluginTypeChange(category) - }, [showSearchParams, handleActivePluginTypeChange]) - - useEffect(() => { - // nuqs manages popstate internally, but we keep this for URL sync - window.addEventListener('popstate', handlePopState) - return () => { - window.removeEventListener('popstate', handlePopState) - } - }, [handlePopState]) - return (
{ handleActivePluginTypeChange(option.value) + if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) { + setSearchMode(null) + } }} > {option.icon} diff --git a/web/app/components/plugins/marketplace/query.ts b/web/app/components/plugins/marketplace/query.ts new file mode 100644 index 0000000000..c5a1421146 --- /dev/null +++ b/web/app/components/plugins/marketplace/query.ts @@ -0,0 +1,38 @@ +import type { CollectionsAndPluginsSearchParams, PluginsSearchParams } from './types' +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils' + +// TODO: Avoid manual maintenance of query keys and better service management, +// https://github.com/langgenius/dify/issues/30342 + +export const marketplaceKeys = { + all: ['marketplace'] as const, + collections: (params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collections', params] as const, + collectionPlugins: (collectionId: string, params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collectionPlugins', collectionId, params] as const, + plugins: (params?: PluginsSearchParams) => [...marketplaceKeys.all, 'plugins', params] as const, +} + +export function useMarketplaceCollectionsAndPlugins( + collectionsParams: CollectionsAndPluginsSearchParams, +) { + return useQuery({ + queryKey: marketplaceKeys.collections(collectionsParams), + queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(collectionsParams, { signal }), + }) +} + +export function useMarketplacePlugins( + queryParams: PluginsSearchParams | undefined, +) { + return useInfiniteQuery({ + queryKey: marketplaceKeys.plugins(queryParams), + queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal), + getNextPageParam: (lastPage) => { + const nextPage = lastPage.page + 1 + const loaded = lastPage.page * lastPage.pageSize + return loaded < (lastPage.total || 0) ? nextPage : undefined + }, + initialPageParam: 1, + enabled: queryParams !== undefined, + }) +} diff --git a/web/app/components/plugins/marketplace/search-box/index.spec.tsx b/web/app/components/plugins/marketplace/search-box/index.spec.tsx index 3e9cc40be0..85be82cb33 100644 --- a/web/app/components/plugins/marketplace/search-box/index.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/index.spec.tsx @@ -26,16 +26,19 @@ vi.mock('#i18n', () => ({ }), })) -// Mock useMarketplaceContext -const mockContextValues = { - searchPluginText: '', - handleSearchPluginTextChange: vi.fn(), - filterPluginTags: [] as string[], - handleFilterPluginTagsChange: vi.fn(), -} +// Mock marketplace state hooks +const { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPluginTags, mockHandleFilterPluginTagsChange } = vi.hoisted(() => { + return { + mockSearchPluginText: '', + mockHandleSearchPluginTextChange: vi.fn(), + mockFilterPluginTags: [] as string[], + mockHandleFilterPluginTagsChange: vi.fn(), + } +}) -vi.mock('../context', () => ({ - useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), +vi.mock('../atoms', () => ({ + useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange], + useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange], })) // Mock useTags hook @@ -430,9 +433,6 @@ describe('SearchBoxWrapper', () => { beforeEach(() => { vi.clearAllMocks() mockPortalOpenState = false - // Reset context values - mockContextValues.searchPluginText = '' - mockContextValues.filterPluginTags = [] }) describe('Rendering', () => { @@ -456,28 +456,14 @@ describe('SearchBoxWrapper', () => { }) }) - describe('Context Integration', () => { - it('should use searchPluginText from context', () => { - mockContextValues.searchPluginText = 'context search' - render() - - expect(screen.getByDisplayValue('context search')).toBeInTheDocument() - }) - + describe('Hook Integration', () => { it('should call handleSearchPluginTextChange when search changes', () => { render() const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'new search' } }) - expect(mockContextValues.handleSearchPluginTextChange).toHaveBeenCalledWith('new search') - }) - - it('should use filterPluginTags from context', () => { - mockContextValues.filterPluginTags = ['agent', 'rag'] - render() - - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search') }) }) diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx index d7fc004236..9957e9bc42 100644 --- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx +++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx @@ -1,15 +1,13 @@ 'use client' import { useTranslation } from '#i18n' -import { useMarketplaceContext } from '../context' +import { useFilterPluginTags, useSearchPluginText } from '../atoms' import SearchBox from './index' const SearchBoxWrapper = () => { const { t } = useTranslation() - const searchPluginText = useMarketplaceContext(v => v.searchPluginText) - const handleSearchPluginTextChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) - const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags) - const handleFilterPluginTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) + const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText() + const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags() return ( (Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[]).withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }), + q: parseAsString.withDefault('').withOptions({ history: 'replace' }), + tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }), +} diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx index 3ed7d78b07..f91c7ba4d3 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/index.spec.tsx @@ -1,4 +1,3 @@ -import type { MarketplaceContextValue } from '../context' import { fireEvent, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -28,18 +27,12 @@ vi.mock('#i18n', () => ({ }), })) -// Mock marketplace context with controllable values -let mockSort = { sortBy: 'install_count', sortOrder: 'DESC' } +// Mock marketplace atoms with controllable values +let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' } const mockHandleSortChange = vi.fn() -vi.mock('../context', () => ({ - useMarketplaceContext: (selector: (value: MarketplaceContextValue) => unknown) => { - const contextValue = { - sort: mockSort, - handleSortChange: mockHandleSortChange, - } as unknown as MarketplaceContextValue - return selector(contextValue) - }, +vi.mock('../atoms', () => ({ + useMarketplaceSort: () => [mockSort, mockHandleSortChange], })) // Mock portal component with controllable open state diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx index 984b114d03..1f7bab1005 100644 --- a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx +++ b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx @@ -10,7 +10,7 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { useMarketplaceContext } from '../context' +import { useMarketplaceSort } from '../atoms' const SortDropdown = () => { const { t } = useTranslation() @@ -36,8 +36,7 @@ const SortDropdown = () => { text: t('marketplace.sortOption.firstReleased', { ns: 'plugin' }), }, ] - const sort = useMarketplaceContext(v => v.sort) - const handleSortChange = useMarketplaceContext(v => v.handleSortChange) + const [sort, handleSortChange] = useMarketplaceSort() const [open, setOpen] = useState(false) const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0] diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts new file mode 100644 index 0000000000..1c1abfc0a1 --- /dev/null +++ b/web/app/components/plugins/marketplace/state.ts @@ -0,0 +1,54 @@ +import type { PluginsSearchParams } from './types' +import { useDebounce } from 'ahooks' +import { useCallback, useMemo } from 'react' +import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchPluginText } from './atoms' +import { PLUGIN_TYPE_SEARCH_MAP } from './constants' +import { useMarketplaceContainerScroll } from './hooks' +import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query' +import { getCollectionsParams, getMarketplaceListFilterType } from './utils' + +export function useMarketplaceData() { + const [searchPluginTextOriginal] = useSearchPluginText() + const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 }) + const [filterPluginTags] = useFilterPluginTags() + const [activePluginType] = useActivePluginType() + + const collectionsQuery = useMarketplaceCollectionsAndPlugins( + getCollectionsParams(activePluginType), + ) + + const sort = useMarketplaceSortValue() + const isSearchMode = useMarketplaceSearchMode() + const queryParams = useMemo((): PluginsSearchParams | undefined => { + if (!isSearchMode) + return undefined + return { + query: searchPluginText, + category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType, + tags: filterPluginTags, + sortBy: sort.sortBy, + sortOrder: sort.sortOrder, + type: getMarketplaceListFilterType(activePluginType), + } + }, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort]) + + const pluginsQuery = useMarketplacePlugins(queryParams) + const { hasNextPage, fetchNextPage, isFetching } = pluginsQuery + + const handlePageChange = useCallback(() => { + if (hasNextPage && !isFetching) + fetchNextPage() + }, [fetchNextPage, hasNextPage, isFetching]) + + // Scroll pagination + useMarketplaceContainerScroll(handlePageChange) + + return { + marketplaceCollections: collectionsQuery.data?.marketplaceCollections, + marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap, + plugins: pluginsQuery.data?.pages.flatMap(page => page.plugins), + pluginsTotal: pluginsQuery.data?.pages[0]?.total, + page: pluginsQuery.data?.pages.length || 1, + isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading, + } +} diff --git a/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx b/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx index 3d3530c83e..4da3844c0a 100644 --- a/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx +++ b/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx @@ -6,12 +6,10 @@ import SearchBoxWrapper from './search-box/search-box-wrapper' type StickySearchAndSwitchWrapperProps = { pluginTypeSwitchClassName?: string - showSearchParams?: boolean } const StickySearchAndSwitchWrapper = ({ pluginTypeSwitchClassName, - showSearchParams, }: StickySearchAndSwitchWrapperProps) => { const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-') @@ -24,9 +22,7 @@ const StickySearchAndSwitchWrapper = ({ )} > - +
) } diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index e51c9b76a6..eaf299314c 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -1,16 +1,19 @@ +import type { ActivePluginType } from './constants' import type { CollectionsAndPluginsSearchParams, MarketplaceCollection, + PluginsSearchParams, } from '@/app/components/plugins/marketplace/types' -import type { Plugin } from '@/app/components/plugins/types' +import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { APP_VERSION, IS_MARKETPLACE, MARKETPLACE_API_PREFIX, } from '@/config' +import { postMarketplace } from '@/service/base' import { getMarketplaceUrl } from '@/utils/var' -import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' +import { PLUGIN_TYPE_SEARCH_MAP } from './constants' type MarketplaceFetchOptions = { signal?: AbortSignal @@ -26,12 +29,13 @@ export const getPluginIconInMarketplace = (plugin: Plugin) => { return `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon` } -export const getFormattedPlugin = (bundle: any) => { +export const getFormattedPlugin = (bundle: Plugin): Plugin => { if (bundle.type === 'bundle') { return { ...bundle, icon: getPluginIconInMarketplace(bundle), brief: bundle.description, + // @ts-expect-error I do not have enough information label: bundle.labels, } } @@ -129,6 +133,64 @@ export const getMarketplaceCollectionsAndPlugins = async ( } } +export const getMarketplacePlugins = async ( + queryParams: PluginsSearchParams | undefined, + pageParam: number, + signal?: AbortSignal, +) => { + if (!queryParams) { + return { + plugins: [] as Plugin[], + total: 0, + page: 1, + pageSize: 40, + } + } + + const { + query, + sortBy, + sortOrder, + category, + tags, + type, + pageSize = 40, + } = queryParams + const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins' + + try { + const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, { + body: { + page: pageParam, + page_size: pageSize, + query, + sort_by: sortBy, + sort_order: sortOrder, + category: category !== 'all' ? category : '', + tags, + type, + }, + signal, + }) + const resPlugins = res.data.bundles || res.data.plugins || [] + + return { + plugins: resPlugins.map(plugin => getFormattedPlugin(plugin)), + total: res.data.total, + page: pageParam, + pageSize, + } + } + catch { + return { + plugins: [], + total: 0, + page: pageParam, + pageSize, + } + } +} + export const getMarketplaceListCondition = (pluginType: string) => { if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum)) return `category=${pluginType}` @@ -142,7 +204,7 @@ export const getMarketplaceListCondition = (pluginType: string) => { return '' } -export const getMarketplaceListFilterType = (category: string) => { +export const getMarketplaceListFilterType = (category: ActivePluginType) => { if (category === PLUGIN_TYPE_SEARCH_MAP.all) return undefined @@ -151,3 +213,14 @@ export const getMarketplaceListFilterType = (category: string) => { return 'plugin' } + +export function getCollectionsParams(category: ActivePluginType): CollectionsAndPluginsSearchParams { + if (category === PLUGIN_TYPE_SEARCH_MAP.all) { + return {} + } + return { + category, + condition: getMarketplaceListCondition(category), + type: getMarketplaceListFilterType(category), + } +} diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index b8fc891254..1f88f691ef 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -27,7 +27,7 @@ import { cn } from '@/utils/classnames' import { PLUGIN_PAGE_TABS_MAP } from '../hooks' import InstallFromLocalPackage from '../install-plugin/install-from-local-package' import InstallFromMarketplace from '../install-plugin/install-from-marketplace' -import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/plugin-type-switch' +import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants' import { PluginPageContextProvider, usePluginPageContext, diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx index d65b0b7957..1008ef461d 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.spec.tsx @@ -262,7 +262,7 @@ vi.mock('@/app/components/base/icons/src/vender/other', () => ({ })) // Mock PLUGIN_TYPE_SEARCH_MAP -vi.mock('../../marketplace/plugin-type-switch', () => ({ +vi.mock('../../marketplace/constants', () => ({ PLUGIN_TYPE_SEARCH_MAP: { all: 'all', model: 'model', diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx index a91df6c793..4e681a6b67 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import type { ActivePluginType } from '../../marketplace/constants' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -12,7 +13,7 @@ import { import SearchBox from '@/app/components/plugins/marketplace/search-box' import { useInstalledPluginList } from '@/service/use-plugins' import { cn } from '@/utils/classnames' -import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/plugin-type-switch' +import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/constants' import { PluginSource } from '../../types' import NoDataPlaceholder from './no-data-placeholder' import ToolItem from './tool-item' @@ -73,7 +74,7 @@ const ToolPicker: FC = ({ }, ] - const [pluginType, setPluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all) + const [pluginType, setPluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all) const [query, setQuery] = useState('') const [tags, setTags] = useState([]) const { data, isLoading } = useInstalledPluginList() diff --git a/web/app/components/share/text-generation/run-once/index.tsx b/web/app/components/share/text-generation/run-once/index.tsx index 6094147bbd..b8193fd944 100644 --- a/web/app/components/share/text-generation/run-once/index.tsx +++ b/web/app/components/share/text-generation/run-once/index.tsx @@ -195,7 +195,7 @@ const RunOnce: FC = ({ noWrapper className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1" placeholder={ -
{item.json_schema}
+
{typeof item.json_schema === 'string' ? item.json_schema : JSON.stringify(item.json_schema || '', null, 2)}
} /> )} diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx index 81a8453582..3eef34bd7b 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx @@ -48,6 +48,12 @@ const FormItem: FC = ({ const { t } = useTranslation() const { type } = payload const fileSettings = useHooksStore(s => s.configsMap?.fileSettings) + const jsonSchemaPlaceholder = React.useMemo(() => { + const schema = (payload as any)?.json_schema + if (!schema) + return '' + return typeof schema === 'string' ? schema : JSON.stringify(schema, null, 2) + }, [payload]) const handleArrayItemChange = useCallback((index: number) => { return (newValue: any) => { @@ -211,7 +217,7 @@ const FormItem: FC = ({ noWrapper className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1" placeholder={ -
{payload.json_schema}
+
{jsonSchemaPlaceholder}
} /> )} diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index 9f77be0ce2..e5e8174456 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -353,7 +353,7 @@ const formatItem = ( try { if (type === VarType.object && v.json_schema) { varRes.children = { - schema: JSON.parse(v.json_schema), + schema: typeof v.json_schema === 'string' ? JSON.parse(v.json_schema) : v.json_schema, } } } diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 2accac4047..02ee45aa7a 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -226,7 +226,7 @@ export type InputVar = { getVarValueFromDependent?: boolean hide?: boolean isFileItem?: boolean - json_schema?: string // for jsonObject type + json_schema?: string | Record // for jsonObject type } & Partial export type ModelConfig = { diff --git a/web/context/query-client-server.ts b/web/context/query-client-server.ts new file mode 100644 index 0000000000..3650e30f52 --- /dev/null +++ b/web/context/query-client-server.ts @@ -0,0 +1,16 @@ +import { QueryClient } from '@tanstack/react-query' +import { cache } from 'react' + +const STALE_TIME = 1000 * 60 * 30 // 30 minutes + +export function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: STALE_TIME, + }, + }, + }) +} + +export const getQueryClientServer = cache(makeQueryClient) diff --git a/web/context/query-client.tsx b/web/context/query-client.tsx index 9562686f6f..a72393490c 100644 --- a/web/context/query-client.tsx +++ b/web/context/query-client.tsx @@ -1,23 +1,27 @@ 'use client' +import type { QueryClient } from '@tanstack/react-query' import type { FC, PropsWithChildren } from 'react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider } from '@tanstack/react-query' +import { useState } from 'react' import { TanStackDevtoolsLoader } from '@/app/components/devtools/tanstack/loader' +import { makeQueryClient } from './query-client-server' -const STALE_TIME = 1000 * 60 * 30 // 30 minutes +let browserQueryClient: QueryClient | undefined -const client = new QueryClient({ - defaultOptions: { - queries: { - staleTime: STALE_TIME, - }, - }, -}) +function getQueryClient() { + if (typeof window === 'undefined') { + return makeQueryClient() + } + if (!browserQueryClient) + browserQueryClient = makeQueryClient() + return browserQueryClient +} -export const TanstackQueryInitializer: FC = (props) => { - const { children } = props +export const TanstackQueryInitializer: FC = ({ children }) => { + const [queryClient] = useState(getQueryClient) return ( - + {children} diff --git a/web/hooks/use-query-params.spec.tsx b/web/hooks/use-query-params.spec.tsx index 2aa6b7998f..b187471809 100644 --- a/web/hooks/use-query-params.spec.tsx +++ b/web/hooks/use-query-params.spec.tsx @@ -8,7 +8,6 @@ import { PRICING_MODAL_QUERY_PARAM, PRICING_MODAL_QUERY_VALUE, useAccountSettingModal, - useMarketplaceFilters, usePluginInstallation, usePricingModal, } from './use-query-params' @@ -302,174 +301,6 @@ describe('useQueryParams hooks', () => { }) }) - // Marketplace filters query behavior. - describe('useMarketplaceFilters', () => { - it('should return default filters when query params are missing', () => { - // Arrange - const { result } = renderWithAdapter(() => useMarketplaceFilters()) - - // Act - const [filters] = result.current - - // Assert - expect(filters.q).toBe('') - expect(filters.category).toBe('all') - expect(filters.tags).toEqual([]) - }) - - it('should parse filters when query params are present', () => { - // Arrange - const { result } = renderWithAdapter( - () => useMarketplaceFilters(), - '?q=prompt&category=tool&tags=ai,ml', - ) - - // Act - const [filters] = result.current - - // Assert - expect(filters.q).toBe('prompt') - expect(filters.category).toBe('tool') - expect(filters.tags).toEqual(['ai', 'ml']) - }) - - it('should treat empty tags param as empty array', () => { - // Arrange - const { result } = renderWithAdapter( - () => useMarketplaceFilters(), - '?tags=', - ) - - // Act - const [filters] = result.current - - // Assert - expect(filters.tags).toEqual([]) - }) - - it('should preserve other filters when updating a single field', async () => { - // Arrange - const { result } = renderWithAdapter( - () => useMarketplaceFilters(), - '?category=tool&tags=ai,ml', - ) - - // Act - act(() => { - result.current[1]({ q: 'search' }) - }) - - // Assert - await waitFor(() => expect(result.current[0].q).toBe('search')) - expect(result.current[0].category).toBe('tool') - expect(result.current[0].tags).toEqual(['ai', 'ml']) - }) - - it('should clear q param when q is empty', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter( - () => useMarketplaceFilters(), - '?q=search', - ) - - // Act - act(() => { - result.current[1]({ q: '' }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.has('q')).toBe(false) - }) - - it('should serialize tags as comma-separated values', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter(() => useMarketplaceFilters()) - - // Act - act(() => { - result.current[1]({ tags: ['ai', 'ml'] }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.get('tags')).toBe('ai,ml') - }) - - it('should remove tags param when list is empty', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter( - () => useMarketplaceFilters(), - '?tags=ai,ml', - ) - - // Act - act(() => { - result.current[1]({ tags: [] }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.has('tags')).toBe(false) - }) - - it('should keep category in the URL when set to default', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter( - () => useMarketplaceFilters(), - '?category=tool', - ) - - // Act - act(() => { - result.current[1]({ category: 'all' }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.get('category')).toBe('all') - }) - - it('should clear all marketplace filters when set to null', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter( - () => useMarketplaceFilters(), - '?q=search&category=tool&tags=ai,ml', - ) - - // Act - act(() => { - result.current[1](null) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.searchParams.has('q')).toBe(false) - expect(update.searchParams.has('category')).toBe(false) - expect(update.searchParams.has('tags')).toBe(false) - }) - - it('should use replace history when updating filters', async () => { - // Arrange - const { result, onUrlUpdate } = renderWithAdapter(() => useMarketplaceFilters()) - - // Act - act(() => { - result.current[1]({ q: 'search' }) - }) - - // Assert - await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) - const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0] - expect(update.options.history).toBe('replace') - }) - }) - // Plugin installation query behavior. describe('usePluginInstallation', () => { it('should parse package ids from JSON arrays', () => { diff --git a/web/hooks/use-query-params.ts b/web/hooks/use-query-params.ts index e0d7cc3c02..73798a4a4f 100644 --- a/web/hooks/use-query-params.ts +++ b/web/hooks/use-query-params.ts @@ -15,7 +15,6 @@ import { createParser, - parseAsArrayOf, parseAsString, useQueryState, useQueryStates, @@ -93,39 +92,6 @@ export function useAccountSettingModal() { return [{ isOpen, payload: currentTab }, setState] as const } -/** - * Marketplace Search Query Parameters - */ -export type MarketplaceFilters = { - q: string // search query - category: string // plugin category - tags: string[] // array of tags -} - -/** - * Hook to manage marketplace search/filter state via URL - * Provides atomic updates - all params update together - * - * @example - * const [filters, setFilters] = useMarketplaceFilters() - * setFilters({ q: 'search', category: 'tool', tags: ['ai'] }) // Updates all at once - * setFilters({ q: '' }) // Only updates q, keeps others - * setFilters(null) // Clears all marketplace params - */ -export function useMarketplaceFilters() { - return useQueryStates( - { - q: parseAsString.withDefault(''), - category: parseAsString.withDefault('all').withOptions({ clearOnDefault: false }), - tags: parseAsArrayOf(parseAsString).withDefault([]), - }, - { - // Update URL without pushing to history (replaceState behavior) - history: 'replace', - }, - ) -} - /** * Plugin Installation Query Parameters */ diff --git a/web/models/debug.ts b/web/models/debug.ts index 5290268fe9..73d0910e82 100644 --- a/web/models/debug.ts +++ b/web/models/debug.ts @@ -62,7 +62,7 @@ export type PromptVariable = { icon?: string icon_background?: string hide?: boolean // used in frontend to hide variable - json_schema?: string + json_schema?: string | Record } export type CompletionParams = { diff --git a/web/service/use-apps.ts b/web/service/use-apps.ts index d16d44af20..74e7662492 100644 --- a/web/service/use-apps.ts +++ b/web/service/use-apps.ts @@ -10,13 +10,14 @@ import type { AppVoicesListResponse, WorkflowDailyConversationsResponse, } from '@/models/app' -import type { App, AppModeEnum } from '@/types/app' +import type { App } from '@/types/app' import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient, } from '@tanstack/react-query' +import { AppModeEnum } from '@/types/app' import { get, post } from './base' import { useInvalid } from './use-base' @@ -36,6 +37,16 @@ type DateRangeParams = { end?: string } +// Allowed app modes for filtering; defined at module scope to avoid re-creating on every call +const allowedModes = new Set([ + 'all', + AppModeEnum.WORKFLOW, + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.CHAT, + AppModeEnum.AGENT_CHAT, + AppModeEnum.COMPLETION, +]) + const normalizeAppListParams = (params: AppListParams) => { const { page = 1, @@ -46,11 +57,13 @@ const normalizeAppListParams = (params: AppListParams) => { is_created_by_me, } = params + const safeMode = allowedModes.has((mode as any)) ? mode : undefined + return { page, limit, name, - ...(mode && mode !== 'all' ? { mode } : {}), + ...(safeMode && safeMode !== 'all' ? { mode: safeMode } : {}), ...(tag_ids?.length ? { tag_ids } : {}), ...(is_created_by_me ? { is_created_by_me } : {}), } diff --git a/web/service/workflow-payload.ts b/web/service/workflow-payload.ts index b294141cb7..5e2cdebdb3 100644 --- a/web/service/workflow-payload.ts +++ b/web/service/workflow-payload.ts @@ -66,7 +66,30 @@ export const sanitizeWorkflowDraftPayload = (params: WorkflowDraftSyncParams): W if (!graph?.nodes?.length) return params - const sanitizedNodes = graph.nodes.map(node => sanitizeTriggerPluginNode(node as Node)) + const sanitizedNodes = graph.nodes.map((node) => { + // First sanitize known node types (TriggerPlugin) + const n = sanitizeTriggerPluginNode(node as Node) as Node + + // Normalize Start node variable json_schema: ensure dict, not string + if ((n.data as any)?.type === BlockEnum.Start && Array.isArray((n.data as any).variables)) { + const next = { ...n, data: { ...n.data } } + next.data.variables = (n.data as any).variables.map((v: any) => { + if (v && v.type === 'json_object' && typeof v.json_schema === 'string') { + try { + const obj = JSON.parse(v.json_schema) + return { ...v, json_schema: obj } + } + catch { + return v + } + } + return v + }) + return next + } + + return n + }) return { ...params, @@ -126,7 +149,25 @@ export const hydrateWorkflowDraftResponse = (draft: FetchWorkflowDraftResponse): if (node.data) removeTempProperties(node.data as Record) - return hydrateTriggerPluginNode(node) + let n = hydrateTriggerPluginNode(node) + // Normalize Start node variable json_schema to object when loading + if ((n.data as any)?.type === BlockEnum.Start && Array.isArray((n.data as any).variables)) { + const next = { ...n, data: { ...n.data } } as Node + next.data.variables = (n.data as any).variables.map((v: any) => { + if (v && v.type === 'json_object' && typeof v.json_schema === 'string') { + try { + const obj = JSON.parse(v.json_schema) + return { ...v, json_schema: obj } + } + catch { + return v + } + } + return v + }) + n = next + } + return n }) } diff --git a/web/service/workflow.ts b/web/service/workflow.ts index 7571e804a9..3a37db791b 100644 --- a/web/service/workflow.ts +++ b/web/service/workflow.ts @@ -9,6 +9,7 @@ import type { } from '@/types/workflow' import { get, post } from './base' import { getFlowPrefix } from './utils' +import { sanitizeWorkflowDraftPayload } from './workflow-payload' export const fetchWorkflowDraft = (url: string) => { return get(url, {}, { silent: true }) as Promise @@ -18,7 +19,8 @@ export const syncWorkflowDraft = ({ url, params }: { url: string params: Pick }) => { - return post(url, { body: params }, { silent: true }) + const sanitized = sanitizeWorkflowDraftPayload(params) + return post(url, { body: sanitized }, { silent: true }) } export const fetchNodesDefaultConfigs = (url: string) => {