Merge branch 'main' into feat/hitl-frontend

This commit is contained in:
twwu 2026-01-12 13:47:36 +08:00
commit 68885afac6
79 changed files with 1485 additions and 3843 deletions

View File

@ -5,5 +5,18 @@
"typescript-lsp@claude-plugins-official": true, "typescript-lsp@claude-plugins-official": true,
"pyright-lsp@claude-plugins-official": true, "pyright-lsp@claude-plugins-official": true,
"ralph-loop@claude-plugins-official": true "ralph-loop@claude-plugins-official": true
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "npx -y block-no-verify@1.1.1"
}
]
}
]
} }
} }

View File

@ -39,12 +39,6 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: uv sync --project api --dev run: uv sync --project api --dev
- name: Run pyrefly check
run: |
cd api
uv add --dev pyrefly
uv run pyrefly check || true
- name: Run dify config tests - name: Run dify config tests
run: uv run --project api dev/pytest/pytest_config_tests.py run: uv run --project api dev/pytest/pytest_config_tests.py

View File

@ -1,4 +1,4 @@
name: Deploy Trigger Dev name: Deploy Agent Dev
permissions: permissions:
contents: read contents: read
@ -7,7 +7,7 @@ on:
workflow_run: workflow_run:
workflows: ["Build and Push API & Web"] workflows: ["Build and Push API & Web"]
branches: branches:
- "deploy/trigger-dev" - "deploy/agent-dev"
types: types:
- completed - completed
@ -16,12 +16,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: | if: |
github.event.workflow_run.conclusion == 'success' && 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: steps:
- name: Deploy to server - name: Deploy to server
uses: appleboy/ssh-action@v0.1.8 uses: appleboy/ssh-action@v0.1.8
with: with:
host: ${{ secrets.TRIGGER_SSH_HOST }} host: ${{ secrets.AGENT_DEV_SSH_HOST }}
username: ${{ secrets.SSH_USER }} username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }} key: ${{ secrets.SSH_PRIVATE_KEY }}
script: | script: |

1
.nvmrc
View File

@ -1 +0,0 @@
22.11.0

View File

@ -1,6 +1,7 @@
import json import json
import logging import logging
import uuid import uuid
from decimal import Decimal
from typing import Union, cast from typing import Union, cast
from sqlalchemy import select from sqlalchemy import select
@ -41,6 +42,7 @@ from core.tools.tool_manager import ToolManager
from core.tools.utils.dataset_retriever_tool import DatasetRetrieverTool from core.tools.utils.dataset_retriever_tool import DatasetRetrieverTool
from extensions.ext_database import db from extensions.ext_database import db
from factories import file_factory from factories import file_factory
from models.enums import CreatorUserRole
from models.model import Conversation, Message, MessageAgentThought, MessageFile from models.model import Conversation, Message, MessageAgentThought, MessageFile
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -289,6 +291,7 @@ class BaseAgentRunner(AppRunner):
thought = MessageAgentThought( thought = MessageAgentThought(
message_id=message_id, message_id=message_id,
message_chain_id=None, message_chain_id=None,
tool_process_data=None,
thought="", thought="",
tool=tool_name, tool=tool_name,
tool_labels_str="{}", tool_labels_str="{}",
@ -296,20 +299,20 @@ class BaseAgentRunner(AppRunner):
tool_input=tool_input, tool_input=tool_input,
message=message, message=message,
message_token=0, message_token=0,
message_unit_price=0, message_unit_price=Decimal(0),
message_price_unit=0, message_price_unit=Decimal("0.001"),
message_files=json.dumps(messages_ids) if messages_ids else "", message_files=json.dumps(messages_ids) if messages_ids else "",
answer="", answer="",
observation="", observation="",
answer_token=0, answer_token=0,
answer_unit_price=0, answer_unit_price=Decimal(0),
answer_price_unit=0, answer_price_unit=Decimal("0.001"),
tokens=0, tokens=0,
total_price=0, total_price=Decimal(0),
position=self.agent_thought_count + 1, position=self.agent_thought_count + 1,
currency="USD", currency="USD",
latency=0, latency=0,
created_by_role="account", created_by_role=CreatorUserRole.ACCOUNT,
created_by=self.user_id, created_by=self.user_id,
) )
@ -342,7 +345,8 @@ class BaseAgentRunner(AppRunner):
raise ValueError("agent thought not found") raise ValueError("agent thought not found")
if thought: if thought:
agent_thought.thought += thought existing_thought = agent_thought.thought or ""
agent_thought.thought = f"{existing_thought}{thought}"
if tool_name: if tool_name:
agent_thought.tool = tool_name agent_thought.tool = tool_name
@ -440,21 +444,30 @@ class BaseAgentRunner(AppRunner):
agent_thoughts: list[MessageAgentThought] = message.agent_thoughts agent_thoughts: list[MessageAgentThought] = message.agent_thoughts
if agent_thoughts: if agent_thoughts:
for agent_thought in agent_thoughts: for agent_thought in agent_thoughts:
tools = agent_thought.tool tool_names_raw = agent_thought.tool
if tools: if tool_names_raw:
tools = tools.split(";") tool_names = tool_names_raw.split(";")
tool_calls: list[AssistantPromptMessage.ToolCall] = [] tool_calls: list[AssistantPromptMessage.ToolCall] = []
tool_call_response: list[ToolPromptMessage] = [] tool_call_response: list[ToolPromptMessage] = []
try: tool_input_payload = agent_thought.tool_input
tool_inputs = json.loads(agent_thought.tool_input) if tool_input_payload:
except Exception: try:
tool_inputs = {tool: {} for tool in tools} tool_inputs = json.loads(tool_input_payload)
try: except Exception:
tool_responses = json.loads(agent_thought.observation) tool_inputs = {tool: {} for tool in tool_names}
except Exception: else:
tool_responses = dict.fromkeys(tools, agent_thought.observation) tool_inputs = {tool: {} for tool in tool_names}
for tool in tools: observation_payload = agent_thought.observation
if observation_payload:
try:
tool_responses = json.loads(observation_payload)
except Exception:
tool_responses = dict.fromkeys(tool_names, observation_payload)
else:
tool_responses = dict.fromkeys(tool_names, observation_payload)
for tool in tool_names:
# generate a uuid for tool call # generate a uuid for tool call
tool_call_id = str(uuid.uuid4()) tool_call_id = str(uuid.uuid4())
tool_calls.append( tool_calls.append(
@ -484,7 +497,7 @@ class BaseAgentRunner(AppRunner):
*tool_call_response, *tool_call_response,
] ]
) )
if not tools: if not tool_names_raw:
result.append(AssistantPromptMessage(content=agent_thought.thought)) result.append(AssistantPromptMessage(content=agent_thought.thought))
else: else:
if message.answer: if message.answer:

View File

@ -1,4 +1,3 @@
import json
from collections.abc import Sequence from collections.abc import Sequence
from enum import StrEnum, auto from enum import StrEnum, auto
from typing import Any, Literal from typing import Any, Literal
@ -121,7 +120,7 @@ class VariableEntity(BaseModel):
allowed_file_types: Sequence[FileType] | None = Field(default_factory=list) allowed_file_types: Sequence[FileType] | None = Field(default_factory=list)
allowed_file_extensions: Sequence[str] | 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) 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") @field_validator("description", mode="before")
@classmethod @classmethod
@ -135,17 +134,11 @@ class VariableEntity(BaseModel):
@field_validator("json_schema") @field_validator("json_schema")
@classmethod @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: if schema is None:
return None return None
try: try:
json_schema = json.loads(schema) Draft7Validator.check_schema(schema)
except json.JSONDecodeError:
raise ValueError(f"invalid json_schema value {schema}")
try:
Draft7Validator.check_schema(json_schema)
except SchemaError as e: except SchemaError as e:
raise ValueError(f"Invalid JSON schema: {e.message}") raise ValueError(f"Invalid JSON schema: {e.message}")
return schema return schema

View File

@ -26,7 +26,6 @@ class AdvancedChatAppConfigManager(BaseAppConfigManager):
@classmethod @classmethod
def get_app_config(cls, app_model: App, workflow: Workflow) -> AdvancedChatAppConfig: def get_app_config(cls, app_model: App, workflow: Workflow) -> AdvancedChatAppConfig:
features_dict = workflow.features_dict features_dict = workflow.features_dict
app_mode = AppMode.value_of(app_model.mode) app_mode = AppMode.value_of(app_model.mode)
app_config = AdvancedChatAppConfig( app_config = AdvancedChatAppConfig(
tenant_id=app_model.tenant_id, tenant_id=app_model.tenant_id,

View File

@ -358,25 +358,6 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
if node_finish_resp: if node_finish_resp:
yield 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( def _handle_node_failed_events(
self, self,
event: Union[QueueNodeFailedEvent, QueueNodeExceptionEvent], event: Union[QueueNodeFailedEvent, QueueNodeExceptionEvent],

View File

@ -1,4 +1,3 @@
import json
from collections.abc import Generator, Mapping, Sequence from collections.abc import Generator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Union, final from typing import TYPE_CHECKING, Any, Union, final
@ -76,12 +75,24 @@ class BaseAppGenerator:
user_inputs = {**user_inputs, **files_inputs, **file_list_inputs} user_inputs = {**user_inputs, **files_inputs, **file_list_inputs}
# Check if all files are converted to File # Check if all files are converted to File
if any(filter(lambda v: isinstance(v, dict), user_inputs.values())): invalid_dict_keys = [
raise ValueError("Invalid input type") k
if any( for k, v in user_inputs.items()
filter(lambda v: isinstance(v, dict), filter(lambda item: isinstance(item, list), user_inputs.values())) if isinstance(v, dict)
): and entity_dictionary[k].type not in {VariableEntityType.FILE, VariableEntityType.JSON_OBJECT}
raise ValueError("Invalid input type") ]
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 return user_inputs
@ -178,12 +189,8 @@ class BaseAppGenerator:
elif value == 0: elif value == 0:
value = False value = False
case VariableEntityType.JSON_OBJECT: case VariableEntityType.JSON_OBJECT:
if not isinstance(value, str): if not isinstance(value, dict):
raise ValueError(f"{variable_entity.variable} in input form must be a string") raise ValueError(f"{variable_entity.variable} in input form must be a dict")
try:
json.loads(value)
except json.JSONDecodeError:
raise ValueError(f"{variable_entity.variable} in input form must be a valid JSON object")
case _: case _:
raise AssertionError("this statement should be unreachable.") raise AssertionError("this statement should be unreachable.")

View File

@ -1,4 +1,3 @@
import json
from typing import Any from typing import Any
from jsonschema import Draft7Validator, ValidationError from jsonschema import Draft7Validator, ValidationError
@ -43,25 +42,22 @@ class StartNode(Node[StartNodeData]):
if value is None and variable.required: if value is None and variable.required:
raise ValueError(f"{key} is required in input form") 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 schema = variable.json_schema
if not schema: if not schema:
continue continue
if not value:
continue
try: try:
json_schema = json.loads(schema) Draft7Validator(schema).validate(value)
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)
except ValidationError as e: except ValidationError as e:
raise ValueError(f"JSON object for '{key}' does not match schema: {e.message}") raise ValueError(f"JSON object for '{key}' does not match schema: {e.message}")
node_inputs[key] = json_value

View File

@ -1843,7 +1843,7 @@ class MessageChain(TypeBase):
) )
class MessageAgentThought(Base): class MessageAgentThought(TypeBase):
__tablename__ = "message_agent_thoughts" __tablename__ = "message_agent_thoughts"
__table_args__ = ( __table_args__ = (
sa.PrimaryKeyConstraint("id", name="message_agent_thought_pkey"), sa.PrimaryKeyConstraint("id", name="message_agent_thought_pkey"),
@ -1851,34 +1851,42 @@ class MessageAgentThought(Base):
sa.Index("message_agent_thought_message_chain_id_idx", "message_chain_id"), sa.Index("message_agent_thought_message_chain_id_idx", "message_chain_id"),
) )
id = mapped_column(StringUUID, default=lambda: str(uuid4())) id: Mapped[str] = mapped_column(
message_id = mapped_column(StringUUID, nullable=False) StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
message_chain_id = mapped_column(StringUUID, nullable=True) )
message_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
position: Mapped[int] = mapped_column(sa.Integer, nullable=False) position: Mapped[int] = mapped_column(sa.Integer, nullable=False)
thought = mapped_column(LongText, nullable=True) created_by_role: Mapped[str] = mapped_column(String(255), nullable=False)
tool = mapped_column(LongText, nullable=True) created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
tool_labels_str = mapped_column(LongText, nullable=False, default=sa.text("'{}'")) message_chain_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
tool_meta_str = mapped_column(LongText, nullable=False, default=sa.text("'{}'")) thought: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
tool_input = mapped_column(LongText, nullable=True) tool: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
observation = mapped_column(LongText, nullable=True) tool_labels_str: Mapped[str] = mapped_column(LongText, nullable=False, default=sa.text("'{}'"))
tool_meta_str: Mapped[str] = mapped_column(LongText, nullable=False, default=sa.text("'{}'"))
tool_input: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
observation: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
# plugin_id = mapped_column(StringUUID, nullable=True) ## for future design # plugin_id = mapped_column(StringUUID, nullable=True) ## for future design
tool_process_data = mapped_column(LongText, nullable=True) tool_process_data: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
message = mapped_column(LongText, nullable=True) message: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
message_token: Mapped[int | None] = mapped_column(sa.Integer, nullable=True) message_token: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None)
message_unit_price = mapped_column(sa.Numeric, nullable=True) message_unit_price: Mapped[Decimal | None] = mapped_column(sa.Numeric, nullable=True, default=None)
message_price_unit = mapped_column(sa.Numeric(10, 7), nullable=False, server_default=sa.text("0.001")) message_price_unit: Mapped[Decimal] = mapped_column(
message_files = mapped_column(LongText, nullable=True) sa.Numeric(10, 7), nullable=False, default=Decimal("0.001"), server_default=sa.text("0.001")
answer = mapped_column(LongText, nullable=True) )
answer_token: Mapped[int | None] = mapped_column(sa.Integer, nullable=True) message_files: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
answer_unit_price = mapped_column(sa.Numeric, nullable=True) answer: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
answer_price_unit = mapped_column(sa.Numeric(10, 7), nullable=False, server_default=sa.text("0.001")) answer_token: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None)
tokens: Mapped[int | None] = mapped_column(sa.Integer, nullable=True) answer_unit_price: Mapped[Decimal | None] = mapped_column(sa.Numeric, nullable=True, default=None)
total_price = mapped_column(sa.Numeric, nullable=True) answer_price_unit: Mapped[Decimal] = mapped_column(
currency = mapped_column(String(255), nullable=True) sa.Numeric(10, 7), nullable=False, default=Decimal("0.001"), server_default=sa.text("0.001")
latency: Mapped[float | None] = mapped_column(sa.Float, nullable=True) )
created_by_role = mapped_column(String(255), nullable=False) tokens: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None)
created_by = mapped_column(StringUUID, nullable=False) total_price: Mapped[Decimal | None] = mapped_column(sa.Numeric, nullable=True, default=None)
created_at = mapped_column(sa.DateTime, nullable=False, server_default=sa.func.current_timestamp()) currency: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
latency: Mapped[float | None] = mapped_column(sa.Float, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(
sa.DateTime, nullable=False, init=False, server_default=sa.func.current_timestamp()
)
@property @property
def files(self) -> list[Any]: def files(self) -> list[Any]:

View File

@ -189,7 +189,7 @@ storage = [
"opendal~=0.46.0", "opendal~=0.46.0",
"oss2==2.18.5", "oss2==2.18.5",
"supabase~=2.18.1", "supabase~=2.18.1",
"tos~=2.7.1", "tos~=2.9.0",
] ]
############################################################ ############################################################

View File

@ -230,7 +230,6 @@ class TestAgentService:
# Create first agent thought # Create first agent thought
thought1 = MessageAgentThought( thought1 = MessageAgentThought(
id=fake.uuid4(),
message_id=message.id, message_id=message.id,
position=1, position=1,
thought="I need to analyze the user's request", thought="I need to analyze the user's request",
@ -257,7 +256,6 @@ class TestAgentService:
# Create second agent thought # Create second agent thought
thought2 = MessageAgentThought( thought2 = MessageAgentThought(
id=fake.uuid4(),
message_id=message.id, message_id=message.id,
position=2, position=2,
thought="Based on the analysis, I can provide a response", thought="Based on the analysis, I can provide a response",
@ -545,7 +543,6 @@ class TestAgentService:
# Create agent thought with tool error # Create agent thought with tool error
thought_with_error = MessageAgentThought( thought_with_error = MessageAgentThought(
id=fake.uuid4(),
message_id=message.id, message_id=message.id,
position=1, position=1,
thought="I need to analyze the user's request", thought="I need to analyze the user's request",
@ -759,7 +756,6 @@ class TestAgentService:
# Create agent thought with multiple tools # Create agent thought with multiple tools
complex_thought = MessageAgentThought( complex_thought = MessageAgentThought(
id=fake.uuid4(),
message_id=message.id, message_id=message.id,
position=1, position=1,
thought="I need to use multiple tools to complete this task", thought="I need to use multiple tools to complete this task",
@ -877,7 +873,6 @@ class TestAgentService:
# Create agent thought with files # Create agent thought with files
thought_with_files = MessageAgentThought( thought_with_files = MessageAgentThought(
id=fake.uuid4(),
message_id=message.id, message_id=message.id,
position=1, position=1,
thought="I need to process some files", thought="I need to process some files",
@ -957,7 +952,6 @@ class TestAgentService:
# Create agent thought with empty tool data # Create agent thought with empty tool data
empty_thought = MessageAgentThought( empty_thought = MessageAgentThought(
id=fake.uuid4(),
message_id=message.id, message_id=message.id,
position=1, position=1,
thought="I need to analyze the user's request", thought="I need to analyze the user's request",
@ -999,7 +993,6 @@ class TestAgentService:
# Create agent thought with malformed JSON # Create agent thought with malformed JSON
malformed_thought = MessageAgentThought( malformed_thought = MessageAgentThought(
id=fake.uuid4(),
message_id=message.id, message_id=message.id,
position=1, position=1,
thought="I need to analyze the user's request", thought="I need to analyze the user's request",

View File

@ -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]

View File

@ -58,6 +58,8 @@ def test_json_object_valid_schema():
} }
) )
schema = json.loads(schema)
variables = [ variables = [
VariableEntity( VariableEntity(
variable="profile", 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) node = make_start_node(user_inputs, variables)
result = node._run() result = node._run()
@ -87,6 +89,8 @@ def test_json_object_invalid_json_string():
"required": ["age", "name"], "required": ["age", "name"],
} }
) )
schema = json.loads(schema)
variables = [ variables = [
VariableEntity( VariableEntity(
variable="profile", 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"'} user_inputs = {"profile": '{"age": 20, "name": "Tom"'}
node = make_start_node(user_inputs, variables) 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() node._run()
@ -118,6 +122,8 @@ def test_json_object_does_not_match_schema():
} }
) )
schema = json.loads(schema)
variables = [ variables = [
VariableEntity( VariableEntity(
variable="profile", variable="profile",
@ -129,7 +135,7 @@ def test_json_object_does_not_match_schema():
] ]
# age is a string, which violates the schema (expects number) # 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) node = make_start_node(user_inputs, variables)
@ -149,6 +155,8 @@ def test_json_object_missing_required_schema_field():
} }
) )
schema = json.loads(schema)
variables = [ variables = [
VariableEntity( VariableEntity(
variable="profile", variable="profile",
@ -160,7 +168,7 @@ def test_json_object_missing_required_schema_field():
] ]
# Missing required field "name" # Missing required field "name"
user_inputs = {"profile": json.dumps({"age": 20})} user_inputs = {"profile": {"age": 20}}
node = make_start_node(user_inputs, variables) node = make_start_node(user_inputs, variables)

53
api/uv.lock generated
View File

@ -1731,7 +1731,7 @@ storage = [
{ name = "opendal", specifier = "~=0.46.0" }, { name = "opendal", specifier = "~=0.46.0" },
{ name = "oss2", specifier = "==2.18.5" }, { name = "oss2", specifier = "==2.18.5" },
{ name = "supabase", specifier = "~=2.18.1" }, { name = "supabase", specifier = "~=2.18.1" },
{ name = "tos", specifier = "~=2.7.1" }, { name = "tos", specifier = "~=2.9.0" },
] ]
tools = [ tools = [
{ name = "cloudscraper", specifier = "~=1.2.71" }, { name = "cloudscraper", specifier = "~=1.2.71" },
@ -6148,7 +6148,7 @@ wheels = [
[[package]] [[package]]
name = "tos" name = "tos"
version = "2.7.2" version = "2.9.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "crcmod" }, { name = "crcmod" },
@ -6156,8 +6156,9 @@ dependencies = [
{ name = "pytz" }, { name = "pytz" },
{ name = "requests" }, { name = "requests" },
{ name = "six" }, { name = "six" },
{ name = "wrapt" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/0c/01/f811af86f1f80d5f289be075c3b281e74bf3fe081cfbe5cfce44954d2c3a/tos-2.7.2.tar.gz", hash = "sha256:3c31257716785bca7b2cac51474ff32543cda94075a7b7aff70d769c15c7b7ed", size = 123407, upload-time = "2024-10-16T15:59:08.634Z" } sdist = { url = "https://files.pythonhosted.org/packages/9a/b3/13451226f564f88d9db2323e9b7eabcced792a0ad5ee1e333751a7634257/tos-2.9.0.tar.gz", hash = "sha256:861cfc348e770f099f911cb96b2c41774ada6c9c51b7a89d97e0c426074dd99e", size = 157071, upload-time = "2026-01-06T04:13:08.921Z" }
[[package]] [[package]]
name = "tqdm" name = "tqdm"
@ -7146,31 +7147,31 @@ wheels = [
[[package]] [[package]]
name = "wrapt" name = "wrapt"
version = "1.17.3" version = "1.16.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } sdist = { url = "https://files.pythonhosted.org/packages/95/4c/063a912e20bcef7124e0df97282a8af3ff3e4b603ce84c481d6d7346be0a/wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d", size = 53972, upload-time = "2023-11-09T06:33:30.191Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, { url = "https://files.pythonhosted.org/packages/fd/03/c188ac517f402775b90d6f312955a5e53b866c964b32119f2ed76315697e/wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09", size = 37313, upload-time = "2023-11-09T06:31:52.168Z" },
{ url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, { url = "https://files.pythonhosted.org/packages/0f/16/ea627d7817394db04518f62934a5de59874b587b792300991b3c347ff5e0/wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d", size = 38164, upload-time = "2023-11-09T06:31:53.522Z" },
{ url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, { url = "https://files.pythonhosted.org/packages/7f/a7/f1212ba098f3de0fd244e2de0f8791ad2539c03bef6c05a9fcb03e45b089/wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389", size = 80890, upload-time = "2023-11-09T06:31:55.247Z" },
{ url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, { url = "https://files.pythonhosted.org/packages/b7/96/bb5e08b3d6db003c9ab219c487714c13a237ee7dcc572a555eaf1ce7dc82/wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060", size = 73118, upload-time = "2023-11-09T06:31:57.023Z" },
{ url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, { url = "https://files.pythonhosted.org/packages/6e/52/2da48b35193e39ac53cfb141467d9f259851522d0e8c87153f0ba4205fb1/wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1", size = 80746, upload-time = "2023-11-09T06:31:58.686Z" },
{ url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, { url = "https://files.pythonhosted.org/packages/11/fb/18ec40265ab81c0e82a934de04596b6ce972c27ba2592c8b53d5585e6bcd/wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3", size = 85668, upload-time = "2023-11-09T06:31:59.992Z" },
{ url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, { url = "https://files.pythonhosted.org/packages/0f/ef/0ecb1fa23145560431b970418dce575cfaec555ab08617d82eb92afc7ccf/wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956", size = 78556, upload-time = "2023-11-09T06:32:01.942Z" },
{ url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, { url = "https://files.pythonhosted.org/packages/25/62/cd284b2b747f175b5a96cbd8092b32e7369edab0644c45784871528eb852/wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d", size = 85712, upload-time = "2023-11-09T06:32:03.686Z" },
{ url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, { url = "https://files.pythonhosted.org/packages/e5/a7/47b7ff74fbadf81b696872d5ba504966591a3468f1bc86bca2f407baef68/wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362", size = 35327, upload-time = "2023-11-09T06:32:05.284Z" },
{ url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, { url = "https://files.pythonhosted.org/packages/cf/c3/0084351951d9579ae83a3d9e38c140371e4c6b038136909235079f2e6e78/wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89", size = 37523, upload-time = "2023-11-09T06:32:07.17Z" },
{ url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, { url = "https://files.pythonhosted.org/packages/92/17/224132494c1e23521868cdd57cd1e903f3b6a7ba6996b7b8f077ff8ac7fe/wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b", size = 37614, upload-time = "2023-11-09T06:32:08.859Z" },
{ url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, { url = "https://files.pythonhosted.org/packages/6a/d7/cfcd73e8f4858079ac59d9db1ec5a1349bc486ae8e9ba55698cc1f4a1dff/wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36", size = 38316, upload-time = "2023-11-09T06:32:10.719Z" },
{ url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, { url = "https://files.pythonhosted.org/packages/7e/79/5ff0a5c54bda5aec75b36453d06be4f83d5cd4932cc84b7cb2b52cee23e2/wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73", size = 86322, upload-time = "2023-11-09T06:32:12.592Z" },
{ url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, { url = "https://files.pythonhosted.org/packages/c4/81/e799bf5d419f422d8712108837c1d9bf6ebe3cb2a81ad94413449543a923/wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809", size = 79055, upload-time = "2023-11-09T06:32:14.394Z" },
{ url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, { url = "https://files.pythonhosted.org/packages/62/62/30ca2405de6a20448ee557ab2cd61ab9c5900be7cbd18a2639db595f0b98/wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b", size = 87291, upload-time = "2023-11-09T06:32:16.201Z" },
{ url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, { url = "https://files.pythonhosted.org/packages/49/4e/5d2f6d7b57fc9956bf06e944eb00463551f7d52fc73ca35cfc4c2cdb7aed/wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81", size = 90374, upload-time = "2023-11-09T06:32:18.052Z" },
{ url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, { url = "https://files.pythonhosted.org/packages/a6/9b/c2c21b44ff5b9bf14a83252a8b973fb84923764ff63db3e6dfc3895cf2e0/wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9", size = 83896, upload-time = "2023-11-09T06:32:19.533Z" },
{ url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, { url = "https://files.pythonhosted.org/packages/14/26/93a9fa02c6f257df54d7570dfe8011995138118d11939a4ecd82cb849613/wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c", size = 91738, upload-time = "2023-11-09T06:32:20.989Z" },
{ url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, { url = "https://files.pythonhosted.org/packages/a2/5b/4660897233eb2c8c4de3dc7cefed114c61bacb3c28327e64150dc44ee2f6/wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc", size = 35568, upload-time = "2023-11-09T06:32:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, { url = "https://files.pythonhosted.org/packages/5c/cc/8297f9658506b224aa4bd71906447dea6bb0ba629861a758c28f67428b91/wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8", size = 37653, upload-time = "2023-11-09T06:32:24.533Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, { url = "https://files.pythonhosted.org/packages/ff/21/abdedb4cdf6ff41ebf01a74087740a709e2edb146490e4d9beea054b0b7a/wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1", size = 23362, upload-time = "2023-11-09T06:33:28.271Z" },
] ]
[[package]] [[package]]

1
web/.nvmrc Normal file
View File

@ -0,0 +1 @@
22.21.1

View File

@ -1,8 +1,10 @@
import type { Preview } from '@storybook/react' import type { Preview } from '@storybook/react'
import type { Resource } from 'i18next'
import { withThemeByDataAttribute } from '@storybook/addon-themes' import { withThemeByDataAttribute } from '@storybook/addon-themes'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ToastProvider } from '../app/components/base/toast' import { ToastProvider } from '../app/components/base/toast'
import I18N from '../app/components/i18n' import { I18nClientProvider as I18N } from '../app/components/provider/i18n'
import commonEnUS from '../i18n/en-US/common.json'
import '../app/styles/globals.css' import '../app/styles/globals.css'
import '../app/styles/markdown.scss' import '../app/styles/markdown.scss'
@ -16,6 +18,14 @@ const queryClient = new QueryClient({
}, },
}) })
const storyResources: Resource = {
'en-US': {
// Preload the most common namespace to avoid missing keys during initial render;
// other namespaces will be loaded on demand via resourcesToBackend.
common: commonEnUS as unknown as Record<string, unknown>,
},
}
export const decorators = [ export const decorators = [
withThemeByDataAttribute({ withThemeByDataAttribute({
themes: { themes: {
@ -28,7 +38,7 @@ export const decorators = [
(Story) => { (Story) => {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<I18N locale="en-US"> <I18N locale="en-US" resource={storyResources}>
<ToastProvider> <ToastProvider>
<Story /> <Story />
</ToastProvider> </ToastProvider>

View File

@ -11,6 +11,16 @@ Before starting the web frontend service, please make sure the following environ
- [Node.js](https://nodejs.org) >= v22.11.x - [Node.js](https://nodejs.org) >= v22.11.x
- [pnpm](https://pnpm.io) v10.x - [pnpm](https://pnpm.io) v10.x
> [!TIP]
> It is recommended to install and enable Corepack to manage package manager versions automatically:
>
> ```bash
> npm install -g corepack
> corepack enable
> ```
>
> Learn more: [Corepack](https://github.com/nodejs/corepack#readme)
First, install the dependencies: First, install the dependencies:
```bash ```bash

View File

@ -2,11 +2,11 @@ import Marketplace from '@/app/components/plugins/marketplace'
import PluginPage from '@/app/components/plugins/plugin-page' import PluginPage from '@/app/components/plugins/plugin-page'
import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel' import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel'
const PluginList = async () => { const PluginList = () => {
return ( return (
<PluginPage <PluginPage
plugins={<PluginsPanel />} plugins={<PluginsPanel />}
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" showSearchParams={false} />} marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" />}
/> />
) )
} }

View File

@ -26,6 +26,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import { useInvalidateAppList } from '@/service/use-apps'
import { fetchWorkflowDraft } from '@/service/workflow' import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection' import { getRedirection } from '@/utils/app-redirection'
@ -66,6 +67,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
const { onPlanInfoChanged } = useProviderContext() const { onPlanInfoChanged } = useProviderContext()
const appDetail = useAppStore(state => state.appDetail) const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail) const setAppDetail = useAppStore(state => state.setAppDetail)
const invalidateAppList = useInvalidateAppList()
const [open, setOpen] = useState(openState) const [open, setOpen] = useState(openState)
const [showEditModal, setShowEditModal] = useState(false) const [showEditModal, setShowEditModal] = useState(false)
const [showDuplicateModal, setShowDuplicateModal] = useState(false) const [showDuplicateModal, setShowDuplicateModal] = useState(false)
@ -191,6 +193,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
try { try {
await deleteApp(appDetail.id) await deleteApp(appDetail.id)
notify({ type: 'success', message: t('appDeleted', { ns: 'app' }) }) notify({ type: 'success', message: t('appDeleted', { ns: 'app' }) })
invalidateAppList()
onPlanInfoChanged() onPlanInfoChanged()
setAppDetail() setAppDetail()
replace('/apps') replace('/apps')
@ -202,7 +205,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
}) })
} }
setShowConfirmDelete(false) setShowConfirmDelete(false)
}, [appDetail, notify, onPlanInfoChanged, replace, setAppDetail, t]) }, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, setAppDetail, t])
const { isCurrentWorkspaceEditor } = useAppContext() const { isCurrentWorkspaceEditor } = useAppContext()

View File

@ -83,7 +83,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
if (!isJsonObject || !tempPayload.json_schema) if (!isJsonObject || !tempPayload.json_schema)
return '' return ''
try { try {
return JSON.stringify(JSON.parse(tempPayload.json_schema), null, 2) return tempPayload.json_schema
} }
catch { catch {
return '' return ''

View File

@ -10,6 +10,7 @@ const mockReplace = vi.fn()
const mockRouter = { replace: mockReplace } const mockRouter = { replace: mockReplace }
vi.mock('next/navigation', () => ({ vi.mock('next/navigation', () => ({
useRouter: () => mockRouter, useRouter: () => mockRouter,
useSearchParams: () => new URLSearchParams(''),
})) }))
// Mock app context // Mock app context

View File

@ -12,6 +12,7 @@ import { useDebounceFn } from 'ahooks'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { import {
useRouter, useRouter,
useSearchParams,
} from 'next/navigation' } from 'next/navigation'
import { parseAsString, useQueryState } from 'nuqs' import { parseAsString, useQueryState } from 'nuqs'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
@ -28,6 +29,7 @@ import { CheckModal } from '@/hooks/use-pay'
import { useInfiniteAppList } from '@/service/use-apps' import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { isServer } from '@/utils/client'
import AppCard from './app-card' import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton' import { AppCardSkeleton } from './app-card-skeleton'
import Empty from './empty' import Empty from './empty'
@ -36,6 +38,16 @@ import useAppsQueryState from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop' import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
import NewAppCard from './new-app-card' 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'), { const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
ssr: false, ssr: false,
}) })
@ -47,12 +59,41 @@ const List = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore() const { systemFeatures } = useGlobalPublicStore()
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams()
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext() const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const [activeTab, setActiveTab] = useQueryState( const [activeTab, setActiveTab] = useQueryState(
'category', 'category',
parseAsString.withDefault('all').withOptions({ history: 'push' }), 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 (isServer)
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 { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe) const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs) const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)

View File

@ -37,7 +37,7 @@ export const getProcessedInputs = (inputs: Record<string, any>, inputsForm: Inpu
return return
} }
if (!inputValue) if (inputValue == null)
return return
if (item.type === InputVarType.singleFile) { if (item.type === InputVarType.singleFile) {
@ -52,6 +52,20 @@ export const getProcessedInputs = (inputs: Record<string, any>, inputsForm: Inpu
else else
processedInputs[item.variable] = getProcessedFiles(inputValue) 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 return processedInputs

View File

@ -11,6 +11,7 @@ import DifyLogo from '@/app/components/base/logo/dify-logo'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { isClient } from '@/utils/client'
import { import {
useEmbeddedChatbotContext, useEmbeddedChatbotContext,
} from '../context' } from '../context'
@ -40,7 +41,6 @@ const Header: FC<IHeaderProps> = ({
allInputsHidden, allInputsHidden,
} = useEmbeddedChatbotContext() } = useEmbeddedChatbotContext()
const isClient = typeof window !== 'undefined'
const isIframe = isClient ? window.self !== window.top : false const isIframe = isClient ? window.self !== window.top : false
const [parentOrigin, setParentOrigin] = useState('') const [parentOrigin, setParentOrigin] = useState('')
const [showToggleExpandButton, setShowToggleExpandButton] = useState(false) const [showToggleExpandButton, setShowToggleExpandButton] = useState(false)

View 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])
}

View File

@ -1,6 +1,30 @@
import { PluginCategoryEnum } from '../types'
export const DEFAULT_SORT = { export const DEFAULT_SORT = {
sortBy: 'install_count', sortBy: 'install_count',
sortOrder: 'DESC', sortOrder: 'DESC',
} }
export const SCROLL_BOTTOM_THRESHOLD = 100 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,
],
)

View File

@ -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>
)
}

View File

@ -26,6 +26,9 @@ import {
getMarketplacePluginsByCollectionId, getMarketplacePluginsByCollectionId,
} from './utils' } from './utils'
/**
* @deprecated Use useMarketplaceCollectionsAndPlugins from query.ts instead
*/
export const useMarketplaceCollectionsAndPlugins = () => { export const useMarketplaceCollectionsAndPlugins = () => {
const [queryParams, setQueryParams] = useState<CollectionsAndPluginsSearchParams>() const [queryParams, setQueryParams] = useState<CollectionsAndPluginsSearchParams>()
const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState<MarketplaceCollection[]>() const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState<MarketplaceCollection[]>()
@ -89,7 +92,9 @@ export const useMarketplacePluginsByCollectionId = (
isSuccess, isSuccess,
} }
} }
/**
* @deprecated Use useMarketplacePlugins from query.ts instead
*/
export const useMarketplacePlugins = () => { export const useMarketplacePlugins = () => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [queryParams, setQueryParams] = useState<PluginsSearchParams>() const [queryParams, setQueryParams] = useState<PluginsSearchParams>()

View 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}</>
}

View 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

View File

@ -1,55 +1,39 @@
import type { MarketplaceCollection, SearchParams } from './types' import type { SearchParams } from 'nuqs'
import type { Plugin } from '@/app/components/plugins/types'
import { TanstackQueryInitializer } from '@/context/query-client' import { TanstackQueryInitializer } from '@/context/query-client'
import { MarketplaceContextProvider } from './context'
import Description from './description' import Description from './description'
import { HydrateMarketplaceAtoms } from './hydration-client'
import { HydrateQueryClient } from './hydration-server'
import ListWrapper from './list/list-wrapper' import ListWrapper from './list/list-wrapper'
import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper' import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
import { getMarketplaceCollectionsAndPlugins } from './utils'
type MarketplaceProps = { type MarketplaceProps = {
showInstallButton?: boolean showInstallButton?: boolean
shouldExclude?: boolean
searchParams?: SearchParams
pluginTypeSwitchClassName?: string 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 ({ const Marketplace = async ({
showInstallButton = true, showInstallButton = true,
shouldExclude,
searchParams,
pluginTypeSwitchClassName, pluginTypeSwitchClassName,
scrollContainerId, searchParams,
showSearchParams = true,
}: MarketplaceProps) => { }: MarketplaceProps) => {
let marketplaceCollections: MarketplaceCollection[] = []
let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {}
if (!shouldExclude) {
const marketplaceCollectionsAndPluginsData = await getMarketplaceCollectionsAndPlugins()
marketplaceCollections = marketplaceCollectionsAndPluginsData.marketplaceCollections
marketplaceCollectionPluginsMap = marketplaceCollectionsAndPluginsData.marketplaceCollectionPluginsMap
}
return ( return (
<TanstackQueryInitializer> <TanstackQueryInitializer>
<MarketplaceContextProvider <HydrateQueryClient searchParams={searchParams}>
searchParams={searchParams} <HydrateMarketplaceAtoms preserveSearchStateInQuery={!!searchParams}>
shouldExclude={shouldExclude} <Description />
scrollContainerId={scrollContainerId} <StickySearchAndSwitchWrapper
showSearchParams={showSearchParams} pluginTypeSwitchClassName={pluginTypeSwitchClassName}
> />
<Description /> <ListWrapper
<StickySearchAndSwitchWrapper showInstallButton={showInstallButton}
pluginTypeSwitchClassName={pluginTypeSwitchClassName} />
showSearchParams={showSearchParams} </HydrateMarketplaceAtoms>
/> </HydrateQueryClient>
<ListWrapper
marketplaceCollections={marketplaceCollections}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
showInstallButton={showInstallButton}
/>
</MarketplaceContextProvider>
</TanstackQueryInitializer> </TanstackQueryInitializer>
) )
} }

View File

@ -1,6 +1,6 @@
import type { MarketplaceCollection, SearchParamsFromCollection } from '../types' import type { MarketplaceCollection, SearchParamsFromCollection } from '../types'
import type { Plugin } from '@/app/components/plugins/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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '@/app/components/plugins/types' import { PluginCategoryEnum } from '@/app/components/plugins/types'
import List from './index' import List from './index'
@ -30,23 +30,27 @@ vi.mock('#i18n', () => ({
useLocale: () => 'en-US', useLocale: () => 'en-US',
})) }))
// Mock useMarketplaceContext with controllable values // Mock marketplace state hooks with controllable values
const mockContextValues = { const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => {
plugins: undefined as Plugin[] | undefined, return {
pluginsTotal: 0, mockMarketplaceData: {
marketplaceCollectionsFromClient: undefined as MarketplaceCollection[] | undefined, plugins: undefined as Plugin[] | undefined,
marketplaceCollectionPluginsMapFromClient: undefined as Record<string, Plugin[]> | undefined, pluginsTotal: 0,
isLoading: false, marketplaceCollections: undefined as MarketplaceCollection[] | undefined,
isSuccessCollections: false, marketplaceCollectionPluginsMap: undefined as Record<string, Plugin[]> | undefined,
handleQueryPlugins: vi.fn(), isLoading: false,
searchPluginText: '', page: 1,
filterPluginTags: [] as string[], },
page: 1, mockMoreClick: vi.fn(),
handleMoreClick: vi.fn(), }
} })
vi.mock('../context', () => ({ vi.mock('../state', () => ({
useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), useMarketplaceData: () => mockMarketplaceData,
}))
vi.mock('../atoms', () => ({
useMarketplaceMoreClick: () => mockMoreClick,
})) }))
// Mock useLocale context // Mock useLocale context
@ -578,7 +582,7 @@ describe('ListWithCollection', () => {
// View More Button Tests // View More Button Tests
// ================================ // ================================
describe('View More Button', () => { 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({ const collections = [createMockCollection({
name: 'collection-0', name: 'collection-0',
searchable: true, searchable: true,
@ -587,14 +591,12 @@ describe('ListWithCollection', () => {
const pluginsMap: Record<string, Plugin[]> = { const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1), 'collection-0': createMockPluginList(1),
} }
const onMoreClick = vi.fn()
render( render(
<ListWithCollection <ListWithCollection
{...defaultProps} {...defaultProps}
marketplaceCollections={collections} marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap} marketplaceCollectionPluginsMap={pluginsMap}
onMoreClick={onMoreClick}
/>, />,
) )
@ -609,42 +611,19 @@ describe('ListWithCollection', () => {
const pluginsMap: Record<string, Plugin[]> = { const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1), 'collection-0': createMockPluginList(1),
} }
const onMoreClick = vi.fn()
render( render(
<ListWithCollection <ListWithCollection
{...defaultProps} {...defaultProps}
marketplaceCollections={collections} marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap} marketplaceCollectionPluginsMap={pluginsMap}
onMoreClick={onMoreClick}
/>, />,
) )
expect(screen.queryByText('View More')).not.toBeInTheDocument() expect(screen.queryByText('View More')).not.toBeInTheDocument()
}) })
it('should not render View More button when onMoreClick is not provided', () => { it('should call moreClick hook with search_params when View More is clicked', () => {
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', () => {
const searchParams: SearchParamsFromCollection = { query: 'test-query', sort_by: 'install_count' } const searchParams: SearchParamsFromCollection = { query: 'test-query', sort_by: 'install_count' }
const collections = [createMockCollection({ const collections = [createMockCollection({
name: 'collection-0', name: 'collection-0',
@ -654,21 +633,19 @@ describe('ListWithCollection', () => {
const pluginsMap: Record<string, Plugin[]> = { const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1), 'collection-0': createMockPluginList(1),
} }
const onMoreClick = vi.fn()
render( render(
<ListWithCollection <ListWithCollection
{...defaultProps} {...defaultProps}
marketplaceCollections={collections} marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap} marketplaceCollectionPluginsMap={pluginsMap}
onMoreClick={onMoreClick}
/>, />,
) )
fireEvent.click(screen.getByText('View More')) fireEvent.click(screen.getByText('View More'))
expect(onMoreClick).toHaveBeenCalledTimes(1) expect(mockMoreClick).toHaveBeenCalledTimes(1)
expect(onMoreClick).toHaveBeenCalledWith(searchParams) expect(mockMoreClick).toHaveBeenCalledWith(searchParams)
}) })
}) })
@ -802,24 +779,15 @@ describe('ListWithCollection', () => {
// ListWrapper Component Tests // ListWrapper Component Tests
// ================================ // ================================
describe('ListWrapper', () => { describe('ListWrapper', () => {
const defaultProps = {
marketplaceCollections: [] as MarketplaceCollection[],
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
showInstallButton: false,
}
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
// Reset context values // Reset mock data
mockContextValues.plugins = undefined mockMarketplaceData.plugins = undefined
mockContextValues.pluginsTotal = 0 mockMarketplaceData.pluginsTotal = 0
mockContextValues.marketplaceCollectionsFromClient = undefined mockMarketplaceData.marketplaceCollections = undefined
mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined mockMarketplaceData.marketplaceCollectionPluginsMap = undefined
mockContextValues.isLoading = false mockMarketplaceData.isLoading = false
mockContextValues.isSuccessCollections = false mockMarketplaceData.page = 1
mockContextValues.searchPluginText = ''
mockContextValues.filterPluginTags = []
mockContextValues.page = 1
}) })
// ================================ // ================================
@ -827,32 +795,32 @@ describe('ListWrapper', () => {
// ================================ // ================================
describe('Rendering', () => { describe('Rendering', () => {
it('should render without crashing', () => { it('should render without crashing', () => {
render(<ListWrapper {...defaultProps} />) render(<ListWrapper />)
expect(document.body).toBeInTheDocument() expect(document.body).toBeInTheDocument()
}) })
it('should render with scrollbarGutter style', () => { it('should render with scrollbarGutter style', () => {
const { container } = render(<ListWrapper {...defaultProps} />) const { container } = render(<ListWrapper />)
const wrapper = container.firstChild as HTMLElement const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveStyle({ scrollbarGutter: 'stable' }) expect(wrapper).toHaveStyle({ scrollbarGutter: 'stable' })
}) })
it('should render Loading component when isLoading is true and page is 1', () => { it('should render Loading component when isLoading is true and page is 1', () => {
mockContextValues.isLoading = true mockMarketplaceData.isLoading = true
mockContextValues.page = 1 mockMarketplaceData.page = 1
render(<ListWrapper {...defaultProps} />) render(<ListWrapper />)
expect(screen.getByTestId('loading-component')).toBeInTheDocument() expect(screen.getByTestId('loading-component')).toBeInTheDocument()
}) })
it('should not render Loading component when page > 1', () => { it('should not render Loading component when page > 1', () => {
mockContextValues.isLoading = true mockMarketplaceData.isLoading = true
mockContextValues.page = 2 mockMarketplaceData.page = 2
render(<ListWrapper {...defaultProps} />) render(<ListWrapper />)
expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
}) })
@ -863,26 +831,26 @@ describe('ListWrapper', () => {
// ================================ // ================================
describe('Plugins Header', () => { describe('Plugins Header', () => {
it('should render plugins result count when plugins are present', () => { it('should render plugins result count when plugins are present', () => {
mockContextValues.plugins = createMockPluginList(5) mockMarketplaceData.plugins = createMockPluginList(5)
mockContextValues.pluginsTotal = 5 mockMarketplaceData.pluginsTotal = 5
render(<ListWrapper {...defaultProps} />) render(<ListWrapper />)
expect(screen.getByText('5 plugins found')).toBeInTheDocument() expect(screen.getByText('5 plugins found')).toBeInTheDocument()
}) })
it('should render SortDropdown when plugins are present', () => { 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() expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument()
}) })
it('should not render plugins header when plugins is undefined', () => { 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() expect(screen.queryByTestId('sort-dropdown')).not.toBeInTheDocument()
}) })
@ -892,197 +860,60 @@ describe('ListWrapper', () => {
// List Rendering Logic Tests // List Rendering Logic Tests
// ================================ // ================================
describe('List Rendering Logic', () => { describe('List Rendering Logic', () => {
it('should render List when not loading', () => { it('should render collections when not loading', () => {
mockContextValues.isLoading = false mockMarketplaceData.isLoading = false
const collections = createMockCollectionList(1) mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
const pluginsMap: Record<string, Plugin[]> = { mockMarketplaceData.marketplaceCollectionPluginsMap = {
'collection-0': createMockPluginList(1), 'collection-0': createMockPluginList(1),
} }
render( render(<ListWrapper />)
<ListWrapper
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
expect(screen.getByText('Collection 0')).toBeInTheDocument() expect(screen.getByText('Collection 0')).toBeInTheDocument()
}) })
it('should render List when loading but page > 1', () => { it('should render List when loading but page > 1', () => {
mockContextValues.isLoading = true mockMarketplaceData.isLoading = true
mockContextValues.page = 2 mockMarketplaceData.page = 2
const collections = createMockCollectionList(1) mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
const pluginsMap: Record<string, Plugin[]> = { mockMarketplaceData.marketplaceCollectionPluginsMap = {
'collection-0': createMockPluginList(1), 'collection-0': createMockPluginList(1),
} }
render( render(<ListWrapper />)
<ListWrapper
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
expect(screen.getByText('Collection 0')).toBeInTheDocument() 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', () => { describe('Data Integration', () => {
it('should pass plugins from context to List', () => { it('should pass plugins from state to List', () => {
const plugins = createMockPluginList(2) mockMarketplaceData.plugins = createMockPluginList(2)
mockContextValues.plugins = plugins
render(<ListWrapper {...defaultProps} />) render(<ListWrapper />)
expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument() expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument()
expect(screen.getByTestId('card-plugin-1')).toBeInTheDocument() expect(screen.getByTestId('card-plugin-1')).toBeInTheDocument()
}) })
it('should pass handleMoreClick from context to List', () => { it('should show View More button and call moreClick hook', () => {
const mockHandleMoreClick = vi.fn() mockMarketplaceData.marketplaceCollections = [createMockCollection({
mockContextValues.handleMoreClick = mockHandleMoreClick
const collections = [createMockCollection({
name: 'collection-0', name: 'collection-0',
searchable: true, searchable: true,
search_params: { query: 'test' }, search_params: { query: 'test' },
})] })]
const pluginsMap: Record<string, Plugin[]> = { mockMarketplaceData.marketplaceCollectionPluginsMap = {
'collection-0': createMockPluginList(1), 'collection-0': createMockPluginList(1),
} }
render( render(<ListWrapper />)
<ListWrapper
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
fireEvent.click(screen.getByText('View More')) fireEvent.click(screen.getByText('View More'))
expect(mockHandleMoreClick).toHaveBeenCalled() expect(mockMoreClick).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()
})
}) })
}) })
@ -1090,32 +921,32 @@ describe('ListWrapper', () => {
// Edge Cases Tests // Edge Cases Tests
// ================================ // ================================
describe('Edge Cases', () => { describe('Edge Cases', () => {
it('should handle empty plugins array from context', () => { it('should handle empty plugins array', () => {
mockContextValues.plugins = [] mockMarketplaceData.plugins = []
mockContextValues.pluginsTotal = 0 mockMarketplaceData.pluginsTotal = 0
render(<ListWrapper {...defaultProps} />) render(<ListWrapper />)
expect(screen.getByText('0 plugins found')).toBeInTheDocument() expect(screen.getByText('0 plugins found')).toBeInTheDocument()
expect(screen.getByTestId('empty-component')).toBeInTheDocument() expect(screen.getByTestId('empty-component')).toBeInTheDocument()
}) })
it('should handle large pluginsTotal', () => { it('should handle large pluginsTotal', () => {
mockContextValues.plugins = createMockPluginList(10) mockMarketplaceData.plugins = createMockPluginList(10)
mockContextValues.pluginsTotal = 10000 mockMarketplaceData.pluginsTotal = 10000
render(<ListWrapper {...defaultProps} />) render(<ListWrapper />)
expect(screen.getByText('10000 plugins found')).toBeInTheDocument() expect(screen.getByText('10000 plugins found')).toBeInTheDocument()
}) })
it('should handle both loading and has plugins', () => { it('should handle both loading and has plugins', () => {
mockContextValues.isLoading = true mockMarketplaceData.isLoading = true
mockContextValues.page = 2 mockMarketplaceData.page = 2
mockContextValues.plugins = createMockPluginList(5) mockMarketplaceData.plugins = createMockPluginList(5)
mockContextValues.pluginsTotal = 50 mockMarketplaceData.pluginsTotal = 50
render(<ListWrapper {...defaultProps} />) render(<ListWrapper />)
// Should show plugins header and list // Should show plugins header and list
expect(screen.getByText('50 plugins found')).toBeInTheDocument() expect(screen.getByText('50 plugins found')).toBeInTheDocument()
@ -1428,106 +1259,72 @@ describe('CardWrapper (via List integration)', () => {
describe('Combined Workflows', () => { describe('Combined Workflows', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockContextValues.plugins = undefined mockMarketplaceData.plugins = undefined
mockContextValues.pluginsTotal = 0 mockMarketplaceData.pluginsTotal = 0
mockContextValues.isLoading = false mockMarketplaceData.isLoading = false
mockContextValues.page = 1 mockMarketplaceData.page = 1
mockContextValues.marketplaceCollectionsFromClient = undefined mockMarketplaceData.marketplaceCollections = undefined
mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined mockMarketplaceData.marketplaceCollectionPluginsMap = undefined
}) })
it('should transition from loading to showing collections', async () => { it('should transition from loading to showing collections', async () => {
mockContextValues.isLoading = true mockMarketplaceData.isLoading = true
mockContextValues.page = 1 mockMarketplaceData.page = 1
const { rerender } = render( const { rerender } = render(<ListWrapper />)
<ListWrapper
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
/>,
)
expect(screen.getByTestId('loading-component')).toBeInTheDocument() expect(screen.getByTestId('loading-component')).toBeInTheDocument()
// Simulate loading complete // Simulate loading complete
mockContextValues.isLoading = false mockMarketplaceData.isLoading = false
const collections = createMockCollectionList(1) mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
const pluginsMap: Record<string, Plugin[]> = { mockMarketplaceData.marketplaceCollectionPluginsMap = {
'collection-0': createMockPluginList(1), 'collection-0': createMockPluginList(1),
} }
mockContextValues.marketplaceCollectionsFromClient = collections
mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap
rerender( rerender(<ListWrapper />)
<ListWrapper
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
/>,
)
expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument() expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
expect(screen.getByText('Collection 0')).toBeInTheDocument() expect(screen.getByText('Collection 0')).toBeInTheDocument()
}) })
it('should transition from collections to search results', async () => { it('should transition from collections to search results', async () => {
const collections = createMockCollectionList(1) mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
const pluginsMap: Record<string, Plugin[]> = { mockMarketplaceData.marketplaceCollectionPluginsMap = {
'collection-0': createMockPluginList(1), 'collection-0': createMockPluginList(1),
} }
mockContextValues.marketplaceCollectionsFromClient = collections
mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap
const { rerender } = render( const { rerender } = render(<ListWrapper />)
<ListWrapper
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
/>,
)
expect(screen.getByText('Collection 0')).toBeInTheDocument() expect(screen.getByText('Collection 0')).toBeInTheDocument()
// Simulate search results // Simulate search results
mockContextValues.plugins = createMockPluginList(5) mockMarketplaceData.plugins = createMockPluginList(5)
mockContextValues.pluginsTotal = 5 mockMarketplaceData.pluginsTotal = 5
rerender( rerender(<ListWrapper />)
<ListWrapper
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
/>,
)
expect(screen.queryByText('Collection 0')).not.toBeInTheDocument() expect(screen.queryByText('Collection 0')).not.toBeInTheDocument()
expect(screen.getByText('5 plugins found')).toBeInTheDocument() expect(screen.getByText('5 plugins found')).toBeInTheDocument()
}) })
it('should handle empty search results', () => { it('should handle empty search results', () => {
mockContextValues.plugins = [] mockMarketplaceData.plugins = []
mockContextValues.pluginsTotal = 0 mockMarketplaceData.pluginsTotal = 0
render( render(<ListWrapper />)
<ListWrapper
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
/>,
)
expect(screen.getByTestId('empty-component')).toBeInTheDocument() expect(screen.getByTestId('empty-component')).toBeInTheDocument()
expect(screen.getByText('0 plugins found')).toBeInTheDocument() expect(screen.getByText('0 plugins found')).toBeInTheDocument()
}) })
it('should support pagination (page > 1)', () => { it('should support pagination (page > 1)', () => {
mockContextValues.plugins = createMockPluginList(40) mockMarketplaceData.plugins = createMockPluginList(40)
mockContextValues.pluginsTotal = 80 mockMarketplaceData.pluginsTotal = 80
mockContextValues.isLoading = true mockMarketplaceData.isLoading = true
mockContextValues.page = 2 mockMarketplaceData.page = 2
render( render(<ListWrapper />)
<ListWrapper
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
/>,
)
// Should show existing results while loading more // Should show existing results while loading more
expect(screen.getByText('80 plugins found')).toBeInTheDocument() expect(screen.getByText('80 plugins found')).toBeInTheDocument()
@ -1542,9 +1339,9 @@ describe('Combined Workflows', () => {
describe('Accessibility', () => { describe('Accessibility', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockContextValues.plugins = undefined mockMarketplaceData.plugins = undefined
mockContextValues.isLoading = false mockMarketplaceData.isLoading = false
mockContextValues.page = 1 mockMarketplaceData.page = 1
}) })
it('should have semantic structure with collections', () => { it('should have semantic structure with collections', () => {
@ -1573,13 +1370,11 @@ describe('Accessibility', () => {
const pluginsMap: Record<string, Plugin[]> = { const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1), 'collection-0': createMockPluginList(1),
} }
const onMoreClick = vi.fn()
render( render(
<ListWithCollection <ListWithCollection
marketplaceCollections={collections} marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap} marketplaceCollectionPluginsMap={pluginsMap}
onMoreClick={onMoreClick}
/>, />,
) )

View File

@ -13,7 +13,6 @@ type ListProps = {
showInstallButton?: boolean showInstallButton?: boolean
cardContainerClassName?: string cardContainerClassName?: string
cardRender?: (plugin: Plugin) => React.JSX.Element | null cardRender?: (plugin: Plugin) => React.JSX.Element | null
onMoreClick?: () => void
emptyClassName?: string emptyClassName?: string
} }
const List = ({ const List = ({
@ -23,7 +22,6 @@ const List = ({
showInstallButton, showInstallButton,
cardContainerClassName, cardContainerClassName,
cardRender, cardRender,
onMoreClick,
emptyClassName, emptyClassName,
}: ListProps) => { }: ListProps) => {
return ( return (
@ -36,7 +34,6 @@ const List = ({
showInstallButton={showInstallButton} showInstallButton={showInstallButton}
cardContainerClassName={cardContainerClassName} cardContainerClassName={cardContainerClassName}
cardRender={cardRender} cardRender={cardRender}
onMoreClick={onMoreClick}
/> />
) )
} }

View File

@ -1,12 +1,12 @@
'use client' 'use client'
import type { MarketplaceCollection } from '../types' import type { MarketplaceCollection } from '../types'
import type { SearchParamsFromCollection } from '@/app/components/plugins/marketplace/types'
import type { Plugin } from '@/app/components/plugins/types' import type { Plugin } from '@/app/components/plugins/types'
import { useLocale, useTranslation } from '#i18n' import { useLocale, useTranslation } from '#i18n'
import { RiArrowRightSLine } from '@remixicon/react' import { RiArrowRightSLine } from '@remixicon/react'
import { getLanguage } from '@/i18n-config/language' import { getLanguage } from '@/i18n-config/language'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { useMarketplaceMoreClick } from '../atoms'
import CardWrapper from './card-wrapper' import CardWrapper from './card-wrapper'
type ListWithCollectionProps = { type ListWithCollectionProps = {
@ -15,7 +15,6 @@ type ListWithCollectionProps = {
showInstallButton?: boolean showInstallButton?: boolean
cardContainerClassName?: string cardContainerClassName?: string
cardRender?: (plugin: Plugin) => React.JSX.Element | null cardRender?: (plugin: Plugin) => React.JSX.Element | null
onMoreClick?: (searchParams?: SearchParamsFromCollection) => void
} }
const ListWithCollection = ({ const ListWithCollection = ({
marketplaceCollections, marketplaceCollections,
@ -23,10 +22,10 @@ const ListWithCollection = ({
showInstallButton, showInstallButton,
cardContainerClassName, cardContainerClassName,
cardRender, cardRender,
onMoreClick,
}: ListWithCollectionProps) => { }: ListWithCollectionProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const locale = useLocale() const locale = useLocale()
const onMoreClick = useMarketplaceMoreClick()
return ( return (
<> <>
@ -44,10 +43,10 @@ const ListWithCollection = ({
<div className="system-xs-regular text-text-tertiary">{collection.description[getLanguage(locale)]}</div> <div className="system-xs-regular text-text-tertiary">{collection.description[getLanguage(locale)]}</div>
</div> </div>
{ {
collection.searchable && onMoreClick && ( collection.searchable && (
<div <div
className="system-xs-medium flex cursor-pointer items-center text-text-accent " 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' })} {t('marketplace.viewMore', { ns: 'plugin' })}
<RiArrowRightSLine className="h-4 w-4" /> <RiArrowRightSLine className="h-4 w-4" />

View File

@ -1,46 +1,26 @@
'use client' 'use client'
import type { Plugin } from '../../types'
import type { MarketplaceCollection } from '../types'
import { useTranslation } from '#i18n' import { useTranslation } from '#i18n'
import { useEffect } from 'react'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { useMarketplaceContext } from '../context'
import SortDropdown from '../sort-dropdown' import SortDropdown from '../sort-dropdown'
import { useMarketplaceData } from '../state'
import List from './index' import List from './index'
type ListWrapperProps = { type ListWrapperProps = {
marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
showInstallButton?: boolean showInstallButton?: boolean
} }
const ListWrapper = ({ const ListWrapper = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
showInstallButton, showInstallButton,
}: ListWrapperProps) => { }: ListWrapperProps) => {
const { t } = useTranslation() 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(() => { const {
if ( plugins,
!marketplaceCollectionsFromClient?.length pluginsTotal,
&& isSuccessCollections marketplaceCollections,
&& !searchPluginText marketplaceCollectionPluginsMap,
&& !filterPluginTags.length isLoading,
) { page,
handleQueryPlugins() } = useMarketplaceData()
}
}, [handleQueryPlugins, marketplaceCollections, marketplaceCollectionsFromClient, isSuccessCollections, searchPluginText, filterPluginTags])
return ( return (
<div <div
@ -66,11 +46,10 @@ const ListWrapper = ({
{ {
(!isLoading || page > 1) && ( (!isLoading || page > 1) && (
<List <List
marketplaceCollections={marketplaceCollectionsFromClient || marketplaceCollections} marketplaceCollections={marketplaceCollections || []}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMapFromClient || marketplaceCollectionPluginsMap} marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
plugins={plugins} plugins={plugins}
showInstallButton={showInstallButton} showInstallButton={showInstallButton}
onMoreClick={handleMoreClick}
/> />
) )
} }

View File

@ -1,4 +1,5 @@
'use client' 'use client'
import type { ActivePluginType } from './constants'
import { useTranslation } from '#i18n' import { useTranslation } from '#i18n'
import { import {
RiArchive2Line, RiArchive2Line,
@ -8,35 +9,27 @@ import {
RiPuzzle2Line, RiPuzzle2Line,
RiSpeakAiLine, RiSpeakAiLine,
} from '@remixicon/react' } 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 { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { PluginCategoryEnum } from '../types' import { searchModeAtom, useActivePluginType } from './atoms'
import { useMarketplaceContext } from './context' 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 = { type PluginTypeSwitchProps = {
className?: string className?: string
showSearchParams?: boolean
} }
const PluginTypeSwitch = ({ const PluginTypeSwitch = ({
className, className,
showSearchParams,
}: PluginTypeSwitchProps) => { }: PluginTypeSwitchProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const activePluginType = useMarketplaceContext(s => s.activePluginType) const [activePluginType, handleActivePluginTypeChange] = useActivePluginType()
const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange) const setSearchMode = useSetAtom(searchModeAtom)
const options = [ const options: Array<{
value: ActivePluginType
text: string
icon: React.ReactNode | null
}> = [
{ {
value: PLUGIN_TYPE_SEARCH_MAP.all, value: PLUGIN_TYPE_SEARCH_MAP.all,
text: t('category.all', { ns: 'plugin' }), 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 ( return (
<div className={cn( <div className={cn(
'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3', 'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
@ -112,6 +88,9 @@ const PluginTypeSwitch = ({
)} )}
onClick={() => { onClick={() => {
handleActivePluginTypeChange(option.value) handleActivePluginTypeChange(option.value)
if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) {
setSearchMode(null)
}
}} }}
> >
{option.icon} {option.icon}

View 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,
})
}

View File

@ -26,16 +26,19 @@ vi.mock('#i18n', () => ({
}), }),
})) }))
// Mock useMarketplaceContext // Mock marketplace state hooks
const mockContextValues = { const { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPluginTags, mockHandleFilterPluginTagsChange } = vi.hoisted(() => {
searchPluginText: '', return {
handleSearchPluginTextChange: vi.fn(), mockSearchPluginText: '',
filterPluginTags: [] as string[], mockHandleSearchPluginTextChange: vi.fn(),
handleFilterPluginTagsChange: vi.fn(), mockFilterPluginTags: [] as string[],
} mockHandleFilterPluginTagsChange: vi.fn(),
}
})
vi.mock('../context', () => ({ vi.mock('../atoms', () => ({
useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange],
useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange],
})) }))
// Mock useTags hook // Mock useTags hook
@ -430,9 +433,6 @@ describe('SearchBoxWrapper', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockPortalOpenState = false mockPortalOpenState = false
// Reset context values
mockContextValues.searchPluginText = ''
mockContextValues.filterPluginTags = []
}) })
describe('Rendering', () => { describe('Rendering', () => {
@ -456,28 +456,14 @@ describe('SearchBoxWrapper', () => {
}) })
}) })
describe('Context Integration', () => { describe('Hook Integration', () => {
it('should use searchPluginText from context', () => {
mockContextValues.searchPluginText = 'context search'
render(<SearchBoxWrapper />)
expect(screen.getByDisplayValue('context search')).toBeInTheDocument()
})
it('should call handleSearchPluginTextChange when search changes', () => { it('should call handleSearchPluginTextChange when search changes', () => {
render(<SearchBoxWrapper />) render(<SearchBoxWrapper />)
const input = screen.getByRole('textbox') const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new search' } }) fireEvent.change(input, { target: { value: 'new search' } })
expect(mockContextValues.handleSearchPluginTextChange).toHaveBeenCalledWith('new search') expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search')
})
it('should use filterPluginTags from context', () => {
mockContextValues.filterPluginTags = ['agent', 'rag']
render(<SearchBoxWrapper />)
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
}) })
}) })

View File

@ -1,15 +1,13 @@
'use client' 'use client'
import { useTranslation } from '#i18n' import { useTranslation } from '#i18n'
import { useMarketplaceContext } from '../context' import { useFilterPluginTags, useSearchPluginText } from '../atoms'
import SearchBox from './index' import SearchBox from './index'
const SearchBoxWrapper = () => { const SearchBoxWrapper = () => {
const { t } = useTranslation() const { t } = useTranslation()
const searchPluginText = useMarketplaceContext(v => v.searchPluginText) const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText()
const handleSearchPluginTextChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags()
const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags)
const handleFilterPluginTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange)
return ( return (
<SearchBox <SearchBox

View 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' }),
}

View File

@ -1,4 +1,3 @@
import type { MarketplaceContextValue } from '../context'
import { fireEvent, render, screen, within } from '@testing-library/react' import { fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -28,18 +27,12 @@ vi.mock('#i18n', () => ({
}), }),
})) }))
// Mock marketplace context with controllable values // Mock marketplace atoms with controllable values
let mockSort = { sortBy: 'install_count', sortOrder: 'DESC' } let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' }
const mockHandleSortChange = vi.fn() const mockHandleSortChange = vi.fn()
vi.mock('../context', () => ({ vi.mock('../atoms', () => ({
useMarketplaceContext: (selector: (value: MarketplaceContextValue) => unknown) => { useMarketplaceSort: () => [mockSort, mockHandleSortChange],
const contextValue = {
sort: mockSort,
handleSortChange: mockHandleSortChange,
} as unknown as MarketplaceContextValue
return selector(contextValue)
},
})) }))
// Mock portal component with controllable open state // Mock portal component with controllable open state

View File

@ -10,7 +10,7 @@ import {
PortalToFollowElemContent, PortalToFollowElemContent,
PortalToFollowElemTrigger, PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem' } from '@/app/components/base/portal-to-follow-elem'
import { useMarketplaceContext } from '../context' import { useMarketplaceSort } from '../atoms'
const SortDropdown = () => { const SortDropdown = () => {
const { t } = useTranslation() const { t } = useTranslation()
@ -36,8 +36,7 @@ const SortDropdown = () => {
text: t('marketplace.sortOption.firstReleased', { ns: 'plugin' }), text: t('marketplace.sortOption.firstReleased', { ns: 'plugin' }),
}, },
] ]
const sort = useMarketplaceContext(v => v.sort) const [sort, handleSortChange] = useMarketplaceSort()
const handleSortChange = useMarketplaceContext(v => v.handleSortChange)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0] const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0]

View 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,
}
}

View File

@ -6,12 +6,10 @@ import SearchBoxWrapper from './search-box/search-box-wrapper'
type StickySearchAndSwitchWrapperProps = { type StickySearchAndSwitchWrapperProps = {
pluginTypeSwitchClassName?: string pluginTypeSwitchClassName?: string
showSearchParams?: boolean
} }
const StickySearchAndSwitchWrapper = ({ const StickySearchAndSwitchWrapper = ({
pluginTypeSwitchClassName, pluginTypeSwitchClassName,
showSearchParams,
}: StickySearchAndSwitchWrapperProps) => { }: StickySearchAndSwitchWrapperProps) => {
const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-') const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-')
@ -24,9 +22,7 @@ const StickySearchAndSwitchWrapper = ({
)} )}
> >
<SearchBoxWrapper /> <SearchBoxWrapper />
<PluginTypeSwitch <PluginTypeSwitch />
showSearchParams={showSearchParams}
/>
</div> </div>
) )
} }

View File

@ -1,16 +1,19 @@
import type { ActivePluginType } from './constants'
import type { import type {
CollectionsAndPluginsSearchParams, CollectionsAndPluginsSearchParams,
MarketplaceCollection, MarketplaceCollection,
PluginsSearchParams,
} from '@/app/components/plugins/marketplace/types' } 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 { PluginCategoryEnum } from '@/app/components/plugins/types'
import { import {
APP_VERSION, APP_VERSION,
IS_MARKETPLACE, IS_MARKETPLACE,
MARKETPLACE_API_PREFIX, MARKETPLACE_API_PREFIX,
} from '@/config' } from '@/config'
import { postMarketplace } from '@/service/base'
import { getMarketplaceUrl } from '@/utils/var' import { getMarketplaceUrl } from '@/utils/var'
import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch' import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
type MarketplaceFetchOptions = { type MarketplaceFetchOptions = {
signal?: AbortSignal signal?: AbortSignal
@ -26,12 +29,13 @@ export const getPluginIconInMarketplace = (plugin: Plugin) => {
return `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon` 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') { if (bundle.type === 'bundle') {
return { return {
...bundle, ...bundle,
icon: getPluginIconInMarketplace(bundle), icon: getPluginIconInMarketplace(bundle),
brief: bundle.description, brief: bundle.description,
// @ts-expect-error I do not have enough information
label: bundle.labels, 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) => { export const getMarketplaceListCondition = (pluginType: string) => {
if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum)) if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum))
return `category=${pluginType}` return `category=${pluginType}`
@ -142,7 +204,7 @@ export const getMarketplaceListCondition = (pluginType: string) => {
return '' return ''
} }
export const getMarketplaceListFilterType = (category: string) => { export const getMarketplaceListFilterType = (category: ActivePluginType) => {
if (category === PLUGIN_TYPE_SEARCH_MAP.all) if (category === PLUGIN_TYPE_SEARCH_MAP.all)
return undefined return undefined
@ -151,3 +213,14 @@ export const getMarketplaceListFilterType = (category: string) => {
return 'plugin' return 'plugin'
} }
export function getCollectionsParams(category: ActivePluginType): CollectionsAndPluginsSearchParams {
if (category === PLUGIN_TYPE_SEARCH_MAP.all) {
return {}
}
return {
category,
condition: getMarketplaceListCondition(category),
type: getMarketplaceListFilterType(category),
}
}

View File

@ -27,7 +27,7 @@ import { cn } from '@/utils/classnames'
import { PLUGIN_PAGE_TABS_MAP } from '../hooks' import { PLUGIN_PAGE_TABS_MAP } from '../hooks'
import InstallFromLocalPackage from '../install-plugin/install-from-local-package' import InstallFromLocalPackage from '../install-plugin/install-from-local-package'
import InstallFromMarketplace from '../install-plugin/install-from-marketplace' 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 { import {
PluginPageContextProvider, PluginPageContextProvider,
usePluginPageContext, usePluginPageContext,

View File

@ -262,7 +262,7 @@ vi.mock('@/app/components/base/icons/src/vender/other', () => ({
})) }))
// Mock PLUGIN_TYPE_SEARCH_MAP // Mock PLUGIN_TYPE_SEARCH_MAP
vi.mock('../../marketplace/plugin-type-switch', () => ({ vi.mock('../../marketplace/constants', () => ({
PLUGIN_TYPE_SEARCH_MAP: { PLUGIN_TYPE_SEARCH_MAP: {
all: 'all', all: 'all',
model: 'model', model: 'model',

View File

@ -1,5 +1,6 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import type { ActivePluginType } from '../../marketplace/constants'
import * as React from 'react' import * as React from 'react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -12,7 +13,7 @@ import {
import SearchBox from '@/app/components/plugins/marketplace/search-box' import SearchBox from '@/app/components/plugins/marketplace/search-box'
import { useInstalledPluginList } from '@/service/use-plugins' import { useInstalledPluginList } from '@/service/use-plugins'
import { cn } from '@/utils/classnames' 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 { PluginSource } from '../../types'
import NoDataPlaceholder from './no-data-placeholder' import NoDataPlaceholder from './no-data-placeholder'
import ToolItem from './tool-item' 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 [query, setQuery] = useState('')
const [tags, setTags] = useState<string[]>([]) const [tags, setTags] = useState<string[]>([])
const { data, isLoading } = useInstalledPluginList() const { data, isLoading } = useInstalledPluginList()

View File

@ -0,0 +1,3 @@
'use client'
export { SerwistProvider } from '@serwist/turbopack/react'

View File

@ -195,7 +195,7 @@ const RunOnce: FC<IRunOnceProps> = ({
noWrapper noWrapper
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1" className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
placeholder={ 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>
} }
/> />
)} )}

View File

@ -13,6 +13,7 @@ import Tooltip from '@/app/components/base/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action' import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
import { useGetLanguage } from '@/context/i18n' import { useGetLanguage } from '@/context/i18n'
import { isServer } from '@/utils/client'
import { formatNumber } from '@/utils/format' import { formatNumber } from '@/utils/format'
import { getMarketplaceUrl } from '@/utils/var' import { getMarketplaceUrl } from '@/utils/var'
import BlockIcon from '../block-icon' import BlockIcon from '../block-icon'
@ -49,14 +50,14 @@ const FeaturedTools = ({
const language = useGetLanguage() const language = useGetLanguage()
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT) const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT)
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => { const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (typeof window === 'undefined') if (isServer)
return false return false
const stored = window.localStorage.getItem(STORAGE_KEY) const stored = window.localStorage.getItem(STORAGE_KEY)
return stored === 'true' return stored === 'true'
}) })
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') if (isServer)
return return
const stored = window.localStorage.getItem(STORAGE_KEY) const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored !== null) if (stored !== null)
@ -64,7 +65,7 @@ const FeaturedTools = ({
}, []) }, [])
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') if (isServer)
return return
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
}, [isCollapsed]) }, [isCollapsed])

View File

@ -12,6 +12,7 @@ import Tooltip from '@/app/components/base/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action' import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
import { useGetLanguage } from '@/context/i18n' import { useGetLanguage } from '@/context/i18n'
import { isServer } from '@/utils/client'
import { formatNumber } from '@/utils/format' import { formatNumber } from '@/utils/format'
import { getMarketplaceUrl } from '@/utils/var' import { getMarketplaceUrl } from '@/utils/var'
import BlockIcon from '../block-icon' import BlockIcon from '../block-icon'
@ -42,14 +43,14 @@ const FeaturedTriggers = ({
const language = useGetLanguage() const language = useGetLanguage()
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT) const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT)
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => { const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (typeof window === 'undefined') if (isServer)
return false return false
const stored = window.localStorage.getItem(STORAGE_KEY) const stored = window.localStorage.getItem(STORAGE_KEY)
return stored === 'true' return stored === 'true'
}) })
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') if (isServer)
return return
const stored = window.localStorage.getItem(STORAGE_KEY) const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored !== null) if (stored !== null)
@ -57,7 +58,7 @@ const FeaturedTriggers = ({
}, []) }, [])
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') if (isServer)
return return
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
}, [isCollapsed]) }, [isCollapsed])

View File

@ -11,6 +11,7 @@ import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils'
import { useRAGRecommendedPlugins } from '@/service/use-tools' import { useRAGRecommendedPlugins } from '@/service/use-tools'
import { isServer } from '@/utils/client'
import { getMarketplaceUrl } from '@/utils/var' import { getMarketplaceUrl } from '@/utils/var'
import List from './list' import List from './list'
@ -29,14 +30,14 @@ const RAGToolRecommendations = ({
}: RAGToolRecommendationsProps) => { }: RAGToolRecommendationsProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => { const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (typeof window === 'undefined') if (isServer)
return false return false
const stored = window.localStorage.getItem(STORAGE_KEY) const stored = window.localStorage.getItem(STORAGE_KEY)
return stored === 'true' return stored === 'true'
}) })
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') if (isServer)
return return
const stored = window.localStorage.getItem(STORAGE_KEY) const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored !== null) if (stored !== null)
@ -44,7 +45,7 @@ const RAGToolRecommendations = ({
}, []) }, [])
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') if (isServer)
return return
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed)) window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
}, [isCollapsed]) }, [isCollapsed])

View File

@ -48,6 +48,12 @@ const FormItem: FC<Props> = ({
const { t } = useTranslation() const { t } = useTranslation()
const { type } = payload const { type } = payload
const fileSettings = useHooksStore(s => s.configsMap?.fileSettings) 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) => { const handleArrayItemChange = useCallback((index: number) => {
return (newValue: any) => { return (newValue: any) => {
@ -211,7 +217,7 @@ const FormItem: FC<Props> = ({
noWrapper noWrapper
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1" className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
placeholder={ placeholder={
<div className="whitespace-pre">{payload.json_schema}</div> <div className="whitespace-pre">{jsonSchemaPlaceholder}</div>
} }
/> />
)} )}

View File

@ -356,7 +356,7 @@ const formatItem = (
try { try {
if (type === VarType.object && v.json_schema) { if (type === VarType.object && v.json_schema) {
varRes.children = { varRes.children = {
schema: JSON.parse(v.json_schema), schema: typeof v.json_schema === 'string' ? JSON.parse(v.json_schema) : v.json_schema,
} }
} }
} }

View File

@ -230,7 +230,7 @@ export type InputVar = {
getVarValueFromDependent?: boolean getVarValueFromDependent?: boolean
hide?: boolean hide?: boolean
isFileItem?: boolean isFileItem?: boolean
json_schema?: string // for jsonObject type json_schema?: string | Record<string, any> // for jsonObject type
} & Partial<UploadFileSetting> } & Partial<UploadFileSetting>
export type ModelConfig = { export type ModelConfig = {

View File

@ -12,6 +12,7 @@ import { ToastProvider } from './components/base/toast'
import BrowserInitializer from './components/browser-initializer' import BrowserInitializer from './components/browser-initializer'
import { ReactScanLoader } from './components/devtools/react-scan/loader' import { ReactScanLoader } from './components/devtools/react-scan/loader'
import { I18nServerProvider } from './components/provider/i18n-server' import { I18nServerProvider } from './components/provider/i18n-server'
import { SerwistProvider } from './components/provider/serwist'
import SentryInitializer from './components/sentry-initializer' import SentryInitializer from './components/sentry-initializer'
import RoutePrefixHandle from './routePrefixHandle' import RoutePrefixHandle from './routePrefixHandle'
import './styles/globals.css' import './styles/globals.css'
@ -39,6 +40,9 @@ const LocaleLayout = async ({
}) => { }) => {
const locale = await getLocaleOnServer() const locale = await getLocaleOnServer()
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''
const swUrl = `${basePath}/serwist/sw.js`
const datasetMap: Record<DatasetAttr, string | undefined> = { const datasetMap: Record<DatasetAttr, string | undefined> = {
[DatasetAttr.DATA_API_PREFIX]: process.env.NEXT_PUBLIC_API_PREFIX, [DatasetAttr.DATA_API_PREFIX]: process.env.NEXT_PUBLIC_API_PREFIX,
[DatasetAttr.DATA_PUBLIC_API_PREFIX]: process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX, [DatasetAttr.DATA_PUBLIC_API_PREFIX]: process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX,
@ -92,33 +96,35 @@ const LocaleLayout = async ({
className="color-scheme h-full select-auto" className="color-scheme h-full select-auto"
{...datasetMap} {...datasetMap}
> >
<ReactScanLoader /> <SerwistProvider swUrl={swUrl}>
<JotaiProvider> <ReactScanLoader />
<ThemeProvider <JotaiProvider>
attribute="data-theme" <ThemeProvider
defaultTheme="system" attribute="data-theme"
enableSystem defaultTheme="system"
disableTransitionOnChange enableSystem
enableColorScheme={false} disableTransitionOnChange
> enableColorScheme={false}
<NuqsAdapter> >
<BrowserInitializer> <NuqsAdapter>
<SentryInitializer> <BrowserInitializer>
<TanstackQueryInitializer> <SentryInitializer>
<I18nServerProvider> <TanstackQueryInitializer>
<ToastProvider> <I18nServerProvider>
<GlobalPublicStoreProvider> <ToastProvider>
{children} <GlobalPublicStoreProvider>
</GlobalPublicStoreProvider> {children}
</ToastProvider> </GlobalPublicStoreProvider>
</I18nServerProvider> </ToastProvider>
</TanstackQueryInitializer> </I18nServerProvider>
</SentryInitializer> </TanstackQueryInitializer>
</BrowserInitializer> </SentryInitializer>
</NuqsAdapter> </BrowserInitializer>
</ThemeProvider> </NuqsAdapter>
</JotaiProvider> </ThemeProvider>
<RoutePrefixHandle /> </JotaiProvider>
<RoutePrefixHandle />
</SerwistProvider>
</body> </body>
</html> </html>
) )

View File

@ -0,0 +1,14 @@
import { spawnSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { createSerwistRoute } from '@serwist/turbopack'
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''
const revision = spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf-8' }).stdout?.trim() || randomUUID()
export const { dynamic, dynamicParams, revalidate, generateStaticParams, GET } = createSerwistRoute({
additionalPrecacheEntries: [{ url: `${basePath}/_offline.html`, revision }],
swSrc: 'app/sw.ts',
nextConfig: {
basePath,
},
})

104
web/app/sw.ts Normal file
View File

@ -0,0 +1,104 @@
/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'
import { CacheableResponsePlugin, CacheFirst, ExpirationPlugin, NetworkFirst, Serwist, StaleWhileRevalidate } from 'serwist'
declare global {
// eslint-disable-next-line ts/consistent-type-definitions
interface WorkerGlobalScope extends SerwistGlobalConfig {
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined
}
}
declare const self: ServiceWorkerGlobalScope
const scopePathname = new URL(self.registration.scope).pathname
const basePath = scopePathname.replace(/\/serwist\/$/, '').replace(/\/$/, '')
const offlineUrl = `${basePath}/_offline.html`
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: [
{
matcher: ({ url }) => url.origin === 'https://fonts.googleapis.com',
handler: new CacheFirst({
cacheName: 'google-fonts',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60,
}),
],
}),
},
{
matcher: ({ url }) => url.origin === 'https://fonts.gstatic.com',
handler: new CacheFirst({
cacheName: 'google-fonts-webfonts',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60,
}),
],
}),
},
{
matcher: ({ request }) => request.destination === 'image',
handler: new CacheFirst({
cacheName: 'images',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 64,
maxAgeSeconds: 30 * 24 * 60 * 60,
}),
],
}),
},
{
matcher: ({ request }) => request.destination === 'script' || request.destination === 'style',
handler: new StaleWhileRevalidate({
cacheName: 'static-resources',
plugins: [
new ExpirationPlugin({
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60,
}),
],
}),
},
{
matcher: ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith('/api/'),
handler: new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
plugins: [
new ExpirationPlugin({
maxEntries: 16,
maxAgeSeconds: 60 * 60,
}),
],
}),
},
],
fallbacks: {
entries: [
{
url: offlineUrl,
matcher({ request }) {
return request.destination === 'document'
},
},
],
},
})
serwist.addEventListeners()

View File

@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { NUM_INFINITE } from '@/app/components/billing/config' import { NUM_INFINITE } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { IS_CLOUD_EDITION } from '@/config' import { IS_CLOUD_EDITION } from '@/config'
import { isServer } from '@/utils/client'
export type TriggerEventsLimitModalPayload = { export type TriggerEventsLimitModalPayload = {
usage: number usage: number
@ -46,7 +47,7 @@ export const useTriggerEventsLimitModal = ({
useEffect(() => { useEffect(() => {
if (!IS_CLOUD_EDITION) if (!IS_CLOUD_EDITION)
return return
if (typeof window === 'undefined') if (isServer)
return return
if (!currentWorkspaceId) if (!currentWorkspaceId)
return return

View 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)

View File

@ -1,23 +1,28 @@
'use client' 'use client'
import type { QueryClient } from '@tanstack/react-query'
import type { FC, PropsWithChildren } from 'react' 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 { TanStackDevtoolsLoader } from '@/app/components/devtools/tanstack/loader'
import { isServer } from '@/utils/client'
import { makeQueryClient } from './query-client-server'
const STALE_TIME = 1000 * 60 * 30 // 30 minutes let browserQueryClient: QueryClient | undefined
const client = new QueryClient({ function getQueryClient() {
defaultOptions: { if (isServer) {
queries: { return makeQueryClient()
staleTime: STALE_TIME, }
}, if (!browserQueryClient)
}, browserQueryClient = makeQueryClient()
}) return browserQueryClient
}
export const TanstackQueryInitializer: FC<PropsWithChildren> = (props) => { export const TanstackQueryInitializer: FC<PropsWithChildren> = ({ children }) => {
const { children } = props const [queryClient] = useState(getQueryClient)
return ( return (
<QueryClientProvider client={client}> <QueryClientProvider client={queryClient}>
{children} {children}
<TanStackDevtoolsLoader /> <TanStackDevtoolsLoader />
</QueryClientProvider> </QueryClientProvider>

View File

@ -8,11 +8,17 @@ import {
PRICING_MODAL_QUERY_PARAM, PRICING_MODAL_QUERY_PARAM,
PRICING_MODAL_QUERY_VALUE, PRICING_MODAL_QUERY_VALUE,
useAccountSettingModal, useAccountSettingModal,
useMarketplaceFilters,
usePluginInstallation, usePluginInstallation,
usePricingModal, usePricingModal,
} from './use-query-params' } from './use-query-params'
// Mock isServer to allow runtime control in tests
const mockIsServer = vi.hoisted(() => ({ value: false }))
vi.mock('@/utils/client', () => ({
get isServer() { return mockIsServer.value },
get isClient() { return !mockIsServer.value },
}))
const renderWithAdapter = <T,>(hook: () => T, searchParams = '') => { const renderWithAdapter = <T,>(hook: () => T, searchParams = '') => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>() const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
const wrapper = ({ children }: { children: ReactNode }) => ( const wrapper = ({ children }: { children: ReactNode }) => (
@ -302,174 +308,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. // Plugin installation query behavior.
describe('usePluginInstallation', () => { describe('usePluginInstallation', () => {
it('should parse package ids from JSON arrays', () => { it('should parse package ids from JSON arrays', () => {
@ -597,6 +435,7 @@ describe('clearQueryParams', () => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals() vi.unstubAllGlobals()
mockIsServer.value = false
}) })
it('should remove a single key when provided one key', () => { it('should remove a single key when provided one key', () => {
@ -632,13 +471,13 @@ describe('clearQueryParams', () => {
replaceSpy.mockRestore() replaceSpy.mockRestore()
}) })
it('should no-op when window is undefined', () => { it('should no-op when running on server', () => {
// Arrange // Arrange
const replaceSpy = vi.spyOn(window.history, 'replaceState') const replaceSpy = vi.spyOn(window.history, 'replaceState')
vi.stubGlobal('window', undefined) mockIsServer.value = true
// Act // Act
expect(() => clearQueryParams('foo')).not.toThrow() clearQueryParams('foo')
// Assert // Assert
expect(replaceSpy).not.toHaveBeenCalled() expect(replaceSpy).not.toHaveBeenCalled()

View File

@ -15,13 +15,13 @@
import { import {
createParser, createParser,
parseAsArrayOf,
parseAsString, parseAsString,
useQueryState, useQueryState,
useQueryStates, useQueryStates,
} from 'nuqs' } from 'nuqs'
import { useCallback } from 'react' import { useCallback } from 'react'
import { ACCOUNT_SETTING_MODAL_ACTION } from '@/app/components/header/account-setting/constants' import { ACCOUNT_SETTING_MODAL_ACTION } from '@/app/components/header/account-setting/constants'
import { isServer } from '@/utils/client'
/** /**
* Modal State Query Parameters * Modal State Query Parameters
@ -93,39 +93,6 @@ export function useAccountSettingModal<T extends string = string>() {
return [{ isOpen, payload: currentTab }, setState] as const 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 * Plugin Installation Query Parameters
*/ */
@ -210,7 +177,7 @@ export function usePluginInstallation() {
* clearQueryParams(['param1', 'param2']) * clearQueryParams(['param1', 'param2'])
*/ */
export function clearQueryParams(keys: string | string[]) { export function clearQueryParams(keys: string | string[]) {
if (typeof window === 'undefined') if (isServer)
return return
const url = new URL(window.location.href) const url = new URL(window.location.href)

View File

@ -15,10 +15,7 @@ const config: KnipConfig = {
ignoreBinaries: [ ignoreBinaries: [
'only-allow', 'only-allow',
], ],
ignoreDependencies: [ ignoreDependencies: [],
// required by next-pwa
'babel-loader',
],
rules: { rules: {
files: 'warn', files: 'warn',
dependencies: 'warn', dependencies: 'warn',

View File

@ -62,7 +62,7 @@ export type PromptVariable = {
icon?: string icon?: string
icon_background?: string icon_background?: string
hide?: boolean // used in frontend to hide variable hide?: boolean // used in frontend to hide variable
json_schema?: string json_schema?: string | Record<string, any>
} }
export type CompletionParams = { export type CompletionParams = {

View File

@ -1,77 +1,8 @@
import withBundleAnalyzerInit from '@next/bundle-analyzer' import withBundleAnalyzerInit from '@next/bundle-analyzer'
import createMDX from '@next/mdx' import createMDX from '@next/mdx'
import { codeInspectorPlugin } from 'code-inspector-plugin' import { codeInspectorPlugin } from 'code-inspector-plugin'
import withPWAInit from 'next-pwa'
const isDev = process.env.NODE_ENV === 'development' const isDev = process.env.NODE_ENV === 'development'
const withPWA = withPWAInit({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
fallbacks: {
document: '/_offline.html',
},
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts',
expiration: {
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
},
},
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-webfonts',
expiration: {
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
},
},
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|avif)$/i,
handler: 'CacheFirst',
options: {
cacheName: 'images',
expiration: {
maxEntries: 64,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
},
},
},
{
urlPattern: /\.(?:js|css)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-resources',
expiration: {
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
},
},
{
urlPattern: /^\/api\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 16,
maxAgeSeconds: 60 * 60, // 1 hour
},
},
},
],
})
const withMDX = createMDX({ const withMDX = createMDX({
extension: /\.mdx?$/, extension: /\.mdx?$/,
options: { options: {
@ -97,6 +28,7 @@ const remoteImageURLs = [hasSetWebPrefix ? new URL(`${process.env.NEXT_PUBLIC_WE
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
basePath: process.env.NEXT_PUBLIC_BASE_PATH || '', basePath: process.env.NEXT_PUBLIC_BASE_PATH || '',
serverExternalPackages: ['esbuild-wasm'],
transpilePackages: ['echarts', 'zrender'], transpilePackages: ['echarts', 'zrender'],
turbopack: { turbopack: {
rules: codeInspectorPlugin({ rules: codeInspectorPlugin({
@ -148,4 +80,4 @@ const nextConfig = {
}, },
} }
export default withPWA(withBundleAnalyzer(withMDX(nextConfig))) export default withBundleAnalyzer(withMDX(nextConfig))

View File

@ -11,7 +11,7 @@
} }
}, },
"engines": { "engines": {
"node": ">=v22.11.0" "node": ">=22.12.0"
}, },
"browserslist": [ "browserslist": [
"last 1 Chrome version", "last 1 Chrome version",
@ -111,7 +111,6 @@
"mitt": "^3.0.1", "mitt": "^3.0.1",
"negotiator": "^1.0.0", "negotiator": "^1.0.0",
"next": "~15.5.9", "next": "~15.5.9",
"next-pwa": "^5.6.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nuqs": "^2.8.6", "nuqs": "^2.8.6",
"pinyin-pro": "^3.27.0", "pinyin-pro": "^3.27.0",
@ -153,7 +152,6 @@
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^6.7.3", "@antfu/eslint-config": "^6.7.3",
"@babel/core": "^7.28.4",
"@chromatic-com/storybook": "^4.1.1", "@chromatic-com/storybook": "^4.1.1",
"@eslint-react/eslint-plugin": "^2.3.13", "@eslint-react/eslint-plugin": "^2.3.13",
"@mdx-js/loader": "^3.1.1", "@mdx-js/loader": "^3.1.1",
@ -162,12 +160,13 @@
"@next/eslint-plugin-next": "15.5.9", "@next/eslint-plugin-next": "15.5.9",
"@next/mdx": "15.5.9", "@next/mdx": "15.5.9",
"@rgrove/parse-xml": "^4.2.0", "@rgrove/parse-xml": "^4.2.0",
"@serwist/turbopack": "^9.5.0",
"@storybook/addon-docs": "9.1.13", "@storybook/addon-docs": "9.1.13",
"@storybook/addon-links": "9.1.13", "@storybook/addon-links": "9.1.13",
"@storybook/addon-onboarding": "9.1.13", "@storybook/addon-onboarding": "9.1.13",
"@storybook/addon-themes": "9.1.13", "@storybook/addon-themes": "9.1.13",
"@storybook/nextjs": "9.1.13", "@storybook/nextjs": "9.1.13",
"@storybook/react": "9.1.13", "@storybook/react": "9.1.17",
"@tanstack/eslint-plugin-query": "^5.91.2", "@tanstack/eslint-plugin-query": "^5.91.2",
"@tanstack/react-devtools": "^0.9.0", "@tanstack/react-devtools": "^0.9.0",
"@tanstack/react-form-devtools": "^0.2.9", "@tanstack/react-form-devtools": "^0.2.9",
@ -194,9 +193,9 @@
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "4.0.16", "@vitest/coverage-v8": "4.0.16",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"babel-loader": "^10.0.0",
"code-inspector-plugin": "1.2.9", "code-inspector-plugin": "1.2.9",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"esbuild-wasm": "^0.27.2",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-react-refresh": "^0.4.26",
@ -212,6 +211,7 @@
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react-scan": "^0.4.3", "react-scan": "^0.4.3",
"sass": "^1.93.2", "sass": "^1.93.2",
"serwist": "^9.5.0",
"storybook": "9.1.17", "storybook": "9.1.17",
"tailwindcss": "^3.4.18", "tailwindcss": "^3.4.18",
"tsx": "^4.21.0", "tsx": "^4.21.0",

1171
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -72,12 +72,12 @@ async function getNewAccessToken(timeout: number): Promise<void> {
} }
function releaseRefreshLock() { function releaseRefreshLock() {
if (isRefreshing) { // Always clear the refresh lock to avoid cross-tab deadlocks.
isRefreshing = false // This is safe to call multiple times and from tabs that were only waiting.
globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY) isRefreshing = false
globalThis.localStorage.removeItem('last_refresh_time') globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY)
globalThis.removeEventListener('beforeunload', releaseRefreshLock) globalThis.localStorage.removeItem('last_refresh_time')
} globalThis.removeEventListener('beforeunload', releaseRefreshLock)
} }
export async function refreshAccessTokenOrReLogin(timeout: number) { export async function refreshAccessTokenOrReLogin(timeout: number) {

View File

@ -10,13 +10,14 @@ import type {
AppVoicesListResponse, AppVoicesListResponse,
WorkflowDailyConversationsResponse, WorkflowDailyConversationsResponse,
} from '@/models/app' } from '@/models/app'
import type { App, AppModeEnum } from '@/types/app' import type { App } from '@/types/app'
import { import {
keepPreviousData, keepPreviousData,
useInfiniteQuery, useInfiniteQuery,
useQuery, useQuery,
useQueryClient, useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import { AppModeEnum } from '@/types/app'
import { get, post } from './base' import { get, post } from './base'
import { useInvalid } from './use-base' import { useInvalid } from './use-base'
@ -36,6 +37,16 @@ type DateRangeParams = {
end?: string 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 normalizeAppListParams = (params: AppListParams) => {
const { const {
page = 1, page = 1,
@ -46,11 +57,13 @@ const normalizeAppListParams = (params: AppListParams) => {
is_created_by_me, is_created_by_me,
} = params } = params
const safeMode = allowedModes.has((mode as any)) ? mode : undefined
return { return {
page, page,
limit, limit,
name, name,
...(mode && mode !== 'all' ? { mode } : {}), ...(safeMode && safeMode !== 'all' ? { mode: safeMode } : {}),
...(tag_ids?.length ? { tag_ids } : {}), ...(tag_ids?.length ? { tag_ids } : {}),
...(is_created_by_me ? { is_created_by_me } : {}), ...(is_created_by_me ? { is_created_by_me } : {}),
} }

View File

@ -66,7 +66,30 @@ export const sanitizeWorkflowDraftPayload = (params: WorkflowDraftSyncParams): W
if (!graph?.nodes?.length) if (!graph?.nodes?.length)
return params 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 { return {
...params, ...params,
@ -126,7 +149,25 @@ export const hydrateWorkflowDraftResponse = (draft: FetchWorkflowDraftResponse):
if (node.data) if (node.data)
removeTempProperties(node.data as Record<string, unknown>) 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
}) })
} }

View File

@ -10,6 +10,7 @@ import type {
} from '@/types/workflow' } from '@/types/workflow'
import { get, post } from './base' import { get, post } from './base'
import { getFlowPrefix } from './utils' import { getFlowPrefix } from './utils'
import { sanitizeWorkflowDraftPayload } from './workflow-payload'
export const fetchWorkflowDraft = (url: string) => { export const fetchWorkflowDraft = (url: string) => {
return get(url, {}, { silent: true }) as Promise<FetchWorkflowDraftResponse> return get(url, {}, { silent: true }) as Promise<FetchWorkflowDraftResponse>
@ -19,7 +20,8 @@ export const syncWorkflowDraft = ({ url, params }: {
url: string url: string
params: Pick<FetchWorkflowDraftResponse, 'graph' | 'features' | 'environment_variables' | 'conversation_variables'> 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) => { export const fetchNodesDefaultConfigs = (url: string) => {

3
web/utils/client.ts Normal file
View File

@ -0,0 +1,3 @@
export const isServer = typeof window === 'undefined'
export const isClient = typeof window !== 'undefined'

View File

@ -1,3 +1,5 @@
import { isServer } from '@/utils/client'
/** /**
* Send Google Analytics event * Send Google Analytics event
* @param eventName - event name * @param eventName - event name
@ -7,7 +9,7 @@ export const sendGAEvent = (
eventName: string, eventName: string,
eventParams?: GtagEventParams, eventParams?: GtagEventParams,
): void => { ): void => {
if (typeof window === 'undefined' || typeof (window as any).gtag !== 'function') { if (isServer || typeof (window as any).gtag !== 'function') {
return return
} }
(window as any).gtag('event', eventName, eventParams) (window as any).gtag('event', eventName, eventParams)