mirror of
https://github.com/langgenius/dify.git
synced 2026-01-14 06:07:33 +08:00
Merge branch 'main' into feat/pull-a-variable
This commit is contained in:
commit
f925266c1b
@ -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: |
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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.")
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 (
|
||||
<PluginPage
|
||||
plugins={<PluginsPanel />}
|
||||
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" showSearchParams={false} />}
|
||||
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -83,7 +83,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
if (!isJsonObject || !tempPayload.json_schema)
|
||||
return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(tempPayload.json_schema), null, 2)
|
||||
return tempPayload.json_schema
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
|
||||
@ -10,6 +10,7 @@ const mockReplace = vi.fn()
|
||||
const mockRouter = { replace: mockReplace }
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => mockRouter,
|
||||
useSearchParams: () => new URLSearchParams(''),
|
||||
}))
|
||||
|
||||
// Mock app context
|
||||
|
||||
@ -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<string | AppModeEnum>([
|
||||
'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<string[]>(tagIDs)
|
||||
|
||||
@ -37,7 +37,7 @@ export const getProcessedInputs = (inputs: Record<string, any>, inputsForm: Inpu
|
||||
return
|
||||
}
|
||||
|
||||
if (!inputValue)
|
||||
if (inputValue == null)
|
||||
return
|
||||
|
||||
if (item.type === InputVarType.singleFile) {
|
||||
@ -52,6 +52,20 @@ export const getProcessedInputs = (inputs: Record<string, any>, 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
|
||||
|
||||
1562
web/app/components/datasets/create/embedding-process/index.spec.tsx
Normal file
1562
web/app/components/datasets/create/embedding-process/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 (
|
||||
<div className="flex flex-col gap-1">
|
||||
{Object.keys(segmentationRuleMap).map((field) => {
|
||||
return (
|
||||
<FieldInfo
|
||||
key={field}
|
||||
label={segmentationRuleMap[field as keyof typeof segmentationRuleMap]}
|
||||
displayedValue={String(getValue(field))}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<FieldInfo
|
||||
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
displayedValue={t(`stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
|
||||
valueIcon={(
|
||||
<Image
|
||||
className="size-4"
|
||||
src={
|
||||
indexingType === IndexingType.ECONOMICAL
|
||||
? indexMethodIcon.economical
|
||||
: indexMethodIcon.high_quality
|
||||
}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldInfo
|
||||
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
// displayedValue={t(`datasetSettings.form.retrievalSetting.${retrievalMethod}`) as string}
|
||||
displayedValue={t(`retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
|
||||
valueIcon={(
|
||||
<Image
|
||||
className="size-4"
|
||||
src={
|
||||
retrievalMethod === RETRIEVE_METHOD.fullText
|
||||
? retrievalIcon.fullText
|
||||
: retrievalMethod === RETRIEVE_METHOD.hybrid
|
||||
? retrievalIcon.hybrid
|
||||
: retrievalIcon.vector
|
||||
}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="system-md-semibold-uppercase flex items-center gap-x-1 text-text-secondary">
|
||||
{isEmbedding && (
|
||||
<>
|
||||
<RiLoader2Fill className="size-4 animate-spin" />
|
||||
<span>{t('embedding.processing', { ns: 'datasetDocuments' })}</span>
|
||||
</>
|
||||
)}
|
||||
{isCompleted && t('embedding.completed', { ns: 'datasetDocuments' })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EmbeddingProcess: FC<Props> = ({ datasetId, batchId, documents = [], indexingType, retrievalMethod }) => {
|
||||
// Action buttons component
|
||||
const ActionButtons: FC<{
|
||||
apiReferenceUrl: string
|
||||
onNavToDocuments: () => void
|
||||
}> = ({ apiReferenceUrl, onNavToDocuments }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex items-center gap-x-2 py-2">
|
||||
<Link href={apiReferenceUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Button className="w-fit gap-x-0.5 px-3">
|
||||
<RiTerminalBoxLine className="size-4" />
|
||||
<span className="px-0.5">Access the API</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
className="w-fit gap-x-0.5 px-3"
|
||||
variant="primary"
|
||||
onClick={onNavToDocuments}
|
||||
>
|
||||
<span className="px-0.5">{t('stepThree.navTo', { ns: 'datasetCreation' })}</span>
|
||||
<RiArrowRightLine className="size-4 stroke-current stroke-1" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EmbeddingProcess: FC<EmbeddingProcessProps> = ({
|
||||
datasetId,
|
||||
batchId,
|
||||
documents = [],
|
||||
indexingType,
|
||||
retrievalMethod,
|
||||
}) => {
|
||||
const { enableBilling, plan } = useProviderContext()
|
||||
|
||||
const getFirstDocument = documents[0]
|
||||
|
||||
const [indexingStatusBatchDetail, setIndexingStatusDetail] = useState<IndexingStatusResponse[]>([])
|
||||
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 (
|
||||
<>
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="system-md-semibold-uppercase flex items-center gap-x-1 text-text-secondary">
|
||||
{isEmbedding && (
|
||||
<>
|
||||
<RiLoader2Fill className="size-4 animate-spin" />
|
||||
<span>{t('embedding.processing', { ns: 'datasetDocuments' })}</span>
|
||||
</>
|
||||
)}
|
||||
{isEmbeddingCompleted && t('embedding.completed', { ns: 'datasetDocuments' })}
|
||||
</div>
|
||||
{
|
||||
enableBilling && plan.type !== Plan.team && (
|
||||
<div className="flex h-14 items-center rounded-xl border-[0.5px] border-black/5 bg-white p-3 shadow-md">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[#FFF6ED]">
|
||||
<ZapFast className="h-4 w-4 text-[#FB6514]" />
|
||||
</div>
|
||||
<div className="mx-3 grow text-[13px] font-medium text-gray-700">
|
||||
{t('plansCommon.documentProcessingPriorityUpgrade', { ns: 'billing' })}
|
||||
</div>
|
||||
<UpgradeBtn loc="knowledge-speed-up" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<StatusHeader isEmbedding={isEmbedding} isCompleted={isEmbeddingCompleted} />
|
||||
|
||||
{showUpgradeBanner && <UpgradeBanner />}
|
||||
|
||||
<div className="flex flex-col gap-0.5 pb-2">
|
||||
{indexingStatusBatchDetail.map(indexingStatusDetail => (
|
||||
<div
|
||||
key={indexingStatusDetail.id}
|
||||
className={cn(
|
||||
'relative h-[26px] overflow-hidden rounded-md bg-components-progress-bar-bg',
|
||||
indexingStatusDetail.indexing_status === 'error' && 'bg-state-destructive-hover-alt',
|
||||
)}
|
||||
>
|
||||
{isSourceEmbedding(indexingStatusDetail) && (
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full min-w-0.5 border-r-[2px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress"
|
||||
style={{ width: `${getSourcePercent(indexingStatusDetail)}%` }}
|
||||
/>
|
||||
)}
|
||||
<div className="z-[1] flex h-full items-center gap-1 pl-[6px] pr-2">
|
||||
{getSourceType(indexingStatusDetail.id) === DataSourceType.FILE && (
|
||||
<DocumentFileIcon
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
name={getSourceName(indexingStatusDetail.id)}
|
||||
extension={getFileType(getSourceName(indexingStatusDetail.id))}
|
||||
/>
|
||||
)}
|
||||
{getSourceType(indexingStatusDetail.id) === DataSourceType.NOTION && (
|
||||
<NotionIcon
|
||||
className="shrink-0"
|
||||
type="page"
|
||||
src={getIcon(indexingStatusDetail.id)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-0 grow items-center gap-1" title={getSourceName(indexingStatusDetail.id)}>
|
||||
<div className="system-xs-medium truncate text-text-secondary">
|
||||
{getSourceName(indexingStatusDetail.id)}
|
||||
</div>
|
||||
{
|
||||
enableBilling && (
|
||||
<PriorityLabel className="ml-0" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{isSourceEmbedding(indexingStatusDetail) && (
|
||||
<div className="shrink-0 text-xs text-text-secondary">{`${getSourcePercent(indexingStatusDetail)}%`}</div>
|
||||
)}
|
||||
{indexingStatusDetail.indexing_status === 'error' && (
|
||||
<Tooltip
|
||||
popupClassName="px-4 py-[14px] max-w-60 body-xs-regular text-text-secondary border-[0.5px] border-components-panel-border rounded-xl"
|
||||
offset={4}
|
||||
popupContent={indexingStatusDetail.error}
|
||||
>
|
||||
<span>
|
||||
<RiErrorWarningFill className="size-4 shrink-0 text-text-destructive" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{indexingStatusDetail.indexing_status === 'completed' && (
|
||||
<RiCheckboxCircleFill className="size-4 shrink-0 text-text-success" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{statusList.map(detail => (
|
||||
<IndexingProgressItem
|
||||
key={detail.id}
|
||||
detail={detail}
|
||||
name={documentLookup.getName(detail.id)}
|
||||
sourceType={documentLookup.getSourceType(detail.id)}
|
||||
notionIcon={documentLookup.getNotionIcon(detail.id)}
|
||||
enableBilling={enableBilling}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Divider type="horizontal" className="my-0 bg-divider-subtle" />
|
||||
|
||||
<RuleDetail
|
||||
sourceData={ruleDetail}
|
||||
indexingType={indexingType}
|
||||
retrievalMethod={retrievalMethod}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center gap-x-2 py-2">
|
||||
<Link
|
||||
href={apiReferenceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
className="w-fit gap-x-0.5 px-3"
|
||||
>
|
||||
<RiTerminalBoxLine className="size-4" />
|
||||
<span className="px-0.5">Access the API</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
className="w-fit gap-x-0.5 px-3"
|
||||
variant="primary"
|
||||
onClick={navToDocumentList}
|
||||
>
|
||||
<span className="px-0.5">{t('stepThree.navTo', { ns: 'datasetCreation' })}</span>
|
||||
<RiArrowRightLine className="size-4 stroke-current stroke-1" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ActionButtons
|
||||
apiReferenceUrl={apiReferenceUrl}
|
||||
onNavToDocuments={handleNavToDocuments}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 <RiCheckboxCircleFill className="size-4 shrink-0 text-text-success" />
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<Tooltip
|
||||
popupClassName="px-4 py-[14px] max-w-60 body-xs-regular text-text-secondary border-[0.5px] border-components-panel-border rounded-xl"
|
||||
offset={4}
|
||||
popupContent={error}
|
||||
>
|
||||
<span>
|
||||
<RiErrorWarningFill className="size-4 shrink-0 text-text-destructive" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Source type icon component
|
||||
const SourceTypeIcon: FC<{
|
||||
sourceType?: DataSourceType
|
||||
name?: string
|
||||
notionIcon?: string
|
||||
}> = ({ sourceType, name, notionIcon }) => {
|
||||
if (sourceType === DataSourceType.FILE) {
|
||||
return (
|
||||
<DocumentFileIcon
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
name={name}
|
||||
extension={getFileType(name)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (sourceType === DataSourceType.NOTION) {
|
||||
return (
|
||||
<NotionIcon
|
||||
className="shrink-0"
|
||||
type="page"
|
||||
src={notionIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const IndexingProgressItem: FC<IndexingProgressItemProps> = ({
|
||||
detail,
|
||||
name,
|
||||
sourceType,
|
||||
notionIcon,
|
||||
enableBilling,
|
||||
}) => {
|
||||
const isEmbedding = isSourceEmbedding(detail)
|
||||
const percent = getSourcePercent(detail)
|
||||
const isError = detail.indexing_status === 'error'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-[26px] overflow-hidden rounded-md bg-components-progress-bar-bg',
|
||||
isError && 'bg-state-destructive-hover-alt',
|
||||
)}
|
||||
>
|
||||
{isEmbedding && (
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full min-w-0.5 border-r-[2px] border-r-components-progress-bar-progress-highlight bg-components-progress-bar-progress"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
)}
|
||||
<div className="z-[1] flex h-full items-center gap-1 pl-[6px] pr-2">
|
||||
<SourceTypeIcon
|
||||
sourceType={sourceType}
|
||||
name={name}
|
||||
notionIcon={notionIcon}
|
||||
/>
|
||||
<div className="flex w-0 grow items-center gap-1" title={name}>
|
||||
<div className="system-xs-medium truncate text-text-secondary">
|
||||
{name}
|
||||
</div>
|
||||
{enableBilling && <PriorityLabel className="ml-0" />}
|
||||
</div>
|
||||
{isEmbedding && (
|
||||
<div className="shrink-0 text-xs text-text-secondary">{`${percent}%`}</div>
|
||||
)}
|
||||
<StatusIcon status={detail.indexing_status} error={detail.error} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IndexingProgressItem
|
||||
@ -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<Record<RETRIEVE_METHOD, string>> = {
|
||||
[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<RuleDetailProps> = ({ 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, () => 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 (
|
||||
<div className="flex flex-col gap-1">
|
||||
{Object.keys(segmentationRuleLabels).map(field => (
|
||||
<FieldInfo
|
||||
key={field}
|
||||
label={segmentationRuleLabels[field as keyof typeof segmentationRuleLabels]}
|
||||
displayedValue={String(fieldValueGetters[field]())}
|
||||
/>
|
||||
))}
|
||||
<FieldInfo
|
||||
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
displayedValue={indexModeLabel}
|
||||
valueIcon={<Image className="size-4" src={indexMethodIconSrc} alt="" />}
|
||||
/>
|
||||
<FieldInfo
|
||||
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
displayedValue={retrievalLabel}
|
||||
valueIcon={<Image className="size-4" src={retrievalIconSrc} alt="" />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RuleDetail
|
||||
@ -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 (
|
||||
<div className="flex h-14 items-center rounded-xl border-[0.5px] border-black/5 bg-white p-3 shadow-md">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[#FFF6ED]">
|
||||
<ZapFast className="h-4 w-4 text-[#FB6514]" />
|
||||
</div>
|
||||
<div className="mx-3 grow text-[13px] font-medium text-gray-700">
|
||||
{t('plansCommon.documentProcessingPriorityUpgrade', { ns: 'billing' })}
|
||||
</div>
|
||||
<UpgradeBtn loc="knowledge-speed-up" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpgradeBanner
|
||||
@ -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<IndexingStatusResponse[]>([])
|
||||
const isStopPollingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Reset polling state on mount
|
||||
isStopPollingRef.current = false
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const fetchStatus = async (): Promise<IndexingStatusResponse[]> => {
|
||||
const response = await fetchIndexingStatusBatch({ datasetId, batchId })
|
||||
setStatusList(response.data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
const poll = async (): Promise<void> => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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<TextLabelProps> = ({ children }) => {
|
||||
return <label className="system-sm-semibold text-text-secondary">{children}</label>
|
||||
}
|
||||
|
||||
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<GeneralChunkingOptionsProps> = ({
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<OptionCard
|
||||
className="mb-2 bg-background-section"
|
||||
title={t('stepTwo.general', { ns: 'datasetCreation' })}
|
||||
icon={<Image width={20} height={20} src={SettingCog} alt={t('stepTwo.general', { ns: 'datasetCreation' })} />}
|
||||
activeHeaderClassName="bg-dataset-option-card-blue-gradient"
|
||||
description={t('stepTwo.generalTip', { ns: 'datasetCreation' })}
|
||||
isActive={isActive}
|
||||
onSwitched={() => onDocFormChange(ChunkingMode.text)}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="secondary-accent" onClick={onPreview}>
|
||||
<RiSearchEyeLine className="mr-0.5 h-4 w-4" />
|
||||
{t('stepTwo.previewChunk', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onReset}>
|
||||
{t('stepTwo.reset', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
noHighlight={isInUpload && isNotUploadInEmptyDataset}
|
||||
>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex gap-3">
|
||||
<DelimiterInput
|
||||
value={segmentIdentifier}
|
||||
onChange={e => onSegmentIdentifierChange(e.target.value)}
|
||||
/>
|
||||
<MaxLengthInput
|
||||
unit="characters"
|
||||
value={maxChunkLength}
|
||||
onChange={onMaxChunkLengthChange}
|
||||
/>
|
||||
<OverlapInput
|
||||
unit="characters"
|
||||
value={overlap}
|
||||
min={1}
|
||||
onChange={onOverlapChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="inline-flex shrink-0">
|
||||
<TextLabel>{t('stepTwo.rules', { ns: 'datasetCreation' })}</TextLabel>
|
||||
</div>
|
||||
<Divider className="grow" bgStyle="gradient" />
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
{rules.map(rule => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className={s.ruleItem}
|
||||
onClick={() => onRuleToggle(rule.id)}
|
||||
>
|
||||
<Checkbox checked={rule.enabled} />
|
||||
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary">
|
||||
{getRuleName(rule.id)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
{IS_CE_EDITION && (
|
||||
<>
|
||||
<Divider type="horizontal" className="my-4 bg-divider-subtle" />
|
||||
<div className="flex items-center py-0.5">
|
||||
<div
|
||||
className="flex items-center"
|
||||
onClick={() => {
|
||||
if (hasCurrentDatasetDocForm)
|
||||
return
|
||||
if (currentDocForm === ChunkingMode.qa)
|
||||
onDocFormChange(ChunkingMode.text)
|
||||
else
|
||||
onDocFormChange(ChunkingMode.qa)
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={currentDocForm === ChunkingMode.qa}
|
||||
disabled={hasCurrentDatasetDocForm}
|
||||
/>
|
||||
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary">
|
||||
{t('stepTwo.useQALanguage', { ns: 'datasetCreation' })}
|
||||
</label>
|
||||
</div>
|
||||
<LanguageSelect
|
||||
currentLanguage={docLanguage || locale}
|
||||
onSelect={onDocLanguageChange}
|
||||
disabled={currentDocForm !== ChunkingMode.qa}
|
||||
/>
|
||||
<Tooltip popupContent={t('stepTwo.QATip', { ns: 'datasetCreation' })} />
|
||||
</div>
|
||||
{currentDocForm === ChunkingMode.qa && (
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.1) 0%, rgba(255, 255, 255, 0.00) 100%)',
|
||||
}}
|
||||
className="mt-2 flex h-10 items-center gap-2 rounded-xl border border-components-panel-border px-3 text-xs shadow-xs backdrop-blur-[5px]"
|
||||
>
|
||||
<RiAlertFill className="size-4 text-text-warning-secondary" />
|
||||
<span className="system-xs-medium text-text-primary">
|
||||
{t('stepTwo.QATip', { ns: 'datasetCreation' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OptionCard>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
@ -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<IndexingModeSectionProps> = ({
|
||||
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 */}
|
||||
<div className="system-md-semibold mb-1 text-text-secondary">
|
||||
{t('stepTwo.indexMode', { ns: 'datasetCreation' })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Qualified option */}
|
||||
{(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.QUALIFIED)) && (
|
||||
<OptionCard
|
||||
className="flex-1 self-stretch"
|
||||
title={(
|
||||
<div className="flex items-center">
|
||||
{t('stepTwo.qualified', { ns: 'datasetCreation' })}
|
||||
<Badge
|
||||
className={cn(
|
||||
'ml-1 h-[18px]',
|
||||
(!hasSetIndexType && indexType === IndexingType.QUALIFIED)
|
||||
? 'border-text-accent-secondary text-text-accent-secondary'
|
||||
: '',
|
||||
)}
|
||||
uppercase
|
||||
>
|
||||
{t('stepTwo.recommend', { ns: 'datasetCreation' })}
|
||||
</Badge>
|
||||
<span className="ml-auto">
|
||||
{!hasSetIndexType && <span className={cn(s.radio)} />}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })}
|
||||
icon={<Image src={indexMethodIcon.high_quality} alt="" />}
|
||||
isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED}
|
||||
disabled={hasSetIndexType}
|
||||
onSwitched={() => onIndexTypeChange(IndexingType.QUALIFIED)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Economical option */}
|
||||
{(!hasSetIndexType || (hasSetIndexType && indexType === IndexingType.ECONOMICAL)) && (
|
||||
<>
|
||||
<CustomDialog show={isQAConfirmDialogOpen} onClose={onQAConfirmDialogClose} className="w-[432px]">
|
||||
<header className="mb-4 pt-6">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{t('stepTwo.qaSwitchHighQualityTipTitle', { ns: 'datasetCreation' })}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm font-normal text-text-secondary">
|
||||
{t('stepTwo.qaSwitchHighQualityTipContent', { ns: 'datasetCreation' })}
|
||||
</p>
|
||||
</header>
|
||||
<div className="flex gap-2 pb-6">
|
||||
<Button className="ml-auto" onClick={onQAConfirmDialogClose}>
|
||||
{t('stepTwo.cancel', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onQAConfirmDialogConfirm}>
|
||||
{t('stepTwo.switch', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
</div>
|
||||
</CustomDialog>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="rounded-lg border-components-panel-border bg-components-tooltip-bg p-3 text-xs font-medium text-text-secondary shadow-lg">
|
||||
{docForm === ChunkingMode.qa
|
||||
? t('stepTwo.notAvailableForQA', { ns: 'datasetCreation' })
|
||||
: t('stepTwo.notAvailableForParentChild', { ns: 'datasetCreation' })}
|
||||
</div>
|
||||
)}
|
||||
noDecoration
|
||||
position="top"
|
||||
asChild={false}
|
||||
triggerClassName="flex-1 self-stretch"
|
||||
>
|
||||
<OptionCard
|
||||
className="h-full"
|
||||
title={t('stepTwo.economical', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.economicalTip', { ns: 'datasetCreation' })}
|
||||
icon={<Image src={indexMethodIcon.economical} alt="" />}
|
||||
isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL}
|
||||
disabled={hasSetIndexType || docForm !== ChunkingMode.text}
|
||||
onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* High quality tip */}
|
||||
{!hasSetIndexType && indexType === IndexingType.QUALIFIED && (
|
||||
<div className="mt-2 flex h-10 items-center gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs backdrop-blur-[5px]">
|
||||
<div className="absolute bottom-0 left-0 right-0 top-0 bg-dataset-warning-message-bg opacity-40"></div>
|
||||
<div className="p-1">
|
||||
<AlertTriangle className="size-4 text-text-warning-secondary" />
|
||||
</div>
|
||||
<span className="system-xs-medium text-text-primary">
|
||||
{t('stepTwo.highQualityTip', { ns: 'datasetCreation' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Economical index setting tip */}
|
||||
{hasSetIndexType && indexType === IndexingType.ECONOMICAL && (
|
||||
<div className="system-xs-medium mt-2 text-text-tertiary">
|
||||
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })}
|
||||
<Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}>
|
||||
{t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Embedding model */}
|
||||
{indexType === IndexingType.QUALIFIED && (
|
||||
<div className="mt-5">
|
||||
<div className={cn('system-md-semibold mb-1 text-text-secondary', datasetId && 'flex items-center justify-between')}>
|
||||
{t('form.embeddingModel', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<ModelSelector
|
||||
readonly={isModelAndRetrievalConfigDisabled}
|
||||
triggerClassName={isModelAndRetrievalConfigDisabled ? 'opacity-50' : ''}
|
||||
defaultModel={embeddingModel}
|
||||
modelList={embeddingModelList ?? []}
|
||||
onSelect={onEmbeddingModelChange}
|
||||
/>
|
||||
{isModelAndRetrievalConfigDisabled && (
|
||||
<div className="system-xs-medium mt-2 text-text-tertiary">
|
||||
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })}
|
||||
<Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}>
|
||||
{t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider className="my-5" />
|
||||
|
||||
{/* Retrieval Method Config */}
|
||||
<div>
|
||||
{!isModelAndRetrievalConfigDisabled
|
||||
? (
|
||||
<div className="mb-1">
|
||||
<div className="system-md-semibold mb-0.5 text-text-secondary">
|
||||
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<div className="body-xs-regular text-text-tertiary">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={docLink('/guides/knowledge-base/create-knowledge-and-upload-documents')}
|
||||
className="text-text-accent"
|
||||
>
|
||||
{t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })}
|
||||
</a>
|
||||
{t('form.retrievalSetting.longDescription', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className={cn('system-md-semibold mb-0.5 text-text-secondary', 'flex items-center justify-between')}>
|
||||
<div>{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{getIndexingTechnique() === IndexingType.QUALIFIED
|
||||
? (
|
||||
<RetrievalMethodConfig
|
||||
disabled={isModelAndRetrievalConfigDisabled}
|
||||
value={retrievalConfig}
|
||||
onChange={onRetrievalConfigChange}
|
||||
showMultiModalTip={showMultiModalTip}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<EconomicalRetrievalMethodConfig
|
||||
disabled={isModelAndRetrievalConfigDisabled}
|
||||
value={retrievalConfig}
|
||||
onChange={onRetrievalConfigChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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<TextLabelProps> = ({ children }) => {
|
||||
return <label className="system-sm-semibold text-text-secondary">{children}</label>
|
||||
}
|
||||
|
||||
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<ParentChildOptionsProps> = ({
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<OptionCard
|
||||
title={t('stepTwo.parentChild', { ns: 'datasetCreation' })}
|
||||
icon={<ParentChildChunk className="h-[20px] w-[20px]" />}
|
||||
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={(
|
||||
<>
|
||||
<Button variant="secondary-accent" onClick={onPreview}>
|
||||
<RiSearchEyeLine className="mr-0.5 h-4 w-4" />
|
||||
{t('stepTwo.previewChunk', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onReset}>
|
||||
{t('stepTwo.reset', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
noHighlight={isInUpload && isNotUploadInEmptyDataset}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Parent chunk for context */}
|
||||
<div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="inline-flex shrink-0">
|
||||
<TextLabel>{t('stepTwo.parentChunkForContext', { ns: 'datasetCreation' })}</TextLabel>
|
||||
</div>
|
||||
<Divider className="grow" bgStyle="gradient" />
|
||||
</div>
|
||||
<RadioCard
|
||||
className="mt-1"
|
||||
icon={<Image src={Note} alt="" />}
|
||||
title={t('stepTwo.paragraph', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })}
|
||||
isChosen={parentChildConfig.chunkForContext === 'paragraph'}
|
||||
onChosen={() => onChunkForContextChange('paragraph')}
|
||||
chosenConfig={(
|
||||
<div className="flex gap-3">
|
||||
<DelimiterInput
|
||||
value={parentChildConfig.parent.delimiter}
|
||||
tooltip={t('stepTwo.parentChildDelimiterTip', { ns: 'datasetCreation' })!}
|
||||
onChange={e => onParentDelimiterChange(e.target.value)}
|
||||
/>
|
||||
<MaxLengthInput
|
||||
unit="characters"
|
||||
value={parentChildConfig.parent.maxLength}
|
||||
onChange={onParentMaxLengthChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<RadioCard
|
||||
className="mt-2"
|
||||
icon={<Image src={FileList} alt="" />}
|
||||
title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })}
|
||||
description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })}
|
||||
onChosen={() => onChunkForContextChange('full-doc')}
|
||||
isChosen={parentChildConfig.chunkForContext === 'full-doc'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Child chunk for retrieval */}
|
||||
<div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="inline-flex shrink-0">
|
||||
<TextLabel>{t('stepTwo.childChunkForRetrieval', { ns: 'datasetCreation' })}</TextLabel>
|
||||
</div>
|
||||
<Divider className="grow" bgStyle="gradient" />
|
||||
</div>
|
||||
<div className="mt-1 flex gap-3">
|
||||
<DelimiterInput
|
||||
value={parentChildConfig.child.delimiter}
|
||||
tooltip={t('stepTwo.parentChildChunkDelimiterTip', { ns: 'datasetCreation' })!}
|
||||
onChange={e => onChildDelimiterChange(e.target.value)}
|
||||
/>
|
||||
<MaxLengthInput
|
||||
unit="characters"
|
||||
value={parentChildConfig.child.maxLength}
|
||||
onChange={onChildMaxLengthChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rules */}
|
||||
<div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="inline-flex shrink-0">
|
||||
<TextLabel>{t('stepTwo.rules', { ns: 'datasetCreation' })}</TextLabel>
|
||||
</div>
|
||||
<Divider className="grow" bgStyle="gradient" />
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
{rules.map(rule => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className={s.ruleItem}
|
||||
onClick={() => onRuleToggle(rule.id)}
|
||||
>
|
||||
<Checkbox checked={rule.enabled} />
|
||||
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary">
|
||||
{getRuleName(rule.id)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OptionCard>
|
||||
)
|
||||
}
|
||||
@ -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<PreviewPanelProps> = ({
|
||||
isMobile,
|
||||
dataSourceType: _dataSourceType,
|
||||
currentDocForm,
|
||||
estimate,
|
||||
parentChildConfig,
|
||||
isSetting,
|
||||
pickerFiles,
|
||||
pickerValue,
|
||||
isIdle,
|
||||
isPending,
|
||||
onPickerChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<FloatRightContainer isMobile={isMobile} isOpen={true} onClose={noop} footer={null}>
|
||||
<PreviewContainer
|
||||
header={(
|
||||
<PreviewHeader title={t('stepTwo.preview', { ns: 'datasetCreation' })}>
|
||||
<div className="flex items-center gap-1">
|
||||
<PreviewDocumentPicker
|
||||
files={pickerFiles as Array<Required<{ id: string, name: string, extension: string }>>}
|
||||
onChange={onPickerChange}
|
||||
value={isSetting ? pickerFiles[0] : pickerValue}
|
||||
/>
|
||||
{currentDocForm !== ChunkingMode.qa && (
|
||||
<Badge
|
||||
text={t('stepTwo.previewChunkCount', {
|
||||
ns: 'datasetCreation',
|
||||
count: estimate?.total_segments || 0,
|
||||
}) as string}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PreviewHeader>
|
||||
)}
|
||||
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) => (
|
||||
<ChunkContainer
|
||||
key={item.question}
|
||||
label={`Chunk-${index + 1}`}
|
||||
characterCount={item.question.length + item.answer.length}
|
||||
>
|
||||
<QAPreview qa={item} />
|
||||
</ChunkContainer>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Text Preview */}
|
||||
{currentDocForm === ChunkingMode.text && estimate?.preview && (
|
||||
estimate.preview.map((item, index) => (
|
||||
<ChunkContainer
|
||||
key={item.content}
|
||||
label={`Chunk-${index + 1}`}
|
||||
characterCount={item.content.length}
|
||||
>
|
||||
{item.content}
|
||||
</ChunkContainer>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* 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 (
|
||||
<ChunkContainer
|
||||
key={item.content}
|
||||
label={`Chunk-${indexForLabel}`}
|
||||
characterCount={item.content.length}
|
||||
>
|
||||
<FormattedText>
|
||||
{childChunks.map((child, childIndex) => {
|
||||
const childIndexForLabel = childIndex + 1
|
||||
return (
|
||||
<PreviewSlice
|
||||
key={`C-${childIndexForLabel}-${child}`}
|
||||
label={`C-${childIndexForLabel}`}
|
||||
text={child}
|
||||
tooltip={`Child-chunk-${childIndexForLabel} · ${child.length} Characters`}
|
||||
labelInnerClassName="text-[10px] font-semibold align-bottom leading-7"
|
||||
dividerClassName="leading-7"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</FormattedText>
|
||||
</ChunkContainer>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Idle State */}
|
||||
{isIdle && (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<RiSearchEyeLine className="size-10 text-text-empty-state-icon" />
|
||||
<p className="text-sm text-text-tertiary">
|
||||
{t('stepTwo.previewChunkTip', { ns: 'datasetCreation' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isPending && (
|
||||
<div className="space-y-6">
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<SkeletonContainer key={i}>
|
||||
<SkeletonRow>
|
||||
<SkeletonRectangle className="w-20" />
|
||||
<SkeletonPoint />
|
||||
<SkeletonRectangle className="w-24" />
|
||||
</SkeletonRow>
|
||||
<SkeletonRectangle className="w-full" />
|
||||
<SkeletonRectangle className="w-full" />
|
||||
<SkeletonRectangle className="w-[422px]" />
|
||||
</SkeletonContainer>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PreviewContainer>
|
||||
</FloatRightContainer>
|
||||
)
|
||||
}
|
||||
@ -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<StepTwoFooterProps> = ({
|
||||
isSetting,
|
||||
isCreating,
|
||||
onPrevious,
|
||||
onCreate,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!isSetting) {
|
||||
return (
|
||||
<div className="mt-8 flex items-center py-2">
|
||||
<Button onClick={onPrevious}>
|
||||
<RiArrowLeftLine className="mr-1 h-4 w-4" />
|
||||
{t('stepTwo.previousStep', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
loading={isCreating}
|
||||
variant="primary"
|
||||
onClick={onCreate}
|
||||
>
|
||||
{t('stepTwo.nextStep', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 flex items-center py-2">
|
||||
<Button
|
||||
loading={isCreating}
|
||||
variant="primary"
|
||||
onClick={onCreate}
|
||||
>
|
||||
{t('stepTwo.save', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
<Button className="ml-2" onClick={onCancel}>
|
||||
{t('stepTwo.cancel', { ns: 'datasetCreation' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
web/app/components/datasets/create/step-two/hooks/index.ts
Normal file
14
web/app/components/datasets/create/step-two/hooks/index.ts
Normal file
@ -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'
|
||||
@ -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<typeof useDocumentCreation>
|
||||
@ -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<IndexingType>(() => {
|
||||
if (initialIndexType)
|
||||
return initialIndexType
|
||||
return isAPIKeySet ? IndexingType.QUALIFIED : IndexingType.ECONOMICAL
|
||||
})
|
||||
|
||||
// Embedding model state
|
||||
const [embeddingModel, setEmbeddingModel] = useState<DefaultModel>(
|
||||
initialEmbeddingModel ?? {
|
||||
provider: defaultEmbeddingModel?.provider.provider || '',
|
||||
model: defaultEmbeddingModel?.model || '',
|
||||
},
|
||||
)
|
||||
|
||||
// Retrieval config state
|
||||
const [retrievalConfig, setRetrievalConfig] = useState<RetrievalConfig>(
|
||||
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<typeof useIndexingConfig>
|
||||
@ -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<typeof useIndexingEstimate>
|
||||
@ -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<DocumentItem>(
|
||||
(datasetId && documentDetail)
|
||||
? documentDetail.file
|
||||
: files[0],
|
||||
)
|
||||
|
||||
// Notion page preview state
|
||||
const [previewNotionPage, setPreviewNotionPage] = useState<NotionPage>(
|
||||
(datasetId && documentDetail)
|
||||
? documentDetail.notion_page
|
||||
: notionPages[0],
|
||||
)
|
||||
|
||||
// Website page preview state
|
||||
const [previewWebsitePage, setPreviewWebsitePage] = useState<CrawlResultItem>(
|
||||
(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<Required<CustomFile>>
|
||||
}
|
||||
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<CustomFile>
|
||||
}
|
||||
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<typeof usePreviewState>
|
||||
@ -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<ProcessMode>(
|
||||
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<PreProcessingRule[]>([])
|
||||
const [defaultConfig, setDefaultConfig] = useState<Rules>()
|
||||
|
||||
// Parent-child config
|
||||
const [parentChildConfig, setParentChildConfig] = useState<ParentChildConfig>(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<typeof useSegmentationState>
|
||||
2197
web/app/components/datasets/create/step-two/index.spec.tsx
Normal file
2197
web/app/components/datasets/create/step-two/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
28
web/app/components/datasets/create/step-two/types.ts
Normal file
28
web/app/components/datasets/create/step-two/types.ts
Normal file
@ -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
|
||||
}
|
||||
81
web/app/components/plugins/marketplace/atoms.ts
Normal file
81
web/app/components/plugins/marketplace/atoms.ts
Normal file
@ -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<PluginsSort>(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<boolean>(false)
|
||||
|
||||
const searchPluginTextAtom = atom<string>('')
|
||||
const activePluginTypeAtom = atom<ActivePluginType>('all')
|
||||
const filterPluginTagsAtom = atom<string[]>([])
|
||||
|
||||
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<true | null>(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])
|
||||
}
|
||||
@ -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> = T[keyof T]
|
||||
|
||||
export type ActivePluginType = ValueOf<typeof PLUGIN_TYPE_SEARCH_MAP>
|
||||
|
||||
export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>(
|
||||
[
|
||||
PLUGIN_TYPE_SEARCH_MAP.all,
|
||||
PLUGIN_TYPE_SEARCH_MAP.tool,
|
||||
],
|
||||
)
|
||||
|
||||
@ -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<string, Plugin[]>
|
||||
setMarketplaceCollectionPluginsMapFromClient: (map: Record<string, Plugin[]>) => void
|
||||
isLoading: boolean
|
||||
isSuccessCollections: boolean
|
||||
}
|
||||
|
||||
export const MarketplaceContext = createContext<MarketplaceContextValue>({
|
||||
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<string[]>(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 (
|
||||
<MarketplaceContext.Provider
|
||||
value={{
|
||||
searchPluginText,
|
||||
handleSearchPluginTextChange,
|
||||
filterPluginTags,
|
||||
handleFilterPluginTagsChange,
|
||||
activePluginType,
|
||||
handleActivePluginTypeChange,
|
||||
page,
|
||||
handlePageChange,
|
||||
plugins,
|
||||
pluginsTotal,
|
||||
resetPlugins,
|
||||
sort,
|
||||
handleSortChange,
|
||||
handleQueryPlugins,
|
||||
handleMoreClick,
|
||||
marketplaceCollectionsFromClient,
|
||||
setMarketplaceCollectionsFromClient,
|
||||
marketplaceCollectionPluginsMapFromClient,
|
||||
setMarketplaceCollectionPluginsMapFromClient,
|
||||
isLoading: isLoading || isPluginsLoading,
|
||||
isSuccessCollections,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MarketplaceContext.Provider>
|
||||
)
|
||||
}
|
||||
@ -26,6 +26,9 @@ import {
|
||||
getMarketplacePluginsByCollectionId,
|
||||
} from './utils'
|
||||
|
||||
/**
|
||||
* @deprecated Use useMarketplaceCollectionsAndPlugins from query.ts instead
|
||||
*/
|
||||
export const useMarketplaceCollectionsAndPlugins = () => {
|
||||
const [queryParams, setQueryParams] = useState<CollectionsAndPluginsSearchParams>()
|
||||
const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState<MarketplaceCollection[]>()
|
||||
@ -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<PluginsSearchParams>()
|
||||
|
||||
15
web/app/components/plugins/marketplace/hydration-client.tsx
Normal file
15
web/app/components/plugins/marketplace/hydration-client.tsx
Normal file
@ -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}</>
|
||||
}
|
||||
45
web/app/components/plugins/marketplace/hydration-server.tsx
Normal file
45
web/app/components/plugins/marketplace/hydration-server.tsx
Normal file
@ -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<SearchParams>) {
|
||||
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<SearchParams> | undefined
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const dehydratedState = await getDehydratedState(searchParams)
|
||||
return (
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
{children}
|
||||
</HydrationBoundary>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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<SearchParams>
|
||||
}
|
||||
|
||||
const Marketplace = async ({
|
||||
showInstallButton = true,
|
||||
shouldExclude,
|
||||
searchParams,
|
||||
pluginTypeSwitchClassName,
|
||||
scrollContainerId,
|
||||
showSearchParams = true,
|
||||
searchParams,
|
||||
}: MarketplaceProps) => {
|
||||
let marketplaceCollections: MarketplaceCollection[] = []
|
||||
let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {}
|
||||
if (!shouldExclude) {
|
||||
const marketplaceCollectionsAndPluginsData = await getMarketplaceCollectionsAndPlugins()
|
||||
marketplaceCollections = marketplaceCollectionsAndPluginsData.marketplaceCollections
|
||||
marketplaceCollectionPluginsMap = marketplaceCollectionsAndPluginsData.marketplaceCollectionPluginsMap
|
||||
}
|
||||
|
||||
return (
|
||||
<TanstackQueryInitializer>
|
||||
<MarketplaceContextProvider
|
||||
searchParams={searchParams}
|
||||
shouldExclude={shouldExclude}
|
||||
scrollContainerId={scrollContainerId}
|
||||
showSearchParams={showSearchParams}
|
||||
>
|
||||
<Description />
|
||||
<StickySearchAndSwitchWrapper
|
||||
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
|
||||
showSearchParams={showSearchParams}
|
||||
/>
|
||||
<ListWrapper
|
||||
marketplaceCollections={marketplaceCollections}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
</MarketplaceContextProvider>
|
||||
<HydrateQueryClient searchParams={searchParams}>
|
||||
<HydrateMarketplaceAtoms preserveSearchStateInQuery={!!searchParams}>
|
||||
<Description />
|
||||
<StickySearchAndSwitchWrapper
|
||||
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
|
||||
/>
|
||||
<ListWrapper
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
</HydrateMarketplaceAtoms>
|
||||
</HydrateQueryClient>
|
||||
</TanstackQueryInitializer>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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<string, Plugin[]> | 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<string, Plugin[]> | 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<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
const onMoreClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
onMoreClick={onMoreClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -609,42 +611,19 @@ describe('ListWithCollection', () => {
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
const onMoreClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
onMoreClick={onMoreClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
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<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
onMoreClick={undefined}
|
||||
/>,
|
||||
)
|
||||
|
||||
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<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
const onMoreClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
onMoreClick={onMoreClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
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<string, Plugin[]>,
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(document.body).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with scrollbarGutter style', () => {
|
||||
const { container } = render(<ListWrapper {...defaultProps} />)
|
||||
const { container } = render(<ListWrapper />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByText('5 plugins found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render SortDropdown when plugins are present', () => {
|
||||
mockContextValues.plugins = createMockPluginList(1)
|
||||
mockMarketplaceData.plugins = createMockPluginList(1)
|
||||
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render plugins header when plugins is undefined', () => {
|
||||
mockContextValues.plugins = undefined
|
||||
mockMarketplaceData.plugins = undefined
|
||||
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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<string, Plugin[]> = {
|
||||
it('should render collections when not loading', () => {
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWrapper
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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<string, Plugin[]> = {
|
||||
mockMarketplaceData.isLoading = true
|
||||
mockMarketplaceData.page = 2
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWrapper
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
const clientPluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
mockContextValues.marketplaceCollectionsFromClient = clientCollections
|
||||
mockContextValues.marketplaceCollectionPluginsMapFromClient = clientPluginsMap
|
||||
|
||||
render(
|
||||
<ListWrapper
|
||||
{...defaultProps}
|
||||
marketplaceCollections={serverCollections}
|
||||
marketplaceCollectionPluginsMap={serverPluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
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<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
mockContextValues.marketplaceCollectionsFromClient = undefined
|
||||
mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined
|
||||
|
||||
render(
|
||||
<ListWrapper
|
||||
{...defaultProps}
|
||||
marketplaceCollections={serverCollections}
|
||||
marketplaceCollectionPluginsMap={serverPluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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<string, Plugin[]> = {
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWrapper
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
|
||||
// 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(<ListWrapper {...defaultProps} />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
// 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(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
const { rerender } = render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByTestId('loading-component')).toBeInTheDocument()
|
||||
|
||||
// Simulate loading complete
|
||||
mockContextValues.isLoading = false
|
||||
const collections = createMockCollectionList(1)
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
mockContextValues.marketplaceCollectionsFromClient = collections
|
||||
mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap
|
||||
|
||||
rerender(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
rerender(<ListWrapper />)
|
||||
|
||||
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<string, Plugin[]> = {
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
mockContextValues.marketplaceCollectionsFromClient = collections
|
||||
mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap
|
||||
|
||||
const { rerender } = render(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
const { rerender } = render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByText('Collection 0')).toBeInTheDocument()
|
||||
|
||||
// Simulate search results
|
||||
mockContextValues.plugins = createMockPluginList(5)
|
||||
mockContextValues.pluginsTotal = 5
|
||||
mockMarketplaceData.plugins = createMockPluginList(5)
|
||||
mockMarketplaceData.pluginsTotal = 5
|
||||
|
||||
rerender(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
rerender(<ListWrapper />)
|
||||
|
||||
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(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
render(<ListWrapper />)
|
||||
|
||||
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(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
render(<ListWrapper />)
|
||||
|
||||
// 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<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
const onMoreClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
onMoreClick={onMoreClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 = ({
|
||||
<div className="system-xs-regular text-text-tertiary">{collection.description[getLanguage(locale)]}</div>
|
||||
</div>
|
||||
{
|
||||
collection.searchable && onMoreClick && (
|
||||
collection.searchable && (
|
||||
<div
|
||||
className="system-xs-medium flex cursor-pointer items-center text-text-accent "
|
||||
onClick={() => onMoreClick?.(collection.search_params)}
|
||||
onClick={() => onMoreClick(collection.search_params)}
|
||||
>
|
||||
{t('marketplace.viewMore', { ns: 'plugin' })}
|
||||
<RiArrowRightSLine className="h-4 w-4" />
|
||||
|
||||
@ -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<string, Plugin[]>
|
||||
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 (
|
||||
<div
|
||||
@ -66,11 +46,10 @@ const ListWrapper = ({
|
||||
{
|
||||
(!isLoading || page > 1) && (
|
||||
<List
|
||||
marketplaceCollections={marketplaceCollectionsFromClient || marketplaceCollections}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMapFromClient || marketplaceCollectionPluginsMap}
|
||||
marketplaceCollections={marketplaceCollections || []}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
|
||||
plugins={plugins}
|
||||
showInstallButton={showInstallButton}
|
||||
onMoreClick={handleMoreClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
|
||||
@ -112,6 +88,9 @@ const PluginTypeSwitch = ({
|
||||
)}
|
||||
onClick={() => {
|
||||
handleActivePluginTypeChange(option.value)
|
||||
if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) {
|
||||
setSearchMode(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.icon}
|
||||
|
||||
38
web/app/components/plugins/marketplace/query.ts
Normal file
38
web/app/components/plugins/marketplace/query.ts
Normal file
@ -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,
|
||||
})
|
||||
}
|
||||
@ -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(<SearchBoxWrapper />)
|
||||
|
||||
expect(screen.getByDisplayValue('context search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Hook Integration', () => {
|
||||
it('should call handleSearchPluginTextChange when search changes', () => {
|
||||
render(<SearchBoxWrapper />)
|
||||
|
||||
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(<SearchBoxWrapper />)
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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 (
|
||||
<SearchBox
|
||||
|
||||
9
web/app/components/plugins/marketplace/search-params.ts
Normal file
9
web/app/components/plugins/marketplace/search-params.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { ActivePluginType } from './constants'
|
||||
import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
|
||||
|
||||
export const marketplaceSearchParamsParsers = {
|
||||
category: parseAsStringEnum<ActivePluginType>(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' }),
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
54
web/app/components/plugins/marketplace/state.ts
Normal file
54
web/app/components/plugins/marketplace/state.ts
Normal file
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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 = ({
|
||||
)}
|
||||
>
|
||||
<SearchBoxWrapper />
|
||||
<PluginTypeSwitch
|
||||
showSearchParams={showSearchParams}
|
||||
/>
|
||||
<PluginTypeSwitch />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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<Props> = ({
|
||||
},
|
||||
]
|
||||
|
||||
const [pluginType, setPluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
const [pluginType, setPluginType] = useState<ActivePluginType>(PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
const [query, setQuery] = useState('')
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const { data, isLoading } = useInstalledPluginList()
|
||||
|
||||
@ -195,7 +195,7 @@ const RunOnce: FC<IRunOnceProps> = ({
|
||||
noWrapper
|
||||
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
||||
placeholder={
|
||||
<div className="whitespace-pre">{item.json_schema}</div>
|
||||
<div className="whitespace-pre">{typeof item.json_schema === 'string' ? item.json_schema : JSON.stringify(item.json_schema || '', null, 2)}</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -48,6 +48,12 @@ const FormItem: FC<Props> = ({
|
||||
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<Props> = ({
|
||||
noWrapper
|
||||
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
||||
placeholder={
|
||||
<div className="whitespace-pre">{payload.json_schema}</div>
|
||||
<div className="whitespace-pre">{jsonSchemaPlaceholder}</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -226,7 +226,7 @@ export type InputVar = {
|
||||
getVarValueFromDependent?: boolean
|
||||
hide?: boolean
|
||||
isFileItem?: boolean
|
||||
json_schema?: string // for jsonObject type
|
||||
json_schema?: string | Record<string, any> // for jsonObject type
|
||||
} & Partial<UploadFileSetting>
|
||||
|
||||
export type ModelConfig = {
|
||||
|
||||
16
web/context/query-client-server.ts
Normal file
16
web/context/query-client-server.ts
Normal file
@ -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)
|
||||
@ -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<PropsWithChildren> = (props) => {
|
||||
const { children } = props
|
||||
export const TanstackQueryInitializer: FC<PropsWithChildren> = ({ children }) => {
|
||||
const [queryClient] = useState(getQueryClient)
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<TanStackDevtoolsLoader />
|
||||
</QueryClientProvider>
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -15,7 +15,6 @@
|
||||
|
||||
import {
|
||||
createParser,
|
||||
parseAsArrayOf,
|
||||
parseAsString,
|
||||
useQueryState,
|
||||
useQueryStates,
|
||||
@ -93,39 +92,6 @@ export function useAccountSettingModal<T extends string = string>() {
|
||||
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
|
||||
*/
|
||||
|
||||
@ -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<string, any>
|
||||
}
|
||||
|
||||
export type CompletionParams = {
|
||||
|
||||
@ -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<AppModeEnum | 'all'>([
|
||||
'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 } : {}),
|
||||
}
|
||||
|
||||
@ -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<TriggerPluginNodePayload>))
|
||||
const sanitizedNodes = graph.nodes.map((node) => {
|
||||
// First sanitize known node types (TriggerPlugin)
|
||||
const n = sanitizeTriggerPluginNode(node as Node<TriggerPluginNodePayload>) as Node<any>
|
||||
|
||||
// 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<string, unknown>)
|
||||
|
||||
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<any>
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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<FetchWorkflowDraftResponse>
|
||||
@ -18,7 +19,8 @@ export const syncWorkflowDraft = ({ url, params }: {
|
||||
url: string
|
||||
params: Pick<FetchWorkflowDraftResponse, 'graph' | 'features' | 'environment_variables' | 'conversation_variables'>
|
||||
}) => {
|
||||
return post<CommonResponse & { updated_at: number, hash: string }>(url, { body: params }, { silent: true })
|
||||
const sanitized = sanitizeWorkflowDraftPayload(params)
|
||||
return post<CommonResponse & { updated_at: number, hash: string }>(url, { body: sanitized }, { silent: true })
|
||||
}
|
||||
|
||||
export const fetchNodesDefaultConfigs = (url: string) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user