mirror of https://github.com/langgenius/dify.git
Merge branch 'main' into feat/hitl-frontend
This commit is contained in:
commit
68885afac6
|
|
@ -5,5 +5,18 @@
|
|||
"typescript-lsp@claude-plugins-official": true,
|
||||
"pyright-lsp@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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,12 +39,6 @@ jobs:
|
|||
- name: Install dependencies
|
||||
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
|
||||
run: uv run --project api dev/pytest/pytest_config_tests.py
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
name: Deploy Trigger Dev
|
||||
name: Deploy Agent Dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -7,7 +7,7 @@ on:
|
|||
workflow_run:
|
||||
workflows: ["Build and Push API & Web"]
|
||||
branches:
|
||||
- "deploy/trigger-dev"
|
||||
- "deploy/agent-dev"
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
|
@ -16,12 +16,12 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'deploy/trigger-dev'
|
||||
github.event.workflow_run.head_branch == 'deploy/agent-dev'
|
||||
steps:
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@v0.1.8
|
||||
with:
|
||||
host: ${{ secrets.TRIGGER_SSH_HOST }}
|
||||
host: ${{ secrets.AGENT_DEV_SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from typing import Union, cast
|
||||
|
||||
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 extensions.ext_database import db
|
||||
from factories import file_factory
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import Conversation, Message, MessageAgentThought, MessageFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -289,6 +291,7 @@ class BaseAgentRunner(AppRunner):
|
|||
thought = MessageAgentThought(
|
||||
message_id=message_id,
|
||||
message_chain_id=None,
|
||||
tool_process_data=None,
|
||||
thought="",
|
||||
tool=tool_name,
|
||||
tool_labels_str="{}",
|
||||
|
|
@ -296,20 +299,20 @@ class BaseAgentRunner(AppRunner):
|
|||
tool_input=tool_input,
|
||||
message=message,
|
||||
message_token=0,
|
||||
message_unit_price=0,
|
||||
message_price_unit=0,
|
||||
message_unit_price=Decimal(0),
|
||||
message_price_unit=Decimal("0.001"),
|
||||
message_files=json.dumps(messages_ids) if messages_ids else "",
|
||||
answer="",
|
||||
observation="",
|
||||
answer_token=0,
|
||||
answer_unit_price=0,
|
||||
answer_price_unit=0,
|
||||
answer_unit_price=Decimal(0),
|
||||
answer_price_unit=Decimal("0.001"),
|
||||
tokens=0,
|
||||
total_price=0,
|
||||
total_price=Decimal(0),
|
||||
position=self.agent_thought_count + 1,
|
||||
currency="USD",
|
||||
latency=0,
|
||||
created_by_role="account",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=self.user_id,
|
||||
)
|
||||
|
||||
|
|
@ -342,7 +345,8 @@ class BaseAgentRunner(AppRunner):
|
|||
raise ValueError("agent thought not found")
|
||||
|
||||
if thought:
|
||||
agent_thought.thought += thought
|
||||
existing_thought = agent_thought.thought or ""
|
||||
agent_thought.thought = f"{existing_thought}{thought}"
|
||||
|
||||
if tool_name:
|
||||
agent_thought.tool = tool_name
|
||||
|
|
@ -440,21 +444,30 @@ class BaseAgentRunner(AppRunner):
|
|||
agent_thoughts: list[MessageAgentThought] = message.agent_thoughts
|
||||
if agent_thoughts:
|
||||
for agent_thought in agent_thoughts:
|
||||
tools = agent_thought.tool
|
||||
if tools:
|
||||
tools = tools.split(";")
|
||||
tool_names_raw = agent_thought.tool
|
||||
if tool_names_raw:
|
||||
tool_names = tool_names_raw.split(";")
|
||||
tool_calls: list[AssistantPromptMessage.ToolCall] = []
|
||||
tool_call_response: list[ToolPromptMessage] = []
|
||||
try:
|
||||
tool_inputs = json.loads(agent_thought.tool_input)
|
||||
except Exception:
|
||||
tool_inputs = {tool: {} for tool in tools}
|
||||
try:
|
||||
tool_responses = json.loads(agent_thought.observation)
|
||||
except Exception:
|
||||
tool_responses = dict.fromkeys(tools, agent_thought.observation)
|
||||
tool_input_payload = agent_thought.tool_input
|
||||
if tool_input_payload:
|
||||
try:
|
||||
tool_inputs = json.loads(tool_input_payload)
|
||||
except Exception:
|
||||
tool_inputs = {tool: {} for tool in tool_names}
|
||||
else:
|
||||
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
|
||||
tool_call_id = str(uuid.uuid4())
|
||||
tool_calls.append(
|
||||
|
|
@ -484,7 +497,7 @@ class BaseAgentRunner(AppRunner):
|
|||
*tool_call_response,
|
||||
]
|
||||
)
|
||||
if not tools:
|
||||
if not tool_names_raw:
|
||||
result.append(AssistantPromptMessage(content=agent_thought.thought))
|
||||
else:
|
||||
if message.answer:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import json
|
||||
from collections.abc import Sequence
|
||||
from enum import StrEnum, auto
|
||||
from typing import Any, Literal
|
||||
|
|
@ -121,7 +120,7 @@ class VariableEntity(BaseModel):
|
|||
allowed_file_types: Sequence[FileType] | None = Field(default_factory=list)
|
||||
allowed_file_extensions: Sequence[str] | None = Field(default_factory=list)
|
||||
allowed_file_upload_methods: Sequence[FileTransferMethod] | None = Field(default_factory=list)
|
||||
json_schema: str | None = Field(default=None)
|
||||
json_schema: dict | None = Field(default=None)
|
||||
|
||||
@field_validator("description", mode="before")
|
||||
@classmethod
|
||||
|
|
@ -135,17 +134,11 @@ class VariableEntity(BaseModel):
|
|||
|
||||
@field_validator("json_schema")
|
||||
@classmethod
|
||||
def validate_json_schema(cls, schema: str | None) -> str | None:
|
||||
def validate_json_schema(cls, schema: dict | None) -> dict | None:
|
||||
if schema is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
json_schema = json.loads(schema)
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError(f"invalid json_schema value {schema}")
|
||||
|
||||
try:
|
||||
Draft7Validator.check_schema(json_schema)
|
||||
Draft7Validator.check_schema(schema)
|
||||
except SchemaError as e:
|
||||
raise ValueError(f"Invalid JSON schema: {e.message}")
|
||||
return schema
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ class AdvancedChatAppConfigManager(BaseAppConfigManager):
|
|||
@classmethod
|
||||
def get_app_config(cls, app_model: App, workflow: Workflow) -> AdvancedChatAppConfig:
|
||||
features_dict = workflow.features_dict
|
||||
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
app_config = AdvancedChatAppConfig(
|
||||
tenant_id=app_model.tenant_id,
|
||||
|
|
|
|||
|
|
@ -358,25 +358,6 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
|
|||
if node_finish_resp:
|
||||
yield node_finish_resp
|
||||
|
||||
# For ANSWER nodes, check if we need to send a message_replace event
|
||||
# Only send if the final output differs from the accumulated task_state.answer
|
||||
# This happens when variables were updated by variable_assigner during workflow execution
|
||||
if event.node_type == NodeType.ANSWER and event.outputs:
|
||||
final_answer = event.outputs.get("answer")
|
||||
if final_answer is not None and final_answer != self._task_state.answer:
|
||||
logger.info(
|
||||
"ANSWER node final output '%s' differs from accumulated answer '%s', sending message_replace event",
|
||||
final_answer,
|
||||
self._task_state.answer,
|
||||
)
|
||||
# Update the task state answer
|
||||
self._task_state.answer = str(final_answer)
|
||||
# Send message_replace event to update the UI
|
||||
yield self._message_cycle_manager.message_replace_to_stream_response(
|
||||
answer=str(final_answer),
|
||||
reason="variable_update",
|
||||
)
|
||||
|
||||
def _handle_node_failed_events(
|
||||
self,
|
||||
event: Union[QueueNodeFailedEvent, QueueNodeExceptionEvent],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import json
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Union, final
|
||||
|
||||
|
|
@ -76,12 +75,24 @@ class BaseAppGenerator:
|
|||
user_inputs = {**user_inputs, **files_inputs, **file_list_inputs}
|
||||
|
||||
# Check if all files are converted to File
|
||||
if any(filter(lambda v: isinstance(v, dict), user_inputs.values())):
|
||||
raise ValueError("Invalid input type")
|
||||
if any(
|
||||
filter(lambda v: isinstance(v, dict), filter(lambda item: isinstance(item, list), user_inputs.values()))
|
||||
):
|
||||
raise ValueError("Invalid input type")
|
||||
invalid_dict_keys = [
|
||||
k
|
||||
for k, v in user_inputs.items()
|
||||
if isinstance(v, dict)
|
||||
and entity_dictionary[k].type not in {VariableEntityType.FILE, VariableEntityType.JSON_OBJECT}
|
||||
]
|
||||
if invalid_dict_keys:
|
||||
raise ValueError(f"Invalid input type for {invalid_dict_keys}")
|
||||
|
||||
invalid_list_dict_keys = [
|
||||
k
|
||||
for k, v in user_inputs.items()
|
||||
if isinstance(v, list)
|
||||
and any(isinstance(item, dict) for item in v)
|
||||
and entity_dictionary[k].type != VariableEntityType.FILE_LIST
|
||||
]
|
||||
if invalid_list_dict_keys:
|
||||
raise ValueError(f"Invalid input type for {invalid_list_dict_keys}")
|
||||
|
||||
return user_inputs
|
||||
|
||||
|
|
@ -178,12 +189,8 @@ class BaseAppGenerator:
|
|||
elif value == 0:
|
||||
value = False
|
||||
case VariableEntityType.JSON_OBJECT:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{variable_entity.variable} in input form must be a string")
|
||||
try:
|
||||
json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError(f"{variable_entity.variable} in input form must be a valid JSON object")
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{variable_entity.variable} in input form must be a dict")
|
||||
case _:
|
||||
raise AssertionError("this statement should be unreachable.")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import json
|
||||
from typing import Any
|
||||
|
||||
from jsonschema import Draft7Validator, ValidationError
|
||||
|
|
@ -43,25 +42,22 @@ class StartNode(Node[StartNodeData]):
|
|||
if value is None and variable.required:
|
||||
raise ValueError(f"{key} is required in input form")
|
||||
|
||||
# If no value provided, skip further processing for this key
|
||||
if not value:
|
||||
continue
|
||||
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"JSON object for '{key}' must be an object")
|
||||
|
||||
# Overwrite with normalized dict to ensure downstream consistency
|
||||
node_inputs[key] = value
|
||||
|
||||
# If schema exists, then validate against it
|
||||
schema = variable.json_schema
|
||||
if not schema:
|
||||
continue
|
||||
|
||||
if not value:
|
||||
continue
|
||||
|
||||
try:
|
||||
json_schema = json.loads(schema)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"{schema} must be a valid JSON object")
|
||||
|
||||
try:
|
||||
json_value = json.loads(value)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"{value} must be a valid JSON object")
|
||||
|
||||
try:
|
||||
Draft7Validator(json_schema).validate(json_value)
|
||||
Draft7Validator(schema).validate(value)
|
||||
except ValidationError as e:
|
||||
raise ValueError(f"JSON object for '{key}' does not match schema: {e.message}")
|
||||
node_inputs[key] = json_value
|
||||
|
|
|
|||
|
|
@ -1843,7 +1843,7 @@ class MessageChain(TypeBase):
|
|||
)
|
||||
|
||||
|
||||
class MessageAgentThought(Base):
|
||||
class MessageAgentThought(TypeBase):
|
||||
__tablename__ = "message_agent_thoughts"
|
||||
__table_args__ = (
|
||||
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"),
|
||||
)
|
||||
|
||||
id = mapped_column(StringUUID, default=lambda: str(uuid4()))
|
||||
message_id = mapped_column(StringUUID, nullable=False)
|
||||
message_chain_id = mapped_column(StringUUID, nullable=True)
|
||||
id: Mapped[str] = mapped_column(
|
||||
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
|
||||
)
|
||||
message_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
position: Mapped[int] = mapped_column(sa.Integer, nullable=False)
|
||||
thought = mapped_column(LongText, nullable=True)
|
||||
tool = mapped_column(LongText, nullable=True)
|
||||
tool_labels_str = mapped_column(LongText, nullable=False, default=sa.text("'{}'"))
|
||||
tool_meta_str = mapped_column(LongText, nullable=False, default=sa.text("'{}'"))
|
||||
tool_input = mapped_column(LongText, nullable=True)
|
||||
observation = mapped_column(LongText, nullable=True)
|
||||
created_by_role: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
message_chain_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
|
||||
thought: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
|
||||
tool: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
|
||||
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
|
||||
tool_process_data = mapped_column(LongText, nullable=True)
|
||||
message = mapped_column(LongText, nullable=True)
|
||||
message_token: Mapped[int | None] = mapped_column(sa.Integer, nullable=True)
|
||||
message_unit_price = mapped_column(sa.Numeric, nullable=True)
|
||||
message_price_unit = mapped_column(sa.Numeric(10, 7), nullable=False, server_default=sa.text("0.001"))
|
||||
message_files = mapped_column(LongText, nullable=True)
|
||||
answer = mapped_column(LongText, nullable=True)
|
||||
answer_token: Mapped[int | None] = mapped_column(sa.Integer, nullable=True)
|
||||
answer_unit_price = mapped_column(sa.Numeric, nullable=True)
|
||||
answer_price_unit = mapped_column(sa.Numeric(10, 7), nullable=False, server_default=sa.text("0.001"))
|
||||
tokens: Mapped[int | None] = mapped_column(sa.Integer, nullable=True)
|
||||
total_price = mapped_column(sa.Numeric, nullable=True)
|
||||
currency = mapped_column(String(255), nullable=True)
|
||||
latency: Mapped[float | None] = mapped_column(sa.Float, nullable=True)
|
||||
created_by_role = mapped_column(String(255), nullable=False)
|
||||
created_by = mapped_column(StringUUID, nullable=False)
|
||||
created_at = mapped_column(sa.DateTime, nullable=False, server_default=sa.func.current_timestamp())
|
||||
tool_process_data: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
|
||||
message: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
|
||||
message_token: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None)
|
||||
message_unit_price: Mapped[Decimal | None] = mapped_column(sa.Numeric, nullable=True, default=None)
|
||||
message_price_unit: Mapped[Decimal] = mapped_column(
|
||||
sa.Numeric(10, 7), nullable=False, default=Decimal("0.001"), server_default=sa.text("0.001")
|
||||
)
|
||||
message_files: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
|
||||
answer: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
|
||||
answer_token: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None)
|
||||
answer_unit_price: Mapped[Decimal | None] = mapped_column(sa.Numeric, nullable=True, default=None)
|
||||
answer_price_unit: Mapped[Decimal] = mapped_column(
|
||||
sa.Numeric(10, 7), nullable=False, default=Decimal("0.001"), server_default=sa.text("0.001")
|
||||
)
|
||||
tokens: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None)
|
||||
total_price: Mapped[Decimal | None] = mapped_column(sa.Numeric, nullable=True, default=None)
|
||||
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
|
||||
def files(self) -> list[Any]:
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ storage = [
|
|||
"opendal~=0.46.0",
|
||||
"oss2==2.18.5",
|
||||
"supabase~=2.18.1",
|
||||
"tos~=2.7.1",
|
||||
"tos~=2.9.0",
|
||||
]
|
||||
|
||||
############################################################
|
||||
|
|
|
|||
|
|
@ -230,7 +230,6 @@ class TestAgentService:
|
|||
|
||||
# Create first agent thought
|
||||
thought1 = MessageAgentThought(
|
||||
id=fake.uuid4(),
|
||||
message_id=message.id,
|
||||
position=1,
|
||||
thought="I need to analyze the user's request",
|
||||
|
|
@ -257,7 +256,6 @@ class TestAgentService:
|
|||
|
||||
# Create second agent thought
|
||||
thought2 = MessageAgentThought(
|
||||
id=fake.uuid4(),
|
||||
message_id=message.id,
|
||||
position=2,
|
||||
thought="Based on the analysis, I can provide a response",
|
||||
|
|
@ -545,7 +543,6 @@ class TestAgentService:
|
|||
|
||||
# Create agent thought with tool error
|
||||
thought_with_error = MessageAgentThought(
|
||||
id=fake.uuid4(),
|
||||
message_id=message.id,
|
||||
position=1,
|
||||
thought="I need to analyze the user's request",
|
||||
|
|
@ -759,7 +756,6 @@ class TestAgentService:
|
|||
|
||||
# Create agent thought with multiple tools
|
||||
complex_thought = MessageAgentThought(
|
||||
id=fake.uuid4(),
|
||||
message_id=message.id,
|
||||
position=1,
|
||||
thought="I need to use multiple tools to complete this task",
|
||||
|
|
@ -877,7 +873,6 @@ class TestAgentService:
|
|||
|
||||
# Create agent thought with files
|
||||
thought_with_files = MessageAgentThought(
|
||||
id=fake.uuid4(),
|
||||
message_id=message.id,
|
||||
position=1,
|
||||
thought="I need to process some files",
|
||||
|
|
@ -957,7 +952,6 @@ class TestAgentService:
|
|||
|
||||
# Create agent thought with empty tool data
|
||||
empty_thought = MessageAgentThought(
|
||||
id=fake.uuid4(),
|
||||
message_id=message.id,
|
||||
position=1,
|
||||
thought="I need to analyze the user's request",
|
||||
|
|
@ -999,7 +993,6 @@ class TestAgentService:
|
|||
|
||||
# Create agent thought with malformed JSON
|
||||
malformed_thought = MessageAgentThought(
|
||||
id=fake.uuid4(),
|
||||
message_id=message.id,
|
||||
position=1,
|
||||
thought="I need to analyze the user's request",
|
||||
|
|
|
|||
|
|
@ -1,390 +0,0 @@
|
|||
"""
|
||||
Tests for AdvancedChatAppGenerateTaskPipeline._handle_node_succeeded_event method,
|
||||
specifically testing the ANSWER node message_replace logic.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity
|
||||
from core.app.entities.queue_entities import QueueNodeSucceededEvent
|
||||
from core.workflow.enums import NodeType
|
||||
from models import EndUser
|
||||
from models.model import AppMode
|
||||
|
||||
|
||||
class TestAnswerNodeMessageReplace:
|
||||
"""Test cases for ANSWER node message_replace event logic."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_application_generate_entity(self):
|
||||
"""Create a mock application generate entity."""
|
||||
entity = Mock(spec=AdvancedChatAppGenerateEntity)
|
||||
entity.task_id = "test-task-id"
|
||||
entity.app_id = "test-app-id"
|
||||
entity.workflow_run_id = "test-workflow-run-id"
|
||||
# minimal app_config used by pipeline internals
|
||||
entity.app_config = SimpleNamespace(
|
||||
tenant_id="test-tenant-id",
|
||||
app_id="test-app-id",
|
||||
app_mode=AppMode.ADVANCED_CHAT,
|
||||
app_model_config_dict={},
|
||||
additional_features=None,
|
||||
sensitive_word_avoidance=None,
|
||||
)
|
||||
entity.query = "test query"
|
||||
entity.files = []
|
||||
entity.extras = {}
|
||||
entity.trace_manager = None
|
||||
entity.inputs = {}
|
||||
entity.invoke_from = "debugger"
|
||||
return entity
|
||||
|
||||
@pytest.fixture
|
||||
def mock_workflow(self):
|
||||
"""Create a mock workflow."""
|
||||
workflow = Mock()
|
||||
workflow.id = "test-workflow-id"
|
||||
workflow.features_dict = {}
|
||||
return workflow
|
||||
|
||||
@pytest.fixture
|
||||
def mock_queue_manager(self):
|
||||
"""Create a mock queue manager."""
|
||||
manager = Mock()
|
||||
manager.listen.return_value = []
|
||||
manager.graph_runtime_state = None
|
||||
return manager
|
||||
|
||||
@pytest.fixture
|
||||
def mock_conversation(self):
|
||||
"""Create a mock conversation."""
|
||||
conversation = Mock()
|
||||
conversation.id = "test-conversation-id"
|
||||
conversation.mode = "advanced_chat"
|
||||
return conversation
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message(self):
|
||||
"""Create a mock message."""
|
||||
message = Mock()
|
||||
message.id = "test-message-id"
|
||||
message.query = "test query"
|
||||
message.created_at = Mock()
|
||||
message.created_at.timestamp.return_value = 1234567890
|
||||
return message
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user(self):
|
||||
"""Create a mock end user."""
|
||||
user = MagicMock(spec=EndUser)
|
||||
user.id = "test-user-id"
|
||||
user.session_id = "test-session-id"
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def mock_draft_var_saver_factory(self):
|
||||
"""Create a mock draft variable saver factory."""
|
||||
return Mock()
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline(
|
||||
self,
|
||||
mock_application_generate_entity,
|
||||
mock_workflow,
|
||||
mock_queue_manager,
|
||||
mock_conversation,
|
||||
mock_message,
|
||||
mock_user,
|
||||
mock_draft_var_saver_factory,
|
||||
):
|
||||
"""Create an AdvancedChatAppGenerateTaskPipeline instance with mocked dependencies."""
|
||||
from core.app.apps.advanced_chat.generate_task_pipeline import AdvancedChatAppGenerateTaskPipeline
|
||||
|
||||
with patch("core.app.apps.advanced_chat.generate_task_pipeline.db"):
|
||||
pipeline = AdvancedChatAppGenerateTaskPipeline(
|
||||
application_generate_entity=mock_application_generate_entity,
|
||||
workflow=mock_workflow,
|
||||
queue_manager=mock_queue_manager,
|
||||
conversation=mock_conversation,
|
||||
message=mock_message,
|
||||
user=mock_user,
|
||||
stream=True,
|
||||
dialogue_count=1,
|
||||
draft_var_saver_factory=mock_draft_var_saver_factory,
|
||||
)
|
||||
# Initialize workflow run id to avoid validation errors
|
||||
pipeline._workflow_run_id = "test-workflow-run-id"
|
||||
# Mock the message cycle manager methods we need to track
|
||||
pipeline._message_cycle_manager.message_replace_to_stream_response = Mock()
|
||||
return pipeline
|
||||
|
||||
def test_answer_node_with_different_output_sends_message_replace(self, pipeline, mock_application_generate_entity):
|
||||
"""
|
||||
Test that when an ANSWER node's final output differs from accumulated answer,
|
||||
a message_replace event is sent.
|
||||
"""
|
||||
# Arrange: Set initial accumulated answer
|
||||
pipeline._task_state.answer = "initial answer"
|
||||
|
||||
# Create ANSWER node succeeded event with different final output
|
||||
event = QueueNodeSucceededEvent(
|
||||
node_execution_id="test-node-execution-id",
|
||||
node_id="test-answer-node",
|
||||
node_type=NodeType.ANSWER,
|
||||
start_at=datetime.now(),
|
||||
outputs={"answer": "updated final answer"},
|
||||
)
|
||||
|
||||
# Mock the workflow response converter to avoid extra processing
|
||||
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
|
||||
pipeline._save_output_for_event = Mock()
|
||||
|
||||
# Act
|
||||
responses = list(pipeline._handle_node_succeeded_event(event))
|
||||
|
||||
# Assert
|
||||
assert pipeline._task_state.answer == "updated final answer"
|
||||
# Verify message_replace was called
|
||||
pipeline._message_cycle_manager.message_replace_to_stream_response.assert_called_once_with(
|
||||
answer="updated final answer", reason="variable_update"
|
||||
)
|
||||
|
||||
def test_answer_node_with_same_output_does_not_send_message_replace(self, pipeline):
|
||||
"""
|
||||
Test that when an ANSWER node's final output is the same as accumulated answer,
|
||||
no message_replace event is sent.
|
||||
"""
|
||||
# Arrange: Set initial accumulated answer
|
||||
pipeline._task_state.answer = "same answer"
|
||||
|
||||
# Create ANSWER node succeeded event with same output
|
||||
event = QueueNodeSucceededEvent(
|
||||
node_execution_id="test-node-execution-id",
|
||||
node_id="test-answer-node",
|
||||
node_type=NodeType.ANSWER,
|
||||
start_at=datetime.now(),
|
||||
outputs={"answer": "same answer"},
|
||||
)
|
||||
|
||||
# Mock the workflow response converter
|
||||
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
|
||||
pipeline._save_output_for_event = Mock()
|
||||
|
||||
# Act
|
||||
list(pipeline._handle_node_succeeded_event(event))
|
||||
|
||||
# Assert: answer should remain unchanged
|
||||
assert pipeline._task_state.answer == "same answer"
|
||||
# Verify message_replace was NOT called
|
||||
pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called()
|
||||
|
||||
def test_answer_node_with_none_output_does_not_send_message_replace(self, pipeline):
|
||||
"""
|
||||
Test that when an ANSWER node's output is None or missing 'answer' key,
|
||||
no message_replace event is sent.
|
||||
"""
|
||||
# Arrange: Set initial accumulated answer
|
||||
pipeline._task_state.answer = "existing answer"
|
||||
|
||||
# Create ANSWER node succeeded event with None output
|
||||
event = QueueNodeSucceededEvent(
|
||||
node_execution_id="test-node-execution-id",
|
||||
node_id="test-answer-node",
|
||||
node_type=NodeType.ANSWER,
|
||||
start_at=datetime.now(),
|
||||
outputs={"answer": None},
|
||||
)
|
||||
|
||||
# Mock the workflow response converter
|
||||
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
|
||||
pipeline._save_output_for_event = Mock()
|
||||
|
||||
# Act
|
||||
list(pipeline._handle_node_succeeded_event(event))
|
||||
|
||||
# Assert: answer should remain unchanged
|
||||
assert pipeline._task_state.answer == "existing answer"
|
||||
# Verify message_replace was NOT called
|
||||
pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called()
|
||||
|
||||
def test_answer_node_with_empty_outputs_does_not_send_message_replace(self, pipeline):
|
||||
"""
|
||||
Test that when an ANSWER node has empty outputs dict,
|
||||
no message_replace event is sent.
|
||||
"""
|
||||
# Arrange: Set initial accumulated answer
|
||||
pipeline._task_state.answer = "existing answer"
|
||||
|
||||
# Create ANSWER node succeeded event with empty outputs
|
||||
event = QueueNodeSucceededEvent(
|
||||
node_execution_id="test-node-execution-id",
|
||||
node_id="test-answer-node",
|
||||
node_type=NodeType.ANSWER,
|
||||
start_at=datetime.now(),
|
||||
outputs={},
|
||||
)
|
||||
|
||||
# Mock the workflow response converter
|
||||
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
|
||||
pipeline._save_output_for_event = Mock()
|
||||
|
||||
# Act
|
||||
list(pipeline._handle_node_succeeded_event(event))
|
||||
|
||||
# Assert: answer should remain unchanged
|
||||
assert pipeline._task_state.answer == "existing answer"
|
||||
# Verify message_replace was NOT called
|
||||
pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called()
|
||||
|
||||
def test_answer_node_with_no_answer_key_in_outputs(self, pipeline):
|
||||
"""
|
||||
Test that when an ANSWER node's outputs don't contain 'answer' key,
|
||||
no message_replace event is sent.
|
||||
"""
|
||||
# Arrange: Set initial accumulated answer
|
||||
pipeline._task_state.answer = "existing answer"
|
||||
|
||||
# Create ANSWER node succeeded event without 'answer' key in outputs
|
||||
event = QueueNodeSucceededEvent(
|
||||
node_execution_id="test-node-execution-id",
|
||||
node_id="test-answer-node",
|
||||
node_type=NodeType.ANSWER,
|
||||
start_at=datetime.now(),
|
||||
outputs={"other_key": "some value"},
|
||||
)
|
||||
|
||||
# Mock the workflow response converter
|
||||
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
|
||||
pipeline._save_output_for_event = Mock()
|
||||
|
||||
# Act
|
||||
list(pipeline._handle_node_succeeded_event(event))
|
||||
|
||||
# Assert: answer should remain unchanged
|
||||
assert pipeline._task_state.answer == "existing answer"
|
||||
# Verify message_replace was NOT called
|
||||
pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called()
|
||||
|
||||
def test_non_answer_node_does_not_send_message_replace(self, pipeline):
|
||||
"""
|
||||
Test that non-ANSWER nodes (e.g., LLM, END) don't trigger message_replace events.
|
||||
"""
|
||||
# Arrange: Set initial accumulated answer
|
||||
pipeline._task_state.answer = "existing answer"
|
||||
|
||||
# Test with LLM node
|
||||
llm_event = QueueNodeSucceededEvent(
|
||||
node_execution_id="test-llm-execution-id",
|
||||
node_id="test-llm-node",
|
||||
node_type=NodeType.LLM,
|
||||
start_at=datetime.now(),
|
||||
outputs={"answer": "different answer"},
|
||||
)
|
||||
|
||||
# Mock the workflow response converter
|
||||
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
|
||||
pipeline._save_output_for_event = Mock()
|
||||
|
||||
# Act
|
||||
list(pipeline._handle_node_succeeded_event(llm_event))
|
||||
|
||||
# Assert: answer should remain unchanged
|
||||
assert pipeline._task_state.answer == "existing answer"
|
||||
# Verify message_replace was NOT called
|
||||
pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called()
|
||||
|
||||
def test_end_node_does_not_send_message_replace(self, pipeline):
|
||||
"""
|
||||
Test that END nodes don't trigger message_replace events even with 'answer' output.
|
||||
"""
|
||||
# Arrange: Set initial accumulated answer
|
||||
pipeline._task_state.answer = "existing answer"
|
||||
|
||||
# Create END node succeeded event with answer output
|
||||
event = QueueNodeSucceededEvent(
|
||||
node_execution_id="test-end-execution-id",
|
||||
node_id="test-end-node",
|
||||
node_type=NodeType.END,
|
||||
start_at=datetime.now(),
|
||||
outputs={"answer": "different answer"},
|
||||
)
|
||||
|
||||
# Mock the workflow response converter
|
||||
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
|
||||
pipeline._save_output_for_event = Mock()
|
||||
|
||||
# Act
|
||||
list(pipeline._handle_node_succeeded_event(event))
|
||||
|
||||
# Assert: answer should remain unchanged
|
||||
assert pipeline._task_state.answer == "existing answer"
|
||||
# Verify message_replace was NOT called
|
||||
pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called()
|
||||
|
||||
def test_answer_node_with_numeric_output_converts_to_string(self, pipeline):
|
||||
"""
|
||||
Test that when an ANSWER node's final output is numeric,
|
||||
it gets converted to string properly.
|
||||
"""
|
||||
# Arrange: Set initial accumulated answer
|
||||
pipeline._task_state.answer = "text answer"
|
||||
|
||||
# Create ANSWER node succeeded event with numeric output
|
||||
event = QueueNodeSucceededEvent(
|
||||
node_execution_id="test-node-execution-id",
|
||||
node_id="test-answer-node",
|
||||
node_type=NodeType.ANSWER,
|
||||
start_at=datetime.now(),
|
||||
outputs={"answer": 12345},
|
||||
)
|
||||
|
||||
# Mock the workflow response converter
|
||||
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
|
||||
pipeline._save_output_for_event = Mock()
|
||||
|
||||
# Act
|
||||
list(pipeline._handle_node_succeeded_event(event))
|
||||
|
||||
# Assert: answer should be converted to string
|
||||
assert pipeline._task_state.answer == "12345"
|
||||
# Verify message_replace was called with string
|
||||
pipeline._message_cycle_manager.message_replace_to_stream_response.assert_called_once_with(
|
||||
answer="12345", reason="variable_update"
|
||||
)
|
||||
|
||||
def test_answer_node_files_are_recorded(self, pipeline):
|
||||
"""
|
||||
Test that ANSWER nodes properly record files from outputs.
|
||||
"""
|
||||
# Arrange
|
||||
pipeline._task_state.answer = "existing answer"
|
||||
|
||||
# Create ANSWER node succeeded event with files
|
||||
event = QueueNodeSucceededEvent(
|
||||
node_execution_id="test-node-execution-id",
|
||||
node_id="test-answer-node",
|
||||
node_type=NodeType.ANSWER,
|
||||
start_at=datetime.now(),
|
||||
outputs={
|
||||
"answer": "same answer",
|
||||
"files": [
|
||||
{"type": "image", "transfer_method": "remote_url", "remote_url": "http://example.com/img.png"}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
# Mock the workflow response converter
|
||||
pipeline._workflow_response_converter.fetch_files_from_node_outputs = Mock(return_value=event.outputs["files"])
|
||||
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
|
||||
pipeline._save_output_for_event = Mock()
|
||||
|
||||
# Act
|
||||
list(pipeline._handle_node_succeeded_event(event))
|
||||
|
||||
# Assert: files should be recorded
|
||||
assert len(pipeline._recorded_files) == 1
|
||||
assert pipeline._recorded_files[0] == event.outputs["files"][0]
|
||||
|
|
@ -58,6 +58,8 @@ def test_json_object_valid_schema():
|
|||
}
|
||||
)
|
||||
|
||||
schema = json.loads(schema)
|
||||
|
||||
variables = [
|
||||
VariableEntity(
|
||||
variable="profile",
|
||||
|
|
@ -68,7 +70,7 @@ def test_json_object_valid_schema():
|
|||
)
|
||||
]
|
||||
|
||||
user_inputs = {"profile": json.dumps({"age": 20, "name": "Tom"})}
|
||||
user_inputs = {"profile": {"age": 20, "name": "Tom"}}
|
||||
|
||||
node = make_start_node(user_inputs, variables)
|
||||
result = node._run()
|
||||
|
|
@ -87,6 +89,8 @@ def test_json_object_invalid_json_string():
|
|||
"required": ["age", "name"],
|
||||
}
|
||||
)
|
||||
|
||||
schema = json.loads(schema)
|
||||
variables = [
|
||||
VariableEntity(
|
||||
variable="profile",
|
||||
|
|
@ -97,12 +101,12 @@ def test_json_object_invalid_json_string():
|
|||
)
|
||||
]
|
||||
|
||||
# Missing closing brace makes this invalid JSON
|
||||
# Providing a string instead of an object should raise a type error
|
||||
user_inputs = {"profile": '{"age": 20, "name": "Tom"'}
|
||||
|
||||
node = make_start_node(user_inputs, variables)
|
||||
|
||||
with pytest.raises(ValueError, match='{"age": 20, "name": "Tom" must be a valid JSON object'):
|
||||
with pytest.raises(ValueError, match="JSON object for 'profile' must be an object"):
|
||||
node._run()
|
||||
|
||||
|
||||
|
|
@ -118,6 +122,8 @@ def test_json_object_does_not_match_schema():
|
|||
}
|
||||
)
|
||||
|
||||
schema = json.loads(schema)
|
||||
|
||||
variables = [
|
||||
VariableEntity(
|
||||
variable="profile",
|
||||
|
|
@ -129,7 +135,7 @@ def test_json_object_does_not_match_schema():
|
|||
]
|
||||
|
||||
# age is a string, which violates the schema (expects number)
|
||||
user_inputs = {"profile": json.dumps({"age": "twenty", "name": "Tom"})}
|
||||
user_inputs = {"profile": {"age": "twenty", "name": "Tom"}}
|
||||
|
||||
node = make_start_node(user_inputs, variables)
|
||||
|
||||
|
|
@ -149,6 +155,8 @@ def test_json_object_missing_required_schema_field():
|
|||
}
|
||||
)
|
||||
|
||||
schema = json.loads(schema)
|
||||
|
||||
variables = [
|
||||
VariableEntity(
|
||||
variable="profile",
|
||||
|
|
@ -160,7 +168,7 @@ def test_json_object_missing_required_schema_field():
|
|||
]
|
||||
|
||||
# Missing required field "name"
|
||||
user_inputs = {"profile": json.dumps({"age": 20})}
|
||||
user_inputs = {"profile": {"age": 20}}
|
||||
|
||||
node = make_start_node(user_inputs, variables)
|
||||
|
||||
|
|
|
|||
53
api/uv.lock
53
api/uv.lock
|
|
@ -1731,7 +1731,7 @@ storage = [
|
|||
{ name = "opendal", specifier = "~=0.46.0" },
|
||||
{ name = "oss2", specifier = "==2.18.5" },
|
||||
{ name = "supabase", specifier = "~=2.18.1" },
|
||||
{ name = "tos", specifier = "~=2.7.1" },
|
||||
{ name = "tos", specifier = "~=2.9.0" },
|
||||
]
|
||||
tools = [
|
||||
{ name = "cloudscraper", specifier = "~=1.2.71" },
|
||||
|
|
@ -6148,7 +6148,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "tos"
|
||||
version = "2.7.2"
|
||||
version = "2.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "crcmod" },
|
||||
|
|
@ -6156,8 +6156,9 @@ dependencies = [
|
|||
{ name = "pytz" },
|
||||
{ name = "requests" },
|
||||
{ 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]]
|
||||
name = "tqdm"
|
||||
|
|
@ -7146,31 +7147,31 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "wrapt"
|
||||
version = "1.17.3"
|
||||
version = "1.16.0"
|
||||
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 = [
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
22.21.1
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import type { Preview } from '@storybook/react'
|
||||
import type { Resource } from 'i18next'
|
||||
import { withThemeByDataAttribute } from '@storybook/addon-themes'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
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/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 = [
|
||||
withThemeByDataAttribute({
|
||||
themes: {
|
||||
|
|
@ -28,7 +38,7 @@ export const decorators = [
|
|||
(Story) => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18N locale="en-US">
|
||||
<I18N locale="en-US" resource={storyResources}>
|
||||
<ToastProvider>
|
||||
<Story />
|
||||
</ToastProvider>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,16 @@ Before starting the web frontend service, please make sure the following environ
|
|||
- [Node.js](https://nodejs.org) >= v22.11.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:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import Marketplace from '@/app/components/plugins/marketplace'
|
|||
import PluginPage from '@/app/components/plugins/plugin-page'
|
||||
import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel'
|
||||
|
||||
const PluginList = async () => {
|
||||
const PluginList = () => {
|
||||
return (
|
||||
<PluginPage
|
||||
plugins={<PluginsPanel />}
|
||||
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" showSearchParams={false} />}
|
||||
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
|||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
|
||||
import { useInvalidateAppList } from '@/service/use-apps'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
|
|
@ -66,6 +67,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
|||
const { onPlanInfoChanged } = useProviderContext()
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const setAppDetail = useAppStore(state => state.setAppDetail)
|
||||
const invalidateAppList = useInvalidateAppList()
|
||||
const [open, setOpen] = useState(openState)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
|
||||
|
|
@ -191,6 +193,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
|||
try {
|
||||
await deleteApp(appDetail.id)
|
||||
notify({ type: 'success', message: t('appDeleted', { ns: 'app' }) })
|
||||
invalidateAppList()
|
||||
onPlanInfoChanged()
|
||||
setAppDetail()
|
||||
replace('/apps')
|
||||
|
|
@ -202,7 +205,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
|||
})
|
||||
}
|
||||
setShowConfirmDelete(false)
|
||||
}, [appDetail, notify, onPlanInfoChanged, replace, setAppDetail, t])
|
||||
}, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, setAppDetail, t])
|
||||
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
|||
if (!isJsonObject || !tempPayload.json_schema)
|
||||
return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(tempPayload.json_schema), null, 2)
|
||||
return tempPayload.json_schema
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const mockReplace = vi.fn()
|
|||
const mockRouter = { replace: mockReplace }
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => mockRouter,
|
||||
useSearchParams: () => new URLSearchParams(''),
|
||||
}))
|
||||
|
||||
// Mock app context
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useDebounceFn } from 'ahooks'
|
|||
import dynamic from 'next/dynamic'
|
||||
import {
|
||||
useRouter,
|
||||
useSearchParams,
|
||||
} from 'next/navigation'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
|
@ -28,6 +29,7 @@ import { CheckModal } from '@/hooks/use-pay'
|
|||
import { useInfiniteAppList } from '@/service/use-apps'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { isServer } from '@/utils/client'
|
||||
import AppCard from './app-card'
|
||||
import { AppCardSkeleton } from './app-card-skeleton'
|
||||
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 NewAppCard from './new-app-card'
|
||||
|
||||
// Define valid tabs at module scope to avoid re-creation on each render and stale closures
|
||||
const validTabs = new Set<string | AppModeEnum>([
|
||||
'all',
|
||||
AppModeEnum.WORKFLOW,
|
||||
AppModeEnum.ADVANCED_CHAT,
|
||||
AppModeEnum.CHAT,
|
||||
AppModeEnum.AGENT_CHAT,
|
||||
AppModeEnum.COMPLETION,
|
||||
])
|
||||
|
||||
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
|
@ -47,12 +59,41 @@ const List = () => {
|
|||
const { t } = useTranslation()
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
|
||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||
const [activeTab, setActiveTab] = useQueryState(
|
||||
'category',
|
||||
parseAsString.withDefault('all').withOptions({ history: 'push' }),
|
||||
)
|
||||
|
||||
// valid tabs for apps list; anything else should fallback to 'all'
|
||||
|
||||
// 1) Normalize legacy/incorrect query params like ?mode=discover -> ?category=all
|
||||
useEffect(() => {
|
||||
// avoid running on server
|
||||
if (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 [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
|
||||
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export const getProcessedInputs = (inputs: Record<string, any>, inputsForm: Inpu
|
|||
return
|
||||
}
|
||||
|
||||
if (!inputValue)
|
||||
if (inputValue == null)
|
||||
return
|
||||
|
||||
if (item.type === InputVarType.singleFile) {
|
||||
|
|
@ -52,6 +52,20 @@ export const getProcessedInputs = (inputs: Record<string, any>, inputsForm: Inpu
|
|||
else
|
||||
processedInputs[item.variable] = getProcessedFiles(inputValue)
|
||||
}
|
||||
else if (item.type === InputVarType.jsonObject) {
|
||||
// Prefer sending an object if the user entered valid JSON; otherwise keep the raw string.
|
||||
try {
|
||||
const v = typeof inputValue === 'string' ? JSON.parse(inputValue) : inputValue
|
||||
if (v && typeof v === 'object' && !Array.isArray(v))
|
||||
processedInputs[item.variable] = v
|
||||
else
|
||||
processedInputs[item.variable] = inputValue
|
||||
}
|
||||
catch {
|
||||
// keep original string; backend will parse/validate
|
||||
processedInputs[item.variable] = inputValue
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return processedInputs
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import DifyLogo from '@/app/components/base/logo/dify-logo'
|
|||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { isClient } from '@/utils/client'
|
||||
import {
|
||||
useEmbeddedChatbotContext,
|
||||
} from '../context'
|
||||
|
|
@ -40,7 +41,6 @@ const Header: FC<IHeaderProps> = ({
|
|||
allInputsHidden,
|
||||
} = useEmbeddedChatbotContext()
|
||||
|
||||
const isClient = typeof window !== 'undefined'
|
||||
const isIframe = isClient ? window.self !== window.top : false
|
||||
const [parentOrigin, setParentOrigin] = useState('')
|
||||
const [showToggleExpandButton, setShowToggleExpandButton] = useState(false)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import type { ActivePluginType } from './constants'
|
||||
import type { PluginsSort, SearchParamsFromCollection } from './types'
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useCallback } from 'react'
|
||||
import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { marketplaceSearchParamsParsers } from './search-params'
|
||||
|
||||
const marketplaceSortAtom = atom<PluginsSort>(DEFAULT_SORT)
|
||||
export function useMarketplaceSort() {
|
||||
return useAtom(marketplaceSortAtom)
|
||||
}
|
||||
export function useMarketplaceSortValue() {
|
||||
return useAtomValue(marketplaceSortAtom)
|
||||
}
|
||||
export function useSetMarketplaceSort() {
|
||||
return useSetAtom(marketplaceSortAtom)
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve the state for marketplace
|
||||
*/
|
||||
export const preserveSearchStateInQueryAtom = atom<boolean>(false)
|
||||
|
||||
const searchPluginTextAtom = atom<string>('')
|
||||
const activePluginTypeAtom = atom<ActivePluginType>('all')
|
||||
const filterPluginTagsAtom = atom<string[]>([])
|
||||
|
||||
export function useSearchPluginText() {
|
||||
const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom)
|
||||
const queryState = useQueryState('q', marketplaceSearchParamsParsers.q)
|
||||
const atomState = useAtom(searchPluginTextAtom)
|
||||
return preserveSearchStateInQuery ? queryState : atomState
|
||||
}
|
||||
export function useActivePluginType() {
|
||||
const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom)
|
||||
const queryState = useQueryState('category', marketplaceSearchParamsParsers.category)
|
||||
const atomState = useAtom(activePluginTypeAtom)
|
||||
return preserveSearchStateInQuery ? queryState : atomState
|
||||
}
|
||||
export function useFilterPluginTags() {
|
||||
const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom)
|
||||
const queryState = useQueryState('tags', marketplaceSearchParamsParsers.tags)
|
||||
const atomState = useAtom(filterPluginTagsAtom)
|
||||
return preserveSearchStateInQuery ? queryState : atomState
|
||||
}
|
||||
|
||||
/**
|
||||
* Not all categories have collections, so we need to
|
||||
* force the search mode for those categories.
|
||||
*/
|
||||
export const searchModeAtom = atom<true | null>(null)
|
||||
|
||||
export function useMarketplaceSearchMode() {
|
||||
const [searchPluginText] = useSearchPluginText()
|
||||
const [filterPluginTags] = useFilterPluginTags()
|
||||
const [activePluginType] = useActivePluginType()
|
||||
|
||||
const searchMode = useAtomValue(searchModeAtom)
|
||||
const isSearchMode = !!searchPluginText
|
||||
|| filterPluginTags.length > 0
|
||||
|| (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType)))
|
||||
return isSearchMode
|
||||
}
|
||||
|
||||
export function useMarketplaceMoreClick() {
|
||||
const [,setQ] = useSearchPluginText()
|
||||
const setSort = useSetAtom(marketplaceSortAtom)
|
||||
const setSearchMode = useSetAtom(searchModeAtom)
|
||||
|
||||
return useCallback((searchParams?: SearchParamsFromCollection) => {
|
||||
if (!searchParams)
|
||||
return
|
||||
setQ(searchParams?.query || '')
|
||||
setSort({
|
||||
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
|
||||
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
|
||||
})
|
||||
setSearchMode(true)
|
||||
}, [setQ, setSort, setSearchMode])
|
||||
}
|
||||
|
|
@ -1,6 +1,30 @@
|
|||
import { PluginCategoryEnum } from '../types'
|
||||
|
||||
export const DEFAULT_SORT = {
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
}
|
||||
|
||||
export const SCROLL_BOTTOM_THRESHOLD = 100
|
||||
|
||||
export const PLUGIN_TYPE_SEARCH_MAP = {
|
||||
all: 'all',
|
||||
model: PluginCategoryEnum.model,
|
||||
tool: PluginCategoryEnum.tool,
|
||||
agent: PluginCategoryEnum.agent,
|
||||
extension: PluginCategoryEnum.extension,
|
||||
datasource: PluginCategoryEnum.datasource,
|
||||
trigger: PluginCategoryEnum.trigger,
|
||||
bundle: 'bundle',
|
||||
} as const
|
||||
|
||||
type ValueOf<T> = T[keyof T]
|
||||
|
||||
export type ActivePluginType = ValueOf<typeof PLUGIN_TYPE_SEARCH_MAP>
|
||||
|
||||
export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>(
|
||||
[
|
||||
PLUGIN_TYPE_SEARCH_MAP.all,
|
||||
PLUGIN_TYPE_SEARCH_MAP.tool,
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,332 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import type {
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import type { TagKey } from '../constants'
|
||||
import type { Plugin } from '../types'
|
||||
import type {
|
||||
MarketplaceCollection,
|
||||
PluginsSort,
|
||||
SearchParams,
|
||||
SearchParamsFromCollection,
|
||||
} from './types'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useContextSelector,
|
||||
} from 'use-context-selector'
|
||||
import { useMarketplaceFilters } from '@/hooks/use-query-params'
|
||||
import { useInstalledPluginList } from '@/service/use-plugins'
|
||||
import {
|
||||
getValidCategoryKeys,
|
||||
getValidTagKeys,
|
||||
} from '../utils'
|
||||
import { DEFAULT_SORT } from './constants'
|
||||
import {
|
||||
useMarketplaceCollectionsAndPlugins,
|
||||
useMarketplaceContainerScroll,
|
||||
useMarketplacePlugins,
|
||||
} from './hooks'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
|
||||
import {
|
||||
getMarketplaceListCondition,
|
||||
getMarketplaceListFilterType,
|
||||
} from './utils'
|
||||
|
||||
export type MarketplaceContextValue = {
|
||||
searchPluginText: string
|
||||
handleSearchPluginTextChange: (text: string) => void
|
||||
filterPluginTags: string[]
|
||||
handleFilterPluginTagsChange: (tags: string[]) => void
|
||||
activePluginType: string
|
||||
handleActivePluginTypeChange: (type: string) => void
|
||||
page: number
|
||||
handlePageChange: () => void
|
||||
plugins?: Plugin[]
|
||||
pluginsTotal?: number
|
||||
resetPlugins: () => void
|
||||
sort: PluginsSort
|
||||
handleSortChange: (sort: PluginsSort) => void
|
||||
handleQueryPlugins: () => void
|
||||
handleMoreClick: (searchParams: SearchParamsFromCollection) => void
|
||||
marketplaceCollectionsFromClient?: MarketplaceCollection[]
|
||||
setMarketplaceCollectionsFromClient: (collections: MarketplaceCollection[]) => void
|
||||
marketplaceCollectionPluginsMapFromClient?: Record<string, Plugin[]>
|
||||
setMarketplaceCollectionPluginsMapFromClient: (map: Record<string, Plugin[]>) => void
|
||||
isLoading: boolean
|
||||
isSuccessCollections: boolean
|
||||
}
|
||||
|
||||
export const MarketplaceContext = createContext<MarketplaceContextValue>({
|
||||
searchPluginText: '',
|
||||
handleSearchPluginTextChange: noop,
|
||||
filterPluginTags: [],
|
||||
handleFilterPluginTagsChange: noop,
|
||||
activePluginType: 'all',
|
||||
handleActivePluginTypeChange: noop,
|
||||
page: 1,
|
||||
handlePageChange: noop,
|
||||
plugins: undefined,
|
||||
pluginsTotal: 0,
|
||||
resetPlugins: noop,
|
||||
sort: DEFAULT_SORT,
|
||||
handleSortChange: noop,
|
||||
handleQueryPlugins: noop,
|
||||
handleMoreClick: noop,
|
||||
marketplaceCollectionsFromClient: [],
|
||||
setMarketplaceCollectionsFromClient: noop,
|
||||
marketplaceCollectionPluginsMapFromClient: {},
|
||||
setMarketplaceCollectionPluginsMapFromClient: noop,
|
||||
isLoading: false,
|
||||
isSuccessCollections: false,
|
||||
})
|
||||
|
||||
type MarketplaceContextProviderProps = {
|
||||
children: ReactNode
|
||||
searchParams?: SearchParams
|
||||
shouldExclude?: boolean
|
||||
scrollContainerId?: string
|
||||
showSearchParams?: boolean
|
||||
}
|
||||
|
||||
export function useMarketplaceContext(selector: (value: MarketplaceContextValue) => any) {
|
||||
return useContextSelector(MarketplaceContext, selector)
|
||||
}
|
||||
|
||||
export const MarketplaceContextProvider = ({
|
||||
children,
|
||||
searchParams,
|
||||
shouldExclude,
|
||||
scrollContainerId,
|
||||
showSearchParams,
|
||||
}: MarketplaceContextProviderProps) => {
|
||||
// Use nuqs hook for URL-based filter state
|
||||
const [urlFilters, setUrlFilters] = useMarketplaceFilters()
|
||||
|
||||
const { data, isSuccess } = useInstalledPluginList(!shouldExclude)
|
||||
const exclude = useMemo(() => {
|
||||
if (shouldExclude)
|
||||
return data?.plugins.map(plugin => plugin.plugin_id)
|
||||
}, [data?.plugins, shouldExclude])
|
||||
|
||||
// Initialize from URL params (legacy support) or use nuqs state
|
||||
const queryFromSearchParams = searchParams?.q || urlFilters.q
|
||||
const tagsFromSearchParams = getValidTagKeys(urlFilters.tags as TagKey[])
|
||||
const hasValidTags = !!tagsFromSearchParams.length
|
||||
const hasValidCategory = getValidCategoryKeys(urlFilters.category)
|
||||
const categoryFromSearchParams = hasValidCategory || PLUGIN_TYPE_SEARCH_MAP.all
|
||||
|
||||
const [searchPluginText, setSearchPluginText] = useState(queryFromSearchParams)
|
||||
const searchPluginTextRef = useRef(searchPluginText)
|
||||
const [filterPluginTags, setFilterPluginTags] = useState<string[]>(tagsFromSearchParams)
|
||||
const filterPluginTagsRef = useRef(filterPluginTags)
|
||||
const [activePluginType, setActivePluginType] = useState(categoryFromSearchParams)
|
||||
const activePluginTypeRef = useRef(activePluginType)
|
||||
const [sort, setSort] = useState(DEFAULT_SORT)
|
||||
const sortRef = useRef(sort)
|
||||
const {
|
||||
marketplaceCollections: marketplaceCollectionsFromClient,
|
||||
setMarketplaceCollections: setMarketplaceCollectionsFromClient,
|
||||
marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapFromClient,
|
||||
setMarketplaceCollectionPluginsMap: setMarketplaceCollectionPluginsMapFromClient,
|
||||
queryMarketplaceCollectionsAndPlugins,
|
||||
isLoading,
|
||||
isSuccess: isSuccessCollections,
|
||||
} = useMarketplaceCollectionsAndPlugins()
|
||||
const {
|
||||
plugins,
|
||||
total: pluginsTotal,
|
||||
resetPlugins,
|
||||
queryPlugins,
|
||||
queryPluginsWithDebounced,
|
||||
cancelQueryPluginsWithDebounced,
|
||||
isLoading: isPluginsLoading,
|
||||
fetchNextPage: fetchNextPluginsPage,
|
||||
hasNextPage: hasNextPluginsPage,
|
||||
page: pluginsPage,
|
||||
} = useMarketplacePlugins()
|
||||
const page = Math.max(pluginsPage || 0, 1)
|
||||
|
||||
useEffect(() => {
|
||||
if (queryFromSearchParams || hasValidTags || hasValidCategory) {
|
||||
queryPlugins({
|
||||
query: queryFromSearchParams,
|
||||
category: hasValidCategory,
|
||||
tags: hasValidTags ? tagsFromSearchParams : [],
|
||||
sortBy: sortRef.current.sortBy,
|
||||
sortOrder: sortRef.current.sortOrder,
|
||||
type: getMarketplaceListFilterType(activePluginTypeRef.current),
|
||||
})
|
||||
}
|
||||
else {
|
||||
if (shouldExclude && isSuccess) {
|
||||
queryMarketplaceCollectionsAndPlugins({
|
||||
exclude,
|
||||
type: getMarketplaceListFilterType(activePluginTypeRef.current),
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [queryPlugins, queryMarketplaceCollectionsAndPlugins, isSuccess, exclude])
|
||||
|
||||
const handleQueryMarketplaceCollectionsAndPlugins = useCallback(() => {
|
||||
queryMarketplaceCollectionsAndPlugins({
|
||||
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
|
||||
condition: getMarketplaceListCondition(activePluginTypeRef.current),
|
||||
exclude,
|
||||
type: getMarketplaceListFilterType(activePluginTypeRef.current),
|
||||
})
|
||||
resetPlugins()
|
||||
}, [exclude, queryMarketplaceCollectionsAndPlugins, resetPlugins])
|
||||
|
||||
const applyUrlFilters = useCallback(() => {
|
||||
if (!showSearchParams)
|
||||
return
|
||||
const nextFilters = {
|
||||
q: searchPluginTextRef.current,
|
||||
category: activePluginTypeRef.current,
|
||||
tags: filterPluginTagsRef.current,
|
||||
}
|
||||
const categoryChanged = urlFilters.category !== nextFilters.category
|
||||
setUrlFilters(nextFilters, {
|
||||
history: categoryChanged ? 'push' : 'replace',
|
||||
})
|
||||
}, [setUrlFilters, showSearchParams, urlFilters.category])
|
||||
|
||||
const debouncedUpdateSearchParams = useMemo(() => debounce(() => {
|
||||
applyUrlFilters()
|
||||
}, 500), [applyUrlFilters])
|
||||
|
||||
const handleUpdateSearchParams = useCallback((debounced?: boolean) => {
|
||||
if (debounced) {
|
||||
debouncedUpdateSearchParams()
|
||||
}
|
||||
else {
|
||||
applyUrlFilters()
|
||||
}
|
||||
}, [applyUrlFilters, debouncedUpdateSearchParams])
|
||||
|
||||
const handleQueryPlugins = useCallback((debounced?: boolean) => {
|
||||
handleUpdateSearchParams(debounced)
|
||||
if (debounced) {
|
||||
queryPluginsWithDebounced({
|
||||
query: searchPluginTextRef.current,
|
||||
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
|
||||
tags: filterPluginTagsRef.current,
|
||||
sortBy: sortRef.current.sortBy,
|
||||
sortOrder: sortRef.current.sortOrder,
|
||||
exclude,
|
||||
type: getMarketplaceListFilterType(activePluginTypeRef.current),
|
||||
})
|
||||
}
|
||||
else {
|
||||
queryPlugins({
|
||||
query: searchPluginTextRef.current,
|
||||
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
|
||||
tags: filterPluginTagsRef.current,
|
||||
sortBy: sortRef.current.sortBy,
|
||||
sortOrder: sortRef.current.sortOrder,
|
||||
exclude,
|
||||
type: getMarketplaceListFilterType(activePluginTypeRef.current),
|
||||
})
|
||||
}
|
||||
}, [exclude, queryPluginsWithDebounced, queryPlugins, handleUpdateSearchParams])
|
||||
|
||||
const handleQuery = useCallback((debounced?: boolean) => {
|
||||
if (!searchPluginTextRef.current && !filterPluginTagsRef.current.length) {
|
||||
handleUpdateSearchParams(debounced)
|
||||
cancelQueryPluginsWithDebounced()
|
||||
handleQueryMarketplaceCollectionsAndPlugins()
|
||||
return
|
||||
}
|
||||
|
||||
handleQueryPlugins(debounced)
|
||||
}, [handleQueryMarketplaceCollectionsAndPlugins, handleQueryPlugins, cancelQueryPluginsWithDebounced, handleUpdateSearchParams])
|
||||
|
||||
const handleSearchPluginTextChange = useCallback((text: string) => {
|
||||
setSearchPluginText(text)
|
||||
searchPluginTextRef.current = text
|
||||
|
||||
handleQuery(true)
|
||||
}, [handleQuery])
|
||||
|
||||
const handleFilterPluginTagsChange = useCallback((tags: string[]) => {
|
||||
setFilterPluginTags(tags)
|
||||
filterPluginTagsRef.current = tags
|
||||
|
||||
handleQuery()
|
||||
}, [handleQuery])
|
||||
|
||||
const handleActivePluginTypeChange = useCallback((type: string) => {
|
||||
setActivePluginType(type)
|
||||
activePluginTypeRef.current = type
|
||||
|
||||
handleQuery()
|
||||
}, [handleQuery])
|
||||
|
||||
const handleSortChange = useCallback((sort: PluginsSort) => {
|
||||
setSort(sort)
|
||||
sortRef.current = sort
|
||||
|
||||
handleQueryPlugins()
|
||||
}, [handleQueryPlugins])
|
||||
|
||||
const handlePageChange = useCallback(() => {
|
||||
if (hasNextPluginsPage)
|
||||
fetchNextPluginsPage()
|
||||
}, [fetchNextPluginsPage, hasNextPluginsPage])
|
||||
|
||||
const handleMoreClick = useCallback((searchParams: SearchParamsFromCollection) => {
|
||||
setSearchPluginText(searchParams?.query || '')
|
||||
searchPluginTextRef.current = searchParams?.query || ''
|
||||
setSort({
|
||||
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
|
||||
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
|
||||
})
|
||||
sortRef.current = {
|
||||
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
|
||||
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
|
||||
}
|
||||
handleQueryPlugins()
|
||||
}, [handleQueryPlugins])
|
||||
|
||||
useMarketplaceContainerScroll(handlePageChange, scrollContainerId)
|
||||
|
||||
return (
|
||||
<MarketplaceContext.Provider
|
||||
value={{
|
||||
searchPluginText,
|
||||
handleSearchPluginTextChange,
|
||||
filterPluginTags,
|
||||
handleFilterPluginTagsChange,
|
||||
activePluginType,
|
||||
handleActivePluginTypeChange,
|
||||
page,
|
||||
handlePageChange,
|
||||
plugins,
|
||||
pluginsTotal,
|
||||
resetPlugins,
|
||||
sort,
|
||||
handleSortChange,
|
||||
handleQueryPlugins,
|
||||
handleMoreClick,
|
||||
marketplaceCollectionsFromClient,
|
||||
setMarketplaceCollectionsFromClient,
|
||||
marketplaceCollectionPluginsMapFromClient,
|
||||
setMarketplaceCollectionPluginsMapFromClient,
|
||||
isLoading: isLoading || isPluginsLoading,
|
||||
isSuccessCollections,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MarketplaceContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -26,6 +26,9 @@ import {
|
|||
getMarketplacePluginsByCollectionId,
|
||||
} from './utils'
|
||||
|
||||
/**
|
||||
* @deprecated Use useMarketplaceCollectionsAndPlugins from query.ts instead
|
||||
*/
|
||||
export const useMarketplaceCollectionsAndPlugins = () => {
|
||||
const [queryParams, setQueryParams] = useState<CollectionsAndPluginsSearchParams>()
|
||||
const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState<MarketplaceCollection[]>()
|
||||
|
|
@ -89,7 +92,9 @@ export const useMarketplacePluginsByCollectionId = (
|
|||
isSuccess,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use useMarketplacePlugins from query.ts instead
|
||||
*/
|
||||
export const useMarketplacePlugins = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [queryParams, setQueryParams] = useState<PluginsSearchParams>()
|
||||
|
|
|
|||
|
|
@ -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}</>
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import type { SearchParams } from 'nuqs'
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import { createLoader } from 'nuqs/server'
|
||||
import { getQueryClientServer } from '@/context/query-client-server'
|
||||
import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { marketplaceKeys } from './query'
|
||||
import { marketplaceSearchParamsParsers } from './search-params'
|
||||
import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils'
|
||||
|
||||
// The server side logic should move to marketplace's codebase so that we can get rid of Next.js
|
||||
|
||||
async function getDehydratedState(searchParams?: Promise<SearchParams>) {
|
||||
if (!searchParams) {
|
||||
return
|
||||
}
|
||||
const loadSearchParams = createLoader(marketplaceSearchParamsParsers)
|
||||
const params = await loadSearchParams(searchParams)
|
||||
|
||||
if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) {
|
||||
return
|
||||
}
|
||||
|
||||
const queryClient = getQueryClientServer()
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: marketplaceKeys.collections(getCollectionsParams(params.category)),
|
||||
queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)),
|
||||
})
|
||||
return dehydrate(queryClient)
|
||||
}
|
||||
|
||||
export async function HydrateQueryClient({
|
||||
searchParams,
|
||||
children,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams> | undefined
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const dehydratedState = await getDehydratedState(searchParams)
|
||||
return (
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
{children}
|
||||
</HydrationBoundary>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,55 +1,39 @@
|
|||
import type { MarketplaceCollection, SearchParams } from './types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { SearchParams } from 'nuqs'
|
||||
import { TanstackQueryInitializer } from '@/context/query-client'
|
||||
import { MarketplaceContextProvider } from './context'
|
||||
import Description from './description'
|
||||
import { HydrateMarketplaceAtoms } from './hydration-client'
|
||||
import { HydrateQueryClient } from './hydration-server'
|
||||
import ListWrapper from './list/list-wrapper'
|
||||
import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
|
||||
import { getMarketplaceCollectionsAndPlugins } from './utils'
|
||||
|
||||
type MarketplaceProps = {
|
||||
showInstallButton?: boolean
|
||||
shouldExclude?: boolean
|
||||
searchParams?: SearchParams
|
||||
pluginTypeSwitchClassName?: string
|
||||
scrollContainerId?: string
|
||||
showSearchParams?: boolean
|
||||
/**
|
||||
* Pass the search params from the request to prefetch data on the server
|
||||
* and preserve the search params in the URL.
|
||||
*/
|
||||
searchParams?: Promise<SearchParams>
|
||||
}
|
||||
|
||||
const Marketplace = async ({
|
||||
showInstallButton = true,
|
||||
shouldExclude,
|
||||
searchParams,
|
||||
pluginTypeSwitchClassName,
|
||||
scrollContainerId,
|
||||
showSearchParams = true,
|
||||
searchParams,
|
||||
}: MarketplaceProps) => {
|
||||
let marketplaceCollections: MarketplaceCollection[] = []
|
||||
let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {}
|
||||
if (!shouldExclude) {
|
||||
const marketplaceCollectionsAndPluginsData = await getMarketplaceCollectionsAndPlugins()
|
||||
marketplaceCollections = marketplaceCollectionsAndPluginsData.marketplaceCollections
|
||||
marketplaceCollectionPluginsMap = marketplaceCollectionsAndPluginsData.marketplaceCollectionPluginsMap
|
||||
}
|
||||
|
||||
return (
|
||||
<TanstackQueryInitializer>
|
||||
<MarketplaceContextProvider
|
||||
searchParams={searchParams}
|
||||
shouldExclude={shouldExclude}
|
||||
scrollContainerId={scrollContainerId}
|
||||
showSearchParams={showSearchParams}
|
||||
>
|
||||
<Description />
|
||||
<StickySearchAndSwitchWrapper
|
||||
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
|
||||
showSearchParams={showSearchParams}
|
||||
/>
|
||||
<ListWrapper
|
||||
marketplaceCollections={marketplaceCollections}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
</MarketplaceContextProvider>
|
||||
<HydrateQueryClient searchParams={searchParams}>
|
||||
<HydrateMarketplaceAtoms preserveSearchStateInQuery={!!searchParams}>
|
||||
<Description />
|
||||
<StickySearchAndSwitchWrapper
|
||||
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
|
||||
/>
|
||||
<ListWrapper
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
</HydrateMarketplaceAtoms>
|
||||
</HydrateQueryClient>
|
||||
</TanstackQueryInitializer>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { MarketplaceCollection, SearchParamsFromCollection } from '../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import List from './index'
|
||||
|
|
@ -30,23 +30,27 @@ vi.mock('#i18n', () => ({
|
|||
useLocale: () => 'en-US',
|
||||
}))
|
||||
|
||||
// Mock useMarketplaceContext with controllable values
|
||||
const mockContextValues = {
|
||||
plugins: undefined as Plugin[] | undefined,
|
||||
pluginsTotal: 0,
|
||||
marketplaceCollectionsFromClient: undefined as MarketplaceCollection[] | undefined,
|
||||
marketplaceCollectionPluginsMapFromClient: undefined as Record<string, Plugin[]> | undefined,
|
||||
isLoading: false,
|
||||
isSuccessCollections: false,
|
||||
handleQueryPlugins: vi.fn(),
|
||||
searchPluginText: '',
|
||||
filterPluginTags: [] as string[],
|
||||
page: 1,
|
||||
handleMoreClick: vi.fn(),
|
||||
}
|
||||
// Mock marketplace state hooks with controllable values
|
||||
const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => {
|
||||
return {
|
||||
mockMarketplaceData: {
|
||||
plugins: undefined as Plugin[] | undefined,
|
||||
pluginsTotal: 0,
|
||||
marketplaceCollections: undefined as MarketplaceCollection[] | undefined,
|
||||
marketplaceCollectionPluginsMap: undefined as Record<string, Plugin[]> | undefined,
|
||||
isLoading: false,
|
||||
page: 1,
|
||||
},
|
||||
mockMoreClick: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../context', () => ({
|
||||
useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues),
|
||||
vi.mock('../state', () => ({
|
||||
useMarketplaceData: () => mockMarketplaceData,
|
||||
}))
|
||||
|
||||
vi.mock('../atoms', () => ({
|
||||
useMarketplaceMoreClick: () => mockMoreClick,
|
||||
}))
|
||||
|
||||
// Mock useLocale context
|
||||
|
|
@ -578,7 +582,7 @@ describe('ListWithCollection', () => {
|
|||
// View More Button Tests
|
||||
// ================================
|
||||
describe('View More Button', () => {
|
||||
it('should render View More button when collection is searchable and onMoreClick is provided', () => {
|
||||
it('should render View More button when collection is searchable', () => {
|
||||
const collections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
searchable: true,
|
||||
|
|
@ -587,14 +591,12 @@ describe('ListWithCollection', () => {
|
|||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
const onMoreClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
onMoreClick={onMoreClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
|
|
@ -609,42 +611,19 @@ describe('ListWithCollection', () => {
|
|||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
const onMoreClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
onMoreClick={onMoreClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('View More')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render View More button when onMoreClick is not provided', () => {
|
||||
const collections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
searchable: true,
|
||||
})]
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
onMoreClick={undefined}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('View More')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onMoreClick with search_params when View More is clicked', () => {
|
||||
it('should call moreClick hook with search_params when View More is clicked', () => {
|
||||
const searchParams: SearchParamsFromCollection = { query: 'test-query', sort_by: 'install_count' }
|
||||
const collections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
|
|
@ -654,21 +633,19 @@ describe('ListWithCollection', () => {
|
|||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
const onMoreClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
onMoreClick={onMoreClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('View More'))
|
||||
|
||||
expect(onMoreClick).toHaveBeenCalledTimes(1)
|
||||
expect(onMoreClick).toHaveBeenCalledWith(searchParams)
|
||||
expect(mockMoreClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockMoreClick).toHaveBeenCalledWith(searchParams)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -802,24 +779,15 @@ describe('ListWithCollection', () => {
|
|||
// ListWrapper Component Tests
|
||||
// ================================
|
||||
describe('ListWrapper', () => {
|
||||
const defaultProps = {
|
||||
marketplaceCollections: [] as MarketplaceCollection[],
|
||||
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
|
||||
showInstallButton: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset context values
|
||||
mockContextValues.plugins = undefined
|
||||
mockContextValues.pluginsTotal = 0
|
||||
mockContextValues.marketplaceCollectionsFromClient = undefined
|
||||
mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined
|
||||
mockContextValues.isLoading = false
|
||||
mockContextValues.isSuccessCollections = false
|
||||
mockContextValues.searchPluginText = ''
|
||||
mockContextValues.filterPluginTags = []
|
||||
mockContextValues.page = 1
|
||||
// Reset mock data
|
||||
mockMarketplaceData.plugins = undefined
|
||||
mockMarketplaceData.pluginsTotal = 0
|
||||
mockMarketplaceData.marketplaceCollections = undefined
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = undefined
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.page = 1
|
||||
})
|
||||
|
||||
// ================================
|
||||
|
|
@ -827,32 +795,32 @@ describe('ListWrapper', () => {
|
|||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(document.body).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with scrollbarGutter style', () => {
|
||||
const { container } = render(<ListWrapper {...defaultProps} />)
|
||||
const { container } = render(<ListWrapper />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveStyle({ scrollbarGutter: 'stable' })
|
||||
})
|
||||
|
||||
it('should render Loading component when isLoading is true and page is 1', () => {
|
||||
mockContextValues.isLoading = true
|
||||
mockContextValues.page = 1
|
||||
mockMarketplaceData.isLoading = true
|
||||
mockMarketplaceData.page = 1
|
||||
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByTestId('loading-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render Loading component when page > 1', () => {
|
||||
mockContextValues.isLoading = true
|
||||
mockContextValues.page = 2
|
||||
mockMarketplaceData.isLoading = true
|
||||
mockMarketplaceData.page = 2
|
||||
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -863,26 +831,26 @@ describe('ListWrapper', () => {
|
|||
// ================================
|
||||
describe('Plugins Header', () => {
|
||||
it('should render plugins result count when plugins are present', () => {
|
||||
mockContextValues.plugins = createMockPluginList(5)
|
||||
mockContextValues.pluginsTotal = 5
|
||||
mockMarketplaceData.plugins = createMockPluginList(5)
|
||||
mockMarketplaceData.pluginsTotal = 5
|
||||
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByText('5 plugins found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render SortDropdown when plugins are present', () => {
|
||||
mockContextValues.plugins = createMockPluginList(1)
|
||||
mockMarketplaceData.plugins = createMockPluginList(1)
|
||||
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render plugins header when plugins is undefined', () => {
|
||||
mockContextValues.plugins = undefined
|
||||
mockMarketplaceData.plugins = undefined
|
||||
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.queryByTestId('sort-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -892,197 +860,60 @@ describe('ListWrapper', () => {
|
|||
// List Rendering Logic Tests
|
||||
// ================================
|
||||
describe('List Rendering Logic', () => {
|
||||
it('should render List when not loading', () => {
|
||||
mockContextValues.isLoading = false
|
||||
const collections = createMockCollectionList(1)
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
it('should render collections when not loading', () => {
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWrapper
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByText('Collection 0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render List when loading but page > 1', () => {
|
||||
mockContextValues.isLoading = true
|
||||
mockContextValues.page = 2
|
||||
const collections = createMockCollectionList(1)
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
mockMarketplaceData.isLoading = true
|
||||
mockMarketplaceData.page = 2
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWrapper
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByText('Collection 0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use client collections when available', () => {
|
||||
const serverCollections = createMockCollectionList(1)
|
||||
serverCollections[0].label = { 'en-US': 'Server Collection' }
|
||||
const clientCollections = createMockCollectionList(1)
|
||||
clientCollections[0].label = { 'en-US': 'Client Collection' }
|
||||
|
||||
const serverPluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
const clientPluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
mockContextValues.marketplaceCollectionsFromClient = clientCollections
|
||||
mockContextValues.marketplaceCollectionPluginsMapFromClient = clientPluginsMap
|
||||
|
||||
render(
|
||||
<ListWrapper
|
||||
{...defaultProps}
|
||||
marketplaceCollections={serverCollections}
|
||||
marketplaceCollectionPluginsMap={serverPluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Client Collection')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Server Collection')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use server collections when client collections are not available', () => {
|
||||
const serverCollections = createMockCollectionList(1)
|
||||
serverCollections[0].label = { 'en-US': 'Server Collection' }
|
||||
const serverPluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
mockContextValues.marketplaceCollectionsFromClient = undefined
|
||||
mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined
|
||||
|
||||
render(
|
||||
<ListWrapper
|
||||
{...defaultProps}
|
||||
marketplaceCollections={serverCollections}
|
||||
marketplaceCollectionPluginsMap={serverPluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Server Collection')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Context Integration Tests
|
||||
// Data Integration Tests
|
||||
// ================================
|
||||
describe('Context Integration', () => {
|
||||
it('should pass plugins from context to List', () => {
|
||||
const plugins = createMockPluginList(2)
|
||||
mockContextValues.plugins = plugins
|
||||
describe('Data Integration', () => {
|
||||
it('should pass plugins from state to List', () => {
|
||||
mockMarketplaceData.plugins = createMockPluginList(2)
|
||||
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('card-plugin-1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass handleMoreClick from context to List', () => {
|
||||
const mockHandleMoreClick = vi.fn()
|
||||
mockContextValues.handleMoreClick = mockHandleMoreClick
|
||||
|
||||
const collections = [createMockCollection({
|
||||
it('should show View More button and call moreClick hook', () => {
|
||||
mockMarketplaceData.marketplaceCollections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
searchable: true,
|
||||
search_params: { query: 'test' },
|
||||
})]
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWrapper
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
render(<ListWrapper />)
|
||||
|
||||
fireEvent.click(screen.getByText('View More'))
|
||||
|
||||
expect(mockHandleMoreClick).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Effect Tests (handleQueryPlugins)
|
||||
// ================================
|
||||
describe('handleQueryPlugins Effect', () => {
|
||||
it('should call handleQueryPlugins when conditions are met', async () => {
|
||||
const mockHandleQueryPlugins = vi.fn()
|
||||
mockContextValues.handleQueryPlugins = mockHandleQueryPlugins
|
||||
mockContextValues.isSuccessCollections = true
|
||||
mockContextValues.marketplaceCollectionsFromClient = undefined
|
||||
mockContextValues.searchPluginText = ''
|
||||
mockContextValues.filterPluginTags = []
|
||||
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleQueryPlugins).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call handleQueryPlugins when client collections exist', async () => {
|
||||
const mockHandleQueryPlugins = vi.fn()
|
||||
mockContextValues.handleQueryPlugins = mockHandleQueryPlugins
|
||||
mockContextValues.isSuccessCollections = true
|
||||
mockContextValues.marketplaceCollectionsFromClient = createMockCollectionList(1)
|
||||
mockContextValues.searchPluginText = ''
|
||||
mockContextValues.filterPluginTags = []
|
||||
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
|
||||
// Give time for effect to run
|
||||
await waitFor(() => {
|
||||
expect(mockHandleQueryPlugins).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call handleQueryPlugins when search text exists', async () => {
|
||||
const mockHandleQueryPlugins = vi.fn()
|
||||
mockContextValues.handleQueryPlugins = mockHandleQueryPlugins
|
||||
mockContextValues.isSuccessCollections = true
|
||||
mockContextValues.marketplaceCollectionsFromClient = undefined
|
||||
mockContextValues.searchPluginText = 'search text'
|
||||
mockContextValues.filterPluginTags = []
|
||||
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleQueryPlugins).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call handleQueryPlugins when filter tags exist', async () => {
|
||||
const mockHandleQueryPlugins = vi.fn()
|
||||
mockContextValues.handleQueryPlugins = mockHandleQueryPlugins
|
||||
mockContextValues.isSuccessCollections = true
|
||||
mockContextValues.marketplaceCollectionsFromClient = undefined
|
||||
mockContextValues.searchPluginText = ''
|
||||
mockContextValues.filterPluginTags = ['tag1']
|
||||
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleQueryPlugins).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(mockMoreClick).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -1090,32 +921,32 @@ describe('ListWrapper', () => {
|
|||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty plugins array from context', () => {
|
||||
mockContextValues.plugins = []
|
||||
mockContextValues.pluginsTotal = 0
|
||||
it('should handle empty plugins array', () => {
|
||||
mockMarketplaceData.plugins = []
|
||||
mockMarketplaceData.pluginsTotal = 0
|
||||
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByText('0 plugins found')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle large pluginsTotal', () => {
|
||||
mockContextValues.plugins = createMockPluginList(10)
|
||||
mockContextValues.pluginsTotal = 10000
|
||||
mockMarketplaceData.plugins = createMockPluginList(10)
|
||||
mockMarketplaceData.pluginsTotal = 10000
|
||||
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByText('10000 plugins found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle both loading and has plugins', () => {
|
||||
mockContextValues.isLoading = true
|
||||
mockContextValues.page = 2
|
||||
mockContextValues.plugins = createMockPluginList(5)
|
||||
mockContextValues.pluginsTotal = 50
|
||||
mockMarketplaceData.isLoading = true
|
||||
mockMarketplaceData.page = 2
|
||||
mockMarketplaceData.plugins = createMockPluginList(5)
|
||||
mockMarketplaceData.pluginsTotal = 50
|
||||
|
||||
render(<ListWrapper {...defaultProps} />)
|
||||
render(<ListWrapper />)
|
||||
|
||||
// Should show plugins header and list
|
||||
expect(screen.getByText('50 plugins found')).toBeInTheDocument()
|
||||
|
|
@ -1428,106 +1259,72 @@ describe('CardWrapper (via List integration)', () => {
|
|||
describe('Combined Workflows', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockContextValues.plugins = undefined
|
||||
mockContextValues.pluginsTotal = 0
|
||||
mockContextValues.isLoading = false
|
||||
mockContextValues.page = 1
|
||||
mockContextValues.marketplaceCollectionsFromClient = undefined
|
||||
mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined
|
||||
mockMarketplaceData.plugins = undefined
|
||||
mockMarketplaceData.pluginsTotal = 0
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.page = 1
|
||||
mockMarketplaceData.marketplaceCollections = undefined
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = undefined
|
||||
})
|
||||
|
||||
it('should transition from loading to showing collections', async () => {
|
||||
mockContextValues.isLoading = true
|
||||
mockContextValues.page = 1
|
||||
mockMarketplaceData.isLoading = true
|
||||
mockMarketplaceData.page = 1
|
||||
|
||||
const { rerender } = render(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
const { rerender } = render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByTestId('loading-component')).toBeInTheDocument()
|
||||
|
||||
// Simulate loading complete
|
||||
mockContextValues.isLoading = false
|
||||
const collections = createMockCollectionList(1)
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
mockContextValues.marketplaceCollectionsFromClient = collections
|
||||
mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap
|
||||
|
||||
rerender(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
rerender(<ListWrapper />)
|
||||
|
||||
expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Collection 0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should transition from collections to search results', async () => {
|
||||
const collections = createMockCollectionList(1)
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
mockContextValues.marketplaceCollectionsFromClient = collections
|
||||
mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap
|
||||
|
||||
const { rerender } = render(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
const { rerender } = render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByText('Collection 0')).toBeInTheDocument()
|
||||
|
||||
// Simulate search results
|
||||
mockContextValues.plugins = createMockPluginList(5)
|
||||
mockContextValues.pluginsTotal = 5
|
||||
mockMarketplaceData.plugins = createMockPluginList(5)
|
||||
mockMarketplaceData.pluginsTotal = 5
|
||||
|
||||
rerender(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
rerender(<ListWrapper />)
|
||||
|
||||
expect(screen.queryByText('Collection 0')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('5 plugins found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty search results', () => {
|
||||
mockContextValues.plugins = []
|
||||
mockContextValues.pluginsTotal = 0
|
||||
mockMarketplaceData.plugins = []
|
||||
mockMarketplaceData.pluginsTotal = 0
|
||||
|
||||
render(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
|
||||
expect(screen.getByText('0 plugins found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should support pagination (page > 1)', () => {
|
||||
mockContextValues.plugins = createMockPluginList(40)
|
||||
mockContextValues.pluginsTotal = 80
|
||||
mockContextValues.isLoading = true
|
||||
mockContextValues.page = 2
|
||||
mockMarketplaceData.plugins = createMockPluginList(40)
|
||||
mockMarketplaceData.pluginsTotal = 80
|
||||
mockMarketplaceData.isLoading = true
|
||||
mockMarketplaceData.page = 2
|
||||
|
||||
render(
|
||||
<ListWrapper
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
render(<ListWrapper />)
|
||||
|
||||
// Should show existing results while loading more
|
||||
expect(screen.getByText('80 plugins found')).toBeInTheDocument()
|
||||
|
|
@ -1542,9 +1339,9 @@ describe('Combined Workflows', () => {
|
|||
describe('Accessibility', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockContextValues.plugins = undefined
|
||||
mockContextValues.isLoading = false
|
||||
mockContextValues.page = 1
|
||||
mockMarketplaceData.plugins = undefined
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.page = 1
|
||||
})
|
||||
|
||||
it('should have semantic structure with collections', () => {
|
||||
|
|
@ -1573,13 +1370,11 @@ describe('Accessibility', () => {
|
|||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
const onMoreClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
onMoreClick={onMoreClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ type ListProps = {
|
|||
showInstallButton?: boolean
|
||||
cardContainerClassName?: string
|
||||
cardRender?: (plugin: Plugin) => React.JSX.Element | null
|
||||
onMoreClick?: () => void
|
||||
emptyClassName?: string
|
||||
}
|
||||
const List = ({
|
||||
|
|
@ -23,7 +22,6 @@ const List = ({
|
|||
showInstallButton,
|
||||
cardContainerClassName,
|
||||
cardRender,
|
||||
onMoreClick,
|
||||
emptyClassName,
|
||||
}: ListProps) => {
|
||||
return (
|
||||
|
|
@ -36,7 +34,6 @@ const List = ({
|
|||
showInstallButton={showInstallButton}
|
||||
cardContainerClassName={cardContainerClassName}
|
||||
cardRender={cardRender}
|
||||
onMoreClick={onMoreClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
'use client'
|
||||
|
||||
import type { MarketplaceCollection } from '../types'
|
||||
import type { SearchParamsFromCollection } from '@/app/components/plugins/marketplace/types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { useLocale, useTranslation } from '#i18n'
|
||||
import { RiArrowRightSLine } from '@remixicon/react'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useMarketplaceMoreClick } from '../atoms'
|
||||
import CardWrapper from './card-wrapper'
|
||||
|
||||
type ListWithCollectionProps = {
|
||||
|
|
@ -15,7 +15,6 @@ type ListWithCollectionProps = {
|
|||
showInstallButton?: boolean
|
||||
cardContainerClassName?: string
|
||||
cardRender?: (plugin: Plugin) => React.JSX.Element | null
|
||||
onMoreClick?: (searchParams?: SearchParamsFromCollection) => void
|
||||
}
|
||||
const ListWithCollection = ({
|
||||
marketplaceCollections,
|
||||
|
|
@ -23,10 +22,10 @@ const ListWithCollection = ({
|
|||
showInstallButton,
|
||||
cardContainerClassName,
|
||||
cardRender,
|
||||
onMoreClick,
|
||||
}: ListWithCollectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLocale()
|
||||
const onMoreClick = useMarketplaceMoreClick()
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -44,10 +43,10 @@ const ListWithCollection = ({
|
|||
<div className="system-xs-regular text-text-tertiary">{collection.description[getLanguage(locale)]}</div>
|
||||
</div>
|
||||
{
|
||||
collection.searchable && onMoreClick && (
|
||||
collection.searchable && (
|
||||
<div
|
||||
className="system-xs-medium flex cursor-pointer items-center text-text-accent "
|
||||
onClick={() => onMoreClick?.(collection.search_params)}
|
||||
onClick={() => onMoreClick(collection.search_params)}
|
||||
>
|
||||
{t('marketplace.viewMore', { ns: 'plugin' })}
|
||||
<RiArrowRightSLine className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -1,46 +1,26 @@
|
|||
'use client'
|
||||
import type { Plugin } from '../../types'
|
||||
import type { MarketplaceCollection } from '../types'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useEffect } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useMarketplaceContext } from '../context'
|
||||
import SortDropdown from '../sort-dropdown'
|
||||
import { useMarketplaceData } from '../state'
|
||||
import List from './index'
|
||||
|
||||
type ListWrapperProps = {
|
||||
marketplaceCollections: MarketplaceCollection[]
|
||||
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
|
||||
showInstallButton?: boolean
|
||||
}
|
||||
const ListWrapper = ({
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
showInstallButton,
|
||||
}: ListWrapperProps) => {
|
||||
const { t } = useTranslation()
|
||||
const plugins = useMarketplaceContext(v => v.plugins)
|
||||
const pluginsTotal = useMarketplaceContext(v => v.pluginsTotal)
|
||||
const marketplaceCollectionsFromClient = useMarketplaceContext(v => v.marketplaceCollectionsFromClient)
|
||||
const marketplaceCollectionPluginsMapFromClient = useMarketplaceContext(v => v.marketplaceCollectionPluginsMapFromClient)
|
||||
const isLoading = useMarketplaceContext(v => v.isLoading)
|
||||
const isSuccessCollections = useMarketplaceContext(v => v.isSuccessCollections)
|
||||
const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins)
|
||||
const searchPluginText = useMarketplaceContext(v => v.searchPluginText)
|
||||
const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags)
|
||||
const page = useMarketplaceContext(v => v.page)
|
||||
const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!marketplaceCollectionsFromClient?.length
|
||||
&& isSuccessCollections
|
||||
&& !searchPluginText
|
||||
&& !filterPluginTags.length
|
||||
) {
|
||||
handleQueryPlugins()
|
||||
}
|
||||
}, [handleQueryPlugins, marketplaceCollections, marketplaceCollectionsFromClient, isSuccessCollections, searchPluginText, filterPluginTags])
|
||||
const {
|
||||
plugins,
|
||||
pluginsTotal,
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
isLoading,
|
||||
page,
|
||||
} = useMarketplaceData()
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -66,11 +46,10 @@ const ListWrapper = ({
|
|||
{
|
||||
(!isLoading || page > 1) && (
|
||||
<List
|
||||
marketplaceCollections={marketplaceCollectionsFromClient || marketplaceCollections}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMapFromClient || marketplaceCollectionPluginsMap}
|
||||
marketplaceCollections={marketplaceCollections || []}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
|
||||
plugins={plugins}
|
||||
showInstallButton={showInstallButton}
|
||||
onMoreClick={handleMoreClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
'use client'
|
||||
import type { ActivePluginType } from './constants'
|
||||
import { useTranslation } from '#i18n'
|
||||
import {
|
||||
RiArchive2Line,
|
||||
|
|
@ -8,35 +9,27 @@ import {
|
|||
RiPuzzle2Line,
|
||||
RiSpeakAiLine,
|
||||
} from '@remixicon/react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { PluginCategoryEnum } from '../types'
|
||||
import { useMarketplaceContext } from './context'
|
||||
import { searchModeAtom, useActivePluginType } from './atoms'
|
||||
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants'
|
||||
|
||||
export const PLUGIN_TYPE_SEARCH_MAP = {
|
||||
all: 'all',
|
||||
model: PluginCategoryEnum.model,
|
||||
tool: PluginCategoryEnum.tool,
|
||||
agent: PluginCategoryEnum.agent,
|
||||
extension: PluginCategoryEnum.extension,
|
||||
datasource: PluginCategoryEnum.datasource,
|
||||
trigger: PluginCategoryEnum.trigger,
|
||||
bundle: 'bundle',
|
||||
}
|
||||
type PluginTypeSwitchProps = {
|
||||
className?: string
|
||||
showSearchParams?: boolean
|
||||
}
|
||||
const PluginTypeSwitch = ({
|
||||
className,
|
||||
showSearchParams,
|
||||
}: PluginTypeSwitchProps) => {
|
||||
const { t } = useTranslation()
|
||||
const activePluginType = useMarketplaceContext(s => s.activePluginType)
|
||||
const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange)
|
||||
const [activePluginType, handleActivePluginTypeChange] = useActivePluginType()
|
||||
const setSearchMode = useSetAtom(searchModeAtom)
|
||||
|
||||
const options = [
|
||||
const options: Array<{
|
||||
value: ActivePluginType
|
||||
text: string
|
||||
icon: React.ReactNode | null
|
||||
}> = [
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.all,
|
||||
text: t('category.all', { ns: 'plugin' }),
|
||||
|
|
@ -79,23 +72,6 @@ const PluginTypeSwitch = ({
|
|||
},
|
||||
]
|
||||
|
||||
const handlePopState = useCallback(() => {
|
||||
if (!showSearchParams)
|
||||
return
|
||||
// nuqs handles popstate automatically
|
||||
const url = new URL(window.location.href)
|
||||
const category = url.searchParams.get('category') || PLUGIN_TYPE_SEARCH_MAP.all
|
||||
handleActivePluginTypeChange(category)
|
||||
}, [showSearchParams, handleActivePluginTypeChange])
|
||||
|
||||
useEffect(() => {
|
||||
// nuqs manages popstate internally, but we keep this for URL sync
|
||||
window.addEventListener('popstate', handlePopState)
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handlePopState)
|
||||
}
|
||||
}, [handlePopState])
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
|
||||
|
|
@ -112,6 +88,9 @@ const PluginTypeSwitch = ({
|
|||
)}
|
||||
onClick={() => {
|
||||
handleActivePluginTypeChange(option.value)
|
||||
if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) {
|
||||
setSearchMode(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.icon}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import type { CollectionsAndPluginsSearchParams, PluginsSearchParams } from './types'
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils'
|
||||
|
||||
// TODO: Avoid manual maintenance of query keys and better service management,
|
||||
// https://github.com/langgenius/dify/issues/30342
|
||||
|
||||
export const marketplaceKeys = {
|
||||
all: ['marketplace'] as const,
|
||||
collections: (params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collections', params] as const,
|
||||
collectionPlugins: (collectionId: string, params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collectionPlugins', collectionId, params] as const,
|
||||
plugins: (params?: PluginsSearchParams) => [...marketplaceKeys.all, 'plugins', params] as const,
|
||||
}
|
||||
|
||||
export function useMarketplaceCollectionsAndPlugins(
|
||||
collectionsParams: CollectionsAndPluginsSearchParams,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: marketplaceKeys.collections(collectionsParams),
|
||||
queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(collectionsParams, { signal }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useMarketplacePlugins(
|
||||
queryParams: PluginsSearchParams | undefined,
|
||||
) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: marketplaceKeys.plugins(queryParams),
|
||||
queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal),
|
||||
getNextPageParam: (lastPage) => {
|
||||
const nextPage = lastPage.page + 1
|
||||
const loaded = lastPage.page * lastPage.pageSize
|
||||
return loaded < (lastPage.total || 0) ? nextPage : undefined
|
||||
},
|
||||
initialPageParam: 1,
|
||||
enabled: queryParams !== undefined,
|
||||
})
|
||||
}
|
||||
|
|
@ -26,16 +26,19 @@ vi.mock('#i18n', () => ({
|
|||
}),
|
||||
}))
|
||||
|
||||
// Mock useMarketplaceContext
|
||||
const mockContextValues = {
|
||||
searchPluginText: '',
|
||||
handleSearchPluginTextChange: vi.fn(),
|
||||
filterPluginTags: [] as string[],
|
||||
handleFilterPluginTagsChange: vi.fn(),
|
||||
}
|
||||
// Mock marketplace state hooks
|
||||
const { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPluginTags, mockHandleFilterPluginTagsChange } = vi.hoisted(() => {
|
||||
return {
|
||||
mockSearchPluginText: '',
|
||||
mockHandleSearchPluginTextChange: vi.fn(),
|
||||
mockFilterPluginTags: [] as string[],
|
||||
mockHandleFilterPluginTagsChange: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../context', () => ({
|
||||
useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues),
|
||||
vi.mock('../atoms', () => ({
|
||||
useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange],
|
||||
useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange],
|
||||
}))
|
||||
|
||||
// Mock useTags hook
|
||||
|
|
@ -430,9 +433,6 @@ describe('SearchBoxWrapper', () => {
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPortalOpenState = false
|
||||
// Reset context values
|
||||
mockContextValues.searchPluginText = ''
|
||||
mockContextValues.filterPluginTags = []
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
|
|
@ -456,28 +456,14 @@ describe('SearchBoxWrapper', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Context Integration', () => {
|
||||
it('should use searchPluginText from context', () => {
|
||||
mockContextValues.searchPluginText = 'context search'
|
||||
render(<SearchBoxWrapper />)
|
||||
|
||||
expect(screen.getByDisplayValue('context search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Hook Integration', () => {
|
||||
it('should call handleSearchPluginTextChange when search changes', () => {
|
||||
render(<SearchBoxWrapper />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'new search' } })
|
||||
|
||||
expect(mockContextValues.handleSearchPluginTextChange).toHaveBeenCalledWith('new search')
|
||||
})
|
||||
|
||||
it('should use filterPluginTags from context', () => {
|
||||
mockContextValues.filterPluginTags = ['agent', 'rag']
|
||||
render(<SearchBoxWrapper />)
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
'use client'
|
||||
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useMarketplaceContext } from '../context'
|
||||
import { useFilterPluginTags, useSearchPluginText } from '../atoms'
|
||||
import SearchBox from './index'
|
||||
|
||||
const SearchBoxWrapper = () => {
|
||||
const { t } = useTranslation()
|
||||
const searchPluginText = useMarketplaceContext(v => v.searchPluginText)
|
||||
const handleSearchPluginTextChange = useMarketplaceContext(v => v.handleSearchPluginTextChange)
|
||||
const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags)
|
||||
const handleFilterPluginTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange)
|
||||
const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText()
|
||||
const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags()
|
||||
|
||||
return (
|
||||
<SearchBox
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import type { ActivePluginType } from './constants'
|
||||
import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
|
||||
|
||||
export const marketplaceSearchParamsParsers = {
|
||||
category: parseAsStringEnum<ActivePluginType>(Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[]).withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }),
|
||||
q: parseAsString.withDefault('').withOptions({ history: 'replace' }),
|
||||
tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import type { MarketplaceContextValue } from '../context'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
|
@ -28,18 +27,12 @@ vi.mock('#i18n', () => ({
|
|||
}),
|
||||
}))
|
||||
|
||||
// Mock marketplace context with controllable values
|
||||
let mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
// Mock marketplace atoms with controllable values
|
||||
let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
const mockHandleSortChange = vi.fn()
|
||||
|
||||
vi.mock('../context', () => ({
|
||||
useMarketplaceContext: (selector: (value: MarketplaceContextValue) => unknown) => {
|
||||
const contextValue = {
|
||||
sort: mockSort,
|
||||
handleSortChange: mockHandleSortChange,
|
||||
} as unknown as MarketplaceContextValue
|
||||
return selector(contextValue)
|
||||
},
|
||||
vi.mock('../atoms', () => ({
|
||||
useMarketplaceSort: () => [mockSort, mockHandleSortChange],
|
||||
}))
|
||||
|
||||
// Mock portal component with controllable open state
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useMarketplaceContext } from '../context'
|
||||
import { useMarketplaceSort } from '../atoms'
|
||||
|
||||
const SortDropdown = () => {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -36,8 +36,7 @@ const SortDropdown = () => {
|
|||
text: t('marketplace.sortOption.firstReleased', { ns: 'plugin' }),
|
||||
},
|
||||
]
|
||||
const sort = useMarketplaceContext(v => v.sort)
|
||||
const handleSortChange = useMarketplaceContext(v => v.handleSortChange)
|
||||
const [sort, handleSortChange] = useMarketplaceSort()
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0]
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import type { PluginsSearchParams } from './types'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchPluginText } from './atoms'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
|
||||
import { useMarketplaceContainerScroll } from './hooks'
|
||||
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query'
|
||||
import { getCollectionsParams, getMarketplaceListFilterType } from './utils'
|
||||
|
||||
export function useMarketplaceData() {
|
||||
const [searchPluginTextOriginal] = useSearchPluginText()
|
||||
const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 })
|
||||
const [filterPluginTags] = useFilterPluginTags()
|
||||
const [activePluginType] = useActivePluginType()
|
||||
|
||||
const collectionsQuery = useMarketplaceCollectionsAndPlugins(
|
||||
getCollectionsParams(activePluginType),
|
||||
)
|
||||
|
||||
const sort = useMarketplaceSortValue()
|
||||
const isSearchMode = useMarketplaceSearchMode()
|
||||
const queryParams = useMemo((): PluginsSearchParams | undefined => {
|
||||
if (!isSearchMode)
|
||||
return undefined
|
||||
return {
|
||||
query: searchPluginText,
|
||||
category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType,
|
||||
tags: filterPluginTags,
|
||||
sortBy: sort.sortBy,
|
||||
sortOrder: sort.sortOrder,
|
||||
type: getMarketplaceListFilterType(activePluginType),
|
||||
}
|
||||
}, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort])
|
||||
|
||||
const pluginsQuery = useMarketplacePlugins(queryParams)
|
||||
const { hasNextPage, fetchNextPage, isFetching } = pluginsQuery
|
||||
|
||||
const handlePageChange = useCallback(() => {
|
||||
if (hasNextPage && !isFetching)
|
||||
fetchNextPage()
|
||||
}, [fetchNextPage, hasNextPage, isFetching])
|
||||
|
||||
// Scroll pagination
|
||||
useMarketplaceContainerScroll(handlePageChange)
|
||||
|
||||
return {
|
||||
marketplaceCollections: collectionsQuery.data?.marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap,
|
||||
plugins: pluginsQuery.data?.pages.flatMap(page => page.plugins),
|
||||
pluginsTotal: pluginsQuery.data?.pages[0]?.total,
|
||||
page: pluginsQuery.data?.pages.length || 1,
|
||||
isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading,
|
||||
}
|
||||
}
|
||||
|
|
@ -6,12 +6,10 @@ import SearchBoxWrapper from './search-box/search-box-wrapper'
|
|||
|
||||
type StickySearchAndSwitchWrapperProps = {
|
||||
pluginTypeSwitchClassName?: string
|
||||
showSearchParams?: boolean
|
||||
}
|
||||
|
||||
const StickySearchAndSwitchWrapper = ({
|
||||
pluginTypeSwitchClassName,
|
||||
showSearchParams,
|
||||
}: StickySearchAndSwitchWrapperProps) => {
|
||||
const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-')
|
||||
|
||||
|
|
@ -24,9 +22,7 @@ const StickySearchAndSwitchWrapper = ({
|
|||
)}
|
||||
>
|
||||
<SearchBoxWrapper />
|
||||
<PluginTypeSwitch
|
||||
showSearchParams={showSearchParams}
|
||||
/>
|
||||
<PluginTypeSwitch />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import type { ActivePluginType } from './constants'
|
||||
import type {
|
||||
CollectionsAndPluginsSearchParams,
|
||||
MarketplaceCollection,
|
||||
PluginsSearchParams,
|
||||
} from '@/app/components/plugins/marketplace/types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import {
|
||||
APP_VERSION,
|
||||
IS_MARKETPLACE,
|
||||
MARKETPLACE_API_PREFIX,
|
||||
} from '@/config'
|
||||
import { postMarketplace } from '@/service/base'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
|
||||
|
||||
type MarketplaceFetchOptions = {
|
||||
signal?: AbortSignal
|
||||
|
|
@ -26,12 +29,13 @@ export const getPluginIconInMarketplace = (plugin: Plugin) => {
|
|||
return `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`
|
||||
}
|
||||
|
||||
export const getFormattedPlugin = (bundle: any) => {
|
||||
export const getFormattedPlugin = (bundle: Plugin): Plugin => {
|
||||
if (bundle.type === 'bundle') {
|
||||
return {
|
||||
...bundle,
|
||||
icon: getPluginIconInMarketplace(bundle),
|
||||
brief: bundle.description,
|
||||
// @ts-expect-error I do not have enough information
|
||||
label: bundle.labels,
|
||||
}
|
||||
}
|
||||
|
|
@ -129,6 +133,64 @@ export const getMarketplaceCollectionsAndPlugins = async (
|
|||
}
|
||||
}
|
||||
|
||||
export const getMarketplacePlugins = async (
|
||||
queryParams: PluginsSearchParams | undefined,
|
||||
pageParam: number,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
if (!queryParams) {
|
||||
return {
|
||||
plugins: [] as Plugin[],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 40,
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
query,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
category,
|
||||
tags,
|
||||
type,
|
||||
pageSize = 40,
|
||||
} = queryParams
|
||||
const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins'
|
||||
|
||||
try {
|
||||
const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, {
|
||||
body: {
|
||||
page: pageParam,
|
||||
page_size: pageSize,
|
||||
query,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
category: category !== 'all' ? category : '',
|
||||
tags,
|
||||
type,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
const resPlugins = res.data.bundles || res.data.plugins || []
|
||||
|
||||
return {
|
||||
plugins: resPlugins.map(plugin => getFormattedPlugin(plugin)),
|
||||
total: res.data.total,
|
||||
page: pageParam,
|
||||
pageSize,
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return {
|
||||
plugins: [],
|
||||
total: 0,
|
||||
page: pageParam,
|
||||
pageSize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getMarketplaceListCondition = (pluginType: string) => {
|
||||
if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum))
|
||||
return `category=${pluginType}`
|
||||
|
|
@ -142,7 +204,7 @@ export const getMarketplaceListCondition = (pluginType: string) => {
|
|||
return ''
|
||||
}
|
||||
|
||||
export const getMarketplaceListFilterType = (category: string) => {
|
||||
export const getMarketplaceListFilterType = (category: ActivePluginType) => {
|
||||
if (category === PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
return undefined
|
||||
|
||||
|
|
@ -151,3 +213,14 @@ export const getMarketplaceListFilterType = (category: string) => {
|
|||
|
||||
return 'plugin'
|
||||
}
|
||||
|
||||
export function getCollectionsParams(category: ActivePluginType): CollectionsAndPluginsSearchParams {
|
||||
if (category === PLUGIN_TYPE_SEARCH_MAP.all) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
category,
|
||||
condition: getMarketplaceListCondition(category),
|
||||
type: getMarketplaceListFilterType(category),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import { cn } from '@/utils/classnames'
|
|||
import { PLUGIN_PAGE_TABS_MAP } from '../hooks'
|
||||
import InstallFromLocalPackage from '../install-plugin/install-from-local-package'
|
||||
import InstallFromMarketplace from '../install-plugin/install-from-marketplace'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/plugin-type-switch'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants'
|
||||
import {
|
||||
PluginPageContextProvider,
|
||||
usePluginPageContext,
|
||||
|
|
|
|||
|
|
@ -262,7 +262,7 @@ vi.mock('@/app/components/base/icons/src/vender/other', () => ({
|
|||
}))
|
||||
|
||||
// Mock PLUGIN_TYPE_SEARCH_MAP
|
||||
vi.mock('../../marketplace/plugin-type-switch', () => ({
|
||||
vi.mock('../../marketplace/constants', () => ({
|
||||
PLUGIN_TYPE_SEARCH_MAP: {
|
||||
all: 'all',
|
||||
model: 'model',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { ActivePluginType } from '../../marketplace/constants'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
@ -12,7 +13,7 @@ import {
|
|||
import SearchBox from '@/app/components/plugins/marketplace/search-box'
|
||||
import { useInstalledPluginList } from '@/service/use-plugins'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/plugin-type-switch'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/constants'
|
||||
import { PluginSource } from '../../types'
|
||||
import NoDataPlaceholder from './no-data-placeholder'
|
||||
import ToolItem from './tool-item'
|
||||
|
|
@ -73,7 +74,7 @@ const ToolPicker: FC<Props> = ({
|
|||
},
|
||||
]
|
||||
|
||||
const [pluginType, setPluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
const [pluginType, setPluginType] = useState<ActivePluginType>(PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
const [query, setQuery] = useState('')
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const { data, isLoading } = useInstalledPluginList()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
'use client'
|
||||
|
||||
export { SerwistProvider } from '@serwist/turbopack/react'
|
||||
|
|
@ -195,7 +195,7 @@ const RunOnce: FC<IRunOnceProps> = ({
|
|||
noWrapper
|
||||
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
||||
placeholder={
|
||||
<div className="whitespace-pre">{item.json_schema}</div>
|
||||
<div className="whitespace-pre">{typeof item.json_schema === 'string' ? item.json_schema : JSON.stringify(item.json_schema || '', null, 2)}</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import Tooltip from '@/app/components/base/tooltip'
|
|||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { isServer } from '@/utils/client'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import BlockIcon from '../block-icon'
|
||||
|
|
@ -49,14 +50,14 @@ const FeaturedTools = ({
|
|||
const language = useGetLanguage()
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT)
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined')
|
||||
if (isServer)
|
||||
return false
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
return stored === 'true'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined')
|
||||
if (isServer)
|
||||
return
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (stored !== null)
|
||||
|
|
@ -64,7 +65,7 @@ const FeaturedTools = ({
|
|||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined')
|
||||
if (isServer)
|
||||
return
|
||||
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
|
||||
}, [isCollapsed])
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import Tooltip from '@/app/components/base/tooltip'
|
|||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { isServer } from '@/utils/client'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import BlockIcon from '../block-icon'
|
||||
|
|
@ -42,14 +43,14 @@ const FeaturedTriggers = ({
|
|||
const language = useGetLanguage()
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT)
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined')
|
||||
if (isServer)
|
||||
return false
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
return stored === 'true'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined')
|
||||
if (isServer)
|
||||
return
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (stored !== null)
|
||||
|
|
@ -57,7 +58,7 @@ const FeaturedTriggers = ({
|
|||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined')
|
||||
if (isServer)
|
||||
return
|
||||
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
|
||||
}, [isCollapsed])
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid
|
|||
import Loading from '@/app/components/base/loading'
|
||||
import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils'
|
||||
import { useRAGRecommendedPlugins } from '@/service/use-tools'
|
||||
import { isServer } from '@/utils/client'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import List from './list'
|
||||
|
||||
|
|
@ -29,14 +30,14 @@ const RAGToolRecommendations = ({
|
|||
}: RAGToolRecommendationsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined')
|
||||
if (isServer)
|
||||
return false
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
return stored === 'true'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined')
|
||||
if (isServer)
|
||||
return
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (stored !== null)
|
||||
|
|
@ -44,7 +45,7 @@ const RAGToolRecommendations = ({
|
|||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined')
|
||||
if (isServer)
|
||||
return
|
||||
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
|
||||
}, [isCollapsed])
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ const FormItem: FC<Props> = ({
|
|||
const { t } = useTranslation()
|
||||
const { type } = payload
|
||||
const fileSettings = useHooksStore(s => s.configsMap?.fileSettings)
|
||||
const jsonSchemaPlaceholder = React.useMemo(() => {
|
||||
const schema = (payload as any)?.json_schema
|
||||
if (!schema)
|
||||
return ''
|
||||
return typeof schema === 'string' ? schema : JSON.stringify(schema, null, 2)
|
||||
}, [payload])
|
||||
|
||||
const handleArrayItemChange = useCallback((index: number) => {
|
||||
return (newValue: any) => {
|
||||
|
|
@ -211,7 +217,7 @@ const FormItem: FC<Props> = ({
|
|||
noWrapper
|
||||
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
||||
placeholder={
|
||||
<div className="whitespace-pre">{payload.json_schema}</div>
|
||||
<div className="whitespace-pre">{jsonSchemaPlaceholder}</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -356,7 +356,7 @@ const formatItem = (
|
|||
try {
|
||||
if (type === VarType.object && v.json_schema) {
|
||||
varRes.children = {
|
||||
schema: JSON.parse(v.json_schema),
|
||||
schema: typeof v.json_schema === 'string' ? JSON.parse(v.json_schema) : v.json_schema,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ export type InputVar = {
|
|||
getVarValueFromDependent?: boolean
|
||||
hide?: boolean
|
||||
isFileItem?: boolean
|
||||
json_schema?: string // for jsonObject type
|
||||
json_schema?: string | Record<string, any> // for jsonObject type
|
||||
} & Partial<UploadFileSetting>
|
||||
|
||||
export type ModelConfig = {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { ToastProvider } from './components/base/toast'
|
|||
import BrowserInitializer from './components/browser-initializer'
|
||||
import { ReactScanLoader } from './components/devtools/react-scan/loader'
|
||||
import { I18nServerProvider } from './components/provider/i18n-server'
|
||||
import { SerwistProvider } from './components/provider/serwist'
|
||||
import SentryInitializer from './components/sentry-initializer'
|
||||
import RoutePrefixHandle from './routePrefixHandle'
|
||||
import './styles/globals.css'
|
||||
|
|
@ -39,6 +40,9 @@ const LocaleLayout = async ({
|
|||
}) => {
|
||||
const locale = await getLocaleOnServer()
|
||||
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''
|
||||
const swUrl = `${basePath}/serwist/sw.js`
|
||||
|
||||
const datasetMap: Record<DatasetAttr, string | undefined> = {
|
||||
[DatasetAttr.DATA_API_PREFIX]: process.env.NEXT_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"
|
||||
{...datasetMap}
|
||||
>
|
||||
<ReactScanLoader />
|
||||
<JotaiProvider>
|
||||
<ThemeProvider
|
||||
attribute="data-theme"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
enableColorScheme={false}
|
||||
>
|
||||
<NuqsAdapter>
|
||||
<BrowserInitializer>
|
||||
<SentryInitializer>
|
||||
<TanstackQueryInitializer>
|
||||
<I18nServerProvider>
|
||||
<ToastProvider>
|
||||
<GlobalPublicStoreProvider>
|
||||
{children}
|
||||
</GlobalPublicStoreProvider>
|
||||
</ToastProvider>
|
||||
</I18nServerProvider>
|
||||
</TanstackQueryInitializer>
|
||||
</SentryInitializer>
|
||||
</BrowserInitializer>
|
||||
</NuqsAdapter>
|
||||
</ThemeProvider>
|
||||
</JotaiProvider>
|
||||
<RoutePrefixHandle />
|
||||
<SerwistProvider swUrl={swUrl}>
|
||||
<ReactScanLoader />
|
||||
<JotaiProvider>
|
||||
<ThemeProvider
|
||||
attribute="data-theme"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
enableColorScheme={false}
|
||||
>
|
||||
<NuqsAdapter>
|
||||
<BrowserInitializer>
|
||||
<SentryInitializer>
|
||||
<TanstackQueryInitializer>
|
||||
<I18nServerProvider>
|
||||
<ToastProvider>
|
||||
<GlobalPublicStoreProvider>
|
||||
{children}
|
||||
</GlobalPublicStoreProvider>
|
||||
</ToastProvider>
|
||||
</I18nServerProvider>
|
||||
</TanstackQueryInitializer>
|
||||
</SentryInitializer>
|
||||
</BrowserInitializer>
|
||||
</NuqsAdapter>
|
||||
</ThemeProvider>
|
||||
</JotaiProvider>
|
||||
<RoutePrefixHandle />
|
||||
</SerwistProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||
import { NUM_INFINITE } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { isServer } from '@/utils/client'
|
||||
|
||||
export type TriggerEventsLimitModalPayload = {
|
||||
usage: number
|
||||
|
|
@ -46,7 +47,7 @@ export const useTriggerEventsLimitModal = ({
|
|||
useEffect(() => {
|
||||
if (!IS_CLOUD_EDITION)
|
||||
return
|
||||
if (typeof window === 'undefined')
|
||||
if (isServer)
|
||||
return
|
||||
if (!currentWorkspaceId)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { cache } from 'react'
|
||||
|
||||
const STALE_TIME = 1000 * 60 * 30 // 30 minutes
|
||||
|
||||
export function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: STALE_TIME,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const getQueryClientServer = cache(makeQueryClient)
|
||||
|
|
@ -1,23 +1,28 @@
|
|||
'use client'
|
||||
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { TanStackDevtoolsLoader } from '@/app/components/devtools/tanstack/loader'
|
||||
import { 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({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: STALE_TIME,
|
||||
},
|
||||
},
|
||||
})
|
||||
function getQueryClient() {
|
||||
if (isServer) {
|
||||
return makeQueryClient()
|
||||
}
|
||||
if (!browserQueryClient)
|
||||
browserQueryClient = makeQueryClient()
|
||||
return browserQueryClient
|
||||
}
|
||||
|
||||
export const TanstackQueryInitializer: FC<PropsWithChildren> = (props) => {
|
||||
const { children } = props
|
||||
export const TanstackQueryInitializer: FC<PropsWithChildren> = ({ children }) => {
|
||||
const [queryClient] = useState(getQueryClient)
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<TanStackDevtoolsLoader />
|
||||
</QueryClientProvider>
|
||||
|
|
|
|||
|
|
@ -8,11 +8,17 @@ import {
|
|||
PRICING_MODAL_QUERY_PARAM,
|
||||
PRICING_MODAL_QUERY_VALUE,
|
||||
useAccountSettingModal,
|
||||
useMarketplaceFilters,
|
||||
usePluginInstallation,
|
||||
usePricingModal,
|
||||
} 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 onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
|
||||
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.
|
||||
describe('usePluginInstallation', () => {
|
||||
it('should parse package ids from JSON arrays', () => {
|
||||
|
|
@ -597,6 +435,7 @@ describe('clearQueryParams', () => {
|
|||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
mockIsServer.value = false
|
||||
})
|
||||
|
||||
it('should remove a single key when provided one key', () => {
|
||||
|
|
@ -632,13 +471,13 @@ describe('clearQueryParams', () => {
|
|||
replaceSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should no-op when window is undefined', () => {
|
||||
it('should no-op when running on server', () => {
|
||||
// Arrange
|
||||
const replaceSpy = vi.spyOn(window.history, 'replaceState')
|
||||
vi.stubGlobal('window', undefined)
|
||||
mockIsServer.value = true
|
||||
|
||||
// Act
|
||||
expect(() => clearQueryParams('foo')).not.toThrow()
|
||||
clearQueryParams('foo')
|
||||
|
||||
// Assert
|
||||
expect(replaceSpy).not.toHaveBeenCalled()
|
||||
|
|
|
|||
|
|
@ -15,13 +15,13 @@
|
|||
|
||||
import {
|
||||
createParser,
|
||||
parseAsArrayOf,
|
||||
parseAsString,
|
||||
useQueryState,
|
||||
useQueryStates,
|
||||
} from 'nuqs'
|
||||
import { useCallback } from 'react'
|
||||
import { ACCOUNT_SETTING_MODAL_ACTION } from '@/app/components/header/account-setting/constants'
|
||||
import { isServer } from '@/utils/client'
|
||||
|
||||
/**
|
||||
* Modal State Query Parameters
|
||||
|
|
@ -93,39 +93,6 @@ export function useAccountSettingModal<T extends string = string>() {
|
|||
return [{ isOpen, payload: currentTab }, setState] as const
|
||||
}
|
||||
|
||||
/**
|
||||
* Marketplace Search Query Parameters
|
||||
*/
|
||||
export type MarketplaceFilters = {
|
||||
q: string // search query
|
||||
category: string // plugin category
|
||||
tags: string[] // array of tags
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage marketplace search/filter state via URL
|
||||
* Provides atomic updates - all params update together
|
||||
*
|
||||
* @example
|
||||
* const [filters, setFilters] = useMarketplaceFilters()
|
||||
* setFilters({ q: 'search', category: 'tool', tags: ['ai'] }) // Updates all at once
|
||||
* setFilters({ q: '' }) // Only updates q, keeps others
|
||||
* setFilters(null) // Clears all marketplace params
|
||||
*/
|
||||
export function useMarketplaceFilters() {
|
||||
return useQueryStates(
|
||||
{
|
||||
q: parseAsString.withDefault(''),
|
||||
category: parseAsString.withDefault('all').withOptions({ clearOnDefault: false }),
|
||||
tags: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
},
|
||||
{
|
||||
// Update URL without pushing to history (replaceState behavior)
|
||||
history: 'replace',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin Installation Query Parameters
|
||||
*/
|
||||
|
|
@ -210,7 +177,7 @@ export function usePluginInstallation() {
|
|||
* clearQueryParams(['param1', 'param2'])
|
||||
*/
|
||||
export function clearQueryParams(keys: string | string[]) {
|
||||
if (typeof window === 'undefined')
|
||||
if (isServer)
|
||||
return
|
||||
|
||||
const url = new URL(window.location.href)
|
||||
|
|
|
|||
|
|
@ -15,10 +15,7 @@ const config: KnipConfig = {
|
|||
ignoreBinaries: [
|
||||
'only-allow',
|
||||
],
|
||||
ignoreDependencies: [
|
||||
// required by next-pwa
|
||||
'babel-loader',
|
||||
],
|
||||
ignoreDependencies: [],
|
||||
rules: {
|
||||
files: 'warn',
|
||||
dependencies: 'warn',
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export type PromptVariable = {
|
|||
icon?: string
|
||||
icon_background?: string
|
||||
hide?: boolean // used in frontend to hide variable
|
||||
json_schema?: string
|
||||
json_schema?: string | Record<string, any>
|
||||
}
|
||||
|
||||
export type CompletionParams = {
|
||||
|
|
|
|||
|
|
@ -1,77 +1,8 @@
|
|||
import withBundleAnalyzerInit from '@next/bundle-analyzer'
|
||||
import createMDX from '@next/mdx'
|
||||
import { codeInspectorPlugin } from 'code-inspector-plugin'
|
||||
import withPWAInit from 'next-pwa'
|
||||
|
||||
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({
|
||||
extension: /\.mdx?$/,
|
||||
options: {
|
||||
|
|
@ -97,6 +28,7 @@ const remoteImageURLs = [hasSetWebPrefix ? new URL(`${process.env.NEXT_PUBLIC_WE
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
basePath: process.env.NEXT_PUBLIC_BASE_PATH || '',
|
||||
serverExternalPackages: ['esbuild-wasm'],
|
||||
transpilePackages: ['echarts', 'zrender'],
|
||||
turbopack: {
|
||||
rules: codeInspectorPlugin({
|
||||
|
|
@ -148,4 +80,4 @@ const nextConfig = {
|
|||
},
|
||||
}
|
||||
|
||||
export default withPWA(withBundleAnalyzer(withMDX(nextConfig)))
|
||||
export default withBundleAnalyzer(withMDX(nextConfig))
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v22.11.0"
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 1 Chrome version",
|
||||
|
|
@ -111,7 +111,6 @@
|
|||
"mitt": "^3.0.1",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "~15.5.9",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.8.6",
|
||||
"pinyin-pro": "^3.27.0",
|
||||
|
|
@ -153,7 +152,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^6.7.3",
|
||||
"@babel/core": "^7.28.4",
|
||||
"@chromatic-com/storybook": "^4.1.1",
|
||||
"@eslint-react/eslint-plugin": "^2.3.13",
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
|
|
@ -162,12 +160,13 @@
|
|||
"@next/eslint-plugin-next": "15.5.9",
|
||||
"@next/mdx": "15.5.9",
|
||||
"@rgrove/parse-xml": "^4.2.0",
|
||||
"@serwist/turbopack": "^9.5.0",
|
||||
"@storybook/addon-docs": "9.1.13",
|
||||
"@storybook/addon-links": "9.1.13",
|
||||
"@storybook/addon-onboarding": "9.1.13",
|
||||
"@storybook/addon-themes": "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/react-devtools": "^0.9.0",
|
||||
"@tanstack/react-form-devtools": "^0.2.9",
|
||||
|
|
@ -194,9 +193,9 @@
|
|||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitest/coverage-v8": "4.0.16",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"babel-loader": "^10.0.0",
|
||||
"code-inspector-plugin": "1.2.9",
|
||||
"cross-env": "^10.1.0",
|
||||
"esbuild-wasm": "^0.27.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
|
|
@ -212,6 +211,7 @@
|
|||
"postcss": "^8.5.6",
|
||||
"react-scan": "^0.4.3",
|
||||
"sass": "^1.93.2",
|
||||
"serwist": "^9.5.0",
|
||||
"storybook": "9.1.17",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"tsx": "^4.21.0",
|
||||
|
|
|
|||
1171
web/pnpm-lock.yaml
1171
web/pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
|
@ -72,12 +72,12 @@ async function getNewAccessToken(timeout: number): Promise<void> {
|
|||
}
|
||||
|
||||
function releaseRefreshLock() {
|
||||
if (isRefreshing) {
|
||||
isRefreshing = false
|
||||
globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY)
|
||||
globalThis.localStorage.removeItem('last_refresh_time')
|
||||
globalThis.removeEventListener('beforeunload', releaseRefreshLock)
|
||||
}
|
||||
// Always clear the refresh lock to avoid cross-tab deadlocks.
|
||||
// This is safe to call multiple times and from tabs that were only waiting.
|
||||
isRefreshing = false
|
||||
globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY)
|
||||
globalThis.localStorage.removeItem('last_refresh_time')
|
||||
globalThis.removeEventListener('beforeunload', releaseRefreshLock)
|
||||
}
|
||||
|
||||
export async function refreshAccessTokenOrReLogin(timeout: number) {
|
||||
|
|
|
|||
|
|
@ -10,13 +10,14 @@ import type {
|
|||
AppVoicesListResponse,
|
||||
WorkflowDailyConversationsResponse,
|
||||
} from '@/models/app'
|
||||
import type { App, AppModeEnum } from '@/types/app'
|
||||
import type { App } from '@/types/app'
|
||||
import {
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { get, post } from './base'
|
||||
import { useInvalid } from './use-base'
|
||||
|
||||
|
|
@ -36,6 +37,16 @@ type DateRangeParams = {
|
|||
end?: string
|
||||
}
|
||||
|
||||
// Allowed app modes for filtering; defined at module scope to avoid re-creating on every call
|
||||
const allowedModes = new Set<AppModeEnum | 'all'>([
|
||||
'all',
|
||||
AppModeEnum.WORKFLOW,
|
||||
AppModeEnum.ADVANCED_CHAT,
|
||||
AppModeEnum.CHAT,
|
||||
AppModeEnum.AGENT_CHAT,
|
||||
AppModeEnum.COMPLETION,
|
||||
])
|
||||
|
||||
const normalizeAppListParams = (params: AppListParams) => {
|
||||
const {
|
||||
page = 1,
|
||||
|
|
@ -46,11 +57,13 @@ const normalizeAppListParams = (params: AppListParams) => {
|
|||
is_created_by_me,
|
||||
} = params
|
||||
|
||||
const safeMode = allowedModes.has((mode as any)) ? mode : undefined
|
||||
|
||||
return {
|
||||
page,
|
||||
limit,
|
||||
name,
|
||||
...(mode && mode !== 'all' ? { mode } : {}),
|
||||
...(safeMode && safeMode !== 'all' ? { mode: safeMode } : {}),
|
||||
...(tag_ids?.length ? { tag_ids } : {}),
|
||||
...(is_created_by_me ? { is_created_by_me } : {}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,30 @@ export const sanitizeWorkflowDraftPayload = (params: WorkflowDraftSyncParams): W
|
|||
if (!graph?.nodes?.length)
|
||||
return params
|
||||
|
||||
const sanitizedNodes = graph.nodes.map(node => sanitizeTriggerPluginNode(node as Node<TriggerPluginNodePayload>))
|
||||
const sanitizedNodes = graph.nodes.map((node) => {
|
||||
// First sanitize known node types (TriggerPlugin)
|
||||
const n = sanitizeTriggerPluginNode(node as Node<TriggerPluginNodePayload>) as Node<any>
|
||||
|
||||
// Normalize Start node variable json_schema: ensure dict, not string
|
||||
if ((n.data as any)?.type === BlockEnum.Start && Array.isArray((n.data as any).variables)) {
|
||||
const next = { ...n, data: { ...n.data } }
|
||||
next.data.variables = (n.data as any).variables.map((v: any) => {
|
||||
if (v && v.type === 'json_object' && typeof v.json_schema === 'string') {
|
||||
try {
|
||||
const obj = JSON.parse(v.json_schema)
|
||||
return { ...v, json_schema: obj }
|
||||
}
|
||||
catch {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return v
|
||||
})
|
||||
return next
|
||||
}
|
||||
|
||||
return n
|
||||
})
|
||||
|
||||
return {
|
||||
...params,
|
||||
|
|
@ -126,7 +149,25 @@ export const hydrateWorkflowDraftResponse = (draft: FetchWorkflowDraftResponse):
|
|||
if (node.data)
|
||||
removeTempProperties(node.data as Record<string, unknown>)
|
||||
|
||||
return hydrateTriggerPluginNode(node)
|
||||
let n = hydrateTriggerPluginNode(node)
|
||||
// Normalize Start node variable json_schema to object when loading
|
||||
if ((n.data as any)?.type === BlockEnum.Start && Array.isArray((n.data as any).variables)) {
|
||||
const next = { ...n, data: { ...n.data } } as Node<any>
|
||||
next.data.variables = (n.data as any).variables.map((v: any) => {
|
||||
if (v && v.type === 'json_object' && typeof v.json_schema === 'string') {
|
||||
try {
|
||||
const obj = JSON.parse(v.json_schema)
|
||||
return { ...v, json_schema: obj }
|
||||
}
|
||||
catch {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return v
|
||||
})
|
||||
n = next
|
||||
}
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
} from '@/types/workflow'
|
||||
import { get, post } from './base'
|
||||
import { getFlowPrefix } from './utils'
|
||||
import { sanitizeWorkflowDraftPayload } from './workflow-payload'
|
||||
|
||||
export const fetchWorkflowDraft = (url: string) => {
|
||||
return get(url, {}, { silent: true }) as Promise<FetchWorkflowDraftResponse>
|
||||
|
|
@ -19,7 +20,8 @@ export const syncWorkflowDraft = ({ url, params }: {
|
|||
url: string
|
||||
params: Pick<FetchWorkflowDraftResponse, 'graph' | 'features' | 'environment_variables' | 'conversation_variables'>
|
||||
}) => {
|
||||
return post<CommonResponse & { updated_at: number, hash: string }>(url, { body: params }, { silent: true })
|
||||
const sanitized = sanitizeWorkflowDraftPayload(params)
|
||||
return post<CommonResponse & { updated_at: number, hash: string }>(url, { body: sanitized }, { silent: true })
|
||||
}
|
||||
|
||||
export const fetchNodesDefaultConfigs = (url: string) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
export const isServer = typeof window === 'undefined'
|
||||
|
||||
export const isClient = typeof window !== 'undefined'
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { isServer } from '@/utils/client'
|
||||
|
||||
/**
|
||||
* Send Google Analytics event
|
||||
* @param eventName - event name
|
||||
|
|
@ -7,7 +9,7 @@ export const sendGAEvent = (
|
|||
eventName: string,
|
||||
eventParams?: GtagEventParams,
|
||||
): void => {
|
||||
if (typeof window === 'undefined' || typeof (window as any).gtag !== 'function') {
|
||||
if (isServer || typeof (window as any).gtag !== 'function') {
|
||||
return
|
||||
}
|
||||
(window as any).gtag('event', eventName, eventParams)
|
||||
|
|
|
|||
Loading…
Reference in New Issue