Compare commits

..

7 Commits

Author SHA1 Message Date
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
276 changed files with 7328 additions and 3777 deletions

View File

@@ -63,9 +63,8 @@ jobs:
if: needs.check-changes.outputs.web-changed == 'true'
uses: ./.github/workflows/web-tests.yml
with:
base_sha: ${{ github.event.before || github.event.pull_request.base.sha }}
diff_range_mode: ${{ github.event.before && 'exact' || 'merge-base' }}
head_sha: ${{ github.event.after || github.event.pull_request.head.sha || github.sha }}
base_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
head_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
style-check:
name: Style Check

View File

@@ -6,9 +6,6 @@ on:
base_sha:
required: false
type: string
diff_range_mode:
required: false
type: string
head_sha:
required: false
type: string
@@ -92,7 +89,6 @@ jobs:
- name: Check app/components diff coverage
env:
BASE_SHA: ${{ inputs.base_sha }}
DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }}
HEAD_SHA: ${{ inputs.head_sha }}
run: node ./scripts/check-components-diff-coverage.mjs

View File

@@ -737,25 +737,24 @@ SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000
# Redis URL used for event bus between API and
# Redis URL used for PubSub between API and
# celery worker
# defaults to url constructed from `REDIS_*`
# configurations
EVENT_BUS_REDIS_URL=
# Event transport type. Options are:
PUBSUB_REDIS_URL=
# Pub/sub channel type for streaming events.
# valid options are:
#
# - pubsub: normal Pub/Sub (at-most-once)
# - sharded: sharded Pub/Sub (at-most-once)
# - streams: Redis Streams (at-least-once, recommended to avoid subscriber races)
# - pubsub: for normal Pub/Sub
# - sharded: for sharded Pub/Sub
#
# Note: Before enabling 'streams' in production, estimate your expected event volume and retention needs.
# Configure Redis memory limits and stream trimming appropriately (e.g., MAXLEN and key expiry) to reduce
# the risk of data loss from Redis auto-eviction under memory pressure.
# Also accepts ENV: EVENT_BUS_REDIS_CHANNEL_TYPE.
EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
# Whether to use Redis cluster mode while use redis as event bus.
# It's highly recommended to use sharded Pub/Sub AND redis cluster
# for large deployments.
PUBSUB_REDIS_CHANNEL_TYPE=pubsub
# Whether to use Redis cluster mode while running
# PubSub.
# It's highly recommended to enable this for large deployments.
EVENT_BUS_REDIS_USE_CLUSTERS=false
PUBSUB_REDIS_USE_CLUSTERS=false
# Whether to Enable human input timeout check task
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true

View File

@@ -41,10 +41,10 @@ class RedisPubSubConfig(BaseSettings, RedisConfigDefaultsMixin):
)
PUBSUB_REDIS_USE_CLUSTERS: bool = Field(
validation_alias=AliasChoices("EVENT_BUS_REDIS_USE_CLUSTERS", "PUBSUB_REDIS_USE_CLUSTERS"),
validation_alias=AliasChoices("EVENT_BUS_REDIS_CLUSTERS", "PUBSUB_REDIS_USE_CLUSTERS"),
description=(
"Enable Redis Cluster mode for pub/sub or streams transport. Recommended for large deployments. "
"Also accepts ENV: EVENT_BUS_REDIS_USE_CLUSTERS."
"Also accepts ENV: EVENT_BUS_REDIS_CLUSTERS."
),
default=False,
)

View File

@@ -55,31 +55,15 @@ def build_protected_resource_metadata_discovery_urls(
"""
urls = []
parsed_server_url = urlparse(server_url)
base_url = f"{parsed_server_url.scheme}://{parsed_server_url.netloc}"
path = parsed_server_url.path.rstrip("/")
# First priority: URL from WWW-Authenticate header
if www_auth_resource_metadata_url:
parsed_metadata_url = urlparse(www_auth_resource_metadata_url)
normalized_metadata_url = None
if parsed_metadata_url.scheme and parsed_metadata_url.netloc:
normalized_metadata_url = www_auth_resource_metadata_url
elif not parsed_metadata_url.scheme and parsed_metadata_url.netloc:
normalized_metadata_url = f"{parsed_server_url.scheme}:{www_auth_resource_metadata_url}"
elif (
not parsed_metadata_url.scheme
and not parsed_metadata_url.netloc
and parsed_metadata_url.path.startswith("/")
):
first_segment = parsed_metadata_url.path.lstrip("/").split("/", 1)[0]
if first_segment == ".well-known" or "." not in first_segment:
normalized_metadata_url = urljoin(base_url, parsed_metadata_url.path)
if normalized_metadata_url:
urls.append(normalized_metadata_url)
urls.append(www_auth_resource_metadata_url)
# Fallback: construct from server URL
parsed = urlparse(server_url)
base_url = f"{parsed.scheme}://{parsed.netloc}"
path = parsed.path.rstrip("/")
# Priority 2: With path insertion (e.g., /.well-known/oauth-protected-resource/public/mcp)
if path:
path_url = f"{base_url}/.well-known/oauth-protected-resource{path}"

View File

@@ -8,8 +8,6 @@ from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta
from typing import Annotated, Any, ClassVar, Literal, Self
import bleach
import markdown
from pydantic import BaseModel, Field, field_validator, model_validator
from dify_graph.entities.base_node_data import BaseNodeData
@@ -60,39 +58,6 @@ class EmailDeliveryConfig(BaseModel):
"""Configuration for email delivery method."""
URL_PLACEHOLDER: ClassVar[str] = "{{#url#}}"
_SUBJECT_NEWLINE_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"[\r\n]+")
_ALLOWED_HTML_TAGS: ClassVar[list[str]] = [
"a",
"blockquote",
"br",
"code",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"li",
"ol",
"p",
"pre",
"strong",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"ul",
]
_ALLOWED_HTML_ATTRIBUTES: ClassVar[dict[str, list[str]]] = {
"a": ["href", "title"],
"td": ["align"],
"th": ["align"],
}
_ALLOWED_PROTOCOLS: ClassVar[list[str]] = ["http", "https", "mailto"]
recipients: EmailRecipients
@@ -133,43 +98,6 @@ class EmailDeliveryConfig(BaseModel):
return templated_body
return variable_pool.convert_template(templated_body).text
@classmethod
def render_markdown_body(cls, body: str) -> str:
"""Render markdown to safe HTML for email delivery."""
sanitized_markdown = bleach.clean(
body,
tags=[],
attributes={},
strip=True,
strip_comments=True,
)
rendered_html = markdown.markdown(
sanitized_markdown,
extensions=["nl2br", "tables"],
extension_configs={"tables": {"use_align_attribute": True}},
)
return bleach.clean(
rendered_html,
tags=cls._ALLOWED_HTML_TAGS,
attributes=cls._ALLOWED_HTML_ATTRIBUTES,
protocols=cls._ALLOWED_PROTOCOLS,
strip=True,
strip_comments=True,
)
@classmethod
def sanitize_subject(cls, subject: str) -> str:
"""Sanitize email subject to plain text and prevent CRLF injection."""
sanitized_subject = bleach.clean(
subject,
tags=[],
attributes={},
strip=True,
strip_comments=True,
)
sanitized_subject = cls._SUBJECT_NEWLINE_PATTERN.sub(" ", sanitized_subject)
return " ".join(sanitized_subject.split())
class _DeliveryMethodBase(BaseModel):
"""Base delivery method configuration."""

View File

@@ -40,7 +40,7 @@ dependencies = [
"numpy~=1.26.4",
"openpyxl~=3.1.5",
"opik~=1.10.37",
"litellm==1.82.2", # Pinned to avoid madoka dependency issue
"litellm==1.82.2", # Pinned to avoid madoka dependency issue
"opentelemetry-api==1.28.0",
"opentelemetry-distro==0.49b0",
"opentelemetry-exporter-otlp==1.28.0",
@@ -91,7 +91,6 @@ dependencies = [
"apscheduler>=3.11.0",
"weave>=0.52.16",
"fastopenapi[flask]>=0.7.0",
"bleach~=6.2.0",
]
# Before adding new dependency, consider place it in
# alphabet order (a-z) and suitable group.
@@ -119,7 +118,7 @@ dev = [
"pytest~=9.0.2",
"pytest-benchmark~=5.2.3",
"pytest-cov~=7.0.0",
"pytest-env~=1.6.0",
"pytest-env~=1.1.3",
"pytest-mock~=3.15.1",
"testcontainers~=4.14.1",
"types-aiofiles~=25.1.0",
@@ -252,7 +251,10 @@ ignore_errors = true
[tool.pyrefly]
project-includes = ["."]
project-excludes = [".venv", "migrations/"]
project-excludes = [
".venv",
"migrations/",
]
python-platform = "linux"
python-version = "3.11.0"
infer-with-first-use = false

View File

@@ -155,15 +155,13 @@ class EmailDeliveryTestHandler:
context=context,
recipient_email=recipient_email,
)
subject_template = render_email_template(method.config.subject, substitutions)
subject = EmailDeliveryConfig.sanitize_subject(subject_template)
subject = render_email_template(method.config.subject, substitutions)
templated_body = EmailDeliveryConfig.render_body_template(
body=method.config.body,
url=substitutions.get("form_link"),
variable_pool=context.variable_pool,
)
body = render_email_template(templated_body, substitutions)
body = EmailDeliveryConfig.render_markdown_body(body)
mail.send(
to=recipient_email,

View File

@@ -111,7 +111,7 @@ def _render_body(
url=form_link,
variable_pool=variable_pool,
)
return EmailDeliveryConfig.render_markdown_body(body)
return body
def _load_variable_pool(workflow_run_id: str | None) -> VariablePool | None:
@@ -173,11 +173,10 @@ def dispatch_human_input_email_task(form_id: str, node_title: str | None = None,
for recipient in job.recipients:
form_link = _build_form_link(recipient.token)
body = _render_body(job.body, form_link, variable_pool=variable_pool)
subject = EmailDeliveryConfig.sanitize_subject(job.subject)
mail.send(
to=recipient.email,
subject=subject,
subject=job.subject,
html=body,
)

View File

@@ -186,7 +186,7 @@ class DifyTestContainers:
# Start Dify Plugin Daemon container for plugin management
# Dify Plugin Daemon provides plugin lifecycle management and execution
logger.info("Initializing Dify Plugin Daemon container...")
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.5.4-local").with_network(
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.3.0-local").with_network(
self.network
)
self.dify_plugin_daemon.with_exposed_ports(5002)

View File

@@ -22,7 +22,7 @@ from controllers.console.extension import (
)
if _NEEDS_METHOD_VIEW_CLEANUP:
del builtins.MethodView
delattr(builtins, "MethodView")
from models.account import AccountStatus
from models.api_based_extension import APIBasedExtension

View File

@@ -801,27 +801,6 @@ class TestAuthOrchestration:
urls = build_protected_resource_metadata_discovery_urls(None, "https://api.example.com")
assert urls == ["https://api.example.com/.well-known/oauth-protected-resource"]
def test_build_protected_resource_metadata_discovery_urls_with_relative_hint(self):
urls = build_protected_resource_metadata_discovery_urls(
"/.well-known/oauth-protected-resource/tenant/mcp",
"https://api.example.com/tenant/mcp",
)
assert urls == [
"https://api.example.com/.well-known/oauth-protected-resource/tenant/mcp",
"https://api.example.com/.well-known/oauth-protected-resource",
]
def test_build_protected_resource_metadata_discovery_urls_ignores_scheme_less_hint(self):
urls = build_protected_resource_metadata_discovery_urls(
"/openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource/tenant/mcp",
"https://openapi-mcp.cn-hangzhou.aliyuncs.com/tenant/mcp",
)
assert urls == [
"https://openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource/tenant/mcp",
"https://openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource",
]
def test_build_oauth_authorization_server_metadata_discovery_urls(self):
# Case 1: with auth_server_url
urls = build_oauth_authorization_server_metadata_discovery_urls(

View File

@@ -14,64 +14,3 @@ def test_render_body_template_replaces_variable_values():
result = config.render_body_template(body=config.body, url="https://example.com", variable_pool=variable_pool)
assert result == "Hello World https://example.com"
def test_render_markdown_body_renders_markdown_to_html():
rendered = EmailDeliveryConfig.render_markdown_body("**Bold** and [link](https://example.com)")
assert "<strong>Bold</strong>" in rendered
assert '<a href="https://example.com">link</a>' in rendered
def test_render_markdown_body_sanitizes_unsafe_html():
rendered = EmailDeliveryConfig.render_markdown_body(
'<script>alert("xss")</script><a href="javascript:alert(1)" onclick="alert(2)">Click</a>'
)
assert "<script" not in rendered
assert "<a" not in rendered
assert "onclick" not in rendered
assert "javascript:" not in rendered
assert "Click" in rendered
def test_render_markdown_body_sanitizes_markdown_link_with_javascript_href():
rendered = EmailDeliveryConfig.render_markdown_body("[bad](javascript:alert(1)) and [ok](https://example.com)")
assert "javascript:" not in rendered
assert "<a>bad</a>" in rendered
assert '<a href="https://example.com">ok</a>' in rendered
def test_render_markdown_body_does_not_allow_raw_html_tags():
rendered = EmailDeliveryConfig.render_markdown_body("<b>raw html</b> and **markdown**")
assert "<b>" not in rendered
assert "raw html" in rendered
assert "<strong>markdown</strong>" in rendered
def test_render_markdown_body_supports_table_syntax():
rendered = EmailDeliveryConfig.render_markdown_body("| h1 | h2 |\n| --- | ---: |\n| v1 | v2 |")
assert "<table>" in rendered
assert "<thead>" in rendered
assert "<tbody>" in rendered
assert 'align="right"' in rendered
assert "style=" not in rendered
def test_sanitize_subject_removes_crlf():
sanitized = EmailDeliveryConfig.sanitize_subject("Notice\r\nBCC:attacker@example.com")
assert "\r" not in sanitized
assert "\n" not in sanitized
assert sanitized == "Notice BCC:attacker@example.com"
def test_sanitize_subject_removes_html_tags():
sanitized = EmailDeliveryConfig.sanitize_subject("<b>Alert</b><img src=x onerror=1>")
assert "<" not in sanitized
assert ">" not in sanitized
assert sanitized == "Alert"

View File

@@ -140,7 +140,7 @@ class TestLoginRequired:
# Remove ensure_sync to simulate Flask 1.x
if hasattr(setup_app, "ensure_sync"):
del setup_app.ensure_sync
delattr(setup_app, "ensure_sync")
with setup_app.test_request_context():
mock_user = MockUser("test_user", is_authenticated=True)

View File

@@ -207,45 +207,6 @@ class TestEmailDeliveryTestHandler:
assert kwargs["to"] == "test@example.com"
assert "RENDERED_Subj" in kwargs["subject"]
def test_send_test_sanitizes_subject(self, monkeypatch):
monkeypatch.setattr(
service_module.FeatureService,
"get_features",
lambda _id: SimpleNamespace(human_input_email_delivery_enabled=True),
)
monkeypatch.setattr(service_module.mail, "is_inited", lambda: True)
mock_mail_send = MagicMock()
monkeypatch.setattr(service_module.mail, "send", mock_mail_send)
monkeypatch.setattr(
service_module,
"render_email_template",
lambda template, substitutions: template.replace("{{ recipient_email }}", substitutions["recipient_email"]),
)
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
handler._resolve_recipients = MagicMock(return_value=["test@example.com"])
context = DeliveryTestContext(
tenant_id="t1",
app_id="a1",
node_id="n1",
node_title="title",
rendered_content="content",
recipients=[DeliveryTestEmailRecipient(email="test@example.com", form_token="token123")],
)
method = EmailDeliveryMethod(
config=EmailDeliveryConfig(
recipients=EmailRecipients(whole_workspace=False, items=[]),
subject="<b>Notice</b>\r\nBCC:{{ recipient_email }}",
body="Body",
)
)
handler.send_test(context=context, method=method)
_, kwargs = mock_mail_send.call_args
assert kwargs["subject"] == "Notice BCC:test@example.com"
def test_resolve_recipients(self):
handler = EmailDeliveryTestHandler(session_factory=MagicMock())

View File

@@ -120,37 +120,4 @@ def test_dispatch_human_input_email_task_replaces_body_variables(monkeypatch: py
session_factory=lambda: _DummySession(form),
)
assert mail.sent[0]["html"] == "<p>Body OK</p>"
@pytest.mark.parametrize("line_break", ["\r\n", "\r", "\n"])
def test_dispatch_human_input_email_task_sanitizes_subject(
monkeypatch: pytest.MonkeyPatch,
line_break: str,
):
mail = _DummyMail()
form = SimpleNamespace(id="form-1", tenant_id="tenant-1", workflow_run_id=None)
job = task_module._EmailDeliveryJob(
form_id="form-1",
subject=f"Notice{line_break}BCC:attacker@example.com <b>Alert</b>",
body="Body",
form_content="content",
recipients=[task_module._EmailRecipient(email="user@example.com", token="token-1")],
)
monkeypatch.setattr(task_module, "mail", mail)
monkeypatch.setattr(
task_module.FeatureService,
"get_features",
lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=True),
)
monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: [job])
monkeypatch.setattr(task_module, "_load_variable_pool", lambda _workflow_run_id: None)
task_module.dispatch_human_input_email_task(
form_id="form-1",
node_title="Approve",
session_factory=lambda: _DummySession(form),
)
assert mail.sent[0]["subject"] == "Notice BCC:attacker@example.com Alert"
assert mail.sent[0]["html"] == "Body OK"

171
api/uv.lock generated
View File

@@ -658,18 +658,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/cc/38b6f87170908bd8aaf9e412b021d17e85f690abe00edf50192f1a4566b9/billiard-4.2.3-py3-none-any.whl", hash = "sha256:989e9b688e3abf153f307b68a1328dfacfb954e30a4f920005654e276c69236b", size = 87042, upload-time = "2025-11-16T17:47:29.005Z" },
]
[[package]]
name = "bleach"
version = "6.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "webencodings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083, upload-time = "2024-10-29T18:30:40.477Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406, upload-time = "2024-10-29T18:30:38.186Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
@@ -720,16 +708,16 @@ wheels = [
[[package]]
name = "boto3-stubs"
version = "1.42.68"
version = "1.41.3"
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/fd/5b/6d274aa25f7fa09f8b7defab5cb9389e6496a7d9b76c1efcf27b0b15e868/boto3_stubs-1.41.3.tar.gz", hash = "sha256:c7cc9706ac969c8ea284c2d45ec45b6371745666d087c6c5e7c9d39dafdd48bc", size = 100010, upload-time = "2025-11-24T20:34:27.052Z" }
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/7e/d6/ef971013d1fc7333c6df322d98ebf4592df9c80e1966fb12732f91e9e71b/boto3_stubs-1.41.3-py3-none-any.whl", hash = "sha256:bec698419b31b499f3740f1dfb6dae6519167d9e3aa536f6f730ed280556230b", size = 69294, upload-time = "2025-11-24T20:34:23.1Z" },
]
[package.optional-dependencies]
@@ -1541,7 +1529,6 @@ dependencies = [
{ name = "arize-phoenix-otel" },
{ name = "azure-identity" },
{ name = "beautifulsoup4" },
{ name = "bleach" },
{ name = "boto3" },
{ name = "bs4" },
{ name = "cachetools" },
@@ -1743,7 +1730,6 @@ requires-dist = [
{ name = "arize-phoenix-otel", specifier = "~=0.15.0" },
{ name = "azure-identity", specifier = "==1.25.3" },
{ name = "beautifulsoup4", specifier = "==4.14.3" },
{ name = "bleach", specifier = "~=6.2.0" },
{ name = "boto3", specifier = "==1.42.68" },
{ name = "bs4", specifier = "~=0.0.1" },
{ name = "cachetools", specifier = "~=5.3.0" },
@@ -1845,7 +1831,7 @@ dev = [
{ name = "pytest", specifier = "~=9.0.2" },
{ name = "pytest-benchmark", specifier = "~=5.2.3" },
{ name = "pytest-cov", specifier = "~=7.0.0" },
{ name = "pytest-env", specifier = "~=1.6.0" },
{ name = "pytest-env", specifier = "~=1.1.3" },
{ name = "pytest-mock", specifier = "~=3.15.1" },
{ name = "pytest-timeout", specifier = ">=2.4.0" },
{ name = "pytest-xdist", specifier = ">=3.8.0" },
@@ -3157,14 +3143,14 @@ wheels = [
[[package]]
name = "hypothesis"
version = "6.151.9"
version = "6.148.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/e1/ef365ff480903b929d28e057f57b76cae51a30375943e33374ec9a165d9c/hypothesis-6.151.9.tar.gz", hash = "sha256:2f284428dda6c3c48c580de0e18470ff9c7f5ef628a647ee8002f38c3f9097ca", size = 463534, upload-time = "2026-02-16T22:59:23.09Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4a/99/a3c6eb3fdd6bfa01433d674b0f12cd9102aa99630689427422d920aea9c6/hypothesis-6.148.2.tar.gz", hash = "sha256:07e65d34d687ddff3e92a3ac6b43966c193356896813aec79f0a611c5018f4b1", size = 469984, upload-time = "2025-11-18T20:21:17.047Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d2/c2673aca0127e204965e0e9b3b7a0e91e9b12993859ac8758abd22669b89/hypothesis-6.148.2-py3-none-any.whl", hash = "sha256:bf8ddc829009da73b321994b902b1964bcc3e5c3f0ed9a1c1e6a1631ab97c5fa", size = 536986, upload-time = "2025-11-18T20:21:15.212Z" },
]
[[package]]
@@ -3178,17 +3164,19 @@ wheels = [
[[package]]
name = "import-linter"
version = "2.11"
version = "2.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "fastapi" },
{ name = "grimp" },
{ name = "rich" },
{ name = "typing-extensions" },
{ name = "uvicorn" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/66/55b697a17bb15c6cb88d97d73716813f5427281527b90f02cc0a600abc6e/import_linter-2.11.tar.gz", hash = "sha256:5abc3394797a54f9bae315e7242dc98715ba485f840ac38c6d3192c370d0085e", size = 1153682, upload-time = "2026-03-06T12:11:38.198Z" }
sdist = { url = "https://files.pythonhosted.org/packages/10/c4/a83cc1ea9ed0171725c0e2edc11fd929994d4f026028657e8b30d62bca37/import_linter-2.10.tar.gz", hash = "sha256:c6a5057d2dbd32e1854c4d6b60e90dfad459b7ab5356230486d8521f25872963", size = 1149263, upload-time = "2026-02-06T17:57:24.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/aa/2ed2c89543632ded7196e0d93dcc6c7fe87769e88391a648c4a298ea864a/import_linter-2.11-py3-none-any.whl", hash = "sha256:3dc54cae933bae3430358c30989762b721c77aa99d424f56a08265be0eeaa465", size = 637315, upload-time = "2026-03-06T12:11:36.599Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e5/4b7b9435eac78ecfd537fa1004a0bcf0f4eac17d3a893f64d38a7bacb51b/import_linter-2.10-py3-none-any.whl", hash = "sha256:cc2ddd7ec0145cbf83f3b25391d2a5dbbf138382aaf80708612497fa6ebc8f60", size = 637081, upload-time = "2026-02-06T17:57:23.386Z" },
]
[[package]]
@@ -3930,14 +3918,14 @@ wheels = [
[[package]]
name = "mypy-boto3-bedrock-runtime"
version = "1.42.42"
version = "1.41.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/bb/65dc1b2c5796a6ab5f60bdb57343bd6c3ecb82251c580eca415c8548333e/mypy_boto3_bedrock_runtime-1.42.42.tar.gz", hash = "sha256:3a4088218478b6fbbc26055c03c95bee4fc04624a801090b3cce3037e8275c8d", size = 29840, upload-time = "2026-02-04T20:53:05.999Z" }
sdist = { url = "https://files.pythonhosted.org/packages/af/f1/00aea4f91501728e7af7e899ce3a75d48d6df97daa720db11e46730fa123/mypy_boto3_bedrock_runtime-1.41.2.tar.gz", hash = "sha256:ba2c11f2f18116fd69e70923389ce68378fa1620f70e600efb354395a1a9e0e5", size = 28890, upload-time = "2025-11-21T20:35:30.074Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/43/7ea062f2228f47b5779dcfa14dab48d6e29f979b35d1a5102b0ba80b9c1b/mypy_boto3_bedrock_runtime-1.42.42-py3-none-any.whl", hash = "sha256:b2d16eae22607d0685f90796b3a0afc78c0b09d45872e00eafd634a31dd9358f", size = 36077, upload-time = "2026-02-04T20:53:01.768Z" },
{ url = "https://files.pythonhosted.org/packages/a7/cc/96a2af58c632701edb5be1dda95434464da43df40ae868a1ab1ddf033839/mypy_boto3_bedrock_runtime-1.41.2-py3-none-any.whl", hash = "sha256:a720ff1e98cf10723c37a61a46cff220b190c55b8fb57d4397e6cf286262cf02", size = 34967, upload-time = "2025-11-21T20:35:27.655Z" },
]
[[package]]
@@ -5526,15 +5514,14 @@ wheels = [
[[package]]
name = "pytest-env"
version = "1.6.0"
version = "1.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "python-dotenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/69/4db1c30625af0621df8dbe73797b38b6d1b04e15d021dd5d26a6d297f78c/pytest_env-1.6.0.tar.gz", hash = "sha256:ac02d6fba16af54d61e311dd70a3c61024a4e966881ea844affc3c8f0bf207d3", size = 16163, upload-time = "2026-03-12T22:39:43.78Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/31/27f28431a16b83cab7a636dce59cf397517807d247caa38ee67d65e71ef8/pytest_env-1.1.5.tar.gz", hash = "sha256:91209840aa0e43385073ac464a554ad2947cc2fd663a9debf88d03b01e0cc1cf", size = 8911, upload-time = "2024-09-17T22:39:18.566Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/16/ad52f56b96d851a2bcfdc1e754c3531341885bd7177a128c13ff2ca72ab4/pytest_env-1.6.0-py3-none-any.whl", hash = "sha256:1e7f8a62215e5885835daaed694de8657c908505b964ec8097a7ce77b403d9a3", size = 10400, upload-time = "2026-03-12T22:39:41.887Z" },
{ url = "https://files.pythonhosted.org/packages/de/b8/87cfb16045c9d4092cfcf526135d73b88101aac83bc1adcf82dfb5fd3833/pytest_env-1.1.5-py3-none-any.whl", hash = "sha256:ce90cf8772878515c24b31cd97c7fa1f4481cd68d588419fd45f10ecaee6bc30", size = 6141, upload-time = "2024-09-17T22:39:16.942Z" },
]
[[package]]
@@ -6046,27 +6033,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.6"
version = "0.15.5"
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/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" }
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/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" },
{ url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" },
{ url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" },
{ url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" },
{ url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" },
{ url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" },
{ url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" },
{ url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" },
{ url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" },
{ url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" },
{ url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" },
{ url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" },
{ url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" },
{ url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" },
]
[[package]]
@@ -6105,14 +6092,14 @@ wheels = [
[[package]]
name = "scipy-stubs"
version = "1.17.1.2"
version = "1.16.3.1"
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/0b/3e/8baf960c68f012b8297930d4686b235813974833a417db8d0af798b0b93d/scipy_stubs-1.16.3.1.tar.gz", hash = "sha256:0738d55a7f8b0c94cdb8063f711d53330ebefe166f7d48dec9ffd932a337226d", size = 359990, upload-time = "2025-11-23T23:05:21.274Z" }
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/0c/39/e2a69866518f88dc01940c9b9b044db97c3387f2826bd2a173e49a5c0469/scipy_stubs-1.16.3.1-py3-none-any.whl", hash = "sha256:69bc52ef6c3f8e09208abdfaf32291eb51e9ddf8fa4389401ccd9473bdd2a26d", size = 560397, upload-time = "2025-11-23T23:05:19.432Z" },
]
[[package]]
@@ -6801,14 +6788,14 @@ wheels = [
[[package]]
name = "types-cffi"
version = "2.0.0.20260316"
version = "1.17.0.20250915"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/4c/805b40b094eb3fd60f8d17fa7b3c58a33781311a95d0e6a74da0751ce294/types_cffi-2.0.0.20260316.tar.gz", hash = "sha256:8fb06ed4709675c999853689941133affcd2250cd6121cc11fd22c0d81ad510c", size = 17399, upload-time = "2026-03-16T07:54:43.059Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/98/ea454cea03e5f351323af6a482c65924f3c26c515efd9090dede58f2b4b6/types_cffi-1.17.0.20250915.tar.gz", hash = "sha256:4362e20368f78dabd5c56bca8004752cc890e07a71605d9e0d9e069dbaac8c06", size = 17229, upload-time = "2025-09-15T03:01:25.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/5e/9f1a709225ad9d0e1d7a6e4366ff285f0113c749e882d6cbeb40eab32e75/types_cffi-2.0.0.20260316-py3-none-any.whl", hash = "sha256:dd504698029db4c580385f679324621cc64d886e6a23e9821d52bc5169251302", size = 20096, upload-time = "2026-03-16T07:54:41.994Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ec/092f2b74b49ec4855cdb53050deb9699f7105b8fda6fe034c0781b8687f3/types_cffi-1.17.0.20250915-py3-none-any.whl", hash = "sha256:cef4af1116c83359c11bb4269283c50f0688e9fc1d7f0eeb390f3661546da52c", size = 20112, upload-time = "2025-09-15T03:01:24.187Z" },
]
[[package]]
@@ -6840,11 +6827,11 @@ wheels = [
[[package]]
name = "types-docutils"
version = "0.22.3.20260316"
version = "0.22.3.20260223"
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/80/33/92c0129283363e3b3ba270bf6a2b7d077d949d2f90afc4abaf6e73578563/types_docutils-0.22.3.20260223.tar.gz", hash = "sha256:e90e868da82df615ea2217cf36dff31f09660daa15fc0f956af53f89c1364501", size = 57230, upload-time = "2026-02-23T04:11:21.806Z" }
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/ba/c7/a4ae6a75d5b07d63089d5c04d450a0de4a5d48ffcb84b95659b22d3885fe/types_docutils-0.22.3.20260223-py3-none-any.whl", hash = "sha256:cc2d6b7560a28e351903db0989091474aa619ad287843a018324baee9c4d9a8f", size = 91969, upload-time = "2026-02-23T04:11:20.966Z" },
]
[[package]]
@@ -6874,15 +6861,15 @@ wheels = [
[[package]]
name = "types-gevent"
version = "25.9.0.20251228"
version = "25.9.0.20251102"
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/4c/21/552d818a475e1a31780fb7ae50308feb64211a05eb403491d1a34df95e5f/types_gevent-25.9.0.20251102.tar.gz", hash = "sha256:76f93513af63f4577bb4178c143676dd6c4780abc305f405a4e8ff8f1fa177f8", size = 38096, upload-time = "2025-11-02T03:07:42.112Z" }
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/60/a1/776d2de31a02123f225aaa790641113ae47f738f6e8e3091d3012240a88e/types_gevent-25.9.0.20251102-py3-none-any.whl", hash = "sha256:0f14b9977cb04bf3d94444b5ae6ec5d78ac30f74c4df83483e0facec86f19d8b", size = 55592, upload-time = "2025-11-02T03:07:41.003Z" },
]
[[package]]
@@ -6908,11 +6895,11 @@ wheels = [
[[package]]
name = "types-jmespath"
version = "1.1.0.20260124"
version = "1.0.2.20250809"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2b/ca/c8d7fc6e450c2f8fc6f510cb194754c43b17f933f2dcabcfc6985cbb97a8/types_jmespath-1.1.0.20260124.tar.gz", hash = "sha256:29d86868e72c0820914577077b27d167dcab08b1fc92157a29d537ff7153fdfe", size = 10709, upload-time = "2026-01-24T03:18:46.557Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d5/ff/6848b1603ca47fff317b44dfff78cc1fb0828262f840b3ab951b619d5a22/types_jmespath-1.0.2.20250809.tar.gz", hash = "sha256:e194efec21c0aeae789f701ae25f17c57c25908e789b1123a5c6f8d915b4adff", size = 10248, upload-time = "2025-08-09T03:14:57.996Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/91/915c4a6e6e9bd2bca3ec0c21c1771b175c59e204b85e57f3f572370fe753/types_jmespath-1.1.0.20260124-py3-none-any.whl", hash = "sha256:ec387666d446b15624215aa9cbd2867ffd885b6c74246d357c65e830c7a138b3", size = 11509, upload-time = "2026-01-24T03:18:45.536Z" },
{ url = "https://files.pythonhosted.org/packages/0e/6a/65c8be6b6555beaf1a654ae1c2308c2e19a610c0b318a9730e691b79ac79/types_jmespath-1.0.2.20250809-py3-none-any.whl", hash = "sha256:4147d17cc33454f0dac7e78b4e18e532a1330c518d85f7f6d19e5818ab83da21", size = 11494, upload-time = "2025-08-09T03:14:57.292Z" },
]
[[package]]
@@ -6965,20 +6952,20 @@ wheels = [
[[package]]
name = "types-openpyxl"
version = "3.1.5.20260316"
version = "3.1.5.20250919"
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/c4/12/8bc4a25d49f1e4b7bbca868daa3ee80b1983d8137b4986867b5b65ab2ecd/types_openpyxl-3.1.5.20250919.tar.gz", hash = "sha256:232b5906773eebace1509b8994cdadda043f692cfdba9bfbb86ca921d54d32d7", size = 100880, upload-time = "2025-09-19T02:54:39.997Z" }
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/36/3c/d49cf3f4489a10e9ddefde18fd258f120754c5825d06d145d9a0aaac770b/types_openpyxl-3.1.5.20250919-py3-none-any.whl", hash = "sha256:bd06f18b12fd5e1c9f0b666ee6151d8140216afa7496f7ebb9fe9d33a1a3ce99", size = 166078, upload-time = "2025-09-19T02:54:38.657Z" },
]
[[package]]
name = "types-pexpect"
version = "4.9.0.20260127"
version = "4.9.0.20250916"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2e/32/7e03a07e16f79a404d6200ed6bdfcc320d0fb833436a5c6895a1403dedb7/types_pexpect-4.9.0.20260127.tar.gz", hash = "sha256:f8d43efc24251a8e533c71ea9be03d19bb5d08af096d561611697af9720cba7f", size = 13461, upload-time = "2026-01-27T03:28:30.923Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0c/e6/cc43e306dc7de14ec7861c24ac4957f688741ae39ae685049695d796b587/types_pexpect-4.9.0.20250916.tar.gz", hash = "sha256:69e5fed6199687a730a572de780a5749248a4c5df2ff1521e194563475c9928d", size = 13322, upload-time = "2025-09-16T02:49:25.61Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/d9/7ac5c9aa5a89a1a64cd835ae348227f4939406d826e461b85b690a8ba1c2/types_pexpect-4.9.0.20260127-py3-none-any.whl", hash = "sha256:69216c0ebf0fe45ad2900823133959b027e9471e24fc3f2e4c7b00605555da5f", size = 17078, upload-time = "2026-01-27T03:28:29.848Z" },
{ url = "https://files.pythonhosted.org/packages/aa/6d/7740e235a9fb2570968da7d386d7feb511ce68cd23472402ff8cdf7fc78f/types_pexpect-4.9.0.20250916-py3-none-any.whl", hash = "sha256:7fa43cb96042ac58bc74f7c28e5d85782be0ee01344149886849e9d90936fe8a", size = 17057, upload-time = "2025-09-16T02:49:24.546Z" },
]
[[package]]
@@ -7001,11 +6988,11 @@ wheels = [
[[package]]
name = "types-psycopg2"
version = "2.9.21.20260223"
version = "2.9.21.20251012"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/55/1f/4daff0ce5e8e191844e65aaa793ed1b9cb40027dc2700906ecf2b6bcc0ed/types_psycopg2-2.9.21.20260223.tar.gz", hash = "sha256:78ed70de2e56bc6b5c26c8c1da8e9af54e49fdc3c94d1504609f3519e2b84f02", size = 27090, upload-time = "2026-02-23T04:11:18.177Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9b/b3/2d09eaf35a084cffd329c584970a3fa07101ca465c13cad1576d7c392587/types_psycopg2-2.9.21.20251012.tar.gz", hash = "sha256:4cdafd38927da0cfde49804f39ab85afd9c6e9c492800e42f1f0c1a1b0312935", size = 26710, upload-time = "2025-10-12T02:55:39.5Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/e7/c566df58410bc0728348b514e718f0b38fa0d248b5c10599a11494ba25d2/types_psycopg2-2.9.21.20260223-py3-none-any.whl", hash = "sha256:c6228ade72d813b0624f4c03feeb89471950ac27cd0506b5debed6f053086bc8", size = 24919, upload-time = "2026-02-23T04:11:17.214Z" },
{ url = "https://files.pythonhosted.org/packages/ec/0c/05feaf8cb51159f2c0af04b871dab7e98a2f83a3622f5f216331d2dd924c/types_psycopg2-2.9.21.20251012-py3-none-any.whl", hash = "sha256:712bad5c423fe979e357edbf40a07ca40ef775d74043de72bd4544ca328cc57e", size = 24883, upload-time = "2025-10-12T02:55:38.439Z" },
]
[[package]]
@@ -7022,11 +7009,11 @@ wheels = [
[[package]]
name = "types-pymysql"
version = "1.1.0.20251220"
version = "1.1.0.20250916"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/e959dd6d2f8e3b3c3f058d79ac9ece328922a5a8770c707fe9c3a757481c/types_pymysql-1.1.0.20251220.tar.gz", hash = "sha256:ae1c3df32a777489431e2e9963880a0df48f6591e0aa2fd3a6fabd9dee6eca54", size = 22184, upload-time = "2025-12-20T03:07:38.689Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/12/bda1d977c07e0e47502bede1c44a986dd45946494d89e005e04cdeb0f8de/types_pymysql-1.1.0.20250916.tar.gz", hash = "sha256:98d75731795fcc06723a192786662bdfa760e1e00f22809c104fbb47bac5e29b", size = 22131, upload-time = "2025-09-16T02:49:22.039Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/fa/4f4d3bfca9ef6dd17d69ed18b96564c53b32d3ce774132308d0bee849f10/types_pymysql-1.1.0.20251220-py3-none-any.whl", hash = "sha256:fa1082af7dea6c53b6caa5784241924b1296ea3a8d3bd060417352c5e10c0618", size = 23067, upload-time = "2025-12-20T03:07:37.766Z" },
{ url = "https://files.pythonhosted.org/packages/21/eb/a225e32a6e7b196af67ab2f1b07363595f63255374cc3b88bfdab53b4ee8/types_pymysql-1.1.0.20250916-py3-none-any.whl", hash = "sha256:873eb9836bb5e3de4368cc7010ca72775f86e9692a5c7810f8c7f48da082e55b", size = 23063, upload-time = "2025-09-16T02:49:20.933Z" },
]
[[package]]
@@ -7044,11 +7031,11 @@ wheels = [
[[package]]
name = "types-python-dateutil"
version = "2.9.0.20260305"
version = "2.9.0.20251115"
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/6a/36/06d01fb52c0d57e9ad0c237654990920fa41195e4b3d640830dabf9eeb2f/types_python_dateutil-2.9.0.20251115.tar.gz", hash = "sha256:8a47f2c3920f52a994056b8786309b43143faa5a64d4cbb2722d6addabdf1a58", size = 16363, upload-time = "2025-11-15T03:00:13.717Z" }
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/43/0b/56961d3ba517ed0df9b3a27bfda6514f3d01b28d499d1bce9068cfe4edd1/types_python_dateutil-2.9.0.20251115-py3-none-any.whl", hash = "sha256:9cf9c1c582019753b8639a081deefd7e044b9fa36bd8217f565c6c4e36ee0624", size = 18251, upload-time = "2025-11-15T03:00:12.317Z" },
]
[[package]]
@@ -7062,11 +7049,11 @@ wheels = [
[[package]]
name = "types-pywin32"
version = "311.0.0.20260316"
version = "311.0.0.20251008"
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/1a/05/cd94300066241a7abb52238f0dd8d7f4fe1877cf2c72bd1860856604d962/types_pywin32-311.0.0.20251008.tar.gz", hash = "sha256:d6d4faf8e0d7fdc0e0a1ff297b80be07d6d18510f102d793bf54e9e3e86f6d06", size = 329561, upload-time = "2025-10-08T02:51:39.436Z" }
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/af/08/00a38e6b71585e6741d5b3b4cc9dd165cf549b6f1ed78815c6585f8b1b58/types_pywin32-311.0.0.20251008-py3-none-any.whl", hash = "sha256:775e1046e0bad6d29ca47501301cce67002f6661b9cebbeca93f9c388c53fab4", size = 392942, upload-time = "2025-10-08T02:51:38.327Z" },
]
[[package]]
@@ -7123,11 +7110,11 @@ wheels = [
[[package]]
name = "types-setuptools"
version = "82.0.0.20260210"
version = "80.9.0.20250822"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4b/90/796ac8c774a7f535084aacbaa6b7053d16fff5c630eff87c3ecff7896c37/types_setuptools-82.0.0.20260210.tar.gz", hash = "sha256:d9719fbbeb185254480ade1f25327c4654f8c00efda3fec36823379cebcdee58", size = 44768, upload-time = "2026-02-10T04:22:02.107Z" }
sdist = { url = "https://files.pythonhosted.org/packages/19/bd/1e5f949b7cb740c9f0feaac430e301b8f1c5f11a81e26324299ea671a237/types_setuptools-80.9.0.20250822.tar.gz", hash = "sha256:070ea7716968ec67a84c7f7768d9952ff24d28b65b6594797a464f1b3066f965", size = 41296, upload-time = "2025-08-22T03:02:08.771Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/54/3489432b1d9bc713c9d8aa810296b8f5b0088403662959fb63a8acdbd4fc/types_setuptools-82.0.0.20260210-py3-none-any.whl", hash = "sha256:5124a7daf67f195c6054e0f00f1d97c69caad12fdcf9113eba33eff0bce8cd2b", size = 68433, upload-time = "2026-02-10T04:22:00.876Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2d/475bf15c1cdc172e7a0d665b6e373ebfb1e9bf734d3f2f543d668b07a142/types_setuptools-80.9.0.20250822-py3-none-any.whl", hash = "sha256:53bf881cb9d7e46ed12c76ef76c0aaf28cfe6211d3fab12e0b83620b1a8642c3", size = 63179, upload-time = "2025-08-22T03:02:07.643Z" },
]
[[package]]
@@ -7162,28 +7149,28 @@ wheels = [
[[package]]
name = "types-tensorflow"
version = "2.18.0.20260224"
version = "2.18.0.20251008"
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/0d/0a/13bde03fb5a23faaadcca2d6914f865e444334133902310ea05e6ade780c/types_tensorflow-2.18.0.20251008.tar.gz", hash = "sha256:8db03d4dd391a362e2ea796ffdbccb03c082127606d4d852edb7ed9504745933", size = 257550, upload-time = "2025-10-08T02:51:51.104Z" }
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/66/cc/e50e49db621b0cf03c1f3d10be47389de41a02dc9924c3a83a9c1a55bf28/types_tensorflow-2.18.0.20251008-py3-none-any.whl", hash = "sha256:d6b0dd4d81ac6d9c5af803ebcc8ce0f65c5850c063e8b9789dc828898944b5f4", size = 329023, upload-time = "2025-10-08T02:51:50.024Z" },
]
[[package]]
name = "types-tqdm"
version = "4.67.3.20260303"
version = "4.67.0.20250809"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/64/3e7cb0f40c4bf9578098b6873df33a96f7e0de90f3a039e614d22bfde40a/types_tqdm-4.67.3.20260303.tar.gz", hash = "sha256:7bfddb506a75aedb4030fabf4f05c5638c9a3bbdf900d54ec6c82be9034bfb96", size = 18117, upload-time = "2026-03-03T04:03:49.679Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fb/d0/cf498fc630d9fdaf2428b93e60b0e67b08008fec22b78716b8323cf644dc/types_tqdm-4.67.0.20250809.tar.gz", hash = "sha256:02bf7ab91256080b9c4c63f9f11b519c27baaf52718e5fdab9e9606da168d500", size = 17200, upload-time = "2025-08-09T03:17:43.489Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/32/e4a1fce59155c74082f1a42d0ffafa59652bfb8cff35b04d56333877748e/types_tqdm-4.67.3.20260303-py3-none-any.whl", hash = "sha256:459decf677e4b05cef36f9012ef8d6e20578edefb6b78c15bd0b546247eda62d", size = 24572, upload-time = "2026-03-03T04:03:48.913Z" },
{ url = "https://files.pythonhosted.org/packages/3f/13/3ff0781445d7c12730befce0fddbbc7a76e56eb0e7029446f2853238360a/types_tqdm-4.67.0.20250809-py3-none-any.whl", hash = "sha256:1a73053b31fcabf3c1f3e2a9d5ecdba0f301bde47a418cd0e0bdf774827c5c57", size = 24020, upload-time = "2025-08-09T03:17:42.453Z" },
]
[[package]]

View File

@@ -1546,25 +1546,24 @@ SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL=200
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
# Redis URL used for event bus between API and
# Redis URL used for PubSub between API and
# celery worker
# defaults to url constructed from `REDIS_*`
# configurations
EVENT_BUS_REDIS_URL=
# Event transport type. Options are:
PUBSUB_REDIS_URL=
# Pub/sub channel type for streaming events.
# valid options are:
#
# - pubsub: normal Pub/Sub (at-most-once)
# - sharded: sharded Pub/Sub (at-most-once)
# - streams: Redis Streams (at-least-once, recommended to avoid subscriber races)
# - pubsub: for normal Pub/Sub
# - sharded: for sharded Pub/Sub
#
# Note: Before enabling 'streams' in production, estimate your expected event volume and retention needs.
# Configure Redis memory limits and stream trimming appropriately (e.g., MAXLEN and key expiry) to reduce
# the risk of data loss from Redis auto-eviction under memory pressure.
# Also accepts ENV: EVENT_BUS_REDIS_CHANNEL_TYPE.
EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
# Whether to use Redis cluster mode while use redis as event bus.
# It's highly recommended to use sharded Pub/Sub AND redis cluster
# for large deployments.
PUBSUB_REDIS_CHANNEL_TYPE=pubsub
# Whether to use Redis cluster mode while running
# PubSub.
# It's highly recommended to enable this for large deployments.
EVENT_BUS_REDIS_USE_CLUSTERS=false
PUBSUB_REDIS_USE_CLUSTERS=false
# Whether to Enable human input timeout check task
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true

View File

@@ -269,7 +269,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.4-local
image: langgenius/dify-plugin-daemon:0.5.3-local
restart: always
environment:
# Use the shared environment variables.

View File

@@ -123,7 +123,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.4-local
image: langgenius/dify-plugin-daemon:0.5.3-local
restart: always
env_file:
- ./middleware.env

View File

@@ -699,9 +699,9 @@ x-shared-env: &shared-api-worker-env
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE:-1000}
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL:-200}
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: ${SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS:-30}
EVENT_BUS_REDIS_URL: ${EVENT_BUS_REDIS_URL:-}
EVENT_BUS_REDIS_CHANNEL_TYPE: ${EVENT_BUS_REDIS_CHANNEL_TYPE:-pubsub}
EVENT_BUS_REDIS_USE_CLUSTERS: ${EVENT_BUS_REDIS_USE_CLUSTERS:-false}
PUBSUB_REDIS_URL: ${PUBSUB_REDIS_URL:-}
PUBSUB_REDIS_CHANNEL_TYPE: ${PUBSUB_REDIS_CHANNEL_TYPE:-pubsub}
PUBSUB_REDIS_USE_CLUSTERS: ${PUBSUB_REDIS_USE_CLUSTERS:-false}
ENABLE_HUMAN_INPUT_TIMEOUT_TASK: ${ENABLE_HUMAN_INPUT_TIMEOUT_TASK:-true}
HUMAN_INPUT_TIMEOUT_TASK_INTERVAL: ${HUMAN_INPUT_TIMEOUT_TASK_INTERVAL:-1}
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL:-90000}
@@ -976,7 +976,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.4-local
image: langgenius/dify-plugin-daemon:0.5.3-local
restart: always
environment:
# Use the shared environment variables.

View File

@@ -1,5 +1,4 @@
import {
buildGitDiffRevisionArgs,
getChangedBranchCoverage,
getChangedStatementCoverage,
getIgnoredChangedLinesFromSource,
@@ -8,11 +7,6 @@ import {
} from '../scripts/check-components-diff-coverage-lib.mjs'
describe('check-components-diff-coverage helpers', () => {
it('should build exact and merge-base git diff revision args', () => {
expect(buildGitDiffRevisionArgs('base-sha', 'head-sha', 'exact')).toEqual(['base-sha', 'head-sha'])
expect(buildGitDiffRevisionArgs('base-sha', 'head-sha')).toEqual(['base-sha...head-sha'])
})
it('should parse changed line maps from unified diffs', () => {
const diff = [
'diff --git a/web/app/components/share/a.ts b/web/app/components/share/a.ts',

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 { useEffect } from 'react'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
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

@@ -218,7 +218,7 @@ describe('ParamConfigContent', () => {
})
render(<ParamConfigContent />)
const input = screen.getByRole('textbox') as HTMLInputElement
const input = screen.getByRole('spinbutton') as HTMLInputElement
fireEvent.change(input, { target: { value: '4' } })
const updatedFile = getLatestFileConfig()

View File

@@ -184,8 +184,8 @@ describe('dataset-config/params-config', () => {
await user.click(incrementButtons[0])
await waitFor(() => {
const [topKInput] = dialogScope.getAllByRole('textbox')
expect(topKInput).toHaveValue('5')
const [topKInput] = dialogScope.getAllByRole('spinbutton')
expect(topKInput).toHaveValue(5)
})
await user.click(dialogScope.getByRole('button', { name: 'common.operation.save' }))
@@ -197,10 +197,10 @@ describe('dataset-config/params-config', () => {
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const reopenedScope = within(reopenedDialog)
const [reopenedTopKInput] = reopenedScope.getAllByRole('textbox')
const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton')
// Assert
expect(reopenedTopKInput).toHaveValue('5')
expect(reopenedTopKInput).toHaveValue(5)
})
it('should discard changes when cancel is clicked', async () => {
@@ -217,8 +217,8 @@ describe('dataset-config/params-config', () => {
await user.click(incrementButtons[0])
await waitFor(() => {
const [topKInput] = dialogScope.getAllByRole('textbox')
expect(topKInput).toHaveValue('5')
const [topKInput] = dialogScope.getAllByRole('spinbutton')
expect(topKInput).toHaveValue(5)
})
const cancelButton = await dialogScope.findByRole('button', { name: 'common.operation.cancel' })
@@ -231,10 +231,10 @@ describe('dataset-config/params-config', () => {
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const reopenedScope = within(reopenedDialog)
const [reopenedTopKInput] = reopenedScope.getAllByRole('textbox')
const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton')
// Assert
expect(reopenedTopKInput).toHaveValue('4')
expect(reopenedTopKInput).toHaveValue(4)
})
it('should prevent saving when rerank model is required but invalid', async () => {

View File

@@ -4,6 +4,7 @@ import type { AppIconSelection } from '../../base/app-icon-picker'
import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -116,10 +117,10 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
<div className="px-10">
<div className="h-6 w-full 2xl:h-[139px]" />
<div className="pb-6 pt-1">
<span className="text-text-primary title-2xl-semi-bold">{t('newApp.startFromBlank', { ns: 'app' })}</span>
<span className="title-2xl-semi-bold text-text-primary">{t('newApp.startFromBlank', { ns: 'app' })}</span>
</div>
<div className="mb-2 leading-6">
<span className="text-text-secondary system-sm-semibold">{t('newApp.chooseAppType', { ns: 'app' })}</span>
<span className="system-sm-semibold text-text-secondary">{t('newApp.chooseAppType', { ns: 'app' })}</span>
</div>
<div className="flex w-[660px] flex-col gap-4">
<div>
@@ -159,7 +160,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
className="flex cursor-pointer items-center border-0 bg-transparent p-0"
onClick={() => setIsAppTypeExpanded(!isAppTypeExpanded)}
>
<span className="text-text-tertiary system-2xs-medium-uppercase">{t('newApp.forBeginners', { ns: 'app' })}</span>
<span className="system-2xs-medium-uppercase text-text-tertiary">{t('newApp.forBeginners', { ns: 'app' })}</span>
<RiArrowRightSLine className={`ml-1 h-4 w-4 text-text-tertiary transition-transform ${isAppTypeExpanded ? 'rotate-90' : ''}`} />
</button>
</div>
@@ -211,7 +212,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
<div className="flex items-center space-x-3">
<div className="flex-1">
<div className="mb-1 flex h-6 items-center">
<label className="text-text-secondary system-sm-semibold">{t('newApp.captionName', { ns: 'app' })}</label>
<label className="system-sm-semibold text-text-secondary">{t('newApp.captionName', { ns: 'app' })}</label>
</div>
<Input
value={name}
@@ -242,8 +243,8 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
</div>
<div>
<div className="mb-1 flex h-6 items-center">
<label className="text-text-secondary system-sm-semibold">{t('newApp.captionDescription', { ns: 'app' })}</label>
<span className="ml-1 text-text-tertiary system-xs-regular">
<label className="system-sm-semibold text-text-secondary">{t('newApp.captionDescription', { ns: 'app' })}</label>
<span className="system-xs-regular ml-1 text-text-tertiary">
(
{t('newApp.optional', { ns: 'app' })}
)
@@ -259,7 +260,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
</div>
{isAppsFull && <AppsFull className="mt-4" loc="app-create" />}
<div className="flex items-center justify-between pb-10 pt-5">
<div className="flex cursor-pointer items-center gap-1 text-text-tertiary system-xs-regular" onClick={onCreateFromTemplate}>
<div className="system-xs-regular flex cursor-pointer items-center gap-1 text-text-tertiary" onClick={onCreateFromTemplate}>
<span>{t('newApp.noIdeaTip', { ns: 'app' })}</span>
<div className="p-[1px]">
<RiArrowRightLine className="h-3.5 w-3.5" />
@@ -333,8 +334,8 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
onClick={onClick}
>
{icon}
<div className="mb-0.5 mt-2 text-text-secondary system-sm-semibold">{title}</div>
<div className="line-clamp-2 text-text-tertiary system-xs-regular" title={description}>{description}</div>
<div className="system-sm-semibold mb-0.5 mt-2 text-text-secondary">{title}</div>
<div className="system-xs-regular line-clamp-2 text-text-tertiary" title={description}>{description}</div>
</div>
)
}
@@ -366,8 +367,8 @@ function AppPreview({ mode }: { mode: AppModeEnum }) {
const previewInfo = modeToPreviewInfoMap[mode]
return (
<div className="px-8 py-4">
<h4 className="text-text-secondary system-sm-semibold-uppercase">{previewInfo.title}</h4>
<div className="mt-1 min-h-8 max-w-96 text-text-tertiary system-xs-regular">
<h4 className="system-sm-semibold-uppercase text-text-secondary">{previewInfo.title}</h4>
<div className="system-xs-regular mt-1 min-h-8 max-w-96 text-text-tertiary">
<span>{previewInfo.description}</span>
</div>
</div>
@@ -388,7 +389,7 @@ function AppScreenShot({ mode, show }: { mode: AppModeEnum, show: boolean }) {
<source media="(resolution: 1x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`} />
<source media="(resolution: 2x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@2x.png`} />
<source media="(resolution: 3x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@3x.png`} />
<img
<Image
className={show ? '' : 'hidden'}
src={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`}
alt="App Screen Shot"

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'
@@ -6,19 +6,15 @@ import { AppModeEnum } from '@/types/app'
import List from '../list'
const mockReplace = vi.fn()
const mockRouter = { replace: mockReplace }
vi.mock('next/navigation', () => ({
useRouter: () => mockRouter,
useSearchParams: () => new URLSearchParams(''),
}))
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 +32,7 @@ const mockQueryState = {
keywords: '',
isCreatedByMe: false,
}
vi.mock('../hooks/use-apps-query-state', () => ({
default: () => ({
query: mockQueryState,
@@ -45,6 +42,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
@@ -59,6 +57,7 @@ const mockServiceState = {
error: null as Error | null,
hasNextPage: false,
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
}
@@ -100,6 +99,7 @@ vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: defaultAppData,
isLoading: mockServiceState.isLoading,
isFetching: mockServiceState.isFetching,
isFetchingNextPage: mockServiceState.isFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockServiceState.hasNextPage,
@@ -133,13 +133,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 +196,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,11 +209,13 @@ 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 = ''
@@ -215,372 +224,94 @@ describe('List', () => {
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')
it('should keep the creators dropdown visual-only and not update app query state', async () => {
renderList()
fireEvent.click(screen.getByText('app.types.all'))
fireEvent.click(screen.getByText('app.studio.filters.creators'))
fireEvent.click(await screen.findByText('Evan'))
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)
expect(mockSetQuery).not.toHaveBeenCalled()
expect(screen.getByText('app.studio.filters.creators +1')).toBeInTheDocument()
})
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' })
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()
})
})
describe('Search Functionality', () => {
it('should render search input field', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
describe('Snippets Mode', () => {
it('should render the snippets create card and fake snippet card', () => {
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')
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument()
})
it('should handle search input change', () => {
renderList()
it('should filter local snippets by the search input and show the snippet empty state', () => {
renderList({ pageType: 'snippets' })
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
fireEvent.change(input, { target: { value: 'missing snippet' } })
expect(mockSetQuery).toHaveBeenCalled()
expect(screen.queryByText('Tone Rewriter')).not.toBeInTheDocument()
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
})
it('should handle search clear button click', () => {
mockQueryState.keywords = 'existing search'
it('should not render app-only controls in snippets mode', () => {
renderList({ pageType: 'snippets' })
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()
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 handle checkbox change', () => {
renderList()
it('should reserve the infinite-scroll anchor without fetching more pages', () => {
renderList({ pageType: 'snippets' })
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', () => {
renderList()
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()
})
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()
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('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()
})
it('should not call fetchNextPage when not intersecting', () => {
mockServiceState.hasNextPage = true
renderList()
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: false } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('should not call fetchNextPage when loading', () => {
mockServiceState.hasNextPage = true
mockServiceState.isLoading = true
renderList()
if (intersectionCallback) {
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()
})
})
})

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,25 +1,30 @@
'use client'
import type { FC } from 'react'
import type { StudioPageType } from '.'
import type { SnippetListItem } from '@/models/snippet'
import type { App } from '@/types/app'
import { useDebounceFn } from 'ahooks'
import dynamic from 'next/dynamic'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
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 { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum, AppModes } from '@/types/app'
import { getSnippetListMock } from '@/service/use-snippets'
import { cn } from '@/utils/classnames'
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'
@@ -33,25 +38,104 @@ 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 StudioRouteSwitch = ({ pageType, appsLabel, snippetsLabel }: { pageType: StudioPageType, appsLabel: string, snippetsLabel: string }) => {
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>
)
}
const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })
const SnippetCreateCard = () => {
const { t } = useTranslation('snippet')
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>
<div className="mb-1 flex w-full items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary">
<span aria-hidden className="i-ri-sticky-note-add-line mr-2 h-4 w-4 shrink-0" />
{t('newApp.startFromBlank', { ns: 'app' })}
</div>
<div className="flex w-full items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary">
<span aria-hidden className="i-ri-file-upload-line mr-2 h-4 w-4 shrink-0" />
{t('importDSL', { ns: 'app' })}
</div>
</div>
</div>
)
}
const SnippetCard = ({
snippet,
}: {
snippet: SnippetListItem
}) => {
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.status && (
<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">
{snippet.status}
</div>
)}
<div className="flex h-[66px] items-center gap-3 px-[14px] pb-3 pt-[14px]">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-divider-regular text-xl text-white" style={{ background: snippet.iconBackground }}>
<span aria-hidden>{snippet.icon}</span>
</div>
<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.updatedAt}</span>
<span>·</span>
<span className="truncate">{snippet.usage}</span>
</div>
</article>
</Link>
)
}
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 +145,21 @@ 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 [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 +170,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,48 +191,40 @@ const List: FC<Props> = ({
hasNextPage,
error,
refetch,
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
} = useInfiniteAppList(appListQueryParams, {
enabled: isAppsPage && !isCurrentWorkspaceDatasetOperator,
})
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) : false
let observer: IntersectionObserver | undefined
if (error) {
if (observer)
observer.disconnect()
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)
@@ -153,110 +232,148 @@ const List: FC<Props> = ({
}, {
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, hasNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading])
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 handleKeywordsChange = useCallback((value: string) => {
if (isAppsPage) {
setKeywords(value)
handleAppSearch(value)
return
}
setSnippetKeywords(value)
}, [handleAppSearch, isAppsPage, setKeywords])
const { run: handleTagsUpdate } = useDebounceFn((value: string[]) => {
setTagIDs(value)
}, { wait: 500 })
const handleTagsChange = (value: string[]) => {
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(() => getSnippetListMock(), [])
const filteredSnippetItems = useMemo(() => {
const normalizedKeywords = snippetKeywords.trim().toLowerCase()
if (!normalizedKeywords)
return snippetItems
return snippetItems.filter(item =>
item.name.toLowerCase().includes(normalizedKeywords)
|| item.description.toLowerCase().includes(normalizedKeywords),
)
}, [snippetItems, snippetKeywords])
const showSkeleton = isAppsPage && (isLoading || (isFetching && data?.pages?.length === 0))
const hasAnyApp = (data?.pages?.[0]?.total ?? 0) > 0
const hasAnySnippet = filteredSnippetItems.length > 0
const currentKeywords = isAppsPage ? keywords : snippetKeywords
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 && filteredSnippetItems.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} />
)}
</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 +381,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

@@ -1,3 +1,5 @@
/* eslint-disable next/no-img-element */
import type { ImgHTMLAttributes } from 'react'
import type { EmbeddedChatbotContextValue } from '../../context'
import type { AppData } from '@/models/share'
import type { SystemFeatures } from '@/types/feature'
@@ -20,6 +22,15 @@ vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropd
default: () => <div data-testid="view-form-dropdown" />,
}))
// Mock next/image to render a normal img tag for testing
vi.mock('next/image', () => ({
__esModule: true,
default: (props: ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => {
const { unoptimized: _, ...rest } = props
return <img {...rest} />
},
}))
type GlobalPublicStoreMock = {
systemFeatures: SystemFeatures
setSystemFeatures: (systemFeatures: SystemFeatures) => void

View File

@@ -1,7 +1,13 @@
/* eslint-disable next/no-img-element */
import type { ImgHTMLAttributes } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import CheckboxList from '..'
vi.mock('next/image', () => ({
default: (props: ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
}))
describe('checkbox list component', () => {
const options = [
{ label: 'Option 1', value: 'option1' },

View File

@@ -1,5 +1,6 @@
'use client'
import type { FC } from 'react'
import Image from 'next/image'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
@@ -168,7 +169,7 @@ const CheckboxList: FC<CheckboxListProps> = ({
{searchQuery
? (
<div className="flex flex-col items-center justify-center gap-2">
<img alt="search menu" src={SearchMenu.src} width={32} />
<Image alt="search menu" src={SearchMenu} width={32} />
<span className="text-text-secondary system-sm-regular">{t('operation.noSearchResults', { ns: 'common', content: title })}</span>
<Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t('operation.resetKeywords', { ns: 'common' })}</Button>
</div>

View File

@@ -1,7 +1,14 @@
/* eslint-disable next/no-img-element */
import type { ImgHTMLAttributes } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import FileThumb from '../index'
vi.mock('next/image', () => ({
__esModule: true,
default: (props: ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
}))
describe('FileThumb Component', () => {
const mockImageFile = {
name: 'test-image.jpg',

View File

@@ -22,7 +22,7 @@ describe('NumberInputField', () => {
it('should render current number value', () => {
render(<NumberInputField label="Count" />)
expect(screen.getByRole('textbox')).toHaveValue('2')
expect(screen.getByDisplayValue('2')).toBeInTheDocument()
})
it('should update value when users click increment', () => {

View File

@@ -45,7 +45,7 @@ describe('BaseField', () => {
it('should render a number input when configured as number input', () => {
render(<FieldHarness config={createConfig({ type: BaseFieldType.numberInput, label: 'Age' })} initialData={{ fieldA: 20 }} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
expect(screen.getByText('Age')).toBeInTheDocument()
})

View File

@@ -13,7 +13,7 @@ describe('InputNumber Component', () => {
it('renders input with default values', () => {
render(<InputNumber {...defaultProps} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
})
@@ -60,7 +60,7 @@ describe('InputNumber Component', () => {
it('handles direct input changes', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '42' } })
expect(onChange).toHaveBeenCalledWith(42)
@@ -69,25 +69,38 @@ describe('InputNumber Component', () => {
it('handles empty input', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} value={1} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '' } })
expect(onChange).toHaveBeenCalledWith(0)
})
it('does not call onChange when input is not parseable', () => {
it('does not call onChange when parsed value is NaN', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: 'abc' } })
expect(onChange).not.toHaveBeenCalled()
const originalNumber = globalThis.Number
const numberSpy = vi.spyOn(globalThis, 'Number').mockImplementation((val: unknown) => {
if (val === '123') {
return Number.NaN
}
return originalNumber(val)
})
try {
fireEvent.change(input, { target: { value: '123' } })
expect(onChange).not.toHaveBeenCalled()
}
finally {
numberSpy.mockRestore()
}
})
it('does not call onChange when direct input exceeds range', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} max={10} min={0} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '11' } })
@@ -128,7 +141,7 @@ describe('InputNumber Component', () => {
it('disables controls when disabled prop is true', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} disabled />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
const incrementBtn = screen.getByRole('button', { name: /increment/i })
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
@@ -198,16 +211,6 @@ describe('InputNumber Component', () => {
expect(onChange).not.toHaveBeenCalled()
})
it('uses fallback step guard when step is any', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<InputNumber onChange={onChange} value={10} max={10} step="any" />)
const incrementBtn = screen.getByRole('button', { name: /increment/i })
await user.click(incrementBtn)
expect(onChange).not.toHaveBeenCalled()
})
it('prevents decrement below min with custom amount', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
@@ -241,7 +244,7 @@ describe('InputNumber Component', () => {
it('validates input against max constraint', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} max={10} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '15' } })
expect(onChange).not.toHaveBeenCalled()
@@ -250,7 +253,7 @@ describe('InputNumber Component', () => {
it('validates input against min constraint', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} min={5} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '2' } })
expect(onChange).not.toHaveBeenCalled()
@@ -259,7 +262,7 @@ describe('InputNumber Component', () => {
it('accepts input within min and max constraints', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} min={0} max={100} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '50' } })
expect(onChange).toHaveBeenCalledWith(50)
@@ -293,25 +296,6 @@ describe('InputNumber Component', () => {
expect(wrapper).toHaveClass(wrapClassName)
})
it('applies wrapperClassName to outer div for Input compatibility', () => {
const onChange = vi.fn()
const wrapperClassName = 'custom-input-wrapper'
render(<InputNumber onChange={onChange} wrapperClassName={wrapperClassName} />)
const input = screen.getByRole('textbox')
const wrapper = screen.getByTestId('input-number-wrapper')
expect(input).not.toHaveAttribute('wrapperClassName')
expect(wrapper).toHaveClass(wrapperClassName)
})
it('applies styleCss to the input element', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} styleCss={{ color: 'red' }} />)
expect(screen.getByRole('textbox')).toHaveStyle({ color: 'rgb(255, 0, 0)' })
})
it('applies controlWrapClassName to control buttons container', () => {
const onChange = vi.fn()
const controlWrapClassName = 'custom-control-wrap'
@@ -343,7 +327,7 @@ describe('InputNumber Component', () => {
it('handles zero as a valid input', () => {
const onChange = vi.fn()
render(<InputNumber onChange={onChange} min={-5} max={5} value={1} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '0' } })
expect(onChange).toHaveBeenCalledWith(0)

View File

@@ -1,23 +1,10 @@
import type { NumberFieldRoot as BaseNumberFieldRoot } from '@base-ui/react/number-field'
import type { CSSProperties, FC, InputHTMLAttributes } from 'react'
import type { FC } from 'react'
import type { InputProps } from '../input'
import { useCallback } from 'react'
import {
NumberField,
NumberFieldControls,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldUnit,
} from '@/app/components/base/ui/number-field'
import { cn } from '@/utils/classnames'
import Input from '../input'
type InputNumberInputProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'defaultValue' | 'max' | 'min' | 'onChange' | 'size' | 'type' | 'value'
>
export type InputNumberProps = InputNumberInputProps & {
export type InputNumberProps = {
unit?: string
value?: number
onChange: (value: number) => void
@@ -25,69 +12,19 @@ export type InputNumberProps = InputNumberInputProps & {
size?: 'regular' | 'large'
max?: number
min?: number
step?: number | 'any'
defaultValue?: number
disabled?: boolean
wrapClassName?: string
wrapperClassName?: string
styleCss?: CSSProperties
controlWrapClassName?: string
controlClassName?: string
type?: 'number'
}
const STEPPER_REASONS = new Set<BaseNumberFieldRoot.ChangeEventDetails['reason']>([
'increment-press',
'decrement-press',
])
const isValueWithinBounds = (value: number, min?: number, max?: number) => {
if (typeof min === 'number' && value < min)
return false
if (typeof max === 'number' && value > max)
return false
return true
}
const resolveStep = (amount?: number, step?: InputNumberProps['step']) => (
amount ?? (step === 'any' || typeof step === 'number' ? step : undefined) ?? 1
)
const exceedsStepBounds = ({
value,
reason,
stepAmount,
min,
max,
}: {
value?: number
reason: BaseNumberFieldRoot.ChangeEventDetails['reason']
stepAmount: number
min?: number
max?: number
}) => {
if (typeof value !== 'number')
return false
if (reason === 'increment-press' && typeof max === 'number')
return value + stepAmount > max
if (reason === 'decrement-press' && typeof min === 'number')
return value - stepAmount < min
return false
}
} & Omit<InputProps, 'value' | 'onChange' | 'size' | 'min' | 'max' | 'defaultValue'>
export const InputNumber: FC<InputNumberProps> = (props) => {
const {
unit,
className,
wrapperClassName,
styleCss,
onChange,
amount,
amount = 1,
value,
size = 'regular',
max,
@@ -97,97 +34,96 @@ export const InputNumber: FC<InputNumberProps> = (props) => {
controlWrapClassName,
controlClassName,
disabled,
step,
id,
name,
readOnly,
required,
type: _type,
...rest
} = props
const resolvedStep = resolveStep(amount, step)
const stepAmount = typeof resolvedStep === 'number' ? resolvedStep : 1
const isValidValue = useCallback((v: number) => {
if (typeof max === 'number' && v > max)
return false
return !(typeof min === 'number' && v < min)
}, [max, min])
const handleValueChange = useCallback((
nextValue: number | null,
eventDetails: BaseNumberFieldRoot.ChangeEventDetails,
) => {
if (value === undefined && STEPPER_REASONS.has(eventDetails.reason)) {
const inc = () => {
/* v8 ignore next 2 - @preserve */
if (disabled)
return
if (value === undefined) {
onChange(defaultValue ?? 0)
return
}
const newValue = value + amount
if (!isValidValue(newValue))
return
onChange(newValue)
}
const dec = () => {
/* v8 ignore next 2 - @preserve */
if (disabled)
return
if (nextValue === null) {
if (value === undefined) {
onChange(defaultValue ?? 0)
return
}
const newValue = value - amount
if (!isValidValue(newValue))
return
onChange(newValue)
}
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value === '') {
onChange(0)
return
}
if (exceedsStepBounds({
value,
reason: eventDetails.reason,
stepAmount,
min,
max,
})) {
return
}
if (!isValueWithinBounds(nextValue, min, max))
const parsed = Number(e.target.value)
if (Number.isNaN(parsed))
return
onChange(nextValue)
}, [defaultValue, max, min, onChange, stepAmount, value])
if (!isValidValue(parsed))
return
onChange(parsed)
}, [isValidValue, onChange])
return (
<div data-testid="input-number-wrapper" className={cn('flex w-full min-w-0', wrapClassName, wrapperClassName)}>
<NumberField
className="min-w-0 grow"
value={value ?? null}
min={min}
<div data-testid="input-number-wrapper" className={cn('flex', wrapClassName)}>
<Input
{...rest}
// disable default controller
type="number"
className={cn('rounded-r-none no-spinner', className)}
value={value ?? 0}
max={max}
step={resolvedStep}
min={min}
disabled={disabled}
readOnly={readOnly}
required={required}
id={id}
name={name}
allowOutOfRange
onValueChange={handleValueChange}
onChange={handleInputChange}
unit={unit}
size={size}
/>
<div
data-testid="input-number-controls"
className={cn('flex flex-col rounded-r-md border-l border-divider-subtle bg-components-input-bg-normal text-text-tertiary focus:shadow-xs', disabled && 'cursor-not-allowed opacity-50', controlWrapClassName)}
>
<NumberFieldGroup size={size}>
<NumberFieldInput
{...rest}
size={size}
style={styleCss}
className={className}
/>
{unit && (
<NumberFieldUnit size={size}>
{unit}
</NumberFieldUnit>
)}
<NumberFieldControls
data-testid="input-number-controls"
className={controlWrapClassName}
>
<NumberFieldIncrement
aria-label="increment"
size={size}
className={controlClassName}
>
<span aria-hidden="true" className="i-ri-arrow-up-s-line size-3" />
</NumberFieldIncrement>
<NumberFieldDecrement
aria-label="decrement"
size={size}
className={controlClassName}
>
<span aria-hidden="true" className="i-ri-arrow-down-s-line size-3" />
</NumberFieldDecrement>
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
<button
type="button"
onClick={inc}
disabled={disabled}
aria-label="increment"
className={cn(size === 'regular' ? 'pt-1' : 'pt-1.5', 'px-1.5 hover:bg-components-input-bg-hover', disabled && 'cursor-not-allowed hover:bg-transparent', controlClassName)}
>
<span className="i-ri-arrow-up-s-line size-3" />
</button>
<button
type="button"
onClick={dec}
disabled={disabled}
aria-label="decrement"
className={cn(size === 'regular' ? 'pb-1' : 'pb-1.5', 'px-1.5 hover:bg-components-input-bg-hover', disabled && 'cursor-not-allowed hover:bg-transparent', controlClassName)}
>
<span className="i-ri-arrow-down-s-line size-3" />
</button>
</div>
</div>
)
}

View File

@@ -1,6 +1,10 @@
import { render, screen } from '@testing-library/react'
import WithIconCardItem from './with-icon-card-item'
vi.mock('next/image', () => ({
default: ({ unoptimized: _unoptimized, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => <img {...props} />,
}))
describe('WithIconCardItem', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import type { WithIconCardItemProps } from './markdown-with-directive-schema'
import Image from 'next/image'
import { cn } from '@/utils/classnames'
type WithIconItemProps = WithIconCardItemProps & {
@@ -10,13 +11,18 @@ type WithIconItemProps = WithIconCardItemProps & {
function WithIconCardItem({ icon, children, className, iconAlt }: WithIconItemProps) {
return (
<div className={cn('flex h-11 items-center space-x-3 rounded-lg bg-background-section px-2', className)}>
<img
{/*
* unoptimized to "url parameter is not allowed" for external domains despite correct remotePatterns configuration.
* https://github.com/vercel/next.js/issues/88873
*/}
<Image
src={icon}
className="!border-none object-contain"
alt={iconAlt ?? ''}
aria-hidden={iconAlt ? undefined : true}
width={40}
height={40}
unoptimized
/>
<div className="min-w-0 grow overflow-hidden text-text-secondary system-sm-medium [&_p]:!m-0 [&_p]:block [&_p]:w-full [&_p]:overflow-hidden [&_p]:text-ellipsis [&_p]:whitespace-nowrap">
{children}

View File

@@ -7,6 +7,10 @@ import { MarkdownWithDirective } from './index'
const FOUR_COLON_RE = /:{4}/
vi.mock('next/image', () => ({
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
}))
function expectDecorativeIcon(container: HTMLElement, src: string) {
const icon = container.querySelector('img')
expect(icon).toBeInTheDocument()

View File

@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import CredentialSelector from '../index'
// Mock CredentialIcon since it's likely a complex component.
// Mock CredentialIcon since it's likely a complex component or uses next/image
vi.mock('@/app/components/datasets/common/credential-icon', () => ({
CredentialIcon: ({ name }: { name: string }) => <div data-testid="credential-icon">{name}</div>,
}))

View File

@@ -53,7 +53,7 @@ describe('ParamItem', () => {
it('should render InputNumber and Slider', () => {
render(<ParamItem {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
})
})
@@ -68,7 +68,7 @@ describe('ParamItem', () => {
it('should disable InputNumber when enable is false', () => {
render(<ParamItem {...defaultProps} enable={false} />)
expect(screen.getByRole('textbox')).toBeDisabled()
expect(screen.getByRole('spinbutton')).toBeDisabled()
})
it('should disable Slider when enable is false', () => {
@@ -104,7 +104,7 @@ describe('ParamItem', () => {
}
render(<StatefulParamItem />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
await user.clear(input)
await user.type(input, '0.8')
@@ -166,10 +166,14 @@ describe('ParamItem', () => {
expect(slider).toHaveAttribute('aria-valuemax', '10')
})
it('should expose default minimum of 0 when min is not provided', () => {
it('should use default step of 0.1 and min of 0 when not provided', () => {
render(<ParamItem {...defaultProps} />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
const input = screen.getByRole('spinbutton')
// Component renders without error with default step/min
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
expect(input).toHaveAttribute('step', '0.1')
expect(input).toHaveAttribute('min', '0')
})
})
})

View File

@@ -31,7 +31,7 @@ describe('ScoreThresholdItem', () => {
it('should render InputNumber and Slider', () => {
render(<ScoreThresholdItem {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
})
})
@@ -62,7 +62,7 @@ describe('ScoreThresholdItem', () => {
it('should disable controls when enable is false', () => {
render(<ScoreThresholdItem {...defaultProps} enable={false} />)
expect(screen.getByRole('textbox')).toBeDisabled()
expect(screen.getByRole('spinbutton')).toBeDisabled()
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
})
})
@@ -70,19 +70,23 @@ describe('ScoreThresholdItem', () => {
describe('Value Clamping', () => {
it('should clamp values to minimum of 0', () => {
render(<ScoreThresholdItem {...defaultProps} />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('min', '0')
})
it('should clamp values to maximum of 1', () => {
render(<ScoreThresholdItem {...defaultProps} />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('max', '1')
})
it('should use step of 0.01', () => {
render(<ScoreThresholdItem {...defaultProps} value={0.5} />)
expect(screen.getByRole('textbox')).toHaveValue('0.5')
render(<ScoreThresholdItem {...defaultProps} />)
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('step', '0.01')
})
it('should call onChange with rounded value when input changes', async () => {
@@ -103,7 +107,7 @@ describe('ScoreThresholdItem', () => {
}
render(<StatefulScoreThresholdItem />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
await user.clear(input)
await user.type(input, '0.55')
@@ -134,8 +138,8 @@ describe('ScoreThresholdItem', () => {
it('should clamp to max=1 when value exceeds maximum', () => {
render(<ScoreThresholdItem {...defaultProps} value={1.5} />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('1')
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(1)
})
})
})

View File

@@ -36,7 +36,7 @@ describe('TopKItem', () => {
it('should render InputNumber and Slider', () => {
render(<TopKItem {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
})
})
@@ -51,7 +51,7 @@ describe('TopKItem', () => {
it('should disable controls when enable is false', () => {
render(<TopKItem {...defaultProps} enable={false} />)
expect(screen.getByRole('textbox')).toBeDisabled()
expect(screen.getByRole('spinbutton')).toBeDisabled()
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
})
})
@@ -59,20 +59,23 @@ describe('TopKItem', () => {
describe('Value Limits', () => {
it('should use step of 1', () => {
render(<TopKItem {...defaultProps} />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('2')
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('step', '1')
})
it('should use minimum of 1', () => {
render(<TopKItem {...defaultProps} />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('min', '1')
})
it('should use maximum from env (10)', () => {
render(<TopKItem {...defaultProps} />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('max', '10')
})
it('should render slider with max >= 5 so no scaling is applied', () => {

View File

@@ -1,113 +0,0 @@
import { NumberField as BaseNumberField } from '@base-ui/react/number-field'
import { render, screen } from '@testing-library/react'
import {
NumberField,
NumberFieldControls,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldUnit,
} from '../index'
describe('NumberField wrapper', () => {
describe('Exports', () => {
it('should map NumberField to the matching base primitive root', () => {
expect(NumberField).toBe(BaseNumberField.Root)
})
})
describe('Variants', () => {
it('should apply regular variant classes and forward className to group and input', () => {
render(
<NumberField defaultValue={12}>
<NumberFieldGroup size="regular" className="custom-group" data-testid="group">
<NumberFieldInput
aria-label="Regular amount"
placeholder="Regular placeholder"
size="regular"
className="custom-input"
/>
</NumberFieldGroup>
</NumberField>,
)
const group = screen.getByTestId('group')
const input = screen.getByRole('textbox', { name: 'Regular amount' })
expect(group).toHaveClass('radius-md')
expect(group).toHaveClass('custom-group')
expect(input).toHaveAttribute('placeholder', 'Regular placeholder')
expect(input).toHaveClass('px-3')
expect(input).toHaveClass('py-[7px]')
expect(input).toHaveClass('custom-input')
})
it('should apply large variant classes to grouped parts when large size is provided', () => {
render(
<NumberField defaultValue={24}>
<NumberFieldGroup size="large" data-testid="group">
<NumberFieldInput aria-label="Large amount" size="large" />
<NumberFieldUnit size="large">ms</NumberFieldUnit>
<NumberFieldControls>
<NumberFieldIncrement aria-label="Increment amount" size="large" />
<NumberFieldDecrement aria-label="Decrement amount" size="large" />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>,
)
const group = screen.getByTestId('group')
const input = screen.getByRole('textbox', { name: 'Large amount' })
const unit = screen.getByText('ms')
const increment = screen.getByRole('button', { name: 'Increment amount' })
const decrement = screen.getByRole('button', { name: 'Decrement amount' })
expect(group).toHaveClass('radius-lg')
expect(input).toHaveClass('px-4')
expect(input).toHaveClass('py-2')
expect(unit).toHaveClass('flex')
expect(unit).toHaveClass('items-center')
expect(unit).toHaveClass('pr-2.5')
expect(increment).toHaveClass('pt-1.5')
expect(decrement).toHaveClass('pb-1.5')
})
})
describe('Passthrough props', () => {
it('should forward passthrough props and custom classes to controls and buttons', () => {
render(
<NumberField defaultValue={8}>
<NumberFieldGroup size="regular">
<NumberFieldInput aria-label="Amount" size="regular" />
<NumberFieldControls className="custom-controls" data-testid="controls">
<NumberFieldIncrement
aria-label="Increment"
size="regular"
className="custom-increment"
data-track-id="increment-track"
/>
<NumberFieldDecrement
aria-label="Decrement"
size="regular"
className="custom-decrement"
data-track-id="decrement-track"
/>
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>,
)
const controls = screen.getByTestId('controls')
const increment = screen.getByRole('button', { name: 'Increment' })
const decrement = screen.getByRole('button', { name: 'Decrement' })
expect(controls).toHaveClass('border-l')
expect(controls).toHaveClass('custom-controls')
expect(increment).toHaveClass('custom-increment')
expect(increment).toHaveAttribute('data-track-id', 'increment-track')
expect(decrement).toHaveClass('custom-decrement')
expect(decrement).toHaveAttribute('data-track-id', 'decrement-track')
})
})
})

View File

@@ -1,211 +0,0 @@
'use client'
import type { VariantProps } from 'class-variance-authority'
import { NumberField as BaseNumberField } from '@base-ui/react/number-field'
import { cva } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '@/utils/classnames'
export const NumberField = BaseNumberField.Root
export const numberFieldGroupVariants = cva(
[
'group/number-field flex w-full min-w-0 items-stretch overflow-hidden border border-transparent bg-components-input-bg-normal text-components-input-text-filled shadow-none outline-none transition-[background-color,border-color,box-shadow]',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'data-[focused]:border-components-input-border-active data-[focused]:bg-components-input-bg-active data-[focused]:shadow-xs',
'data-[disabled]:cursor-not-allowed data-[disabled]:border-transparent data-[disabled]:bg-components-input-bg-disabled data-[disabled]:text-components-input-text-filled-disabled',
'data-[disabled]:hover:border-transparent data-[disabled]:hover:bg-components-input-bg-disabled',
'data-[readonly]:shadow-none motion-reduce:transition-none',
],
{
variants: {
size: {
regular: 'radius-md',
large: 'radius-lg',
},
},
defaultVariants: {
size: 'regular',
},
},
)
type NumberFieldGroupProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Group> & VariantProps<typeof numberFieldGroupVariants>
export function NumberFieldGroup({
className,
size = 'regular',
...props
}: NumberFieldGroupProps) {
return (
<BaseNumberField.Group
className={cn(numberFieldGroupVariants({ size }), className)}
{...props}
/>
)
}
export const numberFieldInputVariants = cva(
[
'w-0 min-w-0 flex-1 appearance-none border-0 bg-transparent text-components-input-text-filled caret-primary-600 outline-none',
'placeholder:text-components-input-text-placeholder',
'disabled:cursor-not-allowed disabled:text-components-input-text-filled-disabled disabled:placeholder:text-components-input-text-disabled',
'data-[readonly]:cursor-default',
],
{
variants: {
size: {
regular: 'px-3 py-[7px] system-sm-regular',
large: 'px-4 py-2 system-md-regular',
},
},
defaultVariants: {
size: 'regular',
},
},
)
type NumberFieldInputProps = Omit<React.ComponentPropsWithoutRef<typeof BaseNumberField.Input>, 'size'> & VariantProps<typeof numberFieldInputVariants>
export function NumberFieldInput({
className,
size = 'regular',
...props
}: NumberFieldInputProps) {
return (
<BaseNumberField.Input
className={cn(numberFieldInputVariants({ size }), className)}
{...props}
/>
)
}
export const numberFieldUnitVariants = cva(
'flex shrink-0 items-center self-stretch text-text-tertiary system-sm-regular',
{
variants: {
size: {
regular: 'pr-2',
large: 'pr-2.5',
},
},
defaultVariants: {
size: 'regular',
},
},
)
type NumberFieldUnitProps = React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof numberFieldUnitVariants>
export function NumberFieldUnit({
className,
size = 'regular',
...props
}: NumberFieldUnitProps) {
return (
<span
className={cn(numberFieldUnitVariants({ size }), className)}
{...props}
/>
)
}
export const numberFieldControlsVariants = cva(
'flex shrink-0 flex-col items-stretch border-l border-divider-subtle bg-transparent text-text-tertiary',
)
type NumberFieldControlsProps = React.HTMLAttributes<HTMLDivElement>
export function NumberFieldControls({
className,
...props
}: NumberFieldControlsProps) {
return (
<div
className={cn(numberFieldControlsVariants(), className)}
{...props}
/>
)
}
export const numberFieldControlButtonVariants = cva(
[
'flex items-center justify-center px-1.5 text-text-tertiary outline-none transition-colors',
'hover:bg-components-input-bg-hover focus-visible:bg-components-input-bg-hover',
'disabled:cursor-not-allowed disabled:hover:bg-transparent',
'group-data-[disabled]/number-field:cursor-not-allowed group-data-[disabled]/number-field:hover:bg-transparent',
'group-data-[readonly]/number-field:cursor-default group-data-[readonly]/number-field:hover:bg-transparent',
'motion-reduce:transition-none',
],
{
variants: {
size: {
regular: '',
large: '',
},
direction: {
increment: '',
decrement: '',
},
},
compoundVariants: [
{
size: 'regular',
direction: 'increment',
className: 'pt-1',
},
{
size: 'regular',
direction: 'decrement',
className: 'pb-1',
},
{
size: 'large',
direction: 'increment',
className: 'pt-1.5',
},
{
size: 'large',
direction: 'decrement',
className: 'pb-1.5',
},
],
defaultVariants: {
size: 'regular',
direction: 'increment',
},
},
)
type NumberFieldButtonVariantProps = Omit<
VariantProps<typeof numberFieldControlButtonVariants>,
'direction'
>
type NumberFieldButtonProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Increment> & NumberFieldButtonVariantProps
export function NumberFieldIncrement({
className,
size = 'regular',
...props
}: NumberFieldButtonProps) {
return (
<BaseNumberField.Increment
className={cn(numberFieldControlButtonVariants({ size, direction: 'increment' }), className)}
{...props}
/>
)
}
export function NumberFieldDecrement({
className,
size = 'regular',
...props
}: NumberFieldButtonProps) {
return (
<BaseNumberField.Decrement
className={cn(numberFieldControlButtonVariants({ size, direction: 'decrement' }), className)}
{...props}
/>
)
}

View File

@@ -1,179 +1,496 @@
import type { AppContextValue } from '@/context/app-context'
import type { SystemFeatures } from '@/types/feature'
import type { Mock } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as React from 'react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { useToastContext } from '@/app/components/base/toast/context'
import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config'
import { contactSalesUrl } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import {
initialLangGeniusVersionInfo,
initialWorkspaceInfo,
useAppContext,
userProfilePlaceholder,
} from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { defaultSystemFeatures } from '@/types/feature'
import CustomPage from '../index'
// Mock external dependencies only
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
vi.mock('@/context/app-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/app-context')>()
return {
...actual,
useAppContext: vi.fn(),
}
})
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
// Mock the complex CustomWebAppBrand component to avoid dependency issues
// This is acceptable because it has complex dependencies (fetch, APIs)
vi.mock('@/app/components/custom/custom-web-app-brand', () => ({
default: () => <div data-testid="custom-web-app-brand">CustomWebAppBrand</div>,
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: vi.fn(),
}))
const mockUseProviderContext = vi.mocked(useProviderContext)
const mockUseModalContext = vi.mocked(useModalContext)
const mockUseAppContext = vi.mocked(useAppContext)
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
const mockUseToastContext = vi.mocked(useToastContext)
const createProviderContext = ({
enableBilling = false,
planType = Plan.professional,
}: {
enableBilling?: boolean
planType?: Plan
} = {}) => {
return createMockProviderContextValue({
enableBilling,
plan: {
...defaultPlan,
type: planType,
},
})
}
const createAppContextValue = (): AppContextValue => ({
userProfile: userProfilePlaceholder,
mutateUserProfile: vi.fn(),
currentWorkspace: {
...initialWorkspaceInfo,
custom_config: {
replace_webapp_logo: 'https://example.com/replace.png',
remove_webapp_brand: false,
},
},
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceEditor: false,
isCurrentWorkspaceDatasetOperator: false,
mutateCurrentWorkspace: vi.fn(),
langGeniusVersionInfo: initialLangGeniusVersionInfo,
useSelector: vi.fn() as unknown as AppContextValue['useSelector'],
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
})
const createSystemFeatures = (): SystemFeatures => ({
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: true,
workspace_logo: 'https://example.com/workspace-logo.png',
},
})
describe('CustomPage', () => {
const setShowPricingModal = vi.fn()
const mockSetShowPricingModal = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockUseProviderContext.mockReturnValue(createProviderContext())
mockUseModalContext.mockReturnValue({
setShowPricingModal,
} as unknown as ReturnType<typeof useModalContext>)
mockUseAppContext.mockReturnValue(createAppContextValue())
mockUseGlobalPublicStore.mockImplementation(selector => selector({
systemFeatures: createSystemFeatures(),
setSystemFeatures: vi.fn(),
}))
mockUseToastContext.mockReturnValue({
notify: vi.fn(),
} as unknown as ReturnType<typeof useToastContext>)
// Default mock setup
;(useModalContext as Mock).mockReturnValue({
setShowPricingModal: mockSetShowPricingModal,
})
})
// Integration coverage for the page and its child custom brand section.
describe('Rendering', () => {
it('should render the custom brand configuration by default', () => {
render(<CustomPage />)
// Helper function to render with different provider contexts
const renderWithContext = (overrides = {}) => {
;(useProviderContext as Mock).mockReturnValue(
createMockProviderContextValue(overrides),
)
return render(<CustomPage />)
}
expect(screen.getByText('custom.webapp.removeBrand')).toBeInTheDocument()
expect(screen.getByText('Chatflow App')).toBeInTheDocument()
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderWithContext()
// Assert
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
})
it('should always render CustomWebAppBrand component', () => {
// Arrange & Act
renderWithContext({
enableBilling: true,
plan: { type: Plan.sandbox },
})
// Assert
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
})
it('should have correct layout structure', () => {
// Arrange & Act
const { container } = renderWithContext()
// Assert
const mainContainer = container.querySelector('.flex.flex-col')
expect(mainContainer).toBeInTheDocument()
})
})
// Conditional Rendering - Billing Tip
describe('Billing Tip Banner', () => {
it('should show billing tip when enableBilling is true and plan is sandbox', () => {
// Arrange & Act
renderWithContext({
enableBilling: true,
plan: { type: Plan.sandbox },
})
// Assert
expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument()
expect(screen.getByText('custom.upgradeTip.des')).toBeInTheDocument()
expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument()
})
it('should not show billing tip when enableBilling is false', () => {
// Arrange & Act
renderWithContext({
enableBilling: false,
plan: { type: Plan.sandbox },
})
// Assert
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument()
})
it('should not show billing tip when plan is professional', () => {
// Arrange & Act
renderWithContext({
enableBilling: true,
plan: { type: Plan.professional },
})
// Assert
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument()
})
it('should not show billing tip when plan is team', () => {
// Arrange & Act
renderWithContext({
enableBilling: true,
plan: { type: Plan.team },
})
// Assert
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument()
})
it('should have correct gradient styling for billing tip banner', () => {
// Arrange & Act
const { container } = renderWithContext({
enableBilling: true,
plan: { type: Plan.sandbox },
})
// Assert
const banner = container.querySelector('.bg-gradient-to-r')
expect(banner).toBeInTheDocument()
expect(banner).toHaveClass('from-components-input-border-active-prompt-1')
expect(banner).toHaveClass('to-components-input-border-active-prompt-2')
expect(banner).toHaveClass('p-4')
expect(banner).toHaveClass('pl-6')
expect(banner).toHaveClass('shadow-lg')
})
})
// Conditional Rendering - Contact Sales
describe('Contact Sales Section', () => {
it('should show contact section when enableBilling is true and plan is professional', () => {
// Arrange & Act
const { container } = renderWithContext({
enableBilling: true,
plan: { type: Plan.professional },
})
// Assert - Check that contact section exists with all parts
const contactSection = container.querySelector('.absolute.bottom-0')
expect(contactSection).toBeInTheDocument()
expect(contactSection).toHaveTextContent('custom.customize.prefix')
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
expect(contactSection).toHaveTextContent('custom.customize.suffix')
})
it('should show contact section when enableBilling is true and plan is team', () => {
// Arrange & Act
const { container } = renderWithContext({
enableBilling: true,
plan: { type: Plan.team },
})
// Assert - Check that contact section exists with all parts
const contactSection = container.querySelector('.absolute.bottom-0')
expect(contactSection).toBeInTheDocument()
expect(contactSection).toHaveTextContent('custom.customize.prefix')
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
expect(contactSection).toHaveTextContent('custom.customize.suffix')
})
it('should not show contact section when enableBilling is false', () => {
// Arrange & Act
renderWithContext({
enableBilling: false,
plan: { type: Plan.professional },
})
// Assert
expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument()
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
})
it('should show the upgrade banner and open pricing modal for sandbox billing', async () => {
const user = userEvent.setup()
mockUseProviderContext.mockReturnValue(createProviderContext({
it('should not show contact section when plan is sandbox', () => {
// Arrange & Act
renderWithContext({
enableBilling: true,
planType: Plan.sandbox,
}))
plan: { type: Plan.sandbox },
})
render(<CustomPage />)
// Assert
expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument()
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
})
it('should render contact link with correct URL', () => {
// Arrange & Act
renderWithContext({
enableBilling: true,
plan: { type: Plan.professional },
})
// Assert
const link = screen.getByText('custom.customize.contactUs').closest('a')
expect(link).toHaveAttribute('href', contactSalesUrl)
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should have correct positioning for contact section', () => {
// Arrange & Act
const { container } = renderWithContext({
enableBilling: true,
plan: { type: Plan.professional },
})
// Assert
const contactSection = container.querySelector('.absolute.bottom-0')
expect(contactSection).toBeInTheDocument()
expect(contactSection).toHaveClass('h-[50px]')
expect(contactSection).toHaveClass('text-xs')
expect(contactSection).toHaveClass('leading-[50px]')
})
})
// User Interactions
describe('User Interactions', () => {
it('should call setShowPricingModal when upgrade button is clicked', async () => {
// Arrange
const user = userEvent.setup()
renderWithContext({
enableBilling: true,
plan: { type: Plan.sandbox },
})
// Act
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
await user.click(upgradeButton)
// Assert
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should call setShowPricingModal without arguments', async () => {
// Arrange
const user = userEvent.setup()
renderWithContext({
enableBilling: true,
plan: { type: Plan.sandbox },
})
// Act
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
await user.click(upgradeButton)
// Assert
expect(mockSetShowPricingModal).toHaveBeenCalledWith()
})
it('should handle multiple clicks on upgrade button', async () => {
// Arrange
const user = userEvent.setup()
renderWithContext({
enableBilling: true,
plan: { type: Plan.sandbox },
})
// Act
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
await user.click(upgradeButton)
await user.click(upgradeButton)
await user.click(upgradeButton)
// Assert
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(3)
})
it('should have correct button styling for upgrade button', () => {
// Arrange & Act
renderWithContext({
enableBilling: true,
plan: { type: Plan.sandbox },
})
// Assert
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
expect(upgradeButton).toHaveClass('cursor-pointer')
expect(upgradeButton).toHaveClass('bg-white')
expect(upgradeButton).toHaveClass('text-text-accent')
expect(upgradeButton).toHaveClass('rounded-3xl')
})
})
// Edge Cases (REQUIRED)
describe('Edge Cases', () => {
it('should handle undefined plan type gracefully', () => {
// Arrange & Act
expect(() => {
renderWithContext({
enableBilling: true,
plan: { type: undefined },
})
}).not.toThrow()
// Assert
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
})
it('should handle plan without type property', () => {
// Arrange & Act
expect(() => {
renderWithContext({
enableBilling: true,
plan: { type: null },
})
}).not.toThrow()
// Assert
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
})
it('should not show any banners when both conditions are false', () => {
// Arrange & Act
renderWithContext({
enableBilling: false,
plan: { type: Plan.sandbox },
})
// Assert
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument()
})
it('should handle enableBilling undefined', () => {
// Arrange & Act
expect(() => {
renderWithContext({
enableBilling: undefined,
plan: { type: Plan.sandbox },
})
}).not.toThrow()
// Assert
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
})
it('should show only billing tip for sandbox plan, not contact section', () => {
// Arrange & Act
renderWithContext({
enableBilling: true,
plan: { type: Plan.sandbox },
})
// Assert
expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument()
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
await user.click(screen.getByText('billing.upgradeBtn.encourageShort'))
expect(setShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should show the contact link for professional workspaces', () => {
mockUseProviderContext.mockReturnValue(createProviderContext({
it('should show only contact section for professional plan, not billing tip', () => {
// Arrange & Act
renderWithContext({
enableBilling: true,
planType: Plan.professional,
}))
plan: { type: Plan.professional },
})
render(<CustomPage />)
const contactLink = screen.getByText('custom.customize.contactUs').closest('a')
// Assert
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
expect(contactLink).toHaveAttribute('href', contactSalesUrl)
expect(contactLink).toHaveAttribute('target', '_blank')
expect(contactLink).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should show the contact link for team workspaces', () => {
mockUseProviderContext.mockReturnValue(createProviderContext({
enableBilling: true,
planType: Plan.team,
}))
render(<CustomPage />)
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
})
it('should hide both billing sections when billing is disabled', () => {
mockUseProviderContext.mockReturnValue(createProviderContext({
it('should show only contact section for team plan, not billing tip', () => {
// Arrange & Act
renderWithContext({
enableBilling: true,
plan: { type: Plan.team },
})
// Assert
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
})
it('should handle empty plan object', () => {
// Arrange & Act
expect(() => {
renderWithContext({
enableBilling: true,
plan: {},
})
}).not.toThrow()
// Assert
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
})
})
// Accessibility Tests
describe('Accessibility', () => {
it('should have clickable upgrade button', () => {
// Arrange & Act
renderWithContext({
enableBilling: true,
plan: { type: Plan.sandbox },
})
// Assert
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
expect(upgradeButton).toBeInTheDocument()
expect(upgradeButton).toHaveClass('cursor-pointer')
})
it('should have proper external link attributes on contact link', () => {
// Arrange & Act
renderWithContext({
enableBilling: true,
plan: { type: Plan.professional },
})
// Assert
const link = screen.getByText('custom.customize.contactUs').closest('a')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
expect(link).toHaveAttribute('target', '_blank')
})
it('should have proper text hierarchy in billing tip', () => {
// Arrange & Act
renderWithContext({
enableBilling: true,
plan: { type: Plan.sandbox },
})
// Assert
const title = screen.getByText('custom.upgradeTip.title')
const description = screen.getByText('custom.upgradeTip.des')
expect(title).toHaveClass('title-xl-semi-bold')
expect(description).toHaveClass('system-sm-regular')
})
it('should use semantic color classes', () => {
// Arrange & Act
renderWithContext({
enableBilling: true,
plan: { type: Plan.sandbox },
})
// Assert - Check that the billing tip has text content (which implies semantic colors)
expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument()
})
})
// Integration Tests
describe('Integration', () => {
it('should render both CustomWebAppBrand and billing tip together', () => {
// Arrange & Act
renderWithContext({
enableBilling: true,
plan: { type: Plan.sandbox },
})
// Assert
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument()
})
it('should render both CustomWebAppBrand and contact section together', () => {
// Arrange & Act
renderWithContext({
enableBilling: true,
plan: { type: Plan.professional },
})
// Assert
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
})
it('should render only CustomWebAppBrand when no billing conditions met', () => {
// Arrange & Act
renderWithContext({
enableBilling: false,
planType: Plan.sandbox,
}))
render(<CustomPage />)
plan: { type: Plan.sandbox },
})
// Assert
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
})

View File

@@ -1,158 +1,147 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import useWebAppBrand from '../hooks/use-web-app-brand'
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
import { useToastContext } from '@/app/components/base/toast/context'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { updateCurrentWorkspace } from '@/service/common'
import CustomWebAppBrand from '../index'
vi.mock('../hooks/use-web-app-brand', () => ({
default: vi.fn(),
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: vi.fn(),
}))
vi.mock('@/service/common', () => ({
updateCurrentWorkspace: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/app/components/base/image-uploader/utils', () => ({
imageUpload: vi.fn(),
getImageUploadErrorMessage: vi.fn(),
}))
const mockUseWebAppBrand = vi.mocked(useWebAppBrand)
const mockNotify = vi.fn()
const mockUseToastContext = vi.mocked(useToastContext)
const mockUpdateCurrentWorkspace = vi.mocked(updateCurrentWorkspace)
const mockUseAppContext = vi.mocked(useAppContext)
const mockUseProviderContext = vi.mocked(useProviderContext)
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
const mockImageUpload = vi.mocked(imageUpload)
const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage)
const createHookState = (overrides: Partial<ReturnType<typeof useWebAppBrand>> = {}): ReturnType<typeof useWebAppBrand> => ({
fileId: '',
imgKey: 100,
uploadProgress: 0,
uploading: false,
webappLogo: 'https://example.com/replace.png',
webappBrandRemoved: false,
uploadDisabled: false,
workspaceLogo: 'https://example.com/workspace-logo.png',
isSandbox: false,
isCurrentWorkspaceManager: true,
handleApply: vi.fn(),
handleCancel: vi.fn(),
handleChange: vi.fn(),
handleRestore: vi.fn(),
handleSwitch: vi.fn(),
...overrides,
})
const renderComponent = (overrides: Partial<ReturnType<typeof useWebAppBrand>> = {}) => {
const hookState = createHookState(overrides)
mockUseWebAppBrand.mockReturnValue(hookState)
return {
hookState,
...render(<CustomWebAppBrand />),
}
const defaultPlanUsage = {
buildApps: 0,
teamMembers: 0,
annotatedResponse: 0,
documentsUploadQuota: 0,
apiRateLimit: 0,
triggerEvents: 0,
vectorSpace: 0,
}
const renderComponent = () => render(<CustomWebAppBrand />)
describe('CustomWebAppBrand', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseToastContext.mockReturnValue({ notify: mockNotify } as unknown as ReturnType<typeof useToastContext>)
mockUpdateCurrentWorkspace.mockResolvedValue({} as unknown as Awaited<ReturnType<typeof updateCurrentWorkspace>>)
mockUseAppContext.mockReturnValue({
currentWorkspace: {
custom_config: {
replace_webapp_logo: 'https://example.com/replace.png',
remove_webapp_brand: false,
},
},
mutateCurrentWorkspace: vi.fn(),
isCurrentWorkspaceManager: true,
} as unknown as ReturnType<typeof useAppContext>)
mockUseProviderContext.mockReturnValue({
plan: {
type: Plan.professional,
usage: defaultPlanUsage,
total: defaultPlanUsage,
reset: {},
},
enableBilling: false,
} as unknown as ReturnType<typeof useProviderContext>)
const systemFeaturesState = {
branding: {
enabled: true,
workspace_logo: 'https://example.com/workspace-logo.png',
},
}
mockUseGlobalPublicStore.mockImplementation(selector => selector ? selector({ systemFeatures: systemFeaturesState, setSystemFeatures: vi.fn() } as unknown as ReturnType<typeof useGlobalPublicStore.getState>) : { systemFeatures: systemFeaturesState })
mockGetImageUploadErrorMessage.mockReturnValue('upload error')
})
// Integration coverage for the root component with the hook mocked at the boundary.
describe('Rendering', () => {
it('should render the upload controls and preview cards with restore action', () => {
renderComponent()
it('disables upload controls when the user cannot manage the workspace', () => {
mockUseAppContext.mockReturnValue({
currentWorkspace: {
custom_config: {
replace_webapp_logo: '',
remove_webapp_brand: false,
},
},
mutateCurrentWorkspace: vi.fn(),
isCurrentWorkspaceManager: false,
} as unknown as ReturnType<typeof useAppContext>)
expect(screen.getByText('custom.webapp.removeBrand')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'custom.restore' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'custom.change' })).toBeInTheDocument()
expect(screen.getByText('Chatflow App')).toBeInTheDocument()
expect(screen.getByText('Workflow App')).toBeInTheDocument()
})
it('should hide the restore action when uploads are disabled or no logo is configured', () => {
renderComponent({
uploadDisabled: true,
webappLogo: '',
})
expect(screen.queryByRole('button', { name: 'custom.restore' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'custom.upload' })).toBeDisabled()
})
it('should show the uploading button and failure message when upload state requires it', () => {
renderComponent({
uploading: true,
uploadProgress: -1,
})
expect(screen.getByRole('button', { name: 'custom.uploading' })).toBeDisabled()
expect(screen.getByText('custom.uploadedFail')).toBeInTheDocument()
})
it('should show apply and cancel actions when a new file is ready', () => {
renderComponent({
fileId: 'new-logo',
})
expect(screen.getByRole('button', { name: 'custom.apply' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
})
it('should disable the switch when sandbox restrictions are active', () => {
renderComponent({
isSandbox: true,
})
expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true')
})
it('should default the switch to unchecked when brand removal state is missing', () => {
const { container } = renderComponent({
webappBrandRemoved: undefined,
})
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
expect(container.querySelector('.opacity-30')).not.toBeInTheDocument()
})
it('should dim the upload row when brand removal is enabled', () => {
const { container } = renderComponent({
webappBrandRemoved: true,
uploadDisabled: true,
})
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
expect(container.querySelector('.opacity-30')).toBeInTheDocument()
})
const { container } = renderComponent()
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
expect(fileInput).toBeDisabled()
})
// User interactions delegated to the hook callbacks.
describe('Interactions', () => {
it('should delegate switch changes to the hook handler', () => {
const { hookState } = renderComponent()
it('toggles remove brand switch and calls the backend + mutate', async () => {
const mutateMock = vi.fn()
mockUseAppContext.mockReturnValue({
currentWorkspace: {
custom_config: {
replace_webapp_logo: '',
remove_webapp_brand: false,
},
},
mutateCurrentWorkspace: mutateMock,
isCurrentWorkspaceManager: true,
} as unknown as ReturnType<typeof useAppContext>)
fireEvent.click(screen.getByRole('switch'))
renderComponent()
const switchInput = screen.getByRole('switch')
fireEvent.click(switchInput)
expect(hookState.handleSwitch).toHaveBeenCalledWith(true)
await waitFor(() => expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({
url: '/workspaces/custom-config',
body: { remove_webapp_brand: true },
}))
await waitFor(() => expect(mutateMock).toHaveBeenCalled())
})
it('shows cancel/apply buttons after successful upload and cancels properly', async () => {
mockImageUpload.mockImplementation(({ onProgressCallback, onSuccessCallback }) => {
onProgressCallback(50)
onSuccessCallback({ id: 'new-logo' })
})
it('should delegate file input changes and reset the native input value on click', () => {
const { container, hookState } = renderComponent()
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const file = new File(['logo'], 'logo.png', { type: 'image/png' })
const { container } = renderComponent()
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const testFile = new File(['content'], 'logo.png', { type: 'image/png' })
fireEvent.change(fileInput, { target: { files: [testFile] } })
Object.defineProperty(fileInput, 'value', {
configurable: true,
value: 'stale-selection',
writable: true,
})
await waitFor(() => expect(mockImageUpload).toHaveBeenCalled())
await waitFor(() => screen.getByRole('button', { name: 'custom.apply' }))
fireEvent.click(fileInput)
fireEvent.change(fileInput, {
target: { files: [file] },
})
const cancelButton = screen.getByRole('button', { name: 'common.operation.cancel' })
fireEvent.click(cancelButton)
expect(fileInput.value).toBe('')
expect(hookState.handleChange).toHaveBeenCalledTimes(1)
})
it('should delegate restore, cancel, and apply actions to the hook handlers', () => {
const { hookState } = renderComponent({
fileId: 'new-logo',
})
fireEvent.click(screen.getByRole('button', { name: 'custom.restore' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
fireEvent.click(screen.getByRole('button', { name: 'custom.apply' }))
expect(hookState.handleRestore).toHaveBeenCalledTimes(1)
expect(hookState.handleCancel).toHaveBeenCalledTimes(1)
expect(hookState.handleApply).toHaveBeenCalledTimes(1)
})
await waitFor(() => expect(screen.queryByRole('button', { name: 'custom.apply' })).toBeNull())
})
})

View File

@@ -1,31 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ChatPreviewCard from '../chat-preview-card'
describe('ChatPreviewCard', () => {
it('should render the chat preview with the powered-by footer', () => {
render(
<ChatPreviewCard
imgKey={8}
webappLogo="https://example.com/custom-logo.png"
/>,
)
expect(screen.getByText('Chatflow App')).toBeInTheDocument()
expect(screen.getByText('Hello! How can I assist you today?')).toBeInTheDocument()
expect(screen.getByText('Talk to Dify')).toBeInTheDocument()
expect(screen.getByText('POWERED BY')).toBeInTheDocument()
})
it('should hide chat branding footer when brand removal is enabled', () => {
render(
<ChatPreviewCard
imgKey={8}
webappBrandRemoved
webappLogo="https://example.com/custom-logo.png"
/>,
)
expect(screen.queryByText('POWERED BY')).not.toBeInTheDocument()
})
})

View File

@@ -1,41 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import PoweredByBrand from '../powered-by-brand'
describe('PoweredByBrand', () => {
it('should render the workspace logo when available', () => {
render(
<PoweredByBrand
imgKey={1}
workspaceLogo="https://example.com/workspace-logo.png"
webappLogo="https://example.com/custom-logo.png"
/>,
)
expect(screen.getByText('POWERED BY')).toBeInTheDocument()
expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/workspace-logo.png')
})
it('should fall back to the custom web app logo when workspace branding is unavailable', () => {
render(
<PoweredByBrand
imgKey={42}
webappLogo="https://example.com/custom-logo.png"
/>,
)
expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/custom-logo.png?hash=42')
})
it('should fall back to the Dify logo when no custom branding exists', () => {
render(<PoweredByBrand imgKey={7} />)
expect(screen.getByAltText('Dify logo')).toBeInTheDocument()
})
it('should render nothing when branding is removed', () => {
const { container } = render(<PoweredByBrand imgKey={7} webappBrandRemoved />)
expect(container).toBeEmptyDOMElement()
})
})

View File

@@ -1,32 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import WorkflowPreviewCard from '../workflow-preview-card'
describe('WorkflowPreviewCard', () => {
it('should render the workflow preview with execute action and branding footer', () => {
render(
<WorkflowPreviewCard
imgKey={9}
workspaceLogo="https://example.com/workspace-logo.png"
/>,
)
expect(screen.getByText('Workflow App')).toBeInTheDocument()
expect(screen.getByText('RUN ONCE')).toBeInTheDocument()
expect(screen.getByText('RUN BATCH')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Execute/i })).toBeDisabled()
expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/workspace-logo.png')
})
it('should hide workflow branding footer when brand removal is enabled', () => {
render(
<WorkflowPreviewCard
imgKey={9}
webappBrandRemoved
workspaceLogo="https://example.com/workspace-logo.png"
/>,
)
expect(screen.queryByText('POWERED BY')).not.toBeInTheDocument()
})
})

View File

@@ -1,78 +0,0 @@
import Button from '@/app/components/base/button'
import { cn } from '@/utils/classnames'
import PoweredByBrand from './powered-by-brand'
type ChatPreviewCardProps = {
webappBrandRemoved?: boolean
workspaceLogo?: string
webappLogo?: string
imgKey: number
}
const ChatPreviewCard = ({
webappBrandRemoved,
workspaceLogo,
webappLogo,
imgKey,
}: ChatPreviewCardProps) => {
return (
<div className="flex h-[320px] grow basis-1/2 overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn">
<div className="flex h-full w-[232px] shrink-0 flex-col p-1 pr-0">
<div className="flex items-center gap-3 p-3 pr-2">
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-blue-light-solid')}>
<span className="i-custom-vender-solid-communication-bubble-text-mod h-4 w-4 text-components-avatar-shape-fill-stop-100" />
</div>
<div className="grow text-text-secondary system-md-semibold">Chatflow App</div>
<div className="p-1.5">
<span className="i-ri-layout-left-2-line h-4 w-4 text-text-tertiary" />
</div>
</div>
<div className="shrink-0 px-4 py-3">
<Button variant="secondary-accent" className="w-full justify-center">
<span className="i-ri-edit-box-line mr-1 h-4 w-4" />
<div className="p-1 opacity-20">
<div className="h-2 w-[94px] rounded-sm bg-text-accent-light-mode-only"></div>
</div>
</Button>
</div>
<div className="grow px-3 pt-5">
<div className="flex h-8 items-center px-3 py-1">
<div className="h-2 w-14 rounded-sm bg-text-quaternary opacity-20"></div>
</div>
<div className="flex h-8 items-center px-3 py-1">
<div className="h-2 w-[168px] rounded-sm bg-text-quaternary opacity-20"></div>
</div>
<div className="flex h-8 items-center px-3 py-1">
<div className="h-2 w-[128px] rounded-sm bg-text-quaternary opacity-20"></div>
</div>
</div>
<div className="flex shrink-0 items-center justify-between p-3">
<div className="p-1.5">
<span className="i-ri-equalizer-2-line h-4 w-4 text-text-tertiary" />
</div>
<div className="flex items-center gap-1.5">
<PoweredByBrand
webappBrandRemoved={webappBrandRemoved}
workspaceLogo={workspaceLogo}
webappLogo={webappLogo}
imgKey={imgKey}
/>
</div>
</div>
</div>
<div className="flex w-[138px] grow flex-col justify-between p-2 pr-0">
<div className="flex grow flex-col justify-between rounded-l-2xl border-[0.5px] border-r-0 border-components-panel-border-subtle bg-chatbot-bg pb-4 pl-[22px] pt-16">
<div className="w-[720px] rounded-2xl border border-divider-subtle bg-chat-bubble-bg px-4 py-3">
<div className="mb-1 text-text-primary body-md-regular">Hello! How can I assist you today?</div>
<Button size="small">
<div className="h-2 w-[144px] rounded-sm bg-text-quaternary opacity-20"></div>
</Button>
</div>
<div className="flex h-[52px] w-[578px] items-center rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pl-3.5 text-text-placeholder shadow-md backdrop-blur-sm body-lg-regular">Talk to Dify</div>
</div>
</div>
</div>
)
}
export default ChatPreviewCard

View File

@@ -1,31 +0,0 @@
import DifyLogo from '@/app/components/base/logo/dify-logo'
type PoweredByBrandProps = {
webappBrandRemoved?: boolean
workspaceLogo?: string
webappLogo?: string
imgKey: number
}
const PoweredByBrand = ({
webappBrandRemoved,
workspaceLogo,
webappLogo,
imgKey,
}: PoweredByBrandProps) => {
if (webappBrandRemoved)
return null
const previewLogo = workspaceLogo || (webappLogo ? `${webappLogo}?hash=${imgKey}` : '')
return (
<>
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
{previewLogo
? <img src={previewLogo} alt="logo" className="block h-5 w-auto" />
: <DifyLogo size="small" />}
</>
)
}
export default PoweredByBrand

View File

@@ -1,64 +0,0 @@
import Button from '@/app/components/base/button'
import { cn } from '@/utils/classnames'
import PoweredByBrand from './powered-by-brand'
type WorkflowPreviewCardProps = {
webappBrandRemoved?: boolean
workspaceLogo?: string
webappLogo?: string
imgKey: number
}
const WorkflowPreviewCard = ({
webappBrandRemoved,
workspaceLogo,
webappLogo,
imgKey,
}: WorkflowPreviewCardProps) => {
return (
<div className="flex h-[320px] grow basis-1/2 flex-col overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn">
<div className="w-full border-b-[0.5px] border-divider-subtle p-4 pb-0">
<div className="mb-2 flex items-center gap-3">
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-indigo-solid')}>
<span className="i-ri-exchange-2-fill h-4 w-4 text-components-avatar-shape-fill-stop-100" />
</div>
<div className="grow text-text-secondary system-md-semibold">Workflow App</div>
<div className="p-1.5">
<span className="i-ri-layout-left-2-line h-4 w-4 text-text-tertiary" />
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex h-10 shrink-0 items-center border-b-2 border-components-tab-active text-text-primary system-md-semibold-uppercase">RUN ONCE</div>
<div className="flex h-10 grow items-center border-b-2 border-transparent text-text-tertiary system-md-semibold-uppercase">RUN BATCH</div>
</div>
</div>
<div className="grow bg-components-panel-bg">
<div className="p-4 pb-1">
<div className="mb-1 py-2">
<div className="h-2 w-20 rounded-sm bg-text-quaternary opacity-20"></div>
</div>
<div className="h-16 w-full rounded-lg bg-components-input-bg-normal"></div>
</div>
<div className="flex items-center justify-between px-4 py-3">
<Button size="small">
<div className="h-2 w-10 rounded-sm bg-text-quaternary opacity-20"></div>
</Button>
<Button variant="primary" size="small" disabled>
<span className="i-ri-play-large-line mr-1 h-4 w-4" />
<span>Execute</span>
</Button>
</div>
</div>
<div className="flex h-12 shrink-0 items-center gap-1.5 bg-components-panel-bg p-4 pt-3">
<PoweredByBrand
webappBrandRemoved={webappBrandRemoved}
workspaceLogo={workspaceLogo}
webappLogo={webappLogo}
imgKey={imgKey}
/>
</div>
</div>
)
}
export default WorkflowPreviewCard

View File

@@ -1,385 +0,0 @@
import type { ChangeEvent } from 'react'
import type { AppContextValue } from '@/context/app-context'
import type { SystemFeatures } from '@/types/feature'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
import { useToastContext } from '@/app/components/base/toast/context'
import { defaultPlan } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import {
initialLangGeniusVersionInfo,
initialWorkspaceInfo,
useAppContext,
userProfilePlaceholder,
} from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { updateCurrentWorkspace } from '@/service/common'
import { defaultSystemFeatures } from '@/types/feature'
import useWebAppBrand from '../use-web-app-brand'
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: vi.fn(),
}))
vi.mock('@/service/common', () => ({
updateCurrentWorkspace: vi.fn(),
}))
vi.mock('@/context/app-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/app-context')>()
return {
...actual,
useAppContext: vi.fn(),
}
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/app/components/base/image-uploader/utils', () => ({
imageUpload: vi.fn(),
getImageUploadErrorMessage: vi.fn(),
}))
const mockNotify = vi.fn()
const mockUseToastContext = vi.mocked(useToastContext)
const mockUpdateCurrentWorkspace = vi.mocked(updateCurrentWorkspace)
const mockUseAppContext = vi.mocked(useAppContext)
const mockUseProviderContext = vi.mocked(useProviderContext)
const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
const mockImageUpload = vi.mocked(imageUpload)
const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage)
const createProviderContext = ({
enableBilling = false,
planType = Plan.professional,
}: {
enableBilling?: boolean
planType?: Plan
} = {}) => {
return createMockProviderContextValue({
enableBilling,
plan: {
...defaultPlan,
type: planType,
},
})
}
const createSystemFeatures = (brandingOverrides: Partial<SystemFeatures['branding']> = {}): SystemFeatures => ({
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: true,
workspace_logo: 'https://example.com/workspace-logo.png',
...brandingOverrides,
},
})
const createAppContextValue = (overrides: Partial<AppContextValue> = {}): AppContextValue => {
const { currentWorkspace: currentWorkspaceOverride, ...restOverrides } = overrides
const workspaceOverrides: Partial<AppContextValue['currentWorkspace']> = currentWorkspaceOverride ?? {}
const currentWorkspace = {
...initialWorkspaceInfo,
...workspaceOverrides,
custom_config: {
replace_webapp_logo: 'https://example.com/replace.png',
remove_webapp_brand: false,
...workspaceOverrides.custom_config,
},
}
return {
userProfile: userProfilePlaceholder,
mutateUserProfile: vi.fn(),
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceEditor: false,
isCurrentWorkspaceDatasetOperator: false,
mutateCurrentWorkspace: vi.fn(),
langGeniusVersionInfo: initialLangGeniusVersionInfo,
useSelector: vi.fn() as unknown as AppContextValue['useSelector'],
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
...restOverrides,
currentWorkspace,
}
}
describe('useWebAppBrand', () => {
let appContextValue: AppContextValue
let systemFeatures: SystemFeatures
beforeEach(() => {
vi.clearAllMocks()
appContextValue = createAppContextValue()
systemFeatures = createSystemFeatures()
mockUseToastContext.mockReturnValue({ notify: mockNotify } as unknown as ReturnType<typeof useToastContext>)
mockUpdateCurrentWorkspace.mockResolvedValue(appContextValue.currentWorkspace)
mockUseAppContext.mockImplementation(() => appContextValue)
mockUseProviderContext.mockReturnValue(createProviderContext())
mockUseGlobalPublicStore.mockImplementation(selector => selector({
systemFeatures,
setSystemFeatures: vi.fn(),
}))
mockGetImageUploadErrorMessage.mockReturnValue('upload error')
})
// Derived state from context and store inputs.
describe('derived state', () => {
it('should expose workspace branding and upload availability by default', () => {
const { result } = renderHook(() => useWebAppBrand())
expect(result.current.webappLogo).toBe('https://example.com/replace.png')
expect(result.current.workspaceLogo).toBe('https://example.com/workspace-logo.png')
expect(result.current.uploadDisabled).toBe(false)
expect(result.current.uploading).toBe(false)
})
it('should disable uploads in sandbox workspaces and when branding is removed', () => {
mockUseProviderContext.mockReturnValue(createProviderContext({
enableBilling: true,
planType: Plan.sandbox,
}))
appContextValue = createAppContextValue({
currentWorkspace: {
...initialWorkspaceInfo,
custom_config: {
replace_webapp_logo: 'https://example.com/replace.png',
remove_webapp_brand: true,
},
},
})
const { result } = renderHook(() => useWebAppBrand())
expect(result.current.isSandbox).toBe(true)
expect(result.current.webappBrandRemoved).toBe(true)
expect(result.current.uploadDisabled).toBe(true)
})
it('should fall back to an empty workspace logo when branding is disabled', () => {
systemFeatures = createSystemFeatures({
enabled: false,
workspace_logo: '',
})
const { result } = renderHook(() => useWebAppBrand())
expect(result.current.workspaceLogo).toBe('')
})
it('should fall back to an empty custom logo when custom config is missing', () => {
appContextValue = {
...createAppContextValue(),
currentWorkspace: {
...initialWorkspaceInfo,
},
}
const { result } = renderHook(() => useWebAppBrand())
expect(result.current.webappLogo).toBe('')
})
})
// State transitions driven by user actions.
describe('actions', () => {
it('should ignore empty file selections', () => {
const { result } = renderHook(() => useWebAppBrand())
act(() => {
result.current.handleChange({
target: { files: [] },
} as unknown as ChangeEvent<HTMLInputElement>)
})
expect(mockImageUpload).not.toHaveBeenCalled()
})
it('should reject oversized files before upload starts', () => {
const { result } = renderHook(() => useWebAppBrand())
const oversizedFile = new File(['logo'], 'logo.png', { type: 'image/png' })
Object.defineProperty(oversizedFile, 'size', {
configurable: true,
value: 5 * 1024 * 1024 + 1,
})
act(() => {
result.current.handleChange({
target: { files: [oversizedFile] },
} as unknown as ChangeEvent<HTMLInputElement>)
})
expect(mockImageUpload).not.toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.imageUploader.uploadFromComputerLimit:{"size":5}',
})
})
it('should update upload state after a successful file upload', () => {
mockImageUpload.mockImplementation(({ onProgressCallback, onSuccessCallback }) => {
onProgressCallback(100)
onSuccessCallback({ id: 'new-logo' })
})
const { result } = renderHook(() => useWebAppBrand())
act(() => {
result.current.handleChange({
target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] },
} as unknown as ChangeEvent<HTMLInputElement>)
})
expect(result.current.fileId).toBe('new-logo')
expect(result.current.uploadProgress).toBe(100)
expect(result.current.uploading).toBe(false)
})
it('should expose the uploading state while progress is incomplete', () => {
mockImageUpload.mockImplementation(({ onProgressCallback }) => {
onProgressCallback(50)
})
const { result } = renderHook(() => useWebAppBrand())
act(() => {
result.current.handleChange({
target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] },
} as unknown as ChangeEvent<HTMLInputElement>)
})
expect(result.current.uploadProgress).toBe(50)
expect(result.current.uploading).toBe(true)
})
it('should surface upload errors and set the failure state', () => {
mockImageUpload.mockImplementation(({ onErrorCallback }) => {
onErrorCallback({ response: { code: 'forbidden' } })
})
const { result } = renderHook(() => useWebAppBrand())
act(() => {
result.current.handleChange({
target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] },
} as unknown as ChangeEvent<HTMLInputElement>)
})
expect(mockGetImageUploadErrorMessage).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'upload error',
})
expect(result.current.uploadProgress).toBe(-1)
})
it('should persist the selected logo and reset transient state on apply', async () => {
const mutateCurrentWorkspace = vi.fn()
appContextValue = createAppContextValue({
mutateCurrentWorkspace,
})
mockImageUpload.mockImplementation(({ onSuccessCallback }) => {
onSuccessCallback({ id: 'new-logo' })
})
const { result } = renderHook(() => useWebAppBrand())
act(() => {
result.current.handleChange({
target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] },
} as unknown as ChangeEvent<HTMLInputElement>)
})
const previousImgKey = result.current.imgKey
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(previousImgKey + 1)
await act(async () => {
await result.current.handleApply()
})
expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({
url: '/workspaces/custom-config',
body: {
remove_webapp_brand: false,
replace_webapp_logo: 'new-logo',
},
})
expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1)
expect(result.current.fileId).toBe('')
expect(result.current.imgKey).toBe(previousImgKey + 1)
dateNowSpy.mockRestore()
})
it('should restore the default branding configuration', async () => {
const mutateCurrentWorkspace = vi.fn()
appContextValue = createAppContextValue({
mutateCurrentWorkspace,
})
const { result } = renderHook(() => useWebAppBrand())
await act(async () => {
await result.current.handleRestore()
})
expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({
url: '/workspaces/custom-config',
body: {
remove_webapp_brand: false,
replace_webapp_logo: '',
},
})
expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1)
})
it('should persist brand removal changes', async () => {
const mutateCurrentWorkspace = vi.fn()
appContextValue = createAppContextValue({
mutateCurrentWorkspace,
})
const { result } = renderHook(() => useWebAppBrand())
await act(async () => {
await result.current.handleSwitch(true)
})
expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({
url: '/workspaces/custom-config',
body: {
remove_webapp_brand: true,
},
})
expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1)
})
it('should clear temporary upload state on cancel', () => {
mockImageUpload.mockImplementation(({ onSuccessCallback }) => {
onSuccessCallback({ id: 'new-logo' })
})
const { result } = renderHook(() => useWebAppBrand())
act(() => {
result.current.handleChange({
target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] },
} as unknown as ChangeEvent<HTMLInputElement>)
})
act(() => {
result.current.handleCancel()
})
expect(result.current.fileId).toBe('')
expect(result.current.uploadProgress).toBe(0)
})
})
})

View File

@@ -1,121 +0,0 @@
import type { ChangeEvent } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
import { useToastContext } from '@/app/components/base/toast/context'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { updateCurrentWorkspace } from '@/service/common'
const MAX_LOGO_FILE_SIZE = 5 * 1024 * 1024
const CUSTOM_CONFIG_URL = '/workspaces/custom-config'
const WEB_APP_LOGO_UPLOAD_URL = '/workspaces/custom-config/webapp-logo/upload'
const useWebAppBrand = () => {
const { t } = useTranslation()
const { notify } = useToastContext()
const { plan, enableBilling } = useProviderContext()
const {
currentWorkspace,
mutateCurrentWorkspace,
isCurrentWorkspaceManager,
} = useAppContext()
const [fileId, setFileId] = useState('')
const [imgKey, setImgKey] = useState(() => Date.now())
const [uploadProgress, setUploadProgress] = useState(0)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const isSandbox = enableBilling && plan.type === Plan.sandbox
const uploading = uploadProgress > 0 && uploadProgress < 100
const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || ''
const webappBrandRemoved = currentWorkspace.custom_config?.remove_webapp_brand
const uploadDisabled = isSandbox || webappBrandRemoved || !isCurrentWorkspaceManager
const workspaceLogo = systemFeatures.branding.enabled ? systemFeatures.branding.workspace_logo : ''
const persistWorkspaceBrand = async (body: Record<string, unknown>) => {
await updateCurrentWorkspace({
url: CUSTOM_CONFIG_URL,
body,
})
mutateCurrentWorkspace()
}
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file)
return
if (file.size > MAX_LOGO_FILE_SIZE) {
notify({ type: 'error', message: t('imageUploader.uploadFromComputerLimit', { ns: 'common', size: 5 }) })
return
}
imageUpload({
file,
onProgressCallback: setUploadProgress,
onSuccessCallback: (res) => {
setUploadProgress(100)
setFileId(res.id)
},
onErrorCallback: (error) => {
const errorMessage = getImageUploadErrorMessage(
error,
t('imageUploader.uploadFromComputerUploadError', { ns: 'common' }),
t,
)
notify({ type: 'error', message: errorMessage })
setUploadProgress(-1)
},
}, false, WEB_APP_LOGO_UPLOAD_URL)
}
const handleApply = async () => {
await persistWorkspaceBrand({
remove_webapp_brand: webappBrandRemoved,
replace_webapp_logo: fileId,
})
setFileId('')
setImgKey(Date.now())
}
const handleRestore = async () => {
await persistWorkspaceBrand({
remove_webapp_brand: false,
replace_webapp_logo: '',
})
}
const handleSwitch = async (checked: boolean) => {
await persistWorkspaceBrand({
remove_webapp_brand: checked,
})
}
const handleCancel = () => {
setFileId('')
setUploadProgress(0)
}
return {
fileId,
imgKey,
uploadProgress,
uploading,
webappLogo,
webappBrandRemoved,
uploadDisabled,
workspaceLogo,
isSandbox,
isCurrentWorkspaceManager,
handleApply,
handleCancel,
handleChange,
handleRestore,
handleSwitch,
}
}
export default useWebAppBrand

View File

@@ -1,33 +1,118 @@
import type { ChangeEvent } from 'react'
import {
RiEditBoxLine,
RiEqualizer2Line,
RiExchange2Fill,
RiImageAddLine,
RiLayoutLeft2Line,
RiLoader2Line,
RiPlayLargeLine,
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import { BubbleTextMod } from '@/app/components/base/icons/src/vender/solid/communication'
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import Switch from '@/app/components/base/switch'
import { useToastContext } from '@/app/components/base/toast/context'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import {
updateCurrentWorkspace,
} from '@/service/common'
import { cn } from '@/utils/classnames'
import ChatPreviewCard from './components/chat-preview-card'
import WorkflowPreviewCard from './components/workflow-preview-card'
import useWebAppBrand from './hooks/use-web-app-brand'
const ALLOW_FILE_EXTENSIONS = ['svg', 'png']
const CustomWebAppBrand = () => {
const { t } = useTranslation()
const { notify } = useToastContext()
const { plan, enableBilling } = useProviderContext()
const {
fileId,
imgKey,
uploadProgress,
uploading,
webappLogo,
webappBrandRemoved,
uploadDisabled,
workspaceLogo,
currentWorkspace,
mutateCurrentWorkspace,
isCurrentWorkspaceManager,
isSandbox,
handleApply,
handleCancel,
handleChange,
handleRestore,
handleSwitch,
} = useWebAppBrand()
} = useAppContext()
const [fileId, setFileId] = useState('')
const [imgKey, setImgKey] = useState(() => Date.now())
const [uploadProgress, setUploadProgress] = useState(0)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const isSandbox = enableBilling && plan.type === Plan.sandbox
const uploading = uploadProgress > 0 && uploadProgress < 100
const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || ''
const webappBrandRemoved = currentWorkspace.custom_config?.remove_webapp_brand
const uploadDisabled = isSandbox || webappBrandRemoved || !isCurrentWorkspaceManager
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file)
return
if (file.size > 5 * 1024 * 1024) {
notify({ type: 'error', message: t('imageUploader.uploadFromComputerLimit', { ns: 'common', size: 5 }) })
return
}
imageUpload({
file,
onProgressCallback: (progress) => {
setUploadProgress(progress)
},
onSuccessCallback: (res) => {
setUploadProgress(100)
setFileId(res.id)
},
onErrorCallback: (error?: any) => {
const errorMessage = getImageUploadErrorMessage(error, t('imageUploader.uploadFromComputerUploadError', { ns: 'common' }), t as any)
notify({ type: 'error', message: errorMessage })
setUploadProgress(-1)
},
}, false, '/workspaces/custom-config/webapp-logo/upload')
}
const handleApply = async () => {
await updateCurrentWorkspace({
url: '/workspaces/custom-config',
body: {
remove_webapp_brand: webappBrandRemoved,
replace_webapp_logo: fileId,
},
})
mutateCurrentWorkspace()
setFileId('')
setImgKey(Date.now())
}
const handleRestore = async () => {
await updateCurrentWorkspace({
url: '/workspaces/custom-config',
body: {
remove_webapp_brand: false,
replace_webapp_logo: '',
},
})
mutateCurrentWorkspace()
}
const handleSwitch = async (checked: boolean) => {
await updateCurrentWorkspace({
url: '/workspaces/custom-config',
body: {
remove_webapp_brand: checked,
},
})
mutateCurrentWorkspace()
}
const handleCancel = () => {
setFileId('')
setUploadProgress(0)
}
return (
<div className="py-4">
@@ -64,7 +149,7 @@ const CustomWebAppBrand = () => {
className="relative mr-2"
disabled={uploadDisabled}
>
<span className="i-ri-image-add-line mr-1 h-4 w-4" />
<RiImageAddLine className="mr-1 h-4 w-4" />
{
(webappLogo || fileId)
? t('change', { ns: 'custom' })
@@ -87,7 +172,7 @@ const CustomWebAppBrand = () => {
className="relative mr-2"
disabled={true}
>
<span className="i-ri-loader-2-line mr-1 h-4 w-4 animate-spin" />
<RiLoader2Line className="mr-1 h-4 w-4 animate-spin" />
{t('uploading', { ns: 'custom' })}
</Button>
)
@@ -123,18 +208,118 @@ const CustomWebAppBrand = () => {
<Divider bgStyle="gradient" className="grow" />
</div>
<div className="relative mb-2 flex items-center gap-3">
<ChatPreviewCard
webappBrandRemoved={webappBrandRemoved}
workspaceLogo={workspaceLogo}
webappLogo={webappLogo}
imgKey={imgKey}
/>
<WorkflowPreviewCard
webappBrandRemoved={webappBrandRemoved}
workspaceLogo={workspaceLogo}
webappLogo={webappLogo}
imgKey={imgKey}
/>
{/* chat card */}
<div className="flex h-[320px] grow basis-1/2 overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn">
<div className="flex h-full w-[232px] shrink-0 flex-col p-1 pr-0">
<div className="flex items-center gap-3 p-3 pr-2">
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-blue-light-solid')}>
<BubbleTextMod className="h-4 w-4 text-components-avatar-shape-fill-stop-100" />
</div>
<div className="grow text-text-secondary system-md-semibold">Chatflow App</div>
<div className="p-1.5">
<RiLayoutLeft2Line className="h-4 w-4 text-text-tertiary" />
</div>
</div>
<div className="shrink-0 px-4 py-3">
<Button variant="secondary-accent" className="w-full justify-center">
<RiEditBoxLine className="mr-1 h-4 w-4" />
<div className="p-1 opacity-20">
<div className="h-2 w-[94px] rounded-sm bg-text-accent-light-mode-only"></div>
</div>
</Button>
</div>
<div className="grow px-3 pt-5">
<div className="flex h-8 items-center px-3 py-1">
<div className="h-2 w-14 rounded-sm bg-text-quaternary opacity-20"></div>
</div>
<div className="flex h-8 items-center px-3 py-1">
<div className="h-2 w-[168px] rounded-sm bg-text-quaternary opacity-20"></div>
</div>
<div className="flex h-8 items-center px-3 py-1">
<div className="h-2 w-[128px] rounded-sm bg-text-quaternary opacity-20"></div>
</div>
</div>
<div className="flex shrink-0 items-center justify-between p-3">
<div className="p-1.5">
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
</div>
<div className="flex items-center gap-1.5">
{!webappBrandRemoved && (
<>
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
: webappLogo
? <img src={`${webappLogo}?hash=${imgKey}`} alt="logo" className="block h-5 w-auto" />
: <DifyLogo size="small" />
}
</>
)}
</div>
</div>
</div>
<div className="flex w-[138px] grow flex-col justify-between p-2 pr-0">
<div className="flex grow flex-col justify-between rounded-l-2xl border-[0.5px] border-r-0 border-components-panel-border-subtle bg-chatbot-bg pb-4 pl-[22px] pt-16">
<div className="w-[720px] rounded-2xl border border-divider-subtle bg-chat-bubble-bg px-4 py-3">
<div className="mb-1 text-text-primary body-md-regular">Hello! How can I assist you today?</div>
<Button size="small">
<div className="h-2 w-[144px] rounded-sm bg-text-quaternary opacity-20"></div>
</Button>
</div>
<div className="flex h-[52px] w-[578px] items-center rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pl-3.5 text-text-placeholder shadow-md backdrop-blur-sm body-lg-regular">Talk to Dify</div>
</div>
</div>
</div>
{/* workflow card */}
<div className="flex h-[320px] grow basis-1/2 flex-col overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn">
<div className="w-full border-b-[0.5px] border-divider-subtle p-4 pb-0">
<div className="mb-2 flex items-center gap-3">
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-indigo-solid')}>
<RiExchange2Fill className="h-4 w-4 text-components-avatar-shape-fill-stop-100" />
</div>
<div className="grow text-text-secondary system-md-semibold">Workflow App</div>
<div className="p-1.5">
<RiLayoutLeft2Line className="h-4 w-4 text-text-tertiary" />
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex h-10 shrink-0 items-center border-b-2 border-components-tab-active text-text-primary system-md-semibold-uppercase">RUN ONCE</div>
<div className="flex h-10 grow items-center border-b-2 border-transparent text-text-tertiary system-md-semibold-uppercase">RUN BATCH</div>
</div>
</div>
<div className="grow bg-components-panel-bg">
<div className="p-4 pb-1">
<div className="mb-1 py-2">
<div className="h-2 w-20 rounded-sm bg-text-quaternary opacity-20"></div>
</div>
<div className="h-16 w-full rounded-lg bg-components-input-bg-normal"></div>
</div>
<div className="flex items-center justify-between px-4 py-3">
<Button size="small">
<div className="h-2 w-10 rounded-sm bg-text-quaternary opacity-20"></div>
</Button>
<Button variant="primary" size="small" disabled>
<RiPlayLargeLine className="mr-1 h-4 w-4" />
<span>Execute</span>
</Button>
</div>
</div>
<div className="flex h-12 shrink-0 items-center gap-1.5 bg-components-panel-bg p-4 pt-3">
{!webappBrandRemoved && (
<>
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
: webappLogo
? <img src={`${webappLogo}?hash=${imgKey}`} alt="logo" className="block h-5 w-auto" />
: <DifyLogo size="small" />
}
</>
)}
</div>
</div>
</div>
</div>
)

View File

@@ -4,6 +4,13 @@ import { RETRIEVE_METHOD } from '@/types/app'
import { retrievalIcon } from '../../../create/icons'
import RetrievalMethodInfo, { getIcon } from '../index'
// Override global next/image auto-mock: tests assert on rendered <img> src attributes via data-testid
vi.mock('next/image', () => ({
default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => (
<img src={src} alt={alt || ''} className={className} data-testid="method-icon" />
),
}))
// Mock RadioCard
vi.mock('@/app/components/base/radio-card', () => ({
default: ({ title, description, chosenConfig, icon }: { title: string, description: string, chosenConfig: ReactNode, icon: ReactNode }) => (
@@ -43,7 +50,7 @@ describe('RetrievalMethodInfo', () => {
})
it('should render correctly with full config', () => {
const { container } = render(<RetrievalMethodInfo value={defaultConfig} />)
render(<RetrievalMethodInfo value={defaultConfig} />)
expect(screen.getByTestId('radio-card')).toBeInTheDocument()
@@ -52,7 +59,7 @@ describe('RetrievalMethodInfo', () => {
expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description')
// Check Icon
const icon = container.querySelector('img')
const icon = screen.getByTestId('method-icon')
expect(icon).toHaveAttribute('src', 'vector-icon.png')
// Check Config Details
@@ -80,18 +87,18 @@ describe('RetrievalMethodInfo', () => {
it('should handle different retrieval methods', () => {
// Test Hybrid
const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid }
const { container, unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
const { unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title')
expect(container.querySelector('img')).toHaveAttribute('src', 'hybrid-icon.png')
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'hybrid-icon.png')
unmount()
// Test FullText
const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText }
const { container: fullTextContainer } = render(<RetrievalMethodInfo value={fullTextConfig} />)
render(<RetrievalMethodInfo value={fullTextConfig} />)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title')
expect(fullTextContainer.querySelector('img')).toHaveAttribute('src', 'fulltext-icon.png')
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'fulltext-icon.png')
})
describe('getIcon utility', () => {
@@ -125,17 +132,17 @@ describe('RetrievalMethodInfo', () => {
it('should render correctly with invertedIndex search method', () => {
const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex }
const { container } = render(<RetrievalMethodInfo value={invertedIndexConfig} />)
render(<RetrievalMethodInfo value={invertedIndexConfig} />)
// invertedIndex uses vector icon
expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png')
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
})
it('should render correctly with keywordSearch search method', () => {
const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch }
const { container } = render(<RetrievalMethodInfo value={keywordSearchConfig} />)
render(<RetrievalMethodInfo value={keywordSearchConfig} />)
// keywordSearch uses vector icon
expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png')
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
})
})

View File

@@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { RetrievalConfig } from '@/types/app'
import Image from 'next/image'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import RadioCard from '@/app/components/base/radio-card'
@@ -27,7 +28,7 @@ const EconomicalRetrievalMethodConfig: FC<Props> = ({
}) => {
const { t } = useTranslation()
const type = value.search_method
const icon = <img className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(type)} alt="" />
const icon = <Image className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(type)} alt="" />
return (
<div className="space-y-2">
<RadioCard

View File

@@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { RetrievalConfig } from '@/types/app'
import Image from 'next/image'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
@@ -126,7 +127,7 @@ const RetrievalParamConfig: FC<Props> = ({
/>
)}
<div className="flex items-center">
<span className="mr-0.5 text-text-secondary system-sm-semibold">{t('modelProvider.rerankModel.key', { ns: 'common' })}</span>
<span className="system-sm-semibold mr-0.5 text-text-secondary">{t('modelProvider.rerankModel.key', { ns: 'common' })}</span>
<Tooltip
popupContent={
<div className="w-[200px]">{t('modelProvider.rerankModel.tip', { ns: 'common' })}</div>
@@ -156,7 +157,7 @@ const RetrievalParamConfig: FC<Props> = ({
<div className="p-1">
<AlertTriangle className="size-4 text-text-warning-secondary" />
</div>
<span className="text-text-primary system-xs-medium">
<span className="system-xs-medium text-text-primary">
{t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })}
</span>
</div>
@@ -214,11 +215,11 @@ const RetrievalParamConfig: FC<Props> = ({
isChosen={value.reranking_mode === option.value}
onChosen={() => handleChangeRerankMode(option.value)}
icon={(
<img
<Image
src={
option.value === RerankingModeEnum.WeightedScore
? ProgressIndicator.src
: Reranking.src
? ProgressIndicator
: Reranking
}
alt=""
/>
@@ -280,7 +281,7 @@ const RetrievalParamConfig: FC<Props> = ({
<div className="p-1">
<AlertTriangle className="size-4 text-text-warning-secondary" />
</div>
<span className="text-text-primary system-xs-medium">
<span className="system-xs-medium text-text-primary">
{t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })}
</span>
</div>

View File

@@ -20,6 +20,14 @@ vi.mock('next/navigation', () => ({
useRouter: () => mockRouter,
}))
// Override global next/image auto-mock: test asserts on data-testid="next-image"
vi.mock('next/image', () => ({
default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => (
// eslint-disable-next-line next/no-img-element
<img src={src} alt={alt} className={className} data-testid="next-image" />
),
}))
// Mock API service
const mockFetchIndexingStatusBatch = vi.fn()
vi.mock('@/service/datasets', () => ({
@@ -971,9 +979,9 @@ describe('RuleDetail', () => {
})
it('should render correct icon for indexing type', () => {
const { container } = render(<RuleDetail indexingType="high_quality" />)
render(<RuleDetail indexingType="high_quality" />)
const images = container.querySelectorAll('img')
const images = screen.getAllByTestId('next-image')
expect(images.length).toBeGreaterThan(0)
})
})

View File

@@ -1,5 +1,6 @@
import type { FC } from 'react'
import type { ProcessRuleResponse } from '@/models/datasets'
import Image from 'next/image'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FieldInfo } from '@/app/components/datasets/documents/detail/metadata'
@@ -118,12 +119,12 @@ const RuleDetail: FC<RuleDetailProps> = ({ sourceData, indexingType, retrievalMe
<FieldInfo
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
displayedValue={indexModeLabel}
valueIcon={<img className="size-4" src={indexMethodIconSrc} alt="" />}
valueIcon={<Image className="size-4" src={indexMethodIconSrc} alt="" />}
/>
<FieldInfo
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
displayedValue={retrievalLabel}
valueIcon={<img className="size-4" src={retrievalIconSrc} alt="" />}
valueIcon={<Image className="size-4" src={retrievalIconSrc} alt="" />}
/>
</div>
)

View File

@@ -5,12 +5,12 @@ import Research from './assets/research-mod.svg'
import Selection from './assets/selection-mod.svg'
export const indexMethodIcon = {
high_quality: GoldIcon.src,
economical: Piggybank.src,
high_quality: GoldIcon,
economical: Piggybank,
}
export const retrievalIcon = {
vector: Selection.src,
fullText: Research.src,
hybrid: PatternRecognition.src,
vector: Selection,
fullText: Research,
hybrid: PatternRecognition,
}

View File

@@ -47,19 +47,19 @@ describe('MaxLengthInput', () => {
it('should render number input', () => {
render(<MaxLengthInput onChange={vi.fn()} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
})
it('should accept value prop', () => {
render(<MaxLengthInput value={500} onChange={vi.fn()} />)
expect(screen.getByRole('textbox')).toHaveValue('500')
expect(screen.getByDisplayValue('500')).toBeInTheDocument()
})
it('should have min of 1', () => {
render(<MaxLengthInput onChange={vi.fn()} />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('min', '1')
})
})
@@ -75,18 +75,18 @@ describe('OverlapInput', () => {
it('should render number input', () => {
render(<OverlapInput onChange={vi.fn()} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
})
it('should accept value prop', () => {
render(<OverlapInput value={50} onChange={vi.fn()} />)
expect(screen.getByRole('textbox')).toHaveValue('50')
expect(screen.getByDisplayValue('50')).toBeInTheDocument()
})
it('should have min of 1', () => {
render(<OverlapInput onChange={vi.fn()} />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('min', '1')
})
})

View File

@@ -2,6 +2,13 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OptionCard, OptionCardHeader } from '../option-card'
// Override global next/image auto-mock: tests assert on rendered <img> elements
vi.mock('next/image', () => ({
default: ({ src, alt, ...props }: { src?: string, alt?: string, width?: number, height?: number }) => (
<img src={src} alt={alt} {...props} />
),
}))
describe('OptionCardHeader', () => {
const defaultProps = {
icon: <span data-testid="icon">icon</span>,

View File

@@ -6,6 +6,7 @@ import {
RiAlertFill,
RiSearchEyeLine,
} from '@remixicon/react'
import Image from 'next/image'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Checkbox from '@/app/components/base/checkbox'
@@ -25,7 +26,7 @@ type TextLabelProps = {
}
const TextLabel: FC<TextLabelProps> = ({ children }) => {
return <label className="text-text-secondary system-sm-semibold">{children}</label>
return <label className="system-sm-semibold text-text-secondary">{children}</label>
}
type GeneralChunkingOptionsProps = {
@@ -96,7 +97,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
<OptionCard
className="mb-2 bg-background-section"
title={t('stepTwo.general', { ns: 'datasetCreation' })}
icon={<img width={20} height={20} src={SettingCog.src} alt={t('stepTwo.general', { ns: 'datasetCreation' })} />}
icon={<Image width={20} height={20} src={SettingCog} alt={t('stepTwo.general', { ns: 'datasetCreation' })} />}
activeHeaderClassName="bg-dataset-option-card-blue-gradient"
description={t('stepTwo.generalTip', { ns: 'datasetCreation' })}
isActive={isActive}
@@ -147,7 +148,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
onClick={() => onRuleToggle(rule.id)}
>
<Checkbox checked={rule.enabled} />
<label className="ml-2 cursor-pointer text-text-secondary system-sm-regular">
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary">
{getRuleName(rule.id)}
</label>
</div>
@@ -182,7 +183,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
checked={currentDocForm === ChunkingMode.qa}
disabled={hasCurrentDatasetDocForm}
/>
<label className="ml-2 cursor-pointer text-text-secondary system-sm-regular">
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary">
{t('stepTwo.useQALanguage', { ns: 'datasetCreation' })}
</label>
</div>
@@ -201,7 +202,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
className="mt-2 flex h-10 items-center gap-2 rounded-xl border border-components-panel-border px-3 text-xs shadow-xs backdrop-blur-[5px]"
>
<RiAlertFill className="size-4 text-text-warning-secondary" />
<span className="text-text-primary system-xs-medium">
<span className="system-xs-medium text-text-primary">
{t('stepTwo.QATip', { ns: 'datasetCreation' })}
</span>
</div>

View File

@@ -3,6 +3,7 @@
import type { FC } from 'react'
import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { RetrievalConfig } from '@/types/app'
import Image from 'next/image'
import Link from 'next/link'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
@@ -69,7 +70,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
return (
<>
{/* Index Mode */}
<div className="mb-1 text-text-secondary system-md-semibold">
<div className="system-md-semibold mb-1 text-text-secondary">
{t('stepTwo.indexMode', { ns: 'datasetCreation' })}
</div>
<div className="flex items-center gap-2">
@@ -97,7 +98,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
</div>
)}
description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })}
icon={<img src={indexMethodIcon.high_quality} alt="" />}
icon={<Image src={indexMethodIcon.high_quality} alt="" />}
isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED}
disabled={hasSetIndexType}
onSwitched={() => onIndexTypeChange(IndexingType.QUALIFIED)}
@@ -142,7 +143,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
className="h-full"
title={t('stepTwo.economical', { ns: 'datasetCreation' })}
description={t('stepTwo.economicalTip', { ns: 'datasetCreation' })}
icon={<img src={indexMethodIcon.economical} alt="" />}
icon={<Image src={indexMethodIcon.economical} alt="" />}
isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL}
disabled={hasSetIndexType || docForm !== ChunkingMode.text}
onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)}
@@ -159,7 +160,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
<div className="p-1">
<AlertTriangle className="size-4 text-text-warning-secondary" />
</div>
<span className="text-text-primary system-xs-medium">
<span className="system-xs-medium text-text-primary">
{t('stepTwo.highQualityTip', { ns: 'datasetCreation' })}
</span>
</div>
@@ -167,7 +168,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
{/* Economical index setting tip */}
{hasSetIndexType && indexType === IndexingType.ECONOMICAL && (
<div className="mt-2 text-text-tertiary system-xs-medium">
<div className="system-xs-medium mt-2 text-text-tertiary">
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })}
<Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}>
{t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })}
@@ -178,7 +179,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
{/* Embedding model */}
{indexType === IndexingType.QUALIFIED && (
<div className="mt-5">
<div className={cn('mb-1 text-text-secondary system-md-semibold', datasetId && 'flex items-center justify-between')}>
<div className={cn('system-md-semibold mb-1 text-text-secondary', datasetId && 'flex items-center justify-between')}>
{t('form.embeddingModel', { ns: 'datasetSettings' })}
</div>
<ModelSelector
@@ -189,7 +190,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
onSelect={onEmbeddingModelChange}
/>
{isModelAndRetrievalConfigDisabled && (
<div className="mt-2 text-text-tertiary system-xs-medium">
<div className="system-xs-medium mt-2 text-text-tertiary">
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })}
<Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}>
{t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })}
@@ -206,10 +207,10 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
{!isModelAndRetrievalConfigDisabled
? (
<div className="mb-1">
<div className="mb-0.5 text-text-secondary system-md-semibold">
<div className="system-md-semibold mb-0.5 text-text-secondary">
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
</div>
<div className="text-text-tertiary body-xs-regular">
<div className="body-xs-regular text-text-tertiary">
<a
target="_blank"
rel="noopener noreferrer"
@@ -223,7 +224,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
</div>
)
: (
<div className={cn('mb-0.5 text-text-secondary system-md-semibold', 'flex items-center justify-between')}>
<div className={cn('system-md-semibold mb-0.5 text-text-secondary', 'flex items-center justify-between')}>
<div>{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}</div>
</div>
)}

View File

@@ -1,4 +1,5 @@
import type { ComponentProps, FC, ReactNode } from 'react'
import Image from 'next/image'
import { cn } from '@/utils/classnames'
const TriangleArrow: FC<ComponentProps<'svg'>> = props => (
@@ -22,7 +23,7 @@ export const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => {
return (
<div className={cn('relative flex h-full overflow-hidden rounded-t-xl', isActive && activeClassName, !disabled && 'cursor-pointer')}>
<div className="relative flex size-14 items-center justify-center overflow-hidden">
{isActive && effectImg && <img src={effectImg} className="absolute left-0 top-0 h-full w-full" alt="" width={56} height={56} />}
{isActive && effectImg && <Image src={effectImg} className="absolute left-0 top-0 h-full w-full" alt="" width={56} height={56} />}
<div className="p-1">
<div className="flex size-8 justify-center rounded-lg border border-components-panel-border-subtle bg-background-default-dodge p-1.5 shadow-md">
{icon}
@@ -33,8 +34,8 @@ export const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => {
className={cn('absolute -bottom-1.5 left-4 text-transparent', isActive && 'text-components-panel-bg')}
/>
<div className="flex-1 space-y-0.5 py-3 pr-4">
<div className="text-text-secondary system-md-semibold">{title}</div>
<div className="text-text-tertiary system-xs-regular">{description}</div>
<div className="system-md-semibold text-text-secondary">{title}</div>
<div className="system-xs-regular text-text-tertiary">{description}</div>
</div>
</div>
)

View File

@@ -4,6 +4,7 @@ import type { FC } from 'react'
import type { ParentChildConfig } from '../hooks'
import type { ParentMode, PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
import { RiSearchEyeLine } from '@remixicon/react'
import Image from 'next/image'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Checkbox from '@/app/components/base/checkbox'
@@ -25,7 +26,7 @@ type TextLabelProps = {
}
const TextLabel: FC<TextLabelProps> = ({ children }) => {
return <label className="text-text-secondary system-sm-semibold">{children}</label>
return <label className="system-sm-semibold text-text-secondary">{children}</label>
}
type ParentChildOptionsProps = {
@@ -117,7 +118,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
</div>
<RadioCard
className="mt-1"
icon={<img src={Note.src} alt="" />}
icon={<Image src={Note} alt="" />}
title={t('stepTwo.paragraph', { ns: 'datasetCreation' })}
description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })}
isChosen={parentChildConfig.chunkForContext === 'paragraph'}
@@ -139,7 +140,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
/>
<RadioCard
className="mt-2"
icon={<img src={FileList.src} alt="" />}
icon={<Image src={FileList} alt="" />}
title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })}
description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })}
onChosen={() => onChunkForContextChange('full-doc')}
@@ -185,7 +186,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
onClick={() => onRuleToggle(rule.id)}
>
<Checkbox checked={rule.enabled} />
<label className="ml-2 cursor-pointer text-text-secondary system-sm-regular">
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary">
{getRuleName(rule.id)}
</label>
</div>

View File

@@ -6,6 +6,14 @@ import { ProcessMode } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import RuleDetail from '../rule-detail'
// Override global next/image auto-mock: tests assert on data-testid="next-image" and src attributes
vi.mock('next/image', () => ({
default: function MockImage({ src, alt, className }: { src: string, alt: string, className?: string }) {
// eslint-disable-next-line next/no-img-element
return <img src={src} alt={alt} className={className} data-testid="next-image" />
},
}))
// Mock FieldInfo component
vi.mock('@/app/components/datasets/documents/detail/metadata', () => ({
FieldInfo: ({ label, displayedValue, valueIcon }: { label: string, displayedValue: string, valueIcon?: React.ReactNode }) => (
@@ -176,16 +184,16 @@ describe('RuleDetail', () => {
})
it('should show high_quality icon for qualified indexing', () => {
const { container } = render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
const images = container.querySelectorAll('img')
const images = screen.getAllByTestId('next-image')
expect(images[0]).toHaveAttribute('src', '/icons/high_quality.svg')
})
it('should show economical icon for economical indexing', () => {
const { container } = render(<RuleDetail indexingType={IndexingType.ECONOMICAL} />)
render(<RuleDetail indexingType={IndexingType.ECONOMICAL} />)
const images = container.querySelectorAll('img')
const images = screen.getAllByTestId('next-image')
expect(images[0]).toHaveAttribute('src', '/icons/economical.svg')
})
})
@@ -248,38 +256,38 @@ describe('RuleDetail', () => {
})
it('should show vector icon for semantic search', () => {
const { container } = render(
render(
<RuleDetail
indexingType={IndexingType.QUALIFIED}
retrievalMethod={RETRIEVE_METHOD.semantic}
/>,
)
const images = container.querySelectorAll('img')
const images = screen.getAllByTestId('next-image')
expect(images[1]).toHaveAttribute('src', '/icons/vector.svg')
})
it('should show fullText icon for full text search', () => {
const { container } = render(
render(
<RuleDetail
indexingType={IndexingType.QUALIFIED}
retrievalMethod={RETRIEVE_METHOD.fullText}
/>,
)
const images = container.querySelectorAll('img')
const images = screen.getAllByTestId('next-image')
expect(images[1]).toHaveAttribute('src', '/icons/fullText.svg')
})
it('should show hybrid icon for hybrid search', () => {
const { container } = render(
render(
<RuleDetail
indexingType={IndexingType.QUALIFIED}
retrievalMethod={RETRIEVE_METHOD.hybrid}
/>,
)
const images = container.querySelectorAll('img')
const images = screen.getAllByTestId('next-image')
expect(images[1]).toHaveAttribute('src', '/icons/hybrid.svg')
})
})
@@ -300,9 +308,9 @@ describe('RuleDetail', () => {
})
it('should handle undefined retrievalMethod with defined indexingType', () => {
const { container } = render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
const images = container.querySelectorAll('img')
const images = screen.getAllByTestId('next-image')
// When retrievalMethod is undefined, vector icon is used as default
expect(images[1]).toHaveAttribute('src', '/icons/vector.svg')
})

View File

@@ -1,4 +1,5 @@
import type { ProcessRuleResponse } from '@/models/datasets'
import Image from 'next/image'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
@@ -49,7 +50,7 @@ const RuleDetail = ({
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
displayedValue={t(`stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
valueIcon={(
<img
<Image
className="size-4"
src={
indexingType === IndexingType.ECONOMICAL
@@ -64,7 +65,7 @@ const RuleDetail = ({
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
displayedValue={t(`retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
valueIcon={(
<img
<Image
className="size-4"
src={
retrievalMethod === RETRIEVE_METHOD.fullText

View File

@@ -1,6 +1,7 @@
import type { FC } from 'react'
import type { ProcessRuleResponse } from '@/models/datasets'
import type { RETRIEVE_METHOD } from '@/types/app'
import Image from 'next/image'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
@@ -100,7 +101,7 @@ const RuleDetail: FC<RuleDetailProps> = React.memo(({
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
displayedValue={t(`stepTwo.${isEconomical ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
valueIcon={(
<img
<Image
className="size-4"
src={isEconomical ? indexMethodIcon.economical : indexMethodIcon.high_quality}
alt=""
@@ -111,7 +112,7 @@ const RuleDetail: FC<RuleDetailProps> = React.memo(({
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
displayedValue={t(`retrieval.${isEconomical ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
valueIcon={(
<img
<Image
className="size-4"
src={getRetrievalIcon(retrievalMethod)}
alt=""

View File

@@ -905,8 +905,8 @@ describe('ExternalKnowledgeBaseCreate', () => {
/>,
)
// The TopKItem renders the visible number-field input as a textbox.
const inputs = screen.getAllByRole('textbox')
// The TopKItem should render an input
const inputs = screen.getAllByRole('spinbutton')
const topKInput = inputs[0]
fireEvent.change(topKInput, { target: { value: '8' } })
@@ -924,8 +924,8 @@ describe('ExternalKnowledgeBaseCreate', () => {
/>,
)
// The ScoreThresholdItem renders the visible number-field input as a textbox.
const inputs = screen.getAllByRole('textbox')
// The ScoreThresholdItem should render an input
const inputs = screen.getAllByRole('spinbutton')
const scoreThresholdInput = inputs[1]
fireEvent.change(scoreThresholdInput, { target: { value: '0.8' } })

View File

@@ -14,6 +14,7 @@ import {
RiEqualizer2Line,
RiPlayCircleLine,
} from '@remixicon/react'
import Image from 'next/image'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -177,7 +178,7 @@ const QueryInput = ({
}, [text, externalRetrievalSettings, externalKnowledgeBaseHitTestingMutation, onUpdateList, setExternalHitResult])
const retrievalMethod = isEconomy ? RETRIEVE_METHOD.keywordSearch : retrievalConfig.search_method
const icon = <img className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(retrievalMethod)} alt="" />
const icon = <Image className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(retrievalMethod)} alt="" />
const TextAreaComp = useMemo(() => {
return (
<Textarea
@@ -205,7 +206,7 @@ const QueryInput = ({
<div className={cn('relative flex h-80 shrink-0 flex-col overflow-hidden rounded-xl bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-0.5 shadow-xs')}>
<div className="flex h-full flex-col overflow-hidden rounded-[10px] bg-background-section-burn">
<div className="relative flex shrink-0 items-center justify-between p-1.5 pb-1 pl-3">
<span className="text-text-secondary system-sm-semibold-uppercase">
<span className="system-sm-semibold-uppercase text-text-secondary">
{t('input.title', { ns: 'datasetHitTesting' })}
</span>
{isExternal
@@ -217,7 +218,7 @@ const QueryInput = ({
>
<RiEqualizer2Line className="h-3.5 w-3.5 text-components-button-secondary-text" />
<div className="flex items-center justify-center gap-1 px-[3px]">
<span className="text-components-button-secondary-text system-xs-medium">{t('settingTitle', { ns: 'datasetHitTesting' })}</span>
<span className="system-xs-medium text-components-button-secondary-text">{t('settingTitle', { ns: 'datasetHitTesting' })}</span>
</div>
</Button>
)

View File

@@ -43,9 +43,8 @@ describe('InputCombined', () => {
render(
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
const input = screen.getByDisplayValue('42')
expect(input).toBeInTheDocument()
expect(input).toHaveValue('42')
})
it('should render date picker for time type', () => {
@@ -97,7 +96,7 @@ describe('InputCombined', () => {
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '123' } })
expect(handleChange).toHaveBeenCalled()
@@ -109,7 +108,7 @@ describe('InputCombined', () => {
<InputCombined type={DataType.number} value={999} onChange={handleChange} />,
)
expect(screen.getByRole('textbox')).toHaveValue('999')
expect(screen.getByDisplayValue('999')).toBeInTheDocument()
})
it('should apply readOnly prop to number input', () => {
@@ -118,7 +117,7 @@ describe('InputCombined', () => {
<InputCombined type={DataType.number} value={42} onChange={handleChange} readOnly />,
)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
expect(input).toHaveAttribute('readonly')
})
})
@@ -187,7 +186,7 @@ describe('InputCombined', () => {
<InputCombined type={DataType.number} value={null} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
})
})
@@ -209,7 +208,7 @@ describe('InputCombined', () => {
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
expect(input).toHaveClass('rounded-l-md')
})
})
@@ -231,7 +230,7 @@ describe('InputCombined', () => {
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
)
expect(screen.getByRole('textbox')).toHaveValue('0')
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
})
it('should handle negative number', () => {
@@ -240,7 +239,7 @@ describe('InputCombined', () => {
<InputCombined type={DataType.number} value={-100} onChange={handleChange} />,
)
expect(screen.getByRole('textbox')).toHaveValue('-100')
expect(screen.getByDisplayValue('-100')).toBeInTheDocument()
})
it('should handle special characters in string', () => {
@@ -264,7 +263,7 @@ describe('InputCombined', () => {
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
})
})
})

View File

@@ -129,15 +129,15 @@ describe('IndexMethod', () => {
it('should pass keywordNumber to KeywordNumber component', () => {
render(<IndexMethod {...defaultProps} keywordNumber={25} />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('25')
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(25)
})
it('should call onKeywordNumberChange when KeywordNumber changes', () => {
const handleKeywordChange = vi.fn()
render(<IndexMethod {...defaultProps} onKeywordNumberChange={handleKeywordChange} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '30' } })
expect(handleKeywordChange).toHaveBeenCalled()
@@ -192,14 +192,14 @@ describe('IndexMethod', () => {
it('should handle keywordNumber of 0', () => {
render(<IndexMethod {...defaultProps} keywordNumber={0} />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('0')
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(0)
})
it('should handle max keywordNumber', () => {
render(<IndexMethod {...defaultProps} keywordNumber={50} />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('50')
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(50)
})
})
})

View File

@@ -38,15 +38,15 @@ describe('KeyWordNumber', () => {
it('should render input number field', () => {
render(<KeyWordNumber {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display correct keywordNumber value in input', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('25')
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(25)
})
it('should display different keywordNumber values', () => {
@@ -54,8 +54,8 @@ describe('KeyWordNumber', () => {
values.forEach((value) => {
const { unmount } = render(<KeyWordNumber {...defaultProps} keywordNumber={value} />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue(String(value))
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(value)
unmount()
})
})
@@ -82,7 +82,7 @@ describe('KeyWordNumber', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '30' } })
expect(handleChange).toHaveBeenCalled()
@@ -92,7 +92,7 @@ describe('KeyWordNumber', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '' } })
// When value is empty/undefined, handleInputChange should not call onKeywordNumberChange
@@ -117,32 +117,32 @@ describe('KeyWordNumber', () => {
describe('Edge Cases', () => {
it('should handle minimum value (0)', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={0} />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('0')
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(0)
})
it('should handle maximum value (50)', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={50} />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('50')
const input = screen.getByRole('spinbutton')
expect(input).toHaveValue(50)
})
it('should handle value updates correctly', () => {
const { rerender } = render(<KeyWordNumber {...defaultProps} keywordNumber={10} />)
let input = screen.getByRole('textbox')
expect(input).toHaveValue('10')
let input = screen.getByRole('spinbutton')
expect(input).toHaveValue(10)
rerender(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
input = screen.getByRole('textbox')
expect(input).toHaveValue('25')
input = screen.getByRole('spinbutton')
expect(input).toHaveValue(25)
})
it('should handle rapid value changes', () => {
const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
// Simulate rapid changes via input with different values
fireEvent.change(input, { target: { value: '15' } })
@@ -162,7 +162,7 @@ describe('KeyWordNumber', () => {
it('should have accessible input', () => {
render(<KeyWordNumber {...defaultProps} />)
const input = screen.getByRole('textbox')
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
})
})

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
}

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