mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 10:28:10 +08:00
Compare commits
3 Commits
feat/evalu
...
5-6-deps
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c58c4007d | |||
| 54c90ddaaa | |||
| 995c43f3dd |
2
.github/workflows/style.yml
vendored
2
.github/workflows/style.yml
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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>"
|
||||
},
|
||||
|
||||
@ -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
2447
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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: {},
|
||||
}),
|
||||
}))
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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' }))
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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' }))
|
||||
|
||||
|
||||
@ -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}/`)
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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">
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -215,7 +215,6 @@ const createMockWorkflowLog = (overrides: Partial<WorkflowAppLogDetail> = {}): W
|
||||
},
|
||||
created_at: Date.now(),
|
||||
...overrides,
|
||||
evaluation: overrides.evaluation ?? [],
|
||||
})
|
||||
|
||||
const createMockLogsResponse = (
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@ -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
|
||||
@ -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} />
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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' })
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
})))
|
||||
|
||||
49
web/app/components/apps/hooks/use-workflow-online-users.ts
Normal file
49
web/app/components/apps/hooks/use-workflow-online-users.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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 || ''}
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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
|
||||
@ -1 +1,2 @@
|
||||
export { default as BracketsX } from './BracketsX'
|
||||
export { default as CodeBrowser } from './CodeBrowser'
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
Reference in New Issue
Block a user