Compare commits

..

3 Commits

Author SHA1 Message Date
3c58c4007d try 2026-05-06 09:24:57 +08:00
54c90ddaaa chore: update deps 2026-05-06 09:15:16 +08:00
yyh
995c43f3dd refactor: migrate workflow queries to contracts (#35799)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-05 14:53:38 +00:00
272 changed files with 2902 additions and 24537 deletions

View File

@ -109,6 +109,8 @@ jobs:
- name: Web tsslint
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
env:
NODE_OPTIONS: --max-old-space-size=4096
run: vp run lint:tss
- name: Web type check

View File

@ -1,4 +1,5 @@
import logging
import re
import uuid
from datetime import datetime
from typing import Any, Literal
@ -8,6 +9,7 @@ from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, Field, computed_field, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.datastructures import MultiDict
from werkzeug.exceptions import BadRequest
from controllers.common.helpers import FileInfo
@ -57,6 +59,7 @@ ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "co
register_enum_models(console_ns, IconType)
_logger = logging.getLogger(__name__)
_TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$")
class AppListQuery(BaseModel):
@ -66,22 +69,19 @@ class AppListQuery(BaseModel):
default="all", description="App mode filter"
)
name: str | None = Field(default=None, description="Filter by app name")
tag_ids: list[str] | None = Field(default=None, description="Comma-separated tag IDs")
tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs")
is_created_by_me: bool | None = Field(default=None, description="Filter by creator")
@field_validator("tag_ids", mode="before")
@classmethod
def validate_tag_ids(cls, value: str | list[str] | None) -> list[str] | None:
def validate_tag_ids(cls, value: list[str] | None) -> list[str] | None:
if not value:
return None
if isinstance(value, str):
items = [item.strip() for item in value.split(",") if item.strip()]
elif isinstance(value, list):
items = [str(item).strip() for item in value if item and str(item).strip()]
else:
raise TypeError("Unsupported tag_ids type.")
if not isinstance(value, list):
raise ValueError("Unsupported tag_ids type.")
items = [str(item).strip() for item in value if item and str(item).strip()]
if not items:
return None
@ -91,6 +91,26 @@ class AppListQuery(BaseModel):
raise ValueError("Invalid UUID format in tag_ids.") from exc
def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]:
normalized: dict[str, str | list[str]] = {}
indexed_tag_ids: list[tuple[int, str]] = []
for key in query_args:
match = _TAG_IDS_BRACKET_PATTERN.fullmatch(key)
if match:
indexed_tag_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key))
continue
value = query_args.get(key)
if value is not None:
normalized[key] = value
if indexed_tag_ids:
normalized["tag_ids"] = [value for _, value in sorted(indexed_tag_ids)]
return normalized
class CreateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
@ -455,7 +475,7 @@ class AppListApi(Resource):
"""Get app list"""
current_user, current_tenant_id = current_account_with_tenant()
args = AppListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
args = AppListQuery.model_validate(_normalize_app_list_query_args(request.args))
args_dict = args.model_dump()
# get app list

View File

@ -10,6 +10,8 @@ from typing import Any
import pytest
from flask.views import MethodView
from pydantic import ValidationError
from werkzeug.datastructures import MultiDict
# kombu references MethodView as a global when importing celery/kombu pools.
if not hasattr(builtins, "MethodView"):
@ -174,6 +176,101 @@ def _dummy_workflow():
)
def test_app_list_query_normalizes_orpc_bracket_tag_ids(app_module):
first_tag_id = "8c4ef3d1-58a1-4d94-8a1c-1c171d889e08"
second_tag_id = "3c39395b-6d1f-4030-8b17-eaa7cc85221c"
query_args = MultiDict(
[
("page", "1"),
("limit", "30"),
("tag_ids[1]", second_tag_id),
("tag_ids[0]", first_tag_id),
]
)
normalized = app_module._normalize_app_list_query_args(query_args)
query = app_module.AppListQuery.model_validate(normalized)
assert query.tag_ids == [first_tag_id, second_tag_id]
def test_app_list_query_preserves_regular_query_params(app_module):
query_args = MultiDict(
[
("page", "2"),
("limit", "50"),
("mode", "chat"),
("name", "Sales Copilot"),
("is_created_by_me", "true"),
]
)
normalized = app_module._normalize_app_list_query_args(query_args)
query = app_module.AppListQuery.model_validate(normalized)
assert normalized == {
"page": "2",
"limit": "50",
"mode": "chat",
"name": "Sales Copilot",
"is_created_by_me": "true",
}
assert query.page == 2
assert query.limit == 50
assert query.mode == "chat"
assert query.name == "Sales Copilot"
assert query.is_created_by_me is True
assert query.tag_ids is None
def test_app_list_query_normalizes_empty_bracket_tag_ids_to_none(app_module):
query_args = MultiDict(
[
("tag_ids[0]", ""),
("tag_ids[1]", " "),
]
)
normalized = app_module._normalize_app_list_query_args(query_args)
query = app_module.AppListQuery.model_validate(normalized)
assert normalized == {"tag_ids": ["", " "]}
assert query.tag_ids is None
def test_app_list_query_rejects_invalid_bracket_tag_id(app_module):
normalized = app_module._normalize_app_list_query_args(MultiDict([("tag_ids[0]", "not-a-uuid")]))
with pytest.raises(ValidationError):
app_module.AppListQuery.model_validate(normalized)
def test_app_list_query_sorts_bracket_tag_ids_by_index(app_module):
first_tag_id = "8c4ef3d1-58a1-4d94-8a1c-1c171d889e08"
second_tag_id = "3c39395b-6d1f-4030-8b17-eaa7cc85221c"
third_tag_id = "9d5ec0f7-4f2b-4e7f-9c13-1e7a034d0eb1"
query_args = MultiDict(
[
("tag_ids[2]", third_tag_id),
("tag_ids[1]", second_tag_id),
("tag_ids[0]", first_tag_id),
]
)
normalized = app_module._normalize_app_list_query_args(query_args)
query = app_module.AppListQuery.model_validate(normalized)
assert query.tag_ids == [first_tag_id, second_tag_id, third_tag_id]
def test_app_list_query_rejects_flat_tag_ids(app_module):
tag_id = "8c4ef3d1-58a1-4d94-8a1c-1c171d889e08"
normalized = app_module._normalize_app_list_query_args(MultiDict([("tag_ids", tag_id)]))
with pytest.raises(ValidationError):
app_module.AppListQuery.model_validate(normalized)
def test_app_partial_serialization_uses_aliases(app_models):
AppPartial = app_models.AppPartial
created_at = _ts()

View File

@ -162,11 +162,6 @@
"count": 5
}
},
"web/app/account/(commonLayout)/account-page/index.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/account/(commonLayout)/delete-account/components/feed-back.tsx": {
"no-restricted-imports": {
"count": 1
@ -653,14 +648,6 @@
"count": 2
}
},
"web/app/components/apps/list.tsx": {
"react-hooks/exhaustive-deps": {
"count": 1
},
"react/unsupported-syntax": {
"count": 2
}
},
"web/app/components/apps/new-app-card.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@ -2824,14 +2811,6 @@
"count": 4
}
},
"web/app/components/header/app-nav/index.tsx": {
"react/set-state-in-effect": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/header/header-wrapper.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -5480,11 +5459,6 @@
"count": 2
}
},
"web/service/use-apps.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/service/use-common.ts": {
"ts/no-empty-object-type": {
"count": 1

View File

@ -2,7 +2,7 @@
"name": "dify",
"type": "module",
"private": true,
"packageManager": "pnpm@11.0.0",
"packageManager": "pnpm@11.0.6",
"engines": {
"node": "^22.22.1"
},

View File

@ -1,5 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 2.25C3.41421 2.25 3.75 2.58579 3.75 3V15C3.75 15.4142 3.41421 15.75 3 15.75C2.58579 15.75 2.25 15.4142 2.25 15V3C2.25 2.58579 2.58579 2.25 3 2.25Z" fill="#676F83"/>
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3V15C15.75 15.4142 15.4142 15.75 15 15.75C14.5858 15.75 14.25 15.4142 14.25 15V3C14.25 2.58579 14.5858 2.25 15 2.25Z" fill="#676F83"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.125 4.5C10.5392 4.5 10.875 4.83579 10.875 5.25V12.75C10.875 13.1642 10.5392 13.5 10.125 13.5H7.875C7.46079 13.5 7.125 13.1642 7.125 12.75V5.25C7.125 4.83579 7.46079 4.5 7.875 4.5H10.125ZM8.625 12H9.375V6H8.625V12Z" fill="#676F83"/>
</svg>

Before

Width:  |  Height:  |  Size: 751 B

View File

@ -1,5 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 14.25C15.4142 14.25 15.75 14.5858 15.75 15C15.75 15.4142 15.4142 15.75 15 15.75H3C2.58579 15.75 2.25 15.4142 2.25 15C2.25 14.5858 2.58579 14.25 3 14.25H15Z" fill="#676F83"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 7.125C13.9142 7.125 14.25 7.46079 14.25 7.875V10.125C14.25 10.5392 13.9142 10.875 13.5 10.875H4.5C4.08579 10.875 3.75 10.5392 3.75 10.125V7.875C3.75 7.46079 4.08579 7.125 4.5 7.125H13.5ZM5.25 9.375H12.75V8.625H5.25V9.375Z" fill="#676F83"/>
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3C15.75 3.41421 15.4142 3.75 15 3.75H3C2.58579 3.75 2.25 3.41421 2.25 3C2.25 2.58579 2.58579 2.25 3 2.25H15Z" fill="#676F83"/>
</svg>

Before

Width:  |  Height:  |  Size: 763 B

View File

@ -1,3 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 15V3.75V12.2625V10.6688V15ZM2.5 16.5C1.94772 16.5 1.5 16.0523 1.5 15.5V3.25C1.5 2.69771 1.94772 2.25 2.5 2.25H15.5C16.0523 2.25 16.5 2.69772 16.5 3.25V10.5H15V3.75H3V15H9V16.5H2.5ZM13.0125 17.25L10.35 14.5875L11.4188 13.5375L13.0125 15.1312L16.2 11.9438L17.25 13.0125L13.0125 17.25ZM7.5 9.75H13.5V8.25H7.5V9.75ZM7.5 6.75H13.5V5.25H7.5V6.75ZM4.5 9.75H6V8.25H4.5V9.75ZM4.5 6.75H6V5.25H4.5V6.75Z" fill="#495464"/>
</svg>

Before

Width:  |  Height:  |  Size: 526 B

View File

@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.33317 3.33333H7.33317V12.6667H5.33317V14H10.6665V12.6667H8.6665V3.33333H10.6665V2H5.33317V3.33333ZM1.33317 4.66667C0.964984 4.66667 0.666504 4.96515 0.666504 5.33333V10.6667C0.666504 11.0349 0.964984 11.3333 1.33317 11.3333H5.33317V10H1.99984V6H5.33317V4.66667H1.33317ZM10.6665 6H13.9998V10H10.6665V11.3333H14.6665C15.0347 11.3333 15.3332 11.0349 15.3332 10.6667V5.33333C15.3332 4.96515 15.0347 4.66667 14.6665 4.66667H10.6665V6Z" fill="#354052"/>
</svg>

Before

Width:  |  Height:  |  Size: 563 B

View File

@ -513,27 +513,12 @@
"width": 14,
"height": 14
},
"line-others-dhs": {
"body": "<g fill=\"currentColor\"><path d=\"M3 2.25a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3A.75.75 0 0 1 3 2.25m12 0a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75\"/><path fill-rule=\"evenodd\" d=\"M10.125 4.5a.75.75 0 0 1 .75.75v7.5a.75.75 0 0 1-.75.75h-2.25a.75.75 0 0 1-.75-.75v-7.5a.75.75 0 0 1 .75-.75zm-1.5 7.5h.75V6h-.75z\" clip-rule=\"evenodd\"/></g>",
"width": 18,
"height": 18
},
"line-others-drag-handle": {
"body": "<g fill=\"none\"><g id=\"Drag Handle\"><path id=\"drag-handle\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6 5C6.55228 5 7 4.55228 7 4C7 3.44772 6.55228 3 6 3C5.44772 3 5 3.44772 5 4C5 4.55228 5.44772 5 6 5ZM6 9C6.55228 9 7 8.55228 7 8C7 7.44772 6.55228 7 6 7C5.44772 7 5 7.44772 5 8C5 8.55228 5.44772 9 6 9ZM11 4C11 4.55228 10.5523 5 10 5C9.44772 5 9 4.55228 9 4C9 3.44772 9.44772 3 10 3C10.5523 3 11 3.44772 11 4ZM10 9C10.5523 9 11 8.55228 11 8C11 7.44772 10.5523 7 10 7C9.44772 7 9 7.44772 9 8C9 8.55228 9.44772 9 10 9ZM7 12C7 12.5523 6.55228 13 6 13C5.44772 13 5 12.5523 5 12C5 11.4477 5.44772 11 6 11C6.55228 11 7 11.4477 7 12ZM10 13C10.5523 13 11 12.5523 11 12C11 11.4477 10.5523 11 10 11C9.44772 11 9 11.4477 9 12C9 12.5523 9.44772 13 10 13Z\" fill=\"currentColor\"/></g></g>"
},
"line-others-dvs": {
"body": "<g fill=\"currentColor\"><path d=\"M15 14.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/><path fill-rule=\"evenodd\" d=\"M13.5 7.125a.75.75 0 0 1 .75.75v2.25a.75.75 0 0 1-.75.75h-9a.75.75 0 0 1-.75-.75v-2.25a.75.75 0 0 1 .75-.75zm-8.25 2.25h7.5v-.75h-7.5z\" clip-rule=\"evenodd\"/><path d=\"M15 2.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/></g>",
"width": 18,
"height": 18
},
"line-others-env": {
"body": "<g fill=\"none\"><g id=\"env\"><g id=\"Vector\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 3.33325C1.33325 2.22868 2.22868 1.33325 3.33325 1.33325H12.6666C13.7712 1.33325 14.6666 2.22869 14.6666 3.33325V3.66659C14.6666 4.03478 14.3681 4.33325 13.9999 4.33325C13.6317 4.33325 13.3333 4.03478 13.3333 3.66659V3.33325C13.3333 2.96506 13.0348 2.66659 12.6666 2.66659H3.33325C2.96506 2.66659 2.66659 2.96506 2.66659 3.33325V3.66659C2.66659 4.03478 2.36811 4.33325 1.99992 4.33325C1.63173 4.33325 1.33325 4.03478 1.33325 3.66659V3.33325Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M14.6666 12.6666C14.6666 13.7712 13.7712 14.6666 12.6666 14.6666L3.33325 14.6666C2.22866 14.6666 1.33325 13.7711 1.33325 12.6666L1.33325 12.3333C1.33325 11.9651 1.63173 11.6666 1.99992 11.6666C2.36811 11.6666 2.66659 11.9651 2.66659 12.3333V12.6666C2.66659 13.0348 2.96505 13.3333 3.33325 13.3333L12.6666 13.3333C13.0348 13.3333 13.3333 13.0348 13.3333 12.6666V12.3333C13.3333 11.9651 13.6317 11.6666 13.9999 11.6666C14.3681 11.6666 14.6666 11.9651 14.6666 12.3333V12.6666Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 5.99992C1.33325 5.63173 1.63173 5.33325 1.99992 5.33325H4.33325C4.70144 5.33325 4.99992 5.63173 4.99992 5.99992C4.99992 6.36811 4.70144 6.66658 4.33325 6.66658H2.66659V7.33325H3.99992C4.36811 7.33325 4.66659 7.63173 4.66659 7.99992C4.66659 8.36811 4.36811 8.66658 3.99992 8.66658H2.66659V9.33325H4.33325C4.70144 9.33325 4.99992 9.63173 4.99992 9.99992C4.99992 10.3681 4.70144 10.6666 4.33325 10.6666H1.99992C1.63173 10.6666 1.33325 10.3681 1.33325 9.99992V5.99992Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.4734 5.36186C6.75457 5.27673 7.05833 5.38568 7.22129 5.63012L8.66659 7.79807V5.99992C8.66659 5.63173 8.96506 5.33325 9.33325 5.33325C9.70144 5.33325 9.99992 5.63173 9.99992 5.99992V9.99992C9.99992 10.2937 9.80761 10.5528 9.52644 10.638C9.24527 10.7231 8.94151 10.6142 8.77855 10.3697L7.33325 8.20177V9.99992C7.33325 10.3681 7.03478 10.6666 6.66659 10.6666C6.2984 10.6666 5.99992 10.3681 5.99992 9.99992V5.99992C5.99992 5.70614 6.19222 5.44699 6.4734 5.36186Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M11.0768 5.38453C11.4167 5.24292 11.807 5.40364 11.9486 5.74351L12.9999 8.26658L14.0512 5.74351C14.1928 5.40364 14.5831 5.24292 14.923 5.38453C15.2629 5.52614 15.4236 5.91646 15.282 6.25633L13.6153 10.2563C13.5118 10.5048 13.2691 10.6666 12.9999 10.6666C12.7308 10.6666 12.488 10.5048 12.3845 10.2563L10.7179 6.25633C10.5763 5.91646 10.737 5.52614 11.0768 5.38453Z\" fill=\"currentColor\"/></g></g></g>"
},
"line-others-evaluation": {
"body": "<path fill=\"currentColor\" d=\"M3 15V3.75v8.513v-1.594zm-.5 1.5a1 1 0 0 1-1-1V3.25a1 1 0 0 1 1-1h13a1 1 0 0 1 1 1v7.25H15V3.75H3V15h6v1.5zm10.513.75l-2.663-2.662l1.069-1.05l1.593 1.593l3.188-3.187l1.05 1.068zM7.5 9.75h6v-1.5h-6zm0-3h6v-1.5h-6zm-3 3H6v-1.5H4.5zm0-3H6v-1.5H4.5z\"/>",
"width": 18,
"height": 18
},
"line-others-global-variable": {
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.23814 1.33333H9.76188C10.4844 1.33332 11.0672 1.33332 11.5391 1.37187C12.025 1.41157 12.4518 1.49545 12.8466 1.69664C13.4739 2.01622 13.9838 2.52615 14.3034 3.15336C14.5046 3.54822 14.5884 3.97501 14.6281 4.46091C14.6667 4.93283 14.6667 5.51559 14.6667 6.23811V9.76188C14.6667 10.4844 14.6667 11.0672 14.6281 11.5391C14.5884 12.025 14.5046 12.4518 14.3034 12.8466C13.9838 13.4738 13.4739 13.9838 12.8466 14.3033C12.4518 14.5045 12.025 14.5884 11.5391 14.6281C11.0672 14.6667 10.4844 14.6667 9.7619 14.6667H6.23812C5.51561 14.6667 4.93284 14.6667 4.46093 14.6281C3.97503 14.5884 3.54824 14.5045 3.15338 14.3033C2.52617 13.9838 2.01623 13.4738 1.69666 12.8466C1.49546 12.4518 1.41159 12.025 1.37189 11.5391C1.33333 11.0672 1.33334 10.4844 1.33334 9.76187V6.23812C1.33334 5.5156 1.33333 4.93283 1.37189 4.46091C1.41159 3.97501 1.49546 3.54822 1.69666 3.15336C2.01623 2.52615 2.52617 2.01622 3.15338 1.69664C3.54824 1.49545 3.97503 1.41157 4.46093 1.37187C4.93285 1.33332 5.51561 1.33332 6.23814 1.33333ZM4.5695 2.70078C4.16606 2.73374 3.93427 2.79519 3.7587 2.88465C3.38237 3.0764 3.07641 3.38236 2.88466 3.75868C2.79521 3.93425 2.73376 4.16604 2.70079 4.56949C2.6672 4.98072 2.66668 5.50892 2.66668 6.26666V9.73333C2.66668 10.4911 2.6672 11.0193 2.70079 11.4305C2.73376 11.8339 2.79521 12.0657 2.88466 12.2413C3.07641 12.6176 3.38237 12.9236 3.7587 13.1153C3.93427 13.2048 4.16606 13.2662 4.5695 13.2992C4.98073 13.3328 5.50894 13.3333 6.26668 13.3333H9.73334C10.4911 13.3333 11.0193 13.3328 11.4305 13.2992C11.834 13.2662 12.0658 13.2048 12.2413 13.1153C12.6176 12.9236 12.9236 12.6176 13.1154 12.2413C13.2048 12.0657 13.2663 11.8339 13.2992 11.4305C13.3328 11.0193 13.3333 10.4911 13.3333 9.73333V6.26666C13.3333 5.50892 13.3328 4.98072 13.2992 4.56949C13.2663 4.16604 13.2048 3.93425 13.1154 3.75868C12.9236 3.38236 12.6176 3.0764 12.2413 2.88465C12.0658 2.79519 11.834 2.73374 11.4305 2.70078C11.0193 2.66718 10.4911 2.66666 9.73334 2.66666H6.26668C5.50894 2.66666 4.98073 2.66718 4.5695 2.70078ZM5.08339 5.33333C5.08339 4.96514 5.38187 4.66666 5.75006 4.66666H6.68433C7.324 4.66666 7.87606 5.09677 8.04724 5.70542L8.30138 6.60902L9.2915 5.43554C9.7018 4.94926 10.3035 4.66666 10.9399 4.66666H11C11.3682 4.66666 11.6667 4.96514 11.6667 5.33333C11.6667 5.70152 11.3682 5.99999 11 5.99999H10.9399C10.7005 5.99999 10.4702 6.10616 10.3106 6.29537L8.73751 8.15972L9.23641 9.93357C9.24921 9.97909 9.28574 10 9.31579 10H10.2501C10.6182 10 10.9167 10.2985 10.9167 10.6667C10.9167 11.0349 10.6182 11.3333 10.2501 11.3333H9.31579C8.67612 11.3333 8.12406 10.9032 7.95288 10.2946L7.69871 9.39088L6.70852 10.5644C6.29822 11.0507 5.6965 11.3333 5.06011 11.3333H5.00001C4.63182 11.3333 4.33334 11.0349 4.33334 10.6667C4.33334 10.2985 4.63182 10 5.00001 10H5.06011C5.29949 10 5.52982 9.89383 5.68946 9.70462L7.26258 7.84019L6.76371 6.06642C6.75091 6.0209 6.71438 5.99999 6.68433 5.99999H5.75006C5.38187 5.99999 5.08339 5.70152 5.08339 5.33333Z\" fill=\"currentColor\"/></g>"
},
@ -1040,11 +1025,6 @@
"workflow-if-else": {
"body": "<g fill=\"none\"><g id=\"icons/if-else\"><path id=\"Vector (Stroke)\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.16667 2.98975C7.80423 2.98975 7.51042 2.69593 7.51042 2.3335C7.51042 1.97106 7.80423 1.67725 8.16667 1.67725H11.0833C11.4458 1.67725 11.7396 1.97106 11.7396 2.3335V5.25016C11.7396 5.6126 11.4458 5.90641 11.0833 5.90641C10.7209 5.90641 10.4271 5.6126 10.4271 5.25016V3.91782L7.34474 7.00016L10.4271 10.0825V8.75016C10.4271 8.38773 10.7209 8.09391 11.0833 8.09391C11.4458 8.09391 11.7396 8.38773 11.7396 8.75016V11.6668C11.7396 12.0293 11.4458 12.3231 11.0833 12.3231H8.16667C7.80423 12.3231 7.51042 12.0293 7.51042 11.6668C7.51042 11.3044 7.80423 11.0106 8.16667 11.0106H9.49901L6.14484 7.65641H1.75C1.38756 7.65641 1.09375 7.3626 1.09375 7.00016C1.09375 6.63773 1.38756 6.34391 1.75 6.34391H6.14484L9.49901 2.98975H8.16667Z\" fill=\"currentColor\"/></g></g>"
},
"workflow-input-field": {
"body": "<path fill=\"currentColor\" d=\"M5.333 3.333h2v9.334h-2V14h5.333v-1.333h-2V3.333h2V2H5.333zm-4 1.334a.667.667 0 0 0-.666.666v5.334c0 .368.298.666.666.666h4V10H2V6h3.333V4.667zM10.667 6H14v4h-3.334v1.333h4a.667.667 0 0 0 .667-.666V5.333a.667.667 0 0 0-.667-.666h-4z\"/>",
"width": 16,
"height": 16
},
"workflow-iteration": {
"body": "<g fill=\"none\"><g id=\"icons/iteration\"><path id=\"Vector\" d=\"M6.82849 0.754349C6.6007 0.526545 6.23133 0.526545 6.00354 0.754349C5.77573 0.982158 5.77573 1.3515 6.00354 1.57931L6.82849 0.754349ZM8.16602 2.91683L8.57849 3.32931C8.80628 3.1015 8.80628 2.73216 8.57849 2.50435L8.16602 2.91683ZM6.00354 4.25435C5.77573 4.48216 5.77573 4.8515 6.00354 5.07931C6.23133 5.30711 6.6007 5.30711 6.82849 5.07931L6.00354 4.25435ZM7.99516 9.74597C8.22295 9.51818 8.22295 9.14881 7.99516 8.92102C7.76737 8.69323 7.398 8.69323 7.17021 8.92102L7.99516 9.74597ZM5.83268 11.0835L5.4202 10.671C5.1924 10.8988 5.1924 11.2682 5.4202 11.496L5.83268 11.0835ZM7.17021 13.246C7.398 13.4738 7.76737 13.4738 7.99516 13.246C8.22295 13.0182 8.22295 12.6488 7.99516 12.421L7.17021 13.246ZM11.4993 3.73414C11.2738 3.50404 10.9045 3.5003 10.6744 3.72578C10.4443 3.95127 10.4405 4.32059 10.6661 4.55069L11.4993 3.73414ZM7.58268 3.50016C7.90486 3.50016 8.16602 3.23899 8.16602 2.91683C8.16602 2.59467 7.90486 2.3335 7.58268 2.3335L7.58268 3.50016ZM2.49938 10.2662C2.72486 10.4963 3.09419 10.5 3.32429 10.2745C3.55439 10.0491 3.55814 9.6797 3.33266 9.44964L2.49938 10.2662ZM6.00354 1.57931L7.75354 3.32931L8.57849 2.50435L6.82849 0.754349L6.00354 1.57931ZM7.75354 2.50435L6.00354 4.25435L6.82849 5.07931L8.57849 3.32931L7.75354 2.50435ZM7.17021 8.92102L5.4202 10.671L6.24516 11.496L7.99516 9.74597L7.17021 8.92102ZM5.4202 11.496L7.17021 13.246L7.99516 12.421L6.24516 10.671L5.4202 11.496ZM8.16602 10.5002L6.41602 10.5002V11.6668L8.16602 11.6668V10.5002ZM11.666 7.00016C11.666 8.93316 10.099 10.5002 8.16602 10.5002V11.6668C10.7434 11.6668 12.8327 9.57751 12.8327 7.00016H11.666ZM12.8327 7.00016C12.8327 5.72882 12.3235 4.57524 11.4993 3.73414L10.6661 4.55069C11.2852 5.18256 11.666 6.0463 11.666 7.00016H12.8327ZM5.83268 3.50016H7.58268L7.58268 2.3335H5.83268L5.83268 3.50016ZM2.33268 7.00016C2.33268 5.06717 3.89968 3.50016 5.83268 3.50016L5.83268 2.3335C3.25535 2.3335 1.16602 4.42283 1.16602 7.00016H2.33268ZM1.16602 7.00016C1.16602 8.27148 1.67517 9.42508 2.49938 10.2662L3.33266 9.44964C2.71348 8.81777 2.33268 7.95403 2.33268 7.00016H1.16602Z\" fill=\"currentColor\"/></g></g>"
},

View File

@ -1,7 +1,7 @@
{
"prefix": "custom-vender",
"name": "Dify Custom Vender",
"total": 281,
"total": 277,
"version": "0.0.0-private",
"author": {
"name": "LangGenius, Inc.",

2447
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@ saveExact: true
catalogMode: prefer
dedupeDirectDeps: true
engineStrict: true
minimumReleaseAge: 1440
minimumReleaseAge: 0
optimisticRepeatInstall: true
verifyDepsBeforeRun: install
resolutionMode: time-based
@ -54,8 +54,8 @@ overrides:
yaml@>=2.0.0 <2.8.3: 2.8.3
yauzl@<3.2.1: 3.2.1
catalog:
'@amplitude/analytics-browser': 2.42.0
'@amplitude/plugin-session-replay-browser': 1.28.1
'@amplitude/analytics-browser': 2.42.1
'@amplitude/plugin-session-replay-browser': 1.29.0
'@antfu/eslint-config': 8.2.0
'@base-ui/react': 1.4.1
'@chromatic-com/storybook': 5.1.2
@ -65,11 +65,11 @@ catalog:
'@eslint-react/eslint-plugin': 3.0.0
'@eslint/js': 10.0.1
'@floating-ui/react': 0.27.19
'@formatjs/intl-localematcher': 0.8.4
'@formatjs/intl-localematcher': 0.8.6
'@headlessui/react': 2.2.10
'@heroicons/react': 2.2.0
'@hey-api/openapi-ts': 0.97.0
'@hono/node-server': 2.0.0
'@hey-api/openapi-ts': 0.97.1
'@hono/node-server': 2.0.1
'@iconify-json/heroicons': 1.2.3
'@iconify-json/ri': 1.2.10
'@lexical/code': 0.44.0
@ -85,42 +85,42 @@ catalog:
'@monaco-editor/react': 4.7.0
'@next/eslint-plugin-next': 16.2.4
'@next/mdx': 16.2.4
'@orpc/client': 1.14.0
'@orpc/contract': 1.14.0
'@orpc/openapi-client': 1.14.0
'@orpc/tanstack-query': 1.14.0
'@orpc/client': 1.14.1
'@orpc/contract': 1.14.1
'@orpc/openapi-client': 1.14.1
'@orpc/tanstack-query': 1.14.1
'@playwright/test': 1.59.1
'@remixicon/react': 4.9.0
'@rgrove/parse-xml': 4.2.0
'@sentry/react': 10.50.0
'@storybook/addon-docs': 10.3.5
'@storybook/addon-links': 10.3.5
'@storybook/addon-onboarding': 10.3.5
'@storybook/addon-themes': 10.3.5
'@storybook/nextjs-vite': 10.3.5
'@storybook/react': 10.3.5
'@storybook/react-vite': 10.3.5
'@sentry/react': 10.51.0
'@storybook/addon-docs': 10.3.6
'@storybook/addon-links': 10.3.6
'@storybook/addon-onboarding': 10.3.6
'@storybook/addon-themes': 10.3.6
'@storybook/nextjs-vite': 10.3.6
'@storybook/react': 10.3.6
'@storybook/react-vite': 10.3.6
'@streamdown/math': 1.0.2
'@svgdotjs/svg.js': 3.2.5
'@t3-oss/env-nextjs': 0.13.11
'@tailwindcss/postcss': 4.2.4
'@tailwindcss/typography': 0.5.19
'@tailwindcss/vite': 4.2.4
'@tanstack/eslint-plugin-query': 5.100.6
'@tanstack/eslint-plugin-query': 5.100.9
'@tanstack/react-devtools': 0.10.2
'@tanstack/react-form': 1.29.1
'@tanstack/react-form-devtools': 0.2.22
'@tanstack/react-hotkeys': 0.10.0
'@tanstack/react-query': 5.100.6
'@tanstack/react-query-devtools': 5.100.6
'@tanstack/react-query': 5.100.9
'@tanstack/react-query-devtools': 5.100.9
'@tanstack/react-virtual': 3.13.24
'@testing-library/dom': 10.4.1
'@testing-library/jest-dom': 6.9.1
'@testing-library/react': 16.3.2
'@testing-library/user-event': 14.6.1
'@tsslint/cli': 3.1.0
'@tsslint/compat-eslint': 3.1.0
'@tsslint/config': 3.1.0
'@tsslint/cli': 3.1.1
'@tsslint/compat-eslint': 3.1.1
'@tsslint/config': 3.1.1
'@types/js-cookie': 3.0.6
'@types/js-yaml': 4.0.9
'@types/negotiator': 0.6.4
@ -129,9 +129,9 @@ catalog:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3
'@types/sortablejs': 1.15.9
'@typescript-eslint/eslint-plugin': 8.59.1
'@typescript-eslint/parser': 8.59.1
'@typescript/native-preview': 7.0.0-dev.20260428.1
'@typescript-eslint/eslint-plugin': 8.59.2
'@typescript-eslint/parser': 8.59.2
'@typescript/native-preview': 7.0.0-dev.20260505.1
'@vitejs/plugin-react': 6.0.1
'@vitejs/plugin-rsc': 0.5.25
'@vitest/coverage-v8': 4.1.5
@ -148,44 +148,44 @@ catalog:
cron-parser: 5.5.0
dayjs: 1.11.20
decimal.js: 10.6.0
dompurify: 3.4.1
dompurify: 3.4.2
echarts: 6.0.0
echarts-for-react: 3.0.6
elkjs: 0.11.1
embla-carousel-autoplay: 8.6.0
embla-carousel-react: 8.6.0
emoji-mart: 5.6.0
es-toolkit: 1.46.0
eslint: 10.2.1
eslint-markdown: 0.7.0
es-toolkit: 1.46.1
eslint: 10.3.0
eslint-markdown: 0.8.0
eslint-plugin-better-tailwindcss: 4.5.0
eslint-plugin-hyoban: 0.14.1
eslint-plugin-markdown-preferences: 0.41.1
eslint-plugin-no-barrel-files: 1.3.1
eslint-plugin-react-refresh: 0.5.2
eslint-plugin-sonarjs: 4.0.3
eslint-plugin-storybook: 10.3.5
eslint-plugin-storybook: 10.3.6
fast-deep-equal: 3.1.3
happy-dom: 20.9.0
hast-util-to-jsx-runtime: 2.3.6
hono: 4.12.15
hono: 4.12.17
html-entities: 2.6.0
html-to-image: 1.11.13
i18next: 26.0.8
i18next-resources-to-backend: 1.2.1
iconify-import-svg: 0.2.0
immer: 11.1.4
jotai: 2.19.1
immer: 11.1.6
jotai: 2.20.0
js-audio-recorder: 1.0.7
js-cookie: 3.0.5
js-yaml: 4.1.1
jsonschema: 1.5.0
katex: 0.16.45
knip: 6.7.0
knip: 6.11.0
ky: 2.0.2
lamejs: 1.2.1
lexical: 0.44.0
loro-crdt: 1.12.0
loro-crdt: 1.12.1
mermaid: 11.14.0
mime: 4.1.0
mitt: 3.0.1
@ -195,14 +195,14 @@ catalog:
nuqs: 2.8.9
pinyin-pro: 3.28.1
playwright: 1.59.1
postcss: 8.5.12
postcss: 8.5.14
qrcode.react: 4.2.0
qs: 6.15.1
react: 19.2.5
react-18-input-autosize: 3.0.0
react-dom: 19.2.5
react-easy-crop: 5.5.7
react-hotkeys-hook: 5.2.4
react-hotkeys-hook: 5.3.2
react-i18next: 16.5.8
react-multi-email: 1.0.25
react-papaparse: 4.4.0
@ -219,25 +219,25 @@ catalog:
socket.io-client: 4.8.3
sortablejs: 1.15.7
std-semver: 1.0.8
storybook: 10.3.5
storybook: 10.3.6
streamdown: 2.5.0
string-ts: 2.3.1
tailwind-merge: 3.5.0
tailwindcss: 4.2.4
tldts: 7.0.29
tldts: 7.0.30
tsx: 4.21.0
typescript: 6.0.3
uglify-js: 3.19.3
unist-util-visit: 5.1.0
use-context-selector: 2.0.0
uuid: 14.0.0
vinext: 0.0.45
vinext: 0.0.47
vite: npm:@voidzero-dev/vite-plus-core@0.1.20
vite-plugin-inspect: 12.0.0-beta.1
vite-plus: 0.1.20
vitest: npm:@voidzero-dev/vite-plus-test@0.1.20
vitest-browser-react: 2.2.0
vitest-canvas-mock: 1.1.4
zod: 4.3.6
zod: 4.4.3
zundo: 2.3.0
zustand: 5.0.12
zustand: 5.0.13

View File

@ -72,13 +72,6 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({
useSnippetAndEvaluationPlanAccess: () => ({
canAccess: true,
isReady: true,
}),
}))
vi.mock('@/app/components/base/tag-management/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
@ -95,46 +88,33 @@ vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'user-1', name: 'Current User', email: 'current@example.com', avatar: '', avatar_url: '', role: 'owner', last_login_at: '', created_at: '', status: 'active' },
],
},
}),
}))
vi.mock('@/service/apps', () => ({
fetchWorkflowOnlineUsers: vi.fn().mockResolvedValue({}),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useInfiniteQuery: () => ({
data: { pages: mockPages },
isLoading: mockIsLoading,
isFetching: mockIsFetching,
isFetchingNextPage: mockIsFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockHasNextPage,
error: mockError,
refetch: mockRefetch,
}),
}
})
vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: { pages: mockPages },
isLoading: mockIsLoading,
isFetching: mockIsFetching,
isFetchingNextPage: mockIsFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockHasNextPage,
error: mockError,
refetch: mockRefetch,
}),
useDeleteAppMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}))
vi.mock('@/service/use-snippets', () => ({
useInfiniteSnippetList: () => ({
data: { pages: [] },
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
error: null,
vi.mock('@/app/components/apps/hooks/use-workflow-online-users', () => ({
useWorkflowOnlineUsers: () => ({
onlineUsersMap: {},
}),
}))
@ -357,11 +337,16 @@ describe('App List Browsing Flow', () => {
// -- Tab navigation --
describe('Tab Navigation', () => {
it('should render the app type dropdown trigger', () => {
it('should render all category tabs', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
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()
})
})
@ -387,19 +372,21 @@ describe('App List Browsing Flow', () => {
// -- "Created by me" filter --
describe('Created By Me Filter', () => {
it('should not render a standalone "created by me" checkbox in the current header layout', () => {
it('should render the "created by me" checkbox', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should keep the current layout stable without a "created by me" control', () => {
it('should toggle the "created by me" filter on click', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
const checkbox = screen.getByText('app.showMyCreatedAppsOnly')
fireEvent.click(checkbox)
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
})

View File

@ -59,13 +59,6 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({
useSnippetAndEvaluationPlanAccess: () => ({
canAccess: true,
isReady: true,
}),
}))
vi.mock('@/app/components/base/tag-management/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
@ -82,46 +75,33 @@ vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'user-1', name: 'Current User', email: 'current@example.com', avatar: '', avatar_url: '', role: 'owner', last_login_at: '', created_at: '', status: 'active' },
],
},
}),
}))
vi.mock('@/service/apps', () => ({
fetchWorkflowOnlineUsers: vi.fn().mockResolvedValue({}),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useInfiniteQuery: () => ({
data: { pages: mockPages },
isLoading: mockIsLoading,
isFetching: mockIsFetching,
isFetchingNextPage: false,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
error: null,
refetch: mockRefetch,
}),
}
})
vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: { pages: mockPages },
isLoading: mockIsLoading,
isFetching: mockIsFetching,
isFetchingNextPage: false,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
error: null,
refetch: mockRefetch,
}),
useDeleteAppMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}))
vi.mock('@/service/use-snippets', () => ({
useInfiniteSnippetList: () => ({
data: { pages: [] },
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
error: null,
vi.mock('@/app/components/apps/hooks/use-workflow-online-users', () => ({
useWorkflowOnlineUsers: () => ({
onlineUsersMap: {},
}),
}))

View File

@ -1,16 +0,0 @@
import SnippetAndEvaluationPlanGuard from '@/app/components/billing/snippet-and-evaluation-plan-guard'
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ appId: string }>
}) => {
const { appId } = await props.params
return (
<SnippetAndEvaluationPlanGuard fallbackHref={`/app/${appId}/overview`}>
<Evaluation resourceType="apps" resourceId={appId} />
</SnippetAndEvaluationPlanGuard>
)
}
export default Page

View File

@ -25,7 +25,6 @@ import { useStore as useTagStore } from '@/app/components/base/tag-management/st
import { useAppContext } from '@/context/app-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
import dynamic from '@/next/dynamic'
import { usePathname, useRouter } from '@/next/navigation'
import { fetchAppDetailDirect } from '@/service/apps'
@ -41,10 +40,6 @@ type IAppDetailLayoutProps = {
appId: string
}
const EvaluationIcon = ({ className }: { className?: string }) => {
return <span aria-hidden className={cn('i-custom-vender-line-others-evaluation', className)} />
}
const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const {
children,
@ -55,7 +50,6 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const pathname = usePathname()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess()
const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
const { appDetail, setAppDetail, setAppSidebarExpand } = useStore(useShallow(state => ({
appDetail: state.appDetail,
@ -73,53 +67,42 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
}>>([])
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
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.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,
})
if (isCurrentWorkspaceEditor && canAccessSnippetsAndEvaluation) {
navConfig.push({
name: t('appMenus.evaluation', { ns: 'common' }),
href: `/app/${appId}/evaluation`,
icon: EvaluationIcon,
selectedIcon: EvaluationIcon,
})
}
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,
},
]
return navConfig
}, [canAccessSnippetsAndEvaluation, t])
}, [t])
useDocumentTitle(appDetail?.name || t('menus.appDetail', { ns: 'common' }))

View File

@ -1,230 +0,0 @@
import type { ReactNode } from 'react'
import type { DataSet } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import DatasetDetailLayout from '../layout-main'
let mockPathname = '/datasets/test-dataset-id/documents'
let mockDataset: DataSet | undefined
let mockCanAccessSnippetsAndEvaluation = true
const mockSetAppSidebarExpand = vi.fn()
const mockMutateDatasetRes = vi.fn()
vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname,
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({
setAppSidebarExpand: mockSetAppSidebarExpand,
}),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: {
mobile: 'mobile',
desktop: 'desktop',
},
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: vi.fn(),
},
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceDatasetOperator: false,
}),
}))
vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({
useSnippetAndEvaluationPlanAccess: () => ({
canAccess: mockCanAccessSnippetsAndEvaluation,
isReady: true,
}),
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetDetail: () => ({
data: mockDataset,
error: null,
refetch: mockMutateDatasetRes,
}),
useDatasetRelatedApps: () => ({
data: [],
}),
}))
vi.mock('@/app/components/app-sidebar', () => ({
default: ({
navigation,
children,
}: {
navigation: Array<{ name: string, href: string, disabled?: boolean }>
children?: ReactNode
}) => (
<div data-testid="app-sidebar">
{navigation.map(item => (
<button
key={item.href}
type="button"
disabled={item.disabled}
>
{item.name}
</button>
))}
{children}
</div>
),
}))
vi.mock('@/app/components/datasets/extra-info', () => ({
default: () => <div data-testid="dataset-extra-info" />,
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div role="status">loading</div>,
}))
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'test-dataset-id',
name: 'Test Dataset',
indexing_status: 'completed',
icon_info: {
icon: 'book',
icon_background: '#fff',
icon_type: 'emoji',
icon_url: '',
},
description: '',
permission: DatasetPermission.onlyMe,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
created_by: 'user-1',
updated_by: 'user-1',
updated_at: 0,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 0,
total_document_count: 0,
word_count: 0,
provider: 'vendor',
embedding_model: 'text-embedding',
embedding_model_provider: 'openai',
embedding_available: true,
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
},
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
},
tags: [],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 3,
score_threshold: 0.5,
score_threshold_enabled: false,
},
built_in_field_enabled: false,
pipeline_id: 'pipeline-1',
is_published: true,
runtime_mode: 'rag_pipeline',
enable_api: false,
is_multimodal: false,
...overrides,
})
describe('DatasetDetailLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPathname = '/datasets/test-dataset-id/documents'
mockDataset = createDataset()
mockCanAccessSnippetsAndEvaluation = true
})
describe('Evaluation navigation', () => {
it('should hide the evaluation menu when the dataset is not a rag pipeline', () => {
mockDataset = createDataset({
runtime_mode: 'general',
is_published: false,
})
render(
<DatasetDetailLayout datasetId="test-dataset-id">
<div data-testid="dataset-detail-content">content</div>
</DatasetDetailLayout>,
)
expect(screen.queryByRole('button', { name: 'common.datasetMenus.evaluation' })).not.toBeInTheDocument()
})
it('should disable the evaluation menu when the rag pipeline is unpublished', () => {
mockDataset = createDataset({
is_published: false,
})
render(
<DatasetDetailLayout datasetId="test-dataset-id">
<div data-testid="dataset-detail-content">content</div>
</DatasetDetailLayout>,
)
expect(screen.getByRole('button', { name: 'common.datasetMenus.evaluation' })).toBeDisabled()
})
it('should enable the evaluation menu when the rag pipeline is published', () => {
render(
<DatasetDetailLayout datasetId="test-dataset-id">
<div data-testid="dataset-detail-content">content</div>
</DatasetDetailLayout>,
)
expect(screen.getByRole('button', { name: 'common.datasetMenus.evaluation' })).toBeEnabled()
})
it('should hide the evaluation menu when snippet and evaluation access is unavailable', () => {
mockCanAccessSnippetsAndEvaluation = false
render(
<DatasetDetailLayout datasetId="test-dataset-id">
<div data-testid="dataset-detail-content">content</div>
</DatasetDetailLayout>,
)
expect(screen.queryByRole('button', { name: 'common.datasetMenus.evaluation' })).not.toBeInTheDocument()
})
})
})

View File

@ -1,16 +0,0 @@
import SnippetAndEvaluationPlanGuard from '@/app/components/billing/snippet-and-evaluation-plan-guard'
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ datasetId: string }>
}) => {
const { datasetId } = await props.params
return (
<SnippetAndEvaluationPlanGuard fallbackHref={`/datasets/${datasetId}/documents`}>
<Evaluation resourceType="datasets" resourceId={datasetId} />
</SnippetAndEvaluationPlanGuard>
)
}
export default Page

View File

@ -23,7 +23,6 @@ import DatasetDetailContext from '@/context/dataset-detail'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
import { usePathname } from '@/next/navigation'
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
@ -32,10 +31,6 @@ type IAppDetailLayoutProps = {
datasetId: string
}
const EvaluationIcon = ({ className }: { className?: string }) => {
return <span aria-hidden className={cn('i-custom-vender-line-others-evaluation', className)} />
}
const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const {
children,
@ -54,7 +49,6 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
setHideHeader(v.payload)
})
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
@ -62,7 +56,6 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const { data: datasetRes, error, refetch: mutateDatasetRes } = useDatasetDetail(datasetId)
const { data: relatedApps } = useDatasetRelatedApps(datasetId)
const isRagPipelineDataset = datasetRes?.runtime_mode === 'rag_pipeline'
const isButtonDisabledWithPipeline = useMemo(() => {
if (!datasetRes)
@ -93,36 +86,24 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
]
if (datasetRes?.provider !== 'external') {
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,
},
...baseNavigation,
...(isRagPipelineDataset && canAccessSnippetsAndEvaluation
? [{
name: t('datasetMenus.evaluation', { ns: 'common' }),
href: `/datasets/${datasetId}/evaluation`,
icon: EvaluationIcon,
selectedIcon: EvaluationIcon,
disabled: isButtonDisabledWithPipeline,
}]
: []),
]
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 baseNavigation
}, [canAccessSnippetsAndEvaluation, t, datasetId, isButtonDisabledWithPipeline, isRagPipelineDataset, datasetRes?.provider])
}, [t, datasetId, isButtonDisabledWithPipeline, datasetRes?.provider])
useDocumentTitle(datasetRes?.name || t('menus.datasets', { ns: 'common' }))

View File

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

View File

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

View File

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

View File

@ -1,21 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,12 +0,0 @@
import Apps from '@/app/components/apps'
import SnippetAndEvaluationPlanGuard from '@/app/components/billing/snippet-and-evaluation-plan-guard'
const SnippetsPage = () => {
return (
<SnippetAndEvaluationPlanGuard fallbackHref="/apps">
<Apps pageType="snippets" />
</SnippetAndEvaluationPlanGuard>
)
}
export default SnippetsPage

View File

@ -7,7 +7,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import {
RiGraduationCapFill,
} from '@remixicon/react'
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
@ -16,9 +16,9 @@ import PremiumBadge from '@/app/components/base/premium-badge'
import Collapse from '@/app/components/header/account-setting/collapse'
import { IS_CE_EDITION, validPassword } from '@/config'
import { useProviderContext } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
import { updateUserProfile } from '@/service/common'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useAppList } from '@/service/use-apps'
import { commonQueryKeys, userProfileQueryOptions } from '@/service/use-common'
import DeleteAccount from '../delete-account'
@ -35,7 +35,15 @@ const descriptionClassName = `
export default function AccountPage() {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: appList } = useAppList({ page: 1, limit: 100, name: '' })
const { data: appList } = useQuery(consoleQuery.apps.list.queryOptions({
input: {
query: {
page: 1,
limit: 100,
name: '',
},
},
}))
const apps = appList?.data || []
const queryClient = useQueryClient()
// Cache is warmed by AppContextProvider's useSuspenseQuery; this hits cache synchronously.
@ -129,7 +137,7 @@ export default function AccountPage() {
}
const renderAppItem = (item: IItem) => {
const { icon, icon_background, icon_type, icon_url } = item as any
const { icon, icon_background, icon_type, icon_url } = item as IItem & Pick<App, 'icon' | 'icon_background' | 'icon_type' | 'icon_url'>
return (
<div className="flex px-3 py-1">
<div className="mr-3">

View File

@ -165,21 +165,6 @@ 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

@ -2,7 +2,7 @@ import type { App, AppSSO } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import { AppModeEnum } from '@/types/app'
import AppInfoDetailPanel from '../app-info-detail-panel'
vi.mock('../../../base/app-icon', () => ({
@ -135,17 +135,6 @@ describe('AppInfoDetailPanel', () => {
expect(cardView).toHaveAttribute('data-app-id', 'app-1')
})
it('should not render CardView when app type is evaluation', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ type: AppTypeEnum.EVALUATION })}
/>,
)
expect(screen.queryByTestId('card-view')).not.toBeInTheDocument()
})
it('should render app icon with large size', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
const icon = screen.getByTestId('app-icon')

View File

@ -15,7 +15,7 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
import ContentDialog from '@/app/components/base/content-dialog'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import { AppModeEnum } from '@/types/app'
import AppIcon from '../../base/app-icon'
import { getAppModeLabel } from './app-mode-labels'
import AppOperations from './app-operations'
@ -126,13 +126,11 @@ const AppInfoDetailPanel = ({
secondaryOperations={secondaryOperations}
/>
</div>
{appDetail.workflow_kind !== AppTypeEnum.EVALUATION && (
<CardView
appId={appDetail.id}
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
)}
<CardView
appId={appDetail.id}
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
{switchOperation && (
<div className="flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2">
<Button

View File

@ -36,16 +36,12 @@ 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,
@ -120,11 +116,10 @@ const AppDetailNav = ({
expand ? 'p-2' : 'p-1',
)}
>
{renderHeader?.(appSidebarExpand)}
{!renderHeader && iconType === 'app' && (
{iconType === 'app' && (
<AppInfo expand={expand} />
)}
{!renderHeader && iconType !== 'app' && (
{iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
@ -153,8 +148,7 @@ const AppDetailNav = ({
expand ? 'px-3 py-2' : 'p-3',
)}
>
{renderNavigation?.(appSidebarExpand)}
{!renderNavigation && navigation.map((item, index) => {
{navigation.map((item, index) => {
return (
<NavLink
key={index}

View File

@ -262,20 +262,4 @@ 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,15 +14,13 @@ 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 = ({
@ -31,8 +29,6 @@ const NavLink = ({
iconMap,
mode = 'expand',
disabled = false,
active,
onClick,
}: NavLinkProps) => {
const segment = useSelectedLayoutSegment()
const formattedSegment = (() => {
@ -43,11 +39,8 @@ const NavLink = ({
return res
})()
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
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')}>
@ -77,32 +70,13 @@ 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={linkClassName}
className={cn(isActive
? 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only'
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pr-1 pl-3')}
title={mode === 'collapse' ? name : ''}
>
{renderIcon()}

View File

@ -1,284 +0,0 @@
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { CreateSnippetDialogPayload } from '@/app/components/workflow/create-snippet-dialog'
import type { SnippetDetail } from '@/models/snippet'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import SnippetInfoDropdown from '../dropdown'
const mockReplace = vi.fn()
const mockDownloadBlob = vi.fn()
const mockToastSuccess = vi.fn()
const mockToastError = vi.fn()
const mockUpdateMutate = vi.fn()
const mockExportMutateAsync = vi.fn()
const mockDeleteMutate = vi.fn()
let mockDropdownOpen = false
let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: (...args: unknown[]) => mockToastSuccess(...args),
error: (...args: unknown[]) => mockToastError(...args),
},
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({
DropdownMenu: ({
open,
onOpenChange,
children,
}: {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}) => {
mockDropdownOpen = !!open
mockDropdownOnOpenChange = onOpenChange
return <div>{children}</div>
},
DropdownMenuTrigger: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => (
<button
type="button"
className={className}
onClick={() => mockDropdownOnOpenChange?.(!mockDropdownOpen)}
>
{children}
</button>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
mockDropdownOpen ? <div>{children}</div> : null
),
DropdownMenuItem: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
DropdownMenuSeparator: () => <hr />,
}))
vi.mock('@/service/use-snippets', () => ({
useUpdateSnippetMutation: () => ({
mutate: mockUpdateMutate,
isPending: false,
}),
useExportSnippetMutation: () => ({
mutateAsync: mockExportMutateAsync,
isPending: false,
}),
useDeleteSnippetMutation: () => ({
mutate: mockDeleteMutate,
isPending: false,
}),
}))
type MockCreateSnippetDialogProps = {
isOpen: boolean
title?: string
confirmText?: string
initialValue?: {
name?: string
description?: string
icon?: AppIconSelection
}
onClose: () => void
onConfirm: (payload: CreateSnippetDialogPayload) => void
}
vi.mock('@/app/components/workflow/create-snippet-dialog', () => ({
default: ({
isOpen,
title,
confirmText,
initialValue,
onClose,
onConfirm,
}: MockCreateSnippetDialogProps) => {
if (!isOpen)
return null
return (
<div data-testid="create-snippet-dialog">
<div>{title}</div>
<div>{confirmText}</div>
<div>{initialValue?.name}</div>
<div>{initialValue?.description}</div>
<button
type="button"
onClick={() => onConfirm({
name: 'Updated snippet',
description: 'Updated description',
icon: {
type: 'emoji',
icon: '✨',
background: '#FFFFFF',
},
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})}
>
submit-edit
</button>
<button type="button" onClick={onClose}>close-edit</button>
</div>
)
},
}))
const mockSnippet: SnippetDetail = {
id: 'snippet-1',
name: 'Social Media Repurposer',
description: 'Turn one blog post into multiple social media variations.',
updatedAt: '2026-03-25 10:00',
usage: '12',
icon: '🤖',
iconBackground: '#F0FDF9',
status: undefined,
}
describe('SnippetInfoDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDropdownOpen = false
mockDropdownOnOpenChange = undefined
})
// Rendering coverage for the menu trigger itself.
describe('Rendering', () => {
it('should render the dropdown trigger button', () => {
render(<SnippetInfoDropdown snippet={mockSnippet} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
// Edit flow should seed the dialog with current snippet info and submit updates.
describe('Edit Snippet', () => {
it('should open the edit dialog and submit snippet updates', async () => {
const user = userEvent.setup()
mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.editInfo'))
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument()
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'submit-edit' }))
expect(mockUpdateMutate).toHaveBeenCalledWith({
params: { snippetId: mockSnippet.id },
body: {
name: 'Updated snippet',
description: 'Updated description',
icon_info: {
icon: '✨',
icon_type: 'emoji',
icon_background: '#FFFFFF',
icon_url: undefined,
},
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone')
})
})
// Export should call the export hook and download the returned YAML blob.
describe('Export Snippet', () => {
it('should export and download the snippet yaml', async () => {
const user = userEvent.setup()
mockExportMutateAsync.mockResolvedValue('yaml: content')
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.exportSnippet'))
await waitFor(() => {
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id })
})
expect(mockDownloadBlob).toHaveBeenCalledWith({
data: expect.any(Blob),
fileName: `${mockSnippet.name}.yml`,
})
})
it('should show an error toast when export fails', async () => {
const user = userEvent.setup()
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.exportSnippet'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed')
})
})
})
// Delete should require confirmation and redirect after a successful mutation.
describe('Delete Snippet', () => {
it('should confirm deletion and redirect to the snippets list', async () => {
const user = userEvent.setup()
mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.deleteSnippet'))
expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument()
expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
expect(mockDeleteMutate).toHaveBeenCalledWith({
params: { snippetId: mockSnippet.id },
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted')
expect(mockReplace).toHaveBeenCalledWith('/snippets')
})
})
})

View File

@ -1,61 +0,0 @@
import type { SnippetDetail } from '@/models/snippet'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import SnippetInfo from '..'
vi.mock('../dropdown', () => ({
default: () => <div data-testid="snippet-info-dropdown" />,
}))
const mockSnippet: SnippetDetail = {
id: 'snippet-1',
name: 'Social Media Repurposer',
description: 'Turn one blog post into multiple social media variations.',
updatedAt: '2026-03-25 10:00',
usage: '12',
icon: '🤖',
iconBackground: '#F0FDF9',
status: undefined,
}
describe('SnippetInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests for the collapsed and expanded sidebar header states.
describe('Rendering', () => {
it('should render the expanded snippet details and dropdown when expand is true', () => {
render(<SnippetInfo expand={true} snippet={mockSnippet} />)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument()
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument()
})
it('should hide the expanded-only content when expand is false', () => {
render(<SnippetInfo expand={false} snippet={mockSnippet} />)
expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument()
expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument()
})
})
// Edge cases around optional snippet fields should not break the header layout.
describe('Edge Cases', () => {
it('should omit the description block when the snippet has no description', () => {
render(
<SnippetInfo
expand={true}
snippet={{ ...mockSnippet, description: '' }}
/>,
)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
})
})
})

View File

@ -1,198 +0,0 @@
'use client'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { SnippetDetail } from '@/models/snippet'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
import { useRouter } from '@/next/navigation'
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
import { downloadBlob } from '@/utils/download'
type SnippetInfoDropdownProps = {
snippet: SnippetDetail
}
const FALLBACK_ICON: AppIconSelection = {
type: 'emoji',
icon: '🤖',
background: '#FFEAD5',
}
const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
const { t } = useTranslation('snippet')
const { replace } = useRouter()
const [open, setOpen] = React.useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false)
const updateSnippetMutation = useUpdateSnippetMutation()
const exportSnippetMutation = useExportSnippetMutation()
const deleteSnippetMutation = useDeleteSnippetMutation()
const initialValue = React.useMemo(() => ({
name: snippet.name,
description: snippet.description,
icon: snippet.icon
? {
type: 'emoji' as const,
icon: snippet.icon,
background: snippet.iconBackground || FALLBACK_ICON.background,
}
: FALLBACK_ICON,
}), [snippet.description, snippet.icon, snippet.iconBackground, snippet.name])
const handleOpenEditDialog = React.useCallback(() => {
setOpen(false)
setIsEditDialogOpen(true)
}, [])
const handleExportSnippet = React.useCallback(async () => {
setOpen(false)
try {
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
const file = new Blob([data], { type: 'application/yaml' })
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
}
catch {
toast.error(t('exportFailed'))
}
}, [exportSnippetMutation, snippet.id, snippet.name, t])
const handleEditSnippet = React.useCallback(async ({ name, description, icon }: {
name: string
description: string
icon: AppIconSelection
}) => {
updateSnippetMutation.mutate({
params: { snippetId: snippet.id },
body: {
name,
description: description || undefined,
icon_info: {
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
icon_type: icon.type,
icon_background: icon.type === 'emoji' ? icon.background : undefined,
icon_url: icon.type === 'image' ? icon.url : undefined,
},
},
}, {
onSuccess: () => {
toast.success(t('editDone'))
setIsEditDialogOpen(false)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('editFailed'))
},
})
}, [snippet.id, t, updateSnippetMutation])
const handleDeleteSnippet = React.useCallback(() => {
deleteSnippetMutation.mutate({
params: { snippetId: snippet.id },
}, {
onSuccess: () => {
toast.success(t('deleted'))
setIsDeleteDialogOpen(false)
replace('/snippets')
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
},
})
}, [deleteSnippetMutation, replace, snippet.id, t])
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn('action-btn action-btn-m size-6 rounded-md text-text-tertiary', open && 'bg-state-base-hover text-text-secondary')}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[180px] p-1"
>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.exportSnippet')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="!my-1 bg-divider-subtle" />
<DropdownMenuItem
className="mx-0 gap-2"
destructive
onClick={() => {
setOpen(false)
setIsDeleteDialogOpen(true)
}}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="grow">{t('menu.deleteSnippet')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isEditDialogOpen && (
<CreateSnippetDialog
isOpen={isEditDialogOpen}
initialValue={initialValue}
title={t('editDialogTitle')}
confirmText={t('operation.save', { ns: 'common' })}
isSubmitting={updateSnippetMutation.isPending}
onClose={() => setIsEditDialogOpen(false)}
onConfirm={handleEditSnippet}
/>
)}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="w-[400px]">
<div className="space-y-2 p-6">
<AlertDialogTitle className="title-lg-semi-bold text-text-primary">
{t('deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{t('deleteConfirmContent')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-0">
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleteSnippetMutation.isPending}
onClick={handleDeleteSnippet}
>
{t('menu.deleteSnippet')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default React.memo(SnippetInfoDropdown)

View File

@ -1,55 +0,0 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import SnippetInfoDropdown from './dropdown'
type SnippetInfoProps = {
expand: boolean
snippet: SnippetDetail
}
const SnippetInfo = ({
expand,
snippet,
}: SnippetInfoProps) => {
const { t } = useTranslation('snippet')
return (
<div className={cn('flex flex-col', expand ? 'px-2 pt-2 pb-1' : 'p-1')}>
<div className={cn('flex flex-col', expand ? 'gap-2 rounded-xl p-2' : '')}>
<div className={cn('flex', expand ? 'items-center justify-between' : 'items-start gap-3')}>
<div className={cn('shrink-0', !expand && 'ml-1')}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType="emoji"
icon={snippet.icon}
background={snippet.iconBackground}
/>
</div>
{expand && <SnippetInfoDropdown snippet={snippet} />}
</div>
{expand && (
<div className="min-w-0">
<div className="system-md-semibold truncate text-text-secondary">
{snippet.name}
</div>
<div className="system-2xs-medium-uppercase pt-1 text-text-tertiary">
{t('typeLabel')}
</div>
</div>
)}
{expand && snippet.description && (
<p className="system-xs-regular line-clamp-3 break-words text-text-tertiary">
{snippet.description}
</p>
)}
</div>
</div>
)
}
export default React.memo(SnippetInfo)

View File

@ -3,7 +3,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import AppPublisher from '../index'
@ -20,11 +20,7 @@ const mockOpenAsyncWindow = vi.fn()
const mockFetchInstalledAppList = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
const mockToastError = vi.fn()
const mockToastSuccess = vi.fn()
const mockConvertWorkflowType = vi.fn()
const mockRefetchEvaluationWorkflowAssociatedTargets = vi.fn()
const mockInvalidateAppWorkflow = vi.fn()
let mockCanAccessSnippetsAndEvaluation = true
const sectionProps = vi.hoisted(() => ({
summary: null as null | Record<string, any>,
@ -36,7 +32,6 @@ const ahooksMocks = vi.hoisted(() => ({
}))
let mockAppDetail: Record<string, any> | null = null
let mockEvaluationWorkflowAssociatedTargets: Record<string, any> | undefined
vi.mock('react-i18next', () => ({
useTranslation: () => ({
@ -69,13 +64,6 @@ vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => mockOpenAsyncWindow,
}))
vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({
useSnippetAndEvaluationPlanAccess: () => ({
canAccess: mockCanAccessSnippetsAndEvaluation,
isReady: true,
}),
}))
vi.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: () => ({
data: { result: true },
@ -99,21 +87,6 @@ vi.mock('@/service/apps', () => ({
publishToCreatorsPlatform: (...args: unknown[]) => mockPublishToCreatorsPlatform(...args),
}))
vi.mock('@/service/use-apps', () => ({
useConvertWorkflowTypeMutation: () => ({
mutateAsync: (...args: unknown[]) => mockConvertWorkflowType(...args),
isPending: false,
}),
}))
vi.mock('@/service/use-evaluation', () => ({
useEvaluationWorkflowAssociatedTargets: () => ({
data: mockEvaluationWorkflowAssociatedTargets,
refetch: mockRefetchEvaluationWorkflowAssociatedTargets,
isFetching: false,
}),
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow,
}))
@ -121,7 +94,6 @@ vi.mock('@/service/use-workflow', () => ({
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
success: (...args: unknown[]) => mockToastSuccess(...args),
},
}))
@ -155,15 +127,15 @@ vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
return {
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<OpenContext value={open}>
<OpenContext.Provider value={open}>
<div>{children}</div>
</OpenContext>
</OpenContext.Provider>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<div onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
const open = ReactModule.use(OpenContext)
const open = ReactModule.useContext(OpenContext)
return open ? <div>{children}</div> : null
},
}
@ -176,7 +148,6 @@ vi.mock('../sections', () => ({
<div>
<button onClick={() => void props.handlePublish()}>publisher-summary-publish</button>
<button onClick={() => void props.handleRestore()}>publisher-summary-restore</button>
<button onClick={() => void props.onWorkflowTypeSwitch()}>publisher-switch-workflow-type</button>
</div>
)
},
@ -202,13 +173,11 @@ describe('AppPublisher', () => {
sectionProps.summary = null
sectionProps.access = null
sectionProps.actions = null
mockCanAccessSnippetsAndEvaluation = true
mockAppDetail = {
id: 'app-1',
name: 'Demo App',
mode: AppModeEnum.CHAT,
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
type: AppTypeEnum.WORKFLOW,
site: {
app_base_url: 'https://example.com',
access_token: 'token-1',
@ -221,12 +190,6 @@ describe('AppPublisher', () => {
id: 'app-1',
access_mode: AccessMode.PUBLIC,
})
mockConvertWorkflowType.mockResolvedValue({})
mockEvaluationWorkflowAssociatedTargets = { items: [] }
mockRefetchEvaluationWorkflowAssociatedTargets.mockResolvedValue({
data: { items: [] },
isError: false,
})
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
await resolver()
})
@ -562,278 +525,4 @@ describe('AppPublisher', () => {
})
expect(screen.getByTestId('access-control'))!.toBeInTheDocument()
})
it('should switch workflow type, refresh app detail, and close the popover for published apps', async () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
mockFetchAppDetailDirect.mockResolvedValueOnce({
id: 'app-1',
workflow_kind: AppTypeEnum.EVALUATION,
})
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockConvertWorkflowType).toHaveBeenCalledWith({
params: { appId: 'app-1' },
query: { target_type: AppTypeEnum.EVALUATION },
})
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockSetAppDetail).toHaveBeenCalledWith({
id: 'app-1',
workflow_kind: AppTypeEnum.EVALUATION,
})
expect(mockToastSuccess).toHaveBeenCalledWith('api.actionSuccess')
})
await waitFor(() => {
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
})
})
it('should publish an unpublished workflow as evaluation workflow through the evaluation publish endpoint', async () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
mockOnPublish.mockResolvedValue(undefined)
mockFetchAppDetailDirect.mockResolvedValueOnce({
id: 'app-1',
workflow_kind: AppTypeEnum.EVALUATION,
})
render(
<AppPublisher
onPublish={mockOnPublish}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockOnPublish).toHaveBeenCalledWith({
url: '/apps/app-1/workflows/publish/evaluation',
title: '',
releaseNotes: '',
})
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockSetAppDetail).toHaveBeenCalledWith({
id: 'app-1',
workflow_kind: AppTypeEnum.EVALUATION,
})
expect(mockToastSuccess).toHaveBeenCalledWith('api.actionSuccess')
})
})
it('should hide access and actions sections for evaluation workflow apps', () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
workflow_kind: AppTypeEnum.EVALUATION,
}
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument()
expect(screen.queryByText('publisher-access-control')).not.toBeInTheDocument()
expect(screen.queryByText('publisher-embed')).not.toBeInTheDocument()
expect(sectionProps.summary?.workflowTypeSwitchConfig).toEqual({
targetType: AppTypeEnum.WORKFLOW,
publishLabelKey: 'common.publishAsStandardWorkflow',
switchLabelKey: 'common.switchToStandardWorkflow',
tipKey: 'common.switchToStandardWorkflowTip',
})
})
it('should confirm before switching an evaluation workflow with associated targets to a standard workflow', async () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
workflow_kind: AppTypeEnum.EVALUATION,
}
mockEvaluationWorkflowAssociatedTargets = {
items: [
{
target_type: 'app',
target_id: 'dependent-app-1',
target_name: 'Dependent App',
},
{
target_type: 'knowledge_base',
target_id: 'knowledge-1',
target_name: 'Knowledge Base',
},
],
}
mockRefetchEvaluationWorkflowAssociatedTargets.mockResolvedValueOnce({
data: mockEvaluationWorkflowAssociatedTargets,
isError: false,
})
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockRefetchEvaluationWorkflowAssociatedTargets).toHaveBeenCalledTimes(1)
})
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
expect(screen.getByText('Dependent App')).toBeInTheDocument()
expect(screen.getByText('Knowledge Base')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.switchToStandardWorkflowConfirm.switch' }))
await waitFor(() => {
expect(mockConvertWorkflowType).toHaveBeenCalledWith({
params: { appId: 'app-1' },
query: { target_type: AppTypeEnum.WORKFLOW },
})
expect(mockToastSuccess).toHaveBeenCalledWith('api.actionSuccess')
})
})
it('should switch an evaluation workflow directly when there are no associated targets', async () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
workflow_kind: AppTypeEnum.EVALUATION,
}
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockRefetchEvaluationWorkflowAssociatedTargets).toHaveBeenCalledTimes(1)
expect(mockConvertWorkflowType).toHaveBeenCalledWith({
params: { appId: 'app-1' },
query: { target_type: AppTypeEnum.WORKFLOW },
})
expect(mockToastSuccess).toHaveBeenCalledWith('api.actionSuccess')
})
expect(screen.queryByText('common.switchToStandardWorkflowConfirm.title')).not.toBeInTheDocument()
})
it('should block switching an evaluation workflow when associated targets fail to load', async () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
workflow_kind: AppTypeEnum.EVALUATION,
}
mockRefetchEvaluationWorkflowAssociatedTargets.mockResolvedValueOnce({
data: undefined,
isError: true,
})
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('common.switchToStandardWorkflowConfirm.loadFailed')
})
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
})
it('should block switching to evaluation workflow when restricted nodes exist', async () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
render(
<AppPublisher
publishedAt={Date.now()}
hasHumanInputNode
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('common.switchToEvaluationWorkflowDisabledTip')
})
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
expect(sectionProps.summary?.workflowTypeSwitchDisabled).toBe(true)
expect(sectionProps.summary?.workflowTypeSwitchDisabledReason).toBe('common.switchToEvaluationWorkflowDisabledTip')
})
it('should keep the evaluation workflow switch visible but disabled when the current plan cannot access it', () => {
mockCanAccessSnippetsAndEvaluation = false
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
expect(sectionProps.summary?.workflowTypeSwitchConfig).toEqual({
targetType: AppTypeEnum.EVALUATION,
publishLabelKey: 'common.publishAsEvaluationWorkflow',
switchLabelKey: 'common.switchToEvaluationWorkflow',
tipKey: 'common.switchToEvaluationWorkflowTip',
})
expect(sectionProps.summary?.workflowTypeSwitchDisabled).toBe(true)
expect(sectionProps.summary?.workflowTypeSwitchDisabledReason).toBe('compliance.sandboxUpgradeTooltip')
})
it('should not expose workflow type switching for non-workflow app modes', async () => {
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
expect(sectionProps.summary?.workflowTypeSwitchConfig).toBeUndefined()
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockConvertWorkflowType).not.toHaveBeenCalled()
})
expect(mockOnPublish).not.toHaveBeenCalled()
expect(mockFetchAppDetailDirect).not.toHaveBeenCalled()
expect(mockToastSuccess).not.toHaveBeenCalled()
})
})

View File

@ -45,14 +45,12 @@ describe('app-publisher sections', () => {
handleRestore={handleRestore}
isChatApp
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={Date.now()}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
@ -85,14 +83,12 @@ describe('app-publisher sections', () => {
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
@ -111,14 +107,12 @@ describe('app-publisher sections', () => {
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[{ id: '1' } as any]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
@ -137,85 +131,18 @@ describe('app-publisher sections', () => {
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
expect(screen.getByText('publishLimit.startNodeDesc')).toBeInTheDocument()
})
it('should render workflow type switch action and call switch handler', () => {
const onWorkflowTypeSwitch = vi.fn()
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '1 minute ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={onWorkflowTypeSwitch}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchConfig={{
targetType: 'evaluation',
publishLabelKey: 'common.publishAsEvaluationWorkflow',
switchLabelKey: 'common.switchToEvaluationWorkflow',
tipKey: 'common.switchToEvaluationWorkflowTip',
}}
workflowTypeSwitchDisabled={false}
/>,
)
fireEvent.click(screen.getByText('common.publishAsEvaluationWorkflow'))
expect(onWorkflowTypeSwitch).toHaveBeenCalledTimes(1)
})
it('should disable workflow type switch when a disabled reason is provided', () => {
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '1 minute ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchConfig={{
targetType: 'evaluation',
publishLabelKey: 'common.publishAsEvaluationWorkflow',
switchLabelKey: 'common.switchToEvaluationWorkflow',
tipKey: 'common.switchToEvaluationWorkflowTip',
}}
workflowTypeSwitchDisabled
workflowTypeSwitchDisabledReason="common.switchToEvaluationWorkflowDisabledTip"
/>,
)
expect(screen.getByRole('button', { name: /common\.publishAsEvaluationWorkflow/i })).toBeDisabled()
})
it('should render loading access state and access mode labels when enabled', () => {
const { rerender } = render(
<PublisherAccessSection

View File

@ -1,158 +0,0 @@
'use client'
import type { EvaluationWorkflowAssociatedTarget, EvaluationWorkflowAssociatedTargetType } from '@/types/evaluation'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
type EvaluationWorkflowSwitchConfirmDialogProps = {
open: boolean
targets: EvaluationWorkflowAssociatedTarget[]
loading?: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
}
const TARGET_TYPE_META: Record<EvaluationWorkflowAssociatedTargetType, {
icon: string
iconClassName: string
labelKey: I18nKeysWithPrefix<'workflow', 'common.switchToStandardWorkflowConfirm.targetTypes.'>
href: (targetId: string) => string
}> = {
app: {
icon: 'i-ri-flow-chart',
iconClassName: 'bg-components-icon-bg-teal-soft text-util-colors-teal-teal-600',
labelKey: 'common.switchToStandardWorkflowConfirm.targetTypes.app',
href: targetId => `/app/${targetId}/workflow`,
},
snippets: {
icon: 'i-ri-edit-2-line',
iconClassName: 'bg-components-icon-bg-violet-soft text-util-colors-violet-violet-600',
labelKey: 'common.switchToStandardWorkflowConfirm.targetTypes.snippets',
href: targetId => `/snippets/${targetId}/orchestrate`,
},
knowledge_base: {
icon: 'i-ri-book-2-line',
iconClassName: 'bg-components-icon-bg-indigo-soft text-util-colors-blue-blue-600',
labelKey: 'common.switchToStandardWorkflowConfirm.targetTypes.knowledge_base',
href: targetId => `/datasets/${targetId}/documents`,
},
}
const getTargetMeta = (targetType: EvaluationWorkflowAssociatedTargetType) => {
return TARGET_TYPE_META[targetType] ?? TARGET_TYPE_META.app
}
const DependentTargetItem = ({
target,
}: {
target: EvaluationWorkflowAssociatedTarget
}) => {
const { t } = useTranslation()
const meta = getTargetMeta(target.target_type)
const targetName = target.target_name || target.target_id
return (
<Link
href={meta.href(target.target_id)}
className="group flex w-full items-center gap-3 rounded-lg bg-background-section p-2 hover:bg-background-section-burn"
title={targetName}
target="_blank"
rel="noreferrer"
>
<span
aria-hidden="true"
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-regular',
meta.iconClassName,
)}
>
<span className={cn(meta.icon, 'size-5')} />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 py-px">
<span className="truncate system-md-semibold text-text-secondary">
{targetName}
</span>
<span className="system-2xs-medium-uppercase text-text-tertiary">
{t(meta.labelKey, { ns: 'workflow' })}
</span>
</span>
<span
aria-hidden="true"
className="i-ri-arrow-right-up-line size-3.5 shrink-0 text-text-quaternary opacity-0 transition-opacity group-hover:opacity-100"
/>
</Link>
)
}
const EvaluationWorkflowSwitchConfirmDialog = ({
open,
targets,
loading = false,
onOpenChange,
onConfirm,
}: EvaluationWorkflowSwitchConfirmDialogProps) => {
const { t } = useTranslation()
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="w-[480px]">
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
{t('common.switchToStandardWorkflowConfirm.title', { ns: 'workflow' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular text-text-secondary">
<span className="block">
{t('common.switchToStandardWorkflowConfirm.activeIn', { ns: 'workflow', count: targets.length })}
</span>
<span className="block">
{t('common.switchToStandardWorkflowConfirm.description', { ns: 'workflow' })}
</span>
</AlertDialogDescription>
</div>
<div className="flex flex-col gap-2 px-6 py-3">
<div className="flex items-center gap-2">
<span className="shrink-0 system-xs-medium-uppercase text-text-quaternary">
{t('common.switchToStandardWorkflowConfirm.dependentWorkflows', { ns: 'workflow' })}
</span>
<span className="h-px min-w-0 flex-1 bg-divider-subtle" />
</div>
<div className="flex max-h-[188px] flex-col gap-1 overflow-y-auto">
{targets.map(target => (
<DependentTargetItem
key={`${target.target_type}:${target.target_id}`}
target={target}
/>
))}
</div>
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={loading}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={loading}
disabled={loading}
onClick={onConfirm}
>
{t('common.switchToStandardWorkflowConfirm.switch', { ns: 'workflow' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
}
export default EvaluationWorkflowSwitchConfirmDialog

View File

@ -1,6 +1,7 @@
import type { AppPublisherProps, AppPublisherPublishParams } from '@/app/components/app/app-publisher'
import type { Features, FileUpload } from '@/app/components/base/features/types'
import type { ModelConfig } from '@/models/debug'
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
import type { FileUpload } from '@/app/components/base/features/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import {
AlertDialog,
AlertDialogActions,
@ -20,15 +21,9 @@ import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { Resolution } from '@/types/app'
type PublishedModelConfig = ModelConfig & {
resetAppConfig?: () => void
}
type Props = Omit<AppPublisherProps, 'onPublish'> & {
onPublish?: (params?: AppPublisherPublishParams, features?: Features) => Promise<unknown> | unknown
publishedConfig: {
modelConfig: PublishedModelConfig
}
onPublish?: (params?: ModelAndParameter | PublishWorkflowParams, features?: any) => Promise<any> | any
publishedConfig?: any
resetAppConfig?: () => void
}
@ -76,7 +71,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
setRestoreConfirmOpen(false)
}, [featuresStore, props])
const handlePublish = useCallback((params?: AppPublisherPublishParams) => {
const handlePublish = useCallback((params?: ModelAndParameter | PublishWorkflowParams) => {
return props.onPublish?.(params, features)
}, [features, props])

View File

@ -5,6 +5,7 @@ import type { PublishWorkflowParams } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { toast } from '@langgenius/dify-ui/toast'
import { RiStoreLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useKeyPress } from 'ahooks'
import {
@ -24,7 +25,6 @@ import { webSocketClient } from '@/app/components/workflow/collaboration/core/we
import { WorkflowContext } from '@/app/components/workflow/context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
import { fetchAppDetailDirect, publishToCreatorsPlatform } from '@/service/apps'
@ -36,27 +36,18 @@ import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
import AccessControl from '../app-access-control'
import EvaluationWorkflowSwitchConfirmDialog from './evaluation-workflow-switch-confirm-dialog'
import {
PublisherAccessSection,
PublisherActionsSection,
PublisherSummarySection,
} from './sections'
import SuggestedAction from './suggested-action'
import { useWorkflowTypeSwitch } from './use-workflow-type-switch'
import {
getDisabledFunctionTooltip,
getPublisherAppUrl,
isPublisherAccessConfigured,
} from './utils'
export type AppPublisherPublishParams
= | ModelAndParameter
| (Pick<PublishWorkflowParams, 'title' | 'releaseNotes'> & {
url?: string
id?: string
})
export type AppPublisherProps = {
disabled?: boolean
publishDisabled?: boolean
@ -66,8 +57,8 @@ export type AppPublisherProps = {
debugWithMultipleModel?: boolean
multipleModelConfigs?: ModelAndParameter[]
/** modelAndParameter is passed when debugWithMultipleModel is true */
onPublish?: (params?: AppPublisherPublishParams) => Promise<unknown> | unknown
onRestore?: () => Promise<unknown> | unknown
onPublish?: (params?: any) => Promise<any> | any
onRestore?: () => Promise<any> | any
onToggle?: (state: boolean) => void
crossAxisOffset?: number
toolPublished?: boolean
@ -116,7 +107,6 @@ const AppPublisher = ({
const workflowStore = useContext(WorkflowContext)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(s => s.setAppDetail)
const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { formatTimeFromNow } = useFormatTimeFromNow()
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
@ -150,7 +140,7 @@ const AppPublisher = ({
refetch()
}, [open, appDetail, refetch, systemFeatures])
const handlePublish = useCallback(async (params?: AppPublisherPublishParams) => {
const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
try {
await onPublish?.(params)
setPublished(true)
@ -232,34 +222,6 @@ const AppPublisher = ({
}
}, [appDetail, setAppDetail])
const handlePublishedWorkflowTypeSwitch = useCallback(() => {
setOpen(false)
}, [])
const {
evaluationWorkflowSwitchTargets,
handleEvaluationWorkflowSwitchConfirmOpenChange,
handleWorkflowTypeSwitch,
isConvertingWorkflowType,
isEvaluationWorkflowType,
performWorkflowTypeSwitch,
showEvaluationWorkflowSwitchConfirm,
workflowTypeSwitchConfig,
workflowTypeSwitchDisabled,
workflowTypeSwitchDisabledReason,
} = useWorkflowTypeSwitch({
appDetail,
canAccessSnippetsAndEvaluation,
hasHumanInputNode,
hasTriggerNode,
onPublish: handlePublish,
onPublishedSwitch: handlePublishedWorkflowTypeSwitch,
published,
publishedAt,
publishDisabled,
setAppDetail,
})
const handlePublishToMarketplace = useCallback(async () => {
if (!appDetail?.id || publishingToMarketplace)
return
@ -357,63 +319,55 @@ const AppPublisher = ({
publishShortcut={PUBLISH_SHORTCUT}
startNodeLimitExceeded={startNodeLimitExceeded}
upgradeHighlightStyle={upgradeHighlightStyle}
workflowTypeSwitchConfig={workflowTypeSwitchConfig}
workflowTypeSwitchDisabled={workflowTypeSwitchDisabled}
workflowTypeSwitchDisabledReason={workflowTypeSwitchDisabledReason}
onWorkflowTypeSwitch={handleWorkflowTypeSwitch}
/>
{!isEvaluationWorkflowType && (
<>
<PublisherAccessSection
enabled={systemFeatures.webapp_auth.enabled}
isAppAccessSet={isAppAccessSet}
isLoading={Boolean(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))}
accessMode={appDetail?.access_mode}
onClick={() => {
setShowAppAccessControl(true)
handleOpenChange(false)
}}
/>
<PublisherActionsSection
appDetail={appDetail}
appURL={appURL}
disabledFunctionButton={disabledFunctionButton}
disabledFunctionTooltip={disabledFunctionTooltip}
handleEmbed={() => {
setEmbeddingModalOpen(true)
handleOpenChange(false)
}}
handleOpenInExplore={() => {
handleOpenChange(false)
handleOpenInExplore()
}}
handlePublish={handlePublish}
hasHumanInputNode={hasHumanInputNode}
hasTriggerNode={hasTriggerNode}
inputs={inputs}
missingStartNode={missingStartNode}
onRefreshData={onRefreshData}
outputs={outputs}
published={published}
publishedAt={publishedAt}
toolPublished={toolPublished}
workflowToolAvailable={workflowToolAvailable}
workflowToolMessage={workflowToolMessage}
/>
{systemFeatures.enable_creators_platform && (
<div className="border-t border-divider-subtle p-4">
<SuggestedAction
icon={<span className="i-ri-store-line h-4 w-4" />}
disabled={!publishedAt || publishingToMarketplace}
onClick={handlePublishToMarketplace}
>
{publishingToMarketplace
? t('common.publishingToMarketplace', { ns: 'workflow' })
: t('common.publishToMarketplace', { ns: 'workflow' })}
</SuggestedAction>
</div>
)}
</>
<PublisherAccessSection
enabled={systemFeatures.webapp_auth.enabled}
isAppAccessSet={isAppAccessSet}
isLoading={Boolean(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))}
accessMode={appDetail?.access_mode}
onClick={() => {
handleOpenChange(false)
setShowAppAccessControl(true)
}}
/>
<PublisherActionsSection
appDetail={appDetail}
appURL={appURL}
disabledFunctionButton={disabledFunctionButton}
disabledFunctionTooltip={disabledFunctionTooltip}
handleEmbed={() => {
setEmbeddingModalOpen(true)
handleOpenChange(false)
}}
handleOpenInExplore={() => {
handleOpenChange(false)
handleOpenInExplore()
}}
handlePublish={handlePublish}
hasHumanInputNode={hasHumanInputNode}
hasTriggerNode={hasTriggerNode}
inputs={inputs}
missingStartNode={missingStartNode}
onRefreshData={onRefreshData}
outputs={outputs}
published={published}
publishedAt={publishedAt}
toolPublished={toolPublished}
workflowToolAvailable={workflowToolAvailable}
workflowToolMessage={workflowToolMessage}
/>
{systemFeatures.enable_creators_platform && (
<div className="border-t border-divider-subtle p-4">
<SuggestedAction
icon={<RiStoreLine className="h-4 w-4" />}
disabled={!publishedAt || publishingToMarketplace}
onClick={handlePublishToMarketplace}
>
{publishingToMarketplace
? t('common.publishingToMarketplace', { ns: 'workflow' })
: t('common.publishToMarketplace', { ns: 'workflow' })}
</SuggestedAction>
</div>
)}
</div>
</PopoverContent>
@ -426,13 +380,6 @@ const AppPublisher = ({
/>
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
</Popover>
<EvaluationWorkflowSwitchConfirmDialog
open={showEvaluationWorkflowSwitchConfirm}
targets={evaluationWorkflowSwitchTargets}
loading={isConvertingWorkflowType}
onOpenChange={handleEvaluationWorkflowSwitchConfirmOpenChange}
onConfirm={() => void performWorkflowTypeSwitch()}
/>
</>
)
}

View File

@ -1,7 +1,6 @@
import type { CSSProperties, ReactNode } from 'react'
import type { ModelAndParameter } from '../configuration/debug/types'
import type { AppPublisherProps } from './index'
import type { WorkflowTypeSwitchConfig } from './use-workflow-type-switch'
import type { PublishWorkflowParams } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import {
@ -11,6 +10,7 @@ import {
} from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development'
import Loading from '@/app/components/base/loading'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
@ -31,13 +31,9 @@ type SummarySectionProps = Pick<AppPublisherProps, | 'debugWithMultipleModel'
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
handleRestore: () => Promise<void>
isChatApp: boolean
onWorkflowTypeSwitch: () => Promise<void>
published: boolean
publishShortcut: string[]
upgradeHighlightStyle: CSSProperties
workflowTypeSwitchConfig?: WorkflowTypeSwitchConfig
workflowTypeSwitchDisabled: boolean
workflowTypeSwitchDisabledReason?: string
}
type AccessSectionProps = {
@ -94,28 +90,6 @@ export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MA
)
}
const ActionTooltip = ({
disabled,
tooltip,
children,
}: {
disabled: boolean
tooltip?: ReactNode
children: ReactNode
}) => {
if (!disabled || !tooltip)
return <>{children}</>
return (
<Tooltip>
<TooltipTrigger render={<div className="flex">{children}</div>} />
<TooltipContent>
{tooltip}
</TooltipContent>
</Tooltip>
)
}
export const PublisherSummarySection = ({
debugWithMultipleModel = false,
draftUpdatedAt,
@ -124,16 +98,12 @@ export const PublisherSummarySection = ({
handleRestore,
isChatApp,
multipleModelConfigs = [],
onWorkflowTypeSwitch,
publishDisabled = false,
published,
publishedAt,
publishShortcut,
startNodeLimitExceeded = false,
upgradeHighlightStyle,
workflowTypeSwitchConfig,
workflowTypeSwitchDisabled,
workflowTypeSwitchDisabledReason,
}: SummarySectionProps) => {
const { t } = useTranslation()
@ -194,47 +164,6 @@ export const PublisherSummarySection = ({
</div>
)}
</Button>
{workflowTypeSwitchConfig && (
<ActionTooltip disabled={workflowTypeSwitchDisabled} tooltip={workflowTypeSwitchDisabledReason}>
<Button
variant="ghost"
className="mt-1 w-full gap-0.5 px-3 text-text-tertiary"
onClick={() => void onWorkflowTypeSwitch()}
disabled={workflowTypeSwitchDisabled}
>
<span className="px-0.5">
{t(
publishedAt
? workflowTypeSwitchConfig.switchLabelKey
: workflowTypeSwitchConfig.publishLabelKey,
{ ns: 'workflow' },
)}
</span>
<Tooltip>
<TooltipTrigger
render={(
<span
className="flex h-4 w-4 items-center justify-center text-text-quaternary hover:text-text-tertiary"
aria-label={t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<span className="i-ri-question-line h-3.5 w-3.5" />
</span>
)}
/>
<TooltipContent
placement="top"
className="w-[180px]"
>
{t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
</Button>
</ActionTooltip>
)}
{startNodeLimitExceeded && (
<div className="mt-3 flex flex-col items-stretch">
<p
@ -298,6 +227,28 @@ export const PublisherAccessSection = ({
)
}
const ActionTooltip = ({
disabled,
tooltip,
children,
}: {
disabled: boolean
tooltip?: ReactNode
children: ReactNode
}) => {
if (!disabled || !tooltip)
return <>{children}</>
return (
<Tooltip>
<TooltipTrigger render={<div className="flex">{children}</div>} />
<TooltipContent>
{tooltip}
</TooltipContent>
</Tooltip>
)
}
export const PublisherActionsSection = ({
appDetail,
appURL,
@ -354,7 +305,7 @@ export const PublisherActionsSection = ({
<SuggestedAction
onClick={handleEmbed}
disabled={!publishedAt}
icon={<span className="i-custom-vender-line-development-code-browser h-4 w-4" />}
icon={<CodeBrowser className="h-4 w-4" />}
>
{t('common.embedIntoSite', { ns: 'workflow' })}
</SuggestedAction>

View File

@ -1,235 +0,0 @@
import type { ModelAndParameter } from '../configuration/debug/types'
import type { App, AppSSO } from '@/types/app'
import type { EvaluationWorkflowAssociatedTarget } from '@/types/evaluation'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import type { PublishWorkflowParams, WorkflowKind, WorkflowTypeConversionTarget } from '@/types/workflow'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { fetchAppDetailDirect } from '@/service/apps'
import { useConvertWorkflowTypeMutation } from '@/service/use-apps'
import { useEvaluationWorkflowAssociatedTargets } from '@/service/use-evaluation'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
type WorkflowTypeSwitchLabelKey = I18nKeysWithPrefix<'workflow', 'common.'>
export type WorkflowTypeSwitchConfig = {
targetType: WorkflowTypeConversionTarget
publishLabelKey: WorkflowTypeSwitchLabelKey
switchLabelKey: WorkflowTypeSwitchLabelKey
tipKey: WorkflowTypeSwitchLabelKey
}
const WORKFLOW_TYPE_SWITCH_CONFIG: Record<WorkflowTypeConversionTarget, WorkflowTypeSwitchConfig> = {
workflow: {
targetType: 'evaluation',
publishLabelKey: 'common.publishAsEvaluationWorkflow',
switchLabelKey: 'common.switchToEvaluationWorkflow',
tipKey: 'common.switchToEvaluationWorkflowTip',
},
evaluation: {
targetType: 'workflow',
publishLabelKey: 'common.publishAsStandardWorkflow',
switchLabelKey: 'common.switchToStandardWorkflow',
tipKey: 'common.switchToStandardWorkflowTip',
},
} as const
const getWorkflowTypeSwitchConfig = (workflowKind?: WorkflowKind | null) => {
if (!workflowKind || workflowKind === 'standard')
return WORKFLOW_TYPE_SWITCH_CONFIG.workflow
if (workflowKind === 'evaluation')
return WORKFLOW_TYPE_SWITCH_CONFIG.evaluation
}
type UseWorkflowTypeSwitchParams = {
appDetail?: App & Partial<AppSSO>
canAccessSnippetsAndEvaluation: boolean
hasHumanInputNode: boolean
hasTriggerNode: boolean
onPublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
onPublishedSwitch: () => void
published: boolean
publishedAt?: number
publishDisabled: boolean
setAppDetail: (appDetail?: App & Partial<AppSSO>) => void
}
export const useWorkflowTypeSwitch = ({
appDetail,
canAccessSnippetsAndEvaluation,
hasHumanInputNode,
hasTriggerNode,
onPublish,
onPublishedSwitch,
published,
publishedAt,
publishDisabled,
setAppDetail,
}: UseWorkflowTypeSwitchParams) => {
const { t } = useTranslation()
const [showEvaluationWorkflowSwitchConfirm, setShowEvaluationWorkflowSwitchConfirm] = useState(false)
const [evaluationWorkflowSwitchTargets, setEvaluationWorkflowSwitchTargets] = useState<EvaluationWorkflowAssociatedTarget[]>([])
const { mutateAsync: convertWorkflowType, isPending: isConvertingWorkflowType } = useConvertWorkflowTypeMutation()
const {
refetch: refetchEvaluationWorkflowAssociatedTargets,
isFetching: isFetchingEvaluationWorkflowAssociatedTargets,
} = useEvaluationWorkflowAssociatedTargets(appDetail?.id, { enabled: false })
const workflowTypeSwitchConfig = useMemo(() => {
if (appDetail?.mode !== AppModeEnum.WORKFLOW)
return undefined
return getWorkflowTypeSwitchConfig(appDetail?.workflow_kind)
}, [appDetail?.mode, appDetail?.workflow_kind])
const workflowTypeSwitchDisabledReason = useMemo(() => {
if (workflowTypeSwitchConfig?.targetType !== AppTypeEnum.EVALUATION)
return undefined
if (!canAccessSnippetsAndEvaluation)
return t('compliance.sandboxUpgradeTooltip', { ns: 'common' })
if (!hasHumanInputNode && !hasTriggerNode)
return undefined
return t('common.switchToEvaluationWorkflowDisabledTip', { ns: 'workflow' })
}, [canAccessSnippetsAndEvaluation, hasHumanInputNode, hasTriggerNode, t, workflowTypeSwitchConfig?.targetType])
const getWorkflowTypeSwitchPublishUrl = useCallback(() => {
if (!appDetail?.id || !workflowTypeSwitchConfig)
return undefined
if (workflowTypeSwitchConfig.targetType === AppTypeEnum.EVALUATION)
return `/apps/${appDetail.id}/workflows/publish/evaluation`
return `/apps/${appDetail.id}/workflows/publish`
}, [appDetail?.id, workflowTypeSwitchConfig])
const resetEvaluationWorkflowSwitchConfirm = useCallback(() => {
setShowEvaluationWorkflowSwitchConfirm(false)
setEvaluationWorkflowSwitchTargets([])
}, [])
const performWorkflowTypeSwitch = useCallback(async () => {
if (!appDetail?.id || !workflowTypeSwitchConfig)
return false
try {
if (!publishedAt) {
const publishUrl = getWorkflowTypeSwitchPublishUrl()
if (!publishUrl)
return false
await onPublish({
url: publishUrl,
title: '',
releaseNotes: '',
})
const latestAppDetail = await fetchAppDetailDirect({
url: '/apps',
id: appDetail.id,
})
setAppDetail(latestAppDetail)
resetEvaluationWorkflowSwitchConfirm()
toast.success(t('api.actionSuccess', { ns: 'common' }))
return true
}
await convertWorkflowType({
params: {
appId: appDetail.id,
},
query: {
target_type: workflowTypeSwitchConfig.targetType,
},
})
const latestAppDetail = await fetchAppDetailDirect({
url: '/apps',
id: appDetail.id,
})
setAppDetail(latestAppDetail)
onPublishedSwitch()
resetEvaluationWorkflowSwitchConfirm()
toast.success(t('api.actionSuccess', { ns: 'common' }))
return true
}
catch {
return false
}
}, [
appDetail?.id,
convertWorkflowType,
getWorkflowTypeSwitchPublishUrl,
onPublish,
onPublishedSwitch,
publishedAt,
resetEvaluationWorkflowSwitchConfirm,
setAppDetail,
t,
workflowTypeSwitchConfig,
])
const handleWorkflowTypeSwitch = useCallback(async () => {
if (!appDetail?.id || !workflowTypeSwitchConfig)
return
if (workflowTypeSwitchDisabledReason) {
toast.error(workflowTypeSwitchDisabledReason)
return
}
if (appDetail.workflow_kind === AppTypeEnum.EVALUATION && workflowTypeSwitchConfig.targetType === AppTypeEnum.WORKFLOW) {
const associatedTargetsResult = await refetchEvaluationWorkflowAssociatedTargets()
if (associatedTargetsResult.isError) {
toast.error(t('common.switchToStandardWorkflowConfirm.loadFailed', { ns: 'workflow' }))
return
}
const associatedTargets = associatedTargetsResult.data?.items ?? []
if (associatedTargets.length > 0) {
setEvaluationWorkflowSwitchTargets(associatedTargets)
setShowEvaluationWorkflowSwitchConfirm(true)
return
}
}
await performWorkflowTypeSwitch()
}, [
appDetail?.id,
appDetail?.workflow_kind,
performWorkflowTypeSwitch,
refetchEvaluationWorkflowAssociatedTargets,
t,
workflowTypeSwitchConfig,
workflowTypeSwitchDisabledReason,
])
const handleEvaluationWorkflowSwitchConfirmOpenChange = useCallback((nextOpen: boolean) => {
setShowEvaluationWorkflowSwitchConfirm(nextOpen)
if (!nextOpen)
setEvaluationWorkflowSwitchTargets([])
}, [])
return {
evaluationWorkflowSwitchTargets,
handleEvaluationWorkflowSwitchConfirmOpenChange,
handleWorkflowTypeSwitch,
isConvertingWorkflowType,
isEvaluationWorkflowType: appDetail?.workflow_kind === AppTypeEnum.EVALUATION,
performWorkflowTypeSwitch,
showEvaluationWorkflowSwitchConfirm,
workflowTypeSwitchConfig,
workflowTypeSwitchDisabled: publishDisabled
|| published
|| isConvertingWorkflowType
|| isFetchingEvaluationWorkflowAssociatedTargets
|| Boolean(workflowTypeSwitchDisabledReason),
workflowTypeSwitchDisabledReason,
}
}

View File

@ -96,7 +96,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
mode: mode as unknown as ModelModeType,
mode: mode as unknown as ModelModeType.chat,
completion_params: {} as CompletionParams,
})
const {

View File

@ -78,7 +78,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
mode: mode as unknown as ModelModeType,
mode: mode as unknown as ModelModeType.chat,
completion_params: defaultCompletionParams,
})
const {

View File

@ -1,6 +1,5 @@
'use client'
import type { ComponentProps } from 'react'
import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher'
import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
import type { Features as FeaturesData, OnFeaturesChange } from '@/app/components/base/features/types'
@ -22,6 +21,7 @@ import type {
TextToSpeechConfig,
} from '@/models/debug'
import type { VisionSettings } from '@/types/app'
import type { PublishWorkflowParams } from '@/types/workflow'
import { useBoolean, useGetState } from 'ahooks'
import { clone } from 'es-toolkit/object'
import { produce } from 'immer'
@ -481,7 +481,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
resolvedModelModeType,
])
const onPublish = useCallback(async (params?: AppPublisherPublishParams, features?: FeaturesData) => {
const onPublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams, features?: FeaturesData) => {
const modelAndParameter = params && 'model' in params && 'provider' in params && 'parameters' in params
? params
: undefined

View File

@ -1,119 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import EvaluationCell from '../evaluation-cell'
describe('EvaluationCell', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render a placeholder when evaluation data is empty', () => {
render(<EvaluationCell evaluation={[]} />)
expect(screen.getByText('-')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'appLog.table.header.evaluation' })).not.toBeInTheDocument()
})
it('should render a placeholder when evaluation data is missing', () => {
render(<EvaluationCell />)
expect(screen.getByText('-')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'appLog.table.header.evaluation' })).not.toBeInTheDocument()
})
it('should render a placeholder when evaluation data is null', () => {
render(<EvaluationCell evaluation={null} />)
expect(screen.getByText('-')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'appLog.table.header.evaluation' })).not.toBeInTheDocument()
})
it('should render a trigger button when evaluation data is available', () => {
render(
<EvaluationCell
evaluation={[{
name: 'Faithfulness',
value: 0.98,
}]}
/>,
)
expect(screen.getByRole('button', { name: 'appLog.table.header.evaluation' })).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should render evaluation details when clicking the trigger', async () => {
const user = userEvent.setup()
render(
<EvaluationCell
evaluation={[{
name: 'Faithfulness',
value: 0.98,
details: {
stubbed: true,
source: 'console-evaluation-run',
value_type: 'number',
},
nodeInfo: {
node_id: 'node-1',
title: 'Knowledge Retrieval',
type: 'knowledge-retrieval',
},
}]}
/>,
)
await user.click(screen.getByRole('button', { name: 'appLog.table.header.evaluation' }))
expect(await screen.findByTestId('workflow-log-evaluation-popover')).toBeInTheDocument()
expect(screen.getByText('Faithfulness')).toBeInTheDocument()
expect(screen.getByText('0.98')).toBeInTheDocument()
expect(screen.getByText('Knowledge Retrieval')).toBeInTheDocument()
})
it('should render boolean values using readable text', async () => {
const user = userEvent.setup()
render(
<EvaluationCell
evaluation={[{
name: 'Correctness',
value: true,
}]}
/>,
)
await user.click(screen.getByRole('button', { name: 'appLog.table.header.evaluation' }))
expect(await screen.findByText('True')).toBeInTheDocument()
})
it('should render evaluation items with null node info', async () => {
const user = userEvent.setup()
render(
<EvaluationCell
evaluation={[{
name: 'custom_score',
value: 0.95,
details: {
stubbed: true,
source: 'console-evaluation-run',
value_type: 'number',
customized: true,
},
nodeInfo: null,
}]}
/>,
)
await user.click(screen.getByRole('button', { name: 'appLog.table.header.evaluation' }))
expect(await screen.findByText('custom_score')).toBeInTheDocument()
expect(screen.getByText('0.95')).toBeInTheDocument()
})
})
})

View File

@ -215,7 +215,6 @@ const createMockWorkflowLog = (overrides: Partial<WorkflowAppLogDetail> = {}): W
},
created_at: Date.now(),
...overrides,
evaluation: overrides.evaluation ?? [],
})
const createMockLogsResponse = (

View File

@ -146,7 +146,6 @@ const createMockWorkflowLog = (overrides: Partial<WorkflowAppLogDetail> = {}): W
email: 'test@example.com',
},
created_at: Date.now(),
evaluation: [],
...overrides,
})
@ -217,7 +216,6 @@ describe('WorkflowAppLogList', () => {
expect(screen.getByText('appLog.table.header.runtime'))!.toBeInTheDocument()
expect(screen.getByText('appLog.table.header.tokens'))!.toBeInTheDocument()
expect(screen.getByText('appLog.table.header.user'))!.toBeInTheDocument()
expect(screen.getByText('appLog.table.header.evaluation'))!.toBeInTheDocument()
})
it('should render trigger column for workflow apps', () => {
@ -525,34 +523,6 @@ describe('WorkflowAppLogList', () => {
// The row should have the selected class
expect(dataRow)!.toHaveClass('bg-background-default-hover')
})
it('should open evaluation popover without opening drawer when clicking evaluation trigger', async () => {
const user = userEvent.setup()
const logs = createMockLogsResponse([
createMockWorkflowLog({
evaluation: [{
name: 'Faithfulness',
value: 0.98,
nodeInfo: {
node_id: 'node-1',
title: 'Knowledge Retrieval',
type: 'knowledge-retrieval',
},
}],
}),
])
render(
<WorkflowAppLogList logs={logs} appDetail={createMockApp()} onRefresh={defaultOnRefresh} />,
)
await user.click(screen.getByRole('button', { name: 'appLog.table.header.evaluation' }))
expect(await screen.findByTestId('workflow-log-evaluation-popover')).toBeInTheDocument()
expect(screen.getByText('Faithfulness')).toBeInTheDocument()
expect(screen.getByText('Knowledge Retrieval')).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'appLog.runDetail.workflowTitle' })).not.toBeInTheDocument()
})
})
// --------------------------------------------------------------------------

View File

@ -1,99 +0,0 @@
'use client'
import type { EvaluationLogItem } from '@/models/log'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getEvaluationNodeBlockType } from '@/app/components/evaluation/components/metric-selector/utils'
import BlockIcon from '@/app/components/workflow/block-icon'
type EvaluationCellProps = {
evaluation?: EvaluationLogItem[] | null
}
const formatEvaluationValue = (value: EvaluationLogItem['value']) => {
if (typeof value === 'boolean')
return value ? 'True' : 'False'
return String(value)
}
const EvaluationCell = ({
evaluation,
}: EvaluationCellProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const evaluationItems = evaluation ?? []
if (!evaluationItems.length) {
return (
<div className="flex items-center justify-center px-2 py-3 system-sm-regular text-text-quaternary">
-
</div>
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<button
type="button"
aria-label={t('table.header.evaluation', { ns: 'appLog' })}
data-testid="workflow-log-evaluation-trigger"
className={cn(
'flex h-7 w-7 items-center justify-center rounded-md text-text-tertiary transition-colors',
'hover:bg-state-base-hover hover:text-text-secondary',
open && 'bg-state-base-hover text-text-secondary',
)}
>
<span aria-hidden="true" className="i-ri-eye-line h-4 w-4" />
</button>
)}
/>
<PopoverContent
placement="left-start"
sideOffset={12}
popupClassName="w-[320px] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border p-0 shadow-[0px_12px_16px_-4px_rgba(9,9,11,0.08),0px_4px_6px_-2px_rgba(9,9,11,0.03)]"
>
<div data-testid="workflow-log-evaluation-popover" className="max-h-[320px] overflow-y-auto bg-components-panel-bg">
{evaluationItems.map((item, index) => (
<div
key={item.nodeInfo ? `${item.name}-${item.nodeInfo.node_id}` : item.name}
className={cn(
'grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-3',
index !== evaluationItems.length - 1 && 'border-b border-divider-subtle',
)}
>
<div className="min-w-0">
<div className="truncate system-sm-medium text-text-secondary">{item.name}</div>
{item.nodeInfo && (
<div className="mt-1 flex min-w-0 items-center gap-1.5">
<BlockIcon
type={getEvaluationNodeBlockType(item.nodeInfo)}
size="xs"
className="h-[18px] w-[18px] shrink-0"
/>
<span className="truncate system-xs-regular text-text-tertiary">
{item.nodeInfo.title}
</span>
</div>
)}
</div>
<div className="max-w-[120px] text-right system-sm-regular wrap-break-word text-text-secondary">
{formatEvaluationValue(item.value)}
</div>
</div>
))}
</div>
</PopoverContent>
</Popover>
)
}
export default EvaluationCell

View File

@ -2,6 +2,7 @@
import type { FC } from 'react'
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunTriggeredFrom } from '@/models/log'
import type { App } from '@/types/app'
import { ArrowDownIcon } from '@heroicons/react/24/outline'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useEffect, useState } from 'react'
@ -13,7 +14,6 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useTimestamp from '@/hooks/use-timestamp'
import { AppModeEnum } from '@/types/app'
import DetailPanel from './detail'
import EvaluationCell from './evaluation-cell'
import TriggerByDisplay from './trigger-by-display'
type ILogs = {
@ -118,21 +118,22 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
return (
<div className="overflow-x-auto">
<table className={cn('mt-2 w-full min-w-[560px] border-collapse border-0')}>
<table className={cn('mt-2 w-full min-w-[440px] border-collapse border-0')}>
<thead className="system-xs-medium-uppercase text-text-tertiary">
<tr>
<td className="w-5 rounded-l-lg bg-background-section-burn pr-1 pl-2 whitespace-nowrap"></td>
<td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">
<div className="flex cursor-pointer items-center hover:text-text-secondary" onClick={handleSort}>
{t('table.header.startTime', { ns: 'appLog' })}
<span className={cn('i-heroicons-arrow-down', 'ml-0.5 h-3 w-3 stroke-current stroke-2 transition-all', 'text-text-tertiary', sortOrder === 'asc' ? 'rotate-180' : '')} />
<ArrowDownIcon
className={cn('ml-0.5 h-3 w-3 stroke-current stroke-2 transition-all', 'text-text-tertiary', sortOrder === 'asc' ? 'rotate-180' : '')}
/>
</div>
</td>
<td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t('table.header.status', { ns: 'appLog' })}</td>
<td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t('table.header.runtime', { ns: 'appLog' })}</td>
<td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t('table.header.tokens', { ns: 'appLog' })}</td>
<td className="bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t('table.header.user', { ns: 'appLog' })}</td>
<td className={cn('bg-background-section-burn py-1.5 px-3 whitespace-nowrap text-center', !isWorkflow ? 'rounded-r-lg' : '')}>{t('table.header.evaluation', { ns: 'appLog' })}</td>
<td className={cn('bg-background-section-burn py-1.5 pl-3 whitespace-nowrap', !isWorkflow ? 'rounded-r-lg' : '')}>{t('table.header.user', { ns: 'appLog' })}</td>
{isWorkflow && <td className="rounded-r-lg bg-background-section-burn py-1.5 pl-3 whitespace-nowrap">{t('table.header.triggered_from', { ns: 'appLog' })}</td>}
</tr>
</thead>
@ -171,9 +172,6 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
{endUser}
</div>
</td>
<td className="p-2 pr-2" onClick={event => event.stopPropagation()}>
<EvaluationCell evaluation={log.evaluation} />
</td>
{isWorkflow && (
<td className="p-3 pr-2">
<TriggerByDisplay triggeredFrom={log.workflow_run.triggered_from as WorkflowRunTriggeredFrom} triggerMetadata={log.details?.trigger_metadata} />

View File

@ -2,8 +2,6 @@ import { render, screen } from '@testing-library/react'
import * as React from 'react'
import Empty from '../empty'
const defaultMessage = 'workflow.tabs.noSnippetsFound'
describe('Empty', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -11,32 +9,32 @@ describe('Empty', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Empty message={defaultMessage} />)
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
render(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
it('should render 36 placeholder cards', () => {
const { container } = render(<Empty message={defaultMessage} />)
const { container } = render(<Empty />)
const placeholderCards = container.querySelectorAll('.bg-background-default-lighter')
expect(placeholderCards).toHaveLength(36)
})
it('should display the provided message', () => {
render(<Empty message="app.newApp.noAppsFound" />)
it('should display the no apps found message', () => {
render(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct container styling for overlay', () => {
const { container } = render(<Empty message={defaultMessage} />)
const { container } = render(<Empty />)
const overlay = container.querySelector('.pointer-events-none')
expect(overlay).toBeInTheDocument()
expect(overlay).toHaveClass('absolute', 'inset-0', 'z-20')
})
it('should have correct styling for placeholder cards', () => {
const { container } = render(<Empty message={defaultMessage} />)
const { container } = render(<Empty />)
const card = container.querySelector('.bg-background-default-lighter')
expect(card).toHaveClass('inline-flex', 'h-[160px]', 'rounded-xl')
})
@ -44,10 +42,10 @@ describe('Empty', () => {
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = render(<Empty message={defaultMessage} />)
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
const { rerender } = render(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
rerender(<Empty message="app.newApp.noAppsFound" />)
rerender(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
})

View File

@ -1,4 +1,4 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen } from '@testing-library/react'
import * as React from 'react'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
@ -7,6 +7,11 @@ import { AppModeEnum } from '@/types/app'
import List from '../list'
const mockAppListInfiniteOptions = vi.hoisted(() => vi.fn((options: unknown) => options))
const mockUseWorkflowOnlineUsers = vi.hoisted(() => vi.fn((_options: unknown) => ({
onlineUsersMap: {},
})))
const mockReplace = vi.fn()
const mockRouter = { replace: mockReplace }
vi.mock('@/next/navigation', () => ({
@ -14,42 +19,37 @@ vi.mock('@/next/navigation', () => ({
useSearchParams: () => new URLSearchParams(''),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
systemFeatures: vi.fn(),
},
consoleQuery: {
apps: {
list: {
infiniteOptions: (options: unknown) => mockAppListInfiniteOptions(options),
},
},
systemFeatures: {
queryKey: () => ['console', 'systemFeatures'],
},
},
}))
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
const mockIsLoadingCurrentWorkspace = vi.fn(() => false)
const mockCanAccessSnippetsAndEvaluation = vi.fn(() => true)
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace(),
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: () => ({
systemFeatures: {
branding: { enabled: false },
},
}),
}))
vi.mock('@/hooks/use-snippet-and-evaluation-plan-access', () => ({
useSnippetAndEvaluationPlanAccess: () => ({
canAccess: mockCanAccessSnippetsAndEvaluation(),
isReady: true,
}),
}))
const mockSetQuery = vi.fn()
const mockQueryState = {
tagIDs: [] as string[],
creatorIDs: [] as string[],
keywords: '',
isCreatedByMe: false,
}
vi.mock('../hooks/use-apps-query-state', () => ({
default: () => ({
query: mockQueryState,
@ -59,7 +59,6 @@ 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
@ -67,17 +66,18 @@ vi.mock('../hooks/use-dsl-drag-drop', () => ({
},
}))
vi.mock('../hooks/use-workflow-online-users', () => ({
useWorkflowOnlineUsers: (options: unknown) => mockUseWorkflowOnlineUsers(options),
}))
const mockRefetch = vi.fn()
const mockFetchNextPage = vi.fn()
const mockFetchSnippetNextPage = vi.fn()
const mockUseInfiniteAppList = vi.fn()
const mockUseInfiniteSnippetList = vi.fn()
const mockServiceState = {
error: null as Error | null,
hasNextPage: false,
isLoading: false,
isFetching: false,
isLoading: false,
isFetchingNextPage: false,
}
@ -115,10 +115,11 @@ const defaultAppData = {
}],
}
vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: (params: unknown, options: unknown) => {
mockUseInfiniteAppList(params, options)
return {
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useInfiniteQuery: () => ({
data: defaultAppData,
isLoading: mockServiceState.isLoading,
isFetching: mockServiceState.isFetching,
@ -127,75 +128,17 @@ vi.mock('@/service/use-apps', () => ({
hasNextPage: mockServiceState.hasNextPage,
error: mockServiceState.error,
refetch: mockRefetch,
}
},
}),
}
})
vi.mock('@/service/use-apps', () => ({
useDeleteAppMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}))
const mockSnippetServiceState = {
error: null as Error | null,
hasNextPage: false,
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
}
const defaultSnippetData = {
pages: [{
data: [
{
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.',
type: 'node',
is_published: false,
use_count: 19,
icon_info: {
icon_type: 'emoji',
icon: '🪄',
icon_background: '#E0EAFF',
icon_url: '',
},
created_at: 1704067200,
created_by: 'user-1',
updated_at: 1704153600,
updated_by: 'user-2',
},
],
total: 1,
}],
}
vi.mock('@/service/use-snippets', () => ({
useInfiniteSnippetList: (params: unknown, options: unknown) => {
mockUseInfiniteSnippetList(params, options)
return {
data: defaultSnippetData,
isLoading: mockSnippetServiceState.isLoading,
isFetching: mockSnippetServiceState.isFetching,
isFetchingNextPage: mockSnippetServiceState.isFetchingNextPage,
fetchNextPage: mockFetchSnippetNextPage,
hasNextPage: mockSnippetServiceState.hasNextPage,
error: mockSnippetServiceState.error,
}
},
useCreateSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useImportSnippetDSLMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useConfirmSnippetImportMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
}))
vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]),
}))
@ -208,17 +151,6 @@ vi.mock('@/config', async (importOriginal) => {
}
})
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'user-1', name: 'Current User', email: 'current@example.com', avatar: '', avatar_url: '', role: 'owner', last_login_at: '', created_at: '', status: 'active' },
{ id: 'user-2', name: 'Alice', email: 'alice@example.com', avatar: '', avatar_url: '', role: 'admin', last_login_at: '', created_at: '', status: 'active' },
],
},
}),
}))
vi.mock('@/hooks/use-pay', () => ({
CheckModal: () => null,
}))
@ -232,21 +164,13 @@ 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', { 'data-testid': 'close-dsl-modal', 'onClick': onClose }, 'Close'),
React.createElement('button', { 'data-testid': 'success-dsl-modal', 'onClick': onSuccess }, 'Success'),
)
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 () => null
},
}))
@ -264,8 +188,8 @@ vi.mock('../new-app-card', () => ({
}))
vi.mock('../empty', () => ({
default: ({ message }: { message: string }) => {
return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, message)
default: () => {
return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, 'No apps found')
},
}))
@ -297,109 +221,148 @@ beforeAll(() => {
// Render helper wrapping with shared nuqs testing helper plus a seeded
// systemFeatures cache so List can resolve its useSuspenseQuery.
const renderList = (props: React.ComponentProps<typeof List> = {}, searchParams = '') => {
const renderList = (searchParams = '') => {
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { branding: { enabled: false } },
})
return renderWithNuqs(<SystemFeaturesWrapper><List {...props} /></SystemFeaturesWrapper>, { searchParams })
return renderWithNuqs(<SystemFeaturesWrapper><List /></SystemFeaturesWrapper>, { searchParams })
}
type AppListInfiniteOptions = {
input: (pageParam: number) => { query: Record<string, unknown> }
getNextPageParam: (lastPage: { has_more: boolean, page: number }) => number | undefined
}
describe('List', () => {
beforeEach(() => {
vi.clearAllMocks()
defaultSnippetData.pages[0]!.data = [
{
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.',
type: 'node',
is_published: false,
use_count: 19,
icon_info: {
icon_type: 'emoji',
icon: '🪄',
icon_background: '#E0EAFF',
icon_url: '',
},
created_at: 1704067200,
created_by: 'user-1',
updated_at: 1704153600,
updated_by: 'user-2',
},
]
defaultSnippetData.pages[0]!.total = 1
useTagStore.setState({
tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app', binding_count: 0 }],
showTagManagementModal: false,
})
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
mockIsLoadingCurrentWorkspace.mockReturnValue(false)
mockCanAccessSnippetsAndEvaluation.mockReturnValue(true)
mockDragging = false
mockOnDSLFileDropped = null
mockServiceState.error = null
mockServiceState.hasNextPage = false
mockServiceState.isLoading = false
mockServiceState.isFetching = false
mockServiceState.isFetchingNextPage = false
mockQueryState.tagIDs = []
mockQueryState.creatorIDs = []
mockQueryState.keywords = ''
mockQueryState.isCreatedByMe = false
mockSnippetServiceState.error = null
mockSnippetServiceState.hasNextPage = false
mockSnippetServiceState.isLoading = false
mockSnippetServiceState.isFetching = false
mockSnippetServiceState.isFetchingNextPage = false
mockUseInfiniteAppList.mockClear()
mockUseInfiniteSnippetList.mockClear()
mockUseWorkflowOnlineUsers.mockClear()
intersectionCallback = null
localStorage.clear()
})
describe('Apps Mode', () => {
it('should render the apps route switch, dropdown filters, and app cards', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
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.allCreators')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
})
it('should update the category query when selecting an app type from the dropdown', async () => {
it('should render tab slider with all app types', () => {
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.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()
})
it('should pass workflow app ids to online users hook', () => {
renderList()
expect(mockUseWorkflowOnlineUsers).toHaveBeenCalledWith({
appIds: ['app-2'],
enabled: expect.any(Boolean),
})
})
})
describe('Tab Navigation', () => {
it('should update URL when workflow tab is clicked', async () => {
const { onUrlUpdate } = renderList()
fireEvent.click(screen.getByText('app.studio.filters.types'))
fireEvent.click(await screen.findByText('app.types.workflow'))
fireEvent.click(screen.getByText('app.types.workflow'))
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
await vi.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 creatorIDs when selecting a creator from the dropdown', async () => {
it('should update URL when all tab is clicked', async () => {
const { onUrlUpdate } = renderList('?category=workflow')
fireEvent.click(screen.getByText('app.types.all'))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
// nuqs removes the default value ('all') from URL params
expect(lastCall.searchParams.has('category')).toBe(false)
})
})
describe('Search Functionality', () => {
it('should render search input field', () => {
renderList()
fireEvent.click(screen.getByText('app.studio.filters.allCreators'))
fireEvent.click(await screen.findByText('Current User'))
expect(mockSetQuery).toHaveBeenCalledTimes(1)
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
})
it('should pass creator_id to the app list query when creatorIDs are selected', () => {
mockQueryState.creatorIDs = ['user-1', 'user-2']
it('should handle search input change', () => {
renderList()
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
expect(mockSetQuery).toHaveBeenCalled()
})
it('should handle search clear button click', () => {
mockQueryState.keywords = 'existing search'
renderList()
expect(mockUseInfiniteAppList).toHaveBeenCalledWith(expect.objectContaining({
creator_id: 'user-1,user-2',
}), expect.any(Object))
const clearButton = document.querySelector('.group')
expect(clearButton)!.toBeInTheDocument()
if (clearButton)
@ -409,6 +372,31 @@ describe('List', () => {
})
})
describe('App List Query', () => {
it('should build paged query input from active filters', () => {
mockQueryState.tagIDs = ['tag-1']
mockQueryState.keywords = 'sales'
mockQueryState.isCreatedByMe = true
renderList('?category=workflow')
const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions
expect(options.input(2)).toEqual({
query: {
page: 2,
limit: 30,
name: 'sales',
tag_ids: ['tag-1'],
is_created_by_me: true,
mode: AppModeEnum.WORKFLOW,
},
})
expect(options.getNextPageParam({ has_more: true, page: 2 })).toBe(3)
expect(options.getNextPageParam({ has_more: false, page: 2 })).toBeUndefined()
})
})
describe('Tag Filter', () => {
it('should render tag filter component', () => {
renderList()
@ -591,40 +579,19 @@ describe('List', () => {
expect(screen.getByTestId('create-dsl-modal'))!.toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
it('should hide the snippets route switch when snippet access is unavailable', () => {
mockCanAccessSnippetsAndEvaluation.mockReturnValue(false)
it('should close DSL modal and refetch when onSuccess is called', () => {
renderList()
expect(screen.getByRole('link', { name: 'app.studio.apps' })).toHaveAttribute('href', '/apps')
expect(screen.queryByRole('link', { name: 'workflow.tabs.snippets' })).not.toBeInTheDocument()
})
})
describe('Snippets Mode', () => {
it('should render the snippets create card and snippet card from the real query hook', () => {
renderList({ pageType: 'snippets' })
expect(screen.getByText('snippet.create')).toBeInTheDocument()
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
expect(screen.getByText('Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Tone Rewriter/i })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate')
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument()
})
it('should request the next snippet page when the infinite-scroll anchor intersects', () => {
mockSnippetServiceState.hasNextPage = true
renderList({ pageType: 'snippets' })
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
})
expect(mockFetchSnippetNextPage).toHaveBeenCalled()
expect(screen.getByTestId('create-dsl-modal'))!.toBeInTheDocument()
fireEvent.click(screen.getByTestId('success-dsl-modal'))
@ -651,43 +618,40 @@ describe('List', () => {
expect(mockFetchNextPage).toHaveBeenCalled()
})
it('should not render app-only controls in snippets mode', () => {
renderList({ pageType: 'snippets' })
it('should not call fetchNextPage when not intersecting', () => {
mockServiceState.hasNextPage = true
renderList()
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()
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: false } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('should pass creator_id to the snippet list query when creatorIDs are selected', () => {
mockQueryState.creatorIDs = ['user-1', 'user-2']
it('should not call fetchNextPage when loading', () => {
mockServiceState.hasNextPage = true
mockServiceState.isLoading = true
renderList()
renderList({ pageType: 'snippets' })
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith(expect.objectContaining({
creator_id: 'user-1,user-2',
}), expect.any(Object))
})
it('should not fetch the next snippet page when no more data is available', () => {
renderList({ pageType: 'snippets' })
act(() => {
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
})
expect(mockFetchSnippetNextPage).not.toHaveBeenCalled()
})
it('should reuse the shared empty state when no snippets are available', () => {
defaultSnippetData.pages[0]!.data = []
defaultSnippetData.pages[0]!.total = 0
renderList({ pageType: 'snippets' })
expect(screen.getByTestId('empty-state')).toHaveTextContent('workflow.tabs.noSnippetsFound')
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
})
describe('Error State', () => {
it('should handle error state in useEffect', () => {
mockServiceState.error = new Error('Test error')

View File

@ -1,16 +0,0 @@
import { parseAsStringLiteral } from 'nuqs'
import { AppModes } from '@/types/app'
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
export type { AppListCategory }
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

@ -1,72 +0,0 @@
'use client'
import type { AppListCategory } from './app-type-filter-shared'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { AppModeEnum } from '@/types/app'
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: AppListCategory
onChange: (value: 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

@ -1,219 +0,0 @@
'use client'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
import { useAppContext } from '@/context/app-context'
import { useMembers } from '@/service/use-common'
type CreatorsFilterProps = {
value: string[]
onChange: (value: string[]) => void
}
type CreatorOption = {
id: string
name: string
avatarUrl: string | null
isYou: boolean
}
const baseChipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors'
const CreatorsFilter = ({
value,
onChange,
}: CreatorsFilterProps) => {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const { data: membersData } = useMembers()
const [keywords, setKeywords] = useState('')
const creatorOptions = useMemo<CreatorOption[]>(() => {
const currentUserId = userProfile?.id
const members = membersData?.accounts ?? []
return [...members]
.filter(member => member.status !== 'pending')
.sort((left, right) => {
if (left.id === currentUserId)
return -1
if (right.id === currentUserId)
return 1
return left.name.localeCompare(right.name)
})
.map(member => ({
id: member.id,
name: member.name,
avatarUrl: member.avatar_url,
isYou: member.id === currentUserId,
}))
}, [membersData?.accounts, userProfile?.id])
const filteredCreators = useMemo(() => {
const normalizedKeywords = keywords.trim().toLowerCase()
if (!normalizedKeywords)
return creatorOptions
return creatorOptions.filter((creator) => {
const keyword = normalizedKeywords
return creator.name.toLowerCase().includes(keyword)
})
}, [creatorOptions, keywords])
const selectedCreators = useMemo(() => {
const creatorMap = new Map(creatorOptions.map(creator => [creator.id, creator]))
return value
.map(id => creatorMap.get(id))
.filter((creator): creator is CreatorOption => Boolean(creator))
}, [creatorOptions, value])
const toggleCreator = useCallback((creatorId: string) => {
if (value.includes(creatorId)) {
onChange(value.filter(id => id !== creatorId))
return
}
onChange([...value, creatorId])
}, [onChange, value])
const resetCreators = useCallback(() => {
onChange([])
setKeywords('')
}, [onChange])
const selectedCount = value.length
const selectedAvatarCreators = selectedCreators.slice(0, 3)
const isSelected = selectedCount > 0
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<div
className={cn(
baseChipClassName,
isSelected
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
)}
/>
)}
>
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
{!isSelected && (
<>
<span className="px-1 text-text-tertiary">{t('studio.filters.allCreators', { ns: 'app' })}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</>
)}
{isSelected && (
<>
<span className="px-1 text-text-tertiary">{t('studio.filters.creators', { ns: 'app' })}</span>
<span className="flex items-center pr-1">
{selectedAvatarCreators.map((creator, index) => (
<Avatar
key={creator.id}
avatar={creator.avatarUrl}
name={creator.name}
size="xs"
className={cn(
'border border-components-panel-bg',
index > 0 && '-ml-1',
)}
/>
))}
</span>
<span className="text-xs leading-4 font-medium text-text-tertiary">{`+${selectedCount}`}</span>
<span
role="button"
tabIndex={0}
aria-label={t('studio.filters.reset', { ns: 'app' })}
className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-xs text-text-quaternary hover:text-text-tertiary"
onClick={(event) => {
event.stopPropagation()
resetCreators()
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ')
return
event.preventDefault()
event.stopPropagation()
resetCreators()
}}
>
<span aria-hidden className="i-ri-close-circle-fill h-3.5 w-3.5" />
</span>
</>
)}
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0">
<div className="flex items-center gap-1 p-2 pb-1">
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
/>
{isSelected && (
<button
type="button"
className="shrink-0 rounded-sm 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="max-h-[240px] overflow-y-auto px-1 pb-1">
{filteredCreators.map((creator) => {
const checked = value.includes(creator.id)
return (
<button
key={creator.id}
type="button"
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 hover:bg-state-base-hover"
onClick={() => toggleCreator(creator.id)}
>
<Checkbox
id={creator.id}
checked={checked}
onCheck={() => undefined}
className="shrink-0"
/>
<div className="flex min-w-0 grow items-center gap-2 px-1">
<Avatar
avatar={creator.avatarUrl}
name={creator.name}
size="xs"
className="border-[0.5px] border-divider-regular"
/>
<div className="flex min-w-0 grow items-center justify-between gap-2">
<span className="truncate text-sm text-text-secondary">{creator.name}</span>
{creator.isYou && (
<span className="shrink-0 text-sm text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
)}
</div>
</div>
</button>
)
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default CreatorsFilter

View File

@ -1,4 +1,5 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
const DefaultCards = React.memo(() => {
const renderArray = Array.from({ length: 36 })
@ -16,17 +17,15 @@ const DefaultCards = React.memo(() => {
)
})
type Props = {
message: string
}
const Empty = () => {
const { t } = useTranslation()
const Empty = ({ message }: Props) => {
return (
<>
<DefaultCards />
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-linear-to-t from-background-body to-transparent">
<span className="system-md-medium text-text-tertiary">
{message}
{t('newApp.noAppsFound', { ns: 'app' })}
</span>
</div>
</>

View File

@ -23,7 +23,6 @@ describe('useAppsQueryState', () => {
const { result } = renderWithAdapter()
expect(result.current.query.tagIDs).toBeUndefined()
expect(result.current.query.creatorIDs).toBeUndefined()
expect(result.current.query.keywords).toBeUndefined()
expect(result.current.query.isCreatedByMe).toBe(false)
})
@ -42,12 +41,6 @@ describe('useAppsQueryState', () => {
expect(result.current.query.keywords).toBe('search term')
})
it('should parse creatorIDs when URL includes creatorIDs', () => {
const { result } = renderWithAdapter('?creatorIDs=user-1;user-2')
expect(result.current.query.creatorIDs).toEqual(['user-1', 'user-2'])
})
it('should parse isCreatedByMe when URL includes true value', () => {
const { result } = renderWithAdapter('?isCreatedByMe=true')
@ -56,11 +49,10 @@ describe('useAppsQueryState', () => {
it('should parse all params when URL includes multiple filters', () => {
const { result } = renderWithAdapter(
'?tagIDs=tag1;tag2&creatorIDs=user-1;user-2&keywords=test&isCreatedByMe=true',
'?tagIDs=tag1;tag2&keywords=test&isCreatedByMe=true',
)
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
expect(result.current.query.creatorIDs).toEqual(['user-1', 'user-2'])
expect(result.current.query.keywords).toBe('test')
expect(result.current.query.isCreatedByMe).toBe(true)
})
@ -87,16 +79,6 @@ describe('useAppsQueryState', () => {
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
})
it('should update creatorIDs when setQuery receives creatorIDs', () => {
const { result } = renderWithAdapter()
act(() => {
result.current.setQuery({ creatorIDs: ['user-1', 'user-2'] })
})
expect(result.current.query.creatorIDs).toEqual(['user-1', 'user-2'])
})
it('should update isCreatedByMe when setQuery receives true', () => {
const { result } = renderWithAdapter()
@ -149,18 +131,6 @@ describe('useAppsQueryState', () => {
expect(update.searchParams.get('tagIDs')).toBe('tag1;tag2')
})
it('should sync creatorIDs to URL when creatorIDs change', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.setQuery({ creatorIDs: ['user-1', 'user-2'] })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('creatorIDs')).toBe('user-1;user-2')
})
it('should sync isCreatedByMe to URL when enabled', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
@ -197,18 +167,6 @@ describe('useAppsQueryState', () => {
expect(update.searchParams.has('tagIDs')).toBe(false)
})
it('should remove creatorIDs from URL when creatorIDs are empty', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?creatorIDs=user-1;user-2')
act(() => {
result.current.setQuery({ creatorIDs: [] })
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('creatorIDs')).toBe(false)
})
it('should remove isCreatedByMe from URL when disabled', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true')
@ -254,17 +212,12 @@ describe('useAppsQueryState', () => {
result.current.setQuery(prev => ({ ...prev, tagIDs: ['tag1'] }))
})
act(() => {
result.current.setQuery(prev => ({ ...prev, creatorIDs: ['user-1'] }))
})
act(() => {
result.current.setQuery(prev => ({ ...prev, isCreatedByMe: true }))
})
expect(result.current.query.keywords).toBe('first')
expect(result.current.query.tagIDs).toEqual(['tag1'])
expect(result.current.query.creatorIDs).toEqual(['user-1'])
expect(result.current.query.isCreatedByMe).toBe(true)
})
})

View File

@ -3,7 +3,6 @@ import { useCallback, useMemo } from 'react'
type AppsQuery = {
tagIDs?: string[]
creatorIDs?: string[]
keywords?: string
isCreatedByMe?: boolean
}
@ -14,7 +13,6 @@ function useAppsQueryState() {
const [urlQuery, setUrlQuery] = useQueryStates(
{
tagIDs: parseAsArrayOf(parseAsString, ';'),
creatorIDs: parseAsArrayOf(parseAsString, ';'),
keywords: parseAsString,
isCreatedByMe: parseAsBoolean,
},
@ -25,18 +23,15 @@ function useAppsQueryState() {
const query = useMemo<AppsQuery>(() => ({
tagIDs: urlQuery.tagIDs ?? undefined,
creatorIDs: urlQuery.creatorIDs ?? undefined,
keywords: normalizeKeywords(urlQuery.keywords),
isCreatedByMe: urlQuery.isCreatedByMe ?? false,
}), [urlQuery.creatorIDs, urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs])
}), [urlQuery.isCreatedByMe, urlQuery.keywords, urlQuery.tagIDs])
const setQuery = useCallback((next: AppsQuery | ((prev: AppsQuery) => AppsQuery)) => {
const buildPatch = (patch: AppsQuery) => {
const result: Partial<typeof urlQuery> = {}
if ('tagIDs' in patch)
result.tagIDs = patch.tagIDs && patch.tagIDs.length > 0 ? patch.tagIDs : null
if ('creatorIDs' in patch)
result.creatorIDs = patch.creatorIDs && patch.creatorIDs.length > 0 ? patch.creatorIDs : null
if ('keywords' in patch)
result.keywords = patch.keywords ? patch.keywords : null
if ('isCreatedByMe' in patch)
@ -47,7 +42,6 @@ function useAppsQueryState() {
if (typeof next === 'function') {
setUrlQuery(prev => buildPatch(next({
tagIDs: prev.tagIDs ?? undefined,
creatorIDs: prev.creatorIDs ?? undefined,
keywords: normalizeKeywords(prev.keywords),
isCreatedByMe: prev.isCreatedByMe ?? false,
})))

View File

@ -0,0 +1,49 @@
import type { WorkflowOnlineUser, WorkflowOnlineUsersResponse } from '@/models/app'
import { skipToken, useQuery } from '@tanstack/react-query'
import { consoleQuery } from '@/service/client'
type WorkflowOnlineUsersMap = Record<string, WorkflowOnlineUser[]>
type UseWorkflowOnlineUsersParams = {
appIds: string[]
enabled: boolean
}
const normalizeWorkflowOnlineUsers = (response?: WorkflowOnlineUsersResponse): WorkflowOnlineUsersMap => {
const data = response?.data
if (!data)
return {}
if (Array.isArray(data)) {
return data.reduce<WorkflowOnlineUsersMap>((acc, item) => {
if (item?.app_id)
acc[item.app_id] = item.users || []
return acc
}, {})
}
return Object.entries(data).reduce<WorkflowOnlineUsersMap>((acc, [appId, users]) => {
if (appId)
acc[appId] = users || []
return acc
}, {})
}
export const useWorkflowOnlineUsers = ({
appIds,
enabled,
}: UseWorkflowOnlineUsersParams) => {
const shouldFetch = enabled && appIds.length > 0
const { data: onlineUsersMap = {} } = useQuery(consoleQuery.apps.workflowOnlineUsers.queryOptions({
input: shouldFetch
? { body: { app_ids: appIds } }
: skipToken,
select: normalizeWorkflowOnlineUsers,
refetchInterval: shouldFetch ? 10000 : false,
}))
return {
onlineUsersMap,
}
}

View File

@ -14,29 +14,19 @@ import { fetchAppDetail } from '@/service/explore'
import { trackCreateApp } from '@/utils/create-app-tracking'
import List from './list'
export type StudioPageType = 'apps' | 'snippets'
type AppsProps = {
pageType?: StudioPageType
}
const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false })
const CreateAppModal = dynamic(() => import('../explore/create-app-modal'), { ssr: false })
const TryApp = dynamic(() => import('../explore/try-app'), { ssr: false })
const ImportFromMarketplaceTemplateModal = dynamic(() => import('./import-from-marketplace-template-modal'), { ssr: false })
const Apps = ({
pageType = 'apps',
}: AppsProps) => {
const Apps = () => {
const { t } = useTranslation()
const searchParams = useSearchParams()
const { replace } = useRouter()
const templateId = searchParams.get('template-id')
const templateDismissedRef = useRef(false)
useDocumentTitle(pageType === 'apps'
? t('menus.apps', { ns: 'common' })
: t('tabs.snippets', { ns: 'workflow' }))
useDocumentTitle(t('menus.apps', { ns: 'common' }))
useEducationInit()
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
@ -156,7 +146,7 @@ const Apps = ({
}}
>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<List controlRefreshList={controlRefreshList} pageType={pageType} />
<List controlRefreshList={controlRefreshList} />
{isShowTryAppPanel && (
<TryApp
appId={currentTryAppParams?.appId || ''}

View File

@ -1,40 +1,33 @@
'use client'
import type { FC } from 'react'
import type { StudioPageType } from '.'
import type { WorkflowOnlineUser } from '@/models/app'
import type { AppListQuery } from '@/contract/console/apps'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import { useQueryState } from 'nuqs'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
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 { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { CheckModal } from '@/hooks/use-pay'
import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
import dynamic from '@/next/dynamic'
import { fetchWorkflowOnlineUsers } from '@/service/apps'
import { consoleQuery } from '@/service/client'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInfiniteAppList } from '@/service/use-apps'
import { useInfiniteSnippetList } from '@/service/use-snippets'
import { AppModeEnum } from '@/types/app'
import SnippetCard from '../snippets/components/snippet-card'
import SnippetCreateCard from '../snippets/components/snippet-create-card'
import { AppModeEnum, AppModes } from '@/types/app'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import AppTypeFilter from './app-type-filter'
import { parseAsAppListCategory } from './app-type-filter-shared'
import CreatorsFilter from './creators-filter'
import Empty from './empty'
import Footer from './footer'
import useAppsQueryState from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users'
import NewAppCard from './new-app-card'
import StudioRouteSwitch from './studio-route-switch'
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
ssr: false,
@ -43,18 +36,25 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
ssr: false,
})
type Props = {
controlRefreshList?: number
pageType?: StudioPageType
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })
type Props = {
controlRefreshList?: number
}
const List: FC<Props> = ({
controlRefreshList = 0,
pageType = 'apps',
}) => {
const { t } = useTranslation()
const isAppsPage = pageType === 'apps'
const { canAccess: canAccessSnippetsAndEvaluation } = useSnippetAndEvaluationPlanAccess()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
@ -63,28 +63,19 @@ const List: FC<Props> = ({
parseAsAppListCategory,
)
const { query: { tagIDs = [], creatorIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
const [appKeywords, setAppKeywords] = useState(keywords)
const [snippetKeywordsInput, setSnippetKeywordsInput] = useState('')
const [snippetKeywords, setSnippetKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState(keywords)
const newAppCardRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const newAppCardRef = useRef<HTMLDivElement>(null)
const [workflowOnlineUsersMap, setWorkflowOnlineUsersMap] = useState<Record<string, WorkflowOnlineUser[]>>({})
const setKeywords = useCallback((keywords: string) => {
setQuery(prev => ({ ...prev, keywords }))
}, [setQuery])
const setTagIDs = useCallback((nextTagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs: nextTagIDs }))
}, [setQuery])
const setCreatorIDs = useCallback((nextCreatorIDs: string[]) => {
setQuery(prev => ({ ...prev, creatorIDs: nextCreatorIDs }))
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs }))
}, [setQuery])
const handleDSLFileDropped = useCallback((file: File) => {
@ -95,18 +86,17 @@ const List: FC<Props> = ({
const { dragging } = useDSLDragDrop({
onDSLFileDropped: handleDSLFileDropped,
containerRef,
enabled: isAppsPage && isCurrentWorkspaceEditor,
enabled: isCurrentWorkspaceEditor,
})
const appListQueryParams = {
const appListQuery = useMemo<AppListQuery>(() => ({
page: 1,
limit: 30,
name: appKeywords,
tag_ids: tagIDs,
is_created_by_me: queryIsCreatedByMe,
...(creatorIDs.length > 0 ? { creator_id: creatorIDs.join(',') } : {}),
name: searchKeywords,
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
...(isCreatedByMe ? { is_created_by_me: isCreatedByMe } : {}),
...(activeTab !== 'all' ? { mode: activeTab } : {}),
}
}), [activeTab, isCreatedByMe, searchKeywords, tagIDs])
const {
data,
@ -117,169 +107,119 @@ const List: FC<Props> = ({
hasNextPage,
error,
refetch,
} = useInfiniteAppList(appListQueryParams, {
enabled: isAppsPage && !isCurrentWorkspaceDatasetOperator,
})
const {
data: snippetData,
isLoading: isSnippetListLoading,
isFetching: isSnippetListFetching,
isFetchingNextPage: isSnippetListFetchingNextPage,
fetchNextPage: fetchSnippetNextPage,
hasNextPage: hasSnippetNextPage,
error: snippetError,
} = useInfiniteSnippetList({
page: 1,
limit: 30,
keyword: snippetKeywords || undefined,
creator_id: creatorIDs.length > 0 ? creatorIDs.join(',') : undefined,
}, {
enabled: !isAppsPage,
} = useInfiniteQuery({
...consoleQuery.apps.list.infiniteOptions({
input: pageParam => ({
query: {
...appListQuery,
page: Number(pageParam),
},
}),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
enabled: !isCurrentWorkspaceDatasetOperator,
refetchInterval: systemFeatures.enable_collaboration_mode ? 10000 : false,
})
useEffect(() => {
if (isAppsPage && controlRefreshList > 0)
if (controlRefreshList > 0) {
refetch()
}, [controlRefreshList, isAppsPage, refetch])
}
}, [controlRefreshList, refetch])
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="mr-1 i-ri-apps-2-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="mr-1 i-ri-exchange-2-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="mr-1 i-ri-robot-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="mr-1 i-ri-file-4-line h-[14px] w-[14px]" /> },
]
useEffect(() => {
if (!isAppsPage)
return
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
refetch()
}
}, [isAppsPage, refetch])
}, [refetch])
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return
const hasMore = isAppsPage ? (hasNextPage ?? true) : (hasSnippetNextPage ?? true)
const isPageLoading = isAppsPage ? isLoading : isSnippetListLoading
const isNextPageFetching = isAppsPage ? isFetchingNextPage : isSnippetListFetchingNextPage
const currentError = isAppsPage ? error : snippetError
const hasMore = hasNextPage ?? true
let observer: IntersectionObserver | undefined
if (currentError) {
observer?.disconnect()
if (error) {
if (observer)
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))
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
observer = new IntersectionObserver((entries) => {
if (entries[0]!.isIntersecting && !isPageLoading && !isNextPageFetching && !currentError && hasMore) {
if (isAppsPage)
fetchNextPage()
else
fetchSnippetNextPage()
}
if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
fetchNextPage()
}, {
root: containerRef.current,
rootMargin: `${dynamicMargin}px`,
threshold: 0.1,
threshold: 0.1, // Trigger when 10% of the anchor element is visible
})
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [error, fetchNextPage, fetchSnippetNextPage, hasNextPage, hasSnippetNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading, isSnippetListFetchingNextPage, isSnippetListLoading, snippetError])
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
const { run: handleAppSearch } = useDebounceFn((value: string) => {
setAppKeywords(value)
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const { run: handleSnippetSearch } = useDebounceFn((value: string) => {
setSnippetKeywords(value)
const { run: handleTagsUpdate } = useDebounceFn(() => {
setTagIDs(tagFilterValue)
}, { wait: 500 })
const handleKeywordsChange = useCallback((value: string) => {
if (isAppsPage) {
setKeywords(value)
handleAppSearch(value)
return
}
setSnippetKeywordsInput(value)
handleSnippetSearch(value)
}, [handleAppSearch, handleSnippetSearch, isAppsPage, setKeywords])
const { run: handleTagsUpdate } = useDebounceFn((value: string[]) => {
setTagIDs(value)
}, { wait: 500 })
const handleTagsChange = useCallback((value: string[]) => {
const handleTagsChange = (value: string[]) => {
setTagFilterValue(value)
handleTagsUpdate(value)
}, [handleTagsUpdate])
handleTagsUpdate()
}
const snippetItems = useMemo(() => {
return (snippetData?.pages ?? []).flatMap(({ data }) => data)
}, [snippetData?.pages])
const handleCreatedByMeChange = useCallback(() => {
const newValue = !isCreatedByMe
setIsCreatedByMe(newValue)
setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
}, [isCreatedByMe, setQuery])
const showSkeleton = isAppsPage
? (isLoading || (isFetching && data?.pages?.length === 0))
: (isSnippetListLoading || (isSnippetListFetching && snippetItems.length === 0))
const hasAnyApp = (data?.pages?.[0]?.total ?? 0) > 0
const hasAnySnippet = snippetItems.length > 0
const currentKeywords = isAppsPage ? keywords : snippetKeywordsInput
const showEmptyState = !showSkeleton && (isAppsPage ? !hasAnyApp : !hasAnySnippet)
const emptyStateMessage = isAppsPage
? t('newApp.noAppsFound', { ns: 'app' })
: t('tabs.noSnippetsFound', { ns: 'workflow' })
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
const workflowOnlineUserAppIds = useMemo(() => {
const appIds = new Set<string>()
pages.forEach(({ data: apps }) => {
apps.forEach((app) => {
if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT)
appIds.add(app.id)
})
apps.forEach((app) => {
if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT)
appIds.add(app.id)
})
return Array.from(appIds)
}, [pages])
}, [apps])
const refreshWorkflowOnlineUsers = useCallback(async () => {
if (!systemFeatures.enable_collaboration_mode) {
setWorkflowOnlineUsersMap({})
return
}
const {
onlineUsersMap: workflowOnlineUsersMap,
} = useWorkflowOnlineUsers({
appIds: workflowOnlineUserAppIds,
enabled: systemFeatures.enable_collaboration_mode,
})
if (!workflowOnlineUserAppIds.length) {
setWorkflowOnlineUsersMap({})
return
}
try {
const onlineUsersMap = await fetchWorkflowOnlineUsers({ appIds: workflowOnlineUserAppIds })
setWorkflowOnlineUsersMap(onlineUsersMap)
}
catch {
setWorkflowOnlineUsersMap({})
}
}, [systemFeatures.enable_collaboration_mode, workflowOnlineUserAppIds])
useEffect(() => {
void refreshWorkflowOnlineUsers()
}, [refreshWorkflowOnlineUsers])
useEffect(() => {
if (!systemFeatures.enable_collaboration_mode)
return
const timer = window.setInterval(() => {
void refetch()
void refreshWorkflowOnlineUsers()
}, 10000)
return () => window.clearInterval(timer)
}, [refetch, refreshWorkflowOnlineUsers, systemFeatures.enable_collaboration_mode])
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)
return (
<>
@ -290,91 +230,66 @@ const List: FC<Props> = ({
)}
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pt-7 pb-5">
<div className="flex flex-wrap items-center gap-2">
<StudioRouteSwitch
pageType={pageType}
appsLabel={t('studio.apps', { ns: 'app' })}
snippetsLabel={t('tabs.snippets', { ns: 'workflow' })}
showSnippets={canAccessSnippetsAndEvaluation}
/>
{isAppsPage && (
<AppTypeFilter
activeTab={activeTab}
onChange={(value) => {
void setActiveTab(value)
}}
/>
)}
<CreatorsFilter value={creatorIDs} onChange={setCreatorIDs} />
{isAppsPage && (
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
)}
</div>
<TabSliderNew
value={activeTab}
onChange={(nextValue) => {
if (isAppListCategory(nextValue))
setActiveTab(nextValue)
}}
options={options}
/>
<div className="flex items-center gap-2">
<label className="mr-2 flex h-7 items-center space-x-2">
<Checkbox checked={isCreatedByMe} onCheck={handleCreatedByMeChange} />
<div className="text-sm font-normal text-text-secondary">
{t('showMyCreatedAppsOnly', { ns: 'app' })}
</div>
</label>
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-[200px]"
placeholder={isAppsPage ? undefined : t('tabs.searchSnippets', { ns: 'workflow' })}
value={currentKeywords}
value={keywords}
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 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
showEmptyState && 'overflow-hidden',
!hasAnyApp && 'overflow-hidden',
)}
>
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
isAppsPage
? (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={activeTab}
className={cn(!hasAnyApp && 'z-10')}
/>
)
: canAccessSnippetsAndEvaluation && <SnippetCreateCard />
)}
{showSkeleton && <AppCardSkeleton count={6} />}
{!showSkeleton && isAppsPage && hasAnyApp && pages.flatMap(({ data: apps }) => apps).map(app => (
<AppCard
key={app.id}
app={app}
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
onRefresh={refetch}
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={activeTab}
className={cn(!hasAnyApp && 'z-10')}
/>
))}
{!showSkeleton && !isAppsPage && hasAnySnippet && snippetItems.map(snippet => (
<SnippetCard key={snippet.id} snippet={snippet} />
))}
{showEmptyState && <Empty message={emptyStateMessage} />}
{isAppsPage && isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
{!isAppsPage && isSnippetListFetchingNextPage && (
{showSkeleton
? <AppCardSkeleton count={6} />
: hasAnyApp
? apps.map(app => (
<AppCard
key={app.id}
app={app}
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
onRefresh={refetch}
/>
))
: <Empty />}
{isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
</div>
{isAppsPage && isCurrentWorkspaceEditor && (
{isCurrentWorkspaceEditor && (
<div
className={cn(
'flex items-center justify-center gap-2 py-4',
dragging ? 'text-text-accent' : 'text-text-quaternary',
)}
className={`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' })}
>
@ -382,18 +297,17 @@ 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>
{isAppsPage && showTagManagementModal && (
{showTagManagementModal && (
<TagManagementModal type="app" show={showTagManagementModal} />
)}
</div>
{isAppsPage && showCreateFromDSLModal && (
{showCreateFromDSLModal && (
<CreateFromDSLModal
show={showCreateFromDSLModal}
onClose={() => {

View File

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

View File

@ -1 +1,2 @@
export { default as BracketsX } from './BracketsX'
export { default as CodeBrowser } from './CodeBrowser'

View File

@ -1,40 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { useEffect } from 'react'
import Loading from '@/app/components/base/loading'
import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
import { useRouter } from '@/next/navigation'
type SnippetAndEvaluationPlanGuardProps = {
children: ReactNode
fallbackHref: string
}
const SnippetAndEvaluationPlanGuard = ({
children,
fallbackHref,
}: SnippetAndEvaluationPlanGuardProps) => {
const router = useRouter()
const { canAccess, isReady } = useSnippetAndEvaluationPlanAccess()
useEffect(() => {
if (isReady && !canAccess)
router.replace(fallbackHref)
}, [canAccess, fallbackHref, isReady, router])
if (!isReady) {
return (
<div className="flex h-full items-center justify-center bg-background-body">
<Loading />
</div>
)
}
if (!canAccess)
return null
return <>{children}</>
}
export default SnippetAndEvaluationPlanGuard

View File

@ -1,7 +1,6 @@
import type { BasicPlan, BillingQuota, CurrentPlanInfoBackend } from '../type'
import dayjs from 'dayjs'
import { ALL_PLANS, NUM_INFINITE } from '@/app/components/billing/config'
import { Plan } from '../type'
/**
* Parse vectorSpace string from ALL_PLANS config and convert to MB
@ -117,21 +116,3 @@ export const parseCurrentPlan = (data: CurrentPlanInfoBackend) => {
},
}
}
export const canAccessSnippetsAndEvaluation = ({
enableBilling,
isFetchedPlan,
planType,
}: {
enableBilling: boolean
isFetchedPlan: boolean
planType: Plan
}) => {
if (!isFetchedPlan)
return !enableBilling
if (!enableBilling)
return true
return planType === Plan.professional || planType === Plan.team || planType === Plan.enterprise
}

View File

@ -1,34 +0,0 @@
import { getDefaultMetricDescription, getDefaultMetricDescriptionI18nKey, getTranslatedMetricDescription } from '../default-metric-descriptions'
describe('default metric descriptions', () => {
it('should resolve descriptions for kebab-case metric ids', () => {
expect(getDefaultMetricDescription('context-precision')).toContain('retrieval pipeline returns little noise')
expect(getDefaultMetricDescription('answer-correctness')).toContain('factual accuracy and completeness')
})
it('should normalize snake_case metric ids from backend payloads', () => {
expect(getDefaultMetricDescription('CONTEXT_RECALL')).toContain('does not miss important supporting evidence')
expect(getDefaultMetricDescription('TOOL_CORRECTNESS')).toContain('tool-use strategy matches the expected behavior')
})
it('should support the legacy relevance alias', () => {
expect(getDefaultMetricDescription('relevance')).toContain('addresses the user\'s question')
})
it('should resolve i18n keys for builtin metrics', () => {
expect(getDefaultMetricDescriptionI18nKey('context-precision')).toBe('metrics.builtin.description.contextPrecision')
expect(getDefaultMetricDescriptionI18nKey('ANSWER_RELEVANCY')).toBe('metrics.builtin.description.answerRelevancy')
})
it('should use translated content when translation key exists', () => {
const t = vi.fn((key: string, options?: { defaultValue?: string }) => {
if (key === 'metrics.builtin.description.faithfulness')
return '忠实性中文文案'
return options?.defaultValue ?? key
})
expect(getTranslatedMetricDescription(t as never, 'faithfulness')).toBe('忠实性中文文案')
expect(getTranslatedMetricDescription(t as never, 'latency', 'Latency fallback')).toBe('Latency fallback')
})
})

View File

@ -1,747 +0,0 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import Evaluation from '..'
import ConditionsSection from '../components/conditions-section'
import { useEvaluationStore } from '../store'
const mockUpload = vi.hoisted(() => vi.fn())
const mockUseDatasetEvaluationMetrics = vi.hoisted(() => vi.fn())
const mockUseDefaultEvaluationMetrics = vi.hoisted(() => vi.fn())
const mockUseEvaluationConfig = vi.hoisted(() => vi.fn())
const mockUseSaveEvaluationConfigMutation = vi.hoisted(() => vi.fn())
const mockUseStartEvaluationRunMutation = vi.hoisted(() => vi.fn())
const mockUseEvaluationTemplateColumns = vi.hoisted(() => vi.fn())
const mockUsePublishedPipelineInfo = vi.hoisted(() => vi.fn())
const mockUseSnippetPublishedWorkflow = vi.hoisted(() => vi.fn())
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,
onSelect,
}: {
defaultModel?: { provider: string, model: string }
onSelect: (model: { provider: string, model: string }) => void
}) => (
<div>
<div data-testid="evaluation-model-selector">
{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'empty'}
</div>
<button
type="button"
onClick={() => onSelect({ provider: 'openai', model: 'gpt-4o-mini' })}
>
select-model
</button>
</div>
),
}))
vi.mock('@/service/base', () => ({
upload: (...args: unknown[]) => mockUpload(...args),
}))
vi.mock('@/service/use-evaluation', () => ({
useEvaluationConfig: (...args: unknown[]) => mockUseEvaluationConfig(...args),
useDatasetEvaluationMetrics: (...args: unknown[]) => mockUseDatasetEvaluationMetrics(...args),
useDefaultEvaluationMetrics: (...args: unknown[]) => mockUseDefaultEvaluationMetrics(...args),
useSaveEvaluationConfigMutation: (...args: unknown[]) => mockUseSaveEvaluationConfigMutation(...args),
useStartEvaluationRunMutation: (...args: unknown[]) => mockUseStartEvaluationRunMutation(...args),
useEvaluationTemplateColumns: (...args: unknown[]) => mockUseEvaluationTemplateColumns(...args),
}))
vi.mock('@/service/use-pipeline', () => ({
usePublishedPipelineInfo: (...args: unknown[]) => mockUsePublishedPipelineInfo(...args),
}))
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id: string } }) => unknown) =>
selector({ dataset: { pipeline_id: 'pipeline-1' } }),
}))
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: () => ({
data: {
graph: {
nodes: [{
id: 'start',
data: {
type: 'start',
variables: [{
variable: 'query',
type: 'text-input',
}],
},
}],
},
},
isLoading: false,
}),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
useSnippetPublishedWorkflow: (...args: unknown[]) => mockUseSnippetPublishedWorkflow(...args),
}))
const renderWithQueryClient = (ui: ReactNode) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
return render(ui, {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
})
}
describe('Evaluation', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {}, initialResources: {} })
vi.clearAllMocks()
mockUseEvaluationConfig.mockReturnValue({
data: null,
})
mockUseDatasetEvaluationMetrics.mockReturnValue({
data: {
metrics: ['answer-correctness', 'faithfulness', 'context-precision', 'context-recall', 'context-relevance'],
},
isLoading: false,
})
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
],
},
{
metric: 'faithfulness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
],
},
{
metric: 'context-precision',
value_type: 'number',
node_info_list: [],
},
{
metric: 'context-recall',
value_type: 'number',
node_info_list: [],
},
{
metric: 'context-relevance',
value_type: 'number',
node_info_list: [],
},
],
},
isLoading: false,
})
mockUseSaveEvaluationConfigMutation.mockReturnValue({
isPending: false,
mutate: vi.fn(),
})
mockUseStartEvaluationRunMutation.mockReturnValue({
isPending: false,
mutate: vi.fn(),
})
mockUseEvaluationTemplateColumns.mockReturnValue({
data: {
columns: [
{ name: 'index', type: 'number' },
{ name: 'query', type: 'string' },
{ name: 'expected_output', type: 'string' },
],
},
isError: false,
isFetching: false,
isPending: false,
})
mockUsePublishedPipelineInfo.mockReturnValue({
data: {
graph: {
nodes: [{
id: 'knowledge-node',
data: {
type: 'knowledge-index',
title: 'Knowledge Base',
},
}],
edges: [],
},
},
})
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: {
graph: {
nodes: [{
id: 'start',
data: {
type: 'start',
variables: [{
variable: 'query',
type: 'text-input',
}],
},
}],
},
input_fields: [],
},
isLoading: false,
})
mockUpload.mockResolvedValue({
id: 'uploaded-file-id',
name: 'evaluation.csv',
})
})
it('should search, select metric nodes, and save evaluation config', () => {
const saveConfig = vi.fn()
mockUseSaveEvaluationConfigMutation.mockReturnValue({
isPending: false,
mutate: saveConfig,
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-1" />)
expect(screen.getByTestId('evaluation-model-selector')).toHaveTextContent('openai:gpt-4o-mini')
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchNodeOrMetrics'), {
target: { value: 'does-not-exist' },
})
expect(screen.getByText('evaluation.metrics.noResults')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchNodeOrMetrics'), {
target: { value: 'faith' },
})
fireEvent.click(screen.getByTestId('evaluation-metric-node-faithfulness-node-faithfulness'))
expect(screen.getAllByText('Faithfulness').length).toBeGreaterThan(0)
expect(screen.getAllByText('Retriever Node').length).toBeGreaterThan(0)
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchNodeOrMetrics'), {
target: { value: '' },
})
fireEvent.click(screen.getByTestId('evaluation-metric-node-answer-correctness-node-answer'))
expect(screen.getAllByText('Answer Correctness').length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(saveConfig).toHaveBeenCalledWith({
params: {
targetType: 'apps',
targetId: 'app-1',
},
body: {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [
{
metric: 'faithfulness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
],
},
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
],
},
],
customized_metrics: null,
judgment_config: null,
},
}, {
onSuccess: expect.any(Function),
onError: expect.any(Function),
})
})
it('should reset unsaved non-pipeline config changes to the hydrated config', () => {
mockUseEvaluationConfig.mockReturnValue({
data: {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [],
customized_metrics: null,
judgment_config: null,
},
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-reset" />)
const resetButton = screen.getByRole('button', { name: 'common.operation.reset' })
expect(resetButton).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchNodeOrMetrics'), {
target: { value: 'faith' },
})
fireEvent.click(screen.getByTestId('evaluation-metric-node-faithfulness-node-faithfulness'))
expect(useEvaluationStore.getState().resources['apps:app-reset']!.metrics).toHaveLength(1)
expect(resetButton).toBeEnabled()
fireEvent.click(resetButton)
expect(useEvaluationStore.getState().resources['apps:app-reset']!.metrics).toHaveLength(0)
expect(resetButton).toBeDisabled()
})
it('should hide the batch config warning when judge model and metrics are configured', () => {
const resourceType = 'apps'
const resourceId = 'app-batch-configured'
const store = useEvaluationStore.getState()
act(() => {
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
store.addBuiltinMetric(resourceType, resourceId, 'faithfulness', [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
])
})
renderWithQueryClient(<Evaluation resourceType={resourceType} resourceId={resourceId} />)
expect(screen.queryByText('evaluation.batch.noticeDescription')).not.toBeInTheDocument()
})
it('should use template columns for snippet batch templates', () => {
const store = useEvaluationStore.getState()
act(() => {
store.ensureResource('snippets', 'snippet-fields')
store.setJudgeModel('snippets', 'snippet-fields', 'openai::gpt-4o-mini')
store.addBuiltinMetric('snippets', 'snippet-fields', 'answer-correctness', [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
})
mockUseEvaluationTemplateColumns.mockReturnValue({
data: {
columns: [
{ name: 'index', type: 'number' },
{ name: 'snippet_topic', type: 'string' },
{ name: 'need_summary', type: 'boolean' },
],
},
isError: false,
isFetching: false,
isPending: false,
})
renderWithQueryClient(<Evaluation resourceType="snippets" resourceId="snippet-fields" />)
expect(mockUseEvaluationTemplateColumns).toHaveBeenCalledWith(
'snippets',
'snippet-fields',
expect.any(Object),
true,
)
expect(screen.getByText('snippet_topic')).toBeInTheDocument()
expect(screen.getByText('need_summary')).toBeInTheDocument()
})
it('should show empty template columns copy', () => {
const store = useEvaluationStore.getState()
act(() => {
store.ensureResource('snippets', 'snippet-empty-fields')
store.setJudgeModel('snippets', 'snippet-empty-fields', 'openai::gpt-4o-mini')
store.addBuiltinMetric('snippets', 'snippet-empty-fields', 'answer-correctness', [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
})
mockUseEvaluationTemplateColumns.mockReturnValue({
data: {
columns: [],
},
isError: false,
isFetching: false,
isPending: false,
})
renderWithQueryClient(<Evaluation resourceType="snippets" resourceId="snippet-empty-fields" />)
expect(screen.getByText('evaluation.batch.noTemplateColumns')).toBeInTheDocument()
})
it('should hide the value row for empty operators', () => {
const resourceType = 'apps'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
let conditionId = ''
act(() => {
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
store.addBuiltinMetric(resourceType, resourceId, 'faithfulness', [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
])
store.addCondition(resourceType, resourceId)
const condition = useEvaluationStore.getState().resources['apps:app-2'].judgmentConfig.conditions[0]
conditionId = condition.id
store.updateConditionOperator(resourceType, resourceId, conditionId, '=')
})
let rerender: ReturnType<typeof render>['rerender']
act(() => {
({ rerender } = renderWithQueryClient(<Evaluation resourceType={resourceType} resourceId={resourceId} />))
})
expect(screen.getByPlaceholderText('evaluation.conditions.valuePlaceholder')).toBeInTheDocument()
act(() => {
store.updateConditionOperator(resourceType, resourceId, conditionId, 'is null')
rerender(<Evaluation resourceType={resourceType} resourceId={resourceId} />)
})
expect(screen.queryByPlaceholderText('evaluation.conditions.valuePlaceholder')).not.toBeInTheDocument()
})
it('should add a condition from grouped metric dropdown items', () => {
const resourceType = 'apps'
const resourceId = 'app-conditions-dropdown'
const store = useEvaluationStore.getState()
act(() => {
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
store.addBuiltinMetric(resourceType, resourceId, 'faithfulness', [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
])
store.addCustomMetric(resourceType, resourceId)
const customMetric = useEvaluationStore.getState().resources['apps:app-conditions-dropdown'].metrics.find(metric => metric.kind === 'custom-workflow')!
store.setCustomMetricWorkflow(resourceType, resourceId, customMetric.id, {
workflowId: 'workflow-1',
workflowAppId: 'workflow-app-1',
workflowName: 'Review Workflow',
})
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
id: 'reason',
valueType: 'string',
}])
})
render(<ConditionsSection resourceType={resourceType} resourceId={resourceId} />)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.conditions.addCondition' }))
expect(screen.getByText('Faithfulness')).toBeInTheDocument()
expect(screen.getByText('Review Workflow')).toBeInTheDocument()
expect(screen.getByText('Retriever Node')).toBeInTheDocument()
expect(screen.getByText('reason')).toBeInTheDocument()
expect(screen.getByText('evaluation.conditions.valueTypes.number')).toBeInTheDocument()
expect(screen.getByText('evaluation.conditions.valueTypes.string')).toBeInTheDocument()
fireEvent.click(screen.getByRole('menuitem', { name: /reason/i }))
const condition = useEvaluationStore.getState().resources['apps:app-conditions-dropdown'].judgmentConfig.conditions[0]
expect(condition.variableSelector).toEqual(['workflow-1', 'reason'])
expect(screen.getAllByText('Review Workflow').length).toBeGreaterThan(0)
})
it('should render the metric no-node empty state', () => {
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [
{
metric: 'context-precision',
value_type: 'number',
node_info_list: [],
},
],
},
isLoading: false,
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-3" />)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
expect(screen.getByText('evaluation.metrics.noNodesInWorkflow')).toBeInTheDocument()
})
it('should add a node from a dynamically returned metric option', () => {
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
],
},
{
metric: 'context-precision',
value_type: 'number',
node_info_list: [
{ node_id: 'node-context', title: 'Context Node', type: 'knowledge-retrieval' },
],
},
],
},
isLoading: false,
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-dynamic-metric" />)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
fireEvent.click(screen.getByTestId('evaluation-metric-node-context-precision-node-context'))
const metrics = useEvaluationStore.getState().resources['apps:app-dynamic-metric']!.metrics
expect(metrics).toHaveLength(1)
expect(metrics[0]).toMatchObject({
optionId: 'context-precision',
label: 'Context Precision',
nodeInfoList: [
{ node_id: 'node-context', title: 'Context Node', type: 'knowledge-retrieval' },
],
})
})
it('should render the global empty state when no metrics are available', () => {
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [],
},
isLoading: false,
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-4" />)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
expect(screen.getByText('evaluation.metrics.noResults')).toBeInTheDocument()
})
it('should show more nodes when a metric has more than three nodes', () => {
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
{ node_id: 'node-3', title: 'LLM 3', type: 'llm' },
{ node_id: 'node-4', title: 'LLM 4', type: 'llm' },
],
},
],
},
isLoading: false,
})
renderWithQueryClient(<Evaluation resourceType="apps" resourceId="app-5" />)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
expect(screen.getByText('LLM 3')).toBeInTheDocument()
expect(screen.queryByText('LLM 4')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.showMore' }))
expect(screen.getByText('LLM 4')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'evaluation.metrics.showLess' })).toBeInTheDocument()
})
it('should render the pipeline-specific layout without auto-selecting a judge model', () => {
renderWithQueryClient(<Evaluation resourceType="datasets" resourceId="dataset-1" />)
expect(mockUseDatasetEvaluationMetrics).toHaveBeenCalledWith('dataset-1')
expect(screen.getByTestId('evaluation-model-selector')).toHaveTextContent('empty')
expect(screen.getByText('evaluation.history.columns.time')).toBeInTheDocument()
expect(screen.getByText('Context Precision')).toBeInTheDocument()
expect(screen.getByText('Context Recall')).toBeInTheDocument()
expect(screen.getByText('Context Relevance')).toBeInTheDocument()
expect(screen.getByText('evaluation.results.empty')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'evaluation.pipeline.uploadAndRun' })).toBeDisabled()
})
it('should render selected pipeline metrics from config with the default threshold input', () => {
mockUseEvaluationConfig.mockReturnValue({
data: {
evaluation_model: null,
evaluation_model_provider: null,
default_metrics: [{
metric: 'context-precision',
}],
customized_metrics: null,
judgment_config: null,
},
})
renderWithQueryClient(<Evaluation resourceType="datasets" resourceId="dataset-2" />)
expect(screen.getByText('Context Precision')).toBeInTheDocument()
expect(screen.getByDisplayValue('0.85')).toBeInTheDocument()
})
it('should enable pipeline batch actions after selecting a judge model and metric', () => {
renderWithQueryClient(<Evaluation resourceType="datasets" resourceId="dataset-2" />)
fireEvent.click(screen.getByRole('button', { name: 'select-model' }))
fireEvent.click(screen.getByRole('button', { name: /Context Precision/i }))
expect(screen.getByDisplayValue('0.85')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'evaluation.batch.downloadTemplate' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'evaluation.pipeline.uploadAndRun' })).toBeEnabled()
})
it('should download the fixed pipeline template columns', () => {
const createElement = document.createElement.bind(document)
mockUseEvaluationTemplateColumns.mockReturnValue({
data: {
columns: [
{ name: 'index', type: 'number' },
{ name: 'query', type: 'string' },
{ name: 'expected_output', type: 'string' },
],
},
isError: false,
isFetching: false,
isPending: false,
})
let downloadLink: HTMLAnchorElement | undefined
const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => {
const element = createElement(tagName, options)
if (tagName === 'a') {
downloadLink = element as HTMLAnchorElement
vi.spyOn(downloadLink, 'click').mockImplementation(() => {})
}
return element
})
renderWithQueryClient(<Evaluation resourceType="datasets" resourceId="dataset-template" />)
fireEvent.click(screen.getByRole('button', { name: 'select-model' }))
fireEvent.click(screen.getByRole('button', { name: /Context Precision/i }))
fireEvent.click(screen.getByRole('button', { name: 'evaluation.batch.downloadTemplate' }))
const templateContent = decodeURIComponent(downloadLink?.href ?? '').replace('data:text/csv;charset=utf-8,', '')
expect(downloadLink?.download).toBe('pipeline-evaluation-template.csv')
expect(templateContent.trim().split(',')).toEqual(['index', 'query', 'expected_output'])
expect(mockUseEvaluationTemplateColumns).toHaveBeenLastCalledWith(
'datasets',
'dataset-template',
expect.objectContaining({
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
}),
true,
)
createElementSpy.mockRestore()
})
it('should upload and start a pipeline evaluation run', async () => {
const startRun = vi.fn()
mockUseStartEvaluationRunMutation.mockReturnValue({
isPending: false,
mutate: startRun,
})
mockUpload.mockResolvedValue({
id: 'file-1',
name: 'pipeline-evaluation.csv',
})
renderWithQueryClient(<Evaluation resourceType="datasets" resourceId="dataset-run" />)
fireEvent.click(screen.getByRole('button', { name: 'select-model' }))
fireEvent.click(screen.getByRole('button', { name: /Context Precision/i }))
fireEvent.click(screen.getByRole('button', { name: 'evaluation.pipeline.uploadAndRun' }))
expect(screen.getAllByText('query').length).toBeGreaterThan(0)
expect(screen.getAllByText('expected_output').length).toBeGreaterThan(0)
const fileInput = document.querySelector<HTMLInputElement>('input[type="file"][accept=".csv"]')
expect(fileInput).toBeInTheDocument()
fireEvent.change(fileInput!, {
target: {
files: [new File(['index,query,expected_output'], 'pipeline-evaluation.csv', { type: 'text/csv' })],
},
})
await waitFor(() => {
expect(mockUpload).toHaveBeenCalledWith({
xhr: expect.any(XMLHttpRequest),
data: expect.any(FormData),
})
})
fireEvent.click(screen.getByRole('button', { name: 'evaluation.batch.run' }))
await waitFor(() => {
expect(startRun).toHaveBeenCalledWith({
params: {
targetType: 'datasets',
targetId: 'dataset-run',
},
body: {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [{
metric: 'context-precision',
value_type: 'number',
node_info_list: [
{ node_id: 'knowledge-node', title: 'Knowledge Base', type: 'knowledge-index' },
],
}],
customized_metrics: null,
judgment_config: {
logical_operator: 'and',
conditions: [{
variable_selector: ['knowledge-node', 'context-precision'],
comparison_operator: '≥',
value: '0.85',
}],
},
file_id: 'file-1',
},
}, {
onSuccess: expect.any(Function),
onError: expect.any(Function),
})
})
})
})

View File

@ -1,456 +0,0 @@
import type { EvaluationConfig } from '@/types/evaluation'
import {
getAllowedOperators,
isCustomMetricConfigured,
requiresConditionValue,
useEvaluationStore,
} from '../store'
import { buildEvaluationConfigPayload, buildEvaluationRunRequest } from '../store-utils'
const customWorkflow = {
id: 'workflow-precision-review',
appId: 'custom-workflow-app-id',
name: 'Precision Review Workflow',
}
describe('evaluation store', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {}, initialResources: {} })
})
it('should configure a custom metric mapping to a valid state', () => {
const resourceType = 'apps'
const resourceId = 'app-1'
const store = useEvaluationStore.getState()
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
const initialMetric = useEvaluationStore.getState().resources['apps:app-1'].metrics.find(metric => metric.kind === 'custom-workflow')
expect(initialMetric).toBeDefined()
expect(isCustomMetricConfigured(initialMetric!)).toBe(false)
store.setCustomMetricWorkflow(resourceType, resourceId, initialMetric!.id, {
workflowId: customWorkflow.id,
workflowAppId: customWorkflow.appId,
workflowName: customWorkflow.name,
})
store.syncCustomMetricMappings(resourceType, resourceId, initialMetric!.id, ['query'])
store.syncCustomMetricOutputs(resourceType, resourceId, initialMetric!.id, [{
id: 'score',
valueType: 'number',
}])
const syncedMetric = useEvaluationStore.getState().resources['apps:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
store.updateCustomMetricMapping(resourceType, resourceId, initialMetric!.id, syncedMetric!.customConfig!.mappings[0].id, {
outputVariableId: 'answer',
})
const configuredMetric = useEvaluationStore.getState().resources['apps:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
expect(isCustomMetricConfigured(configuredMetric!)).toBe(true)
expect(configuredMetric!.customConfig!.workflowAppId).toBe(customWorkflow.appId)
expect(configuredMetric!.customConfig!.workflowName).toBe(customWorkflow.name)
expect(configuredMetric!.customConfig!.outputs).toEqual([{ id: 'score', valueType: 'number' }])
})
it('should only add one custom metric', () => {
const resourceType = 'apps'
const resourceId = 'app-custom-limit'
const store = useEvaluationStore.getState()
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
expect(
useEvaluationStore
.getState()
.resources['apps:app-custom-limit']
.metrics
.filter(metric => metric.kind === 'custom-workflow'),
).toHaveLength(1)
})
it('should add and remove builtin metrics', () => {
const resourceType = 'apps'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, 'faithfulness')
const addedMetric = useEvaluationStore.getState().resources['apps:app-2'].metrics.find(metric => metric.optionId === 'faithfulness')
expect(addedMetric).toBeDefined()
store.removeMetric(resourceType, resourceId, addedMetric!.id)
expect(useEvaluationStore.getState().resources['apps:app-2'].metrics.some(metric => metric.id === addedMetric!.id)).toBe(false)
})
it('should upsert builtin metric node selections and prune stale conditions', () => {
const resourceType = 'apps'
const resourceId = 'app-4'
const store = useEvaluationStore.getState()
const metricId = 'answer-correctness'
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, metricId, [
{ node_id: 'node-1', title: 'Answer Node', type: 'answer' },
])
store.addCondition(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, metricId, [
{ node_id: 'node-2', title: 'Retriever Node', type: 'retriever' },
])
const state = useEvaluationStore.getState().resources['apps:app-4']
const metric = state.metrics.find(item => item.optionId === metricId)
expect(metric?.nodeInfoList).toEqual([
{ node_id: 'node-2', title: 'Retriever Node', type: 'retriever' },
])
expect(state.metrics.filter(item => item.optionId === metricId)).toHaveLength(1)
expect(state.judgmentConfig.conditions).toHaveLength(0)
})
it('should build numeric conditions from selected metrics', () => {
const resourceType = 'apps'
const resourceId = 'app-conditions'
const store = useEvaluationStore.getState()
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
store.setConditionLogicalOperator(resourceType, resourceId, 'or')
store.addCondition(resourceType, resourceId)
const state = useEvaluationStore.getState().resources['apps:app-conditions']
const condition = state.judgmentConfig.conditions[0]
expect(state.judgmentConfig.logicalOperator).toBe('or')
expect(condition.variableSelector).toEqual(['node-answer', 'answer-correctness'])
expect(condition.comparisonOperator).toBe('=')
expect(getAllowedOperators(state.metrics, condition.variableSelector)).toEqual(['=', '≠', '>', '<', '≥', '≤', 'is null', 'is not null'])
})
it('should add a condition from the selected custom metric output', () => {
const resourceType = 'apps'
const resourceId = 'app-condition-selector'
const store = useEvaluationStore.getState()
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
const customMetric = useEvaluationStore.getState().resources['apps:app-condition-selector'].metrics.find(metric => metric.kind === 'custom-workflow')!
store.setCustomMetricWorkflow(resourceType, resourceId, customMetric.id, {
workflowId: customWorkflow.id,
workflowAppId: customWorkflow.appId,
workflowName: customWorkflow.name,
})
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
id: 'reason',
valueType: 'string',
}])
store.addCondition(resourceType, resourceId, [customWorkflow.id, 'reason'])
const condition = useEvaluationStore.getState().resources['apps:app-condition-selector'].judgmentConfig.conditions[0]
expect(condition.variableSelector).toEqual([customWorkflow.id, 'reason'])
expect(condition.comparisonOperator).toBe('contains')
expect(condition.value).toBeNull()
})
it('should clear values for operators without values', () => {
const resourceType = 'apps'
const resourceId = 'app-3'
const store = useEvaluationStore.getState()
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
const customMetric = useEvaluationStore.getState().resources['apps:app-3'].metrics.find(metric => metric.kind === 'custom-workflow')!
store.setCustomMetricWorkflow(resourceType, resourceId, customMetric.id, {
workflowId: customWorkflow.id,
workflowAppId: customWorkflow.appId,
workflowName: customWorkflow.name,
})
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
id: 'reason',
valueType: 'string',
}])
store.addCondition(resourceType, resourceId)
const condition = useEvaluationStore.getState().resources['apps:app-3'].judgmentConfig.conditions[0]
store.updateConditionMetric(resourceType, resourceId, condition.id, [customWorkflow.id, 'reason'])
store.updateConditionValue(resourceType, resourceId, condition.id, 'needs follow-up')
store.updateConditionOperator(resourceType, resourceId, condition.id, 'empty')
const updatedCondition = useEvaluationStore.getState().resources['apps:app-3'].judgmentConfig.conditions[0]
expect(requiresConditionValue('empty')).toBe(false)
expect(updatedCondition.value).toBeNull()
})
it('should hydrate resource state from judgment_config', () => {
const resourceType = 'apps'
const resourceId = 'app-5'
const store = useEvaluationStore.getState()
const config: EvaluationConfig = {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [{
metric: 'faithfulness',
node_info_list: [
{ node_id: 'node-1', title: 'Retriever', type: 'retriever' },
],
}],
customized_metrics: {
evaluation_workflow_id: 'workflow-precision-review',
input_fields: {
query: 'answer',
},
output_fields: [{
variable: 'reason',
value_type: 'string',
}],
},
judgment_config: {
logical_operator: 'or',
conditions: [{
variable_selector: ['node-1', 'faithfulness'],
comparison_operator: '≥',
value: '0.9',
}],
},
}
store.ensureResource(resourceType, resourceId)
store.setBatchTab(resourceType, resourceId, 'history')
store.setUploadedFileName(resourceType, resourceId, 'batch.csv')
useEvaluationStore.setState(state => ({
resources: {
...state.resources,
'apps:app-5': {
...state.resources['apps:app-5'],
batchRecords: [{
id: 'batch-1',
fileName: 'batch.csv',
status: 'success',
startedAt: '10:00:00',
summary: 'App evaluation batch',
}],
},
},
}))
store.hydrateResource(resourceType, resourceId, config)
const hydratedState = useEvaluationStore.getState().resources['apps:app-5']
expect(hydratedState.judgeModelId).toBe('openai::gpt-4o-mini')
expect(hydratedState.metrics).toHaveLength(2)
expect(hydratedState.metrics[0].optionId).toBe('faithfulness')
expect(hydratedState.metrics[0].threshold).toBe(0.85)
expect(hydratedState.metrics[0].nodeInfoList).toEqual([
{ node_id: 'node-1', title: 'Retriever', type: 'retriever' },
])
expect(hydratedState.metrics[1].kind).toBe('custom-workflow')
expect(hydratedState.metrics[1].customConfig?.workflowId).toBe('workflow-precision-review')
expect(hydratedState.metrics[1].customConfig?.workflowAppId).toBe('workflow-precision-review')
expect(hydratedState.metrics[1].customConfig?.mappings[0].inputVariableId).toBe('query')
expect(hydratedState.metrics[1].customConfig?.mappings[0].outputVariableId).toBe('answer')
expect(hydratedState.metrics[1].customConfig?.outputs).toEqual([{ id: 'reason', valueType: 'string' }])
expect(hydratedState.judgmentConfig.logicalOperator).toBe('or')
expect(hydratedState.judgmentConfig.conditions[0]).toMatchObject({
variableSelector: ['node-1', 'faithfulness'],
comparisonOperator: '≥',
value: '0.9',
})
expect(hydratedState.activeBatchTab).toBe('history')
expect(hydratedState.uploadedFileName).toBe('batch.csv')
expect(hydratedState.batchRecords).toHaveLength(1)
})
it('should build an evaluation config save payload from resource state', () => {
const resourceType = 'apps'
const resourceId = 'app-save-config'
const store = useEvaluationStore.getState()
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
store.addBuiltinMetric(resourceType, resourceId, 'faithfulness', [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
])
store.addCustomMetric(resourceType, resourceId)
const customMetric = useEvaluationStore.getState().resources['apps:app-save-config'].metrics.find(metric => metric.kind === 'custom-workflow')!
store.setCustomMetricWorkflow(resourceType, resourceId, customMetric.id, {
workflowId: 'workflow-precision-review',
workflowAppId: 'evaluation-workflow-app-id',
workflowName: 'Precision Review',
})
store.syncCustomMetricMappings(resourceType, resourceId, customMetric.id, ['query'])
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
id: 'score',
valueType: 'number',
}])
const syncedMetric = useEvaluationStore.getState().resources['apps:app-save-config'].metrics.find(metric => metric.id === customMetric.id)!
store.updateCustomMetricMapping(resourceType, resourceId, customMetric.id, syncedMetric.customConfig!.mappings[0].id, {
outputVariableId: '{{#node-answer.output#}}',
})
store.addCondition(resourceType, resourceId, ['workflow-precision-review', 'score'])
const condition = useEvaluationStore.getState().resources['apps:app-save-config'].judgmentConfig.conditions[0]
store.updateConditionOperator(resourceType, resourceId, condition.id, '≥')
store.updateConditionValue(resourceType, resourceId, condition.id, '0.8')
const resource = useEvaluationStore.getState().resources['apps:app-save-config']
const expectedPayload = {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [{
metric: 'faithfulness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
],
}],
customized_metrics: {
evaluation_workflow_id: 'evaluation-workflow-app-id',
input_fields: {
query: '{{#node-answer.output#}}',
},
output_fields: [{
variable: 'score',
value_type: 'number',
}],
},
judgment_config: {
logical_operator: 'and',
conditions: [{
variable_selector: ['evaluation-workflow-app-id', 'score'],
comparison_operator: '≥',
value: '0.8',
}],
},
}
expect(buildEvaluationConfigPayload(resource, resourceType)).toEqual(expectedPayload)
expect(buildEvaluationRunRequest(resource, 'file-1', resourceType)).toEqual({
...expectedPayload,
file_id: 'file-1',
})
})
it('should hydrate pipeline metrics from fixed knowledge-index conditions', () => {
const resourceType = 'datasets'
const resourceId = 'dataset-hydrate'
const store = useEvaluationStore.getState()
const config: EvaluationConfig = {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [{
metric: 'context-precision',
node_info_list: [
{ node_id: 'knowledge-node', title: 'Knowledge Base', type: 'knowledge-index' },
],
}],
customized_metrics: {
evaluation_workflow_id: 'should-be-ignored',
input_fields: {
query: 'answer',
},
output_fields: [{
variable: 'score',
value_type: 'number',
}],
},
judgment_config: {
logical_operator: 'or',
conditions: [{
variable_selector: ['knowledge-node', 'context-precision'],
comparison_operator: '≥',
value: '0.92',
}],
},
}
store.hydrateResource(resourceType, resourceId, config)
const hydratedState = useEvaluationStore.getState().resources['datasets:dataset-hydrate']
expect(hydratedState.judgeModelId).toBe('openai::gpt-4o-mini')
expect(hydratedState.metrics).toHaveLength(1)
expect(hydratedState.metrics[0]).toMatchObject({
optionId: 'context-precision',
kind: 'builtin',
valueType: 'number',
threshold: 0.92,
nodeInfoList: [
{ node_id: 'knowledge-node', title: 'Knowledge Base', type: 'knowledge-index' },
],
})
})
it('should build pipeline judgment payload from metric thresholds', () => {
const resourceType = 'datasets'
const resourceId = 'dataset-save-config'
const store = useEvaluationStore.getState()
const knowledgeNodeInfo = [{ node_id: 'knowledge-node', title: 'Knowledge Base', type: 'knowledge-index' }]
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
store.addBuiltinMetric(resourceType, resourceId, 'context-precision', knowledgeNodeInfo)
store.addBuiltinMetric(resourceType, resourceId, 'context-recall', knowledgeNodeInfo)
const resourceWithMetrics = useEvaluationStore.getState().resources['datasets:dataset-save-config']
const contextPrecisionMetric = resourceWithMetrics.metrics.find(metric => metric.optionId === 'context-precision')!
const contextRecallMetric = resourceWithMetrics.metrics.find(metric => metric.optionId === 'context-recall')!
store.updateMetricThreshold(resourceType, resourceId, contextPrecisionMetric.id, 0.91)
store.updateMetricThreshold(resourceType, resourceId, contextRecallMetric.id, 0.88)
const resource = useEvaluationStore.getState().resources['datasets:dataset-save-config']
const expectedPayload = {
evaluation_model: 'gpt-4o-mini',
evaluation_model_provider: 'openai',
default_metrics: [
{
metric: 'context-precision',
value_type: 'number',
node_info_list: knowledgeNodeInfo,
},
{
metric: 'context-recall',
value_type: 'number',
node_info_list: knowledgeNodeInfo,
},
],
customized_metrics: null,
judgment_config: {
logical_operator: 'and',
conditions: [
{
variable_selector: ['knowledge-node', 'context-precision'],
comparison_operator: '≥',
value: '0.91',
},
{
variable_selector: ['knowledge-node', 'context-recall'],
comparison_operator: '≥',
value: '0.88',
},
],
},
}
expect(buildEvaluationConfigPayload(resource, resourceType)).toEqual(expectedPayload)
expect(buildEvaluationRunRequest(resource, 'file-1', resourceType)).toEqual({
...expectedPayload,
file_id: 'file-1',
})
})
})

View File

@ -1,219 +0,0 @@
import type { EvaluationResourceProps } from '../../types'
import type { EvaluationLog, EvaluationRunStatus } from '@/types/evaluation'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Pagination from '@/app/components/base/pagination'
import useTimestamp from '@/hooks/use-timestamp'
import { consoleClient, consoleQuery } from '@/service/client'
import { useMembers } from '@/service/use-common'
import { downloadUrl } from '@/utils/download'
import { useEvaluationResource, useEvaluationStore } from '../../store'
const PAGE_SIZE = 16
const LOADING_ROW_IDS = ['1', '2', '3', '4', '5', '6']
const CREATED_AT_FORMAT = 'YYYY-MM-DD'
type FormatTimestamp = (value: number, format: string) => string
const STATUS_ICON_CLASS_NAMES: Record<EvaluationRunStatus, string> = {
pending: 'i-ri-time-line text-text-tertiary',
running: 'i-ri-loader-4-line animate-spin text-text-accent',
completed: 'i-ri-checkbox-circle-fill text-util-colors-green-green-600',
failed: 'i-ri-close-circle-fill text-text-destructive',
cancelled: 'i-ri-forbid-2-line text-text-tertiary',
}
const formatCreatedAt = (createdAt: number | null | undefined, formatTime: FormatTimestamp) => {
if (createdAt == null)
return '-'
return formatTime(createdAt, CREATED_AT_FORMAT)
}
const getLogRunId = (record: EvaluationLog) => {
return record.id
}
const HistoryTab = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const { formatTime } = useTimestamp()
const { data: membersData } = useMembers()
const [page, setPage] = useState(0)
const resource = useEvaluationResource(resourceType, resourceId)
const setSelectedRunId = useEvaluationStore(state => state.setSelectedRunId)
const logsQuery = useQuery({
...consoleQuery.evaluation.logs.queryOptions({
input: {
params: {
targetType: resourceType,
targetId: resourceId,
},
query: {
page: page + 1,
page_size: PAGE_SIZE,
},
},
refetchOnWindowFocus: false,
}),
placeholderData: keepPreviousData,
})
const fileDownloadMutation = useMutation({
mutationFn: async (fileId: string) => {
const fileInfo = await consoleClient.evaluation.file({
params: {
targetType: resourceType,
targetId: resourceId,
fileId,
},
})
downloadUrl({ url: fileInfo.download_url, fileName: fileInfo.name })
},
})
const records = useMemo(() => logsQuery.data?.data ?? [], [logsQuery.data?.data])
const memberNameById = useMemo(() => {
return new Map((membersData?.accounts ?? []).map(member => [member.id, member.name]))
}, [membersData?.accounts])
const total = logsQuery.data?.total ?? 0
const isInitialLoading = logsQuery.isLoading && !logsQuery.data
useEffect(() => {
if (resource.selectedRunId)
return
const firstRunId = records.map(getLogRunId).find((runId): runId is string => !!runId)
if (firstRunId)
setSelectedRunId(resourceType, resourceId, firstRunId)
}, [records, resource.selectedRunId, resourceId, resourceType, setSelectedRunId])
return (
<div className="flex min-h-full flex-col">
<div className="min-h-0 flex-1 overflow-hidden">
<table className="w-full table-fixed border-collapse overflow-hidden rounded-md">
<colgroup>
<col className="w-[120px]" />
<col className="w-[120px]" />
<col className="w-[67px]" />
<col className="w-[40px]" />
</colgroup>
<thead>
<tr className="border-b border-divider-regular">
<th className="h-7 px-3 text-left system-xs-medium-uppercase text-text-tertiary">
<span className="inline-flex items-center gap-0.5">
{t('history.columns.time')}
<span aria-hidden="true" className="i-ri-arrow-down-line h-3.5 w-3.5" />
</span>
</th>
<th className="h-7 px-3 text-left system-xs-medium-uppercase text-text-tertiary">{t('history.columns.creator')}</th>
<th className="h-7 px-3 text-left system-xs-medium-uppercase text-text-tertiary">{t('history.columns.status')}</th>
<th className="h-7 text-center text-text-tertiary">
<span aria-hidden="true" className="i-ri-download-2-line inline-block h-3.5 w-3.5" />
</th>
</tr>
</thead>
<tbody>
{isInitialLoading && LOADING_ROW_IDS.map(rowId => (
<tr key={rowId} className="border-b border-divider-subtle">
<td colSpan={4} className="h-10 px-3">
<div className="h-4 animate-pulse rounded bg-background-section" />
</td>
</tr>
))}
{!isInitialLoading && records.map(record => (
<tr
key={record.id}
className={cn(
'border-b border-divider-subtle',
getLogRunId(record) && 'cursor-pointer hover:bg-state-base-hover',
getLogRunId(record) === resource.selectedRunId && 'bg-background-default-subtle',
)}
onClick={() => {
const runId = getLogRunId(record)
if (runId)
setSelectedRunId(resourceType, resourceId, runId)
}}
>
<td className="h-10 truncate px-3 system-sm-regular text-text-secondary">{formatCreatedAt(record.created_at, formatTime)}</td>
<td className="h-10 truncate px-3 system-sm-regular text-text-secondary">{memberNameById.get(record.created_by) ?? record.created_by}</td>
<td className="h-10 px-3">
<div className="flex h-10 items-center justify-center">
<span aria-label={t(`history.status.${record.status}`)} className={cn('inline-block h-4 w-4', STATUS_ICON_CLASS_NAMES[record.status])} />
</div>
</td>
<td className="h-10 text-center">
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
aria-label={t('history.actions.open')}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={event => event.stopPropagation()}
/>
)}
>
<span aria-hidden="true" className="i-ri-more-2-fill h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent popupClassName="w-[180px] rounded-lg border-[0.5px] border-components-panel-border py-1 shadow-lg">
<DropdownMenuItem
className="gap-2"
disabled={!record.dataset_file_id}
onClick={(event) => {
event.stopPropagation()
if (record.dataset_file_id)
fileDownloadMutation.mutate(record.dataset_file_id)
}}
>
<span aria-hidden="true" className="i-ri-file-download-line h-4 w-4" />
{t('history.actions.downloadTestFile')}
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2"
disabled={!record.result_file_id}
onClick={(event) => {
event.stopPropagation()
if (record.result_file_id)
fileDownloadMutation.mutate(record.result_file_id)
}}
>
<span aria-hidden="true" className="i-ri-download-2-line h-4 w-4" />
{t('history.actions.downloadResultFile')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
{!isInitialLoading && records.length === 0 && (
<div className="mt-4 rounded-2xl border border-dashed border-divider-subtle px-4 py-10 text-center system-sm-regular text-text-tertiary">
{t('history.empty')}
</div>
)}
</div>
{total > PAGE_SIZE && (
<Pagination
className="px-0 py-3"
current={page}
limit={PAGE_SIZE}
total={total}
onChange={setPage}
/>
)}
</div>
)
}
export default HistoryTab

View File

@ -1,76 +0,0 @@
'use client'
import type { BatchTestTab, EvaluationResourceProps } from '../../types'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { isEvaluationRunnable, useEvaluationResource, useEvaluationStore } from '../../store'
import { TAB_CLASS_NAME } from '../../utils'
import HistoryTab from './history-tab'
import InputFieldsTab from './input-fields-tab'
const BATCH_TABS: BatchTestTab[] = ['input-fields', 'history']
const BatchTestPanel = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const tabLabels: Record<BatchTestTab, string> = {
'input-fields': t('batch.tabs.input-fields'),
'history': t('batch.tabs.history'),
}
const resource = useEvaluationResource(resourceType, resourceId)
const setBatchTab = useEvaluationStore(state => state.setBatchTab)
const isRunnable = isEvaluationRunnable(resource)
const hasBatchConfig = !!resource.judgeModelId && resource.metrics.length > 0
return (
<div className="flex h-full min-h-0 flex-col bg-background-default">
<div className="px-6 py-4">
<div className="min-w-0">
<div className="system-xl-semibold text-text-primary">{t('batch.title')}</div>
<div className="mt-1 system-sm-regular text-text-tertiary">{t('batch.description')}</div>
</div>
{!hasBatchConfig && (
<div className="mt-4 rounded-xl border border-divider-subtle bg-components-card-bg p-3">
<div className="flex items-start gap-3">
<span aria-hidden="true" className="mt-0.5 i-ri-alert-fill h-4 w-4 shrink-0 text-text-warning" />
<div className="system-xs-regular text-text-tertiary">{t('batch.noticeDescription')}</div>
</div>
</div>
)}
</div>
<div className="border-b border-divider-subtle px-6">
<div className="flex gap-4">
{BATCH_TABS.map(tab => (
<button
key={tab}
type="button"
className={cn(
TAB_CLASS_NAME,
'flex-none rounded-none border-b-2 border-transparent px-0 pt-2 pb-2.5 uppercase',
resource.activeBatchTab === tab ? 'border-text-accent-secondary text-text-primary' : 'text-text-tertiary',
)}
onClick={() => setBatchTab(resourceType, resourceId, tab)}
>
{tabLabels[tab]}
</button>
))}
</div>
</div>
<div className={cn('min-h-0 flex-1 overflow-y-auto px-6 py-4', !hasBatchConfig && 'opacity-50')}>
{resource.activeBatchTab === 'input-fields' && (
<InputFieldsTab
resourceType={resourceType}
resourceId={resourceId}
isPanelReady={hasBatchConfig}
isRunnable={isRunnable}
/>
)}
{resource.activeBatchTab === 'history' && <HistoryTab resourceType={resourceType} resourceId={resourceId} />}
</div>
</div>
)
}
export default BatchTestPanel

View File

@ -1,65 +0,0 @@
import type { EvaluationResourceProps } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
import { EVALUATION_TEMPLATE_FILE_NAMES } from '../../store-utils'
import InputFieldsRequirements from './input-fields/input-fields-requirements'
import UploadRunPopover from './input-fields/upload-run-popover'
import { useInputFieldsActions } from './input-fields/use-input-fields-actions'
type InputFieldsTabProps = EvaluationResourceProps & {
isPanelReady: boolean
isRunnable: boolean
}
const InputFieldsTab = ({
resourceType,
resourceId,
isPanelReady,
isRunnable,
}: InputFieldsTabProps) => {
const { t } = useTranslation('evaluation')
const actions = useInputFieldsActions({
resourceType,
resourceId,
isPanelReady,
isRunnable,
templateFileName: EVALUATION_TEMPLATE_FILE_NAMES[resourceType],
})
return (
<div className="space-y-5">
<InputFieldsRequirements
inputFields={actions.templateColumns}
isLoading={actions.isTemplateColumnsLoading}
/>
<div className="space-y-3">
<Button variant="secondary" className="w-full justify-center" disabled={!actions.canDownloadTemplate} onClick={actions.handleDownloadTemplate}>
<span aria-hidden="true" className="mr-1 i-ri-download-line h-4 w-4" />
{t('batch.downloadTemplate')}
</Button>
<UploadRunPopover
open={actions.isUploadPopoverOpen}
onOpenChange={actions.setIsUploadPopoverOpen}
triggerDisabled={actions.uploadButtonDisabled}
inputFields={actions.templateColumns}
currentFileName={actions.currentFileName}
currentFileExtension={actions.currentFileExtension}
currentFileSize={actions.currentFileSize}
isFileUploading={actions.isFileUploading}
isRunDisabled={actions.isRunDisabled}
isRunning={actions.isRunning}
onUploadFile={actions.handleUploadFile}
onClearUploadedFile={actions.handleClearUploadedFile}
onRun={actions.handleRun}
/>
</div>
{!isRunnable && (
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-2 system-xs-regular text-text-tertiary">
{t('batch.validation')}
</div>
)}
</div>
)
}
export default InputFieldsTab

View File

@ -1,26 +0,0 @@
import { buildTemplateCsvContent, getExampleValue } from '../input-fields-utils'
describe('input fields utils', () => {
describe('buildTemplateCsvContent', () => {
it('should build CSV content from API columns without injecting columns', () => {
expect(buildTemplateCsvContent([
{ name: 'index', type: 'number' },
{ name: 'query', type: 'string' },
{ name: 'expected_output', type: 'string' },
])).toBe('index,query,expected_output\n')
})
it('should escape CSV column names', () => {
expect(buildTemplateCsvContent([
{ name: 'query,text', type: 'string' },
{ name: 'answer "draft"', type: 'string' },
])).toBe('"query,text","answer ""draft"""\n')
})
})
describe('getExampleValue', () => {
it('should use a row number example for index fields', () => {
expect(getExampleValue({ name: 'index', type: 'number' }, 'True')).toBe('1')
})
})
})

View File

@ -1,45 +0,0 @@
import type { InputField } from './input-fields-utils'
import { useTranslation } from 'react-i18next'
type InputFieldsRequirementsProps = {
inputFields: InputField[]
isLoading: boolean
}
const InputFieldsRequirements = ({
inputFields,
isLoading,
}: InputFieldsRequirementsProps) => {
const { t } = useTranslation('evaluation')
return (
<div>
<div className="system-md-semibold text-text-primary">{t('batch.requirementsTitle')}</div>
<div className="mt-1 system-xs-regular text-text-tertiary">{t('batch.requirementsDescription')}</div>
<div className="mt-3 rounded-xl bg-background-section p-3">
{isLoading && (
<div className="px-1 py-0.5 system-xs-regular text-text-tertiary">
{t('batch.loadingInputFields')}
</div>
)}
{!isLoading && inputFields.length === 0 && (
<div className="px-1 py-0.5 system-xs-regular text-text-tertiary">
{t('batch.noTemplateColumns')}
</div>
)}
{!isLoading && inputFields.map(field => (
<div key={field.name} className="flex items-center py-1">
<div className="rounded px-1 py-0.5 system-xs-medium text-text-tertiary">
{field.name}
</div>
<div className="text-[10px] leading-3 text-text-quaternary">
{field.type}
</div>
</div>
))}
</div>
</div>
)
}
export default InputFieldsRequirements

View File

@ -1,88 +0,0 @@
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import type { InputVar, Node } from '@/app/components/workflow/types'
import type { EvaluationTemplateColumn } from '@/types/evaluation'
import type { SnippetInputField } from '@/types/snippet'
import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { PipelineInputVarType } from '@/models/pipeline'
export type InputField = {
name: string
type: string
}
export const INDEX_FIELD_NAME = 'index'
export const getGraphNodes = (graph?: Record<string, unknown>) => {
return Array.isArray(graph?.nodes) ? graph.nodes as Node[] : []
}
export const getStartNodeInputFields = (nodes?: Node[]): InputField[] => {
const startNode = nodes?.find(node => node.data.type === BlockEnum.Start) as Node<StartNodeType> | undefined
const variables = startNode?.data.variables
if (!Array.isArray(variables))
return []
return variables
.filter((variable): variable is InputVar => typeof variable.variable === 'string' && !!variable.variable)
.map(variable => ({
name: variable.variable,
type: inputVarTypeToVarType(variable.type ?? InputVarType.textInput),
}))
}
const PIPELINE_INPUT_VAR_TYPE_TO_FIELD_TYPE: Record<PipelineInputVarType, string> = {
[PipelineInputVarType.textInput]: 'string',
[PipelineInputVarType.paragraph]: 'string',
[PipelineInputVarType.select]: 'string',
[PipelineInputVarType.number]: 'number',
[PipelineInputVarType.singleFile]: 'file',
[PipelineInputVarType.multiFiles]: 'array[file]',
[PipelineInputVarType.checkbox]: 'boolean',
}
export const getSnippetInputFields = (fields?: SnippetInputField[]): InputField[] => {
if (!Array.isArray(fields))
return []
return fields
.filter((field): field is SnippetInputField & { variable: string } =>
typeof field.variable === 'string' && !!field.variable,
)
.map(field => ({
name: field.variable,
type: typeof field.type === 'string' && field.type in PIPELINE_INPUT_VAR_TYPE_TO_FIELD_TYPE
? PIPELINE_INPUT_VAR_TYPE_TO_FIELD_TYPE[field.type as PipelineInputVarType]
: 'string',
}))
}
const escapeCsvCell = (value: string) => {
if (!/[",\n\r]/.test(value))
return value
return `"${value.replace(/"/g, '""')}"`
}
export const buildTemplateCsvContent = (columns: EvaluationTemplateColumn[]) => {
return `${columns.map(column => escapeCsvCell(column.name)).join(',')}\n`
}
export const getFileExtension = (fileName: string) => {
const extension = fileName.split('.').pop()
return extension && extension !== fileName ? extension.toUpperCase() : ''
}
export const getExampleValue = (field: InputField, booleanLabel: string) => {
if (field.name === INDEX_FIELD_NAME)
return '1'
if (field.type === 'number')
return '0.7'
if (field.type === 'boolean')
return booleanLabel
return field.name
}

View File

@ -1,187 +0,0 @@
import type { ChangeEvent, DragEvent } from 'react'
import type { InputField } from './input-fields-utils'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { getExampleValue } from './input-fields-utils'
type UploadRunPopoverProps = {
open: boolean
onOpenChange: (open: boolean) => void
triggerDisabled: boolean
triggerLabel?: string
inputFields: InputField[]
currentFileName: string | null | undefined
currentFileExtension: string
currentFileSize: string | number
isFileUploading: boolean
isRunDisabled: boolean
isRunning: boolean
onUploadFile: (file: File | undefined) => void
onClearUploadedFile: () => void
onRun: () => void
}
const UploadRunPopover = ({
open,
onOpenChange,
triggerDisabled,
triggerLabel,
inputFields,
currentFileName,
currentFileExtension,
currentFileSize,
isFileUploading,
isRunDisabled,
isRunning,
onUploadFile,
onClearUploadedFile,
onRun,
}: UploadRunPopoverProps) => {
const { t } = useTranslation('evaluation')
const { t: tCommon } = useTranslation('common')
const fileInputRef = useRef<HTMLInputElement>(null)
const previewFields = inputFields
const booleanExampleValue = t('conditions.boolean.true')
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
onUploadFile(event.target.files?.[0])
event.target.value = ''
}
const handleDropFile = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
onUploadFile(event.dataTransfer.files?.[0])
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
render={(
<Button className="w-full justify-center" variant="primary" disabled={triggerDisabled}>
{triggerLabel ?? t('batch.uploadAndRun')}
</Button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={8}
popupClassName="w-[402px] overflow-hidden rounded-lg border border-components-panel-border p-0 shadow-[0px_20px_24px_-4px_rgba(9,9,11,0.08),0px_8px_8px_-4px_rgba(9,9,11,0.03)]"
>
<div className="flex flex-col bg-components-panel-bg">
<div className="flex flex-col gap-4 p-4">
<input
ref={fileInputRef}
hidden
type="file"
accept=".csv"
onChange={handleFileChange}
/>
{currentFileName
? (
<div className="flex h-20 items-center gap-3 rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg px-3">
<div className="flex p-3">
<span aria-hidden="true" className="i-ri-file-excel-fill h-6 w-6 text-util-colors-green-green-600" />
</div>
<div className="min-w-0 flex-1 py-1 pr-2">
<div className="truncate system-xs-medium text-text-secondary">
{currentFileName}
</div>
<div className="mt-0.5 flex h-3 items-center gap-1 system-2xs-medium text-text-tertiary">
{!!currentFileExtension && <span className="uppercase">{currentFileExtension}</span>}
{!!currentFileExtension && !!currentFileSize && <span className="text-text-quaternary">·</span>}
{!!currentFileSize && <span>{currentFileSize}</span>}
</div>
</div>
<div className="flex items-center gap-1 pr-3">
{isFileUploading && (
<span aria-hidden="true" className="i-ri-loader-4-line h-4 w-4 animate-spin text-text-accent" />
)}
<button
type="button"
className="rounded-md p-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={onClearUploadedFile}
aria-label={t('batch.removeUploadedFile')}
>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</button>
</div>
</div>
)
: (
<div
className="flex h-20 w-full items-center justify-center gap-3 rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg p-3 text-left hover:border-components-button-secondary-border"
onDragOver={event => event.preventDefault()}
onDrop={handleDropFile}
>
<button
type="button"
className="flex shrink-0 p-3"
onClick={() => fileInputRef.current?.click()}
>
<span aria-hidden="true" className="i-ri-file-upload-line h-6 w-6 text-text-tertiary" />
<span className="sr-only">{t('batch.uploadTitle')}</span>
</button>
<div className="min-w-0 flex-1 text-left">
<div className="system-md-regular text-text-secondary">
{t('batch.uploadDropzonePrefix')}
{' '}
<span className="system-md-semibold">{t('batch.uploadDropzoneEmphasis')}</span>
{' '}
{t('batch.uploadDropzoneSuffix')}
</div>
<div className="mt-0.5 system-xs-regular text-text-tertiary">
<button
type="button"
className="text-text-accent hover:underline"
onClick={() => fileInputRef.current?.click()}
>
{t('batch.uploadDropzoneUploadButton')}
</button>
{' '}
{t('batch.uploadHint')}
</div>
</div>
</div>
)}
{!!previewFields.length && (
<div className="space-y-1">
<div className="system-md-semibold text-text-secondary">{t('batch.example')}</div>
<div className="flex overflow-hidden rounded-lg border border-divider-regular">
{previewFields.map((field, index) => (
<div key={field.name} className={cn('min-w-0 flex-1', index < previewFields.length - 1 && 'border-r border-divider-subtle')}>
<div className="min-h-8 border-b border-divider-regular px-3 py-2 system-xs-medium-uppercase text-text-tertiary">
{field.name}
</div>
<div className="min-h-8 px-3 py-2 system-sm-regular text-text-secondary">
{getExampleValue(field, booleanExampleValue)}
</div>
</div>
))}
</div>
</div>
)}
</div>
<div className="flex items-end justify-end gap-2 border-t border-components-panel-border px-4 py-4">
<Button variant="secondary" className="rounded-lg" onClick={() => onOpenChange(false)}>
{tCommon('operation.cancel')}
</Button>
<Button className="flex-1 justify-center rounded-lg" variant="primary" disabled={isRunDisabled} loading={isRunning} onClick={onRun}>
<span aria-hidden="true" className="mr-1 i-ri-play-fill h-5 w-5" />
{t('batch.run')}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)
}
export default UploadRunPopover

View File

@ -1,175 +0,0 @@
import type { EvaluationResourceProps } from '../../../types'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { upload } from '@/service/base'
import { useEvaluationTemplateColumns, useStartEvaluationRunMutation } from '@/service/use-evaluation'
import { formatFileSize } from '@/utils/format'
import { useEvaluationResource, useEvaluationStore } from '../../../store'
import { buildEvaluationConfigPayload, buildEvaluationRunRequest } from '../../../store-utils'
import { buildTemplateCsvContent, getFileExtension } from './input-fields-utils'
type UploadedFileMeta = {
name: string
size: number
}
type UseInputFieldsActionsParams = EvaluationResourceProps & {
isPanelReady: boolean
isRunnable: boolean
templateFileName: string
}
export const useInputFieldsActions = ({
resourceType,
resourceId,
isPanelReady,
isRunnable,
templateFileName,
}: UseInputFieldsActionsParams) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const setBatchTab = useEvaluationStore(state => state.setBatchTab)
const setSelectedRunId = useEvaluationStore(state => state.setSelectedRunId)
const setUploadedFile = useEvaluationStore(state => state.setUploadedFile)
const setUploadedFileName = useEvaluationStore(state => state.setUploadedFileName)
const startRunMutation = useStartEvaluationRunMutation()
const templateConfigPayload = useMemo(() => {
return isPanelReady ? buildEvaluationConfigPayload(resource, resourceType) : null
}, [isPanelReady, resource, resourceType])
const templateColumnsQuery = useEvaluationTemplateColumns(resourceType, resourceId, templateConfigPayload, isPanelReady)
const [isUploadPopoverOpen, setIsUploadPopoverOpen] = useState(false)
const [uploadedFileMeta, setUploadedFileMeta] = useState<UploadedFileMeta | null>(null)
const uploadMutation = useMutation({
mutationFn: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return upload({
xhr: new XMLHttpRequest(),
data: formData,
})
},
onSuccess: (uploadedFile, file) => {
setUploadedFile(resourceType, resourceId, {
id: uploadedFile.id,
name: typeof uploadedFile.name === 'string' ? uploadedFile.name : file.name,
})
},
onError: () => {
setUploadedFileMeta(null)
setUploadedFile(resourceType, resourceId, null)
toast.error(t('batch.uploadError'))
},
})
const isFileUploading = uploadMutation.isPending
const isRunning = startRunMutation.isPending
const isTemplateColumnsLoading = templateColumnsQuery.isPending || templateColumnsQuery.isFetching
const templateColumns = templateColumnsQuery.data?.columns ?? []
const uploadedFileId = resource.uploadedFileId
const currentFileName = uploadedFileMeta?.name ?? resource.uploadedFileName
const canDownloadTemplate = isPanelReady && !isTemplateColumnsLoading && templateColumns.length > 0
const isRunDisabled = !isRunnable || !uploadedFileId || isFileUploading || isRunning
const uploadButtonDisabled = !isPanelReady || isTemplateColumnsLoading || isRunning
const handleDownloadTemplate = () => {
if (templateColumnsQuery.isError) {
toast.error(t('batch.templateColumnsError'))
return
}
if (!templateColumns.length) {
toast.warning(t('batch.noTemplateColumns'))
return
}
const content = buildTemplateCsvContent(templateColumns)
const link = document.createElement('a')
link.href = `data:text/csv;charset=utf-8,${encodeURIComponent(content)}`
link.download = templateFileName
link.click()
}
const handleRun = () => {
if (!isRunnable) {
toast.warning(t('batch.validation'))
return
}
if (isFileUploading) {
toast.warning(t('batch.uploading'))
return
}
if (!uploadedFileId) {
toast.warning(t('batch.fileRequired'))
return
}
const body = buildEvaluationRunRequest(resource, uploadedFileId, resourceType)
if (!body) {
toast.warning(t('batch.validation'))
return
}
startRunMutation.mutate({
params: {
targetType: resourceType,
targetId: resourceId,
},
body,
}, {
onSuccess: (run) => {
toast.success(t('batch.runStarted'))
setSelectedRunId(resourceType, resourceId, run.id)
setIsUploadPopoverOpen(false)
setBatchTab(resourceType, resourceId, 'history')
},
onError: () => {
toast.error(t('batch.runFailed'))
},
})
}
const handleUploadFile = (file: File | undefined) => {
if (!file) {
setUploadedFileMeta(null)
setUploadedFile(resourceType, resourceId, null)
return
}
setUploadedFileMeta({
name: file.name,
size: file.size,
})
setUploadedFileName(resourceType, resourceId, file.name)
uploadMutation.mutate(file)
}
const handleClearUploadedFile = () => {
setUploadedFileMeta(null)
setUploadedFile(resourceType, resourceId, null)
}
return {
canDownloadTemplate,
currentFileExtension: currentFileName ? getFileExtension(currentFileName) : '',
currentFileName,
currentFileSize: uploadedFileMeta ? formatFileSize(uploadedFileMeta.size) : '',
handleClearUploadedFile,
handleDownloadTemplate,
handleRun,
handleUploadFile,
isFileUploading,
isRunning,
isRunDisabled,
isTemplateColumnsLoading,
isUploadPopoverOpen,
setIsUploadPopoverOpen,
templateColumns,
uploadButtonDisabled,
}
}

View File

@ -1,29 +0,0 @@
import type { EvaluationResourceType } from '../../../types'
import { useMemo } from 'react'
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
import { useAppWorkflow } from '@/service/use-workflow'
import { getSnippetInputFields, getStartNodeInputFields } from './input-fields-utils'
export const usePublishedInputFields = (
resourceType: EvaluationResourceType,
resourceId: string,
) => {
const { data: currentAppWorkflow, isLoading: isAppWorkflowLoading } = useAppWorkflow(resourceType === 'apps' ? resourceId : '')
const { data: currentSnippetWorkflow, isLoading: isSnippetWorkflowLoading } = useSnippetPublishedWorkflow(resourceType === 'snippets' ? resourceId : '')
const inputFields = useMemo(() => {
if (resourceType === 'apps')
return getStartNodeInputFields(currentAppWorkflow?.graph.nodes)
if (resourceType === 'snippets')
return getSnippetInputFields(currentSnippetWorkflow?.input_fields)
return []
}, [currentAppWorkflow?.graph.nodes, currentSnippetWorkflow?.input_fields, resourceType])
return {
inputFields,
isInputFieldsLoading: (resourceType === 'apps' && isAppWorkflowLoading)
|| (resourceType === 'snippets' && isSnippetWorkflowLoading),
}
}

View File

@ -1,84 +0,0 @@
'use client'
import type { ConditionMetricOptionGroup, EvaluationResourceProps } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useEvaluationStore } from '../../store'
import { getConditionMetricValueTypeTranslationKey } from '../../utils'
type AddConditionSelectProps = EvaluationResourceProps & {
metricOptionGroups: ConditionMetricOptionGroup[]
disabled: boolean
}
const AddConditionSelect = ({
resourceType,
resourceId,
metricOptionGroups,
disabled,
}: AddConditionSelectProps) => {
const { t } = useTranslation('evaluation')
const addCondition = useEvaluationStore(state => state.addCondition)
const [open, setOpen] = useState(false)
const handleOpenChange = (nextOpen: boolean) => {
if (disabled)
return
setOpen(nextOpen)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={(
<Button
variant="ghost-accent"
aria-label={t('conditions.addCondition')}
disabled={disabled}
>
<span aria-hidden="true" className="mr-1 i-ri-add-line h-4 w-4" />
{t('conditions.addCondition')}
</Button>
)}
/>
<PopoverContent
placement="bottom-start"
popupClassName="w-[320px] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border p-0 shadow-[0px_12px_16px_-4px_rgba(9,9,11,0.08),0px_4px_6px_-2px_rgba(9,9,11,0.03)]"
>
<div className="max-h-[360px] overflow-y-auto bg-components-panel-bg p-1" role="menu">
{metricOptionGroups.map(group => (
<div key={group.label} role="group" aria-label={group.label}>
<div className="px-3 pt-2 pb-1 system-xs-medium-uppercase text-text-tertiary">{group.label}</div>
{group.options.map(option => (
<button
key={option.id}
type="button"
role="menuitem"
className="flex h-auto w-full items-center gap-3 overflow-hidden rounded-lg px-3 py-2 text-left hover:bg-components-panel-on-panel-item-bg-hover"
onClick={() => {
addCondition(resourceType, resourceId, option.variableSelector)
setOpen(false)
}}
>
<span className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary">{option.itemLabel}</span>
<span className="ml-auto shrink-0 system-xs-medium text-text-tertiary">
{t(getConditionMetricValueTypeTranslationKey(option.valueType))}
</span>
</button>
))}
</div>
))}
</div>
</PopoverContent>
</Popover>
)
}
export default AddConditionSelect

View File

@ -1,311 +0,0 @@
'use client'
import type {
ComparisonOperator,
ConditionMetricOption,
EvaluationResourceProps,
JudgmentConditionItem,
} from '../../types'
import { cn } from '@langgenius/dify-ui/cn'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Input from '@/app/components/base/input'
import BlockIcon from '@/app/components/workflow/block-icon'
import { getAllowedOperators, requiresConditionValue, useEvaluationResource, useEvaluationStore } from '../../store'
import {
buildConditionMetricOptions,
getComparisonOperatorLabel,
getConditionMetricValueTypeTranslationKey,
groupConditionMetricOptions,
isSelectorEqual,
serializeVariableSelector,
} from '../../utils'
import { getEvaluationNodeBlockType } from '../metric-selector/utils'
type ConditionMetricLabelProps = {
metric?: ConditionMetricOption
placeholder: string
}
type ConditionMetricSelectProps = {
metric?: ConditionMetricOption
metricOptions: ConditionMetricOption[]
placeholder: string
onChange: (variableSelector: [string, string]) => void
}
type ConditionOperatorSelectProps = {
operator: ComparisonOperator
operators: ComparisonOperator[]
onChange: (operator: ComparisonOperator) => void
}
type ConditionValueInputProps = {
metric?: ConditionMetricOption
condition: JudgmentConditionItem
onChange: (value: string | string[] | boolean | null) => void
}
type ConditionGroupProps = EvaluationResourceProps
const getMetricVariableLabel = (variableName: string) => {
return variableName.replaceAll('-', '_')
}
const ConditionMetricLabel = ({
metric,
placeholder,
}: ConditionMetricLabelProps) => {
if (!metric)
return <span className="px-1 system-sm-regular text-components-input-text-placeholder">{placeholder}</span>
if (metric.kind === 'builtin' && metric.nodeInfo) {
return (
<div className="flex min-w-0 items-center px-1">
<div className="inline-flex h-6 min-w-0 items-center gap-0.5 rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark py-1 pr-1.5 pl-[5px] shadow-xs">
<span className="truncate system-xs-medium text-text-secondary">{getMetricVariableLabel(metric.variableSelector[1])}</span>
<span className="system-xs-regular text-divider-deep">/</span>
<span className="flex min-w-0 shrink-0 items-center gap-0.5">
<BlockIcon type={getEvaluationNodeBlockType(metric.nodeInfo)} size="xs" className="size-3 rounded-[5px]" />
<span className="max-w-[96px] truncate system-xs-medium text-text-secondary">{metric.itemLabel}</span>
</span>
<span className="shrink-0 system-xs-regular text-text-tertiary">{metric.valueType}</span>
</div>
</div>
)
}
return (
<div className="flex min-w-0 items-center px-1">
<div className="inline-flex h-6 min-w-0 items-center gap-0.5 rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark py-1 pr-1.5 pl-[5px] shadow-xs">
<span className="truncate system-xs-medium text-text-secondary">{metric.itemLabel}</span>
<span className="shrink-0 system-xs-regular text-text-tertiary">{metric.valueType}</span>
</div>
</div>
)
}
const ConditionMetricSelect = ({
metric,
metricOptions,
placeholder,
onChange,
}: ConditionMetricSelectProps) => {
const { t } = useTranslation('evaluation')
const groupedMetricOptions = useMemo(() => {
return groupConditionMetricOptions(metricOptions)
}, [metricOptions])
return (
<Select
value={serializeVariableSelector(metric?.variableSelector)}
onValueChange={(value) => {
const nextMetric = metricOptions.find(option => serializeVariableSelector(option.variableSelector) === value)
if (nextMetric)
onChange(nextMetric.variableSelector)
}}
>
<SelectTrigger className="h-auto bg-transparent px-1 py-1 hover:bg-transparent focus-visible:bg-transparent">
<ConditionMetricLabel metric={metric} placeholder={placeholder} />
</SelectTrigger>
<SelectContent popupClassName="w-[360px]">
{groupedMetricOptions.map(group => (
<SelectGroup key={group.label}>
<SelectLabel className="px-3 pt-2 pb-1 system-xs-medium-uppercase text-text-tertiary">{group.label}</SelectLabel>
{group.options.map(option => (
<SelectItem key={option.id} value={serializeVariableSelector(option.variableSelector)}>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate">{option.itemLabel}</span>
<span className="ml-auto shrink-0 system-xs-medium text-text-quaternary">
{t(getConditionMetricValueTypeTranslationKey(option.valueType))}
</span>
</div>
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
)
}
const ConditionOperatorSelect = ({
operator,
operators,
onChange,
}: ConditionOperatorSelectProps) => {
const { t } = useTranslation()
return (
<Select value={operator} onValueChange={value => value && onChange(value as ComparisonOperator)}>
<SelectTrigger className="h-8 w-auto min-w-[88px] gap-1 rounded-md bg-transparent px-1.5 py-0 hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
<span className="truncate system-xs-medium text-text-secondary">{getComparisonOperatorLabel(operator, t)}</span>
</SelectTrigger>
<SelectContent className="z-1002" popupClassName="w-[240px] bg-components-panel-bg-blur backdrop-blur-[10px]">
{operators.map(nextOperator => (
<SelectItem key={nextOperator} value={nextOperator}>
{getComparisonOperatorLabel(nextOperator, t)}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
const ConditionValueInput = ({
metric,
condition,
onChange,
}: ConditionValueInputProps) => {
const { t } = useTranslation('evaluation')
if (!metric || !requiresConditionValue(condition.comparisonOperator))
return null
if (metric.valueType === 'boolean') {
return (
<div className="px-2 py-1.5">
<Select value={condition.value === null ? '' : String(condition.value)} onValueChange={nextValue => onChange(nextValue === 'true')}>
<SelectTrigger className="bg-transparent hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
<SelectValue placeholder={t('conditions.selectValue')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">{t('conditions.boolean.true')}</SelectItem>
<SelectItem value="false">{t('conditions.boolean.false')}</SelectItem>
</SelectContent>
</Select>
</div>
)
}
const isMultiValue = condition.comparisonOperator === 'in' || condition.comparisonOperator === 'not in'
const inputValue = Array.isArray(condition.value)
? condition.value.join(', ')
: typeof condition.value === 'boolean'
? ''
: condition.value ?? ''
return (
<div className="px-2 py-1.5">
<Input
type={metric.valueType === 'number' && !isMultiValue ? 'number' : 'text'}
value={inputValue}
className="border-none bg-transparent shadow-none hover:border-none hover:bg-state-base-hover-alt focus:border-none focus:bg-state-base-hover-alt focus:shadow-none"
placeholder={t('conditions.valuePlaceholder')}
onChange={(e) => {
if (isMultiValue) {
onChange(e.target.value.split(',').map(item => item.trim()).filter(Boolean))
return
}
onChange(e.target.value === '' ? null : e.target.value)
}}
/>
</div>
)
}
const ConditionGroup = ({
resourceType,
resourceId,
}: ConditionGroupProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const metricOptions = useMemo(() => buildConditionMetricOptions(resource.metrics), [resource.metrics])
const logicalOperator = resource.judgmentConfig.logicalOperator
const logicalLabels = {
and: t('conditions.logical.and'),
or: t('conditions.logical.or'),
}
const hasMultipleConditions = resource.judgmentConfig.conditions.length > 1
const setConditionLogicalOperator = useEvaluationStore(state => state.setConditionLogicalOperator)
const removeCondition = useEvaluationStore(state => state.removeCondition)
const updateConditionMetric = useEvaluationStore(state => state.updateConditionMetric)
const updateConditionOperator = useEvaluationStore(state => state.updateConditionOperator)
const updateConditionValue = useEvaluationStore(state => state.updateConditionValue)
const toggleLogicalOperator = () => {
setConditionLogicalOperator(resourceType, resourceId, logicalOperator === 'and' ? 'or' : 'and')
}
return (
<div className="rounded-2xl border border-divider-subtle bg-components-card-bg p-4">
<div className={cn('relative', hasMultipleConditions && 'pl-[48px]')}>
{hasMultipleConditions && (
<div className="absolute top-0 bottom-0 left-0 w-[48px]">
<div className="absolute top-4 bottom-4 left-[34px] w-2.5 rounded-l-[8px] border border-r-0 border-divider-deep" />
<div className="absolute top-1/2 right-0 h-[29px] w-4 -translate-y-1/2 bg-components-card-bg" />
<button
type="button"
aria-label={logicalLabels[logicalOperator]}
className="absolute top-1/2 right-1 flex h-[21px] -translate-y-1/2 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-1 text-[10px] font-semibold text-text-accent-secondary shadow-xs select-none"
onClick={toggleLogicalOperator}
>
{logicalLabels[logicalOperator]}
<span aria-hidden="true" className="ml-0.5 i-ri-loop-left-line h-3 w-3" />
</button>
</div>
)}
<div className="space-y-3">
{resource.judgmentConfig.conditions.map((condition) => {
const metric = metricOptions.find(option => isSelectorEqual(option.variableSelector, condition.variableSelector))
const allowedOperators = getAllowedOperators(resource.metrics, condition.variableSelector)
const showValue = !!metric && requiresConditionValue(condition.comparisonOperator)
return (
<div key={condition.id} className="flex items-start overflow-hidden rounded-lg">
<div className="min-w-0 flex-1 rounded-lg bg-components-input-bg-normal">
<div className="flex items-center gap-0 pr-1">
<div className="min-w-0 flex-1 py-1">
<ConditionMetricSelect
metric={metric}
metricOptions={metricOptions}
placeholder={t('conditions.fieldPlaceholder')}
onChange={value => updateConditionMetric(resourceType, resourceId, condition.id, value)}
/>
</div>
<div className="h-3 w-px bg-divider-regular" />
<ConditionOperatorSelect
operator={condition.comparisonOperator}
operators={allowedOperators}
onChange={value => updateConditionOperator(resourceType, resourceId, condition.id, value)}
/>
</div>
{showValue && (
<div className="border-t border-divider-subtle">
<ConditionValueInput
metric={metric}
condition={condition}
onChange={value => updateConditionValue(resourceType, resourceId, condition.id, value)}
/>
</div>
)}
</div>
<div className="pt-1 pl-1">
<ActionButton
aria-label={t('conditions.removeCondition')}
onClick={() => removeCondition(resourceType, resourceId, condition.id)}
>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</ActionButton>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
export default ConditionGroup

View File

@ -1,51 +0,0 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useEvaluationResource } from '../../store'
import { buildConditionMetricOptions, groupConditionMetricOptions } from '../../utils'
import { InlineSectionHeader } from '../section-header'
import AddConditionSelect from './add-condition-select'
import ConditionGroup from './condition-group'
const ConditionsSection = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const conditionMetricOptions = useMemo(() => buildConditionMetricOptions(resource.metrics), [resource.metrics])
const groupedConditionMetricOptions = useMemo(() => groupConditionMetricOptions(conditionMetricOptions), [conditionMetricOptions])
const canAddCondition = conditionMetricOptions.length > 0
return (
<section className="max-w-[700px] py-4">
<InlineSectionHeader
title={t('conditions.title')}
tooltip={t('conditions.description')}
/>
<div className="mt-2 space-y-4">
{resource.judgmentConfig.conditions.length === 0 && (
<div className="rounded-xl bg-background-section px-3 py-3 system-xs-regular text-text-tertiary">
{t('conditions.emptyDescription')}
</div>
)}
{resource.judgmentConfig.conditions.length > 0 && (
<ConditionGroup
resourceType={resourceType}
resourceId={resourceId}
/>
)}
<AddConditionSelect
resourceType={resourceType}
resourceId={resourceId}
metricOptionGroups={groupedConditionMetricOptions}
disabled={!canAddCondition}
/>
</div>
</section>
)
}
export default ConditionsSection

View File

@ -1,80 +0,0 @@
'use client'
import type { EvaluationResourceProps } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useTranslation } from 'react-i18next'
import { useSaveEvaluationConfigMutation } from '@/service/use-evaluation'
import {
isEvaluationRunnable,
useEvaluationResource,
useEvaluationStore,
useIsEvaluationConfigDirty,
} from '../store'
import { buildEvaluationConfigPayload } from '../store-utils'
const EvaluationConfigActions = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const { t: tCommon } = useTranslation('common')
const resource = useEvaluationResource(resourceType, resourceId)
const isDirty = useIsEvaluationConfigDirty(resourceType, resourceId)
const resetResourceConfig = useEvaluationStore(state => state.resetResourceConfig)
const markResourceConfigSaved = useEvaluationStore(state => state.markResourceConfigSaved)
const saveConfigMutation = useSaveEvaluationConfigMutation()
const isRunnable = isEvaluationRunnable(resource)
const handleSave = () => {
if (!isRunnable) {
toast.warning(t('batch.validation'))
return
}
const body = buildEvaluationConfigPayload(resource, resourceType)
if (!body) {
toast.warning(t('batch.validation'))
return
}
saveConfigMutation.mutate({
params: {
targetType: resourceType,
targetId: resourceId,
},
body,
}, {
onSuccess: () => {
markResourceConfigSaved(resourceType, resourceId)
toast.success(tCommon('api.saved'))
},
onError: () => {
toast.error(t('config.saveFailed'))
},
})
}
return (
<div className="flex shrink-0 items-center gap-2">
<Button
variant="secondary"
disabled={!isDirty || saveConfigMutation.isPending}
onClick={() => resetResourceConfig(resourceType, resourceId)}
>
{tCommon('operation.reset')}
</Button>
<Button
variant="primary"
disabled={!isRunnable}
loading={saveConfigMutation.isPending}
onClick={handleSave}
>
{tCommon('operation.save')}
</Button>
</div>
)
}
export default EvaluationConfigActions

View File

@ -1,441 +0,0 @@
import type { EvaluationMetric } from '../../../types'
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import type { Node } from '@/app/components/workflow/types'
import type { SnippetWorkflow } from '@/types/snippet'
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types'
import CustomMetricEditorCard from '..'
import { useEvaluationStore } from '../../../store'
const mockUseAppWorkflow = vi.hoisted(() => vi.fn())
const mockUseAppDetail = vi.hoisted(() => vi.fn())
const mockUseSnippetPublishedWorkflow = vi.hoisted(() => vi.fn())
const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn())
const mockUseInfiniteScroll = vi.hoisted(() => vi.fn())
const mockPublishedGraphVariablePicker = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: (...args: unknown[]) => mockUseAppWorkflow(...args),
}))
vi.mock('@/service/use-apps', () => ({
useAppDetail: (...args: unknown[]) => mockUseAppDetail(...args),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
useSnippetPublishedWorkflow: (...args: unknown[]) => mockUseSnippetPublishedWorkflow(...args),
}))
vi.mock('@/service/use-evaluation', () => ({
useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args),
}))
vi.mock('ahooks', () => ({
useInfiniteScroll: (...args: unknown[]) => mockUseInfiniteScroll(...args),
}))
vi.mock('../published-graph-variable-picker', () => ({
default: (props: Record<string, unknown>) => {
mockPublishedGraphVariablePicker(props)
return <div data-testid="published-graph-variable-picker" />
},
}))
const createStartNode = (): Node<StartNodeType> => ({
id: 'start-node',
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.Start,
title: 'Start',
desc: '',
variables: [
{
variable: 'user_question',
label: 'User Question',
type: InputVarType.textInput,
required: true,
},
{
variable: 'retrieved_context',
label: 'Retrieved Context',
type: InputVarType.textInput,
required: true,
},
],
},
})
const createEndNode = (
outputs: EndNodeType['outputs'],
): Node<EndNodeType> => ({
id: 'end-node',
type: 'custom',
position: { x: 100, y: 0 },
data: {
type: BlockEnum.End,
title: 'End',
desc: '',
outputs,
},
})
const createCodeNode = (
id: string,
title: string,
outputs: Record<string, { type: VarType }>,
): Node<CodeNodeType> => ({
id,
type: 'custom',
position: { x: 100, y: 0 },
data: {
type: BlockEnum.Code,
title,
desc: '',
code: '',
code_language: CodeLanguage.python3,
outputs: Object.fromEntries(
Object.entries(outputs).map(([key, value]) => [
key,
{
type: value.type,
children: null,
},
]),
),
variables: [],
},
})
const createWorkflow = (
nodes: Node[],
): FetchWorkflowDraftResponse => ({
id: 'workflow-1',
graph: {
nodes,
edges: [],
},
features: {},
created_at: 1710000000,
created_by: {
id: 'user-1',
name: 'User One',
email: 'user-one@example.com',
},
hash: 'hash-1',
updated_at: 1710000001,
updated_by: {
id: 'user-2',
name: 'User Two',
email: 'user-two@example.com',
},
tool_published: true,
environment_variables: [],
conversation_variables: [],
version: '1',
marked_name: 'Evaluation Workflow',
marked_comment: 'Published',
})
const createSnippetWorkflow = (
nodes: Node[],
): SnippetWorkflow => ({
id: 'snippet-workflow-1',
graph: {
nodes,
edges: [],
},
features: {},
hash: 'snippet-hash-1',
created_at: 1710000000,
updated_at: 1710000001,
})
const createMetric = (): EvaluationMetric => ({
id: 'metric-1',
optionId: 'custom-1',
kind: 'custom-workflow',
label: 'Custom Evaluator',
description: 'Map workflow variables to your evaluation inputs.',
valueType: 'number',
customConfig: {
workflowId: 'workflow-1',
workflowAppId: 'workflow-app-1',
workflowName: 'Evaluation Workflow',
mappings: [{
id: 'mapping-1',
inputVariableId: 'user_question',
outputVariableId: 'current-node.answer',
}, {
id: 'mapping-2',
inputVariableId: 'retrieved_context',
outputVariableId: 'current-node.score',
}],
outputs: [],
},
})
describe('CustomMetricEditorCard', () => {
beforeEach(() => {
vi.clearAllMocks()
useEvaluationStore.setState({ resources: {} })
mockPublishedGraphVariablePicker.mockReset()
mockUseAppDetail.mockReturnValue({ data: undefined })
mockUseInfiniteScroll.mockImplementation(() => undefined)
mockUseAvailableEvaluationWorkflows.mockReturnValue({
data: {
pages: [{
items: [],
page: 1,
limit: 20,
has_more: false,
}],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
})
mockUseSnippetPublishedWorkflow.mockReturnValue({ data: undefined })
})
afterEach(() => {
vi.restoreAllMocks()
})
// Verify the selected evaluation workflow still drives the output summary section.
describe('Outputs', () => {
it('should render the selected workflow outputs from the end node', () => {
const selectedWorkflow = createWorkflow([
createStartNode(),
createEndNode([
{ variable: 'answer_score', value_selector: ['end', 'answer_score'], value_type: VarType.number },
{ variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string },
]),
])
const currentAppWorkflow = createWorkflow([
createCodeNode('current-node', 'Current Node', {
answer: { type: VarType.string },
score: { type: VarType.number },
}),
])
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
if (appId === 'app-under-test')
return { data: currentAppWorkflow }
return { data: undefined }
})
render(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-under-test"
metric={createMetric()}
/>,
)
expect(screen.getByText('evaluation.metrics.custom.outputTitle')).toBeInTheDocument()
expect(screen.getAllByText('answer_score').length).toBeGreaterThan(0)
expect(screen.getAllByText('number').length).toBeGreaterThan(0)
expect(screen.getAllByText('reason').length).toBeGreaterThan(0)
expect(screen.getAllByText('string').length).toBeGreaterThan(0)
})
it('should hide the output section when the selected workflow has no end outputs', () => {
const selectedWorkflow = createWorkflow([
createStartNode(),
createEndNode([]),
])
const currentAppWorkflow = createWorkflow([
createCodeNode('current-node', 'Current Node', {
answer: { type: VarType.string },
}),
])
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
if (appId === 'app-under-test')
return { data: currentAppWorkflow }
return { data: undefined }
})
render(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-under-test"
metric={createMetric()}
/>,
)
expect(screen.queryByText('evaluation.metrics.custom.outputTitle')).not.toBeInTheDocument()
})
})
// Verify mapping rows use workflow start variables on the left and current published graph variables on the right.
describe('Variable Mapping', () => {
it('should preserve saved mappings and outputs while the selected workflow is loading', () => {
const baseMetric = createMetric()
const metric = {
...baseMetric,
customConfig: {
...baseMetric.customConfig!,
outputs: [{ id: 'score', valueType: 'number' }],
},
}
const syncMappingsSpy = vi.spyOn(useEvaluationStore.getState(), 'syncCustomMetricMappings')
const syncOutputsSpy = vi.spyOn(useEvaluationStore.getState(), 'syncCustomMetricOutputs')
mockUseAppWorkflow.mockReturnValue({ data: undefined })
render(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-under-test"
metric={metric}
/>,
)
expect(screen.getByText('Evaluation Workflow')).toBeInTheDocument()
expect(syncMappingsSpy).not.toHaveBeenCalled()
expect(syncOutputsSpy).not.toHaveBeenCalled()
})
it('should show the selected workflow app name from app detail when the config only has workflow id', () => {
const selectedWorkflow = {
...createWorkflow([createStartNode()]),
marked_name: '',
}
const baseMetric = createMetric()
const metric = {
...baseMetric,
customConfig: {
...baseMetric.customConfig!,
workflowName: null,
},
}
mockUseAppDetail.mockReturnValue({
data: {
id: 'workflow-app-1',
name: 'Review Workflow App',
},
})
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
return { data: undefined }
})
render(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-under-test"
metric={metric}
/>,
)
expect(mockUseAppDetail).toHaveBeenCalledWith('workflow-app-1')
expect(screen.getByText('Review Workflow App')).toBeInTheDocument()
expect(screen.queryByText('workflow-1')).not.toBeInTheDocument()
})
it('should pass the current app published graph and saved selector values to the picker', () => {
const selectedWorkflow = createWorkflow([
createStartNode(),
createEndNode([
{ variable: 'answer_score', value_selector: ['end', 'answer_score'], value_type: VarType.number },
{ variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string },
]),
])
const currentAppWorkflow = createWorkflow([
createStartNode(),
createCodeNode('current-node', 'Current Node', {
answer: { type: VarType.string },
score: { type: VarType.number },
}),
])
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
if (appId === 'app-under-test')
return { data: currentAppWorkflow }
return { data: undefined }
})
render(
<CustomMetricEditorCard
resourceType="apps"
resourceId="app-under-test"
metric={createMetric()}
/>,
)
expect(screen.getByText('user_question')).toBeInTheDocument()
expect(screen.getByText('retrieved_context')).toBeInTheDocument()
expect(screen.getAllByText('string')).toHaveLength(3)
expect(mockPublishedGraphVariablePicker).toHaveBeenCalledTimes(2)
expect(mockPublishedGraphVariablePicker.mock.calls[0][0]).toMatchObject({
nodes: currentAppWorkflow.graph.nodes,
edges: currentAppWorkflow.graph.edges,
value: 'current-node.answer',
})
expect(mockPublishedGraphVariablePicker.mock.calls[1][0]).toMatchObject({
nodes: currentAppWorkflow.graph.nodes,
edges: currentAppWorkflow.graph.edges,
value: 'current-node.score',
})
})
it('should use the current snippet published graph when editing a snippet evaluation', () => {
const selectedWorkflow = createWorkflow([
createStartNode(),
createEndNode([
{ variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string },
]),
])
const currentSnippetWorkflow = createSnippetWorkflow([
createCodeNode('snippet-node', 'Snippet Node', {
result: { type: VarType.string },
}),
])
mockUseAppWorkflow.mockImplementation((appId: string) => {
if (appId === 'workflow-app-1')
return { data: selectedWorkflow }
return { data: undefined }
})
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: currentSnippetWorkflow,
})
render(
<CustomMetricEditorCard
resourceType="snippets"
resourceId="snippet-under-test"
metric={createMetric()}
/>,
)
expect(mockPublishedGraphVariablePicker).toHaveBeenCalledTimes(2)
expect(mockPublishedGraphVariablePicker.mock.calls[0][0]).toMatchObject({
nodes: currentSnippetWorkflow.graph.nodes,
edges: currentSnippetWorkflow.graph.edges,
})
})
})
})

View File

@ -1,176 +0,0 @@
import type { ComponentProps } from 'react'
import type { AvailableEvaluationWorkflow } from '@/types/evaluation'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import WorkflowSelector from '../workflow-selector'
const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn())
const mockUseInfiniteScroll = vi.hoisted(() => vi.fn())
let loadMoreHandler: (() => Promise<{ list: unknown[] }>) | null = null
vi.mock('@/service/use-evaluation', () => ({
useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args),
}))
vi.mock('ahooks', () => ({
useInfiniteScroll: (...args: unknown[]) => mockUseInfiniteScroll(...args),
}))
const createWorkflow = (
overrides: Partial<AvailableEvaluationWorkflow> = {},
): AvailableEvaluationWorkflow => ({
id: 'workflow-1',
app_id: 'app-1',
app_name: 'Review Workflow App',
type: 'evaluation',
version: '1',
marked_name: 'Review Workflow',
marked_comment: 'Production release',
hash: 'hash-1',
created_by: {
id: 'user-1',
name: 'User One',
email: 'user-one@example.com',
},
created_at: 1710000000,
updated_by: null,
updated_at: 1710000000,
...overrides,
})
const setupWorkflowQueryMock = (overrides?: {
workflows?: AvailableEvaluationWorkflow[]
hasNextPage?: boolean
isFetchingNextPage?: boolean
}) => {
const fetchNextPage = vi.fn()
mockUseAvailableEvaluationWorkflows.mockReturnValue({
data: {
pages: [{
items: overrides?.workflows ?? [createWorkflow()],
page: 1,
limit: 20,
has_more: overrides?.hasNextPage ?? false,
}],
},
fetchNextPage,
hasNextPage: overrides?.hasNextPage ?? false,
isFetching: false,
isFetchingNextPage: overrides?.isFetchingNextPage ?? false,
isLoading: false,
})
return { fetchNextPage }
}
const renderWorkflowSelector = (props?: Partial<ComponentProps<typeof WorkflowSelector>>) => {
return render(
<WorkflowSelector
value={null}
onSelect={vi.fn()}
{...props}
/>,
)
}
describe('WorkflowSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
loadMoreHandler = null
setupWorkflowQueryMock()
mockUseInfiniteScroll.mockImplementation((handler) => {
loadMoreHandler = handler as () => Promise<{ list: unknown[] }>
})
})
// Cover trigger rendering and selected label fallback.
describe('Rendering', () => {
it('should render the workflow placeholder when value is empty', () => {
renderWorkflowSelector()
expect(screen.getByRole('button', { name: 'evaluation.metrics.custom.workflowLabel' })).toBeInTheDocument()
expect(screen.getByText('evaluation.metrics.custom.workflowPlaceholder')).toBeInTheDocument()
})
it('should render the selected workflow name from props when value is set', () => {
setupWorkflowQueryMock({ workflows: [] })
renderWorkflowSelector({
value: 'app-1',
selectedWorkflowName: 'Saved Review Workflow',
})
expect(screen.getByText('Saved Review Workflow')).toBeInTheDocument()
})
it('should resolve the selected workflow from app id', () => {
setupWorkflowQueryMock()
renderWorkflowSelector({
value: 'app-1',
})
expect(screen.getByText('Review Workflow')).toBeInTheDocument()
})
})
// Cover opening the popover and choosing one workflow option.
describe('Interactions', () => {
it('should call onSelect with the clicked workflow', async () => {
const onSelect = vi.fn()
renderWorkflowSelector({ onSelect })
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.custom.workflowLabel' }))
const option = await screen.findByRole('option', { name: 'Review Workflow' })
fireEvent.click(option)
expect(onSelect).toHaveBeenCalledWith(createWorkflow())
})
it('should mark the option selected when its app id matches the value', async () => {
renderWorkflowSelector({ value: 'app-1' })
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.custom.workflowLabel' }))
expect(await screen.findByRole('option', { name: 'Review Workflow', selected: true })).toBeInTheDocument()
})
})
// Cover the infinite-scroll callback used by the ScrollArea viewport.
describe('Pagination', () => {
it('should fetch the next page when the load-more callback runs and more pages exist', async () => {
const { fetchNextPage } = setupWorkflowQueryMock({ hasNextPage: true })
renderWorkflowSelector()
await waitFor(() => expect(loadMoreHandler).not.toBeNull())
await act(async () => {
await loadMoreHandler?.()
})
expect(fetchNextPage).toHaveBeenCalledTimes(1)
})
it('should not fetch the next page when the current request is already fetching', async () => {
const { fetchNextPage } = setupWorkflowQueryMock({
hasNextPage: true,
isFetchingNextPage: true,
})
renderWorkflowSelector()
await waitFor(() => expect(loadMoreHandler).not.toBeNull())
await act(async () => {
await loadMoreHandler?.()
})
expect(fetchNextPage).not.toHaveBeenCalled()
})
})
})

View File

@ -1,222 +0,0 @@
'use client'
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import type { Edge, InputVar, Node } from '@/app/components/workflow/types'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { useAppDetail } from '@/service/use-apps'
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
import { useAppWorkflow } from '@/service/use-workflow'
import { isCustomMetricConfigured, useEvaluationStore } from '../../store'
import MappingRow from './mapping-row'
import WorkflowSelector from './workflow-selector'
type CustomMetricEditorCardProps = EvaluationResourceProps & {
metric: EvaluationMetric
}
const getWorkflowInputVariables = (
nodes?: Array<Node>,
) => {
const startNode = nodes?.find(node => node.data.type === BlockEnum.Start) as Node<StartNodeType> | undefined
if (!startNode || !Array.isArray(startNode.data.variables))
return []
return startNode.data.variables.map((variable: InputVar) => ({
id: variable.variable,
valueType: inputVarTypeToVarType(variable.type ?? InputVarType.textInput),
}))
}
const getWorkflowOutputs = (nodes?: Array<Node>) => {
return (nodes ?? [])
.filter(node => node.data.type === BlockEnum.End)
.flatMap((node) => {
const endNode = node as Node<EndNodeType>
if (!Array.isArray(endNode.data.outputs))
return []
return endNode.data.outputs
.filter(output => typeof output.variable === 'string' && !!output.variable)
.map(output => ({
id: output.variable,
valueType: typeof output.value_type === 'string' ? output.value_type : null,
nodeId: endNode.id,
nodeTitle: typeof endNode.data.title === 'string' && endNode.data.title ? endNode.data.title : 'End',
}))
})
}
const getWorkflowName = (workflow: {
marked_name?: string
app_name?: string
id: string
}) => {
return workflow.marked_name || workflow.app_name || workflow.id
}
const getGraphNodes = (graph?: Record<string, unknown>) => {
return Array.isArray(graph?.nodes) ? graph.nodes as Node[] : []
}
const getGraphEdges = (graph?: Record<string, unknown>) => {
return Array.isArray(graph?.edges) ? graph.edges as Edge[] : []
}
const CustomMetricEditorCard = ({
resourceType,
resourceId,
metric,
}: CustomMetricEditorCardProps) => {
const { t } = useTranslation('evaluation')
const setCustomMetricWorkflow = useEvaluationStore(state => state.setCustomMetricWorkflow)
const syncCustomMetricMappings = useEvaluationStore(state => state.syncCustomMetricMappings)
const syncCustomMetricOutputs = useEvaluationStore(state => state.syncCustomMetricOutputs)
const updateCustomMetricMapping = useEvaluationStore(state => state.updateCustomMetricMapping)
const selectedWorkflowAppId = metric.customConfig?.workflowAppId ?? metric.customConfig?.workflowId ?? ''
const { data: selectedWorkflowApp } = useAppDetail(selectedWorkflowAppId)
const { data: selectedWorkflow } = useAppWorkflow(selectedWorkflowAppId)
const { data: currentAppWorkflow } = useAppWorkflow(resourceType === 'apps' ? resourceId : '')
const { data: currentSnippetWorkflow } = useSnippetPublishedWorkflow(resourceType === 'snippets' ? resourceId : '')
const inputVariables = useMemo(() => {
return getWorkflowInputVariables(selectedWorkflow?.graph.nodes)
}, [selectedWorkflow?.graph.nodes])
const workflowOutputs = useMemo(() => {
return getWorkflowOutputs(selectedWorkflow?.graph.nodes)
}, [selectedWorkflow?.graph.nodes])
const publishedGraph = useMemo(() => {
if (resourceType === 'apps') {
return {
nodes: currentAppWorkflow?.graph.nodes ?? [],
edges: currentAppWorkflow?.graph.edges ?? [],
environmentVariables: currentAppWorkflow?.environment_variables ?? [],
conversationVariables: currentAppWorkflow?.conversation_variables ?? [],
}
}
return {
nodes: getGraphNodes(currentSnippetWorkflow?.graph),
edges: getGraphEdges(currentSnippetWorkflow?.graph),
environmentVariables: [],
conversationVariables: [],
}
}, [
currentAppWorkflow?.conversation_variables,
currentAppWorkflow?.environment_variables,
currentAppWorkflow?.graph.edges,
currentAppWorkflow?.graph.nodes,
currentSnippetWorkflow?.graph,
resourceType,
])
const inputVariableIds = useMemo(() => inputVariables.map(variable => variable.id), [inputVariables])
const isConfigured = isCustomMetricConfigured(metric)
const isSelectedWorkflowLoaded = !!selectedWorkflow
useEffect(() => {
if (!metric.customConfig?.workflowId || !isSelectedWorkflowLoaded)
return
const currentInputVariableIds = metric.customConfig.mappings
.map(mapping => mapping.inputVariableId)
.filter((value): value is string => !!value)
if (currentInputVariableIds.length === inputVariableIds.length
&& currentInputVariableIds.every((value, index) => value === inputVariableIds[index])) {
return
}
syncCustomMetricMappings(resourceType, resourceId, metric.id, inputVariableIds)
}, [inputVariableIds, isSelectedWorkflowLoaded, metric.customConfig?.mappings, metric.customConfig?.workflowId, metric.id, resourceId, resourceType, syncCustomMetricMappings])
useEffect(() => {
if (!metric.customConfig?.workflowId || !isSelectedWorkflowLoaded)
return
const currentOutputs = metric.customConfig.outputs
if (
currentOutputs.length === workflowOutputs.length
&& currentOutputs.every((output, index) =>
output.id === workflowOutputs[index]?.id && output.valueType === workflowOutputs[index]?.valueType,
)
) {
return
}
syncCustomMetricOutputs(resourceType, resourceId, metric.id, workflowOutputs)
}, [isSelectedWorkflowLoaded, metric.customConfig?.outputs, metric.customConfig?.workflowId, metric.id, resourceId, resourceType, syncCustomMetricOutputs, workflowOutputs])
if (!metric.customConfig)
return null
return (
<div className="px-3 pt-1 pb-3">
<WorkflowSelector
value={metric.customConfig.workflowId}
selectedWorkflowName={metric.customConfig.workflowName ?? selectedWorkflowApp?.name ?? null}
onSelect={workflow => setCustomMetricWorkflow(resourceType, resourceId, metric.id, {
workflowId: workflow.app_id,
workflowAppId: workflow.app_id,
workflowName: getWorkflowName(workflow),
})}
/>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('metrics.custom.mappingTitle')}</div>
</div>
<div className="space-y-2">
{inputVariables.map((inputVariable) => {
const mapping = metric.customConfig?.mappings.find(item => item.inputVariableId === inputVariable.id)
return (
<MappingRow
key={inputVariable.id}
inputVariable={inputVariable}
publishedGraph={publishedGraph}
value={mapping?.outputVariableId ?? null}
onUpdate={(outputVariableId) => {
if (!mapping)
return
updateCustomMetricMapping(resourceType, resourceId, metric.id, mapping.id, { outputVariableId })
}}
/>
)
})}
</div>
{!isConfigured && (
<div className="mt-3 rounded-lg bg-background-section px-3 py-2 system-xs-regular text-text-tertiary">
{t('metrics.custom.mappingWarning')}
</div>
)}
</div>
{!!workflowOutputs.length && (
<div className="mt-4 py-1">
<div className="min-h-6 system-xs-medium-uppercase text-text-tertiary">
{t('metrics.custom.outputTitle')}
</div>
<div className="flex flex-wrap items-center gap-y-1 px-2 py-2 system-xs-regular text-text-tertiary">
{workflowOutputs.map((output, index) => (
<div key={`${output.nodeId}-${output.id}`} className="flex items-center">
<span className="px-1 system-xs-medium text-text-secondary">{output.id}</span>
{output.valueType && (
<span>{output.valueType}</span>
)}
{index < workflowOutputs.length - 1 && (
<span className="pl-0.5">,</span>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}
export default CustomMetricEditorCard

View File

@ -1,64 +0,0 @@
'use client'
import type {
ConversationVariable,
Edge,
EnvironmentVariable,
Node,
} from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import PublishedGraphVariablePicker from './published-graph-variable-picker'
type MappingRowProps = {
inputVariable: {
id: string
valueType: string
}
publishedGraph: {
nodes: Node[]
edges: Edge[]
environmentVariables: EnvironmentVariable[]
conversationVariables: ConversationVariable[]
}
value: string | null
onUpdate: (outputVariableId: string | null) => void
}
const MappingRow = ({
inputVariable,
publishedGraph,
value,
onUpdate,
}: MappingRowProps) => {
const { t } = useTranslation('evaluation')
return (
<div className="flex items-center">
<div className="flex h-8 w-[200px] items-center rounded-md px-2">
<div className="flex min-w-0 items-center gap-0.5 px-1">
<Variable02 className="h-3.5 w-3.5 shrink-0 text-text-accent" />
<div className="truncate system-xs-medium text-text-secondary">{inputVariable.id}</div>
<div className="shrink-0 system-xs-regular text-text-tertiary">{inputVariable.valueType}</div>
</div>
</div>
<div className="flex h-8 w-9 items-center justify-center px-3 system-xs-medium text-text-tertiary">
<span aria-hidden="true"></span>
</div>
<PublishedGraphVariablePicker
className="grow"
nodes={publishedGraph.nodes}
edges={publishedGraph.edges}
environmentVariables={publishedGraph.environmentVariables}
conversationVariables={publishedGraph.conversationVariables}
value={value}
placeholder={t('metrics.custom.outputPlaceholder')}
onChange={onUpdate}
/>
</div>
)
}
export default MappingRow

View File

@ -1,118 +0,0 @@
'use client'
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
import type {
ConversationVariable,
Edge,
EnvironmentVariable,
Node,
ValueSelector,
} from '@/app/components/workflow/types'
import { useMemo } from 'react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { WorkflowContext } from '@/app/components/workflow/context'
import { createHooksStore, HooksStoreContext } from '@/app/components/workflow/hooks-store'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
import { BlockEnum } from '@/app/components/workflow/types'
import { variableTransformer } from '@/app/components/workflow/utils/variable'
type PublishedGraphVariablePickerProps = {
className?: string
nodes: Node[]
edges: Edge[]
environmentVariables?: EnvironmentVariable[]
conversationVariables?: ConversationVariable[]
placeholder: string
value: string | null
onChange: (value: string | null) => void
}
const PICKER_NODE_ID = '__evaluation-variable-picker__'
const createPickerNode = (): Node<EndNodeType> => ({
id: PICKER_NODE_ID,
type: 'custom',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.End,
title: 'End',
desc: '',
outputs: [],
},
})
const PublishedGraphVariablePicker = ({
className,
nodes,
edges,
environmentVariables = [],
conversationVariables = [],
placeholder,
value,
onChange,
}: PublishedGraphVariablePickerProps) => {
const workflowStore = useMemo(() => {
const store = createWorkflowStore({})
store.setState({
isWorkflowDataLoaded: true,
environmentVariables,
conversationVariables,
ragPipelineVariables: [],
dataSourceList: [],
})
return store
}, [conversationVariables, environmentVariables])
const hooksStore = useMemo(() => createHooksStore({}), [])
const pickerNodes = useMemo(() => {
return [...nodes, createPickerNode()]
}, [nodes])
const pickerValue = useMemo<ValueSelector>(() => {
if (!value)
return []
return variableTransformer(value) as ValueSelector
}, [value])
return (
<WorkflowContext.Provider value={workflowStore}>
<HooksStoreContext.Provider value={hooksStore}>
<div id="workflow-container" className={className}>
<ReactFlowProvider>
<div
aria-hidden="true"
className="pointer-events-none absolute h-px w-px overflow-hidden opacity-0"
>
<div style={{ width: 800, height: 600 }}>
<ReactFlow nodes={pickerNodes} edges={edges} fitView />
</div>
</div>
<VarReferencePicker
className="grow"
nodeId={PICKER_NODE_ID}
readonly={!nodes.length}
isShowNodeName
value={pickerValue}
onChange={(nextValue) => {
if (!Array.isArray(nextValue) || !nextValue.length) {
onChange(null)
return
}
onChange(nextValue.join('.'))
}}
availableNodes={nodes}
placeholder={placeholder}
/>
</ReactFlowProvider>
</div>
</HooksStoreContext.Provider>
</WorkflowContext.Provider>
)
}
export default PublishedGraphVariablePicker

View File

@ -1,219 +0,0 @@
'use client'
import type { AvailableEvaluationWorkflow } from '@/types/evaluation'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import {
ScrollAreaContent,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { useInfiniteScroll } from 'ahooks'
import * as React from 'react'
import { useDeferredValue, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Loading from '@/app/components/base/loading'
import { useAvailableEvaluationWorkflows } from '@/service/use-evaluation'
type WorkflowSelectorProps = {
value: string | null
selectedWorkflowName?: string | null
onSelect: (workflow: AvailableEvaluationWorkflow) => void
}
const PAGE_SIZE = 20
const getWorkflowName = (workflow: AvailableEvaluationWorkflow) => {
return workflow.marked_name || workflow.app_name || workflow.id
}
const isSelectedWorkflow = (
workflow: AvailableEvaluationWorkflow,
value: string | null,
) => workflow.app_id === value
const WorkflowSelector = ({
value,
selectedWorkflowName,
onSelect,
}: WorkflowSelectorProps) => {
const { t } = useTranslation('evaluation')
const [isOpen, setIsOpen] = useState(false)
const [searchText, setSearchText] = useState('')
const deferredSearchText = useDeferredValue(searchText)
const viewportRef = useRef<HTMLDivElement>(null)
const keyword = deferredSearchText.trim() || undefined
const {
data,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
isLoading,
} = useAvailableEvaluationWorkflows(
{
page: 1,
limit: PAGE_SIZE,
keyword,
},
{ enabled: isOpen },
)
const workflows = useMemo(() => {
return (data?.pages ?? []).flatMap(page => page.items)
}, [data?.pages])
const currentWorkflowName = useMemo(() => {
if (!value)
return null
const selectedWorkflow = workflows.find(workflow => isSelectedWorkflow(workflow, value))
if (selectedWorkflow)
return getWorkflowName(selectedWorkflow)
return selectedWorkflowName ?? null
}, [selectedWorkflowName, value, workflows])
const isNoMore = hasNextPage === false
useInfiniteScroll(
async () => {
if (!hasNextPage || isFetchingNextPage)
return { list: [] }
await fetchNextPage()
return { list: [] }
},
{
target: viewportRef,
isNoMore: () => isNoMore,
reloadDeps: [isFetchingNextPage, isNoMore, keyword],
},
)
const handleOpenChange = (nextOpen: boolean) => {
setIsOpen(nextOpen)
if (!nextOpen)
setSearchText('')
}
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={(
<button
type="button"
className="group flex w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 text-left outline-hidden hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal"
aria-label={t('metrics.custom.workflowLabel')}
>
<div className="flex min-w-0 grow items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
<div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<span aria-hidden="true" className="i-ri-equalizer-2-line h-3.5 w-3.5 text-text-tertiary" />
</div>
</div>
<div className="min-w-0 flex-1 px-1 py-1 text-left">
<div className={cn(
'truncate system-sm-regular',
currentWorkflowName ? 'text-text-secondary' : 'text-components-input-text-placeholder',
)}
>
{currentWorkflowName ?? t('metrics.custom.workflowPlaceholder')}
</div>
</div>
</div>
<span className="shrink-0 px-1 text-text-quaternary transition-colors group-hover:text-text-secondary">
<span aria-hidden="true" className="i-ri-arrow-down-s-line h-4 w-4" />
</span>
</button>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[360px] overflow-hidden p-0"
>
<div className="bg-components-panel-bg">
<div className="p-2 pb-1">
<Input
showLeftIcon
showClearIcon
value={searchText}
onChange={event => setSearchText(event.target.value)}
onClear={() => setSearchText('')}
/>
</div>
{(isLoading || (isFetching && workflows.length === 0))
? (
<div className="flex h-[120px] items-center justify-center">
<Loading type="area" />
</div>
)
: !workflows.length
? (
<div className="flex h-[120px] items-center justify-center system-sm-regular text-text-tertiary">
{t('noData', { ns: 'common' })}
</div>
)
: (
<ScrollAreaRoot className="relative max-h-[240px] overflow-hidden">
<ScrollAreaViewport ref={viewportRef}>
<ScrollAreaContent className="p-1" role="listbox" aria-label={t('metrics.custom.workflowLabel')}>
{workflows.map(workflow => (
<button
key={workflow.id}
type="button"
role="option"
aria-selected={isSelectedWorkflow(workflow, value)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1 text-left hover:bg-state-base-hover"
onClick={() => {
onSelect(workflow)
setIsOpen(false)
setSearchText('')
}}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
<div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<span aria-hidden="true" className="i-ri-equalizer-2-line h-3.5 w-3.5 text-text-tertiary" />
</div>
</div>
<div className="min-w-0 flex-1 truncate px-1 py-1 system-sm-medium text-text-secondary">
{getWorkflowName(workflow)}
</div>
{isSelectedWorkflow(workflow, value) && (
<span aria-hidden="true" className="i-ri-check-line h-4 w-4 shrink-0 text-text-accent" />
)}
</button>
))}
{isFetchingNextPage && (
<div className="flex justify-center px-3 py-2">
<Loading />
</div>
)}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="vertical">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
)}
</div>
</PopoverContent>
</Popover>
)
}
export default React.memo(WorkflowSelector)

View File

@ -1,30 +0,0 @@
'use client'
import type { EvaluationResourceProps } from '../types'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { useEvaluationResource, useEvaluationStore } from '../store'
import { decodeModelSelection, encodeModelSelection } from '../utils'
const JudgeModelSelector = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { data: modelList } = useModelList(ModelTypeEnum.textGeneration)
const resource = useEvaluationResource(resourceType, resourceId)
const setJudgeModel = useEvaluationStore(state => state.setJudgeModel)
const selectedModel = decodeModelSelection(resource.judgeModelId)
return (
<ModelSelector
defaultModel={selectedModel}
modelList={modelList}
onSelect={model => setJudgeModel(resourceType, resourceId, encodeModelSelection(model.provider, model.model))}
showDeprecatedWarnIcon
triggerClassName="h-8 w-full rounded-lg"
/>
)
}
export default JudgeModelSelector

View File

@ -1,64 +0,0 @@
'use client'
import type { NonPipelineEvaluationResourceProps } from '../../types'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import BatchTestPanel from '../batch-test-panel'
import ConditionsSection from '../conditions-section'
import EvaluationConfigActions from '../config-actions'
import JudgeModelSelector from '../judge-model-selector'
import MetricSection from '../metric-section'
import SectionHeader, { InlineSectionHeader } from '../section-header'
const NonPipelineEvaluation = ({
resourceType,
resourceId,
}: NonPipelineEvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const { t: tCommon } = useTranslation('common')
const docLink = useDocLink()
return (
<div className="flex h-full min-h-0 flex-col bg-background-default xl:flex-row">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="flex min-h-full max-w-[748px] flex-col px-6 py-4">
<SectionHeader
title={t('title')}
description={(
<>
{t('description')}
{' '}
<a
className="text-text-accent"
href={docLink()}
target="_blank"
rel="noopener noreferrer"
>
{tCommon('operation.learnMore')}
</a>
</>
)}
descriptionClassName="max-w-[700px]"
action={<EvaluationConfigActions resourceType={resourceType} resourceId={resourceId} />}
/>
<section className="max-w-[700px] py-4">
<InlineSectionHeader title={t('judgeModel.title')} tooltip={t('judgeModel.description')} />
<div className="mt-1.5">
<JudgeModelSelector resourceType={resourceType} resourceId={resourceId} />
</div>
</section>
<div className="max-w-[700px] border-b border-divider-subtle" />
<MetricSection resourceType={resourceType} resourceId={resourceId} />
<div className="max-w-[700px] border-b border-divider-subtle" />
<ConditionsSection resourceType={resourceType} resourceId={resourceId} />
</div>
</div>
<div className="h-[420px] shrink-0 border-t border-divider-subtle xl:h-auto xl:w-[450px] xl:border-t-0 xl:border-l">
<BatchTestPanel resourceType={resourceType} resourceId={resourceId} />
</div>
</div>
)
}
export default NonPipelineEvaluation

View File

@ -1,97 +0,0 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import { useEvaluationStore } from '../../store'
import HistoryTab from '../batch-test-panel/history-tab'
import EvaluationConfigActions from '../config-actions'
import JudgeModelSelector from '../judge-model-selector'
import PipelineBatchActions from '../pipeline/pipeline-batch-actions'
import PipelineMetricsSection from '../pipeline/pipeline-metrics-section'
import PipelineResultsPanel from '../pipeline/pipeline-results-panel'
import SectionHeader, { InlineSectionHeader } from '../section-header'
const PipelineEvaluation = ({
resourceType,
resourceId,
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const { t: tCommon } = useTranslation('common')
const docLink = useDocLink()
const ensureResource = useEvaluationStore(state => state.ensureResource)
useEffect(() => {
ensureResource(resourceType, resourceId)
}, [ensureResource, resourceId, resourceType])
return (
<div className="flex h-full min-h-0 flex-col overflow-y-auto bg-background-default xl:flex-row xl:overflow-hidden">
<div className="flex shrink-0 flex-col border-b border-divider-subtle bg-background-default xl:min-h-0 xl:w-[450px] xl:border-r xl:border-b-0">
<div className="px-6 pt-4 pb-2">
<SectionHeader
title={t('title')}
description={(
<>
{t('description')}
{' '}
<a
className="text-text-accent"
href={docLink()}
target="_blank"
rel="noopener noreferrer"
>
{tCommon('operation.learnMore')}
</a>
</>
)}
action={<EvaluationConfigActions resourceType={resourceType} resourceId={resourceId} />}
/>
</div>
<div className="px-6 pt-3 pb-4">
<div className="space-y-3">
<section>
<InlineSectionHeader title={t('judgeModel.title')} tooltip={t('judgeModel.description')} />
<div className="mt-1">
<JudgeModelSelector
resourceType={resourceType}
resourceId={resourceId}
/>
</div>
</section>
<PipelineMetricsSection
resourceType={resourceType}
resourceId={resourceId}
/>
<PipelineBatchActions
resourceType={resourceType}
resourceId={resourceId}
/>
</div>
</div>
<div className="border-t border-divider-subtle" />
<div className="px-6 py-4 xl:min-h-0 xl:flex-1">
<HistoryTab
resourceType={resourceType}
resourceId={resourceId}
/>
</div>
</div>
<div className="shrink-0 bg-background-default xl:min-h-0 xl:flex-1">
<PipelineResultsPanel
resourceType={resourceType}
resourceId={resourceId}
/>
</div>
</div>
)
}
export default PipelineEvaluation

View File

@ -1,249 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen } from '@testing-library/react'
import MetricSection from '..'
import { useEvaluationStore } from '../../../store'
const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn())
const mockUseDefaultEvaluationMetrics = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-evaluation', () => ({
useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args),
useDefaultEvaluationMetrics: (...args: unknown[]) => mockUseDefaultEvaluationMetrics(...args),
}))
const resourceType = 'apps' as const
const resourceId = 'metric-section-resource'
const renderMetricSection = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={queryClient}>
<MetricSection resourceType={resourceType} resourceId={resourceId} />
</QueryClientProvider>,
)
}
describe('MetricSection', () => {
beforeEach(() => {
vi.clearAllMocks()
useEvaluationStore.setState({ resources: {} })
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
],
},
],
},
isLoading: false,
})
mockUseAvailableEvaluationWorkflows.mockReturnValue({
data: {
pages: [{ items: [], page: 1, limit: 20, has_more: false }],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
})
})
// Verify the empty state block extracted from MetricSection.
describe('Empty State', () => {
it('should render the metric empty state when no metrics are selected', () => {
renderMetricSection()
expect(screen.getByText('evaluation.metrics.description')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'evaluation.metrics.add' })).toBeInTheDocument()
})
})
// Verify the extracted builtin metric card presentation and removal flow.
describe('Builtin Metric Card', () => {
it('should render node badges for a builtin metric and remove it when delete is clicked', () => {
// Arrange
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
})
// Act
renderMetricSection()
// Assert
expect(screen.getByText('Answer Correctness')).toBeInTheDocument()
expect(screen.getByText('Answer Node')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.remove' }))
expect(screen.queryByText('Answer Correctness')).not.toBeInTheDocument()
expect(screen.getByText('evaluation.metrics.description')).toBeInTheDocument()
})
it('should render the all-nodes label when a builtin metric has no node selection', () => {
// Arrange
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [])
})
// Act
renderMetricSection()
// Assert
expect(screen.getByText('evaluation.metrics.nodesAll')).toBeInTheDocument()
})
it('should collapse and expand the node section when the metric header is clicked', () => {
// Arrange
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
})
// Act
renderMetricSection()
const toggleButton = screen.getByRole('button', { name: 'evaluation.metrics.collapseNodes' })
fireEvent.click(toggleButton)
// Assert
expect(screen.queryByText('Answer Node')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'evaluation.metrics.expandNodes' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.expandNodes' }))
expect(screen.getByText('Answer Node')).toBeInTheDocument()
})
it('should remove the builtin metric when removing its last selected node', () => {
// Arrange
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
})
// Act
renderMetricSection()
fireEvent.click(screen.getByRole('button', { name: 'Answer Node' }))
// Assert
expect(screen.queryByText('Answer Correctness')).not.toBeInTheDocument()
expect(useEvaluationStore.getState().resources[`${resourceType}:${resourceId}`]!.metrics).toHaveLength(0)
})
it('should show only unselected nodes in the add-node dropdown and append the selected node', () => {
// Arrange
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
],
},
],
},
isLoading: false,
})
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
])
})
// Act
renderMetricSection()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.addNode' }))
// Assert
expect(screen.queryByRole('menuitem', { name: 'LLM 1' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('menuitem', { name: 'LLM 2' }))
expect(screen.getByText('LLM 2')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'evaluation.metrics.addNode' })).not.toBeInTheDocument()
})
it('should hide the add-node button when the builtin metric already targets all nodes', () => {
// Arrange
mockUseDefaultEvaluationMetrics.mockReturnValue({
data: {
default_metrics: [
{
metric: 'answer-correctness',
value_type: 'number',
node_info_list: [
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
],
},
],
},
isLoading: false,
})
act(() => {
useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [])
})
// Act
renderMetricSection()
// Assert
expect(screen.getByText('evaluation.metrics.nodesAll')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'evaluation.metrics.addNode' })).not.toBeInTheDocument()
})
})
// Verify the extracted custom metric editor card renders inside the metric card.
describe('Custom Metric Card', () => {
it('should render the custom metric editor card when a custom metric is added', () => {
act(() => {
useEvaluationStore.getState().addCustomMetric(resourceType, resourceId)
})
renderMetricSection()
expect(screen.getByText('Custom Evaluator')).toBeInTheDocument()
expect(screen.getByText('evaluation.metrics.custom.warningBadge')).toBeInTheDocument()
expect(screen.getByText('evaluation.metrics.custom.workflowPlaceholder')).toBeInTheDocument()
expect(screen.getByText('evaluation.metrics.custom.mappingTitle')).toBeInTheDocument()
})
it('should disable adding another custom metric when one already exists', () => {
// Arrange
act(() => {
useEvaluationStore.getState().addCustomMetric(resourceType, resourceId)
})
// Act
renderMetricSection()
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
// Assert
expect(screen.getByRole('button', { name: /evaluation.metrics.custom.footerTitle/i })).toBeDisabled()
expect(screen.getByText('evaluation.metrics.custom.limitDescription')).toBeInTheDocument()
})
})
})

View File

@ -1,165 +0,0 @@
'use client'
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
import type { NodeInfo } from '@/types/evaluation'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import BlockIcon from '@/app/components/workflow/block-icon'
import { useEvaluationStore } from '../../store'
import { dedupeNodeInfoList, getEvaluationNodeBlockType, getMetricVisual, getToneClasses } from '../metric-selector/utils'
type BuiltinMetricCardProps = EvaluationResourceProps & {
metric: EvaluationMetric
availableNodeInfoList?: NodeInfo[]
}
const BuiltinMetricCard = ({
resourceType,
resourceId,
metric,
availableNodeInfoList = [],
}: BuiltinMetricCardProps) => {
const { t } = useTranslation('evaluation')
const updateBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric)
const removeMetric = useEvaluationStore(state => state.removeMetric)
const [isExpanded, setIsExpanded] = useState(true)
const metricVisual = getMetricVisual(metric.optionId)
const metricToneClasses = getToneClasses(metricVisual.tone)
const selectedNodeInfoList = metric.nodeInfoList ?? []
const selectedNodeIdSet = new Set(selectedNodeInfoList.map(nodeInfo => nodeInfo.node_id))
const selectableNodeInfoList = selectedNodeInfoList.length > 0
? availableNodeInfoList.filter(nodeInfo => !selectedNodeIdSet.has(nodeInfo.node_id))
: []
const shouldShowAddNode = selectableNodeInfoList.length > 0
const handleRemoveNode = (nodeId: string) => {
const nextSelectedNodeInfoList = selectedNodeInfoList.filter(item => item.node_id !== nodeId)
if (nextSelectedNodeInfoList.length === 0) {
removeMetric(resourceType, resourceId, metric.id)
return
}
updateBuiltinMetric(resourceType, resourceId, metric.optionId, nextSelectedNodeInfoList)
}
return (
<div className="group overflow-hidden rounded-xl border border-components-panel-border hover:bg-background-section">
<div className={cn('flex items-center justify-between gap-3 px-3 pt-3', isExpanded ? 'pb-1' : 'pb-3')}>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 px-1 text-left"
aria-expanded={isExpanded}
aria-label={isExpanded ? t('metrics.collapseNodes') : t('metrics.expandNodes')}
onClick={() => setIsExpanded(current => !current)}
>
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[5px]', metricToneClasses.soft)}>
<span aria-hidden="true" className={cn(metricVisual.icon, 'h-3.5 w-3.5')} />
</div>
<div className="flex min-w-0 items-center gap-0.5">
<div className="truncate system-md-medium text-text-secondary uppercase">{metric.label}</div>
<span
aria-hidden="true"
className={cn('i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-quaternary transition-transform', !isExpanded && '-rotate-90')}
/>
</div>
</button>
<Button
size="small"
variant="ghost"
aria-label={t('metrics.remove')}
className="h-6 w-6 shrink-0 rounded-md p-0 text-text-quaternary opacity-0 transition-opacity group-hover:opacity-100 hover:text-text-secondary focus-visible:opacity-100"
onClick={() => removeMetric(resourceType, resourceId, metric.id)}
>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</Button>
</div>
{isExpanded && (
<div className="flex flex-wrap gap-1 px-3 pt-1 pb-3">
{selectedNodeInfoList.length
? selectedNodeInfoList.map((nodeInfo) => {
return (
<div
key={nodeInfo.node_id}
className="inline-flex min-w-[18px] items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1.5 shadow-xs"
>
<BlockIcon
type={getEvaluationNodeBlockType(nodeInfo)}
size="xs"
className="h-[18px] w-[18px] shrink-0"
/>
<span className="px-1 system-xs-regular text-text-primary">{nodeInfo.title}</span>
<button
type="button"
className="flex h-4 w-4 items-center justify-center rounded-sm text-text-quaternary transition-colors hover:text-text-secondary"
aria-label={nodeInfo.title}
onClick={() => handleRemoveNode(nodeInfo.node_id)}
>
<span aria-hidden="true" className="i-custom-vender-solid-general-x-circle h-3.5 w-3.5" />
</button>
</div>
)
})
: (
<span className="px-1 system-xs-regular text-text-tertiary">{t('metrics.nodesAll')}</span>
)}
{shouldShowAddNode && (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
aria-label={t('metrics.addNode')}
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-background-default-hover text-text-tertiary transition-colors hover:bg-state-base-hover"
/>
)}
>
<span aria-hidden="true" className="i-ri-add-line h-4 w-4 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
popupClassName="w-[252px] rounded-md border-[0.5px] border-components-panel-border py-1 shadow-[0px_12px_16px_-4px_rgba(9,9,11,0.08),0px_4px_6px_-2px_rgba(9,9,11,0.03)]"
>
{selectableNodeInfoList.map((nodeInfo) => {
return (
<DropdownMenuItem
key={nodeInfo.node_id}
className="h-auto gap-0 rounded-md px-3 py-1.5"
onClick={() => updateBuiltinMetric(
resourceType,
resourceId,
metric.optionId,
dedupeNodeInfoList([...selectedNodeInfoList, nodeInfo]),
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2.5 pr-1">
<BlockIcon
type={getEvaluationNodeBlockType(nodeInfo)}
size="xs"
className="h-[18px] w-[18px] shrink-0"
/>
<span className="truncate system-sm-medium text-text-secondary">{nodeInfo.title}</span>
</div>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
</div>
)
}
export default BuiltinMetricCard

View File

@ -1,63 +0,0 @@
'use client'
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import { isCustomMetricConfigured, useEvaluationStore } from '../../store'
import CustomMetricEditorCard from '../custom-metric-editor'
import { getToneClasses } from '../metric-selector/utils'
type CustomMetricCardProps = EvaluationResourceProps & {
metric: EvaluationMetric
}
const CustomMetricCard = ({
resourceType,
resourceId,
metric,
}: CustomMetricCardProps) => {
const { t } = useTranslation('evaluation')
const removeMetric = useEvaluationStore(state => state.removeMetric)
const isCustomMetricInvalid = !isCustomMetricConfigured(metric)
const metricToneClasses = getToneClasses('indigo')
return (
<div className="group overflow-hidden rounded-xl border border-components-panel-border hover:bg-background-section">
<div className="flex items-center justify-between gap-3 px-3 pt-3 pb-1">
<div className="flex min-w-0 flex-1 items-center gap-2 px-1">
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[5px]', metricToneClasses.soft)}>
<span aria-hidden="true" className="i-ri-equalizer-2-line h-3.5 w-3.5" />
</div>
<div className="truncate system-md-medium text-text-secondary">{metric.label}</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{isCustomMetricInvalid && (
<Badge className="badge-warning">
{t('metrics.custom.warningBadge')}
</Badge>
)}
<Button
size="small"
variant="ghost"
aria-label={t('metrics.remove')}
className="h-6 w-6 shrink-0 rounded-md p-0 text-text-quaternary opacity-0 transition-opacity group-hover:opacity-100 hover:text-text-secondary focus-visible:opacity-100"
onClick={() => removeMetric(resourceType, resourceId, metric.id)}
>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</Button>
</div>
</div>
<CustomMetricEditorCard
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
/>
</div>
)
}
export default CustomMetricCard

View File

@ -1,51 +0,0 @@
'use client'
import type { NonPipelineEvaluationResourceProps } from '../../types'
import { useTranslation } from 'react-i18next'
import { useDefaultEvaluationMetrics } from '@/service/use-evaluation'
import { useEvaluationResource } from '../../store'
import MetricSelector from '../metric-selector'
import { getDefaultMetricNodeInfoMap } from '../metric-selector/utils'
import { InlineSectionHeader } from '../section-header'
import MetricCard from './metric-card'
import MetricSectionEmptyState from './metric-section-empty-state'
const MetricSection = ({
resourceType,
resourceId,
}: NonPipelineEvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const hasMetrics = resource.metrics.length > 0
const hasBuiltinMetrics = resource.metrics.some(metric => metric.kind === 'builtin')
const { data: defaultMetricsData } = useDefaultEvaluationMetrics(resourceType, resourceId, hasBuiltinMetrics)
const nodeInfoMap = getDefaultMetricNodeInfoMap(defaultMetricsData?.default_metrics ?? [])
return (
<section className="max-w-[700px] py-4">
<InlineSectionHeader
title={t('metrics.title')}
tooltip={t('metrics.description')}
/>
<div className="mt-1 space-y-1">
{!hasMetrics && <MetricSectionEmptyState description={t('metrics.description')} />}
{resource.metrics.map(metric => (
<MetricCard
key={metric.id}
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
availableNodeInfoList={metric.kind === 'builtin' ? (nodeInfoMap[metric.optionId] ?? []) : undefined}
/>
))}
<MetricSelector
resourceType={resourceType}
resourceId={resourceId}
triggerClassName="rounded-md px-3 py-2"
/>
</div>
</section>
)
}
export default MetricSection

View File

@ -1,39 +0,0 @@
'use client'
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
import type { NodeInfo } from '@/types/evaluation'
import BuiltinMetricCard from './builtin-metric-card'
import CustomMetricCard from './custom-metric-card'
type MetricCardProps = EvaluationResourceProps & {
metric: EvaluationMetric
availableNodeInfoList?: NodeInfo[]
}
const MetricCard = ({
resourceType,
resourceId,
metric,
availableNodeInfoList,
}: MetricCardProps) => {
if (metric.kind === 'custom-workflow') {
return (
<CustomMetricCard
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
/>
)
}
return (
<BuiltinMetricCard
resourceType={resourceType}
resourceId={resourceId}
metric={metric}
availableNodeInfoList={availableNodeInfoList}
/>
)
}
export default MetricCard

View File

@ -1,18 +0,0 @@
type MetricSectionEmptyStateProps = {
description: string
}
const MetricSectionEmptyState = ({ description }: MetricSectionEmptyStateProps) => {
return (
<div className="flex items-center gap-5 rounded-xl bg-background-section p-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-md">
<span aria-hidden="true" className="i-ri-bar-chart-horizontal-line h-6 w-6 text-text-primary" />
</div>
<div className="min-w-0 flex-1 text-text-tertiary system-xs-regular">
{description}
</div>
</div>
)
}
export default MetricSectionEmptyState

View File

@ -1,137 +0,0 @@
'use client'
import type { ChangeEvent } from 'react'
import type { MetricSelectorProps } from './types'
import { Button } from '@langgenius/dify-ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { useEvaluationResource, useEvaluationStore } from '../../store'
import SelectorEmptyState from './selector-empty-state'
import SelectorFooter from './selector-footer'
import SelectorMetricSection from './selector-metric-section'
import { useMetricSelectorData } from './use-metric-selector-data'
const MetricSelector = ({
resourceType,
resourceId,
triggerClassName,
}: MetricSelectorProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const addCustomMetric = useEvaluationStore(state => state.addCustomMetric)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [collapsedMetricMap, setCollapsedMetricMap] = useState<Record<string, boolean>>({})
const [expandedMetricNodesMap, setExpandedMetricNodesMap] = useState<Record<string, boolean>>({})
const hasCustomMetric = resource.metrics.some(metric => metric.kind === 'custom-workflow')
const {
builtinMetricMap,
filteredSections,
isRemoteLoading,
toggleNodeSelection,
} = useMetricSelectorData({
open,
query,
resourceType,
resourceId,
})
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (nextOpen) {
setQuery('')
setCollapsedMetricMap({})
setExpandedMetricNodesMap({})
}
}
const handleQueryChange = (event: ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={(
<Button variant="ghost-accent" className={triggerClassName}>
<span aria-hidden="true" className="mr-1 i-ri-add-line h-4 w-4" />
{t('metrics.add')}
</Button>
)}
/>
<PopoverContent popupClassName="w-[360px] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border p-0 shadow-[0px_12px_16px_-4px_rgba(9,9,11,0.08),0px_4px_6px_-2px_rgba(9,9,11,0.03)]">
<div className="flex min-h-[560px] flex-col bg-components-panel-bg">
<div className="border-b border-divider-subtle bg-background-section-burn px-2 py-2">
<Input
value={query}
showLeftIcon
placeholder={t('metrics.searchNodeOrMetrics')}
onChange={handleQueryChange}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{isRemoteLoading && (
<div className="space-y-3 px-3 py-4" data-testid="evaluation-metric-loading">
{['metric-skeleton-1', 'metric-skeleton-2', 'metric-skeleton-3'].map(key => (
<div key={key} className="h-20 animate-pulse rounded-xl bg-background-default-subtle" />
))}
</div>
)}
{!isRemoteLoading && filteredSections.length === 0 && (
<SelectorEmptyState message={t('metrics.noResults')} />
)}
{!isRemoteLoading && filteredSections.map((section, index) => {
const { metric } = section
const isExpanded = collapsedMetricMap[metric.id] !== true
const isShowingAllNodes = expandedMetricNodesMap[metric.id] === true
return (
<SelectorMetricSection
key={metric.id}
section={section}
index={index}
addedMetric={builtinMetricMap.get(metric.id)}
isExpanded={isExpanded}
isShowingAllNodes={isShowingAllNodes}
onToggleExpanded={() => setCollapsedMetricMap(current => ({
...current,
[metric.id]: isExpanded,
}))}
onToggleNodeSelection={toggleNodeSelection}
onToggleShowAllNodes={() => setExpandedMetricNodesMap(current => ({
...current,
[metric.id]: !isShowingAllNodes,
}))}
t={t}
/>
)
})}
</div>
<SelectorFooter
title={t('metrics.custom.footerTitle')}
description={hasCustomMetric ? t('metrics.custom.limitDescription') : t('metrics.custom.footerDescription')}
disabled={hasCustomMetric}
onClick={() => {
addCustomMetric(resourceType, resourceId)
setOpen(false)
}}
/>
</div>
</PopoverContent>
</Popover>
)
}
export default MetricSelector

View File

@ -1,26 +0,0 @@
type SelectorEmptyStateProps = {
message: string
}
const EmptySearchStateIcon = () => {
return (
<div className="relative h-8 w-8 text-text-quaternary">
<span aria-hidden="true" className="absolute right-0 bottom-0 i-ri-search-line h-6 w-6" />
<span aria-hidden="true" className="absolute top-[9px] left-0 h-[2px] w-[7px] rounded-full bg-current opacity-80" />
<span aria-hidden="true" className="absolute top-[16px] left-0 h-[2px] w-[4px] rounded-full bg-current opacity-80" />
</div>
)
}
const SelectorEmptyState = ({
message,
}: SelectorEmptyStateProps) => {
return (
<div className="flex h-full min-h-[524px] flex-col items-center justify-center gap-2 px-4 pb-20 text-center">
<EmptySearchStateIcon />
<div className="system-sm-regular text-text-secondary">{message}</div>
</div>
)
}
export default SelectorEmptyState

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