Compare commits

...

28 Commits

Author SHA1 Message Date
JzoNg
b3acf83090 Merge branch 'main' into jzh 2026-03-23 16:46:26 +08:00
JzoNg
36c3d6e48a feat(web): snippet list fetching & display 2026-03-23 16:37:05 +08:00
Coding On Star
fdc880bc67 test(workflow): add unit tests for workflow components (#33910)
Some checks are pending
autofix.ci / autofix (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Waiting to run
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Blocked by required conditions
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Blocked by required conditions
Main CI Pipeline / Check Changed Files (push) Waiting to run
Main CI Pipeline / API Tests (push) Blocked by required conditions
Main CI Pipeline / Web Tests (push) Blocked by required conditions
Main CI Pipeline / Style Check (push) Waiting to run
Main CI Pipeline / VDB Tests (push) Blocked by required conditions
Main CI Pipeline / DB Migration Test (push) Blocked by required conditions
Trigger i18n Sync on Push / trigger (push) Waiting to run
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-03-23 16:37:03 +08:00
Desel72
abda859075 refactor: migrate execution extra content repository tests from mocks to testcontainers (#33852) 2026-03-23 17:32:11 +09:00
yyh
dc1a68661c refactor(web): migrate members invite overlays to base ui (#33922)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-23 16:31:41 +08:00
dependabot[bot]
edb261bc90 chore(deps-dev): bump the dev group across 1 directory with 12 updates (#33919)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 17:26:47 +09:00
dependabot[bot]
407f5f0cde chore(deps-dev): bump alibabacloud-gpdb20160503 from 3.8.3 to 5.1.0 in /api in the vdb group (#33879)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 17:25:44 +09:00
Bowen Liang
d7cafc6296 chore(dep): move hono and @hono/node-server to devDependencies (#33742) 2026-03-23 16:22:33 +08:00
JzoNg
f782ac6b3c feat(web): create snippets by DSL import 2026-03-23 14:55:36 +08:00
JzoNg
feef2dd1fa feat(web): add snippet creation dialog flow 2026-03-23 11:29:41 +08:00
JzoNg
a716d8789d refactor: extract snippet list components 2026-03-23 10:48:15 +08:00
JzoNg
6816f89189 Merge branch 'main' into jzh 2026-03-23 10:13:45 +08:00
JzoNg
bfcac64a9d Merge branch 'main' into jzh 2026-03-20 15:33:49 +08:00
JzoNg
664eb601a2 feat(web): add api of snippet worfklows 2026-03-20 15:29:53 +08:00
JzoNg
8e5cc4e0aa feat(web): add evaluation api 2026-03-20 15:23:03 +08:00
JzoNg
9f28575903 feat(web): add snippets api 2026-03-20 15:11:33 +08:00
JzoNg
4b9a26a5e6 Merge branch 'main' into jzh 2026-03-20 14:01:34 +08:00
JzoNg
7b85adf1cc Merge branch 'main' into jzh 2026-03-20 10:46:45 +08:00
JzoNg
c964708ebe Merge branch 'main' into jzh 2026-03-18 18:07:20 +08:00
JzoNg
883eb498c0 Merge branch 'main' into jzh 2026-03-18 17:40:51 +08:00
JzoNg
4d3738d225 Merge branch 'main' into feat/evaluation-fe 2026-03-17 10:42:44 +08:00
JzoNg
dd0dee739d Merge branch 'main' into jzh 2026-03-16 15:43:20 +08:00
zxhlyh
4d19914fcb Merge branch 'main' into feat/evaluation-fe 2026-03-16 10:47:37 +08:00
zxhlyh
887c7710e9 feat: evaluation 2026-03-16 10:46:33 +08:00
zxhlyh
7a722773c7 feat: snippet canvas 2026-03-13 17:45:04 +08:00
zxhlyh
a763aff58b feat: snippets list 2026-03-13 16:12:42 +08:00
zxhlyh
c1011f4e5c feat: add to snippet 2026-03-13 14:29:59 +08:00
zxhlyh
f7afa103a5 feat: select snippets 2026-03-13 13:43:29 +08:00
151 changed files with 20649 additions and 1443 deletions

View File

@@ -66,8 +66,8 @@ class HumanInputContent(ExecutionExtraContent):
form_id: Mapped[str] = mapped_column(StringUUID, nullable=True)
@classmethod
def new(cls, form_id: str, message_id: str | None) -> "HumanInputContent":
return cls(form_id=form_id, message_id=message_id)
def new(cls, *, workflow_run_id: str, form_id: str, message_id: str | None) -> "HumanInputContent":
return cls(workflow_run_id=workflow_run_id, form_id=form_id, message_id=message_id)
form: Mapped["HumanInputForm"] = relationship(
"HumanInputForm",

View File

@@ -203,7 +203,7 @@ tools = ["cloudscraper~=1.2.71", "nltk~=3.9.1"]
# Required by vector store clients
############################################################
vdb = [
"alibabacloud_gpdb20160503~=3.8.0",
"alibabacloud_gpdb20160503~=5.1.0",
"alibabacloud_tea_openapi~=0.4.3",
"chromadb==0.5.20",
"clickhouse-connect~=0.14.1",

View File

@@ -1,27 +0,0 @@
from __future__ import annotations
from sqlalchemy.orm import sessionmaker
from extensions.ext_database import db
from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
from tests.test_containers_integration_tests.helpers.execution_extra_content import (
create_human_input_message_fixture,
)
def test_get_by_message_ids_returns_human_input_content(db_session_with_containers):
fixture = create_human_input_message_fixture(db_session_with_containers)
repository = SQLAlchemyExecutionExtraContentRepository(
session_maker=sessionmaker(bind=db.engine, expire_on_commit=False)
)
results = repository.get_by_message_ids([fixture.message.id])
assert len(results) == 1
assert len(results[0]) == 1
content = results[0][0]
assert content.submitted is True
assert content.form_submission_data is not None
assert content.form_submission_data.action_id == fixture.action_id
assert content.form_submission_data.action_text == fixture.action_text
assert content.form_submission_data.rendered_content == fixture.form.rendered_content

View File

@@ -0,0 +1,407 @@
"""Integration tests for SQLAlchemyExecutionExtraContentRepository using Testcontainers.
Part of #32454 — replaces the mock-based unit tests with real database interactions.
"""
from __future__ import annotations
from collections.abc import Generator
from dataclasses import dataclass
from datetime import datetime, timedelta
from decimal import Decimal
from uuid import uuid4
import pytest
from sqlalchemy import Engine, delete, select
from sqlalchemy.orm import Session, sessionmaker
from dify_graph.nodes.human_input.entities import FormDefinition, UserAction
from dify_graph.nodes.human_input.enums import HumanInputFormStatus
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.enums import ConversationFromSource, InvokeFrom
from models.execution_extra_content import ExecutionExtraContent, HumanInputContent
from models.human_input import (
ConsoleRecipientPayload,
HumanInputDelivery,
HumanInputForm,
HumanInputFormRecipient,
RecipientType,
)
from models.model import App, Conversation, Message
from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
@dataclass
class _TestScope:
"""Per-test data scope used to isolate DB rows.
IDs are populated after flushing the base entities to the database.
"""
tenant_id: str = ""
app_id: str = ""
user_id: str = ""
def _cleanup_scope_data(session: Session, scope: _TestScope) -> None:
"""Remove test-created DB rows for a test scope."""
form_ids_subquery = select(HumanInputForm.id).where(
HumanInputForm.tenant_id == scope.tenant_id,
)
session.execute(delete(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids_subquery)))
session.execute(delete(HumanInputDelivery).where(HumanInputDelivery.form_id.in_(form_ids_subquery)))
session.execute(
delete(ExecutionExtraContent).where(
ExecutionExtraContent.workflow_run_id.in_(
select(HumanInputForm.workflow_run_id).where(HumanInputForm.tenant_id == scope.tenant_id)
)
)
)
session.execute(delete(HumanInputForm).where(HumanInputForm.tenant_id == scope.tenant_id))
session.execute(delete(Message).where(Message.app_id == scope.app_id))
session.execute(delete(Conversation).where(Conversation.app_id == scope.app_id))
session.execute(delete(App).where(App.id == scope.app_id))
session.execute(delete(TenantAccountJoin).where(TenantAccountJoin.tenant_id == scope.tenant_id))
session.execute(delete(Account).where(Account.id == scope.user_id))
session.execute(delete(Tenant).where(Tenant.id == scope.tenant_id))
session.commit()
def _seed_base_entities(session: Session, scope: _TestScope) -> None:
"""Create the base tenant, account, and app needed by tests."""
tenant = Tenant(name="Test Tenant")
session.add(tenant)
session.flush()
scope.tenant_id = tenant.id
account = Account(
name="Test Account",
email=f"test_{uuid4()}@example.com",
password="hashed-password",
password_salt="salt",
interface_language="en-US",
timezone="UTC",
)
session.add(account)
session.flush()
scope.user_id = account.id
tenant_join = TenantAccountJoin(
tenant_id=scope.tenant_id,
account_id=scope.user_id,
role=TenantAccountRole.OWNER,
current=True,
)
session.add(tenant_join)
app = App(
tenant_id=scope.tenant_id,
name="Test App",
description="",
mode="chat",
icon_type="emoji",
icon="bot",
icon_background="#FFFFFF",
enable_site=False,
enable_api=True,
api_rpm=100,
api_rph=100,
is_demo=False,
is_public=False,
is_universal=False,
created_by=scope.user_id,
updated_by=scope.user_id,
)
session.add(app)
session.flush()
scope.app_id = app.id
def _create_conversation(session: Session, scope: _TestScope) -> Conversation:
conversation = Conversation(
app_id=scope.app_id,
mode="chat",
name="Test Conversation",
summary="",
introduction="",
system_instruction="",
status="normal",
invoke_from=InvokeFrom.EXPLORE,
from_source=ConversationFromSource.CONSOLE,
from_account_id=scope.user_id,
from_end_user_id=None,
)
conversation.inputs = {}
session.add(conversation)
session.flush()
return conversation
def _create_message(
session: Session,
scope: _TestScope,
conversation_id: str,
workflow_run_id: str,
) -> Message:
message = Message(
app_id=scope.app_id,
conversation_id=conversation_id,
inputs={},
query="test query",
message={"messages": []},
answer="test answer",
message_tokens=50,
message_unit_price=Decimal("0.001"),
answer_tokens=80,
answer_unit_price=Decimal("0.001"),
provider_response_latency=0.5,
currency="USD",
from_source=ConversationFromSource.CONSOLE,
from_account_id=scope.user_id,
workflow_run_id=workflow_run_id,
)
session.add(message)
session.flush()
return message
def _create_submitted_form(
session: Session,
scope: _TestScope,
*,
workflow_run_id: str,
action_id: str = "approve",
action_title: str = "Approve",
node_title: str = "Approval",
) -> HumanInputForm:
expiration_time = datetime.utcnow() + timedelta(days=1)
form_definition = FormDefinition(
form_content="content",
inputs=[],
user_actions=[UserAction(id=action_id, title=action_title)],
rendered_content="rendered",
expiration_time=expiration_time,
node_title=node_title,
display_in_ui=True,
)
form = HumanInputForm(
tenant_id=scope.tenant_id,
app_id=scope.app_id,
workflow_run_id=workflow_run_id,
node_id="node-id",
form_definition=form_definition.model_dump_json(),
rendered_content=f"Rendered {action_title}",
status=HumanInputFormStatus.SUBMITTED,
expiration_time=expiration_time,
selected_action_id=action_id,
)
session.add(form)
session.flush()
return form
def _create_waiting_form(
session: Session,
scope: _TestScope,
*,
workflow_run_id: str,
default_values: dict | None = None,
) -> HumanInputForm:
expiration_time = datetime.utcnow() + timedelta(days=1)
form_definition = FormDefinition(
form_content="content",
inputs=[],
user_actions=[UserAction(id="approve", title="Approve")],
rendered_content="rendered",
expiration_time=expiration_time,
default_values=default_values or {"name": "John"},
node_title="Approval",
display_in_ui=True,
)
form = HumanInputForm(
tenant_id=scope.tenant_id,
app_id=scope.app_id,
workflow_run_id=workflow_run_id,
node_id="node-id",
form_definition=form_definition.model_dump_json(),
rendered_content="Rendered block",
status=HumanInputFormStatus.WAITING,
expiration_time=expiration_time,
)
session.add(form)
session.flush()
return form
def _create_human_input_content(
session: Session,
*,
workflow_run_id: str,
message_id: str,
form_id: str,
) -> HumanInputContent:
content = HumanInputContent.new(
workflow_run_id=workflow_run_id,
message_id=message_id,
form_id=form_id,
)
session.add(content)
return content
def _create_recipient(
session: Session,
*,
form_id: str,
delivery_id: str,
recipient_type: RecipientType = RecipientType.CONSOLE,
access_token: str = "token-1",
) -> HumanInputFormRecipient:
payload = ConsoleRecipientPayload(account_id=None)
recipient = HumanInputFormRecipient(
form_id=form_id,
delivery_id=delivery_id,
recipient_type=recipient_type,
recipient_payload=payload.model_dump_json(),
access_token=access_token,
)
session.add(recipient)
return recipient
def _create_delivery(session: Session, *, form_id: str) -> HumanInputDelivery:
from dify_graph.nodes.human_input.enums import DeliveryMethodType
from models.human_input import ConsoleDeliveryPayload
delivery = HumanInputDelivery(
form_id=form_id,
delivery_method_type=DeliveryMethodType.WEBAPP,
channel_payload=ConsoleDeliveryPayload().model_dump_json(),
)
session.add(delivery)
session.flush()
return delivery
@pytest.fixture
def repository(db_session_with_containers: Session) -> SQLAlchemyExecutionExtraContentRepository:
"""Build a repository backed by the testcontainers database engine."""
engine = db_session_with_containers.get_bind()
assert isinstance(engine, Engine)
return SQLAlchemyExecutionExtraContentRepository(sessionmaker(bind=engine, expire_on_commit=False))
@pytest.fixture
def test_scope(db_session_with_containers: Session) -> Generator[_TestScope]:
"""Provide an isolated scope and clean related data after each test."""
scope = _TestScope()
_seed_base_entities(db_session_with_containers, scope)
db_session_with_containers.commit()
yield scope
_cleanup_scope_data(db_session_with_containers, scope)
class TestGetByMessageIds:
"""Tests for SQLAlchemyExecutionExtraContentRepository.get_by_message_ids."""
def test_groups_contents_by_message(
self,
db_session_with_containers: Session,
repository: SQLAlchemyExecutionExtraContentRepository,
test_scope: _TestScope,
) -> None:
"""Submitted forms are correctly mapped and grouped by message ID."""
workflow_run_id = str(uuid4())
conversation = _create_conversation(db_session_with_containers, test_scope)
msg1 = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id)
msg2 = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id)
form = _create_submitted_form(
db_session_with_containers,
test_scope,
workflow_run_id=workflow_run_id,
action_id="approve",
action_title="Approve",
)
_create_human_input_content(
db_session_with_containers,
workflow_run_id=workflow_run_id,
message_id=msg1.id,
form_id=form.id,
)
db_session_with_containers.commit()
result = repository.get_by_message_ids([msg1.id, msg2.id])
assert len(result) == 2
# msg1 has one submitted content
assert len(result[0]) == 1
content = result[0][0]
assert content.submitted is True
assert content.workflow_run_id == workflow_run_id
assert content.form_submission_data is not None
assert content.form_submission_data.action_id == "approve"
assert content.form_submission_data.action_text == "Approve"
assert content.form_submission_data.rendered_content == "Rendered Approve"
assert content.form_submission_data.node_id == "node-id"
assert content.form_submission_data.node_title == "Approval"
# msg2 has no content
assert result[1] == []
def test_returns_unsubmitted_form_definition(
self,
db_session_with_containers: Session,
repository: SQLAlchemyExecutionExtraContentRepository,
test_scope: _TestScope,
) -> None:
"""Waiting forms return full form_definition with resolved token and defaults."""
workflow_run_id = str(uuid4())
conversation = _create_conversation(db_session_with_containers, test_scope)
msg = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id)
form = _create_waiting_form(
db_session_with_containers,
test_scope,
workflow_run_id=workflow_run_id,
default_values={"name": "John"},
)
delivery = _create_delivery(db_session_with_containers, form_id=form.id)
_create_recipient(
db_session_with_containers,
form_id=form.id,
delivery_id=delivery.id,
access_token="token-1",
)
_create_human_input_content(
db_session_with_containers,
workflow_run_id=workflow_run_id,
message_id=msg.id,
form_id=form.id,
)
db_session_with_containers.commit()
result = repository.get_by_message_ids([msg.id])
assert len(result) == 1
assert len(result[0]) == 1
domain_content = result[0][0]
assert domain_content.submitted is False
assert domain_content.workflow_run_id == workflow_run_id
assert domain_content.form_definition is not None
form_def = domain_content.form_definition
assert form_def.form_id == form.id
assert form_def.node_id == "node-id"
assert form_def.node_title == "Approval"
assert form_def.form_content == "Rendered block"
assert form_def.display_in_ui is True
assert form_def.form_token == "token-1"
assert form_def.resolved_default_values == {"name": "John"}
assert form_def.expiration_time == int(form.expiration_time.timestamp())
def test_empty_message_ids_returns_empty_list(
self,
repository: SQLAlchemyExecutionExtraContentRepository,
) -> None:
"""Passing no message IDs returns an empty list without hitting the DB."""
result = repository.get_by_message_ids([])
assert result == []

View File

@@ -1,180 +0,0 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from core.entities.execution_extra_content import HumanInputContent as HumanInputContentDomain
from core.entities.execution_extra_content import HumanInputFormSubmissionData
from dify_graph.nodes.human_input.entities import (
FormDefinition,
UserAction,
)
from dify_graph.nodes.human_input.enums import HumanInputFormStatus
from models.execution_extra_content import HumanInputContent as HumanInputContentModel
from models.human_input import ConsoleRecipientPayload, HumanInputForm, HumanInputFormRecipient, RecipientType
from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
class _FakeScalarResult:
def __init__(self, values: Sequence[HumanInputContentModel]):
self._values = list(values)
def all(self) -> list[HumanInputContentModel]:
return list(self._values)
class _FakeSession:
def __init__(self, values: Sequence[Sequence[object]]):
self._values = list(values)
def scalars(self, _stmt):
if not self._values:
return _FakeScalarResult([])
return _FakeScalarResult(self._values.pop(0))
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
@dataclass
class _FakeSessionMaker:
session: _FakeSession
def __call__(self) -> _FakeSession:
return self.session
def _build_form(action_id: str, action_title: str, rendered_content: str) -> HumanInputForm:
expiration_time = datetime.now(UTC) + timedelta(days=1)
definition = FormDefinition(
form_content="content",
inputs=[],
user_actions=[UserAction(id=action_id, title=action_title)],
rendered_content="rendered",
expiration_time=expiration_time,
node_title="Approval",
display_in_ui=True,
)
form = HumanInputForm(
id=f"form-{action_id}",
tenant_id="tenant-id",
app_id="app-id",
workflow_run_id="workflow-run",
node_id="node-id",
form_definition=definition.model_dump_json(),
rendered_content=rendered_content,
status=HumanInputFormStatus.SUBMITTED,
expiration_time=expiration_time,
)
form.selected_action_id = action_id
return form
def _build_content(message_id: str, action_id: str, action_title: str) -> HumanInputContentModel:
form = _build_form(
action_id=action_id,
action_title=action_title,
rendered_content=f"Rendered {action_title}",
)
content = HumanInputContentModel(
id=f"content-{message_id}",
form_id=form.id,
message_id=message_id,
workflow_run_id=form.workflow_run_id,
)
content.form = form
return content
def test_get_by_message_ids_groups_contents_by_message() -> None:
message_ids = ["msg-1", "msg-2"]
contents = [_build_content("msg-1", "approve", "Approve")]
repository = SQLAlchemyExecutionExtraContentRepository(
session_maker=_FakeSessionMaker(session=_FakeSession(values=[contents, []]))
)
result = repository.get_by_message_ids(message_ids)
assert len(result) == 2
assert [content.model_dump(mode="json", exclude_none=True) for content in result[0]] == [
HumanInputContentDomain(
workflow_run_id="workflow-run",
submitted=True,
form_submission_data=HumanInputFormSubmissionData(
node_id="node-id",
node_title="Approval",
rendered_content="Rendered Approve",
action_id="approve",
action_text="Approve",
),
).model_dump(mode="json", exclude_none=True)
]
assert result[1] == []
def test_get_by_message_ids_returns_unsubmitted_form_definition() -> None:
expiration_time = datetime.now(UTC) + timedelta(days=1)
definition = FormDefinition(
form_content="content",
inputs=[],
user_actions=[UserAction(id="approve", title="Approve")],
rendered_content="rendered",
expiration_time=expiration_time,
default_values={"name": "John"},
node_title="Approval",
display_in_ui=True,
)
form = HumanInputForm(
id="form-1",
tenant_id="tenant-id",
app_id="app-id",
workflow_run_id="workflow-run",
node_id="node-id",
form_definition=definition.model_dump_json(),
rendered_content="Rendered block",
status=HumanInputFormStatus.WAITING,
expiration_time=expiration_time,
)
content = HumanInputContentModel(
id="content-msg-1",
form_id=form.id,
message_id="msg-1",
workflow_run_id=form.workflow_run_id,
)
content.form = form
recipient = HumanInputFormRecipient(
form_id=form.id,
delivery_id="delivery-1",
recipient_type=RecipientType.CONSOLE,
recipient_payload=ConsoleRecipientPayload(account_id=None).model_dump_json(),
access_token="token-1",
)
repository = SQLAlchemyExecutionExtraContentRepository(
session_maker=_FakeSessionMaker(session=_FakeSession(values=[[content], [recipient]]))
)
result = repository.get_by_message_ids(["msg-1"])
assert len(result) == 1
assert len(result[0]) == 1
domain_content = result[0][0]
assert domain_content.submitted is False
assert domain_content.workflow_run_id == "workflow-run"
assert domain_content.form_definition is not None
assert domain_content.form_definition.expiration_time == int(form.expiration_time.timestamp())
assert domain_content.form_definition is not None
form_definition = domain_content.form_definition
assert form_definition.form_id == "form-1"
assert form_definition.node_id == "node-id"
assert form_definition.node_title == "Approval"
assert form_definition.form_content == "Rendered block"
assert form_definition.display_in_ui is True
assert form_definition.form_token == "token-1"
assert form_definition.resolved_default_values == {"name": "John"}
assert form_definition.expiration_time == int(form.expiration_time.timestamp())

254
api/uv.lock generated
View File

@@ -169,12 +169,6 @@ version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a0/87/1d7019d23891897cb076b2f7e3c81ab3c2ba91de3bb067196f675d60d34c/alibabacloud-credentials-api-1.0.0.tar.gz", hash = "sha256:8c340038d904f0218d7214a8f4088c31912bfcf279af2cbc7d9be4897a97dd2f", size = 2330, upload-time = "2025-01-13T05:53:04.931Z" }
[[package]]
name = "alibabacloud-endpoint-util"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/7d/8cc92a95c920e344835b005af6ea45a0db98763ad6ad19299d26892e6c8d/alibabacloud_endpoint_util-0.0.4.tar.gz", hash = "sha256:a593eb8ddd8168d5dc2216cd33111b144f9189fcd6e9ca20e48f358a739bbf90", size = 2813, upload-time = "2025-06-12T07:20:52.572Z" }
[[package]]
name = "alibabacloud-gateway-spi"
version = "0.0.3"
@@ -186,69 +180,17 @@ sdist = { url = "https://files.pythonhosted.org/packages/ab/98/d7111245f17935bf7
[[package]]
name = "alibabacloud-gpdb20160503"
version = "3.8.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-endpoint-util" },
{ name = "alibabacloud-openapi-util" },
{ name = "alibabacloud-openplatform20191219" },
{ name = "alibabacloud-oss-sdk" },
{ name = "alibabacloud-oss-util" },
{ name = "alibabacloud-tea-fileform" },
{ name = "alibabacloud-tea-openapi" },
{ name = "alibabacloud-tea-util" },
]
sdist = { url = "https://files.pythonhosted.org/packages/15/6a/cc72e744e95c8f37fa6a84e66ae0b9b57a13ee97a0ef03d94c7127c31d75/alibabacloud_gpdb20160503-3.8.3.tar.gz", hash = "sha256:4dfcc0d9cff5a921d529d76f4bf97e2ceb9dc2fa53f00ab055f08509423d8e30", size = 155092, upload-time = "2024-07-18T17:09:42.438Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/36/bce41704b3bf59d607590ec73a42a254c5dea27c0f707aee11d20512a200/alibabacloud_gpdb20160503-3.8.3-py3-none-any.whl", hash = "sha256:06e1c46ce5e4e9d1bcae76e76e51034196c625799d06b2efec8d46a7df323fe8", size = 156097, upload-time = "2024-07-18T17:09:40.414Z" },
]
[[package]]
name = "alibabacloud-openapi-util"
version = "0.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-tea-util" },
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/50/5f41ab550d7874c623f6e992758429802c4b52a6804db437017e5387de33/alibabacloud_openapi_util-0.2.2.tar.gz", hash = "sha256:ebbc3906f554cb4bf8f513e43e8a33e8b6a3d4a0ef13617a0e14c3dda8ef52a8", size = 7201, upload-time = "2023-10-23T07:44:18.523Z" }
[[package]]
name = "alibabacloud-openplatform20191219"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-endpoint-util" },
{ name = "alibabacloud-openapi-util" },
{ name = "alibabacloud-tea-openapi" },
{ name = "alibabacloud-tea-util" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4f/bf/f7fa2f3657ed352870f442434cb2f27b7f70dcd52a544a1f3998eeaf6d71/alibabacloud_openplatform20191219-2.0.0.tar.gz", hash = "sha256:e67f4c337b7542538746592c6a474bd4ae3a9edccdf62e11a32ca61fad3c9020", size = 5038, upload-time = "2022-09-21T06:16:10.683Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/e5/18c75213551eeca9db1f6b41ddcc0bd87b5b6508c75a67f05cd8671847b4/alibabacloud_openplatform20191219-2.0.0-py3-none-any.whl", hash = "sha256:873821c45bca72a6c6ec7a906c9cb21554c122e88893bbac3986934dab30dd36", size = 5204, upload-time = "2022-09-21T06:16:07.844Z" },
]
[[package]]
name = "alibabacloud-oss-sdk"
version = "0.1.1"
version = "5.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-credentials" },
{ name = "alibabacloud-oss-util" },
{ name = "alibabacloud-tea-fileform" },
{ name = "alibabacloud-tea-util" },
{ name = "alibabacloud-tea-xml" },
{ name = "alibabacloud-tea-openapi" },
{ name = "darabonba-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7e/d1/f442dd026908fcf55340ca694bb1d027aa91e119e76ae2fbea62f2bde4f4/alibabacloud_oss_sdk-0.1.1.tar.gz", hash = "sha256:f51a368020d0964fcc0978f96736006f49f5ab6a4a4bf4f0b8549e2c659e7358", size = 46434, upload-time = "2025-04-22T12:40:41.717Z" }
[[package]]
name = "alibabacloud-oss-util"
version = "0.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-tea" },
sdist = { url = "https://files.pythonhosted.org/packages/b3/36/69333c7fb7fb5267f338371b14fdd8dbdd503717c97bbc7a6419d155ab4c/alibabacloud_gpdb20160503-5.1.0.tar.gz", hash = "sha256:086ec6d5e39b64f54d0e44bb3fd4fde1a4822a53eb9f6ff7464dff7d19b07b63", size = 295641, upload-time = "2026-03-19T10:09:02.444Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/7f/a91a2f9ad97c92fa9a6981587ea0ff789240cea05b17b17b7c244e5bac64/alibabacloud_gpdb20160503-5.1.0-py3-none-any.whl", hash = "sha256:580e4579285a54c7f04570782e0f60423a1997568684187fe88e4110acfb640e", size = 848784, upload-time = "2026-03-19T10:09:00.72Z" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/7c/d7e812b9968247a302573daebcfef95d0f9a718f7b4bfcca8d3d83e266be/alibabacloud_oss_util-0.0.6.tar.gz", hash = "sha256:d3ecec36632434bd509a113e8cf327dc23e830ac8d9dd6949926f4e334c8b5d6", size = 10008, upload-time = "2021-04-28T09:25:04.056Z" }
[[package]]
name = "alibabacloud-tea"
@@ -260,15 +202,6 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0f3f9d7f26b76b9ed93d4101add7867a2c87ed2534f5/alibabacloud-tea-0.4.3.tar.gz", hash = "sha256:ec8053d0aa8d43ebe1deb632d5c5404339b39ec9a18a0707d57765838418504a", size = 8785, upload-time = "2025-03-24T07:34:42.958Z" }
[[package]]
name = "alibabacloud-tea-fileform"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-tea" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/8a/ef8ddf5ee0350984cad2749414b420369fe943e15e6d96b79be45367630e/alibabacloud_tea_fileform-0.0.5.tar.gz", hash = "sha256:fd00a8c9d85e785a7655059e9651f9e91784678881831f60589172387b968ee8", size = 3961, upload-time = "2021-04-28T09:22:54.56Z" }
[[package]]
name = "alibabacloud-tea-openapi"
version = "0.4.3"
@@ -297,15 +230,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/72/9e/c394b4e2104766fb28a1e44e3ed36e4c7773b4d05c868e482be99d5635c9/alibabacloud_tea_util-0.3.14-py3-none-any.whl", hash = "sha256:10d3e5c340d8f7ec69dd27345eb2fc5a1dab07875742525edf07bbe86db93bfe", size = 6697, upload-time = "2025-11-19T06:01:07.355Z" },
]
[[package]]
name = "alibabacloud-tea-xml"
version = "0.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-tea" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/eb/5e82e419c3061823f3feae9b5681588762929dc4da0176667297c2784c1a/alibabacloud_tea_xml-0.0.3.tar.gz", hash = "sha256:979cb51fadf43de77f41c69fc69c12529728919f849723eb0cd24eb7b048a90c", size = 3466, upload-time = "2025-07-01T08:04:55.144Z" }
[[package]]
name = "aliyun-log-python-sdk"
version = "0.9.37"
@@ -570,14 +494,14 @@ wheels = [
[[package]]
name = "basedpyright"
version = "1.38.2"
version = "1.38.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodejs-wheel-binaries" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/a3/20aa7c4e83f2f614e0036300f3c352775dede0655c66814da16c37b661a9/basedpyright-1.38.2.tar.gz", hash = "sha256:b433b2b8ba745ed7520cdc79a29a03682f3fb00346d272ece5944e9e5e5daa92", size = 25277019, upload-time = "2026-02-26T11:18:43.594Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/58/7abba2c743571a42b2548f07aee556ebc1e4d0bc2b277aeba1ee6c83b0af/basedpyright-1.38.3.tar.gz", hash = "sha256:9725419786afbfad8a9539527f162da02d462afad440b0412fdb3f3cdf179b90", size = 25277430, upload-time = "2026-03-17T13:10:41.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/12/736cab83626fea3fe65cdafb3ef3d2ee9480c56723f2fd33921537289a5e/basedpyright-1.38.2-py3-none-any.whl", hash = "sha256:153481d37fd19f9e3adedc8629d1d071b10c5f5e49321fb026b74444b7c70e24", size = 12312475, upload-time = "2026-02-26T11:18:40.373Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e3/3ebb5c23bd3abb5fc2053b8a06a889aa5c1cf8cff738c78cb6c1957e90cd/basedpyright-1.38.3-py3-none-any.whl", hash = "sha256:1f15c2e489c67d6c5e896c24b6a63251195c04223a55e4568b8f8e8ed49ca830", size = 12313363, upload-time = "2026-03-17T13:10:47.344Z" },
]
[[package]]
@@ -720,16 +644,16 @@ wheels = [
[[package]]
name = "boto3-stubs"
version = "1.42.68"
version = "1.42.73"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore-stubs" },
{ name = "types-s3transfer" },
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4c/8c/dd4b0c95ff008bed5a35ab411452ece121b355539d2a0b6dcd62a0c47be5/boto3_stubs-1.42.68.tar.gz", hash = "sha256:96ad1020735619483fb9b4da7a5e694b460bf2e18f84a34d5d175d0ffe8c4653", size = 101372, upload-time = "2026-03-13T19:49:54.867Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/c3/fcc47102c63278af25ad57c93d97dc393f4dbc54c0117a29c78f2b96ec1e/boto3_stubs-1.42.73.tar.gz", hash = "sha256:36f625769b5505c4bc627f16244b98de9e10dae3ac36f1aa0f0ebe2f201dc138", size = 101373, upload-time = "2026-03-20T19:59:51.463Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/15/3ca5848917214a168134512a5b45f856a56e913659888947a052e02031b5/boto3_stubs-1.42.68-py3-none-any.whl", hash = "sha256:ed7f98334ef7b2377fa8532190e63dc2c6d1dc895e3d7cb3d6d1c83771b81bf6", size = 70011, upload-time = "2026-03-13T19:49:42.801Z" },
{ url = "https://files.pythonhosted.org/packages/4b/57/d570ba61a2a0c7fe0c8667e41269a0480293cb53e1786d6661a2bd827fc5/boto3_stubs-1.42.73-py3-none-any.whl", hash = "sha256:bd658429069d8215247fc3abc003220cd875c24ab6eda7b3405090408afaacdf", size = 70009, upload-time = "2026-03-20T19:59:43.786Z" },
]
[package.optional-dependencies]
@@ -1290,41 +1214,41 @@ wheels = [
[[package]]
name = "coverage"
version = "7.13.4"
version = "7.13.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" },
{ url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" },
{ url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" },
{ url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" },
{ url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" },
{ url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" },
{ url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" },
{ url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" },
{ url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" },
{ url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" },
{ url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" },
{ url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" },
{ url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" },
{ url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" },
{ url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
{ url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
{ url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
{ url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
{ url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
{ url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
{ url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
{ url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
{ url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
{ url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
{ url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
{ url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
{ url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
{ url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
{ url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
{ url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
{ url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
{ url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
{ url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
{ url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
{ url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
{ url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
{ url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
{ url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
{ url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
{ url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
]
[package.optional-dependencies]
@@ -1912,7 +1836,7 @@ tools = [
{ name = "nltk", specifier = "~=3.9.1" },
]
vdb = [
{ name = "alibabacloud-gpdb20160503", specifier = "~=3.8.0" },
{ name = "alibabacloud-gpdb20160503", specifier = "~=5.1.0" },
{ name = "alibabacloud-tea-openapi", specifier = "~=0.4.3" },
{ name = "chromadb", specifier = "==0.5.20" },
{ name = "clickhouse-connect", specifier = "~=0.14.1" },
@@ -6057,27 +5981,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.6"
version = "0.15.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
{ url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
{ url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
{ url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
{ url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
{ url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
{ url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
{ url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
{ url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
{ url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
{ url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
{ url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
{ url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
{ url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" },
{ url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" },
{ url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" },
{ url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" },
{ url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" },
{ url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" },
{ url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" },
{ url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" },
{ url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" },
{ url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" },
{ url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" },
{ url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" },
{ url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" },
{ url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" },
{ url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" },
{ url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" },
]
[[package]]
@@ -6116,14 +6040,14 @@ wheels = [
[[package]]
name = "scipy-stubs"
version = "1.17.1.2"
version = "1.17.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "optype", extra = ["numpy"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/ab/43f681ffba42f363b7ed6b767fd215d1e26006578214ff8330586a11bf95/scipy_stubs-1.17.1.2.tar.gz", hash = "sha256:2ecadc8c87a3b61aaf7379d6d6b10f1038a829c53b9efe5b174fb97fc8b52237", size = 388354, upload-time = "2026-03-15T22:33:20.449Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/59/59c6cc3f9970154b9ed6b1aff42a0185cdd60cef54adc0404b9e77972221/scipy_stubs-1.17.1.3.tar.gz", hash = "sha256:5eb87a8d23d726706259b012ebe76a4a96a9ae9e141fc59bf55fc8eac2ed9e0f", size = 392185, upload-time = "2026-03-22T22:11:58.34Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/0b/ec4fe720c1202d9df729a3e9d9b7e4d2da9f6e7f28bd2877b7d0769f4f75/scipy_stubs-1.17.1.2-py3-none-any.whl", hash = "sha256:f19e8f5273dbe3b7ee6a9554678c3973b9695fa66b91f29206d00830a1536c06", size = 594377, upload-time = "2026-03-15T22:33:18.684Z" },
{ url = "https://files.pythonhosted.org/packages/2c/d4/94304532c0a75a55526119043dd44a9bd1541a21e14483cbb54261c527d2/scipy_stubs-1.17.1.3-py3-none-any.whl", hash = "sha256:7b91d3f05aa47da06fbca14eb6c5bb4c28994e9245fd250cc847e375bab31297", size = 597933, upload-time = "2026-03-22T22:11:56.525Z" },
]
[[package]]
@@ -6803,11 +6727,11 @@ wheels = [
[[package]]
name = "types-cachetools"
version = "6.2.0.20251022"
version = "6.2.0.20260317"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/a8/f9bcc7f1be63af43ef0170a773e2d88817bcc7c9d8769f2228c802826efe/types_cachetools-6.2.0.20251022.tar.gz", hash = "sha256:f1d3c736f0f741e89ec10f0e1b0138625023e21eb33603a930c149e0318c0cef", size = 9608, upload-time = "2025-10-22T03:03:58.16Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8b/7f/16a4d8344c28193a5a74358028c2d2f753f0d9658dd98b9e1967c50045a2/types_cachetools-6.2.0.20260317.tar.gz", hash = "sha256:6d91855bcc944665897c125e720aa3c80aace929b77a64e796343701df4f61c6", size = 9812, upload-time = "2026-03-17T04:06:32.007Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/2d/8d821ed80f6c2c5b427f650bf4dc25b80676ed63d03388e4b637d2557107/types_cachetools-6.2.0.20251022-py3-none-any.whl", hash = "sha256:698eb17b8f16b661b90624708b6915f33dbac2d185db499ed57e4997e7962cad", size = 9341, upload-time = "2025-10-22T03:03:57.036Z" },
{ url = "https://files.pythonhosted.org/packages/17/9a/b00b23054934c4d569c19f7278c4fb32746cd36a64a175a216d3073a4713/types_cachetools-6.2.0.20260317-py3-none-any.whl", hash = "sha256:92fa9bc50e4629e31fca67ceb3fb1de71791e314fa16c0a0d2728724dc222c8b", size = 9346, upload-time = "2026-03-17T04:06:31.184Z" },
]
[[package]]
@@ -6851,11 +6775,11 @@ wheels = [
[[package]]
name = "types-docutils"
version = "0.22.3.20260316"
version = "0.22.3.20260322"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/27/a7f16b3a2fad0a4ddd85a668319f9a1d0311c4bd9578894f6471c7e6c788/types_docutils-0.22.3.20260316.tar.gz", hash = "sha256:8ef27d565b9831ff094fe2eac75337a74151013e2d21ecabd445c2955f891564", size = 57263, upload-time = "2026-03-16T04:29:12.211Z" }
sdist = { url = "https://files.pythonhosted.org/packages/44/bb/243a87fc1605a4a94c2c343d6dbddbf0d7ef7c0b9550f360b8cda8e82c39/types_docutils-0.22.3.20260322.tar.gz", hash = "sha256:e2450bb997283c3141ec5db3e436b91f0aa26efe35eb9165178ca976ccb4930b", size = 57311, upload-time = "2026-03-22T04:08:44.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/60/c1f22b7cfc4837d5419e5a2d8702c7d65f03343f866364b71cccd8a73b79/types_docutils-0.22.3.20260316-py3-none-any.whl", hash = "sha256:083c7091b8072c242998ec51da1bf1492f0332387da81c3b085efbf5ca754c7d", size = 91968, upload-time = "2026-03-16T04:29:11.114Z" },
{ url = "https://files.pythonhosted.org/packages/c6/4a/22c090cd4615a16917dff817cbe7c5956da376c961e024c241cd962d2c3d/types_docutils-0.22.3.20260322-py3-none-any.whl", hash = "sha256:681d4510ce9b80a0c6a593f0f9843d81f8caa786db7b39ba04d9fd5480ac4442", size = 91978, upload-time = "2026-03-22T04:08:43.117Z" },
]
[[package]]
@@ -6885,15 +6809,15 @@ wheels = [
[[package]]
name = "types-gevent"
version = "25.9.0.20251228"
version = "25.9.0.20260322"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-greenlet" },
{ name = "types-psutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/85/c5043c4472f82c8ee3d9e0673eb4093c7d16770a26541a137a53a1d096f6/types_gevent-25.9.0.20251228.tar.gz", hash = "sha256:423ef9891d25c5a3af236c3e9aace4c444c86ff773fe13ef22731bc61d59abef", size = 38063, upload-time = "2025-12-28T03:28:28.651Z" }
sdist = { url = "https://files.pythonhosted.org/packages/34/f0/14a99ddcaa69b559fa7cec8c9de880b792bebb0b848ae865d94ea9058533/types_gevent-25.9.0.20260322.tar.gz", hash = "sha256:91257920845762f09753c08aa20fad1743ac13d2de8bcf23f4b8fe967d803732", size = 38241, upload-time = "2026-03-22T04:08:55.213Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/b7/a2d6b652ab5a26318b68cafd58c46fafb9b15c5313d2d76a70b838febb4b/types_gevent-25.9.0.20251228-py3-none-any.whl", hash = "sha256:e2e225af4fface9241c16044983eb2fc3993f2d13d801f55c2932848649b7f2f", size = 55486, upload-time = "2025-12-28T03:28:27.382Z" },
{ url = "https://files.pythonhosted.org/packages/89/0f/964440b57eb4ddb4aca03479a4093852e1ce79010d1c5967234e6f5d6bd9/types_gevent-25.9.0.20260322-py3-none-any.whl", hash = "sha256:21b3c269b3a20ecb0e4668289c63b97d21694d84a004ab059c1e32ab970eacc2", size = 55500, upload-time = "2026-03-22T04:08:54.103Z" },
]
[[package]]
@@ -6976,11 +6900,11 @@ wheels = [
[[package]]
name = "types-openpyxl"
version = "3.1.5.20260316"
version = "3.1.5.20260322"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/38/32f8ee633dd66ca6d52b8853b9fd45dc3869490195a6ed435d5c868b9c2d/types_openpyxl-3.1.5.20260316.tar.gz", hash = "sha256:081dda9427ea1141e5649e3dcf630e7013a4cf254a5862a7e0a3f53c123b7ceb", size = 101318, upload-time = "2026-03-16T04:29:05.004Z" }
sdist = { url = "https://files.pythonhosted.org/packages/77/bf/15240de4d68192d2a1f385ef2f6f1ecb29b85d2f3791dd2e2d5b980be30f/types_openpyxl-3.1.5.20260322.tar.gz", hash = "sha256:a61d66ebe1e49697853c6db8e0929e1cda2c96755e71fb676ed7fc48dfdcf697", size = 101325, upload-time = "2026-03-22T04:08:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/df/b87ae6226ed7cc84b9e43119c489c7f053a9a25e209e0ebb5d84bc36fa37/types_openpyxl-3.1.5.20260316-py3-none-any.whl", hash = "sha256:38e7e125df520fb7eb72cb1129c9f024eb99ef9564aad2c27f68f080c26bcf2d", size = 166084, upload-time = "2026-03-16T04:29:03.657Z" },
{ url = "https://files.pythonhosted.org/packages/bf/b4/c14191b30bcb266365b124b2bb4e67ecd68425a78ba77ee026f33667daa9/types_openpyxl-3.1.5.20260322-py3-none-any.whl", hash = "sha256:2f515f0b0bbfb04bfb587de34f7522d90b5151a8da7bbbd11ecec4ca40f64238", size = 166102, upload-time = "2026-03-22T04:08:39.174Z" },
]
[[package]]
@@ -7055,11 +6979,11 @@ wheels = [
[[package]]
name = "types-python-dateutil"
version = "2.9.0.20260305"
version = "2.9.0.20260323"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/c7/025c624f347e10476b439a6619a95f1d200250ea88e7ccea6e09e48a7544/types_python_dateutil-2.9.0.20260305.tar.gz", hash = "sha256:389717c9f64d8f769f36d55a01873915b37e97e52ce21928198d210fbd393c8b", size = 16885, upload-time = "2026-03-05T04:00:47.409Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e9/02/f72df9ef5ffc4f959b83cb80c8aa03eb8718a43e563ecd99ccffe265fa89/types_python_dateutil-2.9.0.20260323.tar.gz", hash = "sha256:a107aef5841db41ace381dbbbd7e4945220fc940f7a72172a0be5a92d9ab7164", size = 16897, upload-time = "2026-03-23T04:15:14.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/77/8c0d1ec97f0d9707ad3d8fa270ab8964e7b31b076d2f641c94987395cc75/types_python_dateutil-2.9.0.20260305-py3-none-any.whl", hash = "sha256:a3be9ca444d38cadabd756cfbb29780d8b338ae2a3020e73c266a83cc3025dd7", size = 18419, upload-time = "2026-03-05T04:00:46.392Z" },
{ url = "https://files.pythonhosted.org/packages/92/c1/b661838b97453e699a215451f2e22cee750eaaf4ea4619b34bdaf01221a4/types_python_dateutil-2.9.0.20260323-py3-none-any.whl", hash = "sha256:a23a50a07f6eb87e729d4cb0c2eb511c81761eeb3f505db2c1413be94aae8335", size = 18433, upload-time = "2026-03-23T04:15:13.683Z" },
]
[[package]]
@@ -7073,11 +6997,11 @@ wheels = [
[[package]]
name = "types-pywin32"
version = "311.0.0.20260316"
version = "311.0.0.20260323"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/17/a8/b4652002a854fcfe5d272872a0ae2d5df0e9dc482e1a6dfb5e97b905b76f/types_pywin32-311.0.0.20260316.tar.gz", hash = "sha256:c136fa489fe6279a13bca167b750414e18d657169b7cf398025856dc363004e8", size = 329956, upload-time = "2026-03-16T04:28:57.366Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/cc/f03ddb7412ac2fc2238358b617c2d5919ba96812dff8d3081f3b2754bb83/types_pywin32-311.0.0.20260323.tar.gz", hash = "sha256:2e8dc6a59fedccbc51b241651ce1e8aa58488934f517debf23a9c6d0ff329b4b", size = 332263, upload-time = "2026-03-23T04:15:20.004Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/83/704698d93788cf1c2f5e236eae2b37f1b2152ef84dc66b4b83f6c7487b76/types_pywin32-311.0.0.20260316-py3-none-any.whl", hash = "sha256:abb643d50012386d697af49384cc0e6e475eab76b0ca2a7f93d480d0862b3692", size = 392959, upload-time = "2026-03-16T04:28:56.104Z" },
{ url = "https://files.pythonhosted.org/packages/dc/82/d786d5d8b846e3cbe1ee52da8945560b111c789b42c3771b2129b312ab94/types_pywin32-311.0.0.20260323-py3-none-any.whl", hash = "sha256:2f2b03fc72ae77ccbb0ee258da0f181c3a38bd8602f6e332e42587b3b0d5f095", size = 395435, upload-time = "2026-03-23T04:15:18.76Z" },
]
[[package]]
@@ -7173,16 +7097,16 @@ wheels = [
[[package]]
name = "types-tensorflow"
version = "2.18.0.20260224"
version = "2.18.0.20260322"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "types-protobuf" },
{ name = "types-requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/cb/4914c2fbc1cf8a8d1ef2a7c727bb6f694879be85edeee880a0c88e696af8/types_tensorflow-2.18.0.20260224.tar.gz", hash = "sha256:9b0ccc91c79c88791e43d3f80d6c879748fa0361409c5ff23c7ffe3709be00f2", size = 258786, upload-time = "2026-02-24T04:06:45.613Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4a/cb/81dfaa2680031a6e087bcdfaf1c0556371098e229aee541e21c81a381065/types_tensorflow-2.18.0.20260322.tar.gz", hash = "sha256:135dc6ca06cc647a002e1bca5c5c99516fde51efd08e46c48a9b1916fc5df07f", size = 259030, upload-time = "2026-03-22T04:09:14.069Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/1d/a1c3c60f0eb1a204500dbdc66e3d18aafabc86ad07a8eca71ea05bc8c5a8/types_tensorflow-2.18.0.20260224-py3-none-any.whl", hash = "sha256:6a25f5f41f3e06f28c1f65c6e09f484d4ba0031d6d8df83a39df9d890245eefc", size = 329746, upload-time = "2026-02-24T04:06:44.4Z" },
{ url = "https://files.pythonhosted.org/packages/5b/0c/a178061450b640e53577e2c423ad22bf5d3f692f6bfeeb12156d02b531ef/types_tensorflow-2.18.0.20260322-py3-none-any.whl", hash = "sha256:d8776b6daacdb279e64f105f9dcbc0b8e3544b9a2f2eb71ec6ea5955081f65e6", size = 329771, upload-time = "2026-03-22T04:09:12.844Z" },
]
[[package]]

View File

@@ -0,0 +1,11 @@
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ appId: string }>
}) => {
const { appId } = await props.params
return <Evaluation resourceType="workflow" resourceId={appId} />
}
export default Page

View File

@@ -7,6 +7,8 @@ import {
RiDashboard2Line,
RiFileList3Fill,
RiFileList3Line,
RiFlaskFill,
RiFlaskLine,
RiTerminalBoxFill,
RiTerminalBoxLine,
RiTerminalWindowFill,
@@ -67,40 +69,47 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
}>>([])
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
const navConfig = [
...(isCurrentWorkspaceEditor
? [{
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
}]
: []
),
{
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
},
...(isCurrentWorkspaceEditor
? [{
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
}]
: []
),
{
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
},
]
const navConfig = []
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
})
navConfig.push({
name: t('appMenus.evaluation', { ns: 'common' }),
href: `/app/${appId}/evaluation`,
icon: RiFlaskLine,
selectedIcon: RiFlaskFill,
})
}
navConfig.push({
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
})
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
})
}
navConfig.push({
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
})
return navConfig
}, [t])

View File

@@ -0,0 +1,11 @@
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ datasetId: string }>
}) => {
const { datasetId } = await props.params
return <Evaluation resourceType="pipeline" resourceId={datasetId} />
}
export default Page

View File

@@ -6,6 +6,8 @@ import {
RiEqualizer2Line,
RiFileTextFill,
RiFileTextLine,
RiFlaskFill,
RiFlaskLine,
RiFocus2Fill,
RiFocus2Line,
} from '@remixicon/react'
@@ -86,20 +88,30 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
]
if (datasetRes?.provider !== 'external') {
baseNavigation.unshift({
name: t('datasetMenus.pipeline', { ns: 'common' }),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
})
baseNavigation.unshift({
name: t('datasetMenus.documents', { ns: 'common' }),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
})
return [
{
name: t('datasetMenus.documents', { ns: 'common' }),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
},
{
name: t('datasetMenus.pipeline', { ns: 'common' }),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
},
{
name: t('datasetMenus.evaluation', { ns: 'common' }),
href: `/datasets/${datasetId}/evaluation`,
icon: RiFlaskLine,
selectedIcon: RiFlaskFill,
disabled: false,
},
...baseNavigation,
]
}
return baseNavigation

View File

@@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { usePathname, useRouter } from '@/next/navigation'
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)

View File

@@ -0,0 +1,11 @@
import SnippetPage from '@/app/components/snippets'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetPage snippetId={snippetId} section="evaluation" />
}
export default Page

View File

@@ -0,0 +1,11 @@
import SnippetPage from '@/app/components/snippets'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetPage snippetId={snippetId} section="orchestrate" />
}
export default Page

View File

@@ -0,0 +1,21 @@
import Page from './page'
const mockRedirect = vi.fn()
vi.mock('next/navigation', () => ({
redirect: (path: string) => mockRedirect(path),
}))
describe('snippet detail redirect page', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should redirect legacy snippet detail routes to orchestrate', async () => {
await Page({
params: Promise.resolve({ snippetId: 'snippet-1' }),
})
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
})
})

View File

@@ -0,0 +1,11 @@
import { redirect } from 'next/navigation'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
redirect(`/snippets/${snippetId}/orchestrate`)
}
export default Page

View File

@@ -0,0 +1,7 @@
import Apps from '@/app/components/apps'
const SnippetsPage = () => {
return <Apps pageType="snippets" />
}
export default SnippetsPage

View File

@@ -165,6 +165,21 @@ describe('AppDetailNav', () => {
)
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
})
it('should render custom header and navigation when provided', () => {
render(
<AppDetailNav
navigation={navigation}
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
/>,
)
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
})
})
describe('Workflow canvas mode', () => {

View File

@@ -27,12 +27,16 @@ export type IAppDetailNavProps = {
disabled?: boolean
}>
extraInfo?: (modeState: string) => React.ReactNode
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
}
const AppDetailNav = ({
navigation,
extraInfo,
iconType = 'app',
renderHeader,
renderNavigation,
}: IAppDetailNavProps) => {
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
@@ -104,10 +108,11 @@ const AppDetailNav = ({
expand ? 'p-2' : 'p-1',
)}
>
{iconType === 'app' && (
{renderHeader?.(appSidebarExpand)}
{!renderHeader && iconType === 'app' && (
<AppInfo expand={expand} />
)}
{iconType !== 'app' && (
{!renderHeader && iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
@@ -136,7 +141,8 @@ const AppDetailNav = ({
expand ? 'px-3 py-2' : 'p-3',
)}
>
{navigation.map((item, index) => {
{renderNavigation?.(appSidebarExpand)}
{!renderNavigation && navigation.map((item, index) => {
return (
<NavLink
key={index}

View File

@@ -262,4 +262,20 @@ describe('NavLink Animation and Layout Issues', () => {
expect(iconWrapper).toHaveClass('-ml-1')
})
})
describe('Button Mode', () => {
it('should render as an interactive button when href is omitted', () => {
const onClick = vi.fn()
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
const buttonElement = screen.getByText('Orchestrate').closest('button')
expect(buttonElement).not.toBeNull()
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
buttonElement?.click()
expect(onClick).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -14,13 +14,15 @@ export type NavIcon = React.ComponentType<
export type NavLinkProps = {
name: string
href: string
href?: string
iconMap: {
selected: NavIcon
normal: NavIcon
}
mode?: string
disabled?: boolean
active?: boolean
onClick?: () => void
}
const NavLink = ({
@@ -29,6 +31,8 @@ const NavLink = ({
iconMap,
mode = 'expand',
disabled = false,
active,
onClick,
}: NavLinkProps) => {
const segment = useSelectedLayoutSegment()
const formattedSegment = (() => {
@@ -39,8 +43,11 @@ const NavLink = ({
return res
})()
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
const NavIcon = isActive ? iconMap.selected : iconMap.normal
const linkClassName = cn(isActive
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
const renderIcon = () => (
<div className={cn(mode !== 'expand' && '-ml-1')}>
@@ -70,13 +77,32 @@ const NavLink = ({
)
}
if (!href) {
return (
<button
key={name}
type="button"
className={linkClassName}
title={mode === 'collapse' ? name : ''}
onClick={onClick}
>
{renderIcon()}
<span
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
? 'ml-2 max-w-none opacity-100'
: 'ml-0 max-w-0 opacity-0')}
>
{name}
</span>
</button>
)
}
return (
<Link
key={name}
href={href}
className={cn(isActive
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')}
className={linkClassName}
title={mode === 'collapse' ? name : ''}
>
{renderIcon()}

View File

@@ -0,0 +1,53 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import * as React from 'react'
import AppIcon from '@/app/components/base/app-icon'
import Badge from '@/app/components/base/badge'
import { cn } from '@/utils/classnames'
type SnippetInfoProps = {
expand: boolean
snippet: SnippetDetail
}
const SnippetInfo = ({
expand,
snippet,
}: SnippetInfoProps) => {
return (
<div className={cn('flex flex-col', expand ? '' : 'p-1')}>
<div className="flex flex-col gap-2 p-2">
<div className="flex items-start gap-3">
<div className={cn(!expand && 'ml-1')}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType="emoji"
icon={snippet.icon}
background={snippet.iconBackground}
/>
</div>
{expand && (
<div className="min-w-0 flex-1">
<div className="truncate text-text-secondary system-md-semibold">
{snippet.name}
</div>
{snippet.status && (
<div className="pt-1">
<Badge>{snippet.status}</Badge>
</div>
)}
</div>
)}
</div>
{expand && snippet.description && (
<p className="line-clamp-3 text-text-tertiary system-xs-regular">
{snippet.description}
</p>
)}
</div>
</div>
)
}
export default React.memo(SnippetInfo)

View File

@@ -1,4 +1,4 @@
import { act, fireEvent, screen } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { renderWithNuqs } from '@/test/nuqs-testing'
@@ -15,10 +15,13 @@ vi.mock('@/next/navigation', () => ({
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
const mockIsLoadingCurrentWorkspace = vi.fn(() => false)
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace(),
}),
}))
@@ -36,6 +39,7 @@ const mockQueryState = {
keywords: '',
isCreatedByMe: false,
}
vi.mock('../hooks/use-apps-query-state', () => ({
default: () => ({
query: mockQueryState,
@@ -45,6 +49,7 @@ vi.mock('../hooks/use-apps-query-state', () => ({
let mockOnDSLFileDropped: ((file: File) => void) | null = null
let mockDragging = false
vi.mock('../hooks/use-dsl-drag-drop', () => ({
useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => {
mockOnDSLFileDropped = onDSLFileDropped
@@ -54,11 +59,13 @@ vi.mock('../hooks/use-dsl-drag-drop', () => ({
const mockRefetch = vi.fn()
const mockFetchNextPage = vi.fn()
const mockFetchSnippetNextPage = vi.fn()
const mockServiceState = {
error: null as Error | null,
hasNextPage: false,
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
}
@@ -100,6 +107,7 @@ vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: defaultAppData,
isLoading: mockServiceState.isLoading,
isFetching: mockServiceState.isFetching,
isFetchingNextPage: mockServiceState.isFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockServiceState.hasNextPage,
@@ -112,6 +120,57 @@ vi.mock('@/service/use-apps', () => ({
}),
}))
const mockSnippetServiceState = {
error: null as Error | null,
hasNextPage: false,
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
}
const defaultSnippetData = {
pages: [{
data: [
{
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.',
author: '',
updatedAt: '2024-01-02 10:00',
usage: '19',
icon: '🪄',
iconBackground: '#E0EAFF',
status: undefined,
},
],
total: 1,
}],
}
vi.mock('@/service/use-snippets', () => ({
useInfiniteSnippetList: () => ({
data: defaultSnippetData,
isLoading: mockSnippetServiceState.isLoading,
isFetching: mockSnippetServiceState.isFetching,
isFetchingNextPage: mockSnippetServiceState.isFetchingNextPage,
fetchNextPage: mockFetchSnippetNextPage,
hasNextPage: mockSnippetServiceState.hasNextPage,
error: mockSnippetServiceState.error,
}),
useCreateSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useImportSnippetDSLMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useConfirmSnippetImportMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
}))
vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]),
}))
@@ -133,13 +192,21 @@ vi.mock('@/next/dynamic', () => ({
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
}
}
if (fnString.includes('create-from-dsl-modal')) {
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) {
if (!show)
return null
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'))
return React.createElement(
'div',
{ 'data-testid': 'create-dsl-modal' },
React.createElement('button', { 'data-testid': 'close-dsl-modal', 'onClick': onClose }, 'Close'),
React.createElement('button', { 'data-testid': 'success-dsl-modal', 'onClick': onSuccess }, 'Success'),
)
}
}
return () => null
},
}))
@@ -188,9 +255,8 @@ beforeAll(() => {
} as unknown as typeof IntersectionObserver
})
// Render helper wrapping with shared nuqs testing helper.
const renderList = (searchParams = '') => {
return renderWithNuqs(<List />, { searchParams })
const renderList = (props: React.ComponentProps<typeof List> = {}, searchParams = '') => {
return renderWithNuqs(<List {...props} />, { searchParams })
}
describe('List', () => {
@@ -202,284 +268,62 @@ describe('List', () => {
})
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
mockIsLoadingCurrentWorkspace.mockReturnValue(false)
mockDragging = false
mockOnDSLFileDropped = null
mockServiceState.error = null
mockServiceState.hasNextPage = false
mockServiceState.isLoading = false
mockServiceState.isFetching = false
mockServiceState.isFetchingNextPage = false
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
mockQueryState.isCreatedByMe = false
mockSnippetServiceState.error = null
mockSnippetServiceState.hasNextPage = false
mockSnippetServiceState.isLoading = false
mockSnippetServiceState.isFetching = false
mockSnippetServiceState.isFetchingNextPage = false
intersectionCallback = null
localStorage.clear()
})
describe('Rendering', () => {
it('should render without crashing', () => {
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render tab slider with all app types', () => {
describe('Apps Mode', () => {
it('should render the apps route switch, dropdown filters, and app cards', () => {
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
it('should render search input', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render tag filter', () => {
renderList()
expect(screen.getByRole('link', { name: 'app.studio.apps' })).toHaveAttribute('href', '/apps')
expect(screen.getByRole('link', { name: 'workflow.tabs.snippets' })).toHaveAttribute('href', '/snippets')
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
expect(screen.getByText('app.studio.filters.creators')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
it('should render created by me checkbox', () => {
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
renderList()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should render new app card for editors', () => {
renderList()
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
})
it('should render footer when branding is disabled', () => {
renderList()
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
it('should render drop DSL hint for editors', () => {
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
})
describe('Tab Navigation', () => {
it('should update URL when workflow tab is clicked', async () => {
it('should update the category query when selecting an app type from the dropdown', async () => {
const { onUrlUpdate } = renderList()
fireEvent.click(screen.getByText('app.types.workflow'))
fireEvent.click(screen.getByText('app.studio.filters.types'))
fireEvent.click(await screen.findByText('app.types.workflow'))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
})
it('should update URL when all tab is clicked', async () => {
const { onUrlUpdate } = renderList('?category=workflow')
fireEvent.click(screen.getByText('app.types.all'))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
// nuqs removes the default value ('all') from URL params
expect(lastCall.searchParams.has('category')).toBe(false)
})
})
describe('Search Functionality', () => {
it('should render search input field', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should handle search input change', () => {
it('should keep the creators dropdown visual-only and not update app query state', async () => {
renderList()
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
fireEvent.click(screen.getByText('app.studio.filters.creators'))
fireEvent.click(await screen.findByText('Evan'))
expect(mockSetQuery).toHaveBeenCalled()
expect(mockSetQuery).not.toHaveBeenCalled()
expect(screen.getByText('app.studio.filters.creators +1')).toBeInTheDocument()
})
it('should handle search clear button click', () => {
mockQueryState.keywords = 'existing search'
renderList()
const clearButton = document.querySelector('.group')
expect(clearButton).toBeInTheDocument()
if (clearButton)
fireEvent.click(clearButton)
expect(mockSetQuery).toHaveBeenCalled()
})
})
describe('Tag Filter', () => {
it('should render tag filter component', () => {
renderList()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
})
describe('Created By Me Filter', () => {
it('should render checkbox with correct label', () => {
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should handle checkbox change', () => {
renderList()
const checkbox = screen.getByTestId('checkbox-undefined')
fireEvent.click(checkbox)
expect(mockSetQuery).toHaveBeenCalled()
})
})
describe('Non-Editor User', () => {
it('should not render new app card for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
renderList()
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
})
it('should not render drop DSL hint for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
renderList()
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
})
})
describe('Dataset Operator Behavior', () => {
it('should not trigger redirect at component level for dataset operators', () => {
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
renderList()
expect(mockReplace).not.toHaveBeenCalled()
})
})
describe('Local Storage Refresh', () => {
it('should call refetch when refresh key is set in localStorage', () => {
localStorage.setItem('needRefreshAppList', '1')
renderList()
expect(mockRefetch).toHaveBeenCalled()
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
})
})
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = renderWithNuqs(<List />)
expect(screen.getByText('app.types.all')).toBeInTheDocument()
rerender(<List />)
expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render app cards correctly', () => {
renderList()
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
it('should render with all filter options visible', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
})
describe('Dragging State', () => {
it('should show drop hint when DSL feature is enabled for editors', () => {
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
it('should render dragging state overlay when dragging', () => {
mockDragging = true
const { container } = renderList()
expect(container).toBeInTheDocument()
})
})
describe('App Type Tabs', () => {
it('should render all app type tabs', () => {
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
it('should update URL for each app type tab click', async () => {
const { onUrlUpdate } = renderList()
const appTypeTexts = [
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
{ mode: AppModeEnum.CHAT, text: 'app.types.chatbot' },
{ mode: AppModeEnum.AGENT_CHAT, text: 'app.types.agent' },
{ mode: AppModeEnum.COMPLETION, text: 'app.types.completion' },
]
for (const { mode, text } of appTypeTexts) {
onUrlUpdate.mockClear()
fireEvent.click(screen.getByText(text))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(lastCall.searchParams.get('category')).toBe(mode)
}
})
})
describe('App List Display', () => {
it('should display all app cards from data', () => {
renderList()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should display app names correctly', () => {
renderList()
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
})
describe('Footer Visibility', () => {
it('should render footer when branding is disabled', () => {
renderList()
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
})
describe('DSL File Drop', () => {
it('should handle DSL file drop and show modal', () => {
it('should render and close the DSL import modal when a file is dropped', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
@@ -489,98 +333,50 @@ describe('List', () => {
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
})
it('should close DSL modal when onClose is called', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
})
it('should close DSL modal and refetch when onSuccess is called', () => {
renderList()
describe('Snippets Mode', () => {
it('should render the snippets create card and snippet card from the real query hook', () => {
renderList({ pageType: 'snippets' })
expect(screen.getByText('snippet.create')).toBeInTheDocument()
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
expect(screen.getByText('Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Tone Rewriter/i })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate')
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument()
})
it('should request the next snippet page when the infinite-scroll anchor intersects', () => {
mockSnippetServiceState.hasNextPage = true
renderList({ pageType: 'snippets' })
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('success-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
expect(mockRefetch).toHaveBeenCalled()
})
})
describe('Infinite Scroll', () => {
it('should call fetchNextPage when intersection observer triggers', () => {
mockServiceState.hasNextPage = true
renderList()
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).toHaveBeenCalled()
expect(mockFetchSnippetNextPage).toHaveBeenCalled()
})
it('should not call fetchNextPage when not intersecting', () => {
mockServiceState.hasNextPage = true
renderList()
it('should not render app-only controls in snippets mode', () => {
renderList({ pageType: 'snippets' })
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: false } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
expect(screen.queryByText('app.studio.filters.types')).not.toBeInTheDocument()
expect(screen.queryByText('common.tag.placeholder')).not.toBeInTheDocument()
expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument()
})
it('should not call fetchNextPage when loading', () => {
mockServiceState.hasNextPage = true
mockServiceState.isLoading = true
renderList()
it('should not fetch the next snippet page when no more data is available', () => {
renderList({ pageType: 'snippets' })
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
act(() => {
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
})
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
})
describe('Error State', () => {
it('should handle error state in useEffect', () => {
mockServiceState.error = new Error('Test error')
const { container } = renderList()
expect(container).toBeInTheDocument()
expect(mockFetchSnippetNextPage).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,15 @@
import { parseAsStringLiteral } from 'nuqs'
import { AppModes } from '@/types/app'
export const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
export const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })

View File

@@ -0,0 +1,71 @@
'use client'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import { isAppListCategory } from './app-type-filter-shared'
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
type AppTypeFilterProps = {
activeTab: import('./app-type-filter-shared').AppListCategory
onChange: (value: import('./app-type-filter-shared').AppListCategory) => void
}
const AppTypeFilter = ({
activeTab,
onChange,
}: AppTypeFilterProps) => {
const { t } = useTranslation()
const options = useMemo(() => ([
{ value: 'all', text: t('types.all', { ns: 'app' }), iconClassName: 'i-ri-apps-2-line' },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), iconClassName: 'i-ri-exchange-2-line' },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), iconClassName: 'i-ri-robot-3-line' },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), iconClassName: 'i-ri-file-4-line' },
]), [t])
const activeOption = options.find(option => option.value === activeTab)
const triggerLabel = activeTab === 'all' ? t('studio.filters.types', { ns: 'app' }) : activeOption?.text
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(chipClassName, activeTab !== 'all' && 'shadow-xs')}
/>
)}
>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', activeOption?.iconClassName ?? 'i-ri-apps-2-line')} />
<span>{triggerLabel}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]">
<DropdownMenuRadioGroup value={activeTab} onValueChange={value => isAppListCategory(value) && onChange(value)}>
{options.map(option => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)} />
<span>{option.text}</span>
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default AppTypeFilter

View File

@@ -0,0 +1,128 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuCheckboxItemIndicator,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { cn } from '@/utils/classnames'
type CreatorOption = {
id: string
name: string
isYou?: boolean
avatarClassName: string
}
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
const creatorOptions: CreatorOption[] = [
{ id: 'evan', name: 'Evan', isYou: true, avatarClassName: 'bg-gradient-to-br from-[#ff9b3f] to-[#ff4d00]' },
{ id: 'jack', name: 'Jack', avatarClassName: 'bg-gradient-to-br from-[#fde68a] to-[#d6d3d1]' },
{ id: 'gigi', name: 'Gigi', avatarClassName: 'bg-gradient-to-br from-[#f9a8d4] to-[#a78bfa]' },
{ id: 'alice', name: 'Alice', avatarClassName: 'bg-gradient-to-br from-[#93c5fd] to-[#4f46e5]' },
{ id: 'mandy', name: 'Mandy', avatarClassName: 'bg-gradient-to-br from-[#374151] to-[#111827]' },
]
const CreatorsFilter = () => {
const { t } = useTranslation()
const [selectedCreatorIds, setSelectedCreatorIds] = useState<string[]>([])
const [keywords, setKeywords] = useState('')
const filteredCreators = useMemo(() => {
const normalizedKeywords = keywords.trim().toLowerCase()
if (!normalizedKeywords)
return creatorOptions
return creatorOptions.filter(creator => creator.name.toLowerCase().includes(normalizedKeywords))
}, [keywords])
const selectedCount = selectedCreatorIds.length
const triggerLabel = selectedCount > 0
? `${t('studio.filters.creators', { ns: 'app' })} +${selectedCount}`
: t('studio.filters.creators', { ns: 'app' })
const toggleCreator = useCallback((creatorId: string) => {
setSelectedCreatorIds((prev) => {
if (prev.includes(creatorId))
return prev.filter(id => id !== creatorId)
return [...prev, creatorId]
})
}, [])
const resetCreators = useCallback(() => {
setSelectedCreatorIds([])
setKeywords('')
}, [])
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(chipClassName, selectedCount > 0 && 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs')}
/>
)}
>
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
<span>{triggerLabel}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0">
<div className="flex items-center gap-2 p-2 pb-1">
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
/>
<button
type="button"
className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={resetCreators}
>
{t('studio.filters.reset', { ns: 'app' })}
</button>
</div>
<div className="px-1 pb-1">
<DropdownMenuCheckboxItem
checked={selectedCreatorIds.length === 0}
onCheckedChange={resetCreators}
>
<span aria-hidden className="i-ri-user-line h-4 w-4 shrink-0 text-text-tertiary" />
<span>{t('studio.filters.allCreators', { ns: 'app' })}</span>
<DropdownMenuCheckboxItemIndicator />
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
{filteredCreators.map(creator => (
<DropdownMenuCheckboxItem
key={creator.id}
checked={selectedCreatorIds.includes(creator.id)}
onCheckedChange={() => toggleCreator(creator.id)}
>
<span className={cn('h-5 w-5 shrink-0 rounded-full border border-white', creator.avatarClassName)} />
<span className="flex min-w-0 grow items-center justify-between gap-2">
<span className="truncate">{creator.name}</span>
{creator.isYou && (
<span className="shrink-0 text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
)}
</span>
<DropdownMenuCheckboxItemIndicator />
</DropdownMenuCheckboxItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default CreatorsFilter

View File

@@ -14,10 +14,20 @@ import CreateAppModal from '../explore/create-app-modal'
import TryApp from '../explore/try-app'
import List from './list'
const Apps = () => {
export type StudioPageType = 'apps' | 'snippets'
type AppsProps = {
pageType?: StudioPageType
}
const Apps = ({
pageType = 'apps',
}: AppsProps) => {
const { t } = useTranslation()
useDocumentTitle(t('menus.apps', { ns: 'common' }))
useDocumentTitle(pageType === 'apps'
? t('menus.apps', { ns: 'common' })
: t('tabs.snippets', { ns: 'workflow' }))
useEducationInit()
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
@@ -101,7 +111,7 @@ const Apps = () => {
}}
>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<List controlRefreshList={controlRefreshList} />
<List controlRefreshList={controlRefreshList} pageType={pageType} />
{isShowTryAppPanel && (
<TryApp
appId={currentTryAppParams?.appId || ''}

View File

@@ -1,30 +1,36 @@
'use client'
import type { FC } from 'react'
import type { StudioPageType } from '.'
import type { App } from '@/types/app'
import { useDebounceFn } from 'ahooks'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import TabSliderNew from '@/app/components/base/tab-slider-new'
import TagFilter from '@/app/components/base/tag-management/filter'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { CheckModal } from '@/hooks/use-pay'
import dynamic from '@/next/dynamic'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum, AppModes } from '@/types/app'
import { useInfiniteSnippetList } from '@/service/use-snippets'
import { cn } from '@/utils/classnames'
import SnippetCard from '../snippets/components/snippet-card'
import SnippetCreateCard from '../snippets/components/snippet-create-card'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import AppTypeFilter from './app-type-filter'
import { parseAsAppListCategory } from './app-type-filter-shared'
import CreatorsFilter from './creators-filter'
import Empty from './empty'
import Footer from './footer'
import useAppsQueryState from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
import NewAppCard from './new-app-card'
import StudioRouteSwitch from './studio-route-switch'
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
ssr: false,
@@ -33,25 +39,17 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
ssr: false,
})
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })
type Props = {
controlRefreshList?: number
pageType?: StudioPageType
}
const List: FC<Props> = ({
controlRefreshList = 0,
pageType = 'apps',
}) => {
const { t } = useTranslation()
const isAppsPage = pageType === 'apps'
const { systemFeatures } = useGlobalPublicStore()
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
@@ -61,18 +59,22 @@ const List: FC<Props> = ({
)
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
const [searchKeywords, setSearchKeywords] = useState(keywords)
const newAppCardRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [appKeywords, setAppKeywords] = useState(keywords)
const [snippetKeywordsInput, setSnippetKeywordsInput] = useState('')
const [snippetKeywords, setSnippetKeywords] = useState('')
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
const setKeywords = useCallback((keywords: string) => {
setQuery(prev => ({ ...prev, keywords }))
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const newAppCardRef = useRef<HTMLDivElement>(null)
const setKeywords = useCallback((nextKeywords: string) => {
setQuery(prev => ({ ...prev, keywords: nextKeywords }))
}, [setQuery])
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs }))
const setTagIDs = useCallback((nextTagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs: nextTagIDs }))
}, [setQuery])
const handleDSLFileDropped = useCallback((file: File) => {
@@ -83,15 +85,15 @@ const List: FC<Props> = ({
const { dragging } = useDSLDragDrop({
onDSLFileDropped: handleDSLFileDropped,
containerRef,
enabled: isCurrentWorkspaceEditor,
enabled: isAppsPage && isCurrentWorkspaceEditor,
})
const appListQueryParams = {
page: 1,
limit: 30,
name: searchKeywords,
name: appKeywords,
tag_ids: tagIDs,
is_created_by_me: isCreatedByMe,
is_created_by_me: queryIsCreatedByMe,
...(activeTab !== 'all' ? { mode: activeTab } : {}),
}
@@ -104,159 +106,214 @@ const List: FC<Props> = ({
hasNextPage,
error,
refetch,
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
} = useInfiniteAppList(appListQueryParams, {
enabled: isAppsPage && !isCurrentWorkspaceDatasetOperator,
})
const {
data: snippetData,
isLoading: isSnippetListLoading,
isFetching: isSnippetListFetching,
isFetchingNextPage: isSnippetListFetchingNextPage,
fetchNextPage: fetchSnippetNextPage,
hasNextPage: hasSnippetNextPage,
error: snippetError,
} = useInfiniteSnippetList({
page: 1,
limit: 30,
keyword: snippetKeywords || undefined,
}, {
enabled: !isAppsPage,
})
useEffect(() => {
if (controlRefreshList > 0) {
if (isAppsPage && controlRefreshList > 0)
refetch()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlRefreshList])
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="i-ri-apps-2-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="i-ri-exchange-2-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="i-ri-robot-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="i-ri-file-4-line mr-1 h-[14px] w-[14px]" /> },
]
}, [controlRefreshList, isAppsPage, refetch])
useEffect(() => {
if (!isAppsPage)
return
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
refetch()
}
}, [refetch])
}, [isAppsPage, refetch])
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return
const hasMore = hasNextPage ?? true
const hasMore = isAppsPage ? (hasNextPage ?? true) : (hasSnippetNextPage ?? true)
const isPageLoading = isAppsPage ? isLoading : isSnippetListLoading
const isNextPageFetching = isAppsPage ? isFetchingNextPage : isSnippetListFetchingNextPage
const currentError = isAppsPage ? error : snippetError
let observer: IntersectionObserver | undefined
if (error) {
if (observer)
observer.disconnect()
if (currentError) {
observer?.disconnect()
return
}
if (anchorRef.current && containerRef.current) {
// Calculate dynamic rootMargin: clamps to 100-200px range, using 20% of container height as the base value for better responsiveness
const containerHeight = containerRef.current.clientHeight
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
fetchNextPage()
if (entries[0].isIntersecting && !isPageLoading && !isNextPageFetching && !currentError && hasMore) {
if (isAppsPage)
fetchNextPage()
else
fetchSnippetNextPage()
}
}, {
root: containerRef.current,
rootMargin: `${dynamicMargin}px`,
threshold: 0.1, // Trigger when 10% of the anchor element is visible
threshold: 0.1,
})
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
}, [error, fetchNextPage, fetchSnippetNextPage, hasNextPage, hasSnippetNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading, isSnippetListFetchingNextPage, isSnippetListLoading, snippetError])
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
const { run: handleAppSearch } = useDebounceFn((value: string) => {
setAppKeywords(value)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const { run: handleTagsUpdate } = useDebounceFn(() => {
setTagIDs(tagFilterValue)
const { run: handleSnippetSearch } = useDebounceFn((value: string) => {
setSnippetKeywords(value)
}, { wait: 500 })
const handleTagsChange = (value: string[]) => {
const handleKeywordsChange = useCallback((value: string) => {
if (isAppsPage) {
setKeywords(value)
handleAppSearch(value)
return
}
setSnippetKeywordsInput(value)
handleSnippetSearch(value)
}, [handleAppSearch, handleSnippetSearch, isAppsPage, setKeywords])
const { run: handleTagsUpdate } = useDebounceFn((value: string[]) => {
setTagIDs(value)
}, { wait: 500 })
const handleTagsChange = useCallback((value: string[]) => {
setTagFilterValue(value)
handleTagsUpdate()
}
handleTagsUpdate(value)
}, [handleTagsUpdate])
const handleCreatedByMeChange = useCallback(() => {
const newValue = !isCreatedByMe
setIsCreatedByMe(newValue)
setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
}, [isCreatedByMe, setQuery])
const appItems = useMemo<App[]>(() => {
return (data?.pages ?? []).flatMap(({ data: apps }) => apps)
}, [data?.pages])
const pages = data?.pages ?? []
const hasAnyApp = (pages[0]?.total ?? 0) > 0
// Show skeleton during initial load or when refetching with no previous data
const showSkeleton = isLoading || (isFetching && pages.length === 0)
const snippetItems = useMemo(() => {
return (snippetData?.pages ?? []).flatMap(({ data }) => data)
}, [snippetData?.pages])
const showSkeleton = isAppsPage
? (isLoading || (isFetching && data?.pages?.length === 0))
: (isSnippetListLoading || (isSnippetListFetching && snippetItems.length === 0))
const hasAnyApp = (data?.pages?.[0]?.total ?? 0) > 0
const hasAnySnippet = snippetItems.length > 0
const currentKeywords = isAppsPage ? keywords : snippetKeywordsInput
return (
<>
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
{dragging && (
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2">
</div>
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2" />
)}
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pb-5 pt-7">
<TabSliderNew
value={activeTab}
onChange={(nextValue) => {
if (isAppListCategory(nextValue))
setActiveTab(nextValue)
}}
options={options}
/>
<div className="flex items-center gap-2">
<CheckboxWithLabel
className="mr-2"
label={t('showMyCreatedAppsOnly', { ns: 'app' })}
isChecked={isCreatedByMe}
onChange={handleCreatedByMeChange}
<div className="flex flex-wrap items-center gap-2">
<StudioRouteSwitch
pageType={pageType}
appsLabel={t('studio.apps', { ns: 'app' })}
snippetsLabel={t('tabs.snippets', { ns: 'workflow' })}
/>
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
{isAppsPage && (
<AppTypeFilter
activeTab={activeTab}
onChange={(value) => {
void setActiveTab(value)
}}
/>
)}
<CreatorsFilter />
{isAppsPage && (
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
)}
</div>
<div className="flex items-center gap-2">
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-[200px]"
value={keywords}
placeholder={isAppsPage ? undefined : t('tabs.searchSnippets', { ns: 'workflow' })}
value={currentKeywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
</div>
</div>
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6',
!hasAnyApp && 'overflow-hidden',
isAppsPage && !hasAnyApp && 'overflow-hidden',
)}
>
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={activeTab}
className={cn(!hasAnyApp && 'z-10')}
/>
isAppsPage
? (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={activeTab}
className={cn(!hasAnyApp && 'z-10')}
/>
)
: <SnippetCreateCard />
)}
{(() => {
if (showSkeleton)
return <AppCardSkeleton count={6} />
if (hasAnyApp) {
return pages.flatMap(({ data: apps }) => apps).map(app => (
<AppCard key={app.id} app={app} onRefresh={refetch} />
))
}
{showSkeleton && <AppCardSkeleton count={6} />}
// No apps - show empty state
return <Empty />
})()}
{isFetchingNextPage && (
{!showSkeleton && isAppsPage && hasAnyApp && appItems.map(app => (
<AppCard key={app.id} app={app} onRefresh={refetch} />
))}
{!showSkeleton && !isAppsPage && hasAnySnippet && snippetItems.map(snippet => (
<SnippetCard key={snippet.id} snippet={snippet} />
))}
{!showSkeleton && isAppsPage && !hasAnyApp && <Empty />}
{!showSkeleton && !isAppsPage && !hasAnySnippet && (
<div className="col-span-full flex min-h-[240px] items-center justify-center rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-6 text-center text-sm text-text-tertiary">
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
</div>
)}
{isAppsPage && isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
{!isAppsPage && isSnippetListFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
</div>
{isCurrentWorkspaceEditor && (
{isAppsPage && isCurrentWorkspaceEditor && (
<div
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
className={cn(
'flex items-center justify-center gap-2 py-4',
dragging ? 'text-text-accent' : 'text-text-quaternary',
)}
role="region"
aria-label={t('newApp.dropDSLToCreateApp', { ns: 'app' })}
>
@@ -264,17 +321,18 @@ const List: FC<Props> = ({
<span className="system-xs-regular">{t('newApp.dropDSLToCreateApp', { ns: 'app' })}</span>
</div>
)}
{!systemFeatures.branding.enabled && (
<Footer />
)}
<CheckModal />
<div ref={anchorRef} className="h-0"> </div>
{showTagManagementModal && (
{isAppsPage && showTagManagementModal && (
<TagManagementModal type="app" show={showTagManagementModal} />
)}
</div>
{showCreateFromDSLModal && (
{isAppsPage && showCreateFromDSLModal && (
<CreateFromDSLModal
show={showCreateFromDSLModal}
onClose={() => {

View File

@@ -0,0 +1,44 @@
'use client'
import type { StudioPageType } from '.'
import Link from '@/next/link'
import { cn } from '@/utils/classnames'
type Props = {
pageType: StudioPageType
appsLabel: string
snippetsLabel: string
}
const StudioRouteSwitch = ({
pageType,
appsLabel,
snippetsLabel,
}: Props) => {
return (
<div className="flex items-center rounded-lg border-[0.5px] border-divider-subtle bg-[rgba(200,206,218,0.2)] p-[1px]">
<Link
href="/apps"
className={cn(
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
pageType === 'apps' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
pageType !== 'apps' && 'font-medium',
)}
>
{appsLabel}
</Link>
<Link
href="/snippets"
className={cn(
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
pageType === 'snippets' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
pageType !== 'snippets' && 'font-medium',
)}
>
{snippetsLabel}
</Link>
</div>
)
}
export default StudioRouteSwitch

View File

@@ -35,7 +35,7 @@ vi.mock('../ExternalApiSelect', () => ({
<span data-testid="select-value">{value}</span>
<span data-testid="select-items-count">{items.length}</span>
{items.map((item: MockSelectItem) => (
<button key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}>
<button type="button" key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}>
{item.name}
</button>
))}

View File

@@ -0,0 +1,112 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import Evaluation from '..'
import { getEvaluationMockConfig } from '../mock'
import { useEvaluationStore } from '../store'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({
data: [{
provider: 'openai',
models: [{ model: 'gpt-4o-mini' }],
}],
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => (
<div data-testid="evaluation-model-selector">
{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'empty'}
</div>
),
}))
describe('Evaluation', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {} })
})
it('should search, add metrics, and create a batch history record', async () => {
vi.useFakeTimers()
render(<Evaluation resourceType="workflow" resourceId="app-1" />)
expect(screen.getByTestId('evaluation-model-selector')).toHaveTextContent('openai:gpt-4o-mini')
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
expect(screen.getByTestId('evaluation-metric-loading')).toBeInTheDocument()
await act(async () => {
vi.advanceTimersByTime(200)
})
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchPlaceholder'), {
target: { value: 'does-not-exist' },
})
await act(async () => {
vi.advanceTimersByTime(200)
})
expect(screen.getByText('evaluation.metrics.noResults')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchPlaceholder'), {
target: { value: 'faith' },
})
await act(async () => {
vi.advanceTimersByTime(200)
})
fireEvent.click(screen.getByRole('button', { name: /Faithfulness/i }))
expect(screen.getAllByText('Faithfulness').length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.batch.run' }))
expect(screen.getByText('evaluation.batch.status.running')).toBeInTheDocument()
await act(async () => {
vi.advanceTimersByTime(1300)
})
expect(screen.getByText('evaluation.batch.status.success')).toBeInTheDocument()
expect(screen.getByText('Workflow evaluation batch')).toBeInTheDocument()
vi.useRealTimers()
})
it('should render time placeholders and hide the value row for empty operators', () => {
const resourceType = 'workflow'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
const timeField = config.fieldOptions.find(field => field.type === 'time')!
let groupId = ''
let itemId = ''
act(() => {
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
const group = useEvaluationStore.getState().resources['workflow:app-2'].conditions[0]
groupId = group.id
itemId = group.items[0].id
store.updateConditionField(resourceType, resourceId, groupId, itemId, timeField.id)
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'before')
})
let rerender: ReturnType<typeof render>['rerender']
act(() => {
({ rerender } = render(<Evaluation resourceType={resourceType} resourceId={resourceId} />))
})
expect(screen.getByText('evaluation.conditions.selectTime')).toBeInTheDocument()
act(() => {
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'is_empty')
rerender(<Evaluation resourceType={resourceType} resourceId={resourceId} />)
})
expect(screen.queryByText('evaluation.conditions.selectTime')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,96 @@
import { getEvaluationMockConfig } from '../mock'
import {
getAllowedOperators,
isCustomMetricConfigured,
requiresConditionValue,
useEvaluationStore,
} from '../store'
describe('evaluation store', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {} })
})
it('should configure a custom metric mapping to a valid state', () => {
const resourceType = 'workflow'
const resourceId = 'app-1'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
const initialMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.kind === 'custom-workflow')
expect(initialMetric).toBeDefined()
expect(isCustomMetricConfigured(initialMetric!)).toBe(false)
store.setCustomMetricWorkflow(resourceType, resourceId, initialMetric!.id, config.workflowOptions[0].id)
store.updateCustomMetricMapping(resourceType, resourceId, initialMetric!.id, initialMetric!.customConfig!.mappings[0].id, {
sourceFieldId: config.fieldOptions[0].id,
targetVariableId: config.workflowOptions[0].targetVariables[0].id,
})
const configuredMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
expect(isCustomMetricConfigured(configuredMetric!)).toBe(true)
})
it('should add and remove builtin metrics', () => {
const resourceType = 'workflow'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, config.builtinMetrics[1].id)
const addedMetric = useEvaluationStore.getState().resources['workflow:app-2'].metrics.find(metric => metric.optionId === config.builtinMetrics[1].id)
expect(addedMetric).toBeDefined()
store.removeMetric(resourceType, resourceId, addedMetric!.id)
expect(useEvaluationStore.getState().resources['workflow:app-2'].metrics.some(metric => metric.id === addedMetric!.id)).toBe(false)
})
it('should update condition groups and adapt operators to field types', () => {
const resourceType = 'pipeline'
const resourceId = 'dataset-1'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
const initialGroup = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0]
store.setConditionGroupOperator(resourceType, resourceId, initialGroup.id, 'or')
store.addConditionGroup(resourceType, resourceId)
const booleanField = config.fieldOptions.find(field => field.type === 'boolean')!
const currentItem = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0].items[0]
store.updateConditionField(resourceType, resourceId, initialGroup.id, currentItem.id, booleanField.id)
const updatedGroup = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0]
expect(updatedGroup.logicalOperator).toBe('or')
expect(updatedGroup.items[0].operator).toBe('is')
expect(getAllowedOperators(resourceType, booleanField.id)).toEqual(['is', 'is_not'])
})
it('should support time fields and clear values for empty operators', () => {
const resourceType = 'workflow'
const resourceId = 'app-3'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
const timeField = config.fieldOptions.find(field => field.type === 'time')!
const item = useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].items[0]
store.updateConditionField(resourceType, resourceId, useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].id, item.id, timeField.id)
store.updateConditionOperator(resourceType, resourceId, useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].id, item.id, 'is_empty')
const updatedItem = useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].items[0]
expect(getAllowedOperators(resourceType, timeField.id)).toEqual(['is', 'before', 'after', 'is_empty', 'is_not_empty'])
expect(requiresConditionValue('is_empty')).toBe(false)
expect(updatedItem.value).toBeNull()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
import type {
ComparisonOperator,
EvaluationFieldOption,
EvaluationMockConfig,
EvaluationResourceType,
MetricOption,
} from './types'
const judgeModels = [
{
id: 'gpt-4.1-mini',
label: 'GPT-4.1 mini',
provider: 'OpenAI',
},
{
id: 'claude-3-7-sonnet',
label: 'Claude 3.7 Sonnet',
provider: 'Anthropic',
},
{
id: 'gemini-2.0-flash',
label: 'Gemini 2.0 Flash',
provider: 'Google',
},
]
const builtinMetrics: MetricOption[] = [
{
id: 'answer-correctness',
label: 'Answer Correctness',
description: 'Compares the response with the expected answer and scores factual alignment.',
group: 'quality',
badges: ['LLM', 'Built-in'],
},
{
id: 'faithfulness',
label: 'Faithfulness',
description: 'Checks whether the answer stays grounded in the retrieved evidence.',
group: 'quality',
badges: ['LLM', 'Retrieval'],
},
{
id: 'relevance',
label: 'Relevance',
description: 'Evaluates how directly the answer addresses the original request.',
group: 'quality',
badges: ['LLM'],
},
{
id: 'latency',
label: 'Latency',
description: 'Captures runtime responsiveness for the full execution path.',
group: 'operations',
badges: ['System'],
},
{
id: 'token-usage',
label: 'Token Usage',
description: 'Tracks prompt and completion token consumption for the run.',
group: 'operations',
badges: ['System'],
},
{
id: 'tool-success-rate',
label: 'Tool Success Rate',
description: 'Measures whether each required tool invocation finishes without failure.',
group: 'operations',
badges: ['Workflow'],
},
]
const workflowOptions = [
{
id: 'workflow-precision-review',
label: 'Precision Review Workflow',
description: 'Custom evaluator for nuanced quality review.',
targetVariables: [
{ id: 'query', label: 'query' },
{ id: 'answer', label: 'answer' },
{ id: 'reference', label: 'reference' },
],
},
{
id: 'workflow-risk-review',
label: 'Risk Review Workflow',
description: 'Custom evaluator for policy and escalation checks.',
targetVariables: [
{ id: 'input', label: 'input' },
{ id: 'output', label: 'output' },
],
},
]
const workflowFields: EvaluationFieldOption[] = [
{ id: 'app.input.query', label: 'Query', group: 'App Input', type: 'string' },
{ id: 'app.input.locale', label: 'Locale', group: 'App Input', type: 'enum', options: [{ value: 'en-US', label: 'en-US' }, { value: 'zh-Hans', label: 'zh-Hans' }] },
{ id: 'app.output.answer', label: 'Answer', group: 'App Output', type: 'string' },
{ id: 'app.output.score', label: 'Score', group: 'App Output', type: 'number' },
{ id: 'app.output.published_at', label: 'Publication Date', group: 'App Output', type: 'time' },
{ id: 'system.has_context', label: 'Has Context', group: 'System', type: 'boolean' },
]
const pipelineFields: EvaluationFieldOption[] = [
{ id: 'dataset.input.document_id', label: 'Document ID', group: 'Dataset', type: 'string' },
{ id: 'dataset.input.chunk_count', label: 'Chunk Count', group: 'Dataset', type: 'number' },
{ id: 'dataset.input.updated_at', label: 'Updated At', group: 'Dataset', type: 'time' },
{ id: 'retrieval.output.hit_rate', label: 'Hit Rate', group: 'Retrieval', type: 'number' },
{ id: 'retrieval.output.source', label: 'Source', group: 'Retrieval', type: 'enum', options: [{ value: 'bm25', label: 'BM25' }, { value: 'hybrid', label: 'Hybrid' }] },
{ id: 'pipeline.output.published', label: 'Published', group: 'Output', type: 'boolean' },
]
const snippetFields: EvaluationFieldOption[] = [
{ id: 'snippet.input.blog_url', label: 'Blog URL', group: 'Snippet Input', type: 'string' },
{ id: 'snippet.input.platforms', label: 'Platforms', group: 'Snippet Input', type: 'string' },
{ id: 'snippet.output.content', label: 'Generated Content', group: 'Snippet Output', type: 'string' },
{ id: 'snippet.output.length', label: 'Output Length', group: 'Snippet Output', type: 'number' },
{ id: 'snippet.output.scheduled_at', label: 'Scheduled At', group: 'Snippet Output', type: 'time' },
{ id: 'system.requires_review', label: 'Requires Review', group: 'System', type: 'boolean' },
]
export const getComparisonOperators = (fieldType: EvaluationFieldOption['type']): ComparisonOperator[] => {
if (fieldType === 'number')
return ['is', 'is_not', 'greater_than', 'less_than', 'greater_or_equal', 'less_or_equal', 'is_empty', 'is_not_empty']
if (fieldType === 'time')
return ['is', 'before', 'after', 'is_empty', 'is_not_empty']
if (fieldType === 'boolean' || fieldType === 'enum')
return ['is', 'is_not']
return ['contains', 'not_contains', 'is', 'is_not', 'is_empty', 'is_not_empty']
}
export const getDefaultOperator = (fieldType: EvaluationFieldOption['type']): ComparisonOperator => {
return getComparisonOperators(fieldType)[0]
}
export const getEvaluationMockConfig = (resourceType: EvaluationResourceType): EvaluationMockConfig => {
if (resourceType === 'pipeline') {
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: pipelineFields,
templateFileName: 'pipeline-evaluation-template.csv',
batchRequirements: [
'Include one row per retrieval scenario.',
'Provide the expected source or target chunk for each case.',
'Keep numeric metrics in plain number format.',
],
historySummaryLabel: 'Pipeline evaluation batch',
}
}
if (resourceType === 'snippet') {
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: snippetFields,
templateFileName: 'snippet-evaluation-template.csv',
batchRequirements: [
'Include one row per snippet execution case.',
'Provide the expected final content or acceptance rule.',
'Keep optional fields empty when not used.',
],
historySummaryLabel: 'Snippet evaluation batch',
}
}
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: workflowFields,
templateFileName: 'workflow-evaluation-template.csv',
batchRequirements: [
'Include one row per workflow test case.',
'Provide both user input and expected answer when available.',
'Keep boolean columns as true or false.',
],
historySummaryLabel: 'Workflow evaluation batch',
}
}

View File

@@ -0,0 +1,635 @@
import type {
BatchTestRecord,
ComparisonOperator,
EvaluationFieldOption,
EvaluationMetric,
EvaluationResourceState,
EvaluationResourceType,
JudgmentConditionGroup,
} from './types'
import { create } from 'zustand'
import { getComparisonOperators, getDefaultOperator, getEvaluationMockConfig } from './mock'
type EvaluationStore = {
resources: Record<string, EvaluationResourceState>
ensureResource: (resourceType: EvaluationResourceType, resourceId: string) => void
setJudgeModel: (resourceType: EvaluationResourceType, resourceId: string, judgeModelId: string) => void
addBuiltinMetric: (resourceType: EvaluationResourceType, resourceId: string, optionId: string) => void
addCustomMetric: (resourceType: EvaluationResourceType, resourceId: string) => void
removeMetric: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
setCustomMetricWorkflow: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, workflowId: string) => void
addCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
updateCustomMetricMapping: (
resourceType: EvaluationResourceType,
resourceId: string,
metricId: string,
mappingId: string,
patch: { sourceFieldId?: string | null, targetVariableId?: string | null },
) => void
removeCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, mappingId: string) => void
addConditionGroup: (resourceType: EvaluationResourceType, resourceId: string) => void
removeConditionGroup: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
setConditionGroupOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, logicalOperator: 'and' | 'or') => void
addConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
removeConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string) => void
updateConditionField: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, fieldId: string) => void
updateConditionOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, operator: ComparisonOperator) => void
updateConditionValue: (
resourceType: EvaluationResourceType,
resourceId: string,
groupId: string,
itemId: string,
value: string | number | boolean | null,
) => void
setBatchTab: (resourceType: EvaluationResourceType, resourceId: string, tab: EvaluationResourceState['activeBatchTab']) => void
setUploadedFileName: (resourceType: EvaluationResourceType, resourceId: string, uploadedFileName: string | null) => void
runBatchTest: (resourceType: EvaluationResourceType, resourceId: string) => void
}
const buildResourceKey = (resourceType: EvaluationResourceType, resourceId: string) => `${resourceType}:${resourceId}`
const initialResourceCache: Record<string, EvaluationResourceState> = {}
const createId = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 10)}`
export const conditionOperatorsWithoutValue: ComparisonOperator[] = ['is_empty', 'is_not_empty']
export const requiresConditionValue = (operator: ComparisonOperator) => !conditionOperatorsWithoutValue.includes(operator)
const getConditionValue = (
field: EvaluationFieldOption | undefined,
operator: ComparisonOperator,
previousValue: string | number | boolean | null = null,
) => {
if (!field || !requiresConditionValue(operator))
return null
if (field.type === 'boolean')
return typeof previousValue === 'boolean' ? previousValue : null
if (field.type === 'enum')
return typeof previousValue === 'string' ? previousValue : null
if (field.type === 'number')
return typeof previousValue === 'number' ? previousValue : null
return typeof previousValue === 'string' ? previousValue : null
}
const buildConditionItem = (resourceType: EvaluationResourceType) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions[0]
const operator = field ? getDefaultOperator(field.type) : 'contains'
return {
id: createId('condition'),
fieldId: field?.id ?? null,
operator,
value: getConditionValue(field, operator),
}
}
const buildInitialState = (resourceType: EvaluationResourceType): EvaluationResourceState => {
const config = getEvaluationMockConfig(resourceType)
const defaultMetric = config.builtinMetrics[0]
return {
judgeModelId: null,
metrics: defaultMetric
? [{
id: createId('metric'),
optionId: defaultMetric.id,
kind: 'builtin',
label: defaultMetric.label,
description: defaultMetric.description,
badges: defaultMetric.badges,
}]
: [],
conditions: [{
id: createId('group'),
logicalOperator: 'and',
items: [buildConditionItem(resourceType)],
}],
activeBatchTab: 'input-fields',
uploadedFileName: null,
batchRecords: [],
}
}
const withResourceState = (
resources: EvaluationStore['resources'],
resourceType: EvaluationResourceType,
resourceId: string,
) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
return {
resourceKey,
resource: resources[resourceKey] ?? buildInitialState(resourceType),
}
}
const updateMetric = (
metrics: EvaluationMetric[],
metricId: string,
updater: (metric: EvaluationMetric) => EvaluationMetric,
) => metrics.map(metric => metric.id === metricId ? updater(metric) : metric)
const updateConditionGroup = (
groups: JudgmentConditionGroup[],
groupId: string,
updater: (group: JudgmentConditionGroup) => JudgmentConditionGroup,
) => groups.map(group => group.id === groupId ? updater(group) : group)
export const isCustomMetricConfigured = (metric: EvaluationMetric) => {
if (metric.kind !== 'custom-workflow')
return true
if (!metric.customConfig?.workflowId)
return false
return metric.customConfig.mappings.length > 0
&& metric.customConfig.mappings.every(mapping => !!mapping.sourceFieldId && !!mapping.targetVariableId)
}
export const isEvaluationRunnable = (state: EvaluationResourceState) => {
return !!state.judgeModelId
&& state.metrics.length > 0
&& state.metrics.every(isCustomMetricConfigured)
&& state.conditions.some(group => group.items.length > 0)
}
export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
resources: {},
ensureResource: (resourceType, resourceId) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
if (get().resources[resourceKey])
return
set(state => ({
resources: {
...state.resources,
[resourceKey]: buildInitialState(resourceType),
},
}))
},
setJudgeModel: (resourceType, resourceId, judgeModelId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
judgeModelId,
},
},
}
})
},
addBuiltinMetric: (resourceType, resourceId, optionId) => {
const option = getEvaluationMockConfig(resourceType).builtinMetrics.find(metric => metric.id === optionId)
if (!option)
return
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
if (resource.metrics.some(metric => metric.optionId === optionId && metric.kind === 'builtin'))
return state
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: [
...resource.metrics,
{
id: createId('metric'),
optionId: option.id,
kind: 'builtin',
label: option.label,
description: option.description,
badges: option.badges,
},
],
},
},
}
})
},
addCustomMetric: (resourceType, resourceId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: [
...resource.metrics,
{
id: createId('metric'),
optionId: createId('custom'),
kind: 'custom-workflow',
label: 'Custom Evaluator',
description: 'Map workflow variables to your evaluation inputs.',
badges: ['Workflow'],
customConfig: {
workflowId: null,
mappings: [{
id: createId('mapping'),
sourceFieldId: null,
targetVariableId: null,
}],
},
},
],
},
},
}
})
},
removeMetric: (resourceType, resourceId, metricId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: resource.metrics.filter(metric => metric.id !== metricId),
},
},
}
})
},
setCustomMetricWorkflow: (resourceType, resourceId, metricId, workflowId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
workflowId,
mappings: metric.customConfig.mappings.map(mapping => ({
...mapping,
targetVariableId: null,
})),
}
: metric.customConfig,
})),
},
},
}
})
},
addCustomMetricMapping: (resourceType, resourceId, metricId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: [
...metric.customConfig.mappings,
{
id: createId('mapping'),
sourceFieldId: null,
targetVariableId: null,
},
],
}
: metric.customConfig,
})),
},
},
}
})
},
updateCustomMetricMapping: (resourceType, resourceId, metricId, mappingId, patch) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: metric.customConfig.mappings.map(mapping => mapping.id === mappingId ? { ...mapping, ...patch } : mapping),
}
: metric.customConfig,
})),
},
},
}
})
},
removeCustomMetricMapping: (resourceType, resourceId, metricId, mappingId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: metric.customConfig.mappings.filter(mapping => mapping.id !== mappingId),
}
: metric.customConfig,
})),
},
},
}
})
},
addConditionGroup: (resourceType, resourceId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: [
...resource.conditions,
{
id: createId('group'),
logicalOperator: 'and',
items: [buildConditionItem(resourceType)],
},
],
},
},
}
})
},
removeConditionGroup: (resourceType, resourceId, groupId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: resource.conditions.filter(group => group.id !== groupId),
},
},
}
})
},
setConditionGroupOperator: (resourceType, resourceId, groupId, logicalOperator) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
logicalOperator,
})),
},
},
}
})
},
addConditionItem: (resourceType, resourceId, groupId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: [
...group.items,
buildConditionItem(resourceType),
],
})),
},
},
}
})
},
removeConditionItem: (resourceType, resourceId, groupId, itemId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.filter(item => item.id !== itemId),
})),
},
},
}
})
},
updateConditionField: (resourceType, resourceId, groupId, itemId, fieldId) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map((item) => {
if (item.id !== itemId)
return item
return {
...item,
fieldId,
operator: field ? getDefaultOperator(field.type) : item.operator,
value: getConditionValue(field, field ? getDefaultOperator(field.type) : item.operator),
}
}),
})),
},
},
}
})
},
updateConditionOperator: (resourceType, resourceId, groupId, itemId, operator) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
const fieldOptions = getEvaluationMockConfig(resourceType).fieldOptions
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map((item) => {
if (item.id !== itemId)
return item
const field = fieldOptions.find(option => option.id === item.fieldId)
return {
...item,
operator,
value: getConditionValue(field, operator, item.value),
}
}),
})),
},
},
}
})
},
updateConditionValue: (resourceType, resourceId, groupId, itemId, value) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map(item => item.id === itemId ? { ...item, value } : item),
})),
},
},
}
})
},
setBatchTab: (resourceType, resourceId, tab) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
activeBatchTab: tab,
},
},
}
})
},
setUploadedFileName: (resourceType, resourceId, uploadedFileName) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
uploadedFileName,
},
},
}
})
},
runBatchTest: (resourceType, resourceId) => {
const config = getEvaluationMockConfig(resourceType)
const recordId = createId('batch')
const nextRecord: BatchTestRecord = {
id: recordId,
fileName: get().resources[buildResourceKey(resourceType, resourceId)]?.uploadedFileName ?? config.templateFileName,
status: 'running',
startedAt: new Date().toLocaleTimeString(),
summary: config.historySummaryLabel,
}
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
activeBatchTab: 'history',
batchRecords: [nextRecord, ...resource.batchRecords],
},
},
}
})
window.setTimeout(() => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
batchRecords: resource.batchRecords.map(record => record.id === recordId
? {
...record,
status: resource.metrics.length > 1 ? 'success' : 'failed',
}
: record),
},
},
}
})
}, 1200)
},
}))
export const useEvaluationResource = (resourceType: EvaluationResourceType, resourceId: string) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
return useEvaluationStore(state => state.resources[resourceKey] ?? (initialResourceCache[resourceKey] ??= buildInitialState(resourceType)))
}
export const getAllowedOperators = (resourceType: EvaluationResourceType, fieldId: string | null) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
if (!field)
return ['contains'] as ComparisonOperator[]
return getComparisonOperators(field.type)
}

View File

@@ -0,0 +1,117 @@
export type EvaluationResourceType = 'workflow' | 'pipeline' | 'snippet'
export type MetricKind = 'builtin' | 'custom-workflow'
export type BatchTestTab = 'input-fields' | 'history'
export type FieldType = 'string' | 'number' | 'boolean' | 'enum' | 'time'
export type ComparisonOperator
= | 'contains'
| 'not_contains'
| 'is'
| 'is_not'
| 'is_empty'
| 'is_not_empty'
| 'greater_than'
| 'less_than'
| 'greater_or_equal'
| 'less_or_equal'
| 'before'
| 'after'
export type JudgeModelOption = {
id: string
label: string
provider: string
}
export type MetricOption = {
id: string
label: string
description: string
group: string
badges: string[]
}
export type EvaluationWorkflowOption = {
id: string
label: string
description: string
targetVariables: Array<{
id: string
label: string
}>
}
export type EvaluationFieldOption = {
id: string
label: string
group: string
type: FieldType
options?: Array<{
value: string
label: string
}>
}
export type CustomMetricMapping = {
id: string
sourceFieldId: string | null
targetVariableId: string | null
}
export type CustomMetricConfig = {
workflowId: string | null
mappings: CustomMetricMapping[]
}
export type EvaluationMetric = {
id: string
optionId: string
kind: MetricKind
label: string
description: string
badges: string[]
customConfig?: CustomMetricConfig
}
export type JudgmentConditionItem = {
id: string
fieldId: string | null
operator: ComparisonOperator
value: string | number | boolean | null
}
export type JudgmentConditionGroup = {
id: string
logicalOperator: 'and' | 'or'
items: JudgmentConditionItem[]
}
export type BatchTestRecord = {
id: string
fileName: string
status: 'running' | 'success' | 'failed'
startedAt: string
summary: string
}
export type EvaluationResourceState = {
judgeModelId: string | null
metrics: EvaluationMetric[]
conditions: JudgmentConditionGroup[]
activeBatchTab: BatchTestTab
uploadedFileName: string | null
batchRecords: BatchTestRecord[]
}
export type EvaluationMockConfig = {
judgeModels: JudgeModelOption[]
builtinMetrics: MetricOption[]
workflowOptions: EvaluationWorkflowOption[]
fieldOptions: EvaluationFieldOption[]
templateFileName: string
batchRequirements: string[]
historySummaryLabel: string
}

View File

@@ -78,6 +78,7 @@ const ApiBasedExtensionModal: FC<ApiBasedExtensionModalProps> = ({
<Modal
isShow
onClose={noop}
wrapperClassName="z-[1002]"
className="!w-[640px] !max-w-none !p-8 !pb-6"
>
<div className="mb-2 text-xl font-semibold text-text-primary">

View File

@@ -69,7 +69,7 @@ const ApiBasedExtensionSelector: FC<ApiBasedExtensionSelectorProps> = ({
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[102] w-[calc(100%-32px)] max-w-[576px]">
<PortalToFollowElemContent className="z-[1002] w-[calc(100%-32px)] max-w-[576px]">
<div className="z-10 w-full rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
<div className="p-1">
<div className="flex items-center justify-between px-3 pb-1 pt-2">

View File

@@ -84,7 +84,7 @@ const Configure = ({
{t('dataSource.configure', { ns: 'common' })}
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[61]">
<PortalToFollowElemContent className="z-[1002]">
<div className="w-[240px] space-y-1.5 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg">
{
!!canOAuth && (
@@ -104,7 +104,7 @@ const Configure = ({
}
{
!!canApiKey && !!canOAuth && (
<div className="system-2xs-medium-uppercase flex h-4 items-center p-2 text-text-quaternary">
<div className="flex h-4 items-center p-2 text-text-quaternary system-2xs-medium-uppercase">
<div className="mr-2 h-[1px] grow bg-gradient-to-l from-[rgba(16,24,40,0.08)]" />
OR
<div className="ml-2 h-[1px] grow bg-gradient-to-r from-[rgba(16,24,40,0.08)]" />

View File

@@ -39,7 +39,7 @@ const Operator = ({
text: (
<div className="flex items-center">
<RiHome9Line className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="system-sm-semibold text-text-secondary">{t('auth.setDefault', { ns: 'plugin' })}</div>
<div className="text-text-secondary system-sm-semibold">{t('auth.setDefault', { ns: 'plugin' })}</div>
</div>
),
},
@@ -51,7 +51,7 @@ const Operator = ({
text: (
<div className="flex items-center">
<RiEditLine className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="system-sm-semibold text-text-secondary">{t('operation.rename', { ns: 'common' })}</div>
<div className="text-text-secondary system-sm-semibold">{t('operation.rename', { ns: 'common' })}</div>
</div>
),
},
@@ -66,7 +66,7 @@ const Operator = ({
text: (
<div className="flex items-center">
<RiEqualizer2Line className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="system-sm-semibold text-text-secondary">{t('operation.edit', { ns: 'common' })}</div>
<div className="text-text-secondary system-sm-semibold">{t('operation.edit', { ns: 'common' })}</div>
</div>
),
},
@@ -81,7 +81,7 @@ const Operator = ({
text: (
<div className="flex items-center">
<RiStickyNoteAddLine className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="system-sm-semibold mb-1 text-text-secondary">{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}</div>
<div className="mb-1 text-text-secondary system-sm-semibold">{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}</div>
</div>
),
},
@@ -98,7 +98,7 @@ const Operator = ({
text: (
<div className="flex items-center">
<RiDeleteBinLine className="mr-2 h-4 w-4 text-text-tertiary" />
<div className="system-sm-semibold text-text-secondary">
<div className="text-text-secondary system-sm-semibold">
{t('operation.remove', { ns: 'common' })}
</div>
</div>
@@ -122,7 +122,7 @@ const Operator = ({
items={items}
secondItems={secondItems}
onSelect={handleSelect}
popupClassName="z-[61]"
popupClassName="z-[1002]"
triggerProps={{
size: 'l',
}}

View File

@@ -2,11 +2,15 @@ import type { InvitationResponse } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import { useProviderContextSelector } from '@/context/provider-context'
import { inviteMember } from '@/service/common'
import InviteModal from '../index'
const { mockToastError } = vi.hoisted(() => ({
mockToastError: vi.fn(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: vi.fn(),
useProviderContext: vi.fn(() => ({
@@ -14,6 +18,11 @@ vi.mock('@/context/provider-context', () => ({
})),
}))
vi.mock('@/service/common')
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: mockToastError,
},
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
@@ -37,7 +46,6 @@ describe('InviteModal', () => {
const mockOnCancel = vi.fn()
const mockOnSend = vi.fn()
const mockRefreshLicenseLimit = vi.fn()
const mockNotify = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
@@ -49,10 +57,11 @@ describe('InviteModal', () => {
})
const renderModal = (isEmailSetup = true) => render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<InviteModal isEmailSetup={isEmailSetup} onCancel={mockOnCancel} onSend={mockOnSend} />
</ToastContext.Provider>,
<InviteModal isEmailSetup={isEmailSetup} onCancel={mockOnCancel} onSend={mockOnSend} />,
)
const fillEmails = (value: string) => {
fireEvent.change(screen.getByTestId('mock-email-input'), { target: { value } })
}
it('should render invite modal content', async () => {
renderModal()
@@ -68,12 +77,8 @@ describe('InviteModal', () => {
})
it('should enable send button after entering an email', async () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeEnabled()
})
@@ -84,7 +89,7 @@ describe('InviteModal', () => {
renderModal()
await user.type(screen.getByTestId('mock-email-input'), 'user@example.com')
fillEmails('user@example.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
@@ -103,8 +108,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
@@ -116,8 +120,6 @@ describe('InviteModal', () => {
})
it('should keep send button disabled when license limit is exceeded', async () => {
const user = userEvent.setup()
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
@@ -125,8 +127,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled()
})
@@ -144,15 +145,11 @@ describe('InviteModal', () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByTestId('mock-email-input')
// Use an email that passes basic validation but fails our strict regex (needs 2+ char TLD)
await user.type(input, 'invalid@email.c')
fillEmails('invalid@email.c')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.members.emailInvalid',
})
expect(toast.error).toHaveBeenCalledWith('common.members.emailInvalid')
expect(inviteMember).not.toHaveBeenCalled()
})
@@ -160,8 +157,7 @@ describe('InviteModal', () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
expect(screen.getByText('user@example.com')).toBeInTheDocument()
@@ -203,7 +199,7 @@ describe('InviteModal', () => {
renderModal()
await user.type(screen.getByTestId('mock-email-input'), 'user@example.com')
fillEmails('user@example.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
@@ -214,8 +210,6 @@ describe('InviteModal', () => {
})
it('should show destructive text color when used size exceeds limit', async () => {
const user = userEvent.setup()
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
@@ -223,8 +217,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
// usedSize = 10 + 1 = 11 > limit 10 → destructive color
const counter = screen.getByText('11')
@@ -241,8 +234,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
@@ -264,8 +256,6 @@ describe('InviteModal', () => {
})
it('should show destructive color and disable send button when limit is exactly met with one email', async () => {
const user = userEvent.setup()
// size=10, limit=10 - adding 1 email makes usedSize=11 > limit=10
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
@@ -274,8 +264,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
// isLimitExceeded=true → button is disabled, cannot submit
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
@@ -293,8 +282,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
@@ -320,11 +308,9 @@ describe('InviteModal', () => {
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
const user = userEvent.setup()
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
// isLimited=false → no destructive color
const counter = screen.getByText('1')

View File

@@ -1,12 +0,0 @@
.modal {
padding: 24px 32px !important;
width: 400px !important;
}
.emailsInput {
background-color: rgb(243 244 246 / var(--tw-bg-opacity)) !important;
}
.emailBackground {
background-color: white !important;
}

View File

@@ -2,20 +2,17 @@
import type { RoleKey } from './role-selector'
import type { InvitationResult } from '@/models/common'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ReactMultiEmail } from 'react-multi-email'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast/context'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
import { toast } from '@/app/components/base/ui/toast'
import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
import { useProviderContextSelector } from '@/context/provider-context'
import { inviteMember } from '@/service/common'
import { cn } from '@/utils/classnames'
import s from './index.module.css'
import RoleSelector from './role-selector'
import 'react-multi-email/dist/style.css'
@@ -34,7 +31,6 @@ const InviteModal = ({
const licenseLimit = useProviderContextSelector(s => s.licenseLimit)
const refreshLicenseLimit = useProviderContextSelector(s => s.refreshLicenseLimit)
const [emails, setEmails] = useState<string[]>([])
const { notify } = useContext(ToastContext)
const [isLimited, setIsLimited] = useState(false)
const [isLimitExceeded, setIsLimitExceeded] = useState(false)
const [usedSize, setUsedSize] = useState(licenseLimit.workspace_members.size ?? 0)
@@ -74,21 +70,28 @@ const InviteModal = ({
catch { }
}
else {
notify({ type: 'error', message: t('members.emailInvalid', { ns: 'common' }) })
toast.error(t('members.emailInvalid', { ns: 'common' }))
}
setIsSubmitted()
}, [isLimitExceeded, emails, role, locale, onCancel, onSend, notify, t, isSubmitting, refreshLicenseLimit, setIsSubmitted, setIsSubmitting])
}, [isLimitExceeded, emails, role, locale, onCancel, onSend, t, isSubmitting, refreshLicenseLimit, setIsSubmitted, setIsSubmitting])
return (
<div className={cn(s.wrap)}>
<Modal overflowVisible isShow onClose={noop} className={cn(s.modal)}>
<div className="mb-2 flex justify-between">
<div className="text-xl font-semibold text-text-primary">{t('members.inviteTeamMember', { ns: 'common' })}</div>
<div
data-testid="invite-modal-close"
className="i-ri-close-line h-4 w-4 cursor-pointer text-text-tertiary"
onClick={onCancel}
/>
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
>
<DialogContent
backdropProps={{ forceRender: true }}
className="w-[400px] overflow-visible px-8 py-6"
>
<DialogCloseButton data-testid="invite-modal-close" className="right-8 top-6" />
<div className="mb-2 pr-8">
<DialogTitle className="text-xl font-semibold text-text-primary">
{t('members.inviteTeamMember', { ns: 'common' })}
</DialogTitle>
</div>
<div className="mb-3 text-[13px] text-text-tertiary">{t('members.inviteTeamMemberTip', { ns: 'common' })}</div>
{!isEmailSetup && (
@@ -152,8 +155,8 @@ const InviteModal = ({
{t('members.sendInvite', { ns: 'common' })}
</Button>
</div>
</Modal>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,11 +1,10 @@
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
@@ -25,115 +24,111 @@ export type RoleSelectorProps = {
const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const { datasetOperatorEnabled } = useProviderContext()
const [open, setOpen] = React.useState(false)
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className="block"
>
<PopoverTrigger
data-testid="role-selector-trigger"
className={cn(
'flex w-full cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-state-base-hover',
open && 'bg-state-base-hover',
)}
>
<div className="mr-2 grow text-sm leading-5 text-text-primary">{t('members.invitedAsRole', { ns: 'common', role: t(roleI18nKeyMap[value], { ns: 'common' }) })}</div>
<div className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-secondary" />
</PopoverTrigger>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[336px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg"
>
<div className="p-1">
<div
data-testid="role-selector-trigger"
className={cn('flex cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-state-base-hover', open && 'bg-state-base-hover')}
data-testid="role-option-normal"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('normal')
setOpen(false)
}}
>
<div className="mr-2 grow text-sm leading-5 text-text-primary">{t('members.invitedAsRole', { ns: 'common', role: t(roleI18nKeyMap[value], { ns: 'common' }) })}</div>
<div className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-secondary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1002]">
<div className="relative w-[336px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
<div className="p-1">
<div
data-testid="role-option-normal"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('normal')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.normal', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.normalTip', { ns: 'common' })}</div>
{value === 'normal' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
<div
data-testid="role-option-editor"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('editor')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.editor', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.editorTip', { ns: 'common' })}</div>
{value === 'editor' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
<div
data-testid="role-option-admin"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('admin')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.admin', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.adminTip', { ns: 'common' })}</div>
{value === 'admin' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
{datasetOperatorEnabled && (
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.normal', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.normalTip', { ns: 'common' })}</div>
{value === 'normal' && (
<div
data-testid="role-option-dataset_operator"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('dataset_operator')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.datasetOperator', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.datasetOperatorTip', { ns: 'common' })}</div>
{value === 'dataset_operator' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
</PortalToFollowElemContent>
</div>
</PortalToFollowElem>
<div
data-testid="role-option-editor"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('editor')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.editor', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.editorTip', { ns: 'common' })}</div>
{value === 'editor' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
<div
data-testid="role-option-admin"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('admin')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.admin', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.adminTip', { ns: 'common' })}</div>
{value === 'admin' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
{datasetOperatorEnabled && (
<div
data-testid="role-option-dataset_operator"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('dataset_operator')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.datasetOperator', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.datasetOperatorTip', { ns: 'common' })}</div>
{value === 'dataset_operator' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
)}
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,15 +1,10 @@
import type { InvitationResult } from '@/models/common'
import { XMarkIcon } from '@heroicons/react/24/outline'
import { CheckCircleIcon } from '@heroicons/react/24/solid'
import { RiQuestionLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import Tooltip from '@/app/components/base/tooltip'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { IS_CE_EDITION } from '@/config'
import s from './index.module.css'
import InvitationLink from './invitation-link'
export type SuccessInvitationResult = Extract<InvitationResult, { status: 'success' }>
@@ -29,8 +24,18 @@ const InvitedModal = ({
const failedInvitationResults = useMemo<FailedInvitationResult[]>(() => invitationResults?.filter(item => item.status !== 'success') as FailedInvitationResult[], [invitationResults])
return (
<div className={s.wrap}>
<Modal isShow onClose={noop} className={s.modal}>
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
>
<DialogContent
backdropProps={{ forceRender: true }}
className="w-[480px] p-8"
>
<DialogCloseButton className="right-8 top-8" />
<div className="mb-3 flex justify-between">
<div className="
flex h-12 w-12 items-center justify-center rounded-xl
@@ -38,11 +43,10 @@ const InvitedModal = ({
shadow-xl
"
>
<CheckCircleIcon className="h-[22px] w-[22px] text-[#039855]" />
<div className="i-heroicons-check-circle-solid h-[22px] w-[22px] text-[#039855]" />
</div>
<XMarkIcon className="h-4 w-4 cursor-pointer" onClick={onCancel} />
</div>
<div className="mb-1 text-xl font-semibold text-text-primary">{t('members.invitationSent', { ns: 'common' })}</div>
<DialogTitle className="mb-1 text-xl font-semibold text-text-primary">{t('members.invitationSent', { ns: 'common' })}</DialogTitle>
{!IS_CE_EDITION && (
<div className="mb-10 text-sm text-text-tertiary">{t('members.invitationSentTip', { ns: 'common' })}</div>
)}
@@ -54,7 +58,7 @@ const InvitedModal = ({
!!successInvitationResults.length
&& (
<>
<div className="font-Medium py-2 text-sm text-text-primary">{t('members.invitationLink', { ns: 'common' })}</div>
<div className="py-2 text-sm font-medium text-text-primary">{t('members.invitationLink', { ns: 'common' })}</div>
{successInvitationResults.map(item =>
<InvitationLink key={item.email} value={item} />)}
</>
@@ -64,18 +68,23 @@ const InvitedModal = ({
!!failedInvitationResults.length
&& (
<>
<div className="font-Medium py-2 text-sm text-text-primary">{t('members.failedInvitationEmails', { ns: 'common' })}</div>
<div className="py-2 text-sm font-medium text-text-primary">{t('members.failedInvitationEmails', { ns: 'common' })}</div>
<div className="flex flex-wrap justify-between gap-y-1">
{
failedInvitationResults.map(item => (
<div key={item.email} className="flex justify-center rounded-md border border-red-300 bg-orange-50 px-1">
<Tooltip
popupContent={item.message}
>
<div className="flex items-center justify-center gap-1 text-sm">
{item.email}
<RiQuestionLine className="h-4 w-4 text-red-300" />
</div>
<Tooltip>
<TooltipTrigger
render={(
<div className="flex items-center justify-center gap-1 text-sm">
{item.email}
<div className="i-ri-question-line h-4 w-4 text-red-300" />
</div>
)}
/>
<TooltipContent>
{item.message}
</TooltipContent>
</Tooltip>
</div>
),
@@ -97,8 +106,8 @@ const InvitedModal = ({
{t('members.ok', { ns: 'common' })}
</Button>
</div>
</Modal>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -4,7 +4,7 @@ import copy from 'copy-to-clipboard'
import { t } from 'i18next'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import Tooltip from '@/app/components/base/tooltip'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import s from './index.module.css'
type IInvitationLinkProps = {
@@ -38,20 +38,28 @@ const InvitationLink = ({
<div className="flex items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal py-2 hover:bg-state-base-hover" data-testid="invitation-link-container">
<div className="flex h-5 grow items-center">
<div className="relative h-full grow text-[13px]">
<Tooltip
popupContent={isCopied ? `${t('copied', { ns: 'appApi' })}` : `${t('copy', { ns: 'appApi' })}`}
>
<div className="absolute left-0 right-0 top-0 w-full cursor-pointer truncate pl-2 pr-2 text-text-primary" onClick={copyHandle} data-testid="invitation-link-url">{value.url}</div>
<Tooltip>
<TooltipTrigger
render={<div className="absolute left-0 right-0 top-0 w-full cursor-pointer truncate pl-2 pr-2 text-text-primary" onClick={copyHandle} data-testid="invitation-link-url">{value.url}</div>}
/>
<TooltipContent>
{isCopied ? t('copied', { ns: 'appApi' }) : t('copy', { ns: 'appApi' })}
</TooltipContent>
</Tooltip>
</div>
<div className="h-4 shrink-0 border bg-divider-regular" />
<Tooltip
popupContent={isCopied ? `${t('copied', { ns: 'appApi' })}` : `${t('copy', { ns: 'appApi' })}`}
>
<div className="shrink-0 px-0.5">
<div className={`box-border flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover ${s.copyIcon} ${isCopied ? s.copied : ''}`} onClick={copyHandle} data-testid="invitation-link-copy">
</div>
</div>
<Tooltip>
<TooltipTrigger
render={(
<div className="shrink-0 px-0.5">
<div className={`box-border flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover ${s.copyIcon} ${isCopied ? s.copied : ''}`} onClick={copyHandle} data-testid="invitation-link-copy">
</div>
</div>
)}
/>
<TooltipContent>
{isCopied ? t('copied', { ns: 'appApi' }) : t('copy', { ns: 'appApi' })}
</TooltipContent>
</Tooltip>
</div>
</div>

View File

@@ -102,7 +102,7 @@ const Operation = ({
<ChevronDownIcon className={cn('h-4 w-4 shrink-0 group-hover:block', open ? 'block' : 'hidden')} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[999]">
<PortalToFollowElemContent className="z-[1002]">
<div className={cn('inline-flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm')}>
<div className="p-1">
{

View File

@@ -141,6 +141,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
<Modal
isShow={show}
onClose={noop}
wrapperClassName="z-[1002]"
className="!w-[420px] !p-6"
>
<div

View File

@@ -77,7 +77,7 @@ const MemberSelector: FC<Props> = ({
<div className={cn('i-ri-arrow-down-s-line h-4 w-4 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1000]">
<PortalToFollowElemContent className="z-[1002]">
<div className="min-w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
<div className="p-2 pb-1">
<Input

View File

@@ -116,7 +116,7 @@ const AddCustomModel = ({
>
{renderTrigger(open)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[100]">
<PortalToFollowElemContent className="z-[1002]">
<div className="w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<div className="max-h-[304px] overflow-y-auto p-1">
{
@@ -136,7 +136,7 @@ const AddCustomModel = ({
modelName={model.model}
/>
<div
className="system-md-regular grow truncate text-text-primary"
className="grow truncate text-text-primary system-md-regular"
title={model.model}
>
{model.model}
@@ -148,7 +148,7 @@ const AddCustomModel = ({
{
!notAllowCustomCredential && (
<div
className="system-xs-medium flex cursor-pointer items-center border-t border-t-divider-subtle p-3 text-text-accent-light-mode-only"
className="flex cursor-pointer items-center border-t border-t-divider-subtle p-3 text-text-accent-light-mode-only system-xs-medium"
onClick={() => {
handleOpenModalForAddNewCustomModel()
setOpen(false)

View File

@@ -164,7 +164,7 @@ const Authorized = ({
>
{renderTrigger(mergedIsOpen)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[100]">
<PortalToFollowElemContent className="z-[1002]">
<div className={cn(
'w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]',
popupClassName,
@@ -172,7 +172,7 @@ const Authorized = ({
>
{
popupTitle && (
<div className="system-xs-medium px-3 pb-0.5 pt-[10px] text-text-tertiary">
<div className="px-3 pb-0.5 pt-[10px] text-text-tertiary system-xs-medium">
{popupTitle}
</div>
)
@@ -218,7 +218,7 @@ const Authorized = ({
}
: undefined,
)}
className="system-xs-medium flex h-[40px] cursor-pointer items-center px-3 text-text-accent-light-mode-only"
className="flex h-[40px] cursor-pointer items-center px-3 text-text-accent-light-mode-only system-xs-medium"
>
<RiAddLine className="mr-1 h-4 w-4" />
{t('modelProvider.auth.addModelCredential', { ns: 'common' })}

View File

@@ -53,14 +53,14 @@ const CredentialSelector = ({
triggerPopupSameWidth
>
<PortalToFollowElemTrigger asChild onClick={() => !disabled && setOpen(v => !v)}>
<div className="system-sm-regular flex h-8 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-2">
<div className="flex h-8 w-full items-center justify-between rounded-lg bg-components-input-bg-normal px-2 system-sm-regular">
{
selectedCredential && (
<div className="flex items-center">
{
!selectedCredential.addNewCredential && <Indicator className="ml-1 mr-2 shrink-0" />
}
<div className="system-sm-regular truncate text-components-input-text-filled" title={selectedCredential.credential_name}>{selectedCredential.credential_name}</div>
<div className="truncate text-components-input-text-filled system-sm-regular" title={selectedCredential.credential_name}>{selectedCredential.credential_name}</div>
{
selectedCredential.from_enterprise && (
<Badge className="shrink-0">Enterprise</Badge>
@@ -71,13 +71,13 @@ const CredentialSelector = ({
}
{
!selectedCredential && (
<div className="system-sm-regular grow truncate text-components-input-text-placeholder">{t('modelProvider.auth.selectModelCredential', { ns: 'common' })}</div>
<div className="grow truncate text-components-input-text-placeholder system-sm-regular">{t('modelProvider.auth.selectModelCredential', { ns: 'common' })}</div>
)
}
<RiArrowDownSLine className="h-4 w-4 text-text-quaternary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[100]">
<PortalToFollowElemContent className="z-[1002]">
<div className="border-ccomponents-panel-border rounded-xl border-[0.5px] bg-components-panel-bg-blur shadow-lg">
<div className="max-h-[320px] overflow-y-auto p-1">
{
@@ -98,7 +98,7 @@ const CredentialSelector = ({
{
!notAllowAddNewCredential && (
<div
className="system-xs-medium flex h-10 cursor-pointer items-center border-t border-t-divider-subtle px-7 text-text-accent-light-mode-only"
className="flex h-10 cursor-pointer items-center border-t border-t-divider-subtle px-7 text-text-accent-light-mode-only system-xs-medium"
onClick={handleAddNewCredential}
>
<RiAddLine className="mr-1 h-4 w-4" />

View File

@@ -244,6 +244,7 @@ const ModelLoadBalancingModal = ({
<Modal
isShow={Boolean(model) && open}
onClose={onClose}
wrapperClassName="z-[1002]"
className="w-[640px] max-w-none px-8 pt-8"
title={(
<div className="pb-3 font-semibold">

View File

@@ -105,7 +105,7 @@ const AppNav = () => {
icon={<RiRobot2Line className="h-4 w-4" />}
activeIcon={<RiRobot2Fill className="h-4 w-4" />}
text={t('menus.apps', { ns: 'common' })}
activeSegment={['apps', 'app']}
activeSegment={['apps', 'app', 'snippets']}
link="/apps"
curNav={appDetail}
navigationItems={navItems}

View File

@@ -14,7 +14,7 @@ const HeaderWrapper = ({
children,
}: HeaderWrapperProps) => {
const pathname = usePathname()
const isBordered = ['/apps', '/datasets/create', '/tools'].includes(pathname)
const isBordered = ['/apps', '/snippets', '/datasets/create', '/tools'].includes(pathname)
// Check if the current path is a workflow canvas & fullscreen
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')

View File

@@ -8,6 +8,7 @@ import {
waitFor,
} from '@testing-library/react'
import * as React from 'react'
import { use } from 'react'
import { vi } from 'vitest'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
@@ -23,14 +24,14 @@ vi.mock('@headlessui/react', () => {
const [open, setOpen] = React.useState(false)
const value = React.useMemo(() => ({ open, setOpen }), [open])
return (
<MenuContext.Provider value={value}>
<MenuContext value={value}>
{typeof children === 'function' ? children({ open }) : children}
</MenuContext.Provider>
</MenuContext>
)
}
const MenuButton = ({ onClick, children, ...props }: { onClick?: () => void, children?: React.ReactNode }) => {
const context = React.useContext(MenuContext)
const context = use(MenuContext)
const handleClick = () => {
context?.setOpen(!context.open)
onClick?.()
@@ -43,7 +44,7 @@ vi.mock('@headlessui/react', () => {
}
const MenuItems = ({ as: Component = 'div', role, children, ...props }: { as?: React.ElementType, role?: string, children: React.ReactNode }) => {
const context = React.useContext(MenuContext)
const context = use(MenuContext)
if (!context?.open)
return null
return (
@@ -84,6 +85,26 @@ vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/next/link', () => ({
default: ({
href,
children,
onClick,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string, children?: React.ReactNode }) => (
<a
href={href}
onClick={(event) => {
event.preventDefault()
onClick?.(event)
}}
{...props}
>
{children}
</a>
),
}))
describe('Nav Component', () => {
const mockSetAppDetail = vi.fn()
const mockOnCreate = vi.fn()

View File

@@ -0,0 +1,171 @@
import type { SnippetDetailPayload } from '@/models/snippet'
import { fireEvent, render, screen } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import SnippetPage from '..'
import { useSnippetDetailStore } from '../store'
const mockUseSnippetDetail = vi.fn()
vi.mock('@/service/use-snippets', () => ({
useSnippetDetail: (snippetId: string) => mockUseSnippetDetail(snippetId),
}))
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: () => ({
data: undefined,
}),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: { mobile: 'mobile', desktop: 'desktop' },
}))
vi.mock('@/app/components/workflow', () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-default-context">{children}</div>
),
WorkflowWithInnerContext: ({ children, viewport }: { children: React.ReactNode, viewport?: { zoom?: number } }) => (
<div data-testid="workflow-inner-context">
<span data-testid="workflow-viewport-zoom">{viewport?.zoom ?? 'none'}</span>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-context-provider">{children}</div>
),
}))
vi.mock('@/app/components/app-sidebar', () => ({
default: ({
renderHeader,
renderNavigation,
}: {
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
}) => (
<div data-testid="app-sidebar">
<div data-testid="app-sidebar-header">{renderHeader?.('expand')}</div>
<div data-testid="app-sidebar-navigation">{renderNavigation?.('expand')}</div>
</div>
),
}))
vi.mock('@/app/components/app-sidebar/nav-link', () => ({
default: ({ name, onClick }: { name: string, onClick?: () => void }) => (
<button type="button" onClick={onClick}>{name}</button>
),
}))
vi.mock('@/app/components/workflow/panel', () => ({
default: ({ components }: { components?: { left?: React.ReactNode, right?: React.ReactNode } }) => (
<div data-testid="workflow-panel">
<div data-testid="workflow-panel-left">{components?.left}</div>
<div data-testid="workflow-panel-right">{components?.right}</div>
</div>
),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
initialNodes: (nodes: unknown[]) => nodes,
initialEdges: (edges: unknown[]) => edges,
}
})
vi.mock('react-sortablejs', () => ({
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
const mockSnippetDetail: SnippetDetailPayload = {
snippet: {
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'A static snippet mock.',
author: 'Evan',
updatedAt: 'Updated 2h ago',
usage: 'Used 19 times',
icon: '🪄',
iconBackground: '#E0EAFF',
status: 'Draft',
},
graph: {
viewport: { x: 0, y: 0, zoom: 1 },
nodes: [],
edges: [],
},
inputFields: [
{
type: PipelineInputVarType.textInput,
label: 'Blog URL',
variable: 'blog_url',
required: true,
options: [],
placeholder: 'Paste a source article URL',
max_length: 256,
},
],
uiMeta: {
inputFieldCount: 1,
checklistCount: 2,
autoSavedAt: 'Auto-saved · a few seconds ago',
},
}
describe('SnippetPage', () => {
beforeEach(() => {
vi.clearAllMocks()
useSnippetDetailStore.getState().reset()
mockUseSnippetDetail.mockReturnValue({
data: mockSnippetDetail,
isLoading: false,
})
})
it('should render the snippet detail shell', () => {
render(<SnippetPage snippetId="snippet-1" />)
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
expect(screen.getByText('A static snippet mock.')).toBeInTheDocument()
expect(screen.getByTestId('app-sidebar')).toBeInTheDocument()
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument()
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
expect(screen.getByTestId('workflow-viewport-zoom').textContent).toBe('1')
})
it('should open the input field panel and editor', () => {
render(<SnippetPage snippetId="snippet-1" />)
fireEvent.click(screen.getAllByRole('button', { name: /snippet\.inputFieldButton/i })[0])
expect(screen.getAllByText('snippet.panelTitle').length).toBeGreaterThan(0)
fireEvent.click(screen.getAllByRole('button', { name: /datasetPipeline\.inputFieldPanel\.addInputField/i })[0])
expect(screen.getAllByText('datasetPipeline.inputFieldPanel.addInputField').length).toBeGreaterThan(1)
})
it('should toggle the publish menu', () => {
render(<SnippetPage snippetId="snippet-1" />)
fireEvent.click(screen.getByRole('button', { name: /snippet\.publishButton/i }))
expect(screen.getByText('snippet.publishMenuCurrentDraft')).toBeInTheDocument()
})
it('should render a controlled not found state', () => {
mockUseSnippetDetail.mockReturnValue({
data: null,
isLoading: false,
})
render(<SnippetPage snippetId="missing-snippet" />)
expect(screen.getByText('snippet.notFoundTitle')).toBeInTheDocument()
expect(screen.getByText('snippet.notFoundDescription')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,107 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import SnippetCreateCard from '../snippet-create-card'
const { mockPush, mockCreateMutate, mockToastSuccess, mockToastError } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockCreateMutate: vi.fn(),
mockToastSuccess: vi.fn(),
mockToastError: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: mockToastSuccess,
error: mockToastError,
},
}))
vi.mock('@/service/use-snippets', () => ({
useCreateSnippetMutation: () => ({
mutate: mockCreateMutate,
isPending: false,
}),
}))
vi.mock('../snippet-import-dsl-dialog', () => ({
default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess?: (snippetId: string) => void }) => {
if (!show)
return null
return (
<div data-testid="snippet-import-dsl-dialog">
<button type="button" onClick={() => onSuccess?.('snippet-imported')}>Complete Import</button>
<button type="button" onClick={onClose}>Close Import</button>
</div>
)
},
}))
describe('SnippetCreateCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Create From Blank', () => {
it('should open the create dialog and create a snippet from the modal', async () => {
mockCreateMutate.mockImplementation((_payload, options?: { onSuccess?: (snippet: { id: string }) => void }) => {
options?.onSuccess?.({ id: 'snippet-123' })
})
render(<SnippetCreateCard />)
fireEvent.click(screen.getByRole('button', { name: 'snippet.createFromBlank' }))
expect(screen.getByText('workflow.snippet.createDialogTitle')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
target: { value: 'My Snippet' },
})
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.descriptionPlaceholder'), {
target: { value: 'Useful snippet description' },
})
fireEvent.click(screen.getByRole('button', { name: /workflow\.snippet\.confirm/i }))
expect(mockCreateMutate).toHaveBeenCalledWith({
body: {
name: 'My Snippet',
description: 'Useful snippet description',
icon_info: {
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: undefined,
},
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
})
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
})
})
describe('Import DSL', () => {
it('should open the import dialog and navigate when the import succeeds', async () => {
render(<SnippetCreateCard />)
fireEvent.click(screen.getByRole('button', { name: 'app.importDSL' }))
expect(screen.getByTestId('snippet-import-dsl-dialog')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Complete Import' }))
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-imported/orchestrate')
})
})
})
})

View File

@@ -0,0 +1,54 @@
'use client'
import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
import type { SnippetInputField } from '@/models/snippet'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import InputFieldForm from '@/app/components/rag-pipeline/components/panel/input-field/editor/form'
import { convertFormDataToINputField, convertToInputFieldFormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/utils'
type SnippetInputFieldEditorProps = {
field?: SnippetInputField | null
onClose: () => void
onSubmit: (field: SnippetInputField) => void
}
const SnippetInputFieldEditor = ({
field,
onClose,
onSubmit,
}: SnippetInputFieldEditorProps) => {
const { t } = useTranslation()
const initialData = useMemo(() => {
return convertToInputFieldFormData(field || undefined)
}, [field])
const handleSubmit = useCallback((value: FormData) => {
onSubmit(convertFormDataToINputField(value))
}, [onSubmit])
return (
<div className="relative mr-1 flex h-fit max-h-full w-[min(400px,calc(100vw-24px))] flex-col overflow-y-auto rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9">
<div className="flex items-center pb-1 pl-4 pr-11 pt-3.5 text-text-primary system-xl-semibold">
{field ? t('inputFieldPanel.editInputField', { ns: 'datasetPipeline' }) : t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
</div>
<button
type="button"
className="absolute right-2.5 top-2.5 flex h-8 w-8 items-center justify-center"
onClick={onClose}
>
<span aria-hidden className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</button>
<InputFieldForm
initialData={initialData}
supportFile
onCancel={onClose}
onSubmit={handleSubmit}
isEditMode={!!field}
/>
</div>
)
}
export default SnippetInputFieldEditor

View File

@@ -0,0 +1,119 @@
'use client'
import type { SortableItem } from '@/app/components/rag-pipeline/components/panel/input-field/field-list/types'
import type { SnippetInputField } from '@/models/snippet'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import FieldListContainer from '@/app/components/rag-pipeline/components/panel/input-field/field-list/field-list-container'
type SnippetInputFieldPanelProps = {
fields: SnippetInputField[]
onClose: () => void
onAdd: () => void
onEdit: (field: SnippetInputField) => void
onRemove: (index: number) => void
onPrimarySortChange: (fields: SnippetInputField[]) => void
onSecondarySortChange: (fields: SnippetInputField[]) => void
}
const toInputFields = (list: SortableItem[]) => {
return list.map((item) => {
const { id: _id, chosen: _chosen, selected: _selected, ...field } = item
return field
})
}
const SnippetInputFieldPanel = ({
fields,
onClose,
onAdd,
onEdit,
onRemove,
onPrimarySortChange,
onSecondarySortChange,
}: SnippetInputFieldPanelProps) => {
const { t } = useTranslation('snippet')
const primaryFields = fields.slice(0, 2)
const secondaryFields = fields.slice(2)
const handlePrimaryRemove = useCallback((index: number) => {
onRemove(index)
}, [onRemove])
const handleSecondaryRemove = useCallback((index: number) => {
onRemove(index + primaryFields.length)
}, [onRemove, primaryFields.length])
const handlePrimaryEdit = useCallback((id: string) => {
const field = primaryFields.find(item => item.variable === id)
if (field)
onEdit(field)
}, [onEdit, primaryFields])
const handleSecondaryEdit = useCallback((id: string) => {
const field = secondaryFields.find(item => item.variable === id)
if (field)
onEdit(field)
}, [onEdit, secondaryFields])
return (
<div className="mr-1 flex h-full w-[min(400px,calc(100vw-24px))] flex-col rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
<div className="flex items-start justify-between gap-3 px-4 pb-2 pt-4">
<div className="min-w-0">
<div className="text-text-primary system-xl-semibold">
{t('panelTitle')}
</div>
<div className="pt-1 text-text-tertiary system-sm-regular">
{t('panelDescription')}
</div>
</div>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover"
onClick={onClose}
>
<span aria-hidden className="i-ri-close-line h-4 w-4" />
</button>
</div>
<div className="px-4 pb-2">
<Button variant="secondary" size="small" className="w-full justify-center gap-1" onClick={onAdd}>
<span aria-hidden className="i-ri-add-line h-4 w-4" />
{t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
</Button>
</div>
<div className="flex grow flex-col overflow-y-auto">
<div className="px-4 pb-1 pt-2 text-text-secondary system-xs-semibold-uppercase">
{t('panelPrimaryGroup')}
</div>
<FieldListContainer
className="flex flex-col gap-y-1 px-4 pb-2"
inputFields={primaryFields}
onListSortChange={list => onPrimarySortChange(toInputFields(list))}
onRemoveField={handlePrimaryRemove}
onEditField={handlePrimaryEdit}
/>
<div className="px-4 py-2">
<Divider type="horizontal" className="bg-divider-subtle" />
</div>
<div className="px-4 pb-1 text-text-secondary system-xs-semibold-uppercase">
{t('panelSecondaryGroup')}
</div>
<FieldListContainer
className="flex flex-col gap-y-1 px-4 pb-4"
inputFields={secondaryFields}
onListSortChange={list => onSecondarySortChange(toInputFields(list))}
onRemoveField={handleSecondaryRemove}
onEditField={handleSecondaryEdit}
/>
</div>
</div>
)
}
export default memo(SnippetInputFieldPanel)

View File

@@ -0,0 +1,29 @@
'use client'
import type { SnippetDetailUIModel } from '@/models/snippet'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
const PublishMenu = ({
uiMeta,
}: {
uiMeta: SnippetDetailUIModel
}) => {
const { t } = useTranslation('snippet')
return (
<div className="w-80 rounded-2xl border border-components-panel-border bg-components-panel-bg p-4 shadow-[0px_20px_24px_-4px_rgba(9,9,11,0.08),0px_8px_8px_-4px_rgba(9,9,11,0.03)]">
<div className="text-text-tertiary system-xs-semibold-uppercase">
{t('publishMenuCurrentDraft')}
</div>
<div className="pt-1 text-text-secondary system-sm-medium">
{uiMeta.autoSavedAt}
</div>
<Button variant="primary" size="small" className="mt-4 w-full justify-center">
{t('publishButton')}
</Button>
</div>
)
}
export default PublishMenu

View File

@@ -0,0 +1,55 @@
'use client'
import type { SnippetListItem } from '@/types/snippet'
import AppIcon from '@/app/components/base/app-icon'
import Link from '@/next/link'
type Props = {
snippet: SnippetListItem
}
const SnippetCard = ({ snippet }: Props) => {
return (
<Link href={`/snippets/${snippet.id}/orchestrate`} className="group col-span-1">
<article className="relative inline-flex h-[160px] w-full flex-col rounded-xl border border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg">
{!snippet.is_published && (
<div className="absolute right-0 top-0 rounded-bl-lg rounded-tr-xl bg-background-default-dimmed px-2 py-1 text-[10px] font-medium uppercase leading-3 text-text-placeholder">
Draft
</div>
)}
<div className="flex h-[66px] items-center gap-3 px-[14px] pb-3 pt-[14px]">
<AppIcon
size="large"
iconType={snippet.icon_info.icon_type}
icon={snippet.icon_info.icon}
background={snippet.icon_info.icon_background}
imageUrl={snippet.icon_info.icon_url}
/>
<div className="w-0 grow py-[1px]">
<div className="truncate text-sm font-semibold leading-5 text-text-secondary" title={snippet.name}>
{snippet.name}
</div>
</div>
</div>
<div className="h-[58px] px-[14px] text-xs leading-normal text-text-tertiary">
<div className="line-clamp-2" title={snippet.description}>
{snippet.description}
</div>
</div>
<div className="mt-auto flex items-center gap-1 px-[14px] pb-3 pt-2 text-xs leading-4 text-text-tertiary">
<span className="truncate">{snippet.author}</span>
<span>·</span>
<span className="truncate">{snippet.updated_at}</span>
{!snippet.is_published && (
<>
<span>·</span>
<span className="truncate">{snippet.use_count}</span>
</>
)}
</div>
</article>
</Link>
)
}
export default SnippetCard

View File

@@ -0,0 +1,106 @@
'use client'
import type { SnippetDetailUIModel, SnippetInputField } from '@/models/snippet'
import SnippetInputFieldEditor from './input-field-editor'
import SnippetInputFieldPanel from './panel'
import PublishMenu from './publish-menu'
import SnippetHeader from './snippet-header'
import SnippetWorkflowPanel from './workflow-panel'
type SnippetChildrenProps = {
fields: SnippetInputField[]
uiMeta: SnippetDetailUIModel
editingField: SnippetInputField | null
isEditorOpen: boolean
isInputPanelOpen: boolean
isPublishMenuOpen: boolean
onToggleInputPanel: () => void
onTogglePublishMenu: () => void
onCloseInputPanel: () => void
onOpenEditor: (field?: SnippetInputField | null) => void
onCloseEditor: () => void
onSubmitField: (field: SnippetInputField) => void
onRemoveField: (index: number) => void
onPrimarySortChange: (fields: SnippetInputField[]) => void
onSecondarySortChange: (fields: SnippetInputField[]) => void
}
const SnippetChildren = ({
fields,
uiMeta,
editingField,
isEditorOpen,
isInputPanelOpen,
isPublishMenuOpen,
onToggleInputPanel,
onTogglePublishMenu,
onCloseInputPanel,
onOpenEditor,
onCloseEditor,
onSubmitField,
onRemoveField,
onPrimarySortChange,
onSecondarySortChange,
}: SnippetChildrenProps) => {
return (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-gradient-to-b from-background-body to-transparent" />
<SnippetHeader
inputFieldCount={fields.length}
onToggleInputPanel={onToggleInputPanel}
onTogglePublishMenu={onTogglePublishMenu}
/>
<SnippetWorkflowPanel
fields={fields}
editingField={editingField}
isEditorOpen={isEditorOpen}
isInputPanelOpen={isInputPanelOpen}
onCloseInputPanel={onCloseInputPanel}
onOpenEditor={onOpenEditor}
onCloseEditor={onCloseEditor}
onSubmitField={onSubmitField}
onRemoveField={onRemoveField}
onPrimarySortChange={onPrimarySortChange}
onSecondarySortChange={onSecondarySortChange}
/>
{isPublishMenuOpen && (
<div className="absolute right-3 top-14 z-20">
<PublishMenu uiMeta={uiMeta} />
</div>
)}
{isInputPanelOpen && (
<div className="pointer-events-none absolute inset-y-3 right-3 z-30 flex justify-end">
<div className="pointer-events-auto h-full xl:hidden">
<SnippetInputFieldPanel
fields={fields}
onClose={onCloseInputPanel}
onAdd={() => onOpenEditor()}
onEdit={onOpenEditor}
onRemove={onRemoveField}
onPrimarySortChange={onPrimarySortChange}
onSecondarySortChange={onSecondarySortChange}
/>
</div>
</div>
)}
{isEditorOpen && (
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center bg-black/10 px-3 xl:hidden">
<div className="pointer-events-auto w-full max-w-md">
<SnippetInputFieldEditor
field={editingField}
onClose={onCloseEditor}
onSubmit={onSubmitField}
/>
</div>
</div>
)}
</>
)
}
export default SnippetChildren

View File

@@ -0,0 +1,110 @@
'use client'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/components/base/ui/toast'
import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
import { useRouter } from '@/next/navigation'
import {
useCreateSnippetMutation,
} from '@/service/use-snippets'
import SnippetImportDSLDialog from './snippet-import-dsl-dialog'
const SnippetCreateCard = () => {
const { t } = useTranslation('snippet')
const { push } = useRouter()
const createSnippetMutation = useCreateSnippetMutation()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [isImportDSLDialogOpen, setIsImportDSLDialogOpen] = useState(false)
const handleCreateFromBlank = () => {
setIsCreateDialogOpen(true)
}
const handleImportDSL = () => {
setIsImportDSLDialogOpen(true)
}
const handleCreateSnippet = ({
name,
description,
icon,
}: {
name: string
description: string
icon: AppIconSelection
}) => {
createSnippetMutation.mutate({
body: {
name,
description: description || undefined,
icon_info: {
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
icon_type: icon.type,
icon_background: icon.type === 'emoji' ? icon.background : undefined,
icon_url: icon.type === 'image' ? icon.url : undefined,
},
},
}, {
onSuccess: (snippet) => {
toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
setIsCreateDialogOpen(false)
push(`/snippets/${snippet.id}/orchestrate`)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('createFailed'))
},
})
}
return (
<>
<div className="relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity">
<div className="grow rounded-t-xl p-2">
<div className="px-6 pb-1 pt-2 text-xs font-medium leading-[18px] text-text-tertiary">{t('create')}</div>
<button
type="button"
className="mb-1 flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:opacity-50"
disabled={createSnippetMutation.isPending}
onClick={handleCreateFromBlank}
>
<span aria-hidden className="i-ri-sticky-note-add-line mr-2 h-4 w-4 shrink-0" />
{t('createFromBlank')}
</button>
<button
type="button"
className="flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={handleImportDSL}
>
<span aria-hidden className="i-ri-file-upload-line mr-2 h-4 w-4 shrink-0" />
{t('importDSL', { ns: 'app' })}
</button>
</div>
</div>
{isCreateDialogOpen && (
<CreateSnippetDialog
isOpen={isCreateDialogOpen}
selectedNodeIds={[]}
isSubmitting={createSnippetMutation.isPending}
onClose={() => setIsCreateDialogOpen(false)}
onConfirm={handleCreateSnippet}
/>
)}
{isImportDSLDialogOpen && (
<SnippetImportDSLDialog
show={isImportDSLDialogOpen}
onClose={() => setIsImportDSLDialogOpen(false)}
onSuccess={(snippetId) => {
setIsImportDSLDialogOpen(false)
push(`/snippets/${snippetId}/orchestrate`)
}}
/>
)}
</>
)
}
export default SnippetCreateCard

View File

@@ -0,0 +1,61 @@
'use client'
import { useTranslation } from 'react-i18next'
type SnippetHeaderProps = {
inputFieldCount: number
onToggleInputPanel: () => void
onTogglePublishMenu: () => void
}
const SnippetHeader = ({
inputFieldCount,
onToggleInputPanel,
onTogglePublishMenu,
}: SnippetHeaderProps) => {
const { t } = useTranslation('snippet')
return (
<div className="absolute right-3 top-3 z-20 flex flex-wrap items-center justify-end gap-2">
<button
type="button"
className="flex items-center gap-2 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-text-secondary shadow-xs backdrop-blur"
onClick={onToggleInputPanel}
>
<span className="text-[13px] font-medium leading-4">{t('inputFieldButton')}</span>
<span className="rounded-md border border-divider-deep px-1.5 py-0.5 text-[10px] font-medium leading-3 text-text-tertiary">
{inputFieldCount}
</span>
</button>
<button
type="button"
className="flex items-center gap-2 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-text-accent shadow-xs backdrop-blur"
>
<span aria-hidden className="i-ri-play-mini-fill h-4 w-4" />
<span className="text-[13px] font-medium leading-4">{t('testRunButton')}</span>
<span className="rounded-md bg-state-accent-active px-1.5 py-0.5 text-[10px] font-semibold leading-3 text-text-accent">R</span>
</button>
<div className="relative">
<button
type="button"
className="flex items-center gap-1 rounded-lg bg-components-button-primary-bg px-3 py-2 text-white shadow-[0px_2px_2px_-1px_rgba(0,0,0,0.12),0px_1px_1px_-1px_rgba(0,0,0,0.12),0px_0px_0px_0.5px_rgba(9,9,11,0.05)]"
onClick={onTogglePublishMenu}
>
<span className="text-[13px] font-medium leading-4">{t('publishButton')}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4" />
</button>
</div>
<button
type="button"
className="flex h-9 w-9 items-center justify-center rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg text-text-tertiary shadow-xs"
>
<span aria-hidden className="i-ri-more-2-line h-4 w-4" />
</button>
</div>
)
}
export default SnippetHeader

View File

@@ -0,0 +1,266 @@
'use client'
import { useDebounceFn, useKeyPress } from 'ahooks'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
import { toast } from '@/app/components/base/ui/toast'
import {
DSLImportMode,
DSLImportStatus,
} from '@/models/app'
import {
useConfirmSnippetImportMutation,
useImportSnippetDSLMutation,
} from '@/service/use-snippets'
import { cn } from '@/utils/classnames'
import ShortcutsName from '../../workflow/shortcuts-name'
type SnippetImportDSLDialogProps = {
show: boolean
onClose: () => void
onSuccess?: (snippetId: string) => void
}
const SnippetImportDSLTab = {
FromFile: 'from-file',
FromURL: 'from-url',
} as const
type SnippetImportDSLTabValue = typeof SnippetImportDSLTab[keyof typeof SnippetImportDSLTab]
const SnippetImportDSLDialog = ({
show,
onClose,
onSuccess,
}: SnippetImportDSLDialogProps) => {
const { t } = useTranslation()
const importSnippetDSLMutation = useImportSnippetDSLMutation()
const confirmSnippetImportMutation = useConfirmSnippetImportMutation()
const [currentFile, setCurrentFile] = useState<File>()
const [fileContent, setFileContent] = useState<string>()
const [currentTab, setCurrentTab] = useState<SnippetImportDSLTabValue>(SnippetImportDSLTab.FromFile)
const [dslUrlValue, setDslUrlValue] = useState('')
const [showVersionMismatchDialog, setShowVersionMismatchDialog] = useState(false)
const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>()
const [importId, setImportId] = useState<string>()
const isImporting = importSnippetDSLMutation.isPending || confirmSnippetImportMutation.isPending
const readFile = (file: File) => {
const reader = new FileReader()
reader.onload = (event) => {
const content = event.target?.result
setFileContent(content as string)
}
reader.readAsText(file)
}
const handleFile = (file?: File) => {
setCurrentFile(file)
if (file)
readFile(file)
if (!file)
setFileContent('')
}
const completeImport = (snippetId?: string, status: string = DSLImportStatus.COMPLETED) => {
if (!snippetId) {
toast.error(t('importFailed', { ns: 'snippet' }))
return
}
if (status === DSLImportStatus.COMPLETED_WITH_WARNINGS)
toast.warning(t('newApp.appCreateDSLWarning', { ns: 'app' }))
else
toast.success(t('importSuccess', { ns: 'snippet' }))
onSuccess?.(snippetId)
}
const handleImportResponse = (response: {
id: string
status: string
snippet_id?: string
imported_dsl_version?: string
current_dsl_version?: string
}) => {
if (response.status === DSLImportStatus.COMPLETED || response.status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
completeImport(response.snippet_id, response.status)
return
}
if (response.status === DSLImportStatus.PENDING) {
setVersions({
importedVersion: response.imported_dsl_version ?? '',
systemVersion: response.current_dsl_version ?? '',
})
setImportId(response.id)
setShowVersionMismatchDialog(true)
return
}
toast.error(t('importFailed', { ns: 'snippet' }))
}
const handleCreate = () => {
if (currentTab === SnippetImportDSLTab.FromFile && !currentFile)
return
if (currentTab === SnippetImportDSLTab.FromURL && !dslUrlValue)
return
importSnippetDSLMutation.mutate({
mode: currentTab === SnippetImportDSLTab.FromFile ? DSLImportMode.YAML_CONTENT : DSLImportMode.YAML_URL,
yamlContent: currentTab === SnippetImportDSLTab.FromFile ? fileContent || '' : undefined,
yamlUrl: currentTab === SnippetImportDSLTab.FromURL ? dslUrlValue : undefined,
}, {
onSuccess: handleImportResponse,
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('importFailed', { ns: 'snippet' }))
},
})
}
const { run: handleCreateSnippet } = useDebounceFn(handleCreate, { wait: 300 })
const handleConfirmImport = () => {
if (!importId)
return
confirmSnippetImportMutation.mutate({
importId,
}, {
onSuccess: (response) => {
setShowVersionMismatchDialog(false)
completeImport(response.snippet_id)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('importFailed', { ns: 'snippet' }))
},
})
}
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
if (!show || showVersionMismatchDialog || isImporting)
return
if ((currentTab === SnippetImportDSLTab.FromFile && currentFile) || (currentTab === SnippetImportDSLTab.FromURL && dslUrlValue))
handleCreateSnippet()
})
const buttonDisabled = useMemo(() => {
if (isImporting)
return true
if (currentTab === SnippetImportDSLTab.FromFile)
return !currentFile
return !dslUrlValue
}, [currentFile, currentTab, dslUrlValue, isImporting])
return (
<>
<Dialog open={show} onOpenChange={open => !open && onClose()}>
<DialogContent className="w-[520px] p-0">
<div className="flex items-center justify-between pb-3 pl-6 pr-5 pt-6">
<DialogTitle className="text-text-primary title-2xl-semi-bold">
{t('importFromDSL', { ns: 'app' })}
</DialogTitle>
<DialogCloseButton className="right-5 top-6 h-8 w-8" />
</div>
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 text-text-tertiary system-md-semibold">
{[
{ key: SnippetImportDSLTab.FromFile, label: t('importFromDSLFile', { ns: 'app' }) },
{ key: SnippetImportDSLTab.FromURL, label: t('importFromDSLUrl', { ns: 'app' }) },
].map(tab => (
<button
key={tab.key}
type="button"
className={cn(
'relative flex h-full cursor-pointer items-center',
currentTab === tab.key && 'text-text-primary',
)}
onClick={() => setCurrentTab(tab.key)}
>
{tab.label}
{currentTab === tab.key && (
<div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600" />
)}
</button>
))}
</div>
<div className="px-6 py-4">
{currentTab === SnippetImportDSLTab.FromFile && (
<Uploader
className="mt-0"
file={currentFile}
updateFile={handleFile}
/>
)}
{currentTab === SnippetImportDSLTab.FromURL && (
<div>
<div className="mb-1 text-text-secondary system-md-semibold">DSL URL</div>
<Input
placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''}
value={dslUrlValue}
onChange={e => setDslUrlValue(e.target.value)}
/>
</div>
)}
</div>
<div className="flex justify-end px-6 py-5">
<Button className="mr-2" disabled={isImporting} onClick={onClose}>
{t('newApp.Cancel', { ns: 'app' })}
</Button>
<Button
disabled={buttonDisabled}
variant="primary"
onClick={handleCreateSnippet}
className="gap-1"
>
<span>{t('newApp.Create', { ns: 'app' })}</span>
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={showVersionMismatchDialog} onOpenChange={open => !open && setShowVersionMismatchDialog(false)}>
<DialogContent className="w-[480px]">
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
<DialogTitle className="text-text-primary title-2xl-semi-bold">
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
</DialogTitle>
<div className="flex grow flex-col text-text-secondary system-md-regular">
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
<br />
<div>
{t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
<span className="system-md-medium">{versions?.importedVersion}</span>
</div>
<div>
{t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
<span className="system-md-medium">{versions?.systemVersion}</span>
</div>
</div>
</div>
<div className="flex items-start justify-end gap-2 self-stretch pt-6">
<Button variant="secondary" disabled={isImporting} onClick={() => setShowVersionMismatchDialog(false)}>
{t('newApp.Cancel', { ns: 'app' })}
</Button>
<Button variant="primary" destructive disabled={isImporting} onClick={handleConfirmImport}>
{t('newApp.Confirm', { ns: 'app' })}
</Button>
</div>
</DialogContent>
</Dialog>
</>
)
}
export default SnippetImportDSLDialog

View File

@@ -0,0 +1,198 @@
'use client'
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
import type { WorkflowProps } from '@/app/components/workflow'
import type { SnippetDetailPayload, SnippetInputField, SnippetSection } from '@/models/snippet'
import {
RiFlaskFill,
RiFlaskLine,
RiGitBranchFill,
RiGitBranchLine,
} from '@remixicon/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import AppSideBar from '@/app/components/app-sidebar'
import NavLink from '@/app/components/app-sidebar/nav-link'
import SnippetInfo from '@/app/components/app-sidebar/snippet-info'
import { useStore as useAppStore } from '@/app/components/app/store'
import Toast from '@/app/components/base/toast'
import Evaluation from '@/app/components/evaluation'
import { WorkflowWithInnerContext } from '@/app/components/workflow'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useSnippetDetailStore } from '../store'
import SnippetChildren from './snippet-children'
type SnippetMainProps = {
payload: SnippetDetailPayload
snippetId: string
section: SnippetSection
} & Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'>
const ORCHESTRATE_ICONS: { normal: NavIcon, selected: NavIcon } = {
normal: RiGitBranchLine,
selected: RiGitBranchFill,
}
const EVALUATION_ICONS: { normal: NavIcon, selected: NavIcon } = {
normal: RiFlaskLine,
selected: RiFlaskFill,
}
const SnippetMain = ({
payload,
snippetId,
section,
nodes,
edges,
viewport,
}: SnippetMainProps) => {
const { t } = useTranslation('snippet')
const { graph, snippet, uiMeta } = payload
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const [fields, setFields] = useState<SnippetInputField[]>(payload.inputFields)
const setAppSidebarExpand = useAppStore(state => state.setAppSidebarExpand)
const {
editingField,
isEditorOpen,
isInputPanelOpen,
isPublishMenuOpen,
closeEditor,
openEditor,
reset,
setInputPanelOpen,
toggleInputPanel,
togglePublishMenu,
} = useSnippetDetailStore(useShallow(state => ({
editingField: state.editingField,
isEditorOpen: state.isEditorOpen,
isInputPanelOpen: state.isInputPanelOpen,
isPublishMenuOpen: state.isPublishMenuOpen,
closeEditor: state.closeEditor,
openEditor: state.openEditor,
reset: state.reset,
setInputPanelOpen: state.setInputPanelOpen,
toggleInputPanel: state.toggleInputPanel,
togglePublishMenu: state.togglePublishMenu,
})))
useEffect(() => {
reset()
}, [reset, snippetId])
useEffect(() => {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode)
}, [isMobile, setAppSidebarExpand])
const primaryFields = useMemo(() => fields.slice(0, 2), [fields])
const secondaryFields = useMemo(() => fields.slice(2), [fields])
const handlePrimarySortChange = (newFields: SnippetInputField[]) => {
setFields([...newFields, ...secondaryFields])
}
const handleSecondarySortChange = (newFields: SnippetInputField[]) => {
setFields([...primaryFields, ...newFields])
}
const handleRemoveField = (index: number) => {
setFields(current => current.filter((_, currentIndex) => currentIndex !== index))
}
const handleSubmitField = (field: SnippetInputField) => {
const originalVariable = editingField?.variable
const duplicated = fields.some(item => item.variable === field.variable && item.variable !== originalVariable)
if (duplicated) {
Toast.notify({
type: 'error',
message: t('inputFieldPanel.error.variableDuplicate', { ns: 'datasetPipeline' }),
})
return
}
if (originalVariable)
setFields(current => current.map(item => item.variable === originalVariable ? field : item))
else
setFields(current => [...current, field])
closeEditor()
}
const handleToggleInputPanel = () => {
if (isInputPanelOpen)
closeEditor()
toggleInputPanel()
}
const handleCloseInputPanel = () => {
closeEditor()
setInputPanelOpen(false)
}
return (
<div className="relative flex h-full overflow-hidden bg-background-body">
<AppSideBar
navigation={[]}
renderHeader={mode => <SnippetInfo expand={mode === 'expand'} snippet={snippet} />}
renderNavigation={mode => (
<>
<NavLink
mode={mode}
name={t('sectionOrchestrate')}
iconMap={ORCHESTRATE_ICONS}
href={`/snippets/${snippetId}/orchestrate`}
active={section === 'orchestrate'}
/>
<NavLink
mode={mode}
name={t('sectionEvaluation')}
iconMap={EVALUATION_ICONS}
href={`/snippets/${snippetId}/evaluation`}
active={section === 'evaluation'}
/>
</>
)}
/>
<div className="relative min-h-0 min-w-0 grow overflow-hidden">
<div className="absolute inset-0 min-h-0 min-w-0 overflow-hidden">
{section === 'evaluation'
? (
<Evaluation resourceType="snippet" resourceId={snippetId} />
)
: (
<WorkflowWithInnerContext
nodes={nodes}
edges={edges}
viewport={viewport ?? graph.viewport}
>
<SnippetChildren
fields={fields}
uiMeta={uiMeta}
editingField={editingField}
isEditorOpen={isEditorOpen}
isInputPanelOpen={isInputPanelOpen}
isPublishMenuOpen={isPublishMenuOpen}
onToggleInputPanel={handleToggleInputPanel}
onTogglePublishMenu={togglePublishMenu}
onCloseInputPanel={handleCloseInputPanel}
onOpenEditor={openEditor}
onCloseEditor={closeEditor}
onSubmitField={handleSubmitField}
onRemoveField={handleRemoveField}
onPrimarySortChange={handlePrimarySortChange}
onSecondarySortChange={handleSecondarySortChange}
/>
</WorkflowWithInnerContext>
)}
</div>
</div>
</div>
)
}
export default SnippetMain

View File

@@ -0,0 +1,111 @@
'use client'
import type { PanelProps } from '@/app/components/workflow/panel'
import type { SnippetInputField } from '@/models/snippet'
import { memo, useMemo } from 'react'
import Panel from '@/app/components/workflow/panel'
import SnippetInputFieldEditor from './input-field-editor'
import SnippetInputFieldPanel from './panel'
type SnippetWorkflowPanelProps = {
fields: SnippetInputField[]
editingField: SnippetInputField | null
isEditorOpen: boolean
isInputPanelOpen: boolean
onCloseInputPanel: () => void
onOpenEditor: (field?: SnippetInputField | null) => void
onCloseEditor: () => void
onSubmitField: (field: SnippetInputField) => void
onRemoveField: (index: number) => void
onPrimarySortChange: (fields: SnippetInputField[]) => void
onSecondarySortChange: (fields: SnippetInputField[]) => void
}
const SnippetPanelOnLeft = ({
fields,
editingField,
isEditorOpen,
isInputPanelOpen,
onCloseInputPanel,
onOpenEditor,
onCloseEditor,
onSubmitField,
onRemoveField,
onPrimarySortChange,
onSecondarySortChange,
}: SnippetWorkflowPanelProps) => {
return (
<div className="hidden xl:flex">
{isEditorOpen && (
<SnippetInputFieldEditor
field={editingField}
onClose={onCloseEditor}
onSubmit={onSubmitField}
/>
)}
{isInputPanelOpen && (
<SnippetInputFieldPanel
fields={fields}
onClose={onCloseInputPanel}
onAdd={() => onOpenEditor()}
onEdit={onOpenEditor}
onRemove={onRemoveField}
onPrimarySortChange={onPrimarySortChange}
onSecondarySortChange={onSecondarySortChange}
/>
)}
</div>
)
}
const SnippetWorkflowPanel = ({
fields,
editingField,
isEditorOpen,
isInputPanelOpen,
onCloseInputPanel,
onOpenEditor,
onCloseEditor,
onSubmitField,
onRemoveField,
onPrimarySortChange,
onSecondarySortChange,
}: SnippetWorkflowPanelProps) => {
const panelProps: PanelProps = useMemo(() => {
return {
components: {
left: (
<SnippetPanelOnLeft
fields={fields}
editingField={editingField}
isEditorOpen={isEditorOpen}
isInputPanelOpen={isInputPanelOpen}
onCloseInputPanel={onCloseInputPanel}
onOpenEditor={onOpenEditor}
onCloseEditor={onCloseEditor}
onSubmitField={onSubmitField}
onRemoveField={onRemoveField}
onPrimarySortChange={onPrimarySortChange}
onSecondarySortChange={onSecondarySortChange}
/>
),
},
}
}, [
editingField,
fields,
isEditorOpen,
isInputPanelOpen,
onCloseEditor,
onCloseInputPanel,
onOpenEditor,
onPrimarySortChange,
onRemoveField,
onSecondarySortChange,
onSubmitField,
])
return <Panel {...panelProps} />
}
export default memo(SnippetWorkflowPanel)

View File

@@ -0,0 +1,5 @@
import { useSnippetDetail } from '@/service/use-snippets'
export const useSnippetInit = (snippetId: string) => {
return useSnippetDetail(snippetId)
}

View File

@@ -0,0 +1,86 @@
'use client'
import type { SnippetSection } from '@/models/snippet'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import WorkflowWithDefaultContext from '@/app/components/workflow'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import {
initialEdges,
initialNodes,
} from '@/app/components/workflow/utils'
import SnippetMain from './components/snippet-main'
import { useSnippetInit } from './hooks/use-snippet-init'
type SnippetPageProps = {
snippetId: string
section?: SnippetSection
}
const SnippetPage = ({
snippetId,
section = 'orchestrate',
}: SnippetPageProps) => {
const { t } = useTranslation('snippet')
const { data, isLoading } = useSnippetInit(snippetId)
const nodesData = useMemo(() => {
if (!data)
return []
return initialNodes(data.graph.nodes, data.graph.edges)
}, [data])
const edgesData = useMemo(() => {
if (!data)
return []
return initialEdges(data.graph.edges, data.graph.nodes)
}, [data])
if (isLoading) {
return (
<div className="flex h-full items-center justify-center bg-background-body">
<Loading />
</div>
)
}
if (!data) {
return (
<div className="flex h-full items-center justify-center bg-background-body px-6">
<div className="w-full max-w-md rounded-2xl border border-divider-subtle bg-components-card-bg p-8 text-center shadow-sm">
<div className="text-3xl font-semibold text-text-primary">404</div>
<div className="pt-3 text-text-primary system-md-semibold">{t('notFoundTitle')}</div>
<div className="pt-2 text-text-tertiary system-sm-regular">{t('notFoundDescription')}</div>
</div>
</div>
)
}
return (
<WorkflowWithDefaultContext
edges={edgesData}
nodes={nodesData}
>
<SnippetMain
key={snippetId}
snippetId={snippetId}
section={section}
payload={data}
nodes={nodesData}
edges={edgesData}
viewport={data.graph.viewport}
/>
</WorkflowWithDefaultContext>
)
}
const SnippetPageWrapper = (props: SnippetPageProps) => {
return (
<WorkflowContextProvider>
<SnippetPage {...props} />
</WorkflowContextProvider>
)
}
export default SnippetPageWrapper

View File

@@ -0,0 +1,44 @@
'use client'
import type { SnippetInputField, SnippetSection } from '@/models/snippet'
import { create } from 'zustand'
type SnippetDetailUIState = {
activeSection: SnippetSection
isInputPanelOpen: boolean
isPublishMenuOpen: boolean
isPreviewMode: boolean
isEditorOpen: boolean
editingField: SnippetInputField | null
setActiveSection: (section: SnippetSection) => void
setInputPanelOpen: (value: boolean) => void
toggleInputPanel: () => void
setPublishMenuOpen: (value: boolean) => void
togglePublishMenu: () => void
setPreviewMode: (value: boolean) => void
openEditor: (field?: SnippetInputField | null) => void
closeEditor: () => void
reset: () => void
}
const initialState = {
activeSection: 'orchestrate' as SnippetSection,
isInputPanelOpen: false,
isPublishMenuOpen: false,
isPreviewMode: false,
editingField: null,
isEditorOpen: false,
}
export const useSnippetDetailStore = create<SnippetDetailUIState>(set => ({
...initialState,
setActiveSection: activeSection => set({ activeSection }),
setInputPanelOpen: isInputPanelOpen => set({ isInputPanelOpen }),
toggleInputPanel: () => set(state => ({ isInputPanelOpen: !state.isInputPanelOpen, isPublishMenuOpen: false })),
setPublishMenuOpen: isPublishMenuOpen => set({ isPublishMenuOpen }),
togglePublishMenu: () => set(state => ({ isPublishMenuOpen: !state.isPublishMenuOpen })),
setPreviewMode: isPreviewMode => set({ isPreviewMode }),
openEditor: (editingField = null) => set({ editingField, isEditorOpen: true, isInputPanelOpen: true }),
closeEditor: () => set({ editingField: null, isEditorOpen: false }),
reset: () => set(initialState),
}))

View File

@@ -0,0 +1,532 @@
import type { ToolWithProvider } from '../../types'
import type { ToolValue } from '../types'
import type { Plugin } from '@/app/components/plugins/types'
import type { Tool } from '@/app/components/tools/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useTags } from '@/app/components/plugins/hooks'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { createCustomCollection } from '@/service/tools'
import { useFeaturedToolsRecommendations } from '@/service/use-plugins'
import {
useAllBuiltInTools,
useAllCustomTools,
useAllMCPTools,
useAllWorkflowTools,
useInvalidateAllBuiltInTools,
useInvalidateAllCustomTools,
useInvalidateAllMCPTools,
useInvalidateAllWorkflowTools,
} from '@/service/use-tools'
import { Theme } from '@/types/app'
import { defaultSystemFeatures } from '@/types/feature'
import ToolPicker from '../tool-picker'
const mockNotify = vi.fn()
const mockSetSystemFeatures = vi.fn()
const mockInvalidateBuiltInTools = vi.fn()
const mockInvalidateCustomTools = vi.fn()
const mockInvalidateWorkflowTools = vi.fn()
const mockInvalidateMcpTools = vi.fn()
const mockCreateCustomCollection = vi.mocked(createCustomCollection)
const mockInstallPackageFromMarketPlace = vi.fn()
const mockCheckInstalled = vi.fn()
const mockRefreshPluginList = vi.fn()
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
const mockUseGetLanguage = vi.mocked(useGetLanguage)
const mockUseTheme = vi.mocked(useTheme)
const mockUseTags = vi.mocked(useTags)
const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins)
const mockUseAllBuiltInTools = vi.mocked(useAllBuiltInTools)
const mockUseAllCustomTools = vi.mocked(useAllCustomTools)
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
const mockUseAllMCPTools = vi.mocked(useAllMCPTools)
const mockUseInvalidateAllBuiltInTools = vi.mocked(useInvalidateAllBuiltInTools)
const mockUseInvalidateAllCustomTools = vi.mocked(useInvalidateAllCustomTools)
const mockUseInvalidateAllWorkflowTools = vi.mocked(useInvalidateAllWorkflowTools)
const mockUseInvalidateAllMCPTools = vi.mocked(useInvalidateAllMCPTools)
const mockUseFeaturedToolsRecommendations = vi.mocked(useFeaturedToolsRecommendations)
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: vi.fn(),
}))
vi.mock('@/hooks/use-theme', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/plugins/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/plugins/hooks')>()
return {
...actual,
useTags: vi.fn(),
}
})
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
useMarketplacePlugins: vi.fn(),
}))
vi.mock('@/service/tools', () => ({
createCustomCollection: vi.fn(),
}))
vi.mock('@/service/use-plugins', () => ({
useFeaturedToolsRecommendations: vi.fn(),
useDownloadPlugin: vi.fn(() => ({
data: undefined,
isLoading: false,
})),
useInstallPackageFromMarketPlace: () => ({
mutateAsync: mockInstallPackageFromMarketPlace,
isPending: false,
}),
usePluginDeclarationFromMarketPlace: () => ({
data: undefined,
}),
usePluginTaskList: () => ({
handleRefetch: vi.fn(),
}),
useUpdatePackageFromMarketPlace: () => ({
mutateAsync: vi.fn(),
}),
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: vi.fn(),
useAllCustomTools: vi.fn(),
useAllWorkflowTools: vi.fn(),
useAllMCPTools: vi.fn(),
useInvalidateAllBuiltInTools: vi.fn(),
useInvalidateAllCustomTools: vi.fn(),
useInvalidateAllWorkflowTools: vi.fn(),
useInvalidateAllMCPTools: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (payload: unknown) => mockNotify(payload),
},
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
vi.mock('next-themes', () => ({
useTheme: () => ({ theme: Theme.light }),
}))
vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({
default: ({
onAdd,
onHide,
}: {
onAdd: (payload: { name: string }) => Promise<void>
onHide: () => void
}) => (
<div data-testid="edit-custom-tool-modal">
<button type="button" onClick={() => onAdd({ name: 'collection-a' })}>submit-custom-tool</button>
<button type="button" onClick={onHide}>hide-custom-tool</button>
</div>
),
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
default: () => mockCheckInstalled(),
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit', () => ({
default: () => ({
canInstall: true,
}),
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
default: () => ({
refreshPluginList: mockRefreshPluginList,
}),
}))
vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({
default: () => ({
check: vi.fn().mockResolvedValue({ status: 'success' }),
stop: vi.fn(),
}),
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: ({
onSuccess,
onClose,
}: {
onSuccess: () => void | Promise<void>
onClose: () => void
}) => (
<div data-testid="install-from-marketplace">
<button type="button" onClick={() => onSuccess()}>complete-featured-install</button>
<button type="button" onClick={onClose}>cancel-featured-install</button>
</div>
),
}))
vi.mock('@/utils/var', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/utils/var')>()
return {
...actual,
getMarketplaceUrl: () => 'https://marketplace.test/tools',
}
})
const createTool = (
name: string,
label: string,
description = `${label} description`,
): Tool => ({
name,
author: 'author',
label: {
en_US: label,
zh_Hans: label,
},
description: {
en_US: description,
zh_Hans: description,
},
parameters: [],
labels: [],
output_schema: {},
})
const createToolProvider = (
overrides: Partial<ToolWithProvider> = {},
): ToolWithProvider => ({
id: 'provider-1',
name: 'provider-one',
author: 'Provider Author',
description: {
en_US: 'Provider description',
zh_Hans: 'Provider description',
},
icon: 'icon',
icon_dark: 'icon-dark',
label: {
en_US: 'Provider One',
zh_Hans: 'Provider One',
},
type: CollectionType.builtIn,
team_credentials: {},
is_team_authorization: false,
allow_delete: false,
labels: [],
plugin_id: 'plugin-1',
tools: [createTool('tool-a', 'Tool A')],
meta: { version: '1.0.0' } as ToolWithProvider['meta'],
plugin_unique_identifier: 'plugin-1@1.0.0',
...overrides,
})
const createToolValue = (overrides: Partial<ToolValue> = {}): ToolValue => ({
provider_name: 'provider-a',
tool_name: 'tool-a',
tool_label: 'Tool A',
...overrides,
})
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
type: 'plugin',
org: 'org',
author: 'author',
name: 'Plugin One',
plugin_id: 'plugin-1',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'plugin-1@1.0.0',
icon: 'icon',
verified: true,
label: { en_US: 'Plugin One' },
brief: { en_US: 'Brief' },
description: { en_US: 'Plugin description' },
introduction: 'Intro',
repository: 'https://example.com',
category: PluginCategoryEnum.tool,
install_count: 0,
endpoint: { settings: [] },
tags: [{ name: 'tag-a' }],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
...overrides,
})
const builtInTools = [
createToolProvider({
id: 'built-in-1',
name: 'built-in-provider',
label: { en_US: 'Built-in Provider', zh_Hans: 'Built-in Provider' },
tools: [createTool('built-in-tool', 'Built-in Tool')],
}),
]
const customTools = [
createToolProvider({
id: 'custom-1',
name: 'custom-provider',
label: { en_US: 'Custom Provider', zh_Hans: 'Custom Provider' },
type: CollectionType.custom,
tools: [createTool('weather-tool', 'Weather Tool')],
}),
]
const workflowTools = [
createToolProvider({
id: 'workflow-1',
name: 'workflow-provider',
label: { en_US: 'Workflow Provider', zh_Hans: 'Workflow Provider' },
type: CollectionType.workflow,
tools: [createTool('workflow-tool', 'Workflow Tool')],
}),
]
const mcpTools = [
createToolProvider({
id: 'mcp-1',
name: 'mcp-provider',
label: { en_US: 'MCP Provider', zh_Hans: 'MCP Provider' },
type: CollectionType.mcp,
tools: [createTool('mcp-tool', 'MCP Tool')],
}),
]
const renderToolPicker = (props: Partial<React.ComponentProps<typeof ToolPicker>> = {}) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={queryClient}>
<ToolPicker
disabled={false}
trigger={<button type="button">open-picker</button>}
isShow={false}
onShowChange={vi.fn()}
onSelect={vi.fn()}
onSelectMultiple={vi.fn()}
selectedTools={[createToolValue()]}
{...props}
/>
</QueryClientProvider>,
)
}
describe('ToolPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseGlobalPublicStore.mockImplementation(selector => selector({
systemFeatures: {
...defaultSystemFeatures,
enable_marketplace: true,
},
setSystemFeatures: mockSetSystemFeatures,
}))
mockUseGetLanguage.mockReturnValue('en_US')
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType<typeof useTheme>)
mockUseTags.mockReturnValue({
tags: [{ name: 'weather', label: 'Weather' }],
tagsMap: { weather: { name: 'weather', label: 'Weather' } },
getTagLabel: (name: string) => name,
})
mockUseMarketplacePlugins.mockReturnValue({
plugins: [],
total: 0,
resetPlugins: vi.fn(),
queryPlugins: vi.fn(),
queryPluginsWithDebounced: vi.fn(),
cancelQueryPluginsWithDebounced: vi.fn(),
isLoading: false,
isFetchingNextPage: false,
hasNextPage: false,
fetchNextPage: vi.fn(),
page: 0,
} as ReturnType<typeof useMarketplacePlugins>)
mockUseAllBuiltInTools.mockReturnValue({ data: builtInTools } as ReturnType<typeof useAllBuiltInTools>)
mockUseAllCustomTools.mockReturnValue({ data: customTools } as ReturnType<typeof useAllCustomTools>)
mockUseAllWorkflowTools.mockReturnValue({ data: workflowTools } as ReturnType<typeof useAllWorkflowTools>)
mockUseAllMCPTools.mockReturnValue({ data: mcpTools } as ReturnType<typeof useAllMCPTools>)
mockUseInvalidateAllBuiltInTools.mockReturnValue(mockInvalidateBuiltInTools)
mockUseInvalidateAllCustomTools.mockReturnValue(mockInvalidateCustomTools)
mockUseInvalidateAllWorkflowTools.mockReturnValue(mockInvalidateWorkflowTools)
mockUseInvalidateAllMCPTools.mockReturnValue(mockInvalidateMcpTools)
mockUseFeaturedToolsRecommendations.mockReturnValue({
plugins: [],
isLoading: false,
} as ReturnType<typeof useFeaturedToolsRecommendations>)
mockCreateCustomCollection.mockResolvedValue(undefined)
mockInstallPackageFromMarketPlace.mockResolvedValue({
all_installed: true,
task_id: 'task-1',
})
mockCheckInstalled.mockReturnValue({
installedInfo: undefined,
isLoading: false,
error: undefined,
})
window.localStorage.clear()
})
it('should request opening when the trigger is clicked unless the picker is disabled', async () => {
const user = userEvent.setup()
const onShowChange = vi.fn()
const disabledOnShowChange = vi.fn()
renderToolPicker({ onShowChange })
await user.click(screen.getByRole('button', { name: 'open-picker' }))
expect(onShowChange).toHaveBeenCalledWith(true)
renderToolPicker({
disabled: true,
onShowChange: disabledOnShowChange,
})
await user.click(screen.getAllByRole('button', { name: 'open-picker' })[1]!)
expect(disabledOnShowChange).not.toHaveBeenCalled()
})
it('should render real search and tool lists, then forward tool selections', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onSelectMultiple = vi.fn()
const queryPluginsWithDebounced = vi.fn()
mockUseMarketplacePlugins.mockReturnValue({
plugins: [],
total: 0,
resetPlugins: vi.fn(),
queryPlugins: vi.fn(),
queryPluginsWithDebounced,
cancelQueryPluginsWithDebounced: vi.fn(),
isLoading: false,
isFetchingNextPage: false,
hasNextPage: false,
fetchNextPage: vi.fn(),
page: 0,
} as ReturnType<typeof useMarketplacePlugins>)
renderToolPicker({
isShow: true,
scope: 'custom',
onSelect,
onSelectMultiple,
selectedTools: [],
})
expect(screen.queryByText('Built-in Provider')).not.toBeInTheDocument()
expect(screen.getByText('Custom Provider')).toBeInTheDocument()
expect(screen.getByText('MCP Provider')).toBeInTheDocument()
await user.type(screen.getByRole('textbox'), 'weather')
await waitFor(() => {
expect(queryPluginsWithDebounced).toHaveBeenLastCalledWith({
query: 'weather',
tags: [],
category: PluginCategoryEnum.tool,
})
})
await waitFor(() => {
expect(screen.getByText('Weather Tool')).toBeInTheDocument()
})
await user.click(screen.getByText('Weather Tool'))
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
provider_name: 'custom-provider',
tool_name: 'weather-tool',
tool_label: 'Weather Tool',
}))
await user.hover(screen.getByText('Custom Provider'))
await user.click(screen.getByText('workflow.tabs.addAll'))
expect(onSelectMultiple).toHaveBeenCalledWith([
expect.objectContaining({
provider_name: 'custom-provider',
tool_name: 'weather-tool',
tool_label: 'Weather Tool',
}),
])
})
it('should create a custom collection from the add button and refresh custom tools', async () => {
const user = userEvent.setup()
const { container } = renderToolPicker({
isShow: true,
supportAddCustomTool: true,
})
const addCustomToolButton = Array.from(container.querySelectorAll('button')).find((button) => {
return button.className.includes('bg-components-button-primary-bg')
})
expect(addCustomToolButton).toBeTruthy()
await user.click(addCustomToolButton!)
expect(screen.getByTestId('edit-custom-tool-modal')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'submit-custom-tool' }))
await waitFor(() => {
expect(mockCreateCustomCollection).toHaveBeenCalledWith({ name: 'collection-a' })
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'common.api.actionSuccess',
})
expect(mockInvalidateCustomTools).toHaveBeenCalledTimes(1)
expect(screen.queryByTestId('edit-custom-tool-modal')).not.toBeInTheDocument()
})
it('should invalidate all tool collections after featured install succeeds', async () => {
const user = userEvent.setup()
mockUseFeaturedToolsRecommendations.mockReturnValue({
plugins: [createPlugin({ plugin_id: 'featured-1', latest_package_identifier: 'featured-1@1.0.0' })],
isLoading: false,
} as ReturnType<typeof useFeaturedToolsRecommendations>)
renderToolPicker({
isShow: true,
selectedTools: [],
})
const featuredPluginItem = await screen.findByText('Plugin One')
await user.hover(featuredPluginItem)
await user.click(screen.getByRole('button', { name: 'plugin.installAction' }))
expect(await screen.findByTestId('install-from-marketplace')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'complete-featured-install' }))
await waitFor(() => {
expect(mockInvalidateBuiltInTools).toHaveBeenCalledTimes(1)
expect(mockInvalidateCustomTools).toHaveBeenCalledTimes(1)
expect(mockInvalidateWorkflowTools).toHaveBeenCalledTimes(1)
expect(mockInvalidateMcpTools).toHaveBeenCalledTimes(1)
}, { timeout: 3000 })
})
})

View File

@@ -71,6 +71,10 @@ export const useTabs = ({
name: t('tabs.start', { ns: 'workflow' }),
show: shouldShowStartTab,
disabled: shouldDisableStartTab,
}, {
key: TabsEnum.Snippets,
name: t('tabs.snippets', { ns: 'workflow' }),
show: true,
}]
return tabConfigs.filter(tab => tab.show)
@@ -100,6 +104,7 @@ export const useTabs = ({
preferredOrder.push(TabsEnum.Sources)
if (!noStart)
preferredOrder.push(TabsEnum.Start)
preferredOrder.push(TabsEnum.Snippets)
for (const tabKey of preferredOrder) {
const validKey = getValidTabKey(tabKey)

View File

@@ -15,6 +15,7 @@ import type {
import {
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
@@ -32,6 +33,7 @@ import SearchBox from '@/app/components/plugins/marketplace/search-box'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { BlockEnum, isTriggerNode } from '../types'
import { useTabs } from './hooks'
import Snippets from './snippets'
import Tabs from './tabs'
import { TabsEnum } from './types'
@@ -88,6 +90,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
const { t } = useTranslation()
const nodes = useNodes()
const [searchText, setSearchText] = useState('')
const [snippetsLoading, setSnippetsLoading] = useState(() => Boolean(openFromProps) && defaultActiveTab === TabsEnum.Snippets)
const [tags, setTags] = useState<string[]>([])
const [localOpen, setLocalOpen] = useState(false)
// Exclude nodes explicitly ignored (such as the node currently being edited) when checking canvas state.
@@ -119,28 +122,6 @@ const NodeSelector: FC<NodeSelectorProps> = ({
// Default rule: user input option is only available when no Start node nor Trigger node exists on canvas.
const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
const open = openFromProps === undefined ? localOpen : openFromProps
const handleOpenChange = useCallback((newOpen: boolean) => {
setLocalOpen(newOpen)
if (!newOpen)
setSearchText('')
if (onOpenChange)
onOpenChange(newOpen)
}, [onOpenChange])
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
if (disabled)
return
e.stopPropagation()
handleOpenChange(!open)
}, [handleOpenChange, open, disabled])
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
handleOpenChange(false)
onSelect(type, pluginDefaultValue)
}, [handleOpenChange, onSelect])
const {
activeTab,
setActiveTab,
@@ -154,10 +135,51 @@ const NodeSelector: FC<NodeSelectorProps> = ({
hasUserInputNode,
forceEnableStartTab,
})
const open = openFromProps === undefined ? localOpen : openFromProps
const handleOpenChange = useCallback((newOpen: boolean) => {
setLocalOpen(newOpen)
if (!newOpen) {
setSearchText('')
setSnippetsLoading(false)
}
else if (activeTab === TabsEnum.Snippets) {
setSnippetsLoading(true)
}
if (onOpenChange)
onOpenChange(newOpen)
}, [activeTab, onOpenChange])
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
if (disabled)
return
e.stopPropagation()
handleOpenChange(!open)
}, [handleOpenChange, open, disabled])
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
handleOpenChange(false)
onSelect(type, pluginDefaultValue)
}, [handleOpenChange, onSelect])
const handleActiveTabChange = useCallback((newActiveTab: TabsEnum) => {
setActiveTab(newActiveTab)
}, [setActiveTab])
if (open && newActiveTab === TabsEnum.Snippets)
setSnippetsLoading(true)
}, [open, setActiveTab])
useEffect(() => {
if (!snippetsLoading)
return
const timer = window.setTimeout(() => {
setSnippetsLoading(false)
}, 200)
return () => {
window.clearTimeout(timer)
}
}, [snippetsLoading])
const searchPlaceholder = useMemo(() => {
if (activeTab === TabsEnum.Start)
@@ -171,6 +193,8 @@ const NodeSelector: FC<NodeSelectorProps> = ({
if (activeTab === TabsEnum.Sources)
return t('tabs.searchDataSource', { ns: 'workflow' })
if (activeTab === TabsEnum.Snippets)
return t('tabs.searchSnippets', { ns: 'workflow' })
return ''
}, [activeTab, t])
@@ -257,6 +281,17 @@ const NodeSelector: FC<NodeSelectorProps> = ({
inputClassName="grow"
/>
)}
{activeTab === TabsEnum.Snippets && (
<Input
showLeftIcon
showClearIcon
autoFocus
value={searchText}
placeholder={searchPlaceholder}
onChange={e => setSearchText(e.target.value)}
onClear={() => setSearchText('')}
/>
)}
</div>
)}
onSelect={handleSelect}
@@ -268,6 +303,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
noTools={noTools}
onTagsChange={setTags}
forceShowStartContent={forceShowStartContent}
snippetsElem={<Snippets loading={snippetsLoading} searchText={searchText} />}
/>
</div>
</PortalToFollowElemContent>

View File

@@ -0,0 +1,247 @@
import type { ReactNode } from 'react'
import {
memo,
useDeferredValue,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import {
SearchMenu,
} from '@/app/components/base/icons/src/vender/line/others'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/app/components/base/ui/tooltip'
import { cn } from '@/utils/classnames'
import BlockIcon from '../block-icon'
import { BlockEnum } from '../types'
type SnippetsProps = {
loading?: boolean
searchText: string
}
type StaticSnippet = {
id: string
badge: string
badgeClassName: string
title: string
description: string
author?: string
relatedBlocks?: BlockEnum[]
}
const STATIC_SNIPPETS: StaticSnippet[] = [
{
id: 'customer-review',
badge: 'CR',
title: 'Customer Review',
description: 'Customer Review Description',
author: 'Evan',
relatedBlocks: [
BlockEnum.LLM,
BlockEnum.Code,
BlockEnum.KnowledgeRetrieval,
BlockEnum.QuestionClassifier,
BlockEnum.IfElse,
],
badgeClassName: 'bg-gradient-to-br from-orange-500 to-rose-500',
},
] as const
const LoadingSkeleton = () => {
return (
<div className="relative overflow-hidden">
<div className="p-1">
{['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key, index) => (
<div
key={key}
className={cn(
'flex items-center gap-1 px-3 py-1 opacity-20',
index === 3 && 'opacity-10',
)}
>
<div className="my-1 h-6 w-6 shrink-0 rounded-lg border-[0.5px] border-effects-icon-border bg-text-quaternary" />
<div className="min-w-0 flex-1 px-1 py-1">
<div className="h-2 w-[200px] rounded-[2px] bg-text-quaternary" />
</div>
</div>
))}
</div>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-components-panel-bg-transparent to-background-default-subtle" />
</div>
)
}
const SnippetBadge = ({
badge,
badgeClassName,
}: Pick<StaticSnippet, 'badge' | 'badgeClassName'>) => {
return (
<div
aria-hidden="true"
className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-[9px] font-semibold uppercase text-white shadow-[0px_3px_10px_-2px_rgba(9,9,11,0.08),0px_2px_4px_-2px_rgba(9,9,11,0.06)]',
badgeClassName,
)}
>
{badge}
</div>
)
}
const SnippetDetailCard = ({
author,
description,
relatedBlocks = [],
title,
triggerBadge,
}: {
author?: string
description?: string
relatedBlocks?: BlockEnum[]
title: string
triggerBadge: ReactNode
}) => {
return (
<div className="w-[224px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-3 pb-4 pt-3 shadow-lg backdrop-blur-[5px]">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
{triggerBadge}
<div className="text-text-primary system-md-medium">{title}</div>
</div>
{!!description && (
<div className="w-[200px] text-text-secondary system-xs-regular">
{description}
</div>
)}
{!!relatedBlocks.length && (
<div className="flex items-center gap-0.5 pt-1">
{relatedBlocks.map(block => (
<BlockIcon
key={block}
type={block}
size="sm"
/>
))}
</div>
)}
</div>
{!!author && (
<div className="pt-3 text-text-tertiary system-xs-regular">
{author}
</div>
)}
</div>
)
}
const Snippets = ({
loading = false,
searchText,
}: SnippetsProps) => {
const { t } = useTranslation()
const deferredSearchText = useDeferredValue(searchText)
const [hoveredSnippetId, setHoveredSnippetId] = useState<string | null>(null)
const snippets = useMemo(() => {
return STATIC_SNIPPETS.map(item => ({
...item,
}))
}, [])
const filteredSnippets = useMemo(() => {
const normalizedSearch = deferredSearchText.trim().toLowerCase()
if (!normalizedSearch)
return snippets
return snippets.filter(item => item.title.toLowerCase().includes(normalizedSearch))
}, [deferredSearchText, snippets])
if (loading)
return <LoadingSkeleton />
if (!filteredSnippets.length) {
return (
<div className="flex min-h-[480px] flex-col items-center justify-center gap-2 px-4">
<SearchMenu className="h-8 w-8 text-text-tertiary" />
<div className="text-text-secondary system-sm-regular">
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
</div>
<Button
variant="secondary-accent"
size="small"
onClick={(e) => {
e.preventDefault()
}}
>
{t('tabs.createSnippet', { ns: 'workflow' })}
</Button>
</div>
)
}
return (
<div className="max-h-[480px] max-w-[500px] overflow-y-auto p-1">
{filteredSnippets.map((item) => {
const badge = (
<SnippetBadge
badge={item.badge}
badgeClassName={item.badgeClassName}
/>
)
const row = (
<div
className={cn(
'flex h-8 items-center gap-2 rounded-lg px-3',
hoveredSnippetId === item.id && 'bg-background-default-hover',
)}
onMouseEnter={() => setHoveredSnippetId(item.id)}
onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)}
>
{badge}
<div className="min-w-0 text-text-secondary system-sm-medium">
{item.title}
</div>
{hoveredSnippetId === item.id && item.author && (
<div className="ml-auto text-text-tertiary system-xs-regular">
{item.author}
</div>
)}
</div>
)
if (!item.description)
return <div key={item.id}>{row}</div>
return (
<Tooltip key={item.id}>
<TooltipTrigger
delay={0}
render={row}
/>
<TooltipContent
placement="left-start"
variant="plain"
popupClassName="!bg-transparent !p-0"
>
<SnippetDetailCard
author={item.author}
description={item.description}
relatedBlocks={item.relatedBlocks}
title={item.title}
triggerBadge={badge}
/>
</TooltipContent>
</Tooltip>
)
})}
</div>
)
}
export default memo(Snippets)

View File

@@ -40,6 +40,7 @@ export type TabsProps = {
noTools?: boolean
forceShowStartContent?: boolean // Force show Start content even when noBlocks=true
allowStartNodeSelection?: boolean // Allow user input option even when trigger node already exists (e.g. change-node flow or when no Start node yet).
snippetsElem?: React.ReactNode
}
const Tabs: FC<TabsProps> = ({
activeTab,
@@ -57,6 +58,7 @@ const Tabs: FC<TabsProps> = ({
noTools,
forceShowStartContent = false,
allowStartNodeSelection = false,
snippetsElem,
}) => {
const { t } = useTranslation()
const { data: buildInTools } = useAllBuiltInTools()
@@ -234,6 +236,13 @@ const Tabs: FC<TabsProps> = ({
/>
)
}
{
activeTab === TabsEnum.Snippets && snippetsElem && (
<div className="border-t border-divider-subtle">
{snippetsElem}
</div>
)
}
</div>
)
}

View File

@@ -7,6 +7,7 @@ export enum TabsEnum {
Blocks = 'blocks',
Tools = 'tools',
Sources = 'sources',
Snippets = 'snippets',
}
export enum ToolTypeEnum {

View File

@@ -0,0 +1,180 @@
'use client'
import type { FC } from 'react'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import { useKeyPress } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { Dialog, DialogCloseButton, DialogContent, DialogPortal, DialogTitle } from '@/app/components/base/ui/dialog'
import ShortcutsName from './shortcuts-name'
export type CreateSnippetDialogPayload = {
name: string
description: string
icon: AppIconSelection
selectedNodeIds: string[]
}
type CreateSnippetDialogProps = {
isOpen: boolean
selectedNodeIds: string[]
onClose: () => void
onConfirm: (payload: CreateSnippetDialogPayload) => void
isSubmitting?: boolean
}
const defaultIcon: AppIconSelection = {
type: 'emoji',
icon: '🤖',
background: '#FFEAD5',
}
const CreateSnippetDialog: FC<CreateSnippetDialogProps> = ({
isOpen,
selectedNodeIds,
onClose,
onConfirm,
isSubmitting = false,
}) => {
const { t } = useTranslation()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [icon, setIcon] = useState<AppIconSelection>(defaultIcon)
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const resetForm = useCallback(() => {
setName('')
setDescription('')
setIcon(defaultIcon)
setShowAppIconPicker(false)
}, [])
const handleClose = useCallback(() => {
resetForm()
onClose()
}, [onClose, resetForm])
const handleConfirm = useCallback(() => {
const trimmedName = name.trim()
const trimmedDescription = description.trim()
if (!trimmedName)
return
const payload = {
name: trimmedName,
description: trimmedDescription,
icon,
selectedNodeIds,
}
onConfirm(payload)
}, [description, icon, name, onConfirm, selectedNodeIds])
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
if (!isOpen)
return
if (isSubmitting)
return
handleConfirm()
})
return (
<>
<Dialog open={isOpen} onOpenChange={open => !open && handleClose()}>
<DialogContent className="w-[520px] max-w-[520px] p-0">
<DialogCloseButton />
<div className="px-6 pb-3 pt-6">
<DialogTitle className="text-text-primary title-2xl-semi-bold">
{t('snippet.createDialogTitle', { ns: 'workflow' })}
</DialogTitle>
</div>
<div className="space-y-4 px-6 py-2">
<div className="flex items-end gap-3">
<div className="flex-1 pb-0.5">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">
{t('snippet.nameLabel', { ns: 'workflow' })}
</div>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('snippet.namePlaceholder', { ns: 'workflow' }) || ''}
disabled={isSubmitting}
autoFocus
/>
</div>
<AppIcon
size="xxl"
className="shrink-0 cursor-pointer"
iconType={icon.type}
icon={icon.type === 'emoji' ? icon.icon : icon.fileId}
background={icon.type === 'emoji' ? icon.background : undefined}
imageUrl={icon.type === 'image' ? icon.url : undefined}
onClick={() => setShowAppIconPicker(true)}
/>
</div>
<div>
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">
{t('snippet.descriptionLabel', { ns: 'workflow' })}
</div>
<Textarea
className="resize-none"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder={t('snippet.descriptionPlaceholder', { ns: 'workflow' }) || ''}
disabled={isSubmitting}
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 pb-6 pt-5">
<Button disabled={isSubmitting} onClick={handleClose}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
disabled={!name.trim() || isSubmitting}
loading={isSubmitting}
onClick={handleConfirm}
>
{t('snippet.confirm', { ns: 'workflow' })}
<ShortcutsName className="ml-1" keys={['ctrl', 'enter']} bgColor="white" />
</Button>
</div>
</DialogContent>
<DialogPortal>
<div className="pointer-events-none fixed left-1/2 top-1/2 z-[1002] flex -translate-x-1/2 translate-y-[170px] items-center gap-1 text-text-quaternary body-xs-regular">
<span>{t('snippet.shortcuts.press', { ns: 'workflow' })}</span>
<ShortcutsName keys={['ctrl', 'enter']} textColor="secondary" />
<span>{t('snippet.shortcuts.toConfirm', { ns: 'workflow' })}</span>
</div>
</DialogPortal>
</Dialog>
{showAppIconPicker && (
<AppIconPicker
className="z-[1100]"
onSelect={(selection) => {
setIcon(selection)
setShowAppIconPicker(false)
}}
onClose={() => setShowAppIconPicker(false)}
/>
)}
</>
)
}
export default CreateSnippetDialog

View File

@@ -0,0 +1,91 @@
import type { Node } from '../../types'
import type { DataSet } from '@/models/datasets'
import { render, screen, waitFor } from '@testing-library/react'
import { BlockEnum } from '../../types'
import DatasetsDetailProvider from '../provider'
import { useDatasetsDetailStore } from '../store'
const mockFetchDatasets = vi.fn()
vi.mock('@/service/datasets', () => ({
fetchDatasets: (params: unknown) => mockFetchDatasets(params),
}))
const Consumer = () => {
const datasetCount = useDatasetsDetailStore(state => Object.keys(state.datasetsDetail).length)
return <div>{`dataset-count:${datasetCount}`}</div>
}
const createWorkflowNode = (datasetIds: string[] = []): Node => ({
id: `node-${datasetIds.join('-') || 'empty'}`,
type: 'custom',
position: { x: 0, y: 0 },
data: {
title: 'Knowledge',
desc: '',
type: BlockEnum.KnowledgeRetrieval,
dataset_ids: datasetIds,
},
} as unknown as Node)
const createDataset = (id: string): DataSet => ({
id,
name: `Dataset ${id}`,
} as DataSet)
describe('datasets-detail-store provider', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchDatasets.mockResolvedValue({ data: [] })
})
it('should provide the datasets detail store without fetching when no knowledge datasets are selected', () => {
render(
<DatasetsDetailProvider nodes={[
{
id: 'node-start',
type: 'custom',
position: { x: 0, y: 0 },
data: {
title: 'Start',
desc: '',
type: BlockEnum.Start,
},
} as unknown as Node,
]}
>
<Consumer />
</DatasetsDetailProvider>,
)
expect(screen.getByText('dataset-count:0')).toBeInTheDocument()
expect(mockFetchDatasets).not.toHaveBeenCalled()
})
it('should fetch unique dataset details from knowledge retrieval nodes and store them', async () => {
mockFetchDatasets.mockResolvedValue({
data: [createDataset('dataset-1'), createDataset('dataset-2')],
})
render(
<DatasetsDetailProvider nodes={[
createWorkflowNode(['dataset-1', 'dataset-2']),
createWorkflowNode(['dataset-2']),
]}
>
<Consumer />
</DatasetsDetailProvider>,
)
await waitFor(() => {
expect(mockFetchDatasets).toHaveBeenCalledWith({
url: '/datasets',
params: {
page: 1,
ids: ['dataset-1', 'dataset-2'],
},
})
expect(screen.getByText('dataset-count:2')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,308 @@
import type { Shape } from '../../store/workflow'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { FlowType } from '@/types/common'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import { WorkflowVersion } from '../../types'
import HeaderInNormal from '../header-in-normal'
import HeaderInRestoring from '../header-in-restoring'
import HeaderInHistory from '../header-in-view-history'
const mockUseNodes = vi.fn()
const mockHandleBackupDraft = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
const mockHandleNodeSelect = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
const mockCloseAllInputFieldPanels = vi.fn()
const mockInvalidAllLastRun = vi.fn()
const mockRestoreWorkflow = vi.fn()
const mockNotify = vi.fn()
const mockRunAndHistory = vi.fn()
const mockViewHistory = vi.fn()
let mockNodesReadOnly = false
let mockTheme: 'light' | 'dark' = 'light'
vi.mock('reactflow', () => ({
useNodes: () => mockUseNodes(),
}))
vi.mock('../../hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: mockNodesReadOnly }),
useNodesInteractions: () => ({ handleNodeSelect: mockHandleNodeSelect }),
useWorkflowRun: () => ({
handleBackupDraft: mockHandleBackupDraft,
handleLoadBackupDraft: mockHandleLoadBackupDraft,
}),
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: vi.fn(),
}),
useWorkflowRefreshDraft: () => ({
handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft,
}),
}))
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
useInputFieldPanel: () => ({
closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
}),
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: mockTheme,
}),
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mockInvalidAllLastRun,
useRestoreWorkflow: () => ({
mutateAsync: mockRestoreWorkflow,
}),
}))
vi.mock('../../../base/toast', () => ({
default: {
notify: (payload: unknown) => mockNotify(payload),
},
}))
vi.mock('../editing-title', () => ({
default: () => <div>editing-title</div>,
}))
vi.mock('../scroll-to-selected-node-button', () => ({
default: () => <div>scroll-button</div>,
}))
vi.mock('../env-button', () => ({
default: ({ disabled }: { disabled: boolean }) => <div data-testid="env-button">{`${disabled}`}</div>,
}))
vi.mock('../global-variable-button', () => ({
default: ({ disabled }: { disabled: boolean }) => <div data-testid="global-variable-button">{`${disabled}`}</div>,
}))
vi.mock('../run-and-history', () => ({
default: (props: object) => {
mockRunAndHistory(props)
return <div data-testid="run-and-history" />
},
}))
vi.mock('../version-history-button', () => ({
default: ({ onClick }: { onClick: () => void }) => (
<button type="button" onClick={onClick}>
version-history
</button>
),
}))
vi.mock('../restoring-title', () => ({
default: () => <div>restoring-title</div>,
}))
vi.mock('../running-title', () => ({
default: () => <div>running-title</div>,
}))
vi.mock('../view-history', () => ({
default: (props: { withText?: boolean }) => {
mockViewHistory(props)
return <div data-testid="view-history">{props.withText ? 'with-text' : 'icon-only'}</div>
},
}))
const createSelectedNode = (selected = true) => ({
id: 'node-selected',
data: {
selected,
},
})
const createBackupDraft = (): NonNullable<Shape['backupDraft']> => ({
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
environmentVariables: [],
})
const createCurrentVersion = (): NonNullable<Shape['currentVersion']> => ({
id: 'version-1',
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
created_at: 0,
created_by: {
id: 'user-1',
name: 'Tester',
email: 'tester@example.com',
},
hash: 'hash-1',
updated_at: 0,
updated_by: {
id: 'user-1',
name: 'Tester',
email: 'tester@example.com',
},
tool_published: false,
environment_variables: [],
version: WorkflowVersion.Latest,
marked_name: '',
marked_comment: '',
})
describe('Header layout components', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNodesReadOnly = false
mockTheme = 'light'
mockUseNodes.mockReturnValue([])
mockRestoreWorkflow.mockResolvedValue(undefined)
})
describe('HeaderInNormal', () => {
it('should render slots, pass read-only state to action buttons, and start restoring mode', () => {
mockNodesReadOnly = true
mockUseNodes.mockReturnValue([createSelectedNode()])
const { store } = renderWorkflowComponent(
<HeaderInNormal
components={{
left: <div>left-slot</div>,
middle: <div>middle-slot</div>,
chatVariableTrigger: <div>chat-trigger</div>,
}}
/>,
{
initialStoreState: {
showEnvPanel: true,
showDebugAndPreviewPanel: true,
showVariableInspectPanel: true,
showChatVariablePanel: true,
showGlobalVariablePanel: true,
},
},
)
expect(screen.getByText('editing-title')).toBeInTheDocument()
expect(screen.getByText('scroll-button')).toBeInTheDocument()
expect(screen.getByText('left-slot')).toBeInTheDocument()
expect(screen.getByText('middle-slot')).toBeInTheDocument()
expect(screen.getByText('chat-trigger')).toBeInTheDocument()
expect(screen.getByTestId('env-button')).toHaveTextContent('true')
expect(screen.getByTestId('global-variable-button')).toHaveTextContent('true')
expect(mockRunAndHistory).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: 'version-history' }))
expect(mockHandleBackupDraft).toHaveBeenCalledTimes(1)
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-selected', true)
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
expect(store.getState().isRestoring).toBe(true)
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(true)
expect(store.getState().showEnvPanel).toBe(false)
expect(store.getState().showDebugAndPreviewPanel).toBe(false)
expect(store.getState().showVariableInspectPanel).toBe(false)
expect(store.getState().showChatVariablePanel).toBe(false)
expect(store.getState().showGlobalVariablePanel).toBe(false)
})
})
describe('HeaderInRestoring', () => {
it('should cancel restoring mode and reopen the editor state', () => {
const { store } = renderWorkflowComponent(
<HeaderInRestoring />,
{
initialStoreState: {
isRestoring: true,
showWorkflowVersionHistoryPanel: true,
},
hooksStoreProps: {
configsMap: {
flowType: FlowType.appFlow,
flowId: 'flow-1',
fileSettings: {},
},
},
},
)
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.exitVersions' }))
expect(mockHandleLoadBackupDraft).toHaveBeenCalledTimes(1)
expect(store.getState().isRestoring).toBe(false)
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(false)
})
it('should restore the selected version, clear backup state, and forward lifecycle callbacks', async () => {
const onRestoreSettled = vi.fn()
const deleteAllInspectVars = vi.fn()
const currentVersion = createCurrentVersion()
const { store } = renderWorkflowComponent(
<HeaderInRestoring onRestoreSettled={onRestoreSettled} />,
{
initialStoreState: {
isRestoring: true,
showWorkflowVersionHistoryPanel: true,
backupDraft: createBackupDraft(),
currentVersion,
deleteAllInspectVars,
},
hooksStoreProps: {
configsMap: {
flowType: FlowType.appFlow,
flowId: 'flow-1',
fileSettings: {},
},
},
},
)
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.restore' }))
await waitFor(() => {
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/flow-1/workflows/version-1/restore')
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(false)
expect(store.getState().isRestoring).toBe(false)
expect(store.getState().backupDraft).toBeUndefined()
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(1)
expect(deleteAllInspectVars).toHaveBeenCalledTimes(1)
expect(mockInvalidAllLastRun).toHaveBeenCalledTimes(1)
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'workflow.versionHistory.action.restoreSuccess',
})
})
expect(onRestoreSettled).toHaveBeenCalledTimes(1)
})
})
describe('HeaderInHistory', () => {
it('should render the history trigger with text and return to edit mode', () => {
const { store } = renderWorkflowComponent(
<HeaderInHistory viewHistoryProps={{ historyUrl: '/history' } as never} />,
{
initialStoreState: {
historyWorkflowData: {
id: 'history-1',
} as Shape['historyWorkflowData'],
},
},
)
expect(screen.getByText('running-title')).toBeInTheDocument()
expect(screen.getByTestId('view-history')).toHaveTextContent('with-text')
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.goBackToEdit' }))
expect(mockHandleLoadBackupDraft).toHaveBeenCalledTimes(1)
expect(store.getState().historyWorkflowData).toBeUndefined()
expect(mockViewHistory).toHaveBeenCalledWith(expect.objectContaining({
withText: true,
}))
})
})
})

View File

@@ -0,0 +1,106 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import Header from '../index'
let mockPathname = '/apps/demo/workflow'
let mockMaximizeCanvas = false
let mockWorkflowMode = {
normal: true,
restoring: false,
viewHistory: false,
}
vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname,
}))
vi.mock('../../hooks', () => ({
useWorkflowMode: () => mockWorkflowMode,
}))
vi.mock('../../store', () => ({
useStore: <T,>(selector: (state: { maximizeCanvas: boolean }) => T) => selector({
maximizeCanvas: mockMaximizeCanvas,
}),
}))
vi.mock('@/next/dynamic', async () => {
const ReactModule = await import('react')
return {
default: (
loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>,
) => {
const DynamicComponent = (props: Record<string, unknown>) => {
const [Loaded, setLoaded] = ReactModule.useState<React.ComponentType<Record<string, unknown>> | null>(null)
ReactModule.useEffect(() => {
let mounted = true
loader().then((mod) => {
if (mounted)
setLoaded(() => mod.default)
})
return () => {
mounted = false
}
}, [])
return Loaded ? <Loaded {...props} /> : null
}
return DynamicComponent
},
}
})
vi.mock('../header-in-normal', () => ({
default: () => <div data-testid="header-normal">normal-layout</div>,
}))
vi.mock('../header-in-view-history', () => ({
default: () => <div data-testid="header-history">history-layout</div>,
}))
vi.mock('../header-in-restoring', () => ({
default: () => <div data-testid="header-restoring">restoring-layout</div>,
}))
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPathname = '/apps/demo/workflow'
mockMaximizeCanvas = false
mockWorkflowMode = {
normal: true,
restoring: false,
viewHistory: false,
}
})
it('should render the normal layout and show the maximize spacer on workflow canvases', () => {
mockMaximizeCanvas = true
const { container } = render(<Header />)
expect(screen.getByTestId('header-normal')).toBeInTheDocument()
expect(screen.queryByTestId('header-history')).not.toBeInTheDocument()
expect(screen.queryByTestId('header-restoring')).not.toBeInTheDocument()
expect(container.querySelector('.h-14.w-\\[52px\\]')).not.toBeNull()
})
it('should switch between history and restoring layouts and skip the spacer outside canvas routes', async () => {
mockPathname = '/apps/demo/logs'
mockWorkflowMode = {
normal: false,
restoring: true,
viewHistory: true,
}
const { container } = render(<Header />)
expect(await screen.findByTestId('header-history')).toBeInTheDocument()
expect(await screen.findByTestId('header-restoring')).toBeInTheDocument()
expect(screen.queryByTestId('header-normal')).not.toBeInTheDocument()
expect(container.querySelector('.h-14.w-\\[52px\\]')).toBeNull()
})
})

View File

@@ -0,0 +1,73 @@
import { render, screen, waitFor } from '@testing-library/react'
import { useContext } from 'react'
import { HooksStoreContext, HooksStoreContextProvider } from '../provider'
const mockRefreshAll = vi.fn()
const mockStore = {
getState: () => ({
refreshAll: mockRefreshAll,
}),
}
let mockReactflowState = {
d3Selection: null as object | null,
d3Zoom: null as object | null,
}
vi.mock('reactflow', () => ({
useStore: (selector: (state: typeof mockReactflowState) => unknown) => selector(mockReactflowState),
}))
vi.mock('../store', async () => {
const actual = await vi.importActual<typeof import('../store')>('../store')
return {
...actual,
createHooksStore: vi.fn(() => mockStore),
}
})
const Consumer = () => {
const store = useContext(HooksStoreContext)
return <div>{store ? 'has-hooks-store' : 'missing-hooks-store'}</div>
}
describe('hooks-store provider', () => {
beforeEach(() => {
vi.clearAllMocks()
mockReactflowState = {
d3Selection: null,
d3Zoom: null,
}
})
it('should provide the hooks store context without refreshing when the canvas handles are missing', () => {
render(
<HooksStoreContextProvider>
<Consumer />
</HooksStoreContextProvider>,
)
expect(screen.getByText('has-hooks-store')).toBeInTheDocument()
expect(mockRefreshAll).not.toHaveBeenCalled()
})
it('should refresh the hooks store when both d3Selection and d3Zoom are available', async () => {
const handleRun = vi.fn()
mockReactflowState = {
d3Selection: {},
d3Zoom: {},
}
render(
<HooksStoreContextProvider handleRun={handleRun}>
<Consumer />
</HooksStoreContextProvider>,
)
await waitFor(() => {
expect(mockRefreshAll).toHaveBeenCalledWith({
handleRun,
})
})
})
})

View File

@@ -0,0 +1,107 @@
import type { ReactElement } from 'react'
import type { Node as WorkflowNode } from '../../types'
import { render, screen } from '@testing-library/react'
import { CUSTOM_NODE } from '../../constants'
import { BlockEnum } from '../../types'
import CustomNode, { Panel } from '../index'
vi.mock('../components', () => ({
NodeComponentMap: {
[BlockEnum.Start]: () => <div>start-node-component</div>,
},
PanelComponentMap: {
[BlockEnum.Start]: () => <div>start-panel-component</div>,
},
}))
vi.mock('../_base/node', () => ({
__esModule: true,
default: ({
id,
data,
children,
}: {
id: string
data: { type: BlockEnum }
children: ReactElement
}) => (
<div>
<div>{`base-node:${id}:${data.type}`}</div>
{children}
</div>
),
}))
vi.mock('../_base/components/workflow-panel', () => ({
__esModule: true,
default: ({
id,
data,
children,
}: {
id: string
data: { type: BlockEnum }
children: ReactElement
}) => (
<div>
<div>{`base-panel:${id}:${data.type}`}</div>
{children}
</div>
),
}))
const createNodeData = (): WorkflowNode['data'] => ({
title: 'Start',
desc: '',
type: BlockEnum.Start,
})
const baseNodeProps = {
type: CUSTOM_NODE,
selected: false,
zIndex: 1,
xPos: 0,
yPos: 0,
dragging: false,
isConnectable: true,
}
describe('workflow nodes index', () => {
it('should render the mapped node inside the base node shell', () => {
render(
<CustomNode
id="node-1"
data={createNodeData()}
{...baseNodeProps}
/>,
)
expect(screen.getByText('base-node:node-1:start')).toBeInTheDocument()
expect(screen.getByText('start-node-component')).toBeInTheDocument()
})
it('should render the mapped panel inside the base panel shell for custom nodes', () => {
render(
<Panel
type={CUSTOM_NODE}
id="node-1"
data={createNodeData()}
/>,
)
expect(screen.getByText('base-panel:node-1:start')).toBeInTheDocument()
expect(screen.getByText('start-panel-component')).toBeInTheDocument()
})
it('should return null for non-custom panel types', () => {
const { container } = render(
<Panel
type="default"
id="node-1"
data={createNodeData()}
/>,
)
expect(container).toBeEmptyDOMElement()
})
})

View File

@@ -0,0 +1,226 @@
import type { UploadFileSetting } from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { useFileUploadConfig } from '@/service/use-common'
import { TransferMethod } from '@/types/app'
import FileTypeItem from '../file-type-item'
import FileUploadSetting from '../file-upload-setting'
const mockUseFileUploadConfig = vi.mocked(useFileUploadConfig)
const mockUseFileSizeLimit = vi.mocked(useFileSizeLimit)
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(),
}))
vi.mock('@/app/components/base/file-uploader/hooks', () => ({
useFileSizeLimit: vi.fn(),
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: vi.fn(),
close: vi.fn(),
}),
}))
const createPayload = (overrides: Partial<UploadFileSetting> = {}): UploadFileSetting => ({
allowed_file_upload_methods: [TransferMethod.local_file],
max_length: 2,
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_extensions: ['pdf'],
...overrides,
})
describe('File upload support components', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseFileUploadConfig.mockReturnValue({ data: {} } as ReturnType<typeof useFileUploadConfig>)
mockUseFileSizeLimit.mockReturnValue({
imgSizeLimit: 10 * 1024 * 1024,
docSizeLimit: 20 * 1024 * 1024,
audioSizeLimit: 30 * 1024 * 1024,
videoSizeLimit: 40 * 1024 * 1024,
maxFileUploadLimit: 10,
} as ReturnType<typeof useFileSizeLimit>)
})
describe('FileTypeItem', () => {
it('should render built-in file types and toggle the selected type on click', () => {
const onToggle = vi.fn()
render(
<FileTypeItem
type={SupportUploadFileTypes.image}
selected={false}
onToggle={onToggle}
/>,
)
expect(screen.getByText('appDebug.variableConfig.file.image.name')).toBeInTheDocument()
expect(screen.getByText('JPG, JPEG, PNG, GIF, WEBP, SVG')).toBeInTheDocument()
fireEvent.click(screen.getByText('appDebug.variableConfig.file.image.name'))
expect(onToggle).toHaveBeenCalledWith(SupportUploadFileTypes.image)
})
it('should render the custom tag editor and emit custom extensions', async () => {
const user = userEvent.setup()
const onCustomFileTypesChange = vi.fn()
render(
<FileTypeItem
type={SupportUploadFileTypes.custom}
selected
onToggle={vi.fn()}
customFileTypes={['json']}
onCustomFileTypesChange={onCustomFileTypesChange}
/>,
)
const input = screen.getByPlaceholderText('appDebug.variableConfig.file.custom.createPlaceholder')
await user.type(input, 'csv')
fireEvent.blur(input)
expect(screen.getByText('json')).toBeInTheDocument()
expect(onCustomFileTypesChange).toHaveBeenCalledWith(['json', 'csv'])
})
})
describe('FileUploadSetting', () => {
it('should update file types, upload methods, and upload limits', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<FileUploadSetting
payload={createPayload()}
isMultiple
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.file.image.name'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
allowed_file_types: [SupportUploadFileTypes.document, SupportUploadFileTypes.image],
}))
await user.click(screen.getByText('URL'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
allowed_file_upload_methods: [TransferMethod.remote_url],
}))
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '5' } })
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
max_length: 5,
}))
})
it('should toggle built-in and custom file type selections', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { rerender } = render(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.file.document.name'))
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_types: [],
}))
rerender(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.file.custom.name'))
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_types: [SupportUploadFileTypes.custom],
}))
rerender(
<FileUploadSetting
payload={createPayload({
allowed_file_types: [SupportUploadFileTypes.custom],
})}
isMultiple={false}
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.file.custom.name'))
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_types: [],
}))
})
it('should support both upload methods and update custom extensions', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { rerender } = render(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
onChange={onChange}
/>,
)
await user.click(screen.getByText('appDebug.variableConfig.both'))
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
}))
rerender(
<FileUploadSetting
payload={createPayload({
allowed_file_types: [SupportUploadFileTypes.custom],
})}
isMultiple={false}
onChange={onChange}
/>,
)
const input = screen.getByPlaceholderText('appDebug.variableConfig.file.custom.createPlaceholder')
await user.type(input, 'csv')
fireEvent.blur(input)
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
allowed_file_extensions: ['pdf', 'csv'],
}))
})
it('should render support file types in the feature panel and hide them when requested', () => {
const { rerender } = render(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
inFeaturePanel
onChange={vi.fn()}
/>,
)
expect(screen.getByText('appDebug.variableConfig.file.supportFileTypes')).toBeInTheDocument()
rerender(
<FileUploadSetting
payload={createPayload()}
isMultiple={false}
inFeaturePanel
hideSupportFileType
onChange={vi.fn()}
/>,
)
expect(screen.queryByText('appDebug.variableConfig.file.document.name')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,250 @@
import type { NodeProps } from 'reactflow'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { NodeRunningStatus, VarType } from '@/app/components/workflow/types'
import DefaultValue from '../default-value'
import ErrorHandleOnNode from '../error-handle-on-node'
import ErrorHandleOnPanel from '../error-handle-on-panel'
import ErrorHandleTip from '../error-handle-tip'
import ErrorHandleTypeSelector from '../error-handle-type-selector'
import FailBranchCard from '../fail-branch-card'
import { useDefaultValue, useErrorHandle } from '../hooks'
import { ErrorHandleTypeEnum } from '../types'
const { mockDocLink } = vi.hoisted(() => ({
mockDocLink: vi.fn((path: string) => `https://docs.example.com${path}`),
}))
vi.mock('@/context/i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/i18n')>()
return {
...actual,
useDocLink: () => mockDocLink,
}
})
vi.mock('../hooks', () => ({
useDefaultValue: vi.fn(),
useErrorHandle: vi.fn(),
}))
vi.mock('../../node-handle', () => ({
NodeSourceHandle: ({ handleId }: { handleId: string }) => <div className="react-flow__handle" data-handleid={handleId} />,
}))
const mockUseDefaultValue = vi.mocked(useDefaultValue)
const mockUseErrorHandle = vi.mocked(useErrorHandle)
const originalDOMMatrixReadOnly = window.DOMMatrixReadOnly
const baseData = (overrides: Partial<CommonNodeType> = {}): CommonNodeType => ({
title: 'Code',
desc: '',
type: 'code' as CommonNodeType['type'],
...overrides,
})
const ErrorHandleNodeHarness = ({ id, data }: NodeProps<CommonNodeType>) => (
<ErrorHandleOnNode id={id} data={data} />
)
const renderErrorHandleNode = (data: CommonNodeType) =>
renderWorkflowFlowComponent(<div />, {
nodes: [createNode({
id: 'node-1',
type: 'errorHandleNode',
data,
})],
edges: [],
reactFlowProps: {
nodeTypes: {
errorHandleNode: ErrorHandleNodeHarness,
},
},
})
describe('error-handle path', () => {
beforeAll(() => {
class MockDOMMatrixReadOnly {
inverse() {
return this
}
transformPoint(point: { x: number, y: number }) {
return point
}
}
Object.defineProperty(window, 'DOMMatrixReadOnly', {
configurable: true,
writable: true,
value: MockDOMMatrixReadOnly,
})
})
beforeEach(() => {
vi.clearAllMocks()
mockDocLink.mockImplementation((path: string) => `https://docs.example.com${path}`)
mockUseDefaultValue.mockReturnValue({
handleFormChange: vi.fn(),
})
mockUseErrorHandle.mockReturnValue({
collapsed: false,
setCollapsed: vi.fn(),
handleErrorHandleTypeChange: vi.fn(),
})
})
afterAll(() => {
Object.defineProperty(window, 'DOMMatrixReadOnly', {
configurable: true,
writable: true,
value: originalDOMMatrixReadOnly,
})
})
// The error-handle leaf components should expose selectable strategies and contextual help.
describe('Leaf Components', () => {
it('should render the fail-branch card with the resolved learn-more link', () => {
render(<FailBranchCard />)
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.customize')).toBeInTheDocument()
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://docs.example.com/use-dify/debug/error-type')
})
it('should render string forms and surface array forms in the default value editor', () => {
const onFormChange = vi.fn()
render(
<DefaultValue
forms={[
{ key: 'message', type: VarType.string, value: 'hello' },
{ key: 'items', type: VarType.arrayString, value: '["a"]' },
]}
onFormChange={onFormChange}
/>,
)
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated' } })
expect(onFormChange).toHaveBeenCalledWith({
key: 'message',
type: VarType.string,
value: 'updated',
})
expect(screen.getByText('items')).toBeInTheDocument()
})
it('should toggle the selector popup and report the selected strategy', async () => {
const user = userEvent.setup()
const onSelected = vi.fn()
render(
<ErrorHandleTypeSelector
value={ErrorHandleTypeEnum.none}
onSelected={onSelected}
/>,
)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.title'))
expect(onSelected).toHaveBeenCalledWith(ErrorHandleTypeEnum.defaultValue)
})
it('should render the error tip only when a strategy exists', () => {
const { rerender, container } = render(<ErrorHandleTip />)
expect(container).toBeEmptyDOMElement()
rerender(<ErrorHandleTip type={ErrorHandleTypeEnum.failBranch} />)
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.inLog')).toBeInTheDocument()
rerender(<ErrorHandleTip type={ErrorHandleTypeEnum.defaultValue} />)
expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.inLog')).toBeInTheDocument()
})
})
// The container components should show the correct branch card or default-value editor and propagate actions.
describe('Containers', () => {
it('should render the fail-branch panel body when the strategy is active', () => {
render(
<ErrorHandleOnPanel
id="node-1"
data={baseData({ error_strategy: ErrorHandleTypeEnum.failBranch })}
/>,
)
expect(screen.getByText('workflow.nodes.common.errorHandle.title')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.customize')).toBeInTheDocument()
})
it('should render the default-value panel body and delegate form updates', () => {
const handleFormChange = vi.fn()
mockUseDefaultValue.mockReturnValue({ handleFormChange })
render(
<ErrorHandleOnPanel
id="node-1"
data={baseData({
error_strategy: ErrorHandleTypeEnum.defaultValue,
default_value: [{ key: 'answer', type: VarType.string, value: 'draft' }],
})}
/>,
)
fireEvent.change(screen.getByDisplayValue('draft'), { target: { value: 'next' } })
expect(handleFormChange).toHaveBeenCalledWith(
{ key: 'answer', type: VarType.string, value: 'next' },
expect.objectContaining({ error_strategy: ErrorHandleTypeEnum.defaultValue }),
)
})
it('should hide the panel body when the hook reports a collapsed section', () => {
mockUseErrorHandle.mockReturnValue({
collapsed: true,
setCollapsed: vi.fn(),
handleErrorHandleTypeChange: vi.fn(),
})
render(
<ErrorHandleOnPanel
id="node-1"
data={baseData({ error_strategy: ErrorHandleTypeEnum.failBranch })}
/>,
)
expect(screen.queryByText('workflow.nodes.common.errorHandle.failBranch.customize')).not.toBeInTheDocument()
})
it('should render the default-value node badge', () => {
renderWorkflowFlowComponent(
<ErrorHandleOnNode
id="node-1"
data={baseData({
error_strategy: ErrorHandleTypeEnum.defaultValue,
})}
/>,
{
nodes: [],
edges: [],
},
)
expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.output')).toBeInTheDocument()
})
it('should render the fail-branch node badge when the node throws an exception', () => {
const { container } = renderErrorHandleNode(baseData({
error_strategy: ErrorHandleTypeEnum.failBranch,
_runningStatus: NodeRunningStatus.Exception,
}))
return waitFor(() => {
expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.title')).toBeInTheDocument()
expect(container.querySelector('.react-flow__handle')).toHaveAttribute('data-handleid', ErrorHandleTypeEnum.failBranch)
})
})
})
})

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react'
import Add from '../add'
import InputField from '../index'
describe('InputField', () => {
@@ -14,5 +15,12 @@ describe('InputField', () => {
expect(screen.getAllByText('input field')).toHaveLength(2)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render the standalone add action button', () => {
const { container } = render(<Add />)
expect(screen.getByRole('button')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeNull()
})
})
})

View File

@@ -1,13 +1,47 @@
import { render, screen } from '@testing-library/react'
import { BoxGroupField, FieldTitle } from '../index'
import userEvent from '@testing-library/user-event'
import { Box, BoxGroup, BoxGroupField, Field, Group, GroupField } from '../index'
describe('layout index', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The barrel exports should compose the public layout primitives without extra wrappers.
// The layout primitives should preserve their composition contracts and collapse behavior.
describe('Rendering', () => {
it('should render Box and Group with optional border styles', () => {
render(
<div>
<Box withBorderBottom className="box-test">Box content</Box>
<Group withBorderBottom className="group-test">Group content</Group>
</div>,
)
expect(screen.getByText('Box content')).toHaveClass('border-b', 'box-test')
expect(screen.getByText('Group content')).toHaveClass('border-b', 'group-test')
})
it('should render BoxGroup and GroupField with nested children', () => {
render(
<div>
<BoxGroup>Inside box group</BoxGroup>
<GroupField
fieldProps={{
fieldTitleProps: {
title: 'Grouped field',
},
}}
>
Group field body
</GroupField>
</div>,
)
expect(screen.getByText('Inside box group')).toBeInTheDocument()
expect(screen.getByText('Grouped field')).toBeInTheDocument()
expect(screen.getByText('Group field body')).toBeInTheDocument()
})
it('should render BoxGroupField from the barrel export', () => {
render(
<BoxGroupField
@@ -25,10 +59,23 @@ describe('layout index', () => {
expect(screen.getByText('Body content')).toBeInTheDocument()
})
it('should render FieldTitle from the barrel export', () => {
render(<FieldTitle title="Advanced" subTitle="Extra details" />)
it('should collapse and expand Field children when supportCollapse is enabled', async () => {
const user = userEvent.setup()
render(
<Field
supportCollapse
fieldTitleProps={{ title: 'Advanced' }}
>
<div>Extra details</div>
</Field>,
)
expect(screen.getByText('Advanced')).toBeInTheDocument()
expect(screen.getByText('Extra details')).toBeInTheDocument()
await user.click(screen.getByText('Advanced'))
expect(screen.queryByText('Extra details')).not.toBeInTheDocument()
await user.click(screen.getByText('Advanced'))
expect(screen.getByText('Extra details')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,114 @@
import type { PromptEditorProps } from '@/app/components/base/prompt-editor'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { render } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import MixedVariableTextInput from '../index'
let capturedPromptEditorProps: PromptEditorProps[] = []
vi.mock('@/app/components/base/prompt-editor', () => ({
default: ({
editable,
value,
workflowVariableBlock,
onChange,
}: PromptEditorProps) => {
capturedPromptEditorProps.push({
editable,
value,
onChange,
workflowVariableBlock,
})
return (
<div data-testid="prompt-editor">
<div data-testid="editable-flag">{editable ? 'editable' : 'readonly'}</div>
<div data-testid="value-flag">{value || 'empty'}</div>
<button type="button" onClick={() => onChange?.('updated text')}>trigger-change</button>
</div>
)
},
}))
describe('MixedVariableTextInput', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedPromptEditorProps = []
})
it('should pass workflow variable metadata to the prompt editor and include system variables for start nodes', () => {
const nodesOutputVars: NodeOutPutVar[] = [{
nodeId: 'node-1',
title: 'Question Node',
vars: [],
}]
const availableNodes: Node[] = [
{
id: 'start-node',
position: { x: 0, y: 0 },
data: {
title: 'Start Node',
desc: 'Start description',
type: BlockEnum.Start,
},
},
{
id: 'llm-node',
position: { x: 120, y: 0 },
data: {
title: 'LLM Node',
desc: 'LLM description',
type: BlockEnum.LLM,
},
},
]
render(
<MixedVariableTextInput
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
/>,
)
const latestProps = capturedPromptEditorProps.at(-1)
expect(latestProps?.editable).toBe(true)
expect(latestProps?.workflowVariableBlock?.variables).toHaveLength(1)
expect(latestProps?.workflowVariableBlock?.workflowNodesMap).toEqual({
'start-node': {
title: 'Start Node',
type: 'start',
},
'sys': {
title: 'workflow.blocks.start',
type: 'start',
},
'llm-node': {
title: 'LLM Node',
type: 'llm',
},
})
})
it('should forward read-only state, current value, and change callbacks', async () => {
const onChange = vi.fn()
const { findByRole, getByTestId } = render(
<MixedVariableTextInput
readOnly
value="seed value"
onChange={onChange}
/>,
)
expect(getByTestId('editable-flag')).toHaveTextContent('readonly')
expect(getByTestId('value-flag')).toHaveTextContent('seed value')
const changeButton = await findByRole('button', { name: 'trigger-change' })
changeButton.click()
expect(onChange).toHaveBeenCalledWith('updated text')
})
})

View File

@@ -0,0 +1,78 @@
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
import type { LexicalEditor } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { createEvent, fireEvent, render, screen } from '@testing-library/react'
import { $insertNodes, FOCUS_COMMAND } from 'lexical'
import Placeholder from '../placeholder'
const mockEditorUpdate = vi.fn((callback: () => void) => callback())
const mockDispatchCommand = vi.fn()
const mockInsertNodes = vi.fn()
const mockTextNode = vi.fn()
const mockEditor = {
update: mockEditorUpdate,
dispatchCommand: mockDispatchCommand,
} as unknown as LexicalEditor
const lexicalContextValue: LexicalComposerContextWithEditor = [
mockEditor,
{ getTheme: () => undefined },
]
vi.mock('@lexical/react/LexicalComposerContext', () => ({
useLexicalComposerContext: vi.fn(),
}))
vi.mock('lexical', () => ({
$insertNodes: vi.fn(),
FOCUS_COMMAND: 'focus-command',
}))
vi.mock('@/app/components/base/prompt-editor/plugins/custom-text/node', () => ({
CustomTextNode: class MockCustomTextNode {
value: string
constructor(value: string) {
this.value = value
mockTextNode(value)
}
},
}))
describe('Mixed variable placeholder', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useLexicalComposerContext).mockReturnValue(lexicalContextValue)
vi.mocked($insertNodes).mockImplementation(nodes => mockInsertNodes(nodes))
})
it('should insert an empty text node and focus the editor when the placeholder background is clicked', () => {
const parentClick = vi.fn()
render(
<div onClick={parentClick}>
<Placeholder />
</div>,
)
fireEvent.click(screen.getByText('workflow.nodes.tool.insertPlaceholder1'))
expect(parentClick).not.toHaveBeenCalled()
expect(mockTextNode).toHaveBeenCalledWith('')
expect(mockInsertNodes).toHaveBeenCalledTimes(1)
expect(mockDispatchCommand).toHaveBeenCalledWith(FOCUS_COMMAND, undefined)
})
it('should insert a slash shortcut from the highlighted action and prevent the native mouse down behavior', () => {
render(<Placeholder />)
const shortcut = screen.getByText('workflow.nodes.tool.insertPlaceholder2')
const event = createEvent.mouseDown(shortcut)
fireEvent(shortcut, event)
expect(event.defaultPrevented).toBe(true)
expect(mockTextNode).toHaveBeenCalledWith('/')
expect(mockDispatchCommand).toHaveBeenCalledWith(FOCUS_COMMAND, undefined)
})
})

View File

@@ -0,0 +1,268 @@
/* eslint-disable ts/no-explicit-any */
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import {
useAvailableBlocks,
useIsChatMode,
useNodeDataUpdate,
useNodeMetaData,
useNodesInteractions,
useNodesReadOnly,
useNodesSyncDraft,
} from '@/app/components/workflow/hooks'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { BlockEnum } from '@/app/components/workflow/types'
import { useAllWorkflowTools } from '@/service/use-tools'
import { FlowType } from '@/types/common'
import ChangeBlock from '../change-block'
import PanelOperatorPopup from '../panel-operator-popup'
vi.mock('@/app/components/workflow/block-selector', () => ({
default: ({ trigger, onSelect, availableBlocksTypes, showStartTab, ignoreNodeIds, forceEnableStartTab, allowUserInputSelection }: any) => (
<div>
<div>{trigger()}</div>
<div>{`available:${(availableBlocksTypes || []).join(',')}`}</div>
<div>{`show-start:${String(showStartTab)}`}</div>
<div>{`ignore:${(ignoreNodeIds || []).join(',')}`}</div>
<div>{`force-start:${String(forceEnableStartTab)}`}</div>
<div>{`allow-start:${String(allowUserInputSelection)}`}</div>
<button type="button" onClick={() => onSelect(BlockEnum.HttpRequest)}>select-http</button>
</div>
),
}))
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
return {
...actual,
useAvailableBlocks: vi.fn(),
useIsChatMode: vi.fn(),
useNodeDataUpdate: vi.fn(),
useNodeMetaData: vi.fn(),
useNodesInteractions: vi.fn(),
useNodesReadOnly: vi.fn(),
useNodesSyncDraft: vi.fn(),
}
})
vi.mock('@/app/components/workflow/hooks-store', () => ({
useHooksStore: vi.fn(),
}))
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
default: vi.fn(),
}))
vi.mock('@/service/use-tools', () => ({
useAllWorkflowTools: vi.fn(),
}))
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
const mockUseIsChatMode = vi.mocked(useIsChatMode)
const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate)
const mockUseNodeMetaData = vi.mocked(useNodeMetaData)
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
const mockUseNodesSyncDraft = vi.mocked(useNodesSyncDraft)
const mockUseHooksStore = vi.mocked(useHooksStore)
const mockUseNodes = vi.mocked(useNodes)
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
describe('panel-operator details', () => {
const handleNodeChange = vi.fn()
const handleNodeDelete = vi.fn()
const handleNodesDuplicate = vi.fn()
const handleNodeSelect = vi.fn()
const handleNodesCopy = vi.fn()
const handleNodeDataUpdate = vi.fn()
const handleSyncWorkflowDraft = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseAvailableBlocks.mockReturnValue({
getAvailableBlocks: vi.fn(() => ({
availablePrevBlocks: [BlockEnum.HttpRequest],
availableNextBlocks: [BlockEnum.HttpRequest],
})),
availablePrevBlocks: [BlockEnum.HttpRequest],
availableNextBlocks: [BlockEnum.HttpRequest],
} as ReturnType<typeof useAvailableBlocks>)
mockUseIsChatMode.mockReturnValue(false)
mockUseNodeDataUpdate.mockReturnValue({
handleNodeDataUpdate,
handleNodeDataUpdateWithSyncDraft: vi.fn(),
})
mockUseNodeMetaData.mockReturnValue({
isTypeFixed: false,
isSingleton: false,
isUndeletable: false,
description: 'Node description',
author: 'Dify',
helpLinkUri: 'https://docs.example.com/node',
} as ReturnType<typeof useNodeMetaData>)
mockUseNodesInteractions.mockReturnValue({
handleNodeChange,
handleNodeDelete,
handleNodesDuplicate,
handleNodeSelect,
handleNodesCopy,
} as unknown as ReturnType<typeof useNodesInteractions>)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false } as ReturnType<typeof useNodesReadOnly>)
mockUseNodesSyncDraft.mockReturnValue({
doSyncWorkflowDraft: vi.fn(),
handleSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose: vi.fn(),
} as ReturnType<typeof useNodesSyncDraft>)
mockUseHooksStore.mockImplementation((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
mockUseNodes.mockReturnValue([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
mockUseAllWorkflowTools.mockReturnValue({ data: [] } as any)
})
// The panel operator internals should expose block-change and popup actions using the real workflow popup composition.
describe('Internal Actions', () => {
it('should select a replacement block through ChangeBlock', async () => {
const user = userEvent.setup()
render(
<ChangeBlock
nodeId="node-1"
nodeData={{ type: BlockEnum.Code } as any}
sourceHandle="source"
/>,
)
await user.click(screen.getByText('select-http'))
expect(screen.getByText('available:http-request')).toBeInTheDocument()
expect(screen.getByText('show-start:true')).toBeInTheDocument()
expect(screen.getByText('ignore:')).toBeInTheDocument()
expect(screen.getByText('force-start:false')).toBeInTheDocument()
expect(screen.getByText('allow-start:false')).toBeInTheDocument()
expect(handleNodeChange).toHaveBeenCalledWith('node-1', BlockEnum.HttpRequest, 'source', undefined)
})
it('should expose trigger and start-node specific block selector options', () => {
mockUseAvailableBlocks.mockReturnValueOnce({
getAvailableBlocks: vi.fn(() => ({
availablePrevBlocks: [],
availableNextBlocks: [BlockEnum.HttpRequest],
})),
availablePrevBlocks: [],
availableNextBlocks: [BlockEnum.HttpRequest],
} as ReturnType<typeof useAvailableBlocks>)
mockUseIsChatMode.mockReturnValueOnce(true)
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.appFlow } }))
mockUseNodes.mockReturnValueOnce([] as any)
const { rerender } = render(
<ChangeBlock
nodeId="trigger-node"
nodeData={{ type: BlockEnum.TriggerWebhook } as any}
sourceHandle="source"
/>,
)
expect(screen.getByText('available:http-request')).toBeInTheDocument()
expect(screen.getByText('show-start:true')).toBeInTheDocument()
expect(screen.getByText('ignore:trigger-node')).toBeInTheDocument()
expect(screen.getByText('allow-start:true')).toBeInTheDocument()
mockUseAvailableBlocks.mockReturnValueOnce({
getAvailableBlocks: vi.fn(() => ({
availablePrevBlocks: [BlockEnum.Code],
availableNextBlocks: [],
})),
availablePrevBlocks: [BlockEnum.Code],
availableNextBlocks: [],
} as ReturnType<typeof useAvailableBlocks>)
mockUseHooksStore.mockImplementationOnce((selector: any) => selector({ configsMap: { flowType: FlowType.ragPipeline } }))
mockUseNodes.mockReturnValueOnce([{ id: 'start', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } as any }] as any)
rerender(
<ChangeBlock
nodeId="start-node"
nodeData={{ type: BlockEnum.Start } as any}
sourceHandle="source"
/>,
)
expect(screen.getByText('available:code')).toBeInTheDocument()
expect(screen.getByText('show-start:false')).toBeInTheDocument()
expect(screen.getByText('ignore:start-node')).toBeInTheDocument()
expect(screen.getByText('force-start:true')).toBeInTheDocument()
})
it('should run, copy, duplicate, delete, and expose the help link in the popup', async () => {
const user = userEvent.setup()
renderWorkflowFlowComponent(
<PanelOperatorPopup
id="node-1"
data={{ type: BlockEnum.Code, title: 'Code Node', desc: '' } as any}
onClosePopup={vi.fn()}
showHelpLink
/>,
{
nodes: [],
edges: [{ id: 'edge-1', source: 'node-0', target: 'node-1', sourceHandle: 'branch-a' }],
},
)
await user.click(screen.getByText('workflow.panel.runThisStep'))
await user.click(screen.getByText('workflow.common.copy'))
await user.click(screen.getByText('workflow.common.duplicate'))
await user.click(screen.getByText('common.operation.delete'))
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
expect(handleNodeDataUpdate).toHaveBeenCalledWith({ id: 'node-1', data: { _isSingleRun: true } })
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
expect(handleNodesCopy).toHaveBeenCalledWith('node-1')
expect(handleNodesDuplicate).toHaveBeenCalledWith('node-1')
expect(handleNodeDelete).toHaveBeenCalledWith('node-1')
expect(screen.getByRole('link', { name: 'workflow.panel.helpLink' })).toHaveAttribute('href', 'https://docs.example.com/node')
})
it('should render workflow-tool and readonly popup variants', () => {
mockUseAllWorkflowTools.mockReturnValueOnce({
data: [{ id: 'workflow-tool', workflow_app_id: 'app-123' }],
} as any)
const { rerender } = renderWorkflowFlowComponent(
<PanelOperatorPopup
id="node-2"
data={{ type: BlockEnum.Tool, title: 'Workflow Tool', desc: '', provider_type: 'workflow', provider_id: 'workflow-tool' } as any}
onClosePopup={vi.fn()}
showHelpLink={false}
/>,
{
nodes: [],
edges: [],
},
)
expect(screen.getByRole('link', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute('href', '/app/app-123/workflow')
mockUseNodesReadOnly.mockReturnValueOnce({ nodesReadOnly: true } as ReturnType<typeof useNodesReadOnly>)
mockUseNodeMetaData.mockReturnValueOnce({
isTypeFixed: true,
isSingleton: true,
isUndeletable: true,
description: 'Read only node',
author: 'Dify',
} as ReturnType<typeof useNodeMetaData>)
rerender(
<PanelOperatorPopup
id="node-3"
data={{ type: BlockEnum.End, title: 'Read only node', desc: '' } as any}
onClosePopup={vi.fn()}
showHelpLink={false}
/>,
)
expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.common.copy')).not.toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,52 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SupportVarInput from '../index'
describe('SupportVarInput', () => {
it('should render plain text, highlighted variables, and preserved line breaks', () => {
render(<SupportVarInput value={'Hello {{user_name}}\nWorld'} />)
expect(screen.getByText('World').closest('[title]')).toHaveAttribute('title', 'Hello {{user_name}}\nWorld')
expect(screen.getByText('user_name')).toBeInTheDocument()
expect(screen.getByText('Hello')).toBeInTheDocument()
expect(screen.getByText('World')).toBeInTheDocument()
})
it('should show the focused child content and call onFocus when activated', async () => {
const user = userEvent.setup()
const onFocus = vi.fn()
render(
<SupportVarInput
isFocus
value="draft"
onFocus={onFocus}
>
<input aria-label="inline-editor" />
</SupportVarInput>,
)
const editor = screen.getByRole('textbox', { name: 'inline-editor' })
expect(editor).toBeInTheDocument()
expect(screen.queryByTitle('draft')).not.toBeInTheDocument()
await user.click(editor)
expect(onFocus).toHaveBeenCalledTimes(1)
})
it('should keep the static preview visible when the input is read-only', () => {
render(
<SupportVarInput
isFocus
readonly
value="readonly content"
>
<input aria-label="hidden-editor" />
</SupportVarInput>,
)
expect(screen.queryByRole('textbox', { name: 'hidden-editor' })).not.toBeInTheDocument()
expect(screen.getByTitle('readonly content')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,72 @@
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import { VarType } from '@/app/components/workflow/types'
import AssignedVarReferencePopup from '../assigned-var-reference-popup'
const mockVarReferenceVars = vi.fn()
vi.mock('../var-reference-vars', () => ({
default: ({
vars,
onChange,
itemWidth,
isSupportFileVar,
}: {
vars: NodeOutPutVar[]
onChange: (value: ValueSelector, item: Var) => void
itemWidth?: number
isSupportFileVar?: boolean
}) => {
mockVarReferenceVars({ vars, onChange, itemWidth, isSupportFileVar })
return <div data-testid="var-reference-vars">{vars.length}</div>
},
}))
const createOutputVar = (overrides: Partial<NodeOutPutVar> = {}): NodeOutPutVar => ({
nodeId: 'node-1',
title: 'Node One',
vars: [{
variable: 'answer',
type: VarType.string,
}],
...overrides,
})
describe('AssignedVarReferencePopup', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the empty state when there are no assigned variables', () => {
render(
<AssignedVarReferencePopup
vars={[]}
onChange={vi.fn()}
/>,
)
expect(screen.getByText('workflow.nodes.assigner.noAssignedVars')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.assignedVarsDescription')).toBeInTheDocument()
expect(screen.queryByTestId('var-reference-vars')).not.toBeInTheDocument()
})
it('should delegate populated variable lists to the variable picker with file support enabled', () => {
const onChange = vi.fn()
render(
<AssignedVarReferencePopup
vars={[createOutputVar()]}
itemWidth={280}
onChange={onChange}
/>,
)
expect(screen.getByTestId('var-reference-vars')).toHaveTextContent('1')
expect(mockVarReferenceVars).toHaveBeenCalledWith({
vars: [createOutputVar()],
onChange,
itemWidth: 280,
isSupportFileVar: true,
})
})
})

View File

@@ -1,6 +1,11 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { VariableLabelInNode, VariableLabelInText } from '../index'
import VariableIcon from '../base/variable-icon'
import VariableLabel from '../base/variable-label'
import VariableName from '../base/variable-name'
import VariableNodeLabel from '../base/variable-node-label'
import { VariableIconWithColor, VariableLabelInEditor, VariableLabelInNode, VariableLabelInSelect, VariableLabelInText } from '../index'
describe('variable-label index', () => {
beforeEach(() => {
@@ -39,5 +44,96 @@ describe('variable-label index', () => {
expect(screen.getByText('Source Node')).toBeInTheDocument()
expect(screen.getByText('answer')).toBeInTheDocument()
})
it('should render the select variant with the full variable path', () => {
render(
<VariableLabelInSelect
nodeType={BlockEnum.Code}
nodeTitle="Source Node"
variables={['source-node', 'payload', 'answer']}
/>,
)
expect(screen.getByText('payload.answer')).toBeInTheDocument()
})
it('should render the editor variant with selected styles and inline error feedback', async () => {
const user = userEvent.setup()
const { container } = render(
<VariableLabelInEditor
nodeType={BlockEnum.Code}
nodeTitle="Source Node"
variables={['source-node', 'payload']}
isSelected
errorMsg="Invalid variable"
rightSlot={<span>suffix</span>}
/>,
)
const badge = screen.getByText('payload').closest('div')
expect(badge).toBeInTheDocument()
expect(screen.getByText('suffix')).toBeInTheDocument()
await user.hover(screen.getByText('payload'))
expect(container.querySelector('[data-icon="Warning"]')).not.toBeNull()
})
it('should render the icon helpers for environment and exception variables', () => {
const { container } = render(
<div>
<VariableIcon variables={['env', 'API_KEY']} />
<VariableIconWithColor
variables={['conversation', 'message']}
isExceptionVariable
/>
</div>,
)
expect(container.querySelectorAll('svg').length).toBeGreaterThan(0)
})
it('should render the base variable name with shortened path and title', () => {
render(
<VariableName
variables={['node-id', 'payload', 'answer']}
notShowFullPath
/>,
)
expect(screen.getByText('answer')).toHaveAttribute('title', 'answer')
})
it('should render the base node label only when node type exists', () => {
const { container, rerender } = render(<VariableNodeLabel />)
expect(container).toBeEmptyDOMElement()
rerender(
<VariableNodeLabel
nodeType={BlockEnum.Code}
nodeTitle="Code Node"
/>,
)
expect(screen.getByText('Code Node')).toBeInTheDocument()
})
it('should render the base label with variable type and right slot', () => {
render(
<VariableLabel
nodeType={BlockEnum.Code}
nodeTitle="Source Node"
variables={['sys', 'query']}
variableType={VarType.string}
rightSlot={<span>slot</span>}
/>,
)
expect(screen.getByText('Source Node')).toBeInTheDocument()
expect(screen.getByText('query')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
expect(screen.getByText('slot')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,340 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { AgentNodeType } from '../types'
import type { StrategyParamItem } from '@/app/components/plugins/types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FormTypeEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { BlockEnum } from '@/app/components/workflow/types'
import { VarType as ToolVarType } from '../../tool/types'
import { ModelBar } from '../components/model-bar'
import { ToolIcon } from '../components/tool-icon'
import Node from '../node'
import Panel from '../panel'
import { AgentFeature } from '../types'
import useConfig from '../use-config'
let mockTextGenerationModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
let mockModerationModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
let mockRerankModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
let mockSpeech2TextModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
let mockTextEmbeddingModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
let mockTtsModels: Array<{ provider: string, models: Array<{ model: string }> }> | undefined = []
let mockBuiltInTools: Array<any> | undefined = []
let mockCustomTools: Array<any> | undefined = []
let mockWorkflowTools: Array<any> | undefined = []
let mockMcpTools: Array<any> | undefined = []
let mockMarketplaceIcon: string | Record<string, string> | undefined
const mockResetEditor = vi.fn()
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: (modelType: ModelTypeEnum) => {
if (modelType === ModelTypeEnum.textGeneration)
return { data: mockTextGenerationModels }
if (modelType === ModelTypeEnum.moderation)
return { data: mockModerationModels }
if (modelType === ModelTypeEnum.rerank)
return { data: mockRerankModels }
if (modelType === ModelTypeEnum.speech2text)
return { data: mockSpeech2TextModels }
if (modelType === ModelTypeEnum.textEmbedding)
return { data: mockTextEmbeddingModels }
return { data: mockTtsModels }
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({ defaultModel, modelList }: any) => (
<div>{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}:{modelList.length}</div>
),
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: any) => <div>{`indicator:${color}`}</div>,
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: mockBuiltInTools }),
useAllCustomTools: () => ({ data: mockCustomTools }),
useAllWorkflowTools: () => ({ data: mockWorkflowTools }),
useAllMCPTools: () => ({ data: mockMcpTools }),
}))
vi.mock('@/app/components/base/app-icon', () => ({
default: ({ icon, background }: any) => <div>{`app-icon:${background}:${icon}`}</div>,
}))
vi.mock('@/app/components/base/icons/src/vender/other', () => ({
Group: () => <div>group-icon</div>,
}))
vi.mock('@/utils/get-icon', () => ({
getIconFromMarketPlace: () => mockMarketplaceIcon,
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (value: string) => value,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/group', () => ({
Group: ({ label, children }: any) => <div><div>{label}</div>{children}</div>,
GroupLabel: ({ className, children }: any) => <div className={className}>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/setting-item', () => ({
SettingItem: ({ label, status, tooltip, children }: any) => <div>{label}:{status}:{tooltip}:{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, children }: any) => <div><div>{title}</div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/agent-strategy', () => ({
AgentStrategy: ({ onStrategyChange }: any) => (
<button
type="button"
onClick={() => onStrategyChange({
agent_strategy_provider_name: 'provider/updated',
agent_strategy_name: 'updated-strategy',
agent_strategy_label: 'Updated Strategy',
agent_output_schema: { properties: { extra: { type: 'string', description: 'extra output' } } },
plugin_unique_identifier: 'provider/updated:1.0.0',
meta: { version: '2.0.0' },
})}
>
change-strategy
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({
MCPToolAvailabilityProvider: ({ children }: any) => <div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/memory-config', () => ({
default: ({ onChange }: any) => <button type="button" onClick={() => onChange({ window: { enabled: true, size: 8 }, query_prompt_template: 'history' })}>change-memory</button>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: ({ children }: any) => <div>{children}</div>,
VarItem: ({ name, type, description }: any) => <div>{`${name}:${type}:${description}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { setControlPromptEditorRerenderKey: typeof mockResetEditor }) => unknown) => selector({
setControlPromptEditorRerenderKey: mockResetEditor,
}),
}))
vi.mock('@/utils/plugin-version-feature', () => ({
isSupportMCP: () => true,
}))
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createStrategyParam = (
name: string,
type: FormTypeEnum,
required: boolean,
): StrategyParamItem => ({
name,
type,
required,
label: { en_US: name } as StrategyParamItem['label'],
help: { en_US: `${name} help` } as StrategyParamItem['help'],
placeholder: { en_US: `${name} placeholder` } as StrategyParamItem['placeholder'],
scope: 'global',
default: null,
options: [],
template: { enabled: false },
auto_generate: { type: 'none' },
})
const createData = (overrides: Partial<AgentNodeType> = {}): AgentNodeType => ({
title: 'Agent',
desc: '',
type: BlockEnum.Agent,
output_schema: {},
agent_strategy_provider_name: 'provider/agent',
agent_strategy_name: 'react',
agent_strategy_label: 'React Agent',
agent_parameters: {
modelParam: { type: ToolVarType.constant, value: { provider: 'openai', model: 'gpt-4o' } },
toolParam: { type: ToolVarType.constant, value: { provider_name: 'author/tool-a' } },
multiToolParam: { type: ToolVarType.constant, value: [{ provider_name: 'author/tool-b' }] },
},
meta: { version: '1.0.0' } as any,
plugin_unique_identifier: 'provider/agent:1.0.0',
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
setInputs: vi.fn(),
handleVarListChange: vi.fn(),
handleAddVariable: vi.fn(),
currentStrategy: {
identity: {
author: 'provider',
name: 'react',
icon: 'icon',
label: { en_US: 'React Agent' } as any,
provider: 'provider/agent',
},
parameters: [
createStrategyParam('modelParam', FormTypeEnum.modelSelector, true),
createStrategyParam('optionalModel', FormTypeEnum.modelSelector, false),
createStrategyParam('toolParam', FormTypeEnum.toolSelector, false),
createStrategyParam('multiToolParam', FormTypeEnum.multiToolSelector, false),
],
description: { en_US: 'agent description' } as any,
output_schema: {},
features: [AgentFeature.HISTORY_MESSAGES],
},
formData: {},
onFormChange: vi.fn(),
currentStrategyStatus: {
plugin: { source: 'marketplace', installed: true },
isExistInPlugin: false,
},
strategyProvider: undefined,
pluginDetail: {
declaration: {
label: 'Mock Plugin',
},
} as any,
availableVars: [],
availableNodesWithParent: [],
outputSchema: [{ name: 'jsonField', type: 'String', description: 'json output' }],
handleMemoryChange: vi.fn(),
isChatMode: true,
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('agent path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTextGenerationModels = [{ provider: 'openai', models: [{ model: 'gpt-4o' }] }]
mockModerationModels = []
mockRerankModels = []
mockSpeech2TextModels = []
mockTextEmbeddingModels = []
mockTtsModels = []
mockBuiltInTools = [{ name: 'author/tool-a', is_team_authorization: true, icon: 'https://example.com/icon-a.png' }]
mockCustomTools = []
mockWorkflowTools = [{ id: 'author/tool-b', is_team_authorization: false, icon: { content: 'B', background: '#fff' } }]
mockMcpTools = []
mockMarketplaceIcon = 'https://example.com/marketplace.png'
mockUseConfig.mockReturnValue(createConfigResult())
})
describe('Path Integration', () => {
it('should render model bars for missing, installed, and missing-install models', () => {
const { rerender, container } = render(<ModelBar />)
expect(container).toHaveTextContent('no-model:0')
expect(screen.getByText('indicator:red')).toBeInTheDocument()
rerender(<ModelBar provider="openai" model="gpt-4o" />)
expect(container).toHaveTextContent('openai/gpt-4o:1')
expect(screen.queryByText('indicator:red')).not.toBeInTheDocument()
rerender(<ModelBar provider="openai" model="gpt-4.1" />)
expect(container).toHaveTextContent('openai/gpt-4.1:1')
expect(screen.getByText('indicator:red')).toBeInTheDocument()
})
it('should render tool icons across loading, marketplace fallback, authorization warning, and fetch-error states', async () => {
const user = userEvent.setup()
const { unmount } = render(<ToolIcon id="tool-0" providerName="author/tool-a" />)
expect(screen.getByRole('img', { name: 'tool icon' })).toBeInTheDocument()
fireEvent.error(screen.getByRole('img', { name: 'tool icon' }))
expect(screen.getByText('group-icon')).toBeInTheDocument()
unmount()
const secondRender = render(<ToolIcon id="tool-1" providerName="author/tool-b" />)
expect(screen.getByText('app-icon:#fff:B')).toBeInTheDocument()
expect(screen.getByText('indicator:yellow')).toBeInTheDocument()
mockBuiltInTools = undefined
secondRender.rerender(<ToolIcon id="tool-2" providerName="author/tool-c" />)
expect(screen.getByText('group-icon')).toBeInTheDocument()
mockBuiltInTools = []
secondRender.rerender(<ToolIcon id="tool-3" providerName="market/tool-d" />)
expect(screen.getByRole('img', { name: 'tool icon' })).toBeInTheDocument()
await user.unhover(screen.getByRole('img', { name: 'tool icon' }))
})
it('should render strategy, models, and toolbox entries in the node', () => {
const { container } = render(
<Node
id="agent-node"
data={createData()}
/>,
)
expect(screen.getByText(/workflow\.nodes\.agent\.strategy\.shortLabel/)).toBeInTheDocument()
expect(container).toHaveTextContent('React Agent')
expect(screen.getByText('workflow.nodes.agent.model')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.agent.toolbox')).toBeInTheDocument()
expect(container).toHaveTextContent('openai/gpt-4o:1')
expect(screen.getByText('indicator:yellow')).toBeInTheDocument()
})
it('should render the panel, update the selected strategy, and expose memory plus output vars', async () => {
const user = userEvent.setup()
const config = createConfigResult()
mockUseConfig.mockReturnValue(config)
render(
<Panel
id="agent-node"
data={createData()}
panelProps={panelProps}
/>,
)
expect(screen.getByText('workflow.nodes.agent.strategy.label')).toBeInTheDocument()
expect(screen.getByText('text:String:workflow.nodes.agent.outputVars.text')).toBeInTheDocument()
expect(screen.getByText('jsonField:String:json output')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'change-strategy' }))
expect(config.setInputs).toHaveBeenCalledWith(expect.objectContaining({
agent_strategy_provider_name: 'provider/updated',
agent_strategy_name: 'updated-strategy',
agent_strategy_label: 'Updated Strategy',
plugin_unique_identifier: 'provider/updated:1.0.0',
}))
expect(mockResetEditor).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: 'change-memory' }))
expect(config.handleMemoryChange).toHaveBeenCalledWith({
window: { enabled: true, size: 8 },
query_prompt_template: 'history',
})
})
})
})

View File

@@ -0,0 +1,514 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { AssignerNodeOperation, AssignerNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import OperationSelector from '../components/operation-selector'
import VarList from '../components/var-list'
import Node from '../node'
import Panel from '../panel'
import { AssignerNodeInputType, WriteMode, writeModeTypesNum } from '../types'
import useConfig from '../use-config'
const mockHandleAddOperationItem = vi.fn()
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, operations, children }: any) => <div><div>{title}</div><div>{operations}</div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/list-no-data-placeholder', () => ({
default: ({ children }: any) => <div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ value, onChange, onOpen, placeholder, popupFor, valueTypePlaceHolder, filterVar }: any) => (
<div>
<div>{Array.isArray(value) ? value.join('.') : String(value ?? '')}</div>
{valueTypePlaceHolder && <div>{`type:${valueTypePlaceHolder}`}</div>}
{popupFor === 'toAssigned' && (
<div>{`filter:${String(filterVar?.({ nodeId: 'node-1', variable: 'count', type: VarType.string }))}:${String(filterVar?.({ nodeId: 'node-2', variable: 'other', type: VarType.string }))}`}</div>
)}
<button
type="button"
onClick={() => {
onOpen?.()
onChange(popupFor === 'assigned' ? ['node-1', 'count'] : ['node-2', 'result'])
}}
>
{placeholder || popupFor || 'pick-var'}
</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ value, onChange }: any) => (
<textarea
aria-label="code-editor"
value={value}
onChange={event => onChange(event.target.value)}
/>
),
}))
vi.mock('@/app/components/workflow/panel/chat-variable-panel/components/bool-value', () => ({
default: ({ value, onChange }: any) => (
<button type="button" onClick={() => onChange(!value)}>
{`bool:${String(value)}`}
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
VariableLabelInNode: ({ variables, nodeTitle, rightSlot }: any) => (
<div>
<span>{nodeTitle}</span>
<span>{variables.join('.')}</span>
{rightSlot}
</div>
),
}))
vi.mock('../hooks', () => ({
useHandleAddOperationItem: () => mockHandleAddOperationItem,
}))
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createOperation = (overrides: Partial<AssignerNodeOperation> = {}): AssignerNodeOperation => ({
variable_selector: ['node-1', 'count'],
input_type: AssignerNodeInputType.variable,
operation: WriteMode.overwrite,
value: ['node-2', 'result'],
...overrides,
})
const createData = (overrides: Partial<AssignerNodeType> = {}): AssignerNodeType => ({
title: 'Assigner',
desc: '',
type: BlockEnum.VariableAssigner,
version: '2',
items: [createOperation()],
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
handleOperationListChanges: vi.fn(),
getAssignedVarType: vi.fn(() => VarType.string),
getToAssignedVarType: vi.fn(() => VarType.string),
writeModeTypes: [WriteMode.overwrite, WriteMode.clear, WriteMode.set],
writeModeTypesArr: [WriteMode.overwrite, WriteMode.clear, WriteMode.append, WriteMode.extend],
writeModeTypesNum,
filterAssignedVar: vi.fn(() => true),
filterToAssignedVar: vi.fn(() => true),
getAvailableVars: vi.fn(() => []),
filterVar: vi.fn(() => vi.fn(() => true)),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('assigner path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHandleAddOperationItem.mockReturnValue([createOperation(), createOperation({ variable_selector: [] })])
mockUseConfig.mockReturnValue(createConfigResult())
})
describe('Path Integration', () => {
it('should open the operation selector and choose number operations', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<OperationSelector
value={WriteMode.overwrite}
onSelect={onSelect}
assignedVarType={VarType.number}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={[WriteMode.increment]}
/>,
)
await user.click(screen.getByText('workflow.nodes.assigner.operations.over-write'))
expect(screen.getByText('workflow.nodes.assigner.operations.clear')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.operations.set')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.operations.+=')).toBeInTheDocument()
await user.click(screen.getByText('workflow.nodes.assigner.operations.+='))
expect(onSelect).toHaveBeenCalledWith({ value: WriteMode.increment, name: WriteMode.increment })
})
it('should not open a disabled operation selector', async () => {
const user = userEvent.setup()
render(
<OperationSelector
value={WriteMode.overwrite}
onSelect={vi.fn()}
disabled
assignedVarType={VarType.string}
writeModeTypes={[WriteMode.overwrite]}
/>,
)
await user.click(screen.getByText('workflow.nodes.assigner.operations.over-write'))
expect(screen.queryByText('workflow.nodes.assigner.operations.title')).not.toBeInTheDocument()
})
it('should render empty and populated variable lists across constant editors', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onOpen = vi.fn()
const { rerender } = render(
<VarList
readonly={false}
nodeId="node-1"
list={[]}
onChange={onChange}
/>,
)
expect(screen.getByText('workflow.nodes.assigner.noVarTip')).toBeInTheDocument()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ variable_selector: [], value: [] })]}
onChange={onChange}
onOpen={onOpen}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.string)}
getToAssignedVarType={vi.fn(() => VarType.string)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
await user.click(screen.getByText('workflow.nodes.assigner.selectAssignedVariable'))
expect(onOpen).toHaveBeenCalledWith(0)
expect(onChange).toHaveBeenLastCalledWith([
{
variable_selector: ['node-1', 'count'],
operation: WriteMode.overwrite,
input_type: AssignerNodeInputType.variable,
value: undefined,
},
], ['node-1', 'count'])
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.overwrite, value: ['node-2', 'result'] })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.boolean)}
getToAssignedVarType={vi.fn(() => VarType.string)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
expect(screen.getByText('filter:false:true')).toBeInTheDocument()
await user.click(screen.getByText('workflow.nodes.assigner.operations.over-write'))
await user.click(screen.getByText('workflow.nodes.assigner.operations.set'))
expect(onChange).toHaveBeenLastCalledWith([
createOperation({
operation: WriteMode.set,
input_type: AssignerNodeInputType.constant,
value: false,
}),
])
onChange.mockClear()
await user.click(screen.getByText('workflow.nodes.assigner.setParameter'))
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.overwrite, value: ['node-2', 'result'] }),
], ['node-2', 'result'])
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.set, value: 'hello' })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.string)}
getToAssignedVarType={vi.fn(() => VarType.string)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated text' } })
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.set, value: 'updated text' }),
], 'updated text')
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.set, value: 3 })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.number)}
getToAssignedVarType={vi.fn(() => VarType.number)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
fireEvent.change(screen.getByDisplayValue('3'), { target: { value: '5' } })
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.set, value: 5 }),
], 5)
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.set, value: false })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.boolean)}
getToAssignedVarType={vi.fn(() => VarType.boolean)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
await user.click(screen.getByRole('button', { name: 'bool:false' }))
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.set, value: true }),
], true)
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.set, value: '{\"a\":1}' })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.object)}
getToAssignedVarType={vi.fn(() => VarType.object)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
fireEvent.change(screen.getByLabelText('code-editor'), { target: { value: '{\"a\":2}' } })
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.set, value: '{\"a\":2}' }),
], '{"a":2}')
onChange.mockClear()
rerender(
<VarList
readonly={false}
nodeId="node-1"
list={[createOperation({ operation: WriteMode.increment, value: 2 })]}
onChange={onChange}
filterVar={vi.fn(() => true)}
filterToAssignedVar={vi.fn(() => true)}
getAssignedVarType={vi.fn(() => VarType.number)}
getToAssignedVarType={vi.fn(() => VarType.number)}
writeModeTypes={[WriteMode.overwrite, WriteMode.clear, WriteMode.set]}
writeModeTypesArr={[WriteMode.overwrite, WriteMode.clear]}
writeModeTypesNum={writeModeTypesNum}
/>,
)
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '4' } })
expect(onChange).toHaveBeenLastCalledWith([
createOperation({ operation: WriteMode.increment, value: 4 }),
], 4)
const buttons = screen.getAllByRole('button')
await user.click(buttons.at(-1)!)
expect(onChange).toHaveBeenLastCalledWith([])
})
it('should render version 2 and legacy node previews', () => {
const { rerender } = renderWorkflowFlowComponent(
<Node
id="assigner-node"
data={createData({
items: [createOperation({ variable_selector: [] })],
})}
/>,
{
nodes: [
{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Answer', type: BlockEnum.Answer } as any },
{ id: 'start', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } as any },
],
edges: [],
},
)
expect(screen.getByText('workflow.nodes.assigner.varNotSet')).toBeInTheDocument()
rerender(
<Node
id="assigner-node"
data={createData({
items: [createOperation()],
})}
/>,
)
expect(screen.getByText('Answer')).toBeInTheDocument()
expect(screen.getByText('node-1.count')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.operations.over-write')).toBeInTheDocument()
rerender(
<Node
id="assigner-node"
data={{
title: 'Legacy Assigner',
desc: '',
type: BlockEnum.VariableAssigner,
assigned_variable_selector: ['sys', 'query'],
write_mode: WriteMode.append,
} as any}
/>,
)
expect(screen.getByText('Start')).toBeInTheDocument()
expect(screen.getByText('sys.query')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.operations.append')).toBeInTheDocument()
})
it('should skip empty version 2 items and resolve system variables without an operation badge', () => {
renderWorkflowFlowComponent(
<Node
id="assigner-node"
data={createData({
items: [
createOperation({ variable_selector: [] }),
createOperation({
variable_selector: ['sys', 'query'],
operation: undefined,
}),
],
})}
/>,
{
nodes: [
{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Answer', type: BlockEnum.Answer } as any },
{ id: 'start', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } as any },
],
edges: [],
},
)
expect(screen.getByText('Start')).toBeInTheDocument()
expect(screen.getByText('sys.query')).toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.assigner.operations.over-write')).not.toBeInTheDocument()
})
it('should return null for legacy nodes without assigned variables and resolve non-system legacy vars', () => {
const { rerender } = renderWorkflowFlowComponent(
<Node
id="assigner-node"
data={{
title: 'Legacy Assigner',
desc: '',
type: BlockEnum.VariableAssigner,
assigned_variable_selector: [],
write_mode: WriteMode.append,
} as any}
/>,
{
nodes: [
{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Answer', type: BlockEnum.Answer } as any },
{ id: 'start', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } as any },
],
edges: [],
},
)
expect(screen.queryByText('workflow.nodes.assigner.operations.append')).not.toBeInTheDocument()
expect(screen.queryByText('node-1.count')).not.toBeInTheDocument()
rerender(
<Node
id="assigner-node"
data={{
title: 'Legacy Assigner',
desc: '',
type: BlockEnum.VariableAssigner,
assigned_variable_selector: ['node-1', 'count'],
write_mode: WriteMode.append,
} as any}
/>,
)
expect(screen.getByText('Answer')).toBeInTheDocument()
expect(screen.getByText('node-1.count')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.assigner.operations.append')).toBeInTheDocument()
})
it('should add panel operations with the real variable list inside the panel', async () => {
const user = userEvent.setup()
const config = createConfigResult({
inputs: createData(),
})
mockUseConfig.mockReturnValue(config)
render(
<Panel
id="assigner-node"
data={createData()}
panelProps={panelProps}
/>,
)
await user.click(screen.getAllByRole('button')[0])
expect(mockHandleAddOperationItem).toHaveBeenCalledWith(createData().items)
expect(config.handleOperationListChanges).toHaveBeenCalledWith([
createOperation(),
createOperation({ variable_selector: [] }),
])
expect(screen.getByText('workflow.nodes.assigner.variables')).toBeInTheDocument()
expect(screen.getByText('node-1.count')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,39 @@
import type { CodeDependency } from '../types'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import DependencyPicker from '../dependency-picker'
const dependencies: CodeDependency[] = [
{ name: 'numpy', version: '1.0.0' },
{ name: 'pandas', version: '2.0.0' },
]
describe('DependencyPicker', () => {
it('should open the dependency list, filter by search text, and select a new dependency', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<DependencyPicker
value={dependencies[0]!}
available_dependencies={dependencies}
onChange={onChange}
/>,
)
expect(screen.getByText('numpy')).toBeInTheDocument()
await user.click(screen.getByText('numpy'))
await user.type(screen.getByRole('textbox'), 'pan')
expect(screen.getByRole('textbox')).toHaveValue('pan')
expect(screen.getByText('pandas')).toBeInTheDocument()
await user.click(screen.getByText('pandas'))
expect(onChange).toHaveBeenCalledWith(dependencies[1])
await waitFor(() => {
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,204 @@
import type { ReactNode } from 'react'
import type { DocExtractorNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LanguagesSupported } from '@/i18n-config/language'
import { BlockEnum } from '../../../types'
import Node from '../node'
import Panel from '../panel'
import useConfig from '../use-config'
let mockLocale = 'en-US'
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
useNodes: () => [
{
id: 'node-1',
data: {
title: 'Input Files',
type: BlockEnum.Start,
},
},
],
}
})
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
VariableLabelInNode: ({
variables,
nodeTitle,
nodeType,
}: {
variables: string[]
nodeTitle?: string
nodeType?: BlockEnum
}) => <div>{`${nodeTitle}:${nodeType}:${variables.join('.')}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
__esModule: true,
default: ({ title, children }: { title: ReactNode, children: ReactNode }) => (
<div>
<div>{title}</div>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
__esModule: true,
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
VarItem: ({ name, type }: { name: string, type: string }) => <div>{`${name}:${type}`}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
__esModule: true,
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
__esModule: true,
default: ({
onChange,
}: {
onChange: (value: string[]) => void
}) => <button type="button" onClick={() => onChange(['node-1', 'files'])}>pick-file-var</button>,
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-help-link', () => ({
useNodeHelpLink: () => 'https://docs.example.com/document-extractor',
}))
vi.mock('@/service/use-common', () => ({
useFileSupportTypes: () => ({
data: {
allowed_extensions: ['PDF', 'md', 'md', 'DOCX'],
},
}),
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => mockLocale,
}))
vi.mock('../use-config', () => ({
__esModule: true,
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createData = (overrides: Partial<DocExtractorNodeType> = {}): DocExtractorNodeType => ({
title: 'Document Extractor',
desc: '',
type: BlockEnum.DocExtractor,
variable_selector: ['node-1', 'files'],
is_array_file: false,
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
handleVarChanges: vi.fn(),
filterVar: () => true,
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('document-extractor path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLocale = 'en-US'
mockUseConfig.mockReturnValue(createConfigResult())
})
it('should render nothing when the node input variable is not configured', () => {
const { container } = render(
<Node
id="doc-node"
data={createData({
variable_selector: [],
})}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('should render the selected input variable on the node', () => {
render(
<Node
id="doc-node"
data={createData()}
/>,
)
expect(screen.getByText('workflow.nodes.docExtractor.inputVar')).toBeInTheDocument()
expect(screen.getByText('Input Files:start:node-1.files')).toBeInTheDocument()
})
it('should wire panel input changes and format supported file types for english locales', async () => {
const user = userEvent.setup()
const handleVarChanges = vi.fn()
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
is_array_file: false,
}),
handleVarChanges,
}))
render(
<Panel
id="doc-node"
data={createData()}
panelProps={panelProps}
/>,
)
await user.click(screen.getByRole('button', { name: 'pick-file-var' }))
expect(handleVarChanges).toHaveBeenCalledWith(['node-1', 'files'])
expect(screen.getByText('workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf, markdown, docx"}')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'workflow.nodes.docExtractor.learnMore' })).toHaveAttribute(
'href',
'https://docs.example.com/document-extractor',
)
expect(screen.getByText('text:string')).toBeInTheDocument()
})
it('should use chinese separators and array output types when the input is an array of files', () => {
mockLocale = LanguagesSupported[1]
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
is_array_file: true,
}),
}))
render(
<Panel
id="doc-node"
data={createData({
is_array_file: true,
})}
panelProps={panelProps}
/>,
)
expect(screen.getByText('workflow.nodes.docExtractor.supportFileTypes:{"types":"pdf、 markdown、 docx"}')).toBeInTheDocument()
expect(screen.getByText('text:array[string]')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,705 @@
/* eslint-disable ts/no-explicit-any, style/jsx-one-expression-per-line */
import type { KeyValue as HttpKeyValue, HttpNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { useStore } from '@/app/components/workflow/store'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import ApiInput from '../components/api-input'
import AuthorizationModal from '../components/authorization'
import RadioGroup from '../components/authorization/radio-group'
import EditBody from '../components/edit-body'
import KeyValue from '../components/key-value'
import BulkEdit from '../components/key-value/bulk-edit'
import KeyValueEdit from '../components/key-value/key-value-edit'
import InputItem from '../components/key-value/key-value-edit/input-item'
import KeyValueItem from '../components/key-value/key-value-edit/item'
import Timeout from '../components/timeout'
import Node from '../node'
import Panel from '../panel'
import { AuthorizationType, BodyType, Method } from '../types'
import useConfig from '../use-config'
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
default: vi.fn((_nodeId: string, options?: any) => ({
availableVars: [
{ variable: ['node-1', 'token'], type: VarType.string },
{ variable: ['node-1', 'upload'], type: VarType.file },
].filter(varPayload => options?.filterVar ? options.filterVar(varPayload) : true),
availableNodes: [],
availableNodesWithParent: [],
})),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/input-support-select-var', () => ({
default: ({ value, onChange, placeholder, className, readOnly, onFocusChange }: any) => (
<input
value={value}
placeholder={placeholder}
className={className}
readOnly={readOnly}
onFocus={() => onFocusChange?.(true)}
onBlur={() => onFocusChange?.(false)}
onChange={event => onChange(event.target.value)}
/>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, operations, children }: any) => <div><div>{title}</div><div>{operations}</div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: ({ children }: any) => <div>{children}</div>,
VarItem: ({ name, type }: any) => <div>{name}:{type}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div>split</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ onChange, filterVar, onRemove }: any) => (
<div>
<div>{`file-filter:${String(filterVar?.({ type: VarType.file }))}:${String(filterVar?.({ type: VarType.string }))}`}</div>
<button type="button" onClick={() => onChange(['node-1', 'file'])}>pick-file</button>
{onRemove && <button type="button" onClick={onRemove}>remove-file</button>}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({
default: ({ value, onChange, title }: any) => (
<div>
<div>{typeof title === 'string' ? title : 'editor'}</div>
<input value={value} onChange={event => onChange(event.target.value)} />
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/text-editor', () => ({
default: ({ value, onChange, onBlur, headerRight }: any) => (
<div>
{headerRight}
<textarea value={value} onChange={event => onChange(event.target.value)} onBlur={onBlur} />
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/readonly-input-with-select-var', () => ({
default: ({ value }: any) => <div>{value}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/selector', () => ({
default: ({ options, onChange, trigger }: any) => (
<div>
{trigger}
{options.map((option: any) => (
<button key={option.value} type="button" onClick={() => onChange(option.value)}>
{option.label}
</button>
))}
</div>
),
}))
vi.mock('../components/curl-panel', () => ({
default: () => <div>curl-panel</div>,
}))
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const mockUseStore = vi.mocked(useStore)
const createData = (overrides: Partial<HttpNodeType> = {}): HttpNodeType => ({
title: 'HTTP Request',
desc: '',
type: BlockEnum.HttpRequest,
variables: [],
method: Method.get,
url: 'https://api.example.com',
authorization: { type: AuthorizationType.none },
headers: '',
params: '',
body: { type: BodyType.none, data: [] },
timeout: { connect: 5, read: 10, write: 15 },
ssl_verify: true,
...overrides,
})
const keyValueItem: HttpKeyValue = {
id: 'kv-1',
key: 'name',
value: 'alice',
type: 'text',
}
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
isDataReady: true,
inputs: createData(),
handleVarListChange: vi.fn(),
handleAddVariable: vi.fn(),
filterVar: vi.fn(() => true),
handleMethodChange: vi.fn(),
handleUrlChange: vi.fn(),
headers: [keyValueItem],
setHeaders: vi.fn(),
addHeader: vi.fn(),
isHeaderKeyValueEdit: false,
toggleIsHeaderKeyValueEdit: vi.fn(),
params: [keyValueItem],
setParams: vi.fn(),
addParam: vi.fn(),
isParamKeyValueEdit: false,
toggleIsParamKeyValueEdit: vi.fn(),
setBody: vi.fn(),
handleSSLVerifyChange: vi.fn(),
isShowAuthorization: true,
showAuthorization: vi.fn(),
hideAuthorization: vi.fn(),
setAuthorization: vi.fn(),
setTimeout: vi.fn(),
isShowCurlPanel: true,
showCurlPanel: vi.fn(),
hideCurlPanel: vi.fn(),
handleCurlImport: vi.fn(),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
const renderPanel = (data: HttpNodeType = createData()) => (
render(<Panel id="node-1" data={data} panelProps={panelProps} />)
)
describe('http path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseStore.mockReturnValue({
HttpRequest: {
timeout: {
max_connect_timeout: 10,
max_read_timeout: 600,
max_write_timeout: 600,
},
},
} as any)
mockUseConfig.mockReturnValue(createConfigResult())
})
// The HTTP path should expose auth, request editing, key-value tables, timeout, and request preview behavior.
describe('Path Integration', () => {
it('should switch radio-group options', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<RadioGroup
options={[
{ value: 'none', label: 'None' },
{ value: 'apiKey', label: 'API Key' },
]}
value="none"
onChange={onChange}
/>,
)
await user.click(screen.getByText('API Key'))
expect(onChange).toHaveBeenCalledWith('apiKey')
})
it('should edit authorization settings and save them', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onHide = vi.fn()
render(
<AuthorizationModal
nodeId="node-1"
payload={{ type: 'apiKey', config: { type: 'custom', header: 'X-Key', api_key: 'secret' } } as any}
onChange={onChange}
isShow
onHide={onHide}
/>,
)
await user.click(screen.getByText('workflow.nodes.http.authorization.api-key'))
await user.click(screen.getByText('workflow.nodes.http.authorization.custom'))
fireEvent.change(screen.getByDisplayValue('secret'), { target: { value: 'updated-secret' } })
await user.click(screen.getByText('common.operation.save'))
expect(onChange).toHaveBeenCalled()
expect(onHide).toHaveBeenCalled()
})
it('should bootstrap api key config when auth starts without config', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<AuthorizationModal
nodeId="node-1"
payload={{ type: 'none' as any }}
onChange={onChange}
isShow
onHide={vi.fn()}
/>,
)
await user.click(screen.getByText('workflow.nodes.http.authorization.api-key'))
await user.click(screen.getByText('common.operation.save'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
type: 'api-key',
config: expect.objectContaining({
type: 'basic',
api_key: '',
}),
}))
})
it('should create custom header auth config and apply focus styles to the api key input', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<AuthorizationModal
nodeId="node-1"
payload={{ type: 'api-key' as any }}
onChange={onChange}
isShow
onHide={vi.fn()}
/>,
)
await user.click(screen.getByText('workflow.nodes.http.authorization.custom'))
const inputs = screen.getAllByRole('textbox')
fireEvent.change(inputs[0] as HTMLInputElement, { target: { value: 'X-Token' } })
fireEvent.focus(inputs[1] as HTMLInputElement)
expect(inputs[1]).toHaveClass('border-components-input-border-active')
fireEvent.change(inputs[1] as HTMLInputElement, { target: { value: 'secret-token' } })
fireEvent.blur(inputs[1] as HTMLInputElement)
await user.click(screen.getByText('common.operation.save'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
type: 'api-key',
config: expect.objectContaining({
type: 'custom',
header: 'X-Token',
api_key: 'secret-token',
}),
}))
})
it('should update method and url from the api input', async () => {
const user = userEvent.setup()
const onMethodChange = vi.fn()
const onUrlChange = vi.fn()
render(
<ApiInput
nodeId="node-1"
readonly={false}
method={'GET' as any}
onMethodChange={onMethodChange}
url="https://api.example.com"
onUrlChange={onUrlChange}
/>,
)
await user.click(screen.getByText('POST'))
fireEvent.change(screen.getByDisplayValue('https://api.example.com'), { target: { value: 'https://api.changed.com' } })
expect(onMethodChange).toHaveBeenCalled()
expect(onUrlChange).toHaveBeenCalledWith('https://api.changed.com')
})
it('should hide the method dropdown icon and use an empty placeholder in readonly mode', () => {
const { container } = render(
<ApiInput
nodeId="node-1"
readonly
method={'GET' as any}
onMethodChange={vi.fn()}
url="https://api.example.com"
onUrlChange={vi.fn()}
/>,
)
expect(container.querySelector('svg')).toBeNull()
expect(screen.getByDisplayValue('https://api.example.com')).toHaveAttribute('placeholder', '')
})
it('should update focus styling for editable inputs and show the remove action again on blur', () => {
const onChange = vi.fn()
const onRemove = vi.fn()
const { container, rerender } = render(
<InputItem
nodeId="node-1"
value="alice"
onChange={onChange}
hasRemove
onRemove={onRemove}
/>,
)
const input = screen.getByDisplayValue('alice')
fireEvent.focus(input)
expect(input).toHaveClass('bg-components-input-bg-active')
expect(container.querySelector('button')).toBeNull()
fireEvent.blur(input)
expect(container.querySelector('button')).not.toBeNull()
rerender(
<InputItem
nodeId="node-1"
value=""
onChange={onChange}
hasRemove={false}
placeholder="missing-value"
readOnly
/>,
)
expect(screen.getByText('missing-value')).toBeInTheDocument()
})
it('should clamp timeout values and propagate changes', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<Timeout
readonly={false}
nodeId="node-1"
payload={{ connect: 5, read: 10, write: 15 }}
onChange={onChange}
/>,
)
await user.click(screen.getByText('workflow.nodes.http.timeout.title'))
fireEvent.change(screen.getByDisplayValue('5'), { target: { value: '999' } })
expect(onChange).toHaveBeenCalled()
})
it('should clear timeout values to undefined and clamp low values to the minimum', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<Timeout
readonly={false}
nodeId="node-1"
payload={{ connect: 5, read: 10, write: 15 }}
onChange={onChange}
/>,
)
await user.click(screen.getByText('workflow.nodes.http.timeout.title'))
fireEvent.change(screen.getByDisplayValue('10'), { target: { value: '' } })
fireEvent.change(screen.getByDisplayValue('15'), { target: { value: '0' } })
expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({ read: undefined }))
expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({ write: 1 }))
})
it('should delegate key-value list editing and bulk editing actions', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onAdd = vi.fn()
render(
<div>
<KeyValue
readonly={false}
nodeId="node-1"
list={[keyValueItem]}
onChange={onChange}
onAdd={onAdd}
/>
<BulkEdit
value="name:alice"
onChange={onChange}
onSwitchToKeyValueEdit={onAdd}
/>
</div>,
)
fireEvent.change(screen.getAllByDisplayValue('name:alice')[0], { target: { value: 'name:bob' } })
fireEvent.blur(screen.getAllByDisplayValue('name:bob')[0])
await user.click(screen.getByText('workflow.nodes.http.keyValueEdit'))
expect(onChange).toHaveBeenCalled()
expect(onAdd).toHaveBeenCalled()
})
it('should return null when key-value edit receives a non-array list', () => {
const { container } = render(
<KeyValueEdit
readonly={false}
nodeId="node-1"
list={'invalid' as any}
onChange={vi.fn()}
onAdd={vi.fn()}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('should edit standalone input items and key-value rows', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onRemove = vi.fn()
const onAdd = vi.fn()
render(
<div>
<InputItem
nodeId="node-1"
value="alice"
onChange={onChange}
hasRemove
onRemove={onRemove}
/>
<KeyValueItem
instanceId="kv-1"
nodeId="node-1"
readonly={false}
canRemove
payload={keyValueItem}
onChange={onChange}
onRemove={onRemove}
isLastItem
onAdd={onAdd}
isSupportFile
/>
<KeyValueEdit
readonly={false}
nodeId="node-1"
list={[keyValueItem]}
onChange={onChange}
onAdd={onAdd}
/>
</div>,
)
fireEvent.change(screen.getAllByDisplayValue('alice')[0], { target: { value: 'bob' } })
await user.click(screen.getByText('text'))
await user.click(screen.getByText('file'))
expect(onChange).toHaveBeenCalled()
})
it('should edit key-only rows and select file payload rows', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onRemove = vi.fn()
render(
<KeyValueItem
instanceId="kv-2"
nodeId="node-1"
readonly={false}
canRemove
payload={{ id: 'kv-2', key: 'attachment', value: '', type: 'file', file: [] } as any}
onChange={onChange}
onRemove={onRemove}
isLastItem={false}
onAdd={vi.fn()}
isSupportFile
keyNotSupportVar
/>,
)
fireEvent.change(screen.getByDisplayValue('attachment'), { target: { value: 'upload' } })
expect(screen.getByText('file-filter:true:false')).toBeInTheDocument()
await user.click(screen.getByText('pick-file'))
await user.click(screen.getByText('remove-file'))
expect(onChange).toHaveBeenCalled()
expect(onRemove).toHaveBeenCalled()
})
it('should update the raw-text body payload', () => {
const onChange = vi.fn()
render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'raw-text', data: [{ id: 'body-1', type: 'text', value: 'hello' }] } as any}
onChange={onChange}
/>,
)
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated-body' } })
expect(onChange).toHaveBeenCalled()
})
it('should initialize an empty json body and support legacy string payload rendering', () => {
const onChange = vi.fn()
const { rerender } = render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'json', data: [] } as any}
onChange={onChange}
/>,
)
fireEvent.change(screen.getByRole('textbox'), { target: { value: '{"a":1}' } })
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
type: 'json',
data: [expect.objectContaining({ value: '{"a":1}' })],
}))
rerender(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'json', data: 'legacy' } as any}
onChange={onChange}
/>,
)
expect(screen.getByRole('textbox')).toHaveValue('')
})
it('should switch to key-value body types and propagate key-value edits', () => {
const onChange = vi.fn()
render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'none', data: [] } as any}
onChange={onChange}
/>,
)
fireEvent.click(screen.getByRole('radio', { name: 'form-data' }))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
type: 'form-data',
data: [expect.objectContaining({ key: '', value: '' })],
}))
onChange.mockClear()
render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'form-data', data: [{ id: 'body-1', type: 'text', key: 'name', value: 'alice' }] } as any}
onChange={onChange}
/>,
)
fireEvent.click(screen.getAllByDisplayValue('alice')[0]!)
fireEvent.change(screen.getAllByDisplayValue('alice')[0]!, { target: { value: 'bob' } })
expect(onChange.mock.calls.some(([payload]) => Array.isArray(payload.data) && payload.data.length === 2)).toBe(true)
expect(onChange.mock.calls.some(([payload]) => Array.isArray(payload.data) && payload.data[0]?.value === 'bob')).toBe(true)
})
it('should render the binary body picker and forward file selections', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'binary', data: [{ id: 'body-1', type: 'file', file: [] }] } as any}
onChange={onChange}
/>,
)
await user.click(screen.getByText('pick-file'))
expect(onChange).toHaveBeenCalled()
})
it('should initialize an empty binary body before saving the selected file', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<EditBody
readonly={false}
nodeId="node-1"
payload={{ type: 'binary', data: [] } as any}
onChange={onChange}
/>,
)
expect(screen.getByText('file-filter:true:false')).toBeInTheDocument()
await user.click(screen.getByText('pick-file'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
type: 'binary',
data: [expect.objectContaining({
type: 'file',
file: ['node-1', 'file'],
})],
}))
})
it('should render the request node preview when a url exists', () => {
renderWorkflowFlowComponent(
<Node
id="node-1"
data={createData()}
/>,
{ nodes: [], edges: [] },
)
expect(screen.getByText(Method.get)).toBeInTheDocument()
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
})
it('should render nothing when the request url is empty', () => {
renderWorkflowFlowComponent(
<Node
id="node-1"
data={createData({ url: '' })}
/>,
{ nodes: [], edges: [] },
)
expect(screen.queryByText(Method.get)).not.toBeInTheDocument()
expect(screen.queryByText('https://api.example.com')).not.toBeInTheDocument()
})
it('should render the panel sections and output vars', async () => {
renderPanel()
expect(screen.getByText('body:string')).toBeInTheDocument()
expect(screen.getByText('status_code:number')).toBeInTheDocument()
expect(screen.getByText('headers:object')).toBeInTheDocument()
expect(screen.getByText('files:Array[File]')).toBeInTheDocument()
expect(screen.getAllByText('workflow.nodes.http.authorization.authorization').length).toBeGreaterThan(0)
expect(screen.getByText('workflow.nodes.http.curl.title')).toBeInTheDocument()
expect(screen.getByText('curl-panel')).toBeInTheDocument()
})
it('should hide modal overlays when the panel is readonly', () => {
mockUseConfig.mockReturnValueOnce(createConfigResult({
readOnly: true,
}))
renderPanel()
expect(screen.queryByText('curl-panel')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.http.authorization.api-key-title')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,430 @@
import type { Var } from '../../../types'
import type { IfElseNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
BlockEnum,
VarType,
} from '../../../types'
import { VarType as NumberVarType } from '../../tool/types'
import ConditionAdd from '../components/condition-add'
import ConditionFilesListValue from '../components/condition-files-list-value'
import ConditionList from '../components/condition-list'
import ConditionOperator from '../components/condition-list/condition-operator'
import ConditionNumberInput from '../components/condition-number-input'
import ConditionValue from '../components/condition-value'
import Node from '../node'
import Panel from '../panel'
import {
ComparisonOperator,
LogicalOperator,
} from '../types'
import useConfig from '../use-config'
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
useNodes: () => [
{
id: 'node-1',
data: {
title: 'Start Node',
type: BlockEnum.Start,
},
},
],
}
})
vi.mock('react-sortablejs', () => ({
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-vars', () => ({
default: ({ onChange }: { onChange: (valueSelector: string[], varItem: { type: VarType }) => void }) => (
<button
type="button"
onClick={() => onChange(['node-1', 'score'], { type: VarType.number })}
>
pick-var
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
VariableLabelInText: ({ variables }: { variables: string[] }) => <div>{variables.join('.')}</div>,
VariableLabelInNode: ({ variables }: { variables: string[] }) => <div>{variables.join('.')}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable-tag', () => ({
__esModule: true,
default: ({ valueSelector }: { valueSelector: string[] }) => <div>{valueSelector.join('.')}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/node-handle', () => ({
NodeSourceHandle: ({ handleId }: { handleId: string }) => <div data-testid={`handle-${handleId}`} />,
}))
const mockWorkflowStoreState = {
controlPromptEditorRerenderKey: 0,
pipelineId: undefined as string | undefined,
setShowInputFieldPanel: vi.fn(),
}
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: typeof mockWorkflowStoreState) => unknown) => selector(mockWorkflowStoreState),
useWorkflowStore: () => ({
getState: () => ({
...mockWorkflowStoreState,
conversationVariables: [],
dataSourceList: [],
setControlPromptEditorRerenderKey: vi.fn(),
}),
}),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/use-match-schema-type', () => ({
__esModule: true,
default: () => ({
schemaTypeDefinitions: [],
matchSchemaType: () => undefined,
}),
}))
vi.mock('../../variable-assigner/hooks', () => ({
useGetAvailableVars: () => () => [
{
variable: ['node-1', 'score'],
type: VarType.number,
},
],
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: [] }),
useAllCustomTools: () => ({ data: [] }),
useAllWorkflowTools: () => ({ data: [] }),
useAllMCPTools: () => ({ data: [] }),
}))
vi.mock('../use-config', () => ({
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const createData = (overrides: Partial<IfElseNodeType> = {}): IfElseNodeType => ({
title: 'If Else',
desc: '',
type: BlockEnum.IfElse,
isInIteration: false,
isInLoop: false,
cases: [
{
case_id: 'case-1',
logical_operator: LogicalOperator.and,
conditions: [
{
id: 'condition-1',
varType: VarType.string,
variable_selector: ['node-1', 'answer'],
comparison_operator: ComparisonOperator.contains,
value: 'hello',
},
],
},
],
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
filterVar: () => true,
filterNumberVar: (varPayload: Var) => varPayload.type === VarType.number,
handleAddCase: vi.fn(),
handleRemoveCase: vi.fn(),
handleSortCase: vi.fn(),
handleAddCondition: vi.fn(),
handleUpdateCondition: vi.fn(),
handleRemoveCondition: vi.fn(),
handleToggleConditionLogicalOperator: vi.fn(),
handleAddSubVariableCondition: vi.fn(),
handleRemoveSubVariableCondition: vi.fn(),
handleUpdateSubVariableCondition: vi.fn(),
handleToggleSubVariableConditionLogicalOperator: vi.fn(),
nodesOutputVars: [
{
nodeId: 'node-1',
title: 'Start Node',
vars: [
{
variable: 'answer',
type: VarType.string,
},
],
},
],
availableNodes: [],
nodesOutputNumberVars: [
{
nodeId: 'node-1',
title: 'Start Node',
vars: [
{
variable: 'score',
type: VarType.number,
},
],
},
],
availableNumberNodes: [],
varsIsVarFileAttribute: {},
...overrides,
})
const baseNodeProps = {
type: 'custom',
selected: false,
zIndex: 1,
xPos: 0,
yPos: 0,
dragging: false,
isConnectable: true,
}
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('if-else path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStoreState.controlPromptEditorRerenderKey = 0
mockWorkflowStoreState.pipelineId = undefined
mockWorkflowStoreState.setShowInputFieldPanel = vi.fn()
mockUseConfig.mockReturnValue(createConfigResult())
})
describe('Condition controls', () => {
it('should add a condition variable from the selector', async () => {
const user = userEvent.setup()
const onSelectVariable = vi.fn()
render(
<ConditionAdd
caseId="case-1"
variables={[]}
onSelectVariable={onSelectVariable}
/>,
)
await user.click(screen.getByRole('button', { name: /workflow.nodes.ifElse.addCondition/i }))
await user.click(screen.getByText('pick-var'))
expect(onSelectVariable).toHaveBeenCalledWith('case-1', ['node-1', 'score'], { type: VarType.number })
})
it('should switch operators and number input modes', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onNumberVarTypeChange = vi.fn()
const onValueChange = vi.fn()
render(
<div>
<ConditionOperator
varType={VarType.string}
value={ComparisonOperator.contains}
onSelect={onSelect}
/>
<ConditionNumberInput
value="12"
numberVarType={NumberVarType.constant}
onNumberVarTypeChange={onNumberVarTypeChange}
onValueChange={onValueChange}
variables={[]}
unit="%"
/>
</div>,
)
await user.click(screen.getByRole('button', { name: /contains/i }))
await user.click(screen.getByText('workflow.nodes.ifElse.comparisonOperator.is'))
await user.click(screen.getByRole('button', { name: /constant/i }))
await user.click(screen.getByText('Variable'))
fireEvent.change(screen.getByDisplayValue('12'), { target: { value: '42' } })
expect(onSelect).toHaveBeenCalledWith(ComparisonOperator.is)
expect(onNumberVarTypeChange).toHaveBeenCalledWith(NumberVarType.variable)
expect(onValueChange).toHaveBeenCalledWith('42')
})
it('should toggle logical operators for a case list with multiple conditions', async () => {
const user = userEvent.setup()
const onToggleConditionLogicalOperator = vi.fn()
render(
<ConditionList
caseId="case-1"
caseItem={{
case_id: 'case-1',
logical_operator: LogicalOperator.and,
conditions: [
{
id: 'condition-1',
varType: VarType.string,
variable_selector: ['node-1', 'answer'],
comparison_operator: ComparisonOperator.contains,
value: 'hello',
},
{
id: 'condition-2',
varType: VarType.string,
variable_selector: ['node-1', 'answer'],
comparison_operator: ComparisonOperator.is,
value: 'world',
},
],
}}
nodeId="node-1"
nodesOutputVars={[]}
availableNodes={[]}
numberVariables={[]}
filterVar={() => true}
varsIsVarFileAttribute={{}}
onToggleConditionLogicalOperator={onToggleConditionLogicalOperator}
/>,
)
await user.click(screen.getByText('AND'))
expect(onToggleConditionLogicalOperator).toHaveBeenCalledWith('case-1')
})
})
describe('Display rendering', () => {
it('should render formatted condition values and file sub-conditions', () => {
render(
<div>
<ConditionValue
variableSelector={['node-1', 'answer']}
operator={ComparisonOperator.contains}
value="{{#node-1.answer#}}"
/>
<ConditionFilesListValue
condition={{
id: 'condition-files',
varType: VarType.object,
variable_selector: ['node-1', 'files'],
comparison_operator: ComparisonOperator.contains,
value: '',
sub_variable_condition: {
case_id: 'sub-case',
logical_operator: LogicalOperator.or,
conditions: [
{
id: 'sub-condition',
key: 'name',
varType: VarType.string,
comparison_operator: ComparisonOperator.contains,
value: 'report',
},
],
},
}}
/>
</div>,
)
expect(screen.getByText('node-1.answer')).toBeInTheDocument()
expect(screen.getByText('{{answer}}')).toBeInTheDocument()
expect(screen.getByText('node-1.files')).toBeInTheDocument()
expect(screen.getByText('name')).toBeInTheDocument()
expect(screen.getByText('report')).toBeInTheDocument()
})
it('should render node cases, missing setup state, and else handles', () => {
render(
<Node
id="if-else-node"
{...baseNodeProps}
data={createData({
cases: [
{
case_id: 'case-1',
logical_operator: LogicalOperator.and,
conditions: [
{
id: 'condition-empty',
varType: VarType.string,
variable_selector: [],
comparison_operator: ComparisonOperator.contains,
value: '',
},
],
},
{
case_id: 'case-2',
logical_operator: LogicalOperator.or,
conditions: [
{
id: 'condition-ready',
varType: VarType.boolean,
variable_selector: ['node-1', 'passed'],
comparison_operator: ComparisonOperator.is,
value: false,
},
],
},
],
})}
/>,
)
expect(screen.getByText('IF')).toBeInTheDocument()
expect(screen.getByText('ELIF')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.ifElse.conditionNotSetup')).toBeInTheDocument()
expect(screen.getByText('False')).toBeInTheDocument()
expect(screen.getByText('ELSE')).toBeInTheDocument()
expect(screen.getByTestId('handle-case-1')).toBeInTheDocument()
expect(screen.getByTestId('handle-case-2')).toBeInTheDocument()
expect(screen.getByTestId('handle-false')).toBeInTheDocument()
})
})
describe('Panel integration', () => {
it('should add a case from the panel action and render else description', async () => {
const user = userEvent.setup()
const handleAddCase = vi.fn()
const inputs = createData({ cases: [] })
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs,
handleAddCase,
}))
render(
<Panel
id="if-else-node"
data={inputs}
panelProps={panelProps}
/>,
)
await user.click(screen.getByRole('button', { name: /elif/i }))
expect(handleAddCase).toHaveBeenCalled()
expect(screen.getByText('workflow.nodes.ifElse.elseDescription')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,266 @@
import type { ReactNode } from 'react'
import type { IterationNodeType } from '../types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Toast from '@/app/components/base/toast'
import { ErrorHandleMode } from '@/app/components/workflow/types'
import { BlockEnum, VarType } from '../../../types'
import AddBlock from '../add-block'
import Node from '../node'
import Panel from '../panel'
import useConfig from '../use-config'
const mockHandleNodeAdd = vi.fn()
const mockHandleNodeIterationRerender = vi.fn()
let mockNodesReadOnly = false
vi.mock('reactflow', async () => {
const actual = await vi.importActual<typeof import('reactflow')>('reactflow')
return {
...actual,
Background: ({ id }: { id: string }) => <div data-testid={id} />,
useViewport: () => ({ zoom: 1 }),
useNodesInitialized: () => true,
}
})
vi.mock('@/app/components/workflow/block-selector', () => ({
__esModule: true,
default: ({
trigger,
onSelect,
availableBlocksTypes = [],
disabled,
}: {
trigger?: (open: boolean) => ReactNode
onSelect?: (type: BlockEnum) => void
availableBlocksTypes?: BlockEnum[]
disabled?: boolean
}) => (
<div>
{trigger ? <div>{trigger(false)}</div> : null}
<button
type="button"
disabled={disabled}
onClick={() => onSelect?.(availableBlocksTypes[0] ?? BlockEnum.Code)}
>
select-block
</button>
</div>
),
}))
vi.mock('../../iteration-start', () => ({
IterationStartNodeDumb: () => <div>iteration-start-node</div>,
}))
vi.mock('../use-interactions', () => ({
useNodeIterationInteractions: () => ({
handleNodeIterationRerender: mockHandleNodeIterationRerender,
}),
}))
vi.mock('../../../hooks', () => ({
useAvailableBlocks: () => ({
availableNextBlocks: [BlockEnum.Code],
}),
useNodesInteractions: () => ({
handleNodeAdd: mockHandleNodeAdd,
}),
useNodesReadOnly: () => ({
nodesReadOnly: mockNodesReadOnly,
}),
}))
vi.mock('../../_base/components/variable/var-reference-picker', () => ({
__esModule: true,
default: ({
onChange,
availableVars,
}: {
onChange: (value: string[], kindType?: string, varInfo?: { type: VarType }) => void
availableVars?: unknown[]
}) => (
<button
type="button"
onClick={() => {
if (availableVars)
onChange(['child-node', 'text'], 'variable', { type: VarType.string })
else
onChange(['node-1', 'items'], 'variable', { type: VarType.arrayString })
}}
>
{availableVars ? 'pick-output-var' : 'pick-input-var'}
</button>
),
}))
vi.mock('../use-config', () => ({
__esModule: true,
default: vi.fn(),
}))
const mockUseConfig = vi.mocked(useConfig)
const mockToastNotify = vi.spyOn(Toast, 'notify').mockImplementation(() => ({}))
const createData = (overrides: Partial<IterationNodeType> = {}): IterationNodeType => ({
title: 'Iteration',
desc: '',
type: BlockEnum.Iteration,
start_node_id: 'start-node',
iterator_selector: ['node-1', 'items'],
iterator_input_type: VarType.arrayString,
output_selector: ['child-node', 'text'],
output_type: VarType.arrayString,
is_parallel: false,
parallel_nums: 3,
error_handle_mode: ErrorHandleMode.Terminated,
flatten_output: false,
_isShowTips: false,
_children: [],
...overrides,
})
const createConfigResult = (overrides: Partial<ReturnType<typeof useConfig>> = {}): ReturnType<typeof useConfig> => ({
readOnly: false,
inputs: createData(),
filterInputVar: () => true,
handleInputChange: vi.fn(),
childrenNodeVars: [],
iterationChildrenNodes: [],
handleOutputVarChange: vi.fn(),
changeParallel: vi.fn(),
changeErrorResponseMode: vi.fn(),
changeParallelNums: vi.fn(),
changeFlattenOutput: vi.fn(),
...overrides,
})
const panelProps: PanelProps = {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
}
describe('iteration path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNodesReadOnly = false
mockUseConfig.mockReturnValue(createConfigResult())
})
it('should add the next block from the iteration start node', async () => {
const user = userEvent.setup()
render(
<AddBlock
iterationNodeId="iteration-node"
iterationNodeData={createData()}
/>,
)
await user.click(screen.getByRole('button', { name: 'select-block' }))
expect(mockHandleNodeAdd).toHaveBeenCalledWith({
nodeType: BlockEnum.Code,
pluginDefaultValue: undefined,
}, {
prevNodeId: 'start-node',
prevNodeSourceHandle: 'source',
})
})
it('should render candidate iteration nodes and show the parallel warning once', () => {
render(
<Node
id="iteration-node"
data={createData({
_isCandidate: true,
_children: [{ nodeId: 'child-1', nodeType: BlockEnum.Iteration }],
is_parallel: true,
_isShowTips: true,
})}
/>,
)
expect(screen.getByText('iteration-start-node')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'select-block' })).toBeInTheDocument()
expect(screen.getByTestId('iteration-background-iteration-node')).toBeInTheDocument()
expect(mockHandleNodeIterationRerender).toHaveBeenCalledWith('iteration-node')
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'warning',
message: 'workflow.nodes.iteration.answerNodeWarningDesc',
duration: 5000,
})
})
it('should wire panel input, output, parallel, numeric, error mode, and flatten actions', async () => {
const user = userEvent.setup()
const handleInputChange = vi.fn()
const handleOutputVarChange = vi.fn()
const changeParallel = vi.fn()
const changeParallelNums = vi.fn()
const changeErrorResponseMode = vi.fn()
const changeFlattenOutput = vi.fn()
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
is_parallel: true,
flatten_output: false,
}),
handleInputChange,
handleOutputVarChange,
changeParallel,
changeParallelNums,
changeErrorResponseMode,
changeFlattenOutput,
}))
render(
<Panel
id="iteration-node"
data={createData()}
panelProps={panelProps}
/>,
)
await user.click(screen.getByRole('button', { name: 'pick-input-var' }))
await user.click(screen.getByRole('button', { name: 'pick-output-var' }))
await user.click(screen.getAllByRole('switch')[0]!)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '7' } })
await user.click(screen.getByRole('button', { name: /workflow.nodes.iteration.ErrorMethod.operationTerminated/i }))
await user.click(screen.getByText('workflow.nodes.iteration.ErrorMethod.continueOnError'))
await user.click(screen.getAllByRole('switch')[1]!)
expect(handleInputChange).toHaveBeenCalledWith(['node-1', 'items'], 'variable', { type: VarType.arrayString })
expect(handleOutputVarChange).toHaveBeenCalledWith(['child-node', 'text'], 'variable', { type: VarType.string })
expect(changeParallel).toHaveBeenCalledWith(false)
expect(changeParallelNums).toHaveBeenCalledWith(7)
expect(changeErrorResponseMode).toHaveBeenCalledWith(expect.objectContaining({
value: ErrorHandleMode.ContinueOnError,
}))
expect(changeFlattenOutput).toHaveBeenCalledWith(true)
})
it('should hide parallel controls when parallel mode is disabled', () => {
mockUseConfig.mockReturnValueOnce(createConfigResult({
inputs: createData({
is_parallel: false,
}),
}))
render(
<Panel
id="iteration-node"
data={createData()}
panelProps={panelProps}
/>,
)
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,615 @@
import type {
ComparisonOperator,
MetadataFilteringCondition,
MetadataShape,
} from '../types'
import type { DataSet, MetadataInDoc } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
ChunkingMode,
DatasetPermission,
DataSourceType,
} from '@/models/datasets'
import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app'
import { DatasetsDetailContext } from '../../../datasets-detail-store/provider'
import { createDatasetsDetailStore } from '../../../datasets-detail-store/store'
import { BlockEnum, VarType } from '../../../types'
import AddDataset from '../components/add-dataset'
import DatasetItem from '../components/dataset-item'
import DatasetList from '../components/dataset-list'
import ConditionCommonVariableSelector from '../components/metadata/condition-list/condition-common-variable-selector'
import ConditionDate from '../components/metadata/condition-list/condition-date'
import ConditionItem from '../components/metadata/condition-list/condition-item'
import ConditionOperator from '../components/metadata/condition-list/condition-operator'
import ConditionValueMethod from '../components/metadata/condition-list/condition-value-method'
import ConditionVariableSelector from '../components/metadata/condition-list/condition-variable-selector'
import MetadataFilter from '../components/metadata/metadata-filter'
import MetadataFilterSelector from '../components/metadata/metadata-filter/metadata-filter-selector'
import MetadataTrigger from '../components/metadata/metadata-trigger'
import RetrievalConfig from '../components/retrieval-config'
import Node from '../node'
import {
LogicalOperator,
ComparisonOperator as MetadataComparisonOperator,
MetadataFilteringModeEnum,
MetadataFilteringVariableType,
} from '../types'
const mockHasEditPermissionForDataset = vi.fn((
_userId: string,
_datasetConfig: { createdBy: string, partialMemberList: string[], permission: DatasetPermission },
) => true)
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Dataset Name',
indexing_status: 'completed',
icon_info: {
icon: '📙',
icon_background: '#FFF4ED',
icon_type: 'emoji',
icon_url: '',
},
description: 'Dataset description',
permission: DatasetPermission.onlyMe,
data_source_type: DataSourceType.FILE,
indexing_technique: 'high_quality' as DataSet['indexing_technique'],
created_by: 'user-1',
updated_by: 'user-1',
updated_at: 1690000000,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 1,
total_document_count: 1,
word_count: 1000,
provider: 'internal',
embedding_model: 'text-embedding-3',
embedding_model_provider: 'openai',
embedding_available: true,
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
tags: [],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 0,
score_threshold: 0,
score_threshold_enabled: false,
},
built_in_field_enabled: false,
runtime_mode: 'rag_pipeline',
enable_api: false,
is_multimodal: false,
...overrides,
})
const createMetadata = (overrides: Partial<MetadataInDoc> = {}): MetadataInDoc => ({
id: 'meta-1',
name: 'topic',
type: MetadataFilteringVariableType.string,
value: 'topic',
...overrides,
})
const createCondition = (overrides: Partial<MetadataFilteringCondition> = {}): MetadataFilteringCondition => ({
id: 'condition-1',
name: 'topic',
metadata_id: 'meta-1',
comparison_operator: MetadataComparisonOperator.contains,
value: 'agent',
...overrides,
})
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { userProfile: { id: string } }) => unknown) => selector({
userProfile: { id: 'user-1' },
}),
useAppContext: () => ({
userProfile: {
timezone: 'UTC',
},
}),
}))
vi.mock('@/utils/permission', () => ({
hasEditPermissionForDataset: (
userId: string,
datasetConfig: { createdBy: string, partialMemberList: string[], permission: DatasetPermission },
) => mockHasEditPermissionForDataset(userId, datasetConfig),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
__esModule: true,
default: () => 'desktop',
MediaType: {
mobile: 'mobile',
desktop: 'desktop',
},
}))
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'High Quality',
}),
}))
vi.mock('@/app/components/app/configuration/dataset-config/select-dataset', () => ({
__esModule: true,
default: ({ onSelect, onClose }: { onSelect: (datasets: DataSet[]) => void, onClose: () => void }) => (
<div>
<button type="button" onClick={() => onSelect([createDataset({ id: 'dataset-2', name: 'Selected Dataset' })])}>
select-dataset
</button>
<button type="button" onClick={onClose}>
close-select-dataset
</button>
</div>
),
}))
vi.mock('@/app/components/app/configuration/dataset-config/settings-modal', () => ({
__esModule: true,
default: ({ currentDataset, onSave, onCancel }: { currentDataset: DataSet, onSave: (dataset: DataSet) => void, onCancel: () => void }) => (
<div>
<div>{currentDataset.name}</div>
<button type="button" onClick={() => onSave(createDataset({ ...currentDataset, name: 'Updated Dataset' }))}>
save-settings
</button>
<button type="button" onClick={onCancel}>
cancel-settings
</button>
</div>
),
}))
vi.mock('@/app/components/app/configuration/dataset-config/params-config/config-content', () => ({
__esModule: true,
default: ({ onChange }: { onChange: (config: Record<string, unknown>, isRetrievalModeChange?: boolean) => void }) => (
<div>
<button
type="button"
onClick={() => onChange({
retrieval_model: RETRIEVE_TYPE.multiWay,
top_k: 8,
score_threshold_enabled: true,
score_threshold: 0.4,
reranking_model: {
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-v3',
},
reranking_mode: 'weighted_score',
weights: {
weight_type: 'customized',
vector_setting: {
vector_weight: 0.7,
embedding_provider_name: 'openai',
embedding_model_name: 'text-embedding-3',
},
keyword_setting: {
keyword_weight: 0.3,
},
},
reranking_enable: true,
})}
>
apply-retrieval-config
</button>
<button
type="button"
onClick={() => onChange({
retrieval_model: RETRIEVE_TYPE.oneWay,
}, true)}
>
change-retrieval-mode
</button>
</div>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
__esModule: true,
default: () => <div>model-parameter-modal</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-vars', () => ({
__esModule: true,
default: ({ onChange }: { onChange: (valueSelector: string[], varItem: { type: VarType }) => void }) => (
<button
type="button"
onClick={() => onChange(['node-1', 'field'], { type: VarType.string })}
>
pick-var
</button>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable-tag', () => ({
__esModule: true,
default: ({ valueSelector }: { valueSelector: string[] }) => <div>{valueSelector.join('.')}</div>,
}))
vi.mock('../components/metadata/metadata-panel', () => ({
__esModule: true,
default: ({ onCancel }: { onCancel: () => void }) => (
<div>
<div>metadata-panel</div>
<button type="button" onClick={onCancel}>
close-metadata-panel
</button>
</div>
),
}))
describe('knowledge-retrieval path', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHasEditPermissionForDataset.mockReturnValue(true)
})
describe('Dataset controls', () => {
it('should open dataset selector and forward selected datasets', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<AddDataset
selectedIds={['dataset-1']}
onChange={onChange}
/>,
)
await user.click(screen.getByTestId('add-button'))
await user.click(screen.getByText('select-dataset'))
expect(onChange).toHaveBeenCalledWith([
expect.objectContaining({
id: 'dataset-2',
name: 'Selected Dataset',
}),
])
})
it('should support editing and removing a dataset item', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onRemove = vi.fn()
render(
<DatasetItem
payload={createDataset({ is_multimodal: true })}
onChange={onChange}
onRemove={onRemove}
/>,
)
expect(screen.getByText('Dataset Name')).toBeInTheDocument()
fireEvent.mouseOver(screen.getByText('Dataset Name').closest('.group\\/dataset-item')!)
const buttons = screen.getAllByRole('button')
await user.click(buttons[0]!)
await user.click(screen.getByText('save-settings'))
await user.click(buttons[1]!)
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated Dataset' }))
expect(onRemove).toHaveBeenCalled()
})
it('should render empty and populated dataset lists', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const { rerender } = render(
<DatasetList
list={[]}
onChange={onChange}
/>,
)
expect(screen.getByText('appDebug.datasetConfig.knowledgeTip')).toBeInTheDocument()
rerender(
<DatasetList
list={[createDataset()]}
onChange={onChange}
/>,
)
fireEvent.mouseOver(screen.getByText('Dataset Name').closest('.group\\/dataset-item')!)
await user.click(screen.getAllByRole('button')[1]!)
expect(onChange).toHaveBeenCalledWith([])
})
})
describe('Retrieval settings', () => {
it('should open retrieval config and map config updates back to workflow payload', async () => {
const user = userEvent.setup()
const onRetrievalModeChange = vi.fn()
const onMultipleRetrievalConfigChange = vi.fn()
render(
<RetrievalConfig
payload={{
retrieval_mode: RETRIEVE_TYPE.multiWay,
multiple_retrieval_config: {
top_k: 3,
score_threshold: null,
},
}}
onRetrievalModeChange={onRetrievalModeChange}
onMultipleRetrievalConfigChange={onMultipleRetrievalConfigChange}
rerankModalOpen
onRerankModelOpenChange={vi.fn()}
selectedDatasets={[createDataset()]}
/>,
)
await user.click(screen.getByText('apply-retrieval-config'))
await user.click(screen.getByText('change-retrieval-mode'))
expect(onMultipleRetrievalConfigChange).toHaveBeenCalledWith(expect.objectContaining({
top_k: 8,
score_threshold: 0.4,
reranking_model: {
provider: 'cohere',
model: 'rerank-v3',
},
reranking_enable: true,
}))
expect(onRetrievalModeChange).toHaveBeenCalledWith(RETRIEVE_TYPE.oneWay)
})
})
describe('Metadata controls', () => {
it('should select metadata filter mode from the dropdown', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<MetadataFilterSelector
value={MetadataFilteringModeEnum.disabled}
onSelect={onSelect}
/>,
)
await user.click(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.options.disabled.title/i }))
await user.click(screen.getByText('workflow.nodes.knowledgeRetrieval.metadata.options.manual.title'))
expect(onSelect).toHaveBeenCalledWith(MetadataFilteringModeEnum.manual)
})
it('should remove stale metadata conditions and open the manual metadata panel', async () => {
const user = userEvent.setup()
const handleRemoveCondition = vi.fn()
render(
<MetadataTrigger
selectedDatasetsLoaded
metadataList={[createMetadata()]}
metadataFilteringConditions={{
logical_operator: LogicalOperator.and,
conditions: [
createCondition(),
createCondition({
id: 'condition-stale',
metadata_id: 'missing',
name: 'missing',
}),
],
}}
handleAddCondition={vi.fn()}
handleRemoveCondition={handleRemoveCondition}
handleToggleConditionLogicalOperator={vi.fn()}
handleUpdateCondition={vi.fn()}
/>,
)
expect(handleRemoveCondition).toHaveBeenCalledWith('condition-stale')
await user.click(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.panel.conditions/i }))
expect(screen.getByText('metadata-panel')).toBeInTheDocument()
})
it('should render automatic and manual metadata filter states', async () => {
const user = userEvent.setup()
const baseProps: MetadataShape = {
metadataList: [createMetadata()],
metadataFilteringConditions: {
logical_operator: LogicalOperator.and,
conditions: [createCondition()],
},
selectedDatasetsLoaded: true,
handleAddCondition: vi.fn(),
handleRemoveCondition: vi.fn(),
handleToggleConditionLogicalOperator: vi.fn(),
handleUpdateCondition: vi.fn(),
}
const { rerender } = render(
<MetadataFilter
{...baseProps}
metadataFilterMode={MetadataFilteringModeEnum.automatic}
handleMetadataFilterModeChange={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.options.automatic.title/i })).toBeInTheDocument()
rerender(
<MetadataFilter
{...baseProps}
metadataFilterMode={MetadataFilteringModeEnum.manual}
handleMetadataFilterModeChange={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: /workflow.nodes.knowledgeRetrieval.metadata.panel.conditions/i }))
expect(screen.getByText('metadata-panel')).toBeInTheDocument()
})
})
describe('Condition inputs', () => {
it('should toggle value method and keep the same option idempotent', async () => {
const user = userEvent.setup()
const onValueMethodChange = vi.fn()
render(
<ConditionValueMethod
valueMethod="variable"
onValueMethodChange={onValueMethodChange}
/>,
)
await user.click(screen.getByRole('button', { name: /variable/i }))
await user.click(screen.getByText('Constant'))
await user.click(screen.getByRole('button', { name: /variable/i }))
await user.click(screen.getAllByText('Variable')[1]!)
expect(onValueMethodChange).toHaveBeenCalledTimes(1)
expect(onValueMethodChange).toHaveBeenCalledWith('constant')
})
it('should select workflow and common variables', async () => {
const user = userEvent.setup()
const onVariableChange = vi.fn()
const onCommonVariableChange = vi.fn()
const { rerender } = render(
<ConditionVariableSelector
onChange={onVariableChange}
varType={VarType.string}
/>,
)
await user.click(screen.getByText('workflow.nodes.knowledgeRetrieval.metadata.panel.select'))
await user.click(screen.getByText('pick-var'))
expect(onVariableChange).toHaveBeenCalledWith(['node-1', 'field'], { type: VarType.string })
rerender(
<ConditionCommonVariableSelector
variables={[{ name: 'common', type: 'string', value: 'sys.user_name' }]}
varType={VarType.string}
onChange={onCommonVariableChange}
/>,
)
await user.click(screen.getByText('workflow.nodes.knowledgeRetrieval.metadata.panel.select'))
await user.click(screen.getByText('sys.user_name'))
expect(onCommonVariableChange).toHaveBeenCalledWith('sys.user_name')
})
it('should update operator, clear date values, and remove conditions', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onDateChange = vi.fn()
const onRemoveCondition = vi.fn()
const onUpdateCondition = vi.fn()
const { container } = render(
<div>
<ConditionOperator
variableType={MetadataFilteringVariableType.string}
value={MetadataComparisonOperator.contains}
onSelect={onSelect}
/>
<ConditionDate
value={1710000000}
onChange={onDateChange}
/>
<ConditionItem
metadataList={[createMetadata()]}
condition={createCondition()}
onRemoveCondition={onRemoveCondition}
onUpdateCondition={onUpdateCondition}
/>
</div>,
)
await user.click(screen.getAllByRole('button', { name: /contains/i })[0]!)
await user.click(screen.getByText('workflow.nodes.ifElse.comparisonOperator.is'))
await user.click(screen.getByText(/March 09 2024/).nextElementSibling as Element)
fireEvent.change(screen.getByDisplayValue('agent'), { target: { value: 'updated-agent' } })
fireEvent.click(container.querySelector('.ml-1.mt-1') as Element)
expect(onSelect).toHaveBeenCalledWith(MetadataComparisonOperator.is as ComparisonOperator)
expect(onDateChange).toHaveBeenCalledWith()
expect(onUpdateCondition).toHaveBeenCalledWith('condition-1', expect.objectContaining({ value: 'updated-agent' }))
expect(onRemoveCondition).toHaveBeenCalledWith('condition-1')
})
})
describe('Node rendering', () => {
it('should render selected datasets from the detail store and hide when none are selected', () => {
const store = createDatasetsDetailStore()
store.getState().updateDatasetsDetail([createDataset()])
const renderNode = (datasetIds: string[]) => render(
<DatasetsDetailContext.Provider value={store}>
<Node
id="knowledge-node"
data={{
type: BlockEnum.KnowledgeRetrieval,
title: 'Knowledge Retrieval',
desc: '',
dataset_ids: datasetIds,
query_variable_selector: [],
query_attachment_selector: [],
retrieval_mode: RETRIEVE_TYPE.multiWay,
}}
/>
</DatasetsDetailContext.Provider>,
)
const { rerender, container } = renderNode(['dataset-1'])
expect(screen.getByText('Dataset Name')).toBeInTheDocument()
rerender(
<DatasetsDetailContext.Provider value={store}>
<Node
id="knowledge-node"
data={{
type: BlockEnum.KnowledgeRetrieval,
title: 'Knowledge Retrieval',
desc: '',
dataset_ids: [],
query_variable_selector: [],
query_attachment_selector: [],
retrieval_mode: RETRIEVE_TYPE.multiWay,
}}
/>
</DatasetsDetailContext.Provider>,
)
expect(container).toBeEmptyDOMElement()
})
})
})

Some files were not shown because too many files have changed in this diff Show More