mirror of
https://github.com/langgenius/dify.git
synced 2026-04-09 18:02:39 +00:00
Compare commits
117 Commits
deploy/dev
...
feat/evalu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5efe8b8bd7 | ||
|
|
8dc6d736ee | ||
|
|
5316372772 | ||
|
|
4d1499ef75 | ||
|
|
0438285277 | ||
|
|
4879ea5cd5 | ||
|
|
41eeb1f2e7 | ||
|
|
2a1761ac06 | ||
|
|
c29245c1cb | ||
|
|
5069694bba | ||
|
|
d1a80a85c0 | ||
|
|
5c93d74dec | ||
|
|
e52dbd49be | ||
|
|
ccc8a5f278 | ||
|
|
cfb5b9dfea | ||
|
|
73d95245f8 | ||
|
|
fb91984fcb | ||
|
|
29cb1fa12e | ||
|
|
78240ed199 | ||
|
|
8f8707fd77 | ||
|
|
ed3db06154 | ||
|
|
7c05a68876 | ||
|
|
6cfc0dd8e1 | ||
|
|
81baeae5c4 | ||
|
|
a3010bdc0b | ||
|
|
8133e550ed | ||
|
|
2bb0eab636 | ||
|
|
5311b5d00d | ||
|
|
9b02ccdd12 | ||
|
|
231783eebe | ||
|
|
756606f478 | ||
|
|
6651c1c5da | ||
|
|
61e257b2a8 | ||
|
|
3ac4caf735 | ||
|
|
268ae1751d | ||
|
|
015cbf850b | ||
|
|
873e13c2fb | ||
|
|
688bf7e7a1 | ||
|
|
a6ffff3b39 | ||
|
|
023fc55bd5 | ||
|
|
351b909a53 | ||
|
|
6bec4f65c9 | ||
|
|
74f87ce152 | ||
|
|
92c472ccc7 | ||
|
|
b92b8becd1 | ||
|
|
23d0d6a65d | ||
|
|
1660067d6e | ||
|
|
0642475b85 | ||
|
|
8cb634c9bc | ||
|
|
768b41c3cf | ||
|
|
ca88516d54 | ||
|
|
871a2a149f | ||
|
|
60e381eff0 | ||
|
|
768b3eb6f9 | ||
|
|
2f88da4a6d | ||
|
|
a8cdf6964c | ||
|
|
985c3db4fd | ||
|
|
9636472db7 | ||
|
|
0ad268aa7d | ||
|
|
a4ea33167d | ||
|
|
0f13aabea8 | ||
|
|
1e76ef5ccb | ||
|
|
e6e3229d17 | ||
|
|
dccf8e723a | ||
|
|
c41ba7d627 | ||
|
|
a6e9316de3 | ||
|
|
559d326cbd | ||
|
|
abedf2506f | ||
|
|
d01428b5bc | ||
|
|
0de1f17e5c | ||
|
|
17d07a5a43 | ||
|
|
3bdbea99a3 | ||
|
|
b7683aedb1 | ||
|
|
515036e758 | ||
|
|
22b382527f | ||
|
|
2cfe4b5b86 | ||
|
|
6876c8041c | ||
|
|
7de45584ce | ||
|
|
5572d7c7e8 | ||
|
|
db0a2fe52e | ||
|
|
f0ae8d6167 | ||
|
|
2514e181ba | ||
|
|
be2e6e9a14 | ||
|
|
875e2eac1b | ||
|
|
c3c73ceb1f | ||
|
|
6318bf0a2a | ||
|
|
5e1f252046 | ||
|
|
df3b960505 | ||
|
|
26bc108bf1 | ||
|
|
a5cff32743 | ||
|
|
d418dd8eec | ||
|
|
61702fe346 | ||
|
|
43f0c780c3 | ||
|
|
30ebf2bfa9 | ||
|
|
7e3027b5f7 | ||
|
|
b3acf83090 | ||
|
|
36c3d6e48a | ||
|
|
f782ac6b3c | ||
|
|
feef2dd1fa | ||
|
|
a716d8789d | ||
|
|
6816f89189 | ||
|
|
bfcac64a9d | ||
|
|
664eb601a2 | ||
|
|
8e5cc4e0aa | ||
|
|
9f28575903 | ||
|
|
4b9a26a5e6 | ||
|
|
7b85adf1cc | ||
|
|
c964708ebe | ||
|
|
883eb498c0 | ||
|
|
4d3738d225 | ||
|
|
dd0dee739d | ||
|
|
4d19914fcb | ||
|
|
887c7710e9 | ||
|
|
7a722773c7 | ||
|
|
a763aff58b | ||
|
|
c1011f4e5c | ||
|
|
f7afa103a5 |
@@ -77,7 +77,7 @@ if $web_modified; then
|
||||
fi
|
||||
|
||||
cd ./web || exit 1
|
||||
vp staged
|
||||
pnpm exec vp staged
|
||||
|
||||
if $web_ts_modified; then
|
||||
echo "Running TypeScript type-check:tsgo"
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import base64
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Literal
|
||||
|
||||
from flask import request
|
||||
@@ -11,7 +9,6 @@ from werkzeug.exceptions import BadRequest
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import account_initialization_required, only_edition_cloud, setup_required
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from extensions.ext_redis import redis_client
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from services.billing_service import BillingService
|
||||
|
||||
@@ -87,39 +84,3 @@ class PartnerTenants(Resource):
|
||||
raise BadRequest("Invalid partner information")
|
||||
|
||||
return BillingService.sync_partner_tenants_bindings(current_user.id, decoded_partner_key, click_id)
|
||||
|
||||
|
||||
_DEBUG_KEY = "billing:debug"
|
||||
_DEBUG_TTL = timedelta(days=7)
|
||||
|
||||
|
||||
class DebugDataPayload(BaseModel):
|
||||
type: str = Field(..., min_length=1, description="Data type key")
|
||||
data: str = Field(..., min_length=1, description="Data value to append")
|
||||
|
||||
|
||||
@console_ns.route("/billing/debug/data")
|
||||
class DebugData(Resource):
|
||||
def post(self):
|
||||
body = DebugDataPayload.model_validate(request.get_json(force=True))
|
||||
item = json.dumps({
|
||||
"type": body.type,
|
||||
"data": body.data,
|
||||
"createTime": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
})
|
||||
redis_client.lpush(_DEBUG_KEY, item)
|
||||
redis_client.expire(_DEBUG_KEY, _DEBUG_TTL)
|
||||
return {"result": "ok"}, 201
|
||||
|
||||
def get(self):
|
||||
recent = request.args.get("recent", 10, type=int)
|
||||
items = redis_client.lrange(_DEBUG_KEY, 0, recent - 1)
|
||||
return {
|
||||
"data": [
|
||||
json.loads(item.decode("utf-8") if isinstance(item, bytes) else item) for item in items
|
||||
]
|
||||
}
|
||||
|
||||
def delete(self):
|
||||
redis_client.delete(_DEBUG_KEY)
|
||||
return {"result": "ok"}
|
||||
|
||||
@@ -1,17 +1,56 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum, auto
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuotaCharge:
|
||||
"""
|
||||
Result of a quota consumption operation.
|
||||
|
||||
Attributes:
|
||||
success: Whether the quota charge succeeded
|
||||
charge_id: UUID for refund, or None if failed/disabled
|
||||
"""
|
||||
|
||||
success: bool
|
||||
charge_id: str | None
|
||||
_quota_type: "QuotaType"
|
||||
|
||||
def refund(self) -> None:
|
||||
"""
|
||||
Refund this quota charge.
|
||||
|
||||
Safe to call even if charge failed or was disabled.
|
||||
This method guarantees no exceptions will be raised.
|
||||
"""
|
||||
if self.charge_id:
|
||||
self._quota_type.refund(self.charge_id)
|
||||
logger.info("Refunded quota for %s with charge_id: %s", self._quota_type.value, self.charge_id)
|
||||
|
||||
|
||||
class QuotaType(StrEnum):
|
||||
"""
|
||||
Supported quota types for tenant feature usage.
|
||||
|
||||
Add additional types here whenever new billable features become available.
|
||||
"""
|
||||
|
||||
# Trigger execution quota
|
||||
TRIGGER = auto()
|
||||
|
||||
# Workflow execution quota
|
||||
WORKFLOW = auto()
|
||||
|
||||
UNLIMITED = auto()
|
||||
|
||||
@property
|
||||
def billing_key(self) -> str:
|
||||
"""
|
||||
Get the billing key for the feature.
|
||||
"""
|
||||
match self:
|
||||
case QuotaType.TRIGGER:
|
||||
return "trigger_event"
|
||||
@@ -19,3 +58,152 @@ class QuotaType(StrEnum):
|
||||
return "api_rate_limit"
|
||||
case _:
|
||||
raise ValueError(f"Invalid quota type: {self}")
|
||||
|
||||
def consume(self, tenant_id: str, amount: int = 1) -> QuotaCharge:
|
||||
"""
|
||||
Consume quota for the feature.
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant identifier
|
||||
amount: Amount to consume (default: 1)
|
||||
|
||||
Returns:
|
||||
QuotaCharge with success status and charge_id for refund
|
||||
|
||||
Raises:
|
||||
QuotaExceededError: When quota is insufficient
|
||||
"""
|
||||
from configs import dify_config
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.app import QuotaExceededError
|
||||
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
logger.debug("Billing disabled, allowing request for %s", tenant_id)
|
||||
return QuotaCharge(success=True, charge_id=None, _quota_type=self)
|
||||
|
||||
logger.info("Consuming %d %s quota for tenant %s", amount, self.value, tenant_id)
|
||||
|
||||
if amount <= 0:
|
||||
raise ValueError("Amount to consume must be greater than 0")
|
||||
|
||||
try:
|
||||
response = BillingService.update_tenant_feature_plan_usage(tenant_id, self.billing_key, delta=amount)
|
||||
|
||||
if response.get("result") != "success":
|
||||
logger.warning(
|
||||
"Failed to consume quota for %s, feature %s details: %s",
|
||||
tenant_id,
|
||||
self.value,
|
||||
response.get("detail"),
|
||||
)
|
||||
raise QuotaExceededError(feature=self.value, tenant_id=tenant_id, required=amount)
|
||||
|
||||
charge_id = response.get("history_id")
|
||||
logger.debug(
|
||||
"Successfully consumed %d %s quota for tenant %s, charge_id: %s",
|
||||
amount,
|
||||
self.value,
|
||||
tenant_id,
|
||||
charge_id,
|
||||
)
|
||||
return QuotaCharge(success=True, charge_id=charge_id, _quota_type=self)
|
||||
|
||||
except QuotaExceededError:
|
||||
raise
|
||||
except Exception:
|
||||
# fail-safe: allow request on billing errors
|
||||
logger.exception("Failed to consume quota for %s, feature %s", tenant_id, self.value)
|
||||
return unlimited()
|
||||
|
||||
def check(self, tenant_id: str, amount: int = 1) -> bool:
|
||||
"""
|
||||
Check if tenant has sufficient quota without consuming.
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant identifier
|
||||
amount: Amount to check (default: 1)
|
||||
|
||||
Returns:
|
||||
True if quota is sufficient, False otherwise
|
||||
"""
|
||||
from configs import dify_config
|
||||
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
return True
|
||||
|
||||
if amount <= 0:
|
||||
raise ValueError("Amount to check must be greater than 0")
|
||||
|
||||
try:
|
||||
remaining = self.get_remaining(tenant_id)
|
||||
return remaining >= amount if remaining != -1 else True
|
||||
except Exception:
|
||||
logger.exception("Failed to check quota for %s, feature %s", tenant_id, self.value)
|
||||
# fail-safe: allow request on billing errors
|
||||
return True
|
||||
|
||||
def refund(self, charge_id: str) -> None:
|
||||
"""
|
||||
Refund quota using charge_id from consume().
|
||||
|
||||
This method guarantees no exceptions will be raised.
|
||||
All errors are logged but silently handled.
|
||||
|
||||
Args:
|
||||
charge_id: The UUID returned from consume()
|
||||
"""
|
||||
try:
|
||||
from configs import dify_config
|
||||
from services.billing_service import BillingService
|
||||
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
return
|
||||
|
||||
if not charge_id:
|
||||
logger.warning("Cannot refund: charge_id is empty")
|
||||
return
|
||||
|
||||
logger.info("Refunding %s quota with charge_id: %s", self.value, charge_id)
|
||||
|
||||
response = BillingService.refund_tenant_feature_plan_usage(charge_id)
|
||||
if response.get("result") == "success":
|
||||
logger.debug("Successfully refunded %s quota, charge_id: %s", self.value, charge_id)
|
||||
else:
|
||||
logger.warning("Refund failed for charge_id: %s", charge_id)
|
||||
|
||||
except Exception:
|
||||
# Catch ALL exceptions - refund must never fail
|
||||
logger.exception("Failed to refund quota for charge_id: %s", charge_id)
|
||||
# Don't raise - refund is best-effort and must be silent
|
||||
|
||||
def get_remaining(self, tenant_id: str) -> int:
|
||||
"""
|
||||
Get remaining quota for the tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant identifier
|
||||
|
||||
Returns:
|
||||
Remaining quota amount
|
||||
"""
|
||||
from services.billing_service import BillingService
|
||||
|
||||
try:
|
||||
usage_info = BillingService.get_tenant_feature_plan_usage(tenant_id, self.billing_key)
|
||||
# Assuming the API returns a dict with 'remaining' or 'limit' and 'used'
|
||||
if isinstance(usage_info, dict):
|
||||
return usage_info.get("remaining", 0)
|
||||
# If it returns a simple number, treat it as remaining
|
||||
return int(usage_info) if usage_info else 0
|
||||
except Exception:
|
||||
logger.exception("Failed to get remaining quota for %s, feature %s", tenant_id, self.value)
|
||||
return -1
|
||||
|
||||
|
||||
def unlimited() -> QuotaCharge:
|
||||
"""
|
||||
Return a quota charge for unlimited quota.
|
||||
|
||||
This is useful for features that are not subject to quota limits, such as the UNLIMITED quota type.
|
||||
"""
|
||||
return QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.UNLIMITED)
|
||||
|
||||
@@ -18,13 +18,12 @@ from core.app.features.rate_limiting import RateLimit
|
||||
from core.app.features.rate_limiting.rate_limit import rate_limit_context
|
||||
from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig
|
||||
from core.db import session_factory
|
||||
from enums.quota_type import QuotaType
|
||||
from enums.quota_type import QuotaType, unlimited
|
||||
from extensions.otel import AppGenerateHandler, trace_span
|
||||
from models.model import Account, App, AppMode, EndUser
|
||||
from models.workflow import Workflow, WorkflowRun
|
||||
from services.errors.app import QuotaExceededError, WorkflowIdFormatError, WorkflowNotFoundError
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.quota_service import QuotaService, unlimited
|
||||
from services.workflow_service import WorkflowService
|
||||
from tasks.app_generate.workflow_execute_task import AppExecutionParams, workflow_based_app_execution_task
|
||||
|
||||
@@ -107,7 +106,7 @@ class AppGenerateService:
|
||||
quota_charge = unlimited()
|
||||
if dify_config.BILLING_ENABLED:
|
||||
try:
|
||||
quota_charge = QuotaService.reserve(QuotaType.WORKFLOW, app_model.tenant_id)
|
||||
quota_charge = QuotaType.WORKFLOW.consume(app_model.tenant_id)
|
||||
except QuotaExceededError:
|
||||
raise InvokeRateLimitError(f"Workflow execution quota limit reached for tenant {app_model.tenant_id}")
|
||||
|
||||
@@ -117,7 +116,6 @@ class AppGenerateService:
|
||||
request_id = RateLimit.gen_request_key()
|
||||
try:
|
||||
request_id = rate_limit.enter(request_id)
|
||||
quota_charge.commit()
|
||||
effective_mode = (
|
||||
AppMode.AGENT_CHAT if app_model.is_agent and app_model.mode != AppMode.AGENT_CHAT else app_model.mode
|
||||
)
|
||||
|
||||
@@ -22,7 +22,6 @@ from models.trigger import WorkflowTriggerLog, WorkflowTriggerLogDict
|
||||
from models.workflow import Workflow
|
||||
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
|
||||
from services.errors.app import QuotaExceededError, WorkflowNotFoundError, WorkflowQuotaLimitError
|
||||
from services.quota_service import QuotaService, unlimited
|
||||
from services.workflow.entities import AsyncTriggerResponse, TriggerData, WorkflowTaskData
|
||||
from services.workflow.queue_dispatcher import QueueDispatcherManager, QueuePriority
|
||||
from services.workflow_service import WorkflowService
|
||||
@@ -132,10 +131,9 @@ class AsyncWorkflowService:
|
||||
trigger_log = trigger_log_repo.create(trigger_log)
|
||||
session.commit()
|
||||
|
||||
# 7. Reserve quota (commit after successful dispatch)
|
||||
quota_charge = unlimited()
|
||||
# 7. Check and consume quota
|
||||
try:
|
||||
quota_charge = QuotaService.reserve(QuotaType.WORKFLOW, trigger_data.tenant_id)
|
||||
QuotaType.WORKFLOW.consume(trigger_data.tenant_id)
|
||||
except QuotaExceededError as e:
|
||||
# Update trigger log status
|
||||
trigger_log.status = WorkflowTriggerStatus.RATE_LIMITED
|
||||
@@ -155,18 +153,13 @@ class AsyncWorkflowService:
|
||||
# 9. Dispatch to appropriate queue
|
||||
task_data_dict = task_data.model_dump(mode="json")
|
||||
|
||||
try:
|
||||
task: AsyncResult[Any] | None = None
|
||||
if queue_name == QueuePriority.PROFESSIONAL:
|
||||
task = execute_workflow_professional.delay(task_data_dict)
|
||||
elif queue_name == QueuePriority.TEAM:
|
||||
task = execute_workflow_team.delay(task_data_dict)
|
||||
else: # SANDBOX
|
||||
task = execute_workflow_sandbox.delay(task_data_dict)
|
||||
quota_charge.commit()
|
||||
except Exception:
|
||||
quota_charge.refund()
|
||||
raise
|
||||
task: AsyncResult[Any] | None = None
|
||||
if queue_name == QueuePriority.PROFESSIONAL:
|
||||
task = execute_workflow_professional.delay(task_data_dict)
|
||||
elif queue_name == QueuePriority.TEAM:
|
||||
task = execute_workflow_team.delay(task_data_dict)
|
||||
else: # SANDBOX
|
||||
task = execute_workflow_sandbox.delay(task_data_dict)
|
||||
|
||||
# 10. Update trigger log with task info
|
||||
trigger_log.status = WorkflowTriggerStatus.QUEUED
|
||||
|
||||
@@ -32,102 +32,6 @@ class SubscriptionPlan(TypedDict):
|
||||
expiration_date: int
|
||||
|
||||
|
||||
class QuotaReserveResult(TypedDict):
|
||||
reservation_id: str
|
||||
available: int
|
||||
reserved: int
|
||||
|
||||
|
||||
class QuotaCommitResult(TypedDict):
|
||||
available: int
|
||||
reserved: int
|
||||
refunded: int
|
||||
|
||||
|
||||
class QuotaReleaseResult(TypedDict):
|
||||
available: int
|
||||
reserved: int
|
||||
released: int
|
||||
|
||||
|
||||
_quota_reserve_adapter = TypeAdapter(QuotaReserveResult)
|
||||
_quota_commit_adapter = TypeAdapter(QuotaCommitResult)
|
||||
_quota_release_adapter = TypeAdapter(QuotaReleaseResult)
|
||||
class _BillingQuota(TypedDict):
|
||||
size: int
|
||||
limit: int
|
||||
|
||||
|
||||
class _VectorSpaceQuota(TypedDict):
|
||||
size: float
|
||||
limit: int
|
||||
|
||||
|
||||
class _KnowledgeRateLimit(TypedDict):
|
||||
# NOTE (hj24):
|
||||
# 1. Return for sandbox users but is null for other plans, it's defined but never used.
|
||||
# 2. Keep it for compatibility for now, can be deprecated in future versions.
|
||||
size: NotRequired[int]
|
||||
# NOTE END
|
||||
limit: int
|
||||
|
||||
|
||||
class _BillingSubscription(TypedDict):
|
||||
plan: str
|
||||
interval: str
|
||||
education: bool
|
||||
|
||||
|
||||
class BillingInfo(TypedDict):
|
||||
"""Response of /subscription/info.
|
||||
|
||||
NOTE (hj24):
|
||||
- Fields not listed here (e.g. trigger_event, api_rate_limit) are stripped by TypeAdapter.validate_python()
|
||||
- To ensure the precision, billing may convert fields like int as str, be careful when use TypeAdapter:
|
||||
1. validate_python in non-strict mode will coerce it to the expected type
|
||||
2. In strict mode, it will raise ValidationError
|
||||
3. To preserve compatibility, always keep non-strict mode here and avoid strict mode
|
||||
"""
|
||||
|
||||
enabled: bool
|
||||
subscription: _BillingSubscription
|
||||
members: _BillingQuota
|
||||
apps: _BillingQuota
|
||||
vector_space: _VectorSpaceQuota
|
||||
knowledge_rate_limit: _KnowledgeRateLimit
|
||||
documents_upload_quota: _BillingQuota
|
||||
annotation_quota_limit: _BillingQuota
|
||||
docs_processing: str
|
||||
can_replace_logo: bool
|
||||
model_load_balancing_enabled: bool
|
||||
knowledge_pipeline_publish_enabled: bool
|
||||
next_credit_reset_date: NotRequired[int]
|
||||
|
||||
|
||||
_billing_info_adapter = TypeAdapter(BillingInfo)
|
||||
|
||||
|
||||
class _TenantFeatureQuota(TypedDict):
|
||||
usage: int
|
||||
limit: int
|
||||
reset_date: NotRequired[int]
|
||||
|
||||
|
||||
class TenantFeatureQuotaInfo(TypedDict):
|
||||
"""Response of /quota/info.
|
||||
|
||||
NOTE (hj24):
|
||||
- Same convention as BillingInfo: billing may return int fields as str,
|
||||
always keep non-strict mode to auto-coerce.
|
||||
"""
|
||||
|
||||
trigger_event: _TenantFeatureQuota
|
||||
api_rate_limit: _TenantFeatureQuota
|
||||
|
||||
|
||||
_tenant_feature_quota_info_adapter = TypeAdapter(TenantFeatureQuotaInfo)
|
||||
|
||||
|
||||
class _BillingQuota(TypedDict):
|
||||
size: int
|
||||
limit: int
|
||||
@@ -245,63 +149,11 @@ class BillingService:
|
||||
|
||||
@classmethod
|
||||
def get_tenant_feature_plan_usage_info(cls, tenant_id: str):
|
||||
"""Deprecated: Use get_quota_info instead."""
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
usage_info = cls._send_request("GET", "/tenant-feature-usage/info", params=params)
|
||||
return usage_info
|
||||
|
||||
@classmethod
|
||||
def get_quota_info(cls, tenant_id: str) -> TenantFeatureQuotaInfo:
|
||||
params = {"tenant_id": tenant_id}
|
||||
return _tenant_feature_quota_info_adapter.validate_python(
|
||||
cls._send_request("GET", "/quota/info", params=params)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def quota_reserve(
|
||||
cls, tenant_id: str, feature_key: str, request_id: str, amount: int = 1, meta: dict | None = None
|
||||
) -> QuotaReserveResult:
|
||||
"""Reserve quota before task execution."""
|
||||
payload: dict = {
|
||||
"tenant_id": tenant_id,
|
||||
"feature_key": feature_key,
|
||||
"request_id": request_id,
|
||||
"amount": amount,
|
||||
}
|
||||
if meta:
|
||||
payload["meta"] = meta
|
||||
return _quota_reserve_adapter.validate_python(cls._send_request("POST", "/quota/reserve", json=payload))
|
||||
|
||||
@classmethod
|
||||
def quota_commit(
|
||||
cls, tenant_id: str, feature_key: str, reservation_id: str, actual_amount: int, meta: dict | None = None
|
||||
) -> QuotaCommitResult:
|
||||
"""Commit a reservation with actual consumption."""
|
||||
payload: dict = {
|
||||
"tenant_id": tenant_id,
|
||||
"feature_key": feature_key,
|
||||
"reservation_id": reservation_id,
|
||||
"actual_amount": actual_amount,
|
||||
}
|
||||
if meta:
|
||||
payload["meta"] = meta
|
||||
return _quota_commit_adapter.validate_python(cls._send_request("POST", "/quota/commit", json=payload))
|
||||
|
||||
@classmethod
|
||||
def quota_release(cls, tenant_id: str, feature_key: str, reservation_id: str) -> QuotaReleaseResult:
|
||||
"""Release a reservation (cancel, return frozen quota)."""
|
||||
return _quota_release_adapter.validate_python(
|
||||
cls._send_request(
|
||||
"POST",
|
||||
"/quota/release",
|
||||
json={
|
||||
"tenant_id": tenant_id,
|
||||
"feature_key": feature_key,
|
||||
"reservation_id": reservation_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_knowledge_rate_limit(cls, tenant_id: str) -> KnowledgeRateLimitDict:
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
@@ -281,7 +281,7 @@ class FeatureService:
|
||||
def _fulfill_params_from_billing_api(cls, features: FeatureModel, tenant_id: str):
|
||||
billing_info = BillingService.get_info(tenant_id)
|
||||
|
||||
features_usage_info = BillingService.get_quota_info(tenant_id)
|
||||
features_usage_info = BillingService.get_tenant_feature_plan_usage_info(tenant_id)
|
||||
|
||||
features.billing.enabled = billing_info["enabled"]
|
||||
features.billing.subscription.plan = billing_info["subscription"]["plan"]
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from extensions.ext_database import db
|
||||
from core.db.session_factory import session_factory
|
||||
from models.account import TenantPluginAutoUpgradeStrategy
|
||||
|
||||
|
||||
class PluginAutoUpgradeService:
|
||||
@staticmethod
|
||||
def get_strategy(tenant_id: str) -> TenantPluginAutoUpgradeStrategy | None:
|
||||
with sessionmaker(bind=db.engine).begin() as session:
|
||||
with session_factory.create_session() as session:
|
||||
return session.scalar(
|
||||
select(TenantPluginAutoUpgradeStrategy)
|
||||
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
|
||||
@@ -24,7 +23,7 @@ class PluginAutoUpgradeService:
|
||||
exclude_plugins: list[str],
|
||||
include_plugins: list[str],
|
||||
) -> bool:
|
||||
with sessionmaker(bind=db.engine).begin() as session:
|
||||
with session_factory.create_session() as session:
|
||||
exist_strategy = session.scalar(
|
||||
select(TenantPluginAutoUpgradeStrategy)
|
||||
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
|
||||
@@ -51,7 +50,7 @@ class PluginAutoUpgradeService:
|
||||
|
||||
@staticmethod
|
||||
def exclude_plugin(tenant_id: str, plugin_id: str) -> bool:
|
||||
with sessionmaker(bind=db.engine).begin() as session:
|
||||
with session_factory.create_session() as session:
|
||||
exist_strategy = session.scalar(
|
||||
select(TenantPluginAutoUpgradeStrategy)
|
||||
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from configs import dify_config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from enums.quota_type import QuotaType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuotaCharge:
|
||||
"""
|
||||
Result of a quota reservation (Reserve phase).
|
||||
|
||||
Lifecycle:
|
||||
charge = QuotaService.consume(QuotaType.TRIGGER, tenant_id)
|
||||
try:
|
||||
do_work()
|
||||
charge.commit() # Confirm consumption
|
||||
except:
|
||||
charge.refund() # Release frozen quota
|
||||
|
||||
If neither commit() nor refund() is called, the billing system's
|
||||
cleanup CronJob will auto-release the reservation within ~75 seconds.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
charge_id: str | None # reservation_id
|
||||
_quota_type: QuotaType
|
||||
_tenant_id: str | None = None
|
||||
_feature_key: str | None = None
|
||||
_amount: int = 0
|
||||
_committed: bool = field(default=False, repr=False)
|
||||
|
||||
def commit(self, actual_amount: int | None = None) -> None:
|
||||
"""
|
||||
Confirm the consumption with actual amount.
|
||||
|
||||
Args:
|
||||
actual_amount: Actual amount consumed. Defaults to the reserved amount.
|
||||
If less than reserved, the difference is refunded automatically.
|
||||
"""
|
||||
if self._committed or not self.charge_id or not self._tenant_id or not self._feature_key:
|
||||
return
|
||||
|
||||
try:
|
||||
from services.billing_service import BillingService
|
||||
|
||||
amount = actual_amount if actual_amount is not None else self._amount
|
||||
BillingService.quota_commit(
|
||||
tenant_id=self._tenant_id,
|
||||
feature_key=self._feature_key,
|
||||
reservation_id=self.charge_id,
|
||||
actual_amount=amount,
|
||||
)
|
||||
self._committed = True
|
||||
logger.debug(
|
||||
"Committed %s quota for tenant %s, reservation_id: %s, amount: %d",
|
||||
self._quota_type,
|
||||
self._tenant_id,
|
||||
self.charge_id,
|
||||
amount,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to commit quota, reservation_id: %s", self.charge_id)
|
||||
|
||||
def refund(self) -> None:
|
||||
"""
|
||||
Release the reserved quota (cancel the charge).
|
||||
|
||||
Safe to call even if:
|
||||
- charge failed or was disabled (charge_id is None)
|
||||
- already committed (Release after Commit is a no-op)
|
||||
- already refunded (idempotent)
|
||||
|
||||
This method guarantees no exceptions will be raised.
|
||||
"""
|
||||
if not self.charge_id or not self._tenant_id or not self._feature_key:
|
||||
return
|
||||
|
||||
QuotaService.release(self._quota_type, self.charge_id, self._tenant_id, self._feature_key)
|
||||
|
||||
|
||||
def unlimited() -> QuotaCharge:
|
||||
from enums.quota_type import QuotaType
|
||||
|
||||
return QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.UNLIMITED)
|
||||
|
||||
|
||||
class QuotaService:
|
||||
"""Orchestrates quota reserve / commit / release lifecycle via BillingService."""
|
||||
|
||||
@staticmethod
|
||||
def consume(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> QuotaCharge:
|
||||
"""
|
||||
Reserve + immediate Commit (one-shot mode).
|
||||
|
||||
The returned QuotaCharge supports .refund() which calls Release.
|
||||
For two-phase usage (e.g. streaming), use reserve() directly.
|
||||
"""
|
||||
charge = QuotaService.reserve(quota_type, tenant_id, amount)
|
||||
if charge.success and charge.charge_id:
|
||||
charge.commit()
|
||||
return charge
|
||||
|
||||
@staticmethod
|
||||
def reserve(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> QuotaCharge:
|
||||
"""
|
||||
Reserve quota before task execution (Reserve phase only).
|
||||
|
||||
The caller MUST call charge.commit() after the task succeeds,
|
||||
or charge.refund() if the task fails.
|
||||
|
||||
Raises:
|
||||
QuotaExceededError: When quota is insufficient
|
||||
"""
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.app import QuotaExceededError
|
||||
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
logger.debug("Billing disabled, allowing request for %s", tenant_id)
|
||||
return QuotaCharge(success=True, charge_id=None, _quota_type=quota_type)
|
||||
|
||||
logger.info("Reserving %d %s quota for tenant %s", amount, quota_type.value, tenant_id)
|
||||
|
||||
if amount <= 0:
|
||||
raise ValueError("Amount to reserve must be greater than 0")
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
feature_key = quota_type.billing_key
|
||||
|
||||
try:
|
||||
reserve_resp = BillingService.quota_reserve(
|
||||
tenant_id=tenant_id,
|
||||
feature_key=feature_key,
|
||||
request_id=request_id,
|
||||
amount=amount,
|
||||
)
|
||||
|
||||
reservation_id = reserve_resp.get("reservation_id")
|
||||
if not reservation_id:
|
||||
logger.warning(
|
||||
"Reserve returned no reservation_id for %s, feature %s, response: %s",
|
||||
tenant_id,
|
||||
quota_type.value,
|
||||
reserve_resp,
|
||||
)
|
||||
raise QuotaExceededError(feature=quota_type.value, tenant_id=tenant_id, required=amount)
|
||||
|
||||
logger.debug(
|
||||
"Reserved %d %s quota for tenant %s, reservation_id: %s",
|
||||
amount,
|
||||
quota_type.value,
|
||||
tenant_id,
|
||||
reservation_id,
|
||||
)
|
||||
return QuotaCharge(
|
||||
success=True,
|
||||
charge_id=reservation_id,
|
||||
_quota_type=quota_type,
|
||||
_tenant_id=tenant_id,
|
||||
_feature_key=feature_key,
|
||||
_amount=amount,
|
||||
)
|
||||
|
||||
except QuotaExceededError:
|
||||
raise
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Failed to reserve quota for %s, feature %s", tenant_id, quota_type.value)
|
||||
return unlimited()
|
||||
|
||||
@staticmethod
|
||||
def check(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> bool:
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
return True
|
||||
|
||||
if amount <= 0:
|
||||
raise ValueError("Amount to check must be greater than 0")
|
||||
|
||||
try:
|
||||
remaining = QuotaService.get_remaining(quota_type, tenant_id)
|
||||
return remaining >= amount if remaining != -1 else True
|
||||
except Exception:
|
||||
logger.exception("Failed to check quota for %s, feature %s", tenant_id, quota_type.value)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def release(quota_type: QuotaType, reservation_id: str, tenant_id: str, feature_key: str) -> None:
|
||||
"""Release a reservation. Guarantees no exceptions."""
|
||||
try:
|
||||
from services.billing_service import BillingService
|
||||
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
return
|
||||
|
||||
if not reservation_id:
|
||||
return
|
||||
|
||||
logger.info("Releasing %s quota, reservation_id: %s", quota_type.value, reservation_id)
|
||||
BillingService.quota_release(
|
||||
tenant_id=tenant_id,
|
||||
feature_key=feature_key,
|
||||
reservation_id=reservation_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to release quota, reservation_id: %s", reservation_id)
|
||||
|
||||
@staticmethod
|
||||
def get_remaining(quota_type: QuotaType, tenant_id: str) -> int:
|
||||
from services.billing_service import BillingService
|
||||
|
||||
try:
|
||||
usage_info = BillingService.get_quota_info(tenant_id)
|
||||
if isinstance(usage_info, dict):
|
||||
feature_info = usage_info.get(quota_type.billing_key, {})
|
||||
if isinstance(feature_info, dict):
|
||||
limit = feature_info.get("limit", 0)
|
||||
usage = feature_info.get("usage", 0)
|
||||
if limit == -1:
|
||||
return -1
|
||||
return max(0, limit - usage)
|
||||
return 0
|
||||
except Exception:
|
||||
logger.exception("Failed to get remaining quota for %s, feature %s", tenant_id, quota_type.value)
|
||||
return -1
|
||||
@@ -38,7 +38,6 @@ from models.workflow import Workflow
|
||||
from services.async_workflow_service import AsyncWorkflowService
|
||||
from services.end_user_service import EndUserService
|
||||
from services.errors.app import QuotaExceededError
|
||||
from services.quota_service import QuotaService
|
||||
from services.trigger.app_trigger_service import AppTriggerService
|
||||
from services.workflow.entities import WebhookTriggerData
|
||||
|
||||
@@ -820,9 +819,9 @@ class WebhookService:
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
# reserve quota before triggering workflow execution
|
||||
# consume quota before triggering workflow execution
|
||||
try:
|
||||
quota_charge = QuotaService.reserve(QuotaType.TRIGGER, webhook_trigger.tenant_id)
|
||||
QuotaType.TRIGGER.consume(webhook_trigger.tenant_id)
|
||||
except QuotaExceededError:
|
||||
AppTriggerService.mark_tenant_triggers_rate_limited(webhook_trigger.tenant_id)
|
||||
logger.info(
|
||||
@@ -833,16 +832,11 @@ class WebhookService:
|
||||
raise
|
||||
|
||||
# Trigger workflow execution asynchronously
|
||||
try:
|
||||
AsyncWorkflowService.trigger_workflow_async(
|
||||
session,
|
||||
end_user,
|
||||
trigger_data,
|
||||
)
|
||||
quota_charge.commit()
|
||||
except Exception:
|
||||
quota_charge.refund()
|
||||
raise
|
||||
AsyncWorkflowService.trigger_workflow_async(
|
||||
session,
|
||||
end_user,
|
||||
trigger_data,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to trigger workflow for webhook %s", webhook_trigger.webhook_id)
|
||||
|
||||
@@ -28,7 +28,7 @@ from core.trigger.entities.entities import TriggerProviderEntity
|
||||
from core.trigger.provider import PluginTriggerProviderController
|
||||
from core.trigger.trigger_manager import TriggerManager
|
||||
from core.workflow.nodes.trigger_plugin.entities import TriggerEventNodeData
|
||||
from enums.quota_type import QuotaType
|
||||
from enums.quota_type import QuotaType, unlimited
|
||||
from models.enums import (
|
||||
AppTriggerType,
|
||||
CreatorUserRole,
|
||||
@@ -42,7 +42,6 @@ from models.workflow import Workflow, WorkflowAppLog, WorkflowAppLogCreatedFrom,
|
||||
from services.async_workflow_service import AsyncWorkflowService
|
||||
from services.end_user_service import EndUserService
|
||||
from services.errors.app import QuotaExceededError
|
||||
from services.quota_service import QuotaService, unlimited
|
||||
from services.trigger.app_trigger_service import AppTriggerService
|
||||
from services.trigger.trigger_provider_service import TriggerProviderService
|
||||
from services.trigger.trigger_request_service import TriggerHttpRequestCachingService
|
||||
@@ -299,10 +298,10 @@ def dispatch_triggered_workflow(
|
||||
icon_dark_filename=trigger_entity.identity.icon_dark or "",
|
||||
)
|
||||
|
||||
# reserve quota before invoking trigger
|
||||
# consume quota before invoking trigger
|
||||
quota_charge = unlimited()
|
||||
try:
|
||||
quota_charge = QuotaService.reserve(QuotaType.TRIGGER, subscription.tenant_id)
|
||||
quota_charge = QuotaType.TRIGGER.consume(subscription.tenant_id)
|
||||
except QuotaExceededError:
|
||||
AppTriggerService.mark_tenant_triggers_rate_limited(subscription.tenant_id)
|
||||
logger.info(
|
||||
@@ -388,7 +387,6 @@ def dispatch_triggered_workflow(
|
||||
raise ValueError(f"End user not found for app {plugin_trigger.app_id}")
|
||||
|
||||
AsyncWorkflowService.trigger_workflow_async(session=session, user=end_user, trigger_data=trigger_data)
|
||||
quota_charge.commit()
|
||||
dispatched_count += 1
|
||||
logger.info(
|
||||
"Triggered workflow for app %s with trigger event %s",
|
||||
|
||||
@@ -8,11 +8,10 @@ from core.workflow.nodes.trigger_schedule.exc import (
|
||||
ScheduleNotFoundError,
|
||||
TenantOwnerNotFoundError,
|
||||
)
|
||||
from enums.quota_type import QuotaType
|
||||
from enums.quota_type import QuotaType, unlimited
|
||||
from models.trigger import WorkflowSchedulePlan
|
||||
from services.async_workflow_service import AsyncWorkflowService
|
||||
from services.errors.app import QuotaExceededError
|
||||
from services.quota_service import QuotaService, unlimited
|
||||
from services.trigger.app_trigger_service import AppTriggerService
|
||||
from services.trigger.schedule_service import ScheduleService
|
||||
from services.workflow.entities import ScheduleTriggerData
|
||||
@@ -44,7 +43,7 @@ def run_schedule_trigger(schedule_id: str) -> None:
|
||||
|
||||
quota_charge = unlimited()
|
||||
try:
|
||||
quota_charge = QuotaService.reserve(QuotaType.TRIGGER, schedule.tenant_id)
|
||||
quota_charge = QuotaType.TRIGGER.consume(schedule.tenant_id)
|
||||
except QuotaExceededError:
|
||||
AppTriggerService.mark_tenant_triggers_rate_limited(schedule.tenant_id)
|
||||
logger.info("Tenant %s rate limited, skipping schedule trigger %s", schedule.tenant_id, schedule_id)
|
||||
@@ -62,7 +61,6 @@ def run_schedule_trigger(schedule_id: str) -> None:
|
||||
tenant_id=schedule.tenant_id,
|
||||
),
|
||||
)
|
||||
quota_charge.commit()
|
||||
logger.info("Schedule %s triggered workflow: %s", schedule_id, response.workflow_trigger_log_id)
|
||||
except Exception as e:
|
||||
quota_charge.refund()
|
||||
|
||||
@@ -36,19 +36,12 @@ class TestAppGenerateService:
|
||||
) as mock_message_based_generator,
|
||||
patch("services.account_service.FeatureService", autospec=True) as mock_account_feature_service,
|
||||
patch("services.app_generate_service.dify_config", autospec=True) as mock_dify_config,
|
||||
patch("services.quota_service.dify_config", autospec=True) as mock_quota_dify_config,
|
||||
patch("configs.dify_config", autospec=True) as mock_global_dify_config,
|
||||
):
|
||||
# Setup default mock returns for billing service
|
||||
mock_billing_service.quota_reserve.return_value = {
|
||||
"reservation_id": "test-reservation-id",
|
||||
"available": 100,
|
||||
"reserved": 1,
|
||||
}
|
||||
mock_billing_service.quota_commit.return_value = {
|
||||
"available": 99,
|
||||
"reserved": 0,
|
||||
"refunded": 0,
|
||||
mock_billing_service.update_tenant_feature_plan_usage.return_value = {
|
||||
"result": "success",
|
||||
"history_id": "test_history_id",
|
||||
}
|
||||
|
||||
# Setup default mock returns for workflow service
|
||||
@@ -108,8 +101,6 @@ class TestAppGenerateService:
|
||||
mock_dify_config.APP_DEFAULT_ACTIVE_REQUESTS = 100
|
||||
mock_dify_config.APP_DAILY_RATE_LIMIT = 1000
|
||||
|
||||
mock_quota_dify_config.BILLING_ENABLED = False
|
||||
|
||||
mock_global_dify_config.BILLING_ENABLED = False
|
||||
mock_global_dify_config.APP_MAX_ACTIVE_REQUESTS = 100
|
||||
mock_global_dify_config.APP_DAILY_RATE_LIMIT = 1000
|
||||
@@ -127,7 +118,6 @@ class TestAppGenerateService:
|
||||
"message_based_generator": mock_message_based_generator,
|
||||
"account_feature_service": mock_account_feature_service,
|
||||
"dify_config": mock_dify_config,
|
||||
"quota_dify_config": mock_quota_dify_config,
|
||||
"global_dify_config": mock_global_dify_config,
|
||||
}
|
||||
|
||||
@@ -475,7 +465,6 @@ class TestAppGenerateService:
|
||||
|
||||
# Set BILLING_ENABLED to True for this test
|
||||
mock_external_service_dependencies["dify_config"].BILLING_ENABLED = True
|
||||
mock_external_service_dependencies["quota_dify_config"].BILLING_ENABLED = True
|
||||
mock_external_service_dependencies["global_dify_config"].BILLING_ENABLED = True
|
||||
|
||||
# Setup test arguments
|
||||
@@ -489,10 +478,8 @@ class TestAppGenerateService:
|
||||
# Verify the result
|
||||
assert result == ["test_response"]
|
||||
|
||||
# Verify billing two-phase quota (reserve + commit)
|
||||
billing = mock_external_service_dependencies["billing_service"]
|
||||
billing.quota_reserve.assert_called_once()
|
||||
billing.quota_commit.assert_called_once()
|
||||
# Verify billing service was called to consume quota
|
||||
mock_external_service_dependencies["billing_service"].update_tenant_feature_plan_usage.assert_called_once()
|
||||
|
||||
def test_generate_with_invalid_app_mode(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
|
||||
@@ -602,9 +602,9 @@ def test_schedule_trigger_creates_trigger_log(
|
||||
)
|
||||
|
||||
# Mock quota to avoid rate limiting
|
||||
from services import quota_service
|
||||
from enums import quota_type
|
||||
|
||||
monkeypatch.setattr(quota_service.QuotaService, "reserve", lambda *_args, **_kwargs: quota_service.unlimited())
|
||||
monkeypatch.setattr(quota_type.QuotaType.TRIGGER, "consume", lambda _tenant_id: quota_type.unlimited())
|
||||
|
||||
# Execute schedule trigger
|
||||
workflow_schedule_tasks.run_schedule_trigger(plan.id)
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
"""Unit tests for QuotaType, QuotaService, and QuotaCharge."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from enums.quota_type import QuotaType
|
||||
from services.quota_service import QuotaCharge, QuotaService, unlimited
|
||||
|
||||
|
||||
class TestQuotaType:
|
||||
def test_billing_key_trigger(self):
|
||||
assert QuotaType.TRIGGER.billing_key == "trigger_event"
|
||||
|
||||
def test_billing_key_workflow(self):
|
||||
assert QuotaType.WORKFLOW.billing_key == "api_rate_limit"
|
||||
|
||||
def test_billing_key_unlimited_raises(self):
|
||||
with pytest.raises(ValueError, match="Invalid quota type"):
|
||||
_ = QuotaType.UNLIMITED.billing_key
|
||||
|
||||
|
||||
class TestQuotaService:
|
||||
def test_reserve_billing_disabled(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService"),
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = False
|
||||
charge = QuotaService.reserve(QuotaType.TRIGGER, "t1")
|
||||
assert charge.success is True
|
||||
assert charge.charge_id is None
|
||||
|
||||
def test_reserve_zero_amount_raises(self):
|
||||
with patch("services.quota_service.dify_config") as mock_cfg:
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
with pytest.raises(ValueError, match="greater than 0"):
|
||||
QuotaService.reserve(QuotaType.TRIGGER, "t1", amount=0)
|
||||
|
||||
def test_reserve_success(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
mock_bs.quota_reserve.return_value = {"reservation_id": "rid-1", "available": 99}
|
||||
|
||||
charge = QuotaService.reserve(QuotaType.TRIGGER, "t1", amount=1)
|
||||
|
||||
assert charge.success is True
|
||||
assert charge.charge_id == "rid-1"
|
||||
assert charge._tenant_id == "t1"
|
||||
assert charge._feature_key == "trigger_event"
|
||||
assert charge._amount == 1
|
||||
mock_bs.quota_reserve.assert_called_once()
|
||||
|
||||
def test_reserve_no_reservation_id_raises(self):
|
||||
from services.errors.app import QuotaExceededError
|
||||
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
mock_bs.quota_reserve.return_value = {}
|
||||
|
||||
with pytest.raises(QuotaExceededError):
|
||||
QuotaService.reserve(QuotaType.TRIGGER, "t1")
|
||||
|
||||
def test_reserve_quota_exceeded_propagates(self):
|
||||
from services.errors.app import QuotaExceededError
|
||||
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
mock_bs.quota_reserve.side_effect = QuotaExceededError(feature="trigger", tenant_id="t1", required=1)
|
||||
|
||||
with pytest.raises(QuotaExceededError):
|
||||
QuotaService.reserve(QuotaType.TRIGGER, "t1")
|
||||
|
||||
def test_reserve_api_exception_returns_unlimited(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
mock_bs.quota_reserve.side_effect = RuntimeError("network")
|
||||
|
||||
charge = QuotaService.reserve(QuotaType.TRIGGER, "t1")
|
||||
assert charge.success is True
|
||||
assert charge.charge_id is None
|
||||
|
||||
def test_consume_calls_reserve_and_commit(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
mock_bs.quota_reserve.return_value = {"reservation_id": "rid-c"}
|
||||
mock_bs.quota_commit.return_value = {}
|
||||
|
||||
charge = QuotaService.consume(QuotaType.TRIGGER, "t1")
|
||||
assert charge.success is True
|
||||
mock_bs.quota_commit.assert_called_once()
|
||||
|
||||
def test_check_billing_disabled(self):
|
||||
with patch("services.quota_service.dify_config") as mock_cfg:
|
||||
mock_cfg.BILLING_ENABLED = False
|
||||
assert QuotaService.check(QuotaType.TRIGGER, "t1") is True
|
||||
|
||||
def test_check_zero_amount_raises(self):
|
||||
with patch("services.quota_service.dify_config") as mock_cfg:
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
with pytest.raises(ValueError, match="greater than 0"):
|
||||
QuotaService.check(QuotaType.TRIGGER, "t1", amount=0)
|
||||
|
||||
def test_check_sufficient_quota(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch.object(QuotaService, "get_remaining", return_value=100),
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=50) is True
|
||||
|
||||
def test_check_insufficient_quota(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch.object(QuotaService, "get_remaining", return_value=5),
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=10) is False
|
||||
|
||||
def test_check_unlimited_quota(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch.object(QuotaService, "get_remaining", return_value=-1),
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=999) is True
|
||||
|
||||
def test_check_exception_returns_true(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch.object(QuotaService, "get_remaining", side_effect=RuntimeError),
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
assert QuotaService.check(QuotaType.TRIGGER, "t1") is True
|
||||
|
||||
def test_release_billing_disabled(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = False
|
||||
QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event")
|
||||
mock_bs.quota_release.assert_not_called()
|
||||
|
||||
def test_release_empty_reservation(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
QuotaService.release(QuotaType.TRIGGER, "", "t1", "trigger_event")
|
||||
mock_bs.quota_release.assert_not_called()
|
||||
|
||||
def test_release_success(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
mock_bs.quota_release.return_value = {}
|
||||
QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event")
|
||||
mock_bs.quota_release.assert_called_once_with(
|
||||
tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1"
|
||||
)
|
||||
|
||||
def test_release_exception_swallowed(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
mock_bs.quota_release.side_effect = RuntimeError("fail")
|
||||
QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event")
|
||||
|
||||
def test_get_remaining_normal(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": 100, "usage": 30}}
|
||||
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 70
|
||||
|
||||
def test_get_remaining_unlimited(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": -1, "usage": 0}}
|
||||
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == -1
|
||||
|
||||
def test_get_remaining_over_limit_returns_zero(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": 10, "usage": 15}}
|
||||
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0
|
||||
|
||||
def test_get_remaining_exception_returns_neg1(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.get_quota_info.side_effect = RuntimeError
|
||||
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == -1
|
||||
|
||||
def test_get_remaining_empty_response(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.get_quota_info.return_value = {}
|
||||
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0
|
||||
|
||||
def test_get_remaining_non_dict_response(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.get_quota_info.return_value = "invalid"
|
||||
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0
|
||||
|
||||
def test_get_remaining_feature_not_in_response(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.get_quota_info.return_value = {"other_feature": {"limit": 100, "usage": 0}}
|
||||
remaining = QuotaService.get_remaining(QuotaType.TRIGGER, "t1")
|
||||
assert remaining == 0
|
||||
|
||||
def test_get_remaining_non_dict_feature_info(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.get_quota_info.return_value = {"trigger_event": "not_a_dict"}
|
||||
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0
|
||||
|
||||
|
||||
class TestQuotaCharge:
|
||||
def test_commit_success(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.quota_commit.return_value = {}
|
||||
charge = QuotaCharge(
|
||||
success=True,
|
||||
charge_id="rid-1",
|
||||
_quota_type=QuotaType.TRIGGER,
|
||||
_tenant_id="t1",
|
||||
_feature_key="trigger_event",
|
||||
_amount=1,
|
||||
)
|
||||
charge.commit()
|
||||
mock_bs.quota_commit.assert_called_once_with(
|
||||
tenant_id="t1",
|
||||
feature_key="trigger_event",
|
||||
reservation_id="rid-1",
|
||||
actual_amount=1,
|
||||
)
|
||||
assert charge._committed is True
|
||||
|
||||
def test_commit_with_actual_amount(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.quota_commit.return_value = {}
|
||||
charge = QuotaCharge(
|
||||
success=True,
|
||||
charge_id="rid-1",
|
||||
_quota_type=QuotaType.TRIGGER,
|
||||
_tenant_id="t1",
|
||||
_feature_key="trigger_event",
|
||||
_amount=10,
|
||||
)
|
||||
charge.commit(actual_amount=5)
|
||||
call_kwargs = mock_bs.quota_commit.call_args[1]
|
||||
assert call_kwargs["actual_amount"] == 5
|
||||
|
||||
def test_commit_idempotent(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.quota_commit.return_value = {}
|
||||
charge = QuotaCharge(
|
||||
success=True,
|
||||
charge_id="rid-1",
|
||||
_quota_type=QuotaType.TRIGGER,
|
||||
_tenant_id="t1",
|
||||
_feature_key="trigger_event",
|
||||
_amount=1,
|
||||
)
|
||||
charge.commit()
|
||||
charge.commit()
|
||||
assert mock_bs.quota_commit.call_count == 1
|
||||
|
||||
def test_commit_no_charge_id_noop(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
charge = QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.TRIGGER)
|
||||
charge.commit()
|
||||
mock_bs.quota_commit.assert_not_called()
|
||||
|
||||
def test_commit_no_tenant_id_noop(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
charge = QuotaCharge(
|
||||
success=True,
|
||||
charge_id="rid-1",
|
||||
_quota_type=QuotaType.TRIGGER,
|
||||
_tenant_id=None,
|
||||
_feature_key="trigger_event",
|
||||
)
|
||||
charge.commit()
|
||||
mock_bs.quota_commit.assert_not_called()
|
||||
|
||||
def test_commit_exception_swallowed(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.quota_commit.side_effect = RuntimeError("fail")
|
||||
charge = QuotaCharge(
|
||||
success=True,
|
||||
charge_id="rid-1",
|
||||
_quota_type=QuotaType.TRIGGER,
|
||||
_tenant_id="t1",
|
||||
_feature_key="trigger_event",
|
||||
_amount=1,
|
||||
)
|
||||
charge.commit()
|
||||
|
||||
def test_refund_success(self):
|
||||
with patch.object(QuotaService, "release") as mock_rel:
|
||||
charge = QuotaCharge(
|
||||
success=True,
|
||||
charge_id="rid-1",
|
||||
_quota_type=QuotaType.TRIGGER,
|
||||
_tenant_id="t1",
|
||||
_feature_key="trigger_event",
|
||||
)
|
||||
charge.refund()
|
||||
mock_rel.assert_called_once_with(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event")
|
||||
|
||||
def test_refund_no_charge_id_noop(self):
|
||||
with patch.object(QuotaService, "release") as mock_rel:
|
||||
charge = QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.TRIGGER)
|
||||
charge.refund()
|
||||
mock_rel.assert_not_called()
|
||||
|
||||
def test_refund_no_tenant_id_noop(self):
|
||||
with patch.object(QuotaService, "release") as mock_rel:
|
||||
charge = QuotaCharge(
|
||||
success=True,
|
||||
charge_id="rid-1",
|
||||
_quota_type=QuotaType.TRIGGER,
|
||||
_tenant_id=None,
|
||||
)
|
||||
charge.refund()
|
||||
mock_rel.assert_not_called()
|
||||
|
||||
|
||||
class TestUnlimited:
|
||||
def test_unlimited_returns_success_with_no_charge_id(self):
|
||||
charge = unlimited()
|
||||
assert charge.success is True
|
||||
assert charge.charge_id is None
|
||||
assert charge._quota_type == QuotaType.UNLIMITED
|
||||
@@ -6,23 +6,23 @@ MODULE = "services.plugin.plugin_auto_upgrade_service"
|
||||
|
||||
|
||||
def _patched_session():
|
||||
"""Patch sessionmaker(bind=db.engine).begin() to return a mock session as context manager."""
|
||||
"""Patch session_factory.create_session() to return a mock session as context manager."""
|
||||
session = MagicMock()
|
||||
mock_sessionmaker = MagicMock()
|
||||
mock_sessionmaker.return_value.begin.return_value.__enter__ = MagicMock(return_value=session)
|
||||
mock_sessionmaker.return_value.begin.return_value.__exit__ = MagicMock(return_value=False)
|
||||
patcher = patch(f"{MODULE}.sessionmaker", mock_sessionmaker)
|
||||
db_patcher = patch(f"{MODULE}.db")
|
||||
return patcher, db_patcher, session
|
||||
session.__enter__ = MagicMock(return_value=session)
|
||||
session.__exit__ = MagicMock(return_value=False)
|
||||
mock_factory = MagicMock()
|
||||
mock_factory.create_session.return_value = session
|
||||
patcher = patch(f"{MODULE}.session_factory", mock_factory)
|
||||
return patcher, session
|
||||
|
||||
|
||||
class TestGetStrategy:
|
||||
def test_returns_strategy_when_found(self):
|
||||
p1, p2, session = _patched_session()
|
||||
p1, session = _patched_session()
|
||||
strategy = MagicMock()
|
||||
session.scalar.return_value = strategy
|
||||
|
||||
with p1, p2:
|
||||
with p1:
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.get_strategy("t1")
|
||||
@@ -30,10 +30,10 @@ class TestGetStrategy:
|
||||
assert result is strategy
|
||||
|
||||
def test_returns_none_when_not_found(self):
|
||||
p1, p2, session = _patched_session()
|
||||
p1, session = _patched_session()
|
||||
session.scalar.return_value = None
|
||||
|
||||
with p1, p2:
|
||||
with p1:
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.get_strategy("t1")
|
||||
@@ -43,10 +43,10 @@ class TestGetStrategy:
|
||||
|
||||
class TestChangeStrategy:
|
||||
def test_creates_new_strategy(self):
|
||||
p1, p2, session = _patched_session()
|
||||
p1, session = _patched_session()
|
||||
session.scalar.return_value = None
|
||||
|
||||
with p1, p2, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.return_value = MagicMock()
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
@@ -63,11 +63,11 @@ class TestChangeStrategy:
|
||||
session.add.assert_called_once()
|
||||
|
||||
def test_updates_existing_strategy(self):
|
||||
p1, p2, session = _patched_session()
|
||||
p1, session = _patched_session()
|
||||
existing = MagicMock()
|
||||
session.scalar.return_value = existing
|
||||
|
||||
with p1, p2:
|
||||
with p1:
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.change_strategy(
|
||||
@@ -89,12 +89,11 @@ class TestChangeStrategy:
|
||||
|
||||
class TestExcludePlugin:
|
||||
def test_creates_default_strategy_when_none_exists(self):
|
||||
p1, p2, session = _patched_session()
|
||||
p1, session = _patched_session()
|
||||
session.scalar.return_value = None
|
||||
|
||||
with (
|
||||
p1,
|
||||
p2,
|
||||
patch(f"{MODULE}.select"),
|
||||
patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls,
|
||||
patch(f"{MODULE}.PluginAutoUpgradeService.change_strategy") as cs,
|
||||
@@ -110,13 +109,13 @@ class TestExcludePlugin:
|
||||
cs.assert_called_once()
|
||||
|
||||
def test_appends_to_exclude_list_in_exclude_mode(self):
|
||||
p1, p2, session = _patched_session()
|
||||
p1, session = _patched_session()
|
||||
existing = MagicMock()
|
||||
existing.upgrade_mode = "exclude"
|
||||
existing.exclude_plugins = ["p-existing"]
|
||||
session.scalar.return_value = existing
|
||||
|
||||
with p1, p2, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.UpgradeMode.EXCLUDE = "exclude"
|
||||
strat_cls.UpgradeMode.PARTIAL = "partial"
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
@@ -128,13 +127,13 @@ class TestExcludePlugin:
|
||||
assert existing.exclude_plugins == ["p-existing", "p-new"]
|
||||
|
||||
def test_removes_from_include_list_in_partial_mode(self):
|
||||
p1, p2, session = _patched_session()
|
||||
p1, session = _patched_session()
|
||||
existing = MagicMock()
|
||||
existing.upgrade_mode = "partial"
|
||||
existing.include_plugins = ["p1", "p2"]
|
||||
session.scalar.return_value = existing
|
||||
|
||||
with p1, p2, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.UpgradeMode.EXCLUDE = "exclude"
|
||||
strat_cls.UpgradeMode.PARTIAL = "partial"
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
@@ -146,12 +145,12 @@ class TestExcludePlugin:
|
||||
assert existing.include_plugins == ["p2"]
|
||||
|
||||
def test_switches_to_exclude_mode_from_all(self):
|
||||
p1, p2, session = _patched_session()
|
||||
p1, session = _patched_session()
|
||||
existing = MagicMock()
|
||||
existing.upgrade_mode = "all"
|
||||
session.scalar.return_value = existing
|
||||
|
||||
with p1, p2, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.UpgradeMode.EXCLUDE = "exclude"
|
||||
strat_cls.UpgradeMode.PARTIAL = "partial"
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
@@ -164,13 +163,13 @@ class TestExcludePlugin:
|
||||
assert existing.exclude_plugins == ["p1"]
|
||||
|
||||
def test_no_duplicate_in_exclude_list(self):
|
||||
p1, p2, session = _patched_session()
|
||||
p1, session = _patched_session()
|
||||
existing = MagicMock()
|
||||
existing.upgrade_mode = "exclude"
|
||||
existing.exclude_plugins = ["p1"]
|
||||
session.scalar.return_value = existing
|
||||
|
||||
with p1, p2, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls:
|
||||
strat_cls.UpgradeMode.EXCLUDE = "exclude"
|
||||
strat_cls.UpgradeMode.PARTIAL = "partial"
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
|
||||
@@ -23,7 +23,6 @@ import pytest
|
||||
|
||||
import services.app_generate_service as ags_module
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from enums.quota_type import QuotaType
|
||||
from models.model import AppMode
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.errors.app import WorkflowIdFormatError, WorkflowNotFoundError
|
||||
@@ -448,8 +447,8 @@ class TestGenerateBilling:
|
||||
def test_billing_enabled_consumes_quota(self, mocker, monkeypatch):
|
||||
monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True)
|
||||
quota_charge = MagicMock()
|
||||
reserve_mock = mocker.patch(
|
||||
"services.app_generate_service.QuotaService.reserve",
|
||||
consume_mock = mocker.patch(
|
||||
"services.app_generate_service.QuotaType.WORKFLOW.consume",
|
||||
return_value=quota_charge,
|
||||
)
|
||||
mocker.patch(
|
||||
@@ -468,8 +467,7 @@ class TestGenerateBilling:
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=False,
|
||||
)
|
||||
reserve_mock.assert_called_once_with(QuotaType.WORKFLOW, "tenant-id")
|
||||
quota_charge.commit.assert_called_once()
|
||||
consume_mock.assert_called_once_with("tenant-id")
|
||||
|
||||
def test_billing_quota_exceeded_raises_rate_limit_error(self, mocker, monkeypatch):
|
||||
from services.errors.app import QuotaExceededError
|
||||
@@ -477,7 +475,7 @@ class TestGenerateBilling:
|
||||
|
||||
monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True)
|
||||
mocker.patch(
|
||||
"services.app_generate_service.QuotaService.reserve",
|
||||
"services.app_generate_service.QuotaType.WORKFLOW.consume",
|
||||
side_effect=QuotaExceededError(feature="workflow", tenant_id="t", required=1),
|
||||
)
|
||||
|
||||
@@ -494,7 +492,7 @@ class TestGenerateBilling:
|
||||
monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True)
|
||||
quota_charge = MagicMock()
|
||||
mocker.patch(
|
||||
"services.app_generate_service.QuotaService.reserve",
|
||||
"services.app_generate_service.QuotaType.WORKFLOW.consume",
|
||||
return_value=quota_charge,
|
||||
)
|
||||
mocker.patch(
|
||||
|
||||
@@ -57,7 +57,7 @@ class TestAsyncWorkflowService:
|
||||
- repo: SQLAlchemyWorkflowTriggerLogRepository
|
||||
- dispatcher_manager_class: QueueDispatcherManager class
|
||||
- dispatcher: dispatcher instance
|
||||
- quota_service: QuotaService mock
|
||||
- quota_workflow: QuotaType.WORKFLOW
|
||||
- get_workflow: AsyncWorkflowService._get_workflow method
|
||||
- professional_task: execute_workflow_professional
|
||||
- team_task: execute_workflow_team
|
||||
@@ -72,7 +72,7 @@ class TestAsyncWorkflowService:
|
||||
mock_repo.create.side_effect = _create_side_effect
|
||||
|
||||
mock_dispatcher = MagicMock()
|
||||
mock_quota_service = MagicMock()
|
||||
quota_workflow = MagicMock()
|
||||
mock_get_workflow = MagicMock()
|
||||
|
||||
mock_professional_task = MagicMock()
|
||||
@@ -93,8 +93,8 @@ class TestAsyncWorkflowService:
|
||||
) as mock_get_workflow,
|
||||
patch.object(
|
||||
async_workflow_service_module,
|
||||
"QuotaService",
|
||||
new=mock_quota_service,
|
||||
"QuotaType",
|
||||
new=SimpleNamespace(WORKFLOW=quota_workflow),
|
||||
),
|
||||
patch.object(async_workflow_service_module, "execute_workflow_professional") as mock_professional_task,
|
||||
patch.object(async_workflow_service_module, "execute_workflow_team") as mock_team_task,
|
||||
@@ -107,7 +107,7 @@ class TestAsyncWorkflowService:
|
||||
"repo": mock_repo,
|
||||
"dispatcher_manager_class": mock_dispatcher_manager_class,
|
||||
"dispatcher": mock_dispatcher,
|
||||
"quota_service": mock_quota_service,
|
||||
"quota_workflow": quota_workflow,
|
||||
"get_workflow": mock_get_workflow,
|
||||
"professional_task": mock_professional_task,
|
||||
"team_task": mock_team_task,
|
||||
@@ -146,9 +146,6 @@ class TestAsyncWorkflowService:
|
||||
mocks["team_task"].delay.return_value = task_result
|
||||
mocks["sandbox_task"].delay.return_value = task_result
|
||||
|
||||
quota_charge_mock = MagicMock()
|
||||
mocks["quota_service"].reserve.return_value = quota_charge_mock
|
||||
|
||||
class DummyAccount:
|
||||
def __init__(self, user_id: str):
|
||||
self.id = user_id
|
||||
@@ -166,8 +163,7 @@ class TestAsyncWorkflowService:
|
||||
assert result.status == "queued"
|
||||
assert result.queue == queue_name
|
||||
|
||||
mocks["quota_service"].reserve.assert_called_once()
|
||||
quota_charge_mock.commit.assert_called_once()
|
||||
mocks["quota_workflow"].consume.assert_called_once_with("tenant-123")
|
||||
assert session.commit.call_count == 2
|
||||
|
||||
created_log = mocks["repo"].create.call_args[0][0]
|
||||
@@ -254,7 +250,7 @@ class TestAsyncWorkflowService:
|
||||
mocks = async_workflow_trigger_mocks
|
||||
mocks["dispatcher"].get_queue_name.return_value = QueuePriority.TEAM
|
||||
mocks["get_workflow"].return_value = workflow
|
||||
mocks["quota_service"].reserve.side_effect = QuotaExceededError(
|
||||
mocks["quota_workflow"].consume.side_effect = QuotaExceededError(
|
||||
feature="workflow",
|
||||
tenant_id="tenant-123",
|
||||
required=1,
|
||||
|
||||
@@ -425,7 +425,7 @@ class TestBillingServiceUsageCalculation:
|
||||
yield mock
|
||||
|
||||
def test_get_tenant_feature_plan_usage_info(self, mock_send_request):
|
||||
"""Test retrieval of tenant feature plan usage information (legacy endpoint)."""
|
||||
"""Test retrieval of tenant feature plan usage information."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
expected_response = {"features": {"trigger": {"used": 50, "limit": 100}, "workflow": {"used": 20, "limit": 50}}}
|
||||
@@ -438,20 +438,6 @@ class TestBillingServiceUsageCalculation:
|
||||
assert result == expected_response
|
||||
mock_send_request.assert_called_once_with("GET", "/tenant-feature-usage/info", params={"tenant_id": tenant_id})
|
||||
|
||||
def test_get_quota_info(self, mock_send_request):
|
||||
"""Test retrieval of quota info from new endpoint."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
expected_response = {"trigger_event": {"limit": 100, "usage": 30}, "api_rate_limit": {"limit": -1, "usage": 0}}
|
||||
mock_send_request.return_value = expected_response
|
||||
|
||||
# Act
|
||||
result = BillingService.get_quota_info(tenant_id)
|
||||
|
||||
# Assert
|
||||
assert result == expected_response
|
||||
mock_send_request.assert_called_once_with("GET", "/quota/info", params={"tenant_id": tenant_id})
|
||||
|
||||
def test_update_tenant_feature_plan_usage_positive_delta(self, mock_send_request):
|
||||
"""Test updating tenant feature usage with positive delta (adding credits)."""
|
||||
# Arrange
|
||||
@@ -529,150 +515,6 @@ class TestBillingServiceUsageCalculation:
|
||||
)
|
||||
|
||||
|
||||
class TestBillingServiceQuotaOperations:
|
||||
"""Unit tests for quota reserve/commit/release operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_send_request(self):
|
||||
with patch.object(BillingService, "_send_request") as mock:
|
||||
yield mock
|
||||
|
||||
def test_quota_reserve_success(self, mock_send_request):
|
||||
expected = {"reservation_id": "rid-1", "available": 99, "reserved": 1}
|
||||
mock_send_request.return_value = expected
|
||||
|
||||
result = BillingService.quota_reserve(tenant_id="t1", feature_key="trigger_event", request_id="req-1", amount=1)
|
||||
|
||||
assert result == expected
|
||||
mock_send_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/quota/reserve",
|
||||
json={"tenant_id": "t1", "feature_key": "trigger_event", "request_id": "req-1", "amount": 1},
|
||||
)
|
||||
|
||||
def test_quota_reserve_coerces_string_to_int(self, mock_send_request):
|
||||
"""Test that TypeAdapter coerces string values to int."""
|
||||
mock_send_request.return_value = {"reservation_id": "rid-str", "available": "99", "reserved": "1"}
|
||||
|
||||
result = BillingService.quota_reserve(tenant_id="t1", feature_key="trigger_event", request_id="req-s", amount=1)
|
||||
|
||||
assert result["available"] == 99
|
||||
assert isinstance(result["available"], int)
|
||||
assert result["reserved"] == 1
|
||||
assert isinstance(result["reserved"], int)
|
||||
|
||||
def test_quota_reserve_with_meta(self, mock_send_request):
|
||||
mock_send_request.return_value = {"reservation_id": "rid-2", "available": 98, "reserved": 1}
|
||||
meta = {"source": "webhook"}
|
||||
|
||||
BillingService.quota_reserve(
|
||||
tenant_id="t1", feature_key="trigger_event", request_id="req-2", amount=1, meta=meta
|
||||
)
|
||||
|
||||
call_json = mock_send_request.call_args[1]["json"]
|
||||
assert call_json["meta"] == {"source": "webhook"}
|
||||
|
||||
def test_quota_commit_success(self, mock_send_request):
|
||||
expected = {"available": 98, "reserved": 0, "refunded": 0}
|
||||
mock_send_request.return_value = expected
|
||||
|
||||
result = BillingService.quota_commit(
|
||||
tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1", actual_amount=1
|
||||
)
|
||||
|
||||
assert result == expected
|
||||
mock_send_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/quota/commit",
|
||||
json={
|
||||
"tenant_id": "t1",
|
||||
"feature_key": "trigger_event",
|
||||
"reservation_id": "rid-1",
|
||||
"actual_amount": 1,
|
||||
},
|
||||
)
|
||||
|
||||
def test_quota_commit_coerces_string_to_int(self, mock_send_request):
|
||||
"""Test that TypeAdapter coerces string values to int."""
|
||||
mock_send_request.return_value = {"available": "97", "reserved": "0", "refunded": "1"}
|
||||
|
||||
result = BillingService.quota_commit(
|
||||
tenant_id="t1", feature_key="trigger_event", reservation_id="rid-s", actual_amount=1
|
||||
)
|
||||
|
||||
assert result["available"] == 97
|
||||
assert isinstance(result["available"], int)
|
||||
assert result["refunded"] == 1
|
||||
assert isinstance(result["refunded"], int)
|
||||
|
||||
def test_quota_commit_with_meta(self, mock_send_request):
|
||||
mock_send_request.return_value = {"available": 97, "reserved": 0, "refunded": 0}
|
||||
meta = {"reason": "partial"}
|
||||
|
||||
BillingService.quota_commit(
|
||||
tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1", actual_amount=1, meta=meta
|
||||
)
|
||||
|
||||
call_json = mock_send_request.call_args[1]["json"]
|
||||
assert call_json["meta"] == {"reason": "partial"}
|
||||
|
||||
def test_quota_release_success(self, mock_send_request):
|
||||
expected = {"available": 100, "reserved": 0, "released": 1}
|
||||
mock_send_request.return_value = expected
|
||||
|
||||
result = BillingService.quota_release(tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1")
|
||||
|
||||
assert result == expected
|
||||
mock_send_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/quota/release",
|
||||
json={"tenant_id": "t1", "feature_key": "trigger_event", "reservation_id": "rid-1"},
|
||||
)
|
||||
|
||||
def test_quota_release_coerces_string_to_int(self, mock_send_request):
|
||||
"""Test that TypeAdapter coerces string values to int."""
|
||||
mock_send_request.return_value = {"available": "100", "reserved": "0", "released": "1"}
|
||||
|
||||
result = BillingService.quota_release(tenant_id="t1", feature_key="trigger_event", reservation_id="rid-s")
|
||||
|
||||
assert result["available"] == 100
|
||||
assert isinstance(result["available"], int)
|
||||
assert result["released"] == 1
|
||||
assert isinstance(result["released"], int)
|
||||
|
||||
def test_get_quota_info_coerces_string_to_int(self, mock_send_request):
|
||||
"""Test that TypeAdapter coerces string values to int for get_quota_info."""
|
||||
mock_send_request.return_value = {
|
||||
"trigger_event": {"usage": "42", "limit": "3000", "reset_date": "1700000000"},
|
||||
"api_rate_limit": {"usage": "10", "limit": "-1", "reset_date": "-1"},
|
||||
}
|
||||
|
||||
result = BillingService.get_quota_info("t1")
|
||||
|
||||
assert result["trigger_event"]["usage"] == 42
|
||||
assert isinstance(result["trigger_event"]["usage"], int)
|
||||
assert result["trigger_event"]["limit"] == 3000
|
||||
assert isinstance(result["trigger_event"]["limit"], int)
|
||||
assert result["trigger_event"]["reset_date"] == 1700000000
|
||||
assert isinstance(result["trigger_event"]["reset_date"], int)
|
||||
assert result["api_rate_limit"]["limit"] == -1
|
||||
assert isinstance(result["api_rate_limit"]["limit"], int)
|
||||
|
||||
def test_get_quota_info_accepts_int_values(self, mock_send_request):
|
||||
"""Test that get_quota_info works with native int values."""
|
||||
expected = {
|
||||
"trigger_event": {"usage": 42, "limit": 3000, "reset_date": 1700000000},
|
||||
"api_rate_limit": {"usage": 0, "limit": -1},
|
||||
}
|
||||
mock_send_request.return_value = expected
|
||||
|
||||
result = BillingService.get_quota_info("t1")
|
||||
|
||||
assert result["trigger_event"]["usage"] == 42
|
||||
assert result["trigger_event"]["limit"] == 3000
|
||||
assert result["api_rate_limit"]["limit"] == -1
|
||||
|
||||
|
||||
class TestBillingServiceRateLimitEnforcement:
|
||||
"""Unit tests for rate limit enforcement mechanisms.
|
||||
|
||||
|
||||
@@ -1115,11 +1115,12 @@ def test_trigger_workflow_execution_should_mark_tenant_rate_limited_when_quota_e
|
||||
"get_or_create_end_user_by_type",
|
||||
MagicMock(return_value=SimpleNamespace(id="end-user-1")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service_module.QuotaService,
|
||||
"reserve",
|
||||
MagicMock(side_effect=QuotaExceededError(feature="trigger", tenant_id="tenant-1", required=1)),
|
||||
quota_type = SimpleNamespace(
|
||||
TRIGGER=SimpleNamespace(
|
||||
consume=MagicMock(side_effect=QuotaExceededError(feature="trigger", tenant_id="tenant-1", required=1))
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(service_module, "QuotaType", quota_type)
|
||||
mark_rate_limited_mock = MagicMock()
|
||||
monkeypatch.setattr(service_module.AppTriggerService, "mark_tenant_triggers_rate_limited", mark_rate_limited_mock)
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 563 B |
@@ -0,0 +1,11 @@
|
||||
import Evaluation from '@/app/components/evaluation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ appId: string }>
|
||||
}) => {
|
||||
const { appId } = await props.params
|
||||
|
||||
return <Evaluation resourceType="apps" resourceId={appId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
RiDashboard2Line,
|
||||
RiFileList3Fill,
|
||||
RiFileList3Line,
|
||||
RiFlaskFill,
|
||||
RiFlaskLine,
|
||||
RiTerminalBoxFill,
|
||||
RiTerminalBoxLine,
|
||||
RiTerminalWindowFill,
|
||||
@@ -67,40 +69,47 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
}>>([])
|
||||
|
||||
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
|
||||
const navConfig = [
|
||||
...(isCurrentWorkspaceEditor
|
||||
? [{
|
||||
name: t('appMenus.promptEng', { ns: 'common' }),
|
||||
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
|
||||
icon: RiTerminalWindowLine,
|
||||
selectedIcon: RiTerminalWindowFill,
|
||||
}]
|
||||
: []
|
||||
),
|
||||
{
|
||||
name: t('appMenus.apiAccess', { ns: 'common' }),
|
||||
href: `/app/${appId}/develop`,
|
||||
icon: RiTerminalBoxLine,
|
||||
selectedIcon: RiTerminalBoxFill,
|
||||
},
|
||||
...(isCurrentWorkspaceEditor
|
||||
? [{
|
||||
name: mode !== AppModeEnum.WORKFLOW
|
||||
? t('appMenus.logAndAnn', { ns: 'common' })
|
||||
: t('appMenus.logs', { ns: 'common' }),
|
||||
href: `/app/${appId}/logs`,
|
||||
icon: RiFileList3Line,
|
||||
selectedIcon: RiFileList3Fill,
|
||||
}]
|
||||
: []
|
||||
),
|
||||
{
|
||||
name: t('appMenus.overview', { ns: 'common' }),
|
||||
href: `/app/${appId}/overview`,
|
||||
icon: RiDashboard2Line,
|
||||
selectedIcon: RiDashboard2Fill,
|
||||
},
|
||||
]
|
||||
const navConfig = []
|
||||
|
||||
if (isCurrentWorkspaceEditor) {
|
||||
navConfig.push({
|
||||
name: t('appMenus.promptEng', { ns: 'common' }),
|
||||
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
|
||||
icon: RiTerminalWindowLine,
|
||||
selectedIcon: RiTerminalWindowFill,
|
||||
})
|
||||
navConfig.push({
|
||||
name: t('appMenus.evaluation', { ns: 'common' }),
|
||||
href: `/app/${appId}/evaluation`,
|
||||
icon: RiFlaskLine,
|
||||
selectedIcon: RiFlaskFill,
|
||||
})
|
||||
}
|
||||
|
||||
navConfig.push({
|
||||
name: t('appMenus.apiAccess', { ns: 'common' }),
|
||||
href: `/app/${appId}/develop`,
|
||||
icon: RiTerminalBoxLine,
|
||||
selectedIcon: RiTerminalBoxFill,
|
||||
})
|
||||
|
||||
if (isCurrentWorkspaceEditor) {
|
||||
navConfig.push({
|
||||
name: mode !== AppModeEnum.WORKFLOW
|
||||
? t('appMenus.logAndAnn', { ns: 'common' })
|
||||
: t('appMenus.logs', { ns: 'common' }),
|
||||
href: `/app/${appId}/logs`,
|
||||
icon: RiFileList3Line,
|
||||
selectedIcon: RiFileList3Fill,
|
||||
})
|
||||
}
|
||||
|
||||
navConfig.push({
|
||||
name: t('appMenus.overview', { ns: 'common' }),
|
||||
href: `/app/${appId}/overview`,
|
||||
icon: RiDashboard2Line,
|
||||
selectedIcon: RiDashboard2Fill,
|
||||
})
|
||||
return navConfig
|
||||
}, [t])
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
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
|
||||
|
||||
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-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()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import Evaluation from '@/app/components/evaluation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ datasetId: string }>
|
||||
}) => {
|
||||
const { datasetId } = await props.params
|
||||
|
||||
return <Evaluation resourceType="datasets" resourceId={datasetId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
RiEqualizer2Line,
|
||||
RiFileTextFill,
|
||||
RiFileTextLine,
|
||||
RiFlaskFill,
|
||||
RiFlaskLine,
|
||||
RiFocus2Fill,
|
||||
RiFocus2Line,
|
||||
} from '@remixicon/react'
|
||||
@@ -56,6 +58,7 @@ 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)
|
||||
@@ -86,24 +89,36 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
]
|
||||
|
||||
if (datasetRes?.provider !== 'external') {
|
||||
baseNavigation.unshift({
|
||||
name: t('datasetMenus.pipeline', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/pipeline`,
|
||||
icon: PipelineLine as RemixiconComponentType,
|
||||
selectedIcon: PipelineFill as RemixiconComponentType,
|
||||
disabled: false,
|
||||
})
|
||||
baseNavigation.unshift({
|
||||
name: t('datasetMenus.documents', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/documents`,
|
||||
icon: RiFileTextLine,
|
||||
selectedIcon: RiFileTextFill,
|
||||
disabled: isButtonDisabledWithPipeline,
|
||||
})
|
||||
return [
|
||||
{
|
||||
name: t('datasetMenus.documents', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/documents`,
|
||||
icon: RiFileTextLine,
|
||||
selectedIcon: RiFileTextFill,
|
||||
disabled: isButtonDisabledWithPipeline,
|
||||
},
|
||||
{
|
||||
name: t('datasetMenus.pipeline', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/pipeline`,
|
||||
icon: PipelineLine as RemixiconComponentType,
|
||||
selectedIcon: PipelineFill as RemixiconComponentType,
|
||||
disabled: false,
|
||||
},
|
||||
...(isRagPipelineDataset
|
||||
? [{
|
||||
name: t('datasetMenus.evaluation', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/evaluation`,
|
||||
icon: RiFlaskLine,
|
||||
selectedIcon: RiFlaskFill,
|
||||
disabled: isButtonDisabledWithPipeline,
|
||||
}]
|
||||
: []),
|
||||
...baseNavigation,
|
||||
]
|
||||
}
|
||||
|
||||
return baseNavigation
|
||||
}, [t, datasetId, isButtonDisabledWithPipeline, datasetRes?.provider])
|
||||
}, [t, datasetId, isButtonDisabledWithPipeline, isRagPipelineDataset, 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', '/explore', '/tools'] as const
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
|
||||
|
||||
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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
|
||||
@@ -0,0 +1,11 @@
|
||||
import SnippetPage from '@/app/components/snippets'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
return <SnippetPage snippetId={snippetId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal file
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import Page from './page'
|
||||
|
||||
const mockRedirect = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
redirect: (path: string) => mockRedirect(path),
|
||||
}))
|
||||
|
||||
describe('snippet detail redirect page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should redirect legacy snippet detail routes to orchestrate', async () => {
|
||||
await Page({
|
||||
params: Promise.resolve({ snippetId: 'snippet-1' }),
|
||||
})
|
||||
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
|
||||
})
|
||||
})
|
||||
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
redirect(`/snippets/${snippetId}/orchestrate`)
|
||||
}
|
||||
|
||||
export default Page
|
||||
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import Apps from '@/app/components/apps'
|
||||
|
||||
const SnippetsPage = () => {
|
||||
return <Apps pageType="snippets" />
|
||||
}
|
||||
|
||||
export default SnippetsPage
|
||||
@@ -165,6 +165,21 @@ describe('AppDetailNav', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom header and navigation when provided', () => {
|
||||
render(
|
||||
<AppDetailNav
|
||||
navigation={navigation}
|
||||
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
|
||||
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow canvas mode', () => {
|
||||
|
||||
@@ -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 } from '@/types/app'
|
||||
import { AppModeEnum, AppTypeEnum } from '@/types/app'
|
||||
import AppInfoDetailPanel from '../app-info-detail-panel'
|
||||
|
||||
vi.mock('../../../base/app-icon', () => ({
|
||||
@@ -135,6 +135,17 @@ 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 { useTranslation } from 'react-i18next'
|
||||
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ContentDialog from '@/app/components/base/content-dialog'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AppModeEnum, AppTypeEnum } from '@/types/app'
|
||||
import AppIcon from '../../base/app-icon'
|
||||
import { getAppModeLabel } from './app-mode-labels'
|
||||
import AppOperations from './app-operations'
|
||||
@@ -97,7 +97,7 @@ const AppInfoDetailPanel = ({
|
||||
<ContentDialog
|
||||
show={show}
|
||||
onClose={onClose}
|
||||
className="absolute bottom-2 left-2 top-2 flex w-[420px] flex-col rounded-2xl p-0!"
|
||||
className="absolute top-2 bottom-2 left-2 flex w-[420px] flex-col rounded-2xl p-0!"
|
||||
>
|
||||
<div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4">
|
||||
<div className="flex items-center gap-3 self-stretch">
|
||||
@@ -109,14 +109,14 @@ const AppInfoDetailPanel = ({
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col items-start justify-center overflow-hidden">
|
||||
<div className="w-full truncate text-text-secondary system-md-semibold">{appDetail.name}</div>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">
|
||||
<div className="w-full truncate system-md-semibold text-text-secondary">{appDetail.name}</div>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
{getAppModeLabel(appDetail.mode, t)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{appDetail.description && (
|
||||
<div className="overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto whitespace-normal wrap-break-word text-text-tertiary system-xs-regular">
|
||||
<div className="overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto system-xs-regular wrap-break-word whitespace-normal text-text-tertiary">
|
||||
{appDetail.description}
|
||||
</div>
|
||||
)}
|
||||
@@ -126,11 +126,13 @@ const AppInfoDetailPanel = ({
|
||||
secondaryOperations={secondaryOperations}
|
||||
/>
|
||||
</div>
|
||||
<CardView
|
||||
appId={appDetail.id}
|
||||
isInPanel={true}
|
||||
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
|
||||
/>
|
||||
{appDetail.type !== AppTypeEnum.EVALUATION && (
|
||||
<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
|
||||
@@ -140,7 +142,7 @@ const AppInfoDetailPanel = ({
|
||||
onClick={switchOperation.onClick}
|
||||
>
|
||||
{switchOperation.icon}
|
||||
<span className="text-text-tertiary system-sm-medium">{switchOperation.title}</span>
|
||||
<span className="system-sm-medium text-text-tertiary">{switchOperation.title}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -27,12 +27,16 @@ type IAppDetailNavProps = {
|
||||
disabled?: boolean
|
||||
}>
|
||||
extraInfo?: (modeState: string) => React.ReactNode
|
||||
renderHeader?: (modeState: string) => React.ReactNode
|
||||
renderNavigation?: (modeState: string) => React.ReactNode
|
||||
}
|
||||
|
||||
const AppDetailNav = ({
|
||||
navigation,
|
||||
extraInfo,
|
||||
iconType = 'app',
|
||||
renderHeader,
|
||||
renderNavigation,
|
||||
}: IAppDetailNavProps) => {
|
||||
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
|
||||
appSidebarExpand: state.appSidebarExpand,
|
||||
@@ -104,10 +108,11 @@ const AppDetailNav = ({
|
||||
expand ? 'p-2' : 'p-1',
|
||||
)}
|
||||
>
|
||||
{iconType === 'app' && (
|
||||
{renderHeader?.(appSidebarExpand)}
|
||||
{!renderHeader && iconType === 'app' && (
|
||||
<AppInfo expand={expand} />
|
||||
)}
|
||||
{iconType !== 'app' && (
|
||||
{!renderHeader && iconType !== 'app' && (
|
||||
<DatasetInfo expand={expand} />
|
||||
)}
|
||||
</div>
|
||||
@@ -136,7 +141,8 @@ const AppDetailNav = ({
|
||||
expand ? 'px-3 py-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
{navigation.map((item, index) => {
|
||||
{renderNavigation?.(appSidebarExpand)}
|
||||
{!renderNavigation && navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
|
||||
@@ -262,4 +262,20 @@ describe('NavLink Animation and Layout Issues', () => {
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button Mode', () => {
|
||||
it('should render as an interactive button when href is omitted', () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
|
||||
|
||||
const buttonElement = screen.getByText('Orchestrate').closest('button')
|
||||
expect(buttonElement).not.toBeNull()
|
||||
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
|
||||
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
|
||||
|
||||
buttonElement?.click()
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,13 +14,15 @@ export type NavIcon = React.ComponentType<
|
||||
|
||||
export type NavLinkProps = {
|
||||
name: string
|
||||
href: string
|
||||
href?: string
|
||||
iconMap: {
|
||||
selected: NavIcon
|
||||
normal: NavIcon
|
||||
}
|
||||
mode?: string
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const NavLink = ({
|
||||
@@ -29,6 +31,8 @@ const NavLink = ({
|
||||
iconMap,
|
||||
mode = 'expand',
|
||||
disabled = false,
|
||||
active,
|
||||
onClick,
|
||||
}: NavLinkProps) => {
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const formattedSegment = (() => {
|
||||
@@ -39,8 +43,11 @@ const NavLink = ({
|
||||
|
||||
return res
|
||||
})()
|
||||
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
|
||||
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
|
||||
const NavIcon = isActive ? iconMap.selected : iconMap.normal
|
||||
const linkClassName = cn(isActive
|
||||
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
|
||||
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
|
||||
|
||||
const renderIcon = () => (
|
||||
<div className={cn(mode !== 'expand' && '-ml-1')}>
|
||||
@@ -70,13 +77,32 @@ const NavLink = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (!href) {
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
onClick={onClick}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span
|
||||
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
|
||||
? 'ml-2 max-w-none opacity-100'
|
||||
: 'ml-0 max-w-0 opacity-0')}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={name}
|
||||
href={href}
|
||||
className={cn(isActive
|
||||
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
|
||||
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')}
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
>
|
||||
{renderIcon()}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
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('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/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.',
|
||||
author: 'Dify',
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
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.',
|
||||
author: 'Dify',
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
197
web/app/components/app-sidebar/snippet-info/dropdown.tsx
Normal file
197
web/app/components/app-sidebar/snippet-info/dropdown.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@/app/components/base/ui/alert-dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
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="text-text-primary title-lg-semi-bold">
|
||||
{t('deleteConfirmTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-text-tertiary system-sm-regular">
|
||||
{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)
|
||||
55
web/app/components/app-sidebar/snippet-info/index.tsx
Normal file
55
web/app/components/app-sidebar/snippet-info/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { cn } from '@/utils/classnames'
|
||||
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 pb-1 pt-2' : '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="truncate text-text-secondary system-md-semibold">
|
||||
{snippet.name}
|
||||
</div>
|
||||
<div className="pt-1 text-text-tertiary system-2xs-medium-uppercase">
|
||||
{t('typeLabel')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{expand && snippet.description && (
|
||||
<p className="line-clamp-3 break-words text-text-tertiary system-xs-regular">
|
||||
{snippet.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfo)
|
||||
@@ -2,7 +2,7 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AppModeEnum, AppTypeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import AppPublisher from '../index'
|
||||
|
||||
@@ -15,6 +15,7 @@ const mockOpenAsyncWindow = vi.fn()
|
||||
const mockFetchInstalledAppList = vi.fn()
|
||||
const mockFetchAppDetailDirect = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockConvertWorkflowType = vi.fn()
|
||||
|
||||
const sectionProps = vi.hoisted(() => ({
|
||||
summary: null as null | Record<string, any>,
|
||||
@@ -88,6 +89,13 @@ vi.mock('@/service/apps', () => ({
|
||||
fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useConvertWorkflowTypeMutation: () => ({
|
||||
mutateAsync: (...args: unknown[]) => mockConvertWorkflowType(...args),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
@@ -124,15 +132,15 @@ vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
|
||||
|
||||
return {
|
||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
|
||||
<OpenContext.Provider value={open}>
|
||||
<OpenContext value={open}>
|
||||
<div>{children}</div>
|
||||
</OpenContext.Provider>
|
||||
</OpenContext>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
|
||||
<div onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
|
||||
const open = ReactModule.useContext(OpenContext)
|
||||
const open = ReactModule.use(OpenContext)
|
||||
return open ? <div>{children}</div> : null
|
||||
},
|
||||
}
|
||||
@@ -145,6 +153,7 @@ 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>
|
||||
)
|
||||
},
|
||||
@@ -175,6 +184,7 @@ describe('AppPublisher', () => {
|
||||
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',
|
||||
@@ -187,6 +197,7 @@ describe('AppPublisher', () => {
|
||||
id: 'app-1',
|
||||
access_mode: AccessMode.PUBLIC,
|
||||
})
|
||||
mockConvertWorkflowType.mockResolvedValue({})
|
||||
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
|
||||
await resolver()
|
||||
})
|
||||
@@ -452,4 +463,78 @@ describe('AppPublisher', () => {
|
||||
})
|
||||
expect(screen.getByTestId('access-control')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch workflow type, refresh app detail, and close the popover for published apps', async () => {
|
||||
mockFetchAppDetailDirect.mockResolvedValueOnce({
|
||||
id: 'app-1',
|
||||
type: 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',
|
||||
type: AppTypeEnum.EVALUATION,
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide access and actions sections for evaluation workflow apps', () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
type: 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 block switching to evaluation workflow when restricted nodes exist', async () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,12 +45,14 @@ 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}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -83,12 +85,14 @@ 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}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -107,12 +111,14 @@ 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}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -131,18 +137,85 @@ 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,6 +1,7 @@
|
||||
import type { ModelAndParameter } from '../configuration/debug/types'
|
||||
import type { InputVar, Variable } from '@/app/components/workflow/types'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import type { I18nKeysWithPrefix } from '@/types/i18n'
|
||||
import type { PublishWorkflowParams, WorkflowTypeConversionTarget } from '@/types/workflow'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import {
|
||||
memo,
|
||||
@@ -26,7 +27,8 @@ import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { fetchAppDetailDirect } from '@/service/apps'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { useConvertWorkflowTypeMutation } from '@/service/use-apps'
|
||||
import { AppModeEnum, AppTypeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import { toast } from '../../base/ui/toast'
|
||||
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
|
||||
@@ -68,6 +70,32 @@ export type AppPublisherProps = {
|
||||
|
||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||
|
||||
type WorkflowTypeSwitchLabelKey = I18nKeysWithPrefix<'workflow', 'common.'>
|
||||
|
||||
const WORKFLOW_TYPE_SWITCH_CONFIG: Record<WorkflowTypeConversionTarget, {
|
||||
targetType: WorkflowTypeConversionTarget
|
||||
publishLabelKey: WorkflowTypeSwitchLabelKey
|
||||
switchLabelKey: WorkflowTypeSwitchLabelKey
|
||||
tipKey: WorkflowTypeSwitchLabelKey
|
||||
}> = {
|
||||
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 isWorkflowTypeConversionTarget = (type?: AppTypeEnum): type is WorkflowTypeConversionTarget => {
|
||||
return type === 'workflow' || type === 'evaluation'
|
||||
}
|
||||
|
||||
const AppPublisher = ({
|
||||
disabled = false,
|
||||
publishDisabled = false,
|
||||
@@ -102,9 +130,23 @@ const AppPublisher = ({
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
|
||||
const { mutateAsync: convertWorkflowType, isPending: isConvertingWorkflowType } = useConvertWorkflowTypeMutation()
|
||||
|
||||
const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode })
|
||||
const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT)
|
||||
const workflowTypeSwitchConfig = isWorkflowTypeConversionTarget(appDetail?.type)
|
||||
? WORKFLOW_TYPE_SWITCH_CONFIG[appDetail.type]
|
||||
: undefined
|
||||
const isEvaluationWorkflowType = appDetail?.type === AppTypeEnum.EVALUATION
|
||||
const workflowTypeSwitchDisabledReason = useMemo(() => {
|
||||
if (workflowTypeSwitchConfig?.targetType !== AppTypeEnum.EVALUATION)
|
||||
return undefined
|
||||
|
||||
if (!hasHumanInputNode && !hasTriggerNode)
|
||||
return undefined
|
||||
|
||||
return t('common.switchToEvaluationWorkflowDisabledTip', { ns: 'workflow' })
|
||||
}, [hasHumanInputNode, hasTriggerNode, t, workflowTypeSwitchConfig?.targetType])
|
||||
|
||||
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
|
||||
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
@@ -192,6 +234,39 @@ const AppPublisher = ({
|
||||
}
|
||||
}, [appDetail, setAppDetail])
|
||||
|
||||
const handleWorkflowTypeSwitch = useCallback(async () => {
|
||||
if (!appDetail?.id || !workflowTypeSwitchConfig)
|
||||
return
|
||||
if (workflowTypeSwitchDisabledReason) {
|
||||
toast.error(workflowTypeSwitchDisabledReason)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await convertWorkflowType({
|
||||
params: {
|
||||
appId: appDetail.id,
|
||||
},
|
||||
query: {
|
||||
target_type: workflowTypeSwitchConfig.targetType,
|
||||
},
|
||||
})
|
||||
|
||||
if (!publishedAt)
|
||||
await handlePublish()
|
||||
|
||||
const latestAppDetail = await fetchAppDetailDirect({
|
||||
url: '/apps',
|
||||
id: appDetail.id,
|
||||
})
|
||||
setAppDetail(latestAppDetail)
|
||||
|
||||
if (publishedAt)
|
||||
setOpen(false)
|
||||
}
|
||||
catch { }
|
||||
}, [appDetail?.id, convertWorkflowType, handlePublish, publishedAt, setAppDetail, workflowTypeSwitchConfig, workflowTypeSwitchDisabledReason])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
|
||||
e.preventDefault()
|
||||
if (publishDisabled || published)
|
||||
@@ -224,7 +299,7 @@ const AppPublisher = ({
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="py-2 pl-3 pr-2"
|
||||
className="py-2 pr-2 pl-3"
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('common.publish', { ns: 'workflow' })}
|
||||
@@ -247,37 +322,45 @@ const AppPublisher = ({
|
||||
publishShortcut={PUBLISH_SHORTCUT}
|
||||
startNodeLimitExceeded={startNodeLimitExceeded}
|
||||
upgradeHighlightStyle={upgradeHighlightStyle}
|
||||
workflowTypeSwitchConfig={workflowTypeSwitchConfig}
|
||||
workflowTypeSwitchDisabled={publishDisabled || published || isConvertingWorkflowType || Boolean(workflowTypeSwitchDisabledReason)}
|
||||
workflowTypeSwitchDisabledReason={workflowTypeSwitchDisabledReason}
|
||||
onWorkflowTypeSwitch={handleWorkflowTypeSwitch}
|
||||
/>
|
||||
<PublisherAccessSection
|
||||
enabled={systemFeatures.webapp_auth.enabled}
|
||||
isAppAccessSet={isAppAccessSet}
|
||||
isLoading={Boolean(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))}
|
||||
accessMode={appDetail?.access_mode}
|
||||
onClick={() => setShowAppAccessControl(true)}
|
||||
/>
|
||||
<PublisherActionsSection
|
||||
appDetail={appDetail}
|
||||
appURL={appURL}
|
||||
disabledFunctionButton={disabledFunctionButton}
|
||||
disabledFunctionTooltip={disabledFunctionTooltip}
|
||||
handleEmbed={() => {
|
||||
setEmbeddingModalOpen(true)
|
||||
handleTrigger()
|
||||
}}
|
||||
handleOpenInExplore={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}
|
||||
/>
|
||||
{!isEvaluationWorkflowType && (
|
||||
<>
|
||||
<PublisherAccessSection
|
||||
enabled={systemFeatures.webapp_auth.enabled}
|
||||
isAppAccessSet={isAppAccessSet}
|
||||
isLoading={Boolean(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))}
|
||||
accessMode={appDetail?.access_mode}
|
||||
onClick={() => setShowAppAccessControl(true)}
|
||||
/>
|
||||
<PublisherActionsSection
|
||||
appDetail={appDetail}
|
||||
appURL={appURL}
|
||||
disabledFunctionButton={disabledFunctionButton}
|
||||
disabledFunctionTooltip={disabledFunctionTooltip}
|
||||
handleEmbed={() => {
|
||||
setEmbeddingModalOpen(true)
|
||||
handleTrigger()
|
||||
}}
|
||||
handleOpenInExplore={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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
<EmbeddedModal
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import type { ModelAndParameter } from '../configuration/debug/types'
|
||||
import type { AppPublisherProps } from './index'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import type { I18nKeysWithPrefix } from '@/types/i18n'
|
||||
import type { PublishWorkflowParams, WorkflowTypeConversionTarget } from '@/types/workflow'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
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 {
|
||||
Tooltip,
|
||||
@@ -21,6 +21,8 @@ import PublishWithMultipleModel from './publish-with-multiple-model'
|
||||
import SuggestedAction from './suggested-action'
|
||||
import { ACCESS_MODE_MAP } from './utils'
|
||||
|
||||
type WorkflowTypeSwitchLabelKey = I18nKeysWithPrefix<'workflow', 'common.'>
|
||||
|
||||
type SummarySectionProps = Pick<AppPublisherProps, | 'debugWithMultipleModel'
|
||||
| 'draftUpdatedAt'
|
||||
| 'multipleModelConfigs'
|
||||
@@ -31,9 +33,18 @@ 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?: {
|
||||
targetType: WorkflowTypeConversionTarget
|
||||
publishLabelKey: WorkflowTypeSwitchLabelKey
|
||||
switchLabelKey: WorkflowTypeSwitchLabelKey
|
||||
tipKey: WorkflowTypeSwitchLabelKey
|
||||
}
|
||||
workflowTypeSwitchDisabled: boolean
|
||||
workflowTypeSwitchDisabledReason?: string
|
||||
}
|
||||
|
||||
type AccessSectionProps = {
|
||||
@@ -90,6 +101,28 @@ 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,
|
||||
@@ -98,12 +131,16 @@ export const PublisherSummarySection = ({
|
||||
handleRestore,
|
||||
isChatApp,
|
||||
multipleModelConfigs = [],
|
||||
onWorkflowTypeSwitch,
|
||||
publishDisabled = false,
|
||||
published,
|
||||
publishedAt,
|
||||
publishShortcut,
|
||||
startNodeLimitExceeded = false,
|
||||
upgradeHighlightStyle,
|
||||
workflowTypeSwitchConfig,
|
||||
workflowTypeSwitchDisabled,
|
||||
workflowTypeSwitchDisabledReason,
|
||||
}: SummarySectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -164,6 +201,47 @@ export const PublisherSummarySection = ({
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
{workflowTypeSwitchConfig && (
|
||||
<ActionTooltip disabled={workflowTypeSwitchDisabled} tooltip={workflowTypeSwitchDisabledReason}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full items-center justify-center gap-0.5 rounded-lg px-3 py-2 system-sm-medium text-text-tertiary hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||
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"
|
||||
popupClassName="w-[180px]"
|
||||
>
|
||||
{t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</button>
|
||||
</ActionTooltip>
|
||||
)}
|
||||
{startNodeLimitExceeded && (
|
||||
<div className="mt-3 flex flex-col items-stretch">
|
||||
<p
|
||||
@@ -227,28 +305,6 @@ 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,
|
||||
@@ -305,7 +361,7 @@ export const PublisherActionsSection = ({
|
||||
<SuggestedAction
|
||||
onClick={handleEmbed}
|
||||
disabled={!publishedAt}
|
||||
icon={<CodeBrowser className="h-4 w-4" />}
|
||||
icon={<span className="i-custom-vender-line-development-code-browser h-4 w-4" />}
|
||||
>
|
||||
{t('common.embedIntoSite', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
|
||||
@@ -62,7 +62,7 @@ const TryLabel: FC<{
|
||||
}> = ({ Icon, text, onClick }) => {
|
||||
return (
|
||||
<div
|
||||
className="mr-1 mt-2 flex h-7 shrink-0 cursor-pointer items-center rounded-lg bg-components-button-secondary-bg px-2"
|
||||
className="mt-2 mr-1 flex h-7 shrink-0 cursor-pointer items-center rounded-lg bg-components-button-secondary-bg px-2"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-text-tertiary"></Icon>
|
||||
@@ -89,7 +89,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
const [model, setModel] = React.useState<Model>(localModel || {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: mode as unknown as ModelModeType.chat,
|
||||
mode: mode as unknown as ModelModeType,
|
||||
completion_params: {} as CompletionParams,
|
||||
})
|
||||
const {
|
||||
@@ -283,7 +283,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
<div className="flex h-[680px] flex-wrap">
|
||||
<div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6">
|
||||
<div className="mb-5">
|
||||
<div className={`text-lg font-bold leading-[28px] ${s.textGradient}`}>{t('generate.title', { ns: 'appDebug' })}</div>
|
||||
<div className={`text-lg leading-[28px] font-bold ${s.textGradient}`}>{t('generate.title', { ns: 'appDebug' })}</div>
|
||||
<div className="mt-1 text-[13px] font-normal text-text-tertiary">{t('generate.description', { ns: 'appDebug' })}</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -301,7 +301,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
{isBasicMode && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-3 shrink-0 text-xs font-semibold uppercase leading-[18px] text-text-tertiary">{t('generate.tryIt', { ns: 'appDebug' })}</div>
|
||||
<div className="mr-3 shrink-0 text-xs leading-[18px] font-semibold text-text-tertiary uppercase">{t('generate.tryIt', { ns: 'appDebug' })}</div>
|
||||
<div
|
||||
className="h-px grow"
|
||||
style={{
|
||||
@@ -326,7 +326,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
{/* inputs */}
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
<div className="system-sm-semibold-uppercase mb-1.5 text-text-secondary">{t('generate.instruction', { ns: 'appDebug' })}</div>
|
||||
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">{t('generate.instruction', { ns: 'appDebug' })}</div>
|
||||
{isBasicMode
|
||||
? (
|
||||
<InstructionEditorInBasic
|
||||
|
||||
@@ -70,7 +70,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
const [model, setModel] = React.useState<Model>(localModel || {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: mode as unknown as ModelModeType.chat,
|
||||
mode: mode as unknown as ModelModeType,
|
||||
completion_params: defaultCompletionParams,
|
||||
})
|
||||
const {
|
||||
@@ -202,7 +202,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
<div className="relative flex h-[680px] flex-wrap">
|
||||
<div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6">
|
||||
<div className="mb-5">
|
||||
<div className={`text-lg font-bold leading-[28px] ${s.textGradient}`}>{t('codegen.title', { ns: 'appDebug' })}</div>
|
||||
<div className={`text-lg leading-[28px] font-bold ${s.textGradient}`}>{t('codegen.title', { ns: 'appDebug' })}</div>
|
||||
<div className="mt-1 text-[13px] font-normal text-text-tertiary">{t('codegen.description', { ns: 'appDebug' })}</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
@@ -219,7 +219,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[0px]">
|
||||
<div className="mb-1.5 text-text-secondary system-sm-semibold-uppercase">{t('codegen.instruction', { ns: 'appDebug' })}</div>
|
||||
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">{t('codegen.instruction', { ns: 'appDebug' })}</div>
|
||||
<InstructionEditor
|
||||
editorKey={editorKey}
|
||||
value={instruction}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
@@ -15,10 +15,13 @@ vi.mock('@/next/navigation', () => ({
|
||||
|
||||
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
|
||||
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
|
||||
const mockIsLoadingCurrentWorkspace = vi.fn(() => false)
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
|
||||
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
|
||||
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace(),
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -36,6 +39,7 @@ const mockQueryState = {
|
||||
keywords: '',
|
||||
isCreatedByMe: false,
|
||||
}
|
||||
|
||||
vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
default: () => ({
|
||||
query: mockQueryState,
|
||||
@@ -45,6 +49,7 @@ vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
|
||||
let mockOnDSLFileDropped: ((file: File) => void) | null = null
|
||||
let mockDragging = false
|
||||
|
||||
vi.mock('../hooks/use-dsl-drag-drop', () => ({
|
||||
useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => {
|
||||
mockOnDSLFileDropped = onDSLFileDropped
|
||||
@@ -54,11 +59,13 @@ vi.mock('../hooks/use-dsl-drag-drop', () => ({
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
const mockFetchNextPage = vi.fn()
|
||||
const mockFetchSnippetNextPage = vi.fn()
|
||||
|
||||
const mockServiceState = {
|
||||
error: null as Error | null,
|
||||
hasNextPage: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
}
|
||||
|
||||
@@ -100,6 +107,7 @@ vi.mock('@/service/use-apps', () => ({
|
||||
useInfiniteAppList: () => ({
|
||||
data: defaultAppData,
|
||||
isLoading: mockServiceState.isLoading,
|
||||
isFetching: mockServiceState.isFetching,
|
||||
isFetchingNextPage: mockServiceState.isFetchingNextPage,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: mockServiceState.hasNextPage,
|
||||
@@ -112,6 +120,57 @@ vi.mock('@/service/use-apps', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSnippetServiceState = {
|
||||
error: null as Error | null,
|
||||
hasNextPage: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
}
|
||||
|
||||
const defaultSnippetData = {
|
||||
pages: [{
|
||||
data: [
|
||||
{
|
||||
id: 'snippet-1',
|
||||
name: 'Tone Rewriter',
|
||||
description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.',
|
||||
author: '',
|
||||
updatedAt: '2024-01-02 10:00',
|
||||
usage: '19',
|
||||
icon: '🪄',
|
||||
iconBackground: '#E0EAFF',
|
||||
status: undefined,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}],
|
||||
}
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useInfiniteSnippetList: () => ({
|
||||
data: defaultSnippetData,
|
||||
isLoading: mockSnippetServiceState.isLoading,
|
||||
isFetching: mockSnippetServiceState.isFetching,
|
||||
isFetchingNextPage: mockSnippetServiceState.isFetchingNextPage,
|
||||
fetchNextPage: mockFetchSnippetNextPage,
|
||||
hasNextPage: mockSnippetServiceState.hasNextPage,
|
||||
error: mockSnippetServiceState.error,
|
||||
}),
|
||||
useCreateSnippetMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useImportSnippetDSLMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useConfirmSnippetImportMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/tag', () => ({
|
||||
fetchTagList: vi.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]),
|
||||
}))
|
||||
@@ -133,13 +192,21 @@ vi.mock('@/next/dynamic', () => ({
|
||||
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
|
||||
}
|
||||
}
|
||||
|
||||
if (fnString.includes('create-from-dsl-modal')) {
|
||||
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) {
|
||||
if (!show)
|
||||
return null
|
||||
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'))
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'create-dsl-modal' },
|
||||
React.createElement('button', { 'data-testid': 'close-dsl-modal', 'onClick': onClose }, 'Close'),
|
||||
React.createElement('button', { 'data-testid': 'success-dsl-modal', 'onClick': onSuccess }, 'Success'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return () => null
|
||||
},
|
||||
}))
|
||||
@@ -188,9 +255,8 @@ beforeAll(() => {
|
||||
} as unknown as typeof IntersectionObserver
|
||||
})
|
||||
|
||||
// Render helper wrapping with shared nuqs testing helper.
|
||||
const renderList = (searchParams = '') => {
|
||||
return renderWithNuqs(<List />, { searchParams })
|
||||
const renderList = (props: React.ComponentProps<typeof List> = {}, searchParams = '') => {
|
||||
return renderWithNuqs(<List {...props} />, { searchParams })
|
||||
}
|
||||
|
||||
describe('List', () => {
|
||||
@@ -202,284 +268,62 @@ describe('List', () => {
|
||||
})
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
|
||||
mockIsLoadingCurrentWorkspace.mockReturnValue(false)
|
||||
mockDragging = false
|
||||
mockOnDSLFileDropped = null
|
||||
mockServiceState.error = null
|
||||
mockServiceState.hasNextPage = false
|
||||
mockServiceState.isLoading = false
|
||||
mockServiceState.isFetching = false
|
||||
mockServiceState.isFetchingNextPage = false
|
||||
mockQueryState.tagIDs = []
|
||||
mockQueryState.keywords = ''
|
||||
mockQueryState.isCreatedByMe = false
|
||||
mockSnippetServiceState.error = null
|
||||
mockSnippetServiceState.hasNextPage = false
|
||||
mockSnippetServiceState.isLoading = false
|
||||
mockSnippetServiceState.isFetching = false
|
||||
mockSnippetServiceState.isFetchingNextPage = false
|
||||
intersectionCallback = null
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tab slider with all app types', () => {
|
||||
describe('Apps Mode', () => {
|
||||
it('should render the apps route switch, dropdown filters, and app cards', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render search input', () => {
|
||||
renderList()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tag filter', () => {
|
||||
renderList()
|
||||
expect(screen.getByRole('link', { name: 'app.studio.apps' })).toHaveAttribute('href', '/apps')
|
||||
expect(screen.getByRole('link', { name: 'workflow.tabs.snippets' })).toHaveAttribute('href', '/snippets')
|
||||
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.creators')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render created by me checkbox', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards when apps exist', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render new app card for editors', () => {
|
||||
renderList()
|
||||
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render footer when branding is disabled', () => {
|
||||
renderList()
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render drop DSL hint for editors', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
it('should update URL when workflow tab is clicked', async () => {
|
||||
it('should update the category query when selecting an app type from the dropdown', async () => {
|
||||
const { onUrlUpdate } = renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.workflow'))
|
||||
fireEvent.click(screen.getByText('app.studio.filters.types'))
|
||||
fireEvent.click(await screen.findByText('app.types.workflow'))
|
||||
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
|
||||
})
|
||||
|
||||
it('should update URL when all tab is clicked', async () => {
|
||||
const { onUrlUpdate } = renderList('?category=workflow')
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.all'))
|
||||
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
// nuqs removes the default value ('all') from URL params
|
||||
expect(lastCall.searchParams.has('category')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('should render search input field', () => {
|
||||
renderList()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle search input change', () => {
|
||||
it('should keep the creators dropdown visual-only and not update app query state', async () => {
|
||||
renderList()
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
fireEvent.click(screen.getByText('app.studio.filters.creators'))
|
||||
fireEvent.click(await screen.findByText('Evan'))
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
expect(mockSetQuery).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('app.studio.filters.creators +1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle search clear button click', () => {
|
||||
mockQueryState.keywords = 'existing search'
|
||||
|
||||
renderList()
|
||||
|
||||
const clearButton = document.querySelector('.group')
|
||||
expect(clearButton).toBeInTheDocument()
|
||||
if (clearButton)
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tag Filter', () => {
|
||||
it('should render tag filter component', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render checkbox with correct label', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle checkbox change', () => {
|
||||
renderList()
|
||||
|
||||
const checkbox = screen.getByTestId('checkbox-undefined')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Non-Editor User', () => {
|
||||
it('should not render new app card for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render drop DSL hint for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dataset Operator Behavior', () => {
|
||||
it('should not trigger redirect at component level for dataset operators', () => {
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Local Storage Refresh', () => {
|
||||
it('should call refetch when refresh key is set in localStorage', () => {
|
||||
localStorage.setItem('needRefreshAppList', '1')
|
||||
|
||||
renderList()
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { rerender } = renderWithNuqs(<List />)
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
|
||||
rerender(<List />)
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards correctly', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with all filter options visible', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dragging State', () => {
|
||||
it('should show drop hint when DSL feature is enabled for editors', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dragging state overlay when dragging', () => {
|
||||
mockDragging = true
|
||||
const { container } = renderList()
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App Type Tabs', () => {
|
||||
it('should render all app type tabs', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update URL for each app type tab click', async () => {
|
||||
const { onUrlUpdate } = renderList()
|
||||
|
||||
const appTypeTexts = [
|
||||
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
|
||||
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
|
||||
{ mode: AppModeEnum.CHAT, text: 'app.types.chatbot' },
|
||||
{ mode: AppModeEnum.AGENT_CHAT, text: 'app.types.agent' },
|
||||
{ mode: AppModeEnum.COMPLETION, text: 'app.types.completion' },
|
||||
]
|
||||
|
||||
for (const { mode, text } of appTypeTexts) {
|
||||
onUrlUpdate.mockClear()
|
||||
fireEvent.click(screen.getByText(text))
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(lastCall.searchParams.get('category')).toBe(mode)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('App List Display', () => {
|
||||
it('should display all app cards from data', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display app names correctly', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Footer Visibility', () => {
|
||||
it('should render footer when branding is disabled', () => {
|
||||
renderList()
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DSL File Drop', () => {
|
||||
it('should handle DSL file drop and show modal', () => {
|
||||
it('should render and close the DSL import modal when a file is dropped', () => {
|
||||
renderList()
|
||||
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
@@ -489,98 +333,50 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close DSL modal when onClose is called', () => {
|
||||
renderList()
|
||||
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
act(() => {
|
||||
if (mockOnDSLFileDropped)
|
||||
mockOnDSLFileDropped(mockFile)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-dsl-modal'))
|
||||
|
||||
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close DSL modal and refetch when onSuccess is called', () => {
|
||||
renderList()
|
||||
describe('Snippets Mode', () => {
|
||||
it('should render the snippets create card and snippet card from the real query hook', () => {
|
||||
renderList({ pageType: 'snippets' })
|
||||
|
||||
expect(screen.getByText('snippet.create')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
|
||||
expect(screen.getByText('Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Tone Rewriter/i })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate')
|
||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should request the next snippet page when the infinite-scroll anchor intersects', () => {
|
||||
mockSnippetServiceState.hasNextPage = true
|
||||
renderList({ pageType: 'snippets' })
|
||||
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
act(() => {
|
||||
if (mockOnDSLFileDropped)
|
||||
mockOnDSLFileDropped(mockFile)
|
||||
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('success-dsl-modal'))
|
||||
|
||||
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Infinite Scroll', () => {
|
||||
it('should call fetchNextPage when intersection observer triggers', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
renderList()
|
||||
|
||||
if (intersectionCallback) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).toHaveBeenCalled()
|
||||
expect(mockFetchSnippetNextPage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call fetchNextPage when not intersecting', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
renderList()
|
||||
it('should not render app-only controls in snippets mode', () => {
|
||||
renderList({ pageType: 'snippets' })
|
||||
|
||||
if (intersectionCallback) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
[{ isIntersecting: false } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('app.studio.filters.types')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.tag.placeholder')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not call fetchNextPage when loading', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
mockServiceState.isLoading = true
|
||||
renderList()
|
||||
it('should not fetch the next snippet page when no more data is available', () => {
|
||||
renderList({ pageType: 'snippets' })
|
||||
|
||||
if (intersectionCallback) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
}
|
||||
act(() => {
|
||||
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
|
||||
})
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error State', () => {
|
||||
it('should handle error state in useEffect', () => {
|
||||
mockServiceState.error = new Error('Test error')
|
||||
const { container } = renderList()
|
||||
expect(container).toBeInTheDocument()
|
||||
expect(mockFetchSnippetNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
16
web/app/components/apps/app-type-filter-shared.ts
Normal file
16
web/app/components/apps/app-type-filter-shared.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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' })
|
||||
72
web/app/components/apps/app-type-filter.tsx
Normal file
72
web/app/components/apps/app-type-filter.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import type { AppListCategory } from './app-type-filter-shared'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRadioItemIndicator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { isAppListCategory } from './app-type-filter-shared'
|
||||
|
||||
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
|
||||
|
||||
type AppTypeFilterProps = {
|
||||
activeTab: 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
|
||||
128
web/app/components/apps/creators-filter.tsx
Normal file
128
web/app/components/apps/creators-filter.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuCheckboxItemIndicator,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type CreatorOption = {
|
||||
id: string
|
||||
name: string
|
||||
isYou?: boolean
|
||||
avatarClassName: string
|
||||
}
|
||||
|
||||
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
|
||||
|
||||
const creatorOptions: CreatorOption[] = [
|
||||
{ id: 'evan', name: 'Evan', isYou: true, avatarClassName: 'bg-gradient-to-br from-[#ff9b3f] to-[#ff4d00]' },
|
||||
{ id: 'jack', name: 'Jack', avatarClassName: 'bg-gradient-to-br from-[#fde68a] to-[#d6d3d1]' },
|
||||
{ id: 'gigi', name: 'Gigi', avatarClassName: 'bg-gradient-to-br from-[#f9a8d4] to-[#a78bfa]' },
|
||||
{ id: 'alice', name: 'Alice', avatarClassName: 'bg-gradient-to-br from-[#93c5fd] to-[#4f46e5]' },
|
||||
{ id: 'mandy', name: 'Mandy', avatarClassName: 'bg-gradient-to-br from-[#374151] to-[#111827]' },
|
||||
]
|
||||
|
||||
const CreatorsFilter = () => {
|
||||
const { t } = useTranslation()
|
||||
const [selectedCreatorIds, setSelectedCreatorIds] = useState<string[]>([])
|
||||
const [keywords, setKeywords] = useState('')
|
||||
|
||||
const filteredCreators = useMemo(() => {
|
||||
const normalizedKeywords = keywords.trim().toLowerCase()
|
||||
if (!normalizedKeywords)
|
||||
return creatorOptions
|
||||
|
||||
return creatorOptions.filter(creator => creator.name.toLowerCase().includes(normalizedKeywords))
|
||||
}, [keywords])
|
||||
|
||||
const selectedCount = selectedCreatorIds.length
|
||||
const triggerLabel = selectedCount > 0
|
||||
? `${t('studio.filters.creators', { ns: 'app' })} +${selectedCount}`
|
||||
: t('studio.filters.creators', { ns: 'app' })
|
||||
|
||||
const toggleCreator = useCallback((creatorId: string) => {
|
||||
setSelectedCreatorIds((prev) => {
|
||||
if (prev.includes(creatorId))
|
||||
return prev.filter(id => id !== creatorId)
|
||||
return [...prev, creatorId]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const resetCreators = useCallback(() => {
|
||||
setSelectedCreatorIds([])
|
||||
setKeywords('')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(chipClassName, selectedCount > 0 && 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs')}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
<span>{triggerLabel}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0">
|
||||
<div className="flex items-center gap-2 p-2 pb-1">
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
onClear={() => setKeywords('')}
|
||||
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={resetCreators}
|
||||
>
|
||||
{t('studio.filters.reset', { ns: 'app' })}
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-1 pb-1">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={selectedCreatorIds.length === 0}
|
||||
onCheckedChange={resetCreators}
|
||||
>
|
||||
<span aria-hidden className="i-ri-user-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
<span>{t('studio.filters.allCreators', { ns: 'app' })}</span>
|
||||
<DropdownMenuCheckboxItemIndicator />
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
{filteredCreators.map(creator => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={creator.id}
|
||||
checked={selectedCreatorIds.includes(creator.id)}
|
||||
onCheckedChange={() => toggleCreator(creator.id)}
|
||||
>
|
||||
<span className={cn('h-5 w-5 shrink-0 rounded-full border border-white', creator.avatarClassName)} />
|
||||
<span className="flex min-w-0 grow items-center justify-between gap-2">
|
||||
<span className="truncate">{creator.name}</span>
|
||||
{creator.isYou && (
|
||||
<span className="shrink-0 text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
|
||||
)}
|
||||
</span>
|
||||
<DropdownMenuCheckboxItemIndicator />
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreatorsFilter
|
||||
@@ -12,14 +12,24 @@ import dynamic from '@/next/dynamic'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
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 Apps = () => {
|
||||
const Apps = ({
|
||||
pageType = 'apps',
|
||||
}: AppsProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useDocumentTitle(t('menus.apps', { ns: 'common' }))
|
||||
useDocumentTitle(pageType === 'apps'
|
||||
? t('menus.apps', { ns: 'common' })
|
||||
: t('tabs.snippets', { ns: 'workflow' }))
|
||||
useEducationInit()
|
||||
|
||||
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
|
||||
@@ -103,7 +113,7 @@ const Apps = () => {
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
<List controlRefreshList={controlRefreshList} />
|
||||
<List controlRefreshList={controlRefreshList} pageType={pageType} />
|
||||
{isShowTryAppPanel && (
|
||||
<TryApp
|
||||
appId={currentTryAppParams?.appId || ''}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { StudioPageType } from '.'
|
||||
import type { App } from '@/types/app'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import 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'
|
||||
@@ -16,15 +16,21 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { useInfiniteAppList } from '@/service/use-apps'
|
||||
import { AppModeEnum, AppModes } from '@/types/app'
|
||||
import { useInfiniteSnippetList } from '@/service/use-snippets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import SnippetCard from '../snippets/components/snippet-card'
|
||||
import SnippetCreateCard from '../snippets/components/snippet-create-card'
|
||||
import AppCard from './app-card'
|
||||
import { AppCardSkeleton } from './app-card-skeleton'
|
||||
import AppTypeFilter from './app-type-filter'
|
||||
import { parseAsAppListCategory } from './app-type-filter-shared'
|
||||
import CreatorsFilter from './creators-filter'
|
||||
import Empty from './empty'
|
||||
import Footer from './footer'
|
||||
import useAppsQueryState from './hooks/use-apps-query-state'
|
||||
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
|
||||
import NewAppCard from './new-app-card'
|
||||
import StudioRouteSwitch from './studio-route-switch'
|
||||
|
||||
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
|
||||
ssr: false,
|
||||
@@ -33,25 +39,17 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' })
|
||||
|
||||
type Props = {
|
||||
controlRefreshList?: number
|
||||
pageType?: StudioPageType
|
||||
}
|
||||
|
||||
const List: FC<Props> = ({
|
||||
controlRefreshList = 0,
|
||||
pageType = 'apps',
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isAppsPage = pageType === 'apps'
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
|
||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||
@@ -61,18 +59,22 @@ const List: FC<Props> = ({
|
||||
)
|
||||
|
||||
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
|
||||
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
|
||||
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
|
||||
const [searchKeywords, setSearchKeywords] = useState(keywords)
|
||||
const newAppCardRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [appKeywords, setAppKeywords] = useState(keywords)
|
||||
const [snippetKeywordsInput, setSnippetKeywordsInput] = useState('')
|
||||
const [snippetKeywords, setSnippetKeywords] = useState('')
|
||||
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
|
||||
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
|
||||
const setKeywords = useCallback((keywords: string) => {
|
||||
setQuery(prev => ({ ...prev, keywords }))
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const newAppCardRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const setKeywords = useCallback((nextKeywords: string) => {
|
||||
setQuery(prev => ({ ...prev, keywords: nextKeywords }))
|
||||
}, [setQuery])
|
||||
const setTagIDs = useCallback((tagIDs: string[]) => {
|
||||
setQuery(prev => ({ ...prev, tagIDs }))
|
||||
|
||||
const setTagIDs = useCallback((nextTagIDs: string[]) => {
|
||||
setQuery(prev => ({ ...prev, tagIDs: nextTagIDs }))
|
||||
}, [setQuery])
|
||||
|
||||
const handleDSLFileDropped = useCallback((file: File) => {
|
||||
@@ -83,15 +85,15 @@ const List: FC<Props> = ({
|
||||
const { dragging } = useDSLDragDrop({
|
||||
onDSLFileDropped: handleDSLFileDropped,
|
||||
containerRef,
|
||||
enabled: isCurrentWorkspaceEditor,
|
||||
enabled: isAppsPage && isCurrentWorkspaceEditor,
|
||||
})
|
||||
|
||||
const appListQueryParams = {
|
||||
page: 1,
|
||||
limit: 30,
|
||||
name: searchKeywords,
|
||||
name: appKeywords,
|
||||
tag_ids: tagIDs,
|
||||
is_created_by_me: isCreatedByMe,
|
||||
is_created_by_me: queryIsCreatedByMe,
|
||||
...(activeTab !== 'all' ? { mode: activeTab } : {}),
|
||||
}
|
||||
|
||||
@@ -104,159 +106,214 @@ const List: FC<Props> = ({
|
||||
hasNextPage,
|
||||
error,
|
||||
refetch,
|
||||
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
|
||||
} = useInfiniteAppList(appListQueryParams, {
|
||||
enabled: isAppsPage && !isCurrentWorkspaceDatasetOperator,
|
||||
})
|
||||
|
||||
const {
|
||||
data: snippetData,
|
||||
isLoading: isSnippetListLoading,
|
||||
isFetching: isSnippetListFetching,
|
||||
isFetchingNextPage: isSnippetListFetchingNextPage,
|
||||
fetchNextPage: fetchSnippetNextPage,
|
||||
hasNextPage: hasSnippetNextPage,
|
||||
error: snippetError,
|
||||
} = useInfiniteSnippetList({
|
||||
page: 1,
|
||||
limit: 30,
|
||||
keyword: snippetKeywords || undefined,
|
||||
}, {
|
||||
enabled: !isAppsPage,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (controlRefreshList > 0) {
|
||||
if (isAppsPage && controlRefreshList > 0)
|
||||
refetch()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controlRefreshList])
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const options = [
|
||||
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="i-ri-apps-2-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="i-ri-exchange-2-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="i-ri-robot-3-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="i-ri-file-4-line mr-1 h-[14px] w-[14px]" /> },
|
||||
]
|
||||
}, [controlRefreshList, isAppsPage, refetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAppsPage)
|
||||
return
|
||||
|
||||
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
|
||||
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
|
||||
refetch()
|
||||
}
|
||||
}, [refetch])
|
||||
}, [isAppsPage, refetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrentWorkspaceDatasetOperator)
|
||||
return
|
||||
const hasMore = hasNextPage ?? true
|
||||
|
||||
const hasMore = isAppsPage ? (hasNextPage ?? true) : (hasSnippetNextPage ?? true)
|
||||
const isPageLoading = isAppsPage ? isLoading : isSnippetListLoading
|
||||
const isNextPageFetching = isAppsPage ? isFetchingNextPage : isSnippetListFetchingNextPage
|
||||
const currentError = isAppsPage ? error : snippetError
|
||||
let observer: IntersectionObserver | undefined
|
||||
|
||||
if (error) {
|
||||
if (observer)
|
||||
observer.disconnect()
|
||||
if (currentError) {
|
||||
observer?.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (anchorRef.current && containerRef.current) {
|
||||
// Calculate dynamic rootMargin: clamps to 100-200px range, using 20% of container height as the base value for better responsiveness
|
||||
const containerHeight = containerRef.current.clientHeight
|
||||
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value
|
||||
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
|
||||
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
|
||||
fetchNextPage()
|
||||
if (entries[0].isIntersecting && !isPageLoading && !isNextPageFetching && !currentError && hasMore) {
|
||||
if (isAppsPage)
|
||||
fetchNextPage()
|
||||
else
|
||||
fetchSnippetNextPage()
|
||||
}
|
||||
}, {
|
||||
root: containerRef.current,
|
||||
rootMargin: `${dynamicMargin}px`,
|
||||
threshold: 0.1, // Trigger when 10% of the anchor element is visible
|
||||
threshold: 0.1,
|
||||
})
|
||||
observer.observe(anchorRef.current)
|
||||
}
|
||||
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
|
||||
}, [error, fetchNextPage, fetchSnippetNextPage, hasNextPage, hasSnippetNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading, isSnippetListFetchingNextPage, isSnippetListLoading, snippetError])
|
||||
|
||||
const { run: handleSearch } = useDebounceFn(() => {
|
||||
setSearchKeywords(keywords)
|
||||
const { run: handleAppSearch } = useDebounceFn((value: string) => {
|
||||
setAppKeywords(value)
|
||||
}, { wait: 500 })
|
||||
const handleKeywordsChange = (value: string) => {
|
||||
setKeywords(value)
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const { run: handleTagsUpdate } = useDebounceFn(() => {
|
||||
setTagIDs(tagFilterValue)
|
||||
const { run: handleSnippetSearch } = useDebounceFn((value: string) => {
|
||||
setSnippetKeywords(value)
|
||||
}, { wait: 500 })
|
||||
const handleTagsChange = (value: string[]) => {
|
||||
|
||||
const handleKeywordsChange = useCallback((value: string) => {
|
||||
if (isAppsPage) {
|
||||
setKeywords(value)
|
||||
handleAppSearch(value)
|
||||
return
|
||||
}
|
||||
|
||||
setSnippetKeywordsInput(value)
|
||||
handleSnippetSearch(value)
|
||||
}, [handleAppSearch, handleSnippetSearch, isAppsPage, setKeywords])
|
||||
|
||||
const { run: handleTagsUpdate } = useDebounceFn((value: string[]) => {
|
||||
setTagIDs(value)
|
||||
}, { wait: 500 })
|
||||
|
||||
const handleTagsChange = useCallback((value: string[]) => {
|
||||
setTagFilterValue(value)
|
||||
handleTagsUpdate()
|
||||
}
|
||||
handleTagsUpdate(value)
|
||||
}, [handleTagsUpdate])
|
||||
|
||||
const handleCreatedByMeChange = useCallback(() => {
|
||||
const newValue = !isCreatedByMe
|
||||
setIsCreatedByMe(newValue)
|
||||
setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
|
||||
}, [isCreatedByMe, setQuery])
|
||||
const appItems = useMemo<App[]>(() => {
|
||||
return (data?.pages ?? []).flatMap(({ data: apps }) => apps)
|
||||
}, [data?.pages])
|
||||
|
||||
const pages = data?.pages ?? []
|
||||
const hasAnyApp = (pages[0]?.total ?? 0) > 0
|
||||
// Show skeleton during initial load or when refetching with no previous data
|
||||
const showSkeleton = isLoading || (isFetching && pages.length === 0)
|
||||
const snippetItems = useMemo(() => {
|
||||
return (snippetData?.pages ?? []).flatMap(({ data }) => data)
|
||||
}, [snippetData?.pages])
|
||||
|
||||
const showSkeleton = isAppsPage
|
||||
? (isLoading || (isFetching && data?.pages?.length === 0))
|
||||
: (isSnippetListLoading || (isSnippetListFetching && snippetItems.length === 0))
|
||||
const hasAnyApp = (data?.pages?.[0]?.total ?? 0) > 0
|
||||
const hasAnySnippet = snippetItems.length > 0
|
||||
const currentKeywords = isAppsPage ? keywords : snippetKeywordsInput
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
{dragging && (
|
||||
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2">
|
||||
</div>
|
||||
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2" />
|
||||
)}
|
||||
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pb-5 pt-7">
|
||||
<TabSliderNew
|
||||
value={activeTab}
|
||||
onChange={(nextValue) => {
|
||||
if (isAppListCategory(nextValue))
|
||||
setActiveTab(nextValue)
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StudioRouteSwitch
|
||||
pageType={pageType}
|
||||
appsLabel={t('studio.apps', { ns: 'app' })}
|
||||
snippetsLabel={t('tabs.snippets', { ns: 'workflow' })}
|
||||
/>
|
||||
{isAppsPage && (
|
||||
<AppTypeFilter
|
||||
activeTab={activeTab}
|
||||
onChange={(value) => {
|
||||
void setActiveTab(value)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CreatorsFilter />
|
||||
{isAppsPage && (
|
||||
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<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]"
|
||||
value={keywords}
|
||||
placeholder={isAppsPage ? undefined : t('tabs.searchSnippets', { ns: 'workflow' })}
|
||||
value={currentKeywords}
|
||||
onChange={e => handleKeywordsChange(e.target.value)}
|
||||
onClear={() => handleKeywordsChange('')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6',
|
||||
!hasAnyApp && 'overflow-hidden',
|
||||
isAppsPage && !hasAnyApp && 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
|
||||
<NewAppCard
|
||||
ref={newAppCardRef}
|
||||
isLoading={isLoadingCurrentWorkspace}
|
||||
onSuccess={refetch}
|
||||
selectedAppType={activeTab}
|
||||
className={cn(!hasAnyApp && 'z-10')}
|
||||
/>
|
||||
isAppsPage
|
||||
? (
|
||||
<NewAppCard
|
||||
ref={newAppCardRef}
|
||||
isLoading={isLoadingCurrentWorkspace}
|
||||
onSuccess={refetch}
|
||||
selectedAppType={activeTab}
|
||||
className={cn(!hasAnyApp && 'z-10')}
|
||||
/>
|
||||
)
|
||||
: <SnippetCreateCard />
|
||||
)}
|
||||
{(() => {
|
||||
if (showSkeleton)
|
||||
return <AppCardSkeleton count={6} />
|
||||
|
||||
if (hasAnyApp) {
|
||||
return pages.flatMap(({ data: apps }) => apps).map(app => (
|
||||
<AppCard key={app.id} app={app} onRefresh={refetch} />
|
||||
))
|
||||
}
|
||||
{showSkeleton && <AppCardSkeleton count={6} />}
|
||||
|
||||
// No apps - show empty state
|
||||
return <Empty />
|
||||
})()}
|
||||
{isFetchingNextPage && (
|
||||
{!showSkeleton && isAppsPage && hasAnyApp && appItems.map(app => (
|
||||
<AppCard key={app.id} app={app} onRefresh={refetch} />
|
||||
))}
|
||||
|
||||
{!showSkeleton && !isAppsPage && hasAnySnippet && snippetItems.map(snippet => (
|
||||
<SnippetCard key={snippet.id} snippet={snippet} />
|
||||
))}
|
||||
|
||||
{!showSkeleton && isAppsPage && !hasAnyApp && <Empty />}
|
||||
|
||||
{!showSkeleton && !isAppsPage && !hasAnySnippet && (
|
||||
<div className="col-span-full flex min-h-[240px] items-center justify-center rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-6 text-center text-sm text-text-tertiary">
|
||||
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAppsPage && isFetchingNextPage && (
|
||||
<AppCardSkeleton count={3} />
|
||||
)}
|
||||
|
||||
{!isAppsPage && isSnippetListFetchingNextPage && (
|
||||
<AppCardSkeleton count={3} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCurrentWorkspaceEditor && (
|
||||
{isAppsPage && isCurrentWorkspaceEditor && (
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 py-4',
|
||||
dragging ? 'text-text-accent' : 'text-text-quaternary',
|
||||
)}
|
||||
role="region"
|
||||
aria-label={t('newApp.dropDSLToCreateApp', { ns: 'app' })}
|
||||
>
|
||||
@@ -264,17 +321,18 @@ const List: FC<Props> = ({
|
||||
<span className="system-xs-regular">{t('newApp.dropDSLToCreateApp', { ns: 'app' })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!systemFeatures.branding.enabled && (
|
||||
<Footer />
|
||||
)}
|
||||
<CheckModal />
|
||||
<div ref={anchorRef} className="h-0"> </div>
|
||||
{showTagManagementModal && (
|
||||
{isAppsPage && showTagManagementModal && (
|
||||
<TagManagementModal type="app" show={showTagManagementModal} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreateFromDSLModal && (
|
||||
{isAppsPage && showCreateFromDSLModal && (
|
||||
<CreateFromDSLModal
|
||||
show={showCreateFromDSLModal}
|
||||
onClose={() => {
|
||||
|
||||
44
web/app/components/apps/studio-route-switch.tsx
Normal file
44
web/app/components/apps/studio-route-switch.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import type { StudioPageType } from '.'
|
||||
import Link from '@/next/link'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
pageType: StudioPageType
|
||||
appsLabel: string
|
||||
snippetsLabel: string
|
||||
}
|
||||
|
||||
const StudioRouteSwitch = ({
|
||||
pageType,
|
||||
appsLabel,
|
||||
snippetsLabel,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex items-center rounded-lg border-[0.5px] border-divider-subtle bg-[rgba(200,206,218,0.2)] p-[1px]">
|
||||
<Link
|
||||
href="/apps"
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
|
||||
pageType === 'apps' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
|
||||
pageType !== 'apps' && 'font-medium',
|
||||
)}
|
||||
>
|
||||
{appsLabel}
|
||||
</Link>
|
||||
<Link
|
||||
href="/snippets"
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
|
||||
pageType === 'snippets' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
|
||||
pageType !== 'snippets' && 'font-medium',
|
||||
)}
|
||||
>
|
||||
{snippetsLabel}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StudioRouteSwitch
|
||||
@@ -1,2 +1 @@
|
||||
export { default as BracketsX } from './BracketsX'
|
||||
export { default as CodeBrowser } from './CodeBrowser'
|
||||
|
||||
@@ -2,8 +2,6 @@ import { render } from '@testing-library/react'
|
||||
import PartnerStackCookieRecorder from '../cookie-recorder'
|
||||
|
||||
let isCloudEdition = true
|
||||
let psPartnerKey: string | undefined
|
||||
let psClickId: string | undefined
|
||||
|
||||
const saveOrUpdate = vi.fn()
|
||||
|
||||
@@ -15,8 +13,6 @@ vi.mock('@/config', () => ({
|
||||
|
||||
vi.mock('../use-ps-info', () => ({
|
||||
default: () => ({
|
||||
psPartnerKey,
|
||||
psClickId,
|
||||
saveOrUpdate,
|
||||
}),
|
||||
}))
|
||||
@@ -25,8 +21,6 @@ describe('PartnerStackCookieRecorder', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isCloudEdition = true
|
||||
psPartnerKey = undefined
|
||||
psClickId = undefined
|
||||
})
|
||||
|
||||
it('should call saveOrUpdate once on mount when running in cloud edition', () => {
|
||||
@@ -48,16 +42,4 @@ describe('PartnerStackCookieRecorder', () => {
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should call saveOrUpdate again when partner stack query changes', () => {
|
||||
const { rerender } = render(<PartnerStackCookieRecorder />)
|
||||
|
||||
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
|
||||
|
||||
psPartnerKey = 'updated-partner'
|
||||
psClickId = 'updated-click'
|
||||
rerender(<PartnerStackCookieRecorder />)
|
||||
|
||||
expect(saveOrUpdate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,13 +5,13 @@ import { IS_CLOUD_EDITION } from '@/config'
|
||||
import usePSInfo from './use-ps-info'
|
||||
|
||||
const PartnerStackCookieRecorder = () => {
|
||||
const { psPartnerKey, psClickId, saveOrUpdate } = usePSInfo()
|
||||
const { saveOrUpdate } = usePSInfo()
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_CLOUD_EDITION)
|
||||
return
|
||||
saveOrUpdate()
|
||||
}, [psPartnerKey, psClickId, saveOrUpdate])
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IS_CLOUD_EDITION } from '@/config'
|
||||
import usePSInfo from './use-ps-info'
|
||||
|
||||
const PartnerStack: FC = () => {
|
||||
const { psPartnerKey, psClickId, saveOrUpdate, bind } = usePSInfo()
|
||||
const { saveOrUpdate, bind } = usePSInfo()
|
||||
useEffect(() => {
|
||||
if (!IS_CLOUD_EDITION)
|
||||
return
|
||||
@@ -14,7 +14,7 @@ const PartnerStack: FC = () => {
|
||||
saveOrUpdate()
|
||||
// bind PartnerStack info after user logged in
|
||||
bind()
|
||||
}, [psPartnerKey, psClickId, saveOrUpdate, bind])
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -27,8 +27,6 @@ const usePSInfo = () => {
|
||||
const domain = globalThis.location?.hostname.replace('cloud', '')
|
||||
|
||||
const saveOrUpdate = useCallback(() => {
|
||||
if (hasBind)
|
||||
return
|
||||
if (!psPartnerKey || !psClickId)
|
||||
return
|
||||
if (!isPSChanged)
|
||||
@@ -41,21 +39,9 @@ const usePSInfo = () => {
|
||||
path: '/',
|
||||
domain,
|
||||
})
|
||||
}, [psPartnerKey, psClickId, isPSChanged, domain, hasBind])
|
||||
}, [psPartnerKey, psClickId, isPSChanged, domain])
|
||||
|
||||
const bind = useCallback(async () => {
|
||||
// for debug
|
||||
if (!hasBind)
|
||||
fetch("https://cloud.dify.dev/console/api/billing/debug/data", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "bind",
|
||||
data: psPartnerKey ? JSON.stringify({ psPartnerKey, psClickId }) : "",
|
||||
}),
|
||||
})
|
||||
if (psPartnerKey && psClickId && !hasBind) {
|
||||
let shouldRemoveCookie = false
|
||||
try {
|
||||
|
||||
313
web/app/components/evaluation/__tests__/index.spec.tsx
Normal file
313
web/app/components/evaluation/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import Evaluation from '..'
|
||||
import ConditionsSection from '../components/conditions-section'
|
||||
import { useEvaluationStore } from '../store'
|
||||
|
||||
const mockUseAvailableEvaluationMetrics = vi.hoisted(() => vi.fn())
|
||||
const mockUseEvaluationConfig = vi.hoisted(() => vi.fn())
|
||||
const mockUseEvaluationNodeInfoMutation = 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/use-evaluation', () => ({
|
||||
useEvaluationConfig: (...args: unknown[]) => mockUseEvaluationConfig(...args),
|
||||
useAvailableEvaluationMetrics: (...args: unknown[]) => mockUseAvailableEvaluationMetrics(...args),
|
||||
useEvaluationNodeInfoMutation: (...args: unknown[]) => mockUseEvaluationNodeInfoMutation(...args),
|
||||
}))
|
||||
|
||||
describe('Evaluation', () => {
|
||||
beforeEach(() => {
|
||||
useEvaluationStore.setState({ resources: {} })
|
||||
vi.clearAllMocks()
|
||||
mockUseEvaluationConfig.mockReturnValue({
|
||||
data: null,
|
||||
})
|
||||
|
||||
mockUseAvailableEvaluationMetrics.mockReturnValue({
|
||||
data: {
|
||||
metrics: ['answer-correctness', 'faithfulness', 'context-precision', 'context-recall', 'context-relevance'],
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
mockUseEvaluationNodeInfoMutation.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
|
||||
options?.onSuccess?.({
|
||||
'answer-correctness': [
|
||||
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
|
||||
],
|
||||
'faithfulness': [
|
||||
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should search, select metric nodes, and create a batch history record', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
render(<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: 'evaluation.batch.run' }))
|
||||
expect(screen.getByText('evaluation.batch.status.running')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1300)
|
||||
})
|
||||
|
||||
expect(screen.getByText('evaluation.batch.status.success')).toBeInTheDocument()
|
||||
expect(screen.getByText('Workflow evaluation batch')).toBeInTheDocument()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should 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 } = render(<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('combobox', { 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('option', { 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', () => {
|
||||
mockUseAvailableEvaluationMetrics.mockReturnValue({
|
||||
data: {
|
||||
metrics: ['context-precision'],
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
mockUseEvaluationNodeInfoMutation.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
|
||||
options?.onSuccess?.({
|
||||
'context-precision': [],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
render(<Evaluation resourceType="apps" resourceId="app-3" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
|
||||
|
||||
expect(screen.getByText('evaluation.metrics.noNodesInWorkflow')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the global empty state when no metrics are available', () => {
|
||||
mockUseAvailableEvaluationMetrics.mockReturnValue({
|
||||
data: {
|
||||
metrics: [],
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<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', () => {
|
||||
mockUseAvailableEvaluationMetrics.mockReturnValue({
|
||||
data: {
|
||||
metrics: ['answer-correctness'],
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
mockUseEvaluationNodeInfoMutation.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
|
||||
options?.onSuccess?.({
|
||||
'answer-correctness': [
|
||||
{ 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' },
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
render(<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', () => {
|
||||
render(<Evaluation resourceType="datasets" resourceId="dataset-1" />)
|
||||
|
||||
expect(screen.getByTestId('evaluation-model-selector')).toHaveTextContent('empty')
|
||||
expect(screen.getByText('evaluation.history.title')).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,
|
||||
},
|
||||
})
|
||||
|
||||
render(<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', () => {
|
||||
render(<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()
|
||||
})
|
||||
})
|
||||
274
web/app/components/evaluation/__tests__/store.spec.ts
Normal file
274
web/app/components/evaluation/__tests__/store.spec.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import type { EvaluationConfig } from '@/types/evaluation'
|
||||
import { getEvaluationMockConfig } from '../mock'
|
||||
import {
|
||||
getAllowedOperators,
|
||||
isCustomMetricConfigured,
|
||||
requiresConditionValue,
|
||||
useEvaluationStore,
|
||||
} from '../store'
|
||||
|
||||
describe('evaluation store', () => {
|
||||
beforeEach(() => {
|
||||
useEvaluationStore.setState({ resources: {} })
|
||||
})
|
||||
|
||||
it('should configure a custom metric mapping to a valid state', () => {
|
||||
const resourceType = 'apps'
|
||||
const resourceId = 'app-1'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
store.addCustomMetric(resourceType, resourceId)
|
||||
|
||||
const initialMetric = useEvaluationStore.getState().resources['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: config.workflowOptions[0].id,
|
||||
workflowAppId: 'custom-workflow-app-id',
|
||||
workflowName: config.workflowOptions[0].label,
|
||||
})
|
||||
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('custom-workflow-app-id')
|
||||
expect(configuredMetric!.customConfig!.workflowName).toBe(config.workflowOptions[0].label)
|
||||
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()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
store.addBuiltinMetric(resourceType, resourceId, config.builtinMetrics[1].id)
|
||||
|
||||
const addedMetric = useEvaluationStore.getState().resources['apps:app-2'].metrics.find(metric => metric.optionId === config.builtinMetrics[1].id)
|
||||
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 config = getEvaluationMockConfig(resourceType)
|
||||
const metricId = config.builtinMetrics[0].id
|
||||
|
||||
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()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
store.addBuiltinMetric(resourceType, resourceId, config.builtinMetrics[0].id, [
|
||||
{ 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()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
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: config.workflowOptions[0].id,
|
||||
workflowAppId: 'custom-workflow-app-id',
|
||||
workflowName: config.workflowOptions[0].label,
|
||||
})
|
||||
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
|
||||
id: 'reason',
|
||||
valueType: 'string',
|
||||
}])
|
||||
|
||||
store.addCondition(resourceType, resourceId, [config.workflowOptions[0].id, 'reason'])
|
||||
|
||||
const condition = useEvaluationStore.getState().resources['apps:app-condition-selector'].judgmentConfig.conditions[0]
|
||||
|
||||
expect(condition.variableSelector).toEqual([config.workflowOptions[0].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()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
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: config.workflowOptions[0].id,
|
||||
workflowAppId: 'custom-workflow-app-id',
|
||||
workflowName: config.workflowOptions[0].label,
|
||||
})
|
||||
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, [config.workflowOptions[0].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?.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)
|
||||
})
|
||||
})
|
||||
179
web/app/components/evaluation/components/batch-test-panel.tsx
Normal file
179
web/app/components/evaluation/components/batch-test-panel.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import type { EvaluationResourceProps } from '../types'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getEvaluationMockConfig } from '../mock'
|
||||
import { isEvaluationRunnable, useEvaluationResource, useEvaluationStore } from '../store'
|
||||
import { TAB_CLASS_NAME } from '../utils'
|
||||
|
||||
const BatchTestPanel = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
}: EvaluationResourceProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
const requirementFields = config.fieldOptions
|
||||
.filter(field => field.id.includes('.input.') || field.group.toLowerCase().includes('input'))
|
||||
.slice(0, 4)
|
||||
const displayedRequirementFields = requirementFields.length > 0 ? requirementFields : config.fieldOptions.slice(0, 4)
|
||||
const tabLabels = {
|
||||
'input-fields': t('batch.tabs.input-fields'),
|
||||
'history': t('batch.tabs.history'),
|
||||
}
|
||||
const statusLabels = {
|
||||
running: t('batch.status.running'),
|
||||
success: t('batch.status.success'),
|
||||
failed: t('batch.status.failed'),
|
||||
}
|
||||
const resource = useEvaluationResource(resourceType, resourceId)
|
||||
const setBatchTab = useEvaluationStore(state => state.setBatchTab)
|
||||
const setUploadedFileName = useEvaluationStore(state => state.setUploadedFileName)
|
||||
const runBatchTest = useEvaluationStore(state => state.runBatchTest)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const isRunnable = isEvaluationRunnable(resource)
|
||||
const isPanelReady = !!resource.judgeModelId && resource.metrics.length > 0
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
const content = ['case_id,input,expected', '1,Example input,Example output'].join('\n')
|
||||
const link = document.createElement('a')
|
||||
link.href = `data:text/csv;charset=utf-8,${encodeURIComponent(content)}`
|
||||
link.download = config.templateFileName
|
||||
link.click()
|
||||
}
|
||||
|
||||
const handleRun = () => {
|
||||
if (!isRunnable) {
|
||||
toast.warning(t('batch.validation'))
|
||||
return
|
||||
}
|
||||
|
||||
runBatchTest(resourceType, resourceId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background-default">
|
||||
<div className="px-6 py-4">
|
||||
<div className="text-text-primary system-xl-semibold">{t('batch.title')}</div>
|
||||
<div className="mt-1 text-text-tertiary system-sm-regular">{t('batch.description')}</div>
|
||||
<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="i-ri-alert-fill mt-0.5 h-4 w-4 shrink-0 text-text-warning" />
|
||||
<div className="text-text-tertiary system-xs-regular">{t('batch.noticeDescription')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-divider-subtle px-6">
|
||||
<div className="flex gap-4">
|
||||
{(['input-fields', 'history'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
className={cn(
|
||||
TAB_CLASS_NAME,
|
||||
'flex-none rounded-none border-b-2 border-transparent px-0 pb-2.5 pt-2 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', !isPanelReady && 'opacity-50')}>
|
||||
{resource.activeBatchTab === 'input-fields' && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<div className="text-text-primary system-md-semibold">{t('batch.requirementsTitle')}</div>
|
||||
<div className="mt-1 text-text-tertiary system-xs-regular">{t('batch.requirementsDescription')}</div>
|
||||
<div className="mt-3 rounded-xl bg-background-section p-3">
|
||||
{displayedRequirementFields.map(field => (
|
||||
<div key={field.id} className="flex items-center py-1">
|
||||
<div className="rounded px-1 py-0.5 text-text-tertiary system-xs-medium">
|
||||
{field.label}
|
||||
</div>
|
||||
<div className="text-[10px] leading-3 text-text-quaternary">
|
||||
{field.type}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Button variant="secondary" className="w-full justify-center" disabled={!isPanelReady} onClick={handleDownloadTemplate}>
|
||||
<span aria-hidden="true" className="i-ri-download-line mr-1 h-4 w-4" />
|
||||
{t('batch.downloadTemplate')}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
hidden
|
||||
type="file"
|
||||
accept=".csv,.xlsx"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
setUploadedFileName(resourceType, resourceId, file?.name ?? null)
|
||||
}}
|
||||
/>
|
||||
{isPanelReady && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full flex-col items-center justify-center rounded-xl border border-dashed border-divider-subtle bg-background-default-subtle px-4 py-6 text-center hover:border-components-button-secondary-border"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-file-upload-line h-5 w-5 text-text-tertiary" />
|
||||
<div className="mt-2 text-text-primary system-sm-semibold">{t('batch.uploadTitle')}</div>
|
||||
<div className="mt-1 text-text-tertiary system-xs-regular">{resource.uploadedFileName ?? t('batch.uploadHint')}</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isRunnable && (
|
||||
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-2 text-text-tertiary system-xs-regular">
|
||||
{t('batch.validation')}
|
||||
</div>
|
||||
)}
|
||||
<Button className="w-full justify-center" variant="primary" disabled={!isRunnable} onClick={handleRun}>
|
||||
{t('batch.run')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{resource.activeBatchTab === 'history' && (
|
||||
<div className="space-y-3">
|
||||
{resource.batchRecords.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-divider-subtle px-4 py-10 text-center text-text-tertiary system-sm-regular">
|
||||
{t('batch.emptyHistory')}
|
||||
</div>
|
||||
)}
|
||||
{resource.batchRecords.map(record => (
|
||||
<div key={record.id} className="rounded-2xl border border-divider-subtle bg-background-default-subtle p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-text-primary system-sm-semibold">{record.summary}</div>
|
||||
<div className="mt-1 text-text-tertiary system-xs-regular">{record.fileName}</div>
|
||||
</div>
|
||||
<Badge className={record.status === 'failed' ? 'badge-warning' : record.status === 'success' ? 'badge-accent' : ''}>
|
||||
{record.status === 'running'
|
||||
? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span aria-hidden="true" className="i-ri-loader-4-line h-3 w-3 animate-spin" />
|
||||
{statusLabels.running}
|
||||
</span>
|
||||
)
|
||||
: statusLabels[record.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 text-text-tertiary system-xs-regular">{record.startedAt}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BatchTestPanel
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import type { ConditionMetricOptionGroup, EvaluationResourceProps } from '../../types'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectGroupLabel,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@/app/components/base/ui/select'
|
||||
import { cn } from '@/utils/classnames'
|
||||
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 [selectKey, setSelectKey] = useState(0)
|
||||
|
||||
return (
|
||||
<Select key={selectKey}>
|
||||
<SelectTrigger
|
||||
aria-label={t('conditions.addCondition')}
|
||||
className={cn(
|
||||
'inline-flex w-auto min-w-0 border-none bg-transparent px-0 py-0 text-text-accent shadow-none hover:bg-transparent focus-visible:bg-transparent',
|
||||
disabled && 'cursor-not-allowed text-components-button-secondary-accent-text-disabled',
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-add-line h-4 w-4" />
|
||||
{t('conditions.addCondition')}
|
||||
</SelectTrigger>
|
||||
<SelectContent placement="bottom-start" popupClassName="w-[320px]">
|
||||
{metricOptionGroups.map(group => (
|
||||
<SelectGroup key={group.label}>
|
||||
<SelectGroupLabel className="px-3 pt-2 pb-1 system-xs-medium-uppercase text-text-tertiary">{group.label}</SelectGroupLabel>
|
||||
{group.options.map(option => (
|
||||
<SelectItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
className="h-auto gap-0 px-3 py-2"
|
||||
onClick={() => {
|
||||
addCondition(resourceType, resourceId, option.variableSelector)
|
||||
setSelectKey(current => current + 1)
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className="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>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddConditionSelect
|
||||
@@ -0,0 +1,302 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
ComparisonOperator,
|
||||
ConditionMetricOption,
|
||||
EvaluationResourceProps,
|
||||
JudgmentConditionItem,
|
||||
} from '../../types'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectGroupLabel,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/app/components/base/ui/select'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getAllowedOperators, requiresConditionValue, useEvaluationResource, useEvaluationStore } from '../../store'
|
||||
import {
|
||||
buildConditionMetricOptions,
|
||||
getComparisonOperatorLabel,
|
||||
getConditionMetricValueTypeTranslationKey,
|
||||
groupConditionMetricOptions,
|
||||
isSelectorEqual,
|
||||
serializeVariableSelector,
|
||||
} from '../../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 getMetricValueTypeIconClassName = (valueType: ConditionMetricOption['valueType']) => {
|
||||
if (valueType === 'number')
|
||||
return 'i-ri-hashtag'
|
||||
|
||||
if (valueType === 'boolean')
|
||||
return 'i-ri-checkbox-circle-line'
|
||||
|
||||
return 'i-ri-bar-chart-box-line'
|
||||
}
|
||||
|
||||
const ConditionMetricLabel = ({
|
||||
metric,
|
||||
placeholder,
|
||||
}: ConditionMetricLabelProps) => {
|
||||
if (!metric)
|
||||
return <span className="px-1 system-sm-regular text-components-input-text-placeholder">{placeholder}</span>
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 px-1">
|
||||
<div className="inline-flex h-6 min-w-0 items-center gap-1 rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark pr-1.5 pl-[5px] shadow-xs">
|
||||
<span className={cn(getMetricValueTypeIconClassName(metric.valueType), 'h-3 w-3 shrink-0 text-text-secondary')} />
|
||||
<span className="truncate system-xs-medium text-text-secondary">{metric.itemLabel}</span>
|
||||
</div>
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">{metric.groupLabel}</span>
|
||||
</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}>
|
||||
<SelectGroupLabel className="px-3 pt-2 pb-1 system-xs-medium-uppercase text-text-tertiary">{group.label}</SelectGroupLabel>
|
||||
{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={cn(getMetricValueTypeIconClassName(option.valueType), 'h-3.5 w-3.5 shrink-0 text-text-tertiary')} />
|
||||
<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 logicalLabels = {
|
||||
and: t('conditions.logical.and'),
|
||||
or: t('conditions.logical.or'),
|
||||
}
|
||||
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)
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-divider-subtle bg-components-card-bg p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-lg border border-divider-subtle bg-background-default-subtle p-1">
|
||||
{(['and', 'or'] as const).map(operator => (
|
||||
<button
|
||||
key={operator}
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 system-xs-medium-uppercase',
|
||||
resource.judgmentConfig.logicalOperator === operator
|
||||
? 'bg-components-card-bg text-text-primary shadow-xs'
|
||||
: 'text-text-tertiary',
|
||||
)}
|
||||
onClick={() => setConditionLogicalOperator(resourceType, resourceId, operator)}
|
||||
>
|
||||
{logicalLabels[operator]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t('conditions.removeCondition')}
|
||||
onClick={() => removeCondition(resourceType, resourceId, condition.id)}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-close-line h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConditionGroup
|
||||
@@ -0,0 +1,51 @@
|
||||
'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
|
||||
@@ -0,0 +1,364 @@
|
||||
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 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-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()
|
||||
|
||||
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 })
|
||||
})
|
||||
|
||||
// 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 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
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: 'workflow-1',
|
||||
selectedWorkflowName: 'Saved Review Workflow',
|
||||
})
|
||||
|
||||
expect(screen.getByText('Saved 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())
|
||||
})
|
||||
})
|
||||
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
'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 { 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,
|
||||
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 { data: selectedWorkflow } = useAppWorkflow(metric.customConfig?.workflowAppId ?? '')
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
if (!metric.customConfig?.workflowId)
|
||||
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, metric.customConfig?.mappings, metric.customConfig?.workflowId, metric.id, resourceId, resourceType, syncCustomMetricMappings])
|
||||
|
||||
useEffect(() => {
|
||||
if (!metric.customConfig?.workflowId)
|
||||
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)
|
||||
}, [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 ?? (selectedWorkflow ? getWorkflowName(selectedWorkflow) : null)}
|
||||
onSelect={workflow => setCustomMetricWorkflow(resourceType, resourceId, metric.id, {
|
||||
workflowId: workflow.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-secondary">{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.nodeTitle}-${output.id}-${index}`} 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
|
||||
@@ -0,0 +1,64 @@
|
||||
'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
|
||||
@@ -0,0 +1,118 @@
|
||||
'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
|
||||
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import type { AvailableEvaluationWorkflow } from '@/types/evaluation'
|
||||
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 {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/app/components/base/ui/popover'
|
||||
import {
|
||||
ScrollAreaContent,
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '@/app/components/base/ui/scroll-area'
|
||||
import { useAvailableEvaluationWorkflows } from '@/service/use-evaluation'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
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 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 => workflow.id === 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 text-text-tertiary system-sm-regular">
|
||||
{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={workflow.id === 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 text-text-secondary system-sm-medium">
|
||||
{getWorkflowName(workflow)}
|
||||
</div>
|
||||
{workflow.id === 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)
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import type { EvaluationResourceProps } from '../types'
|
||||
import { useEffect } from 'react'
|
||||
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'
|
||||
|
||||
type JudgeModelSelectorProps = EvaluationResourceProps & {
|
||||
autoSelectFirst?: boolean
|
||||
}
|
||||
|
||||
const JudgeModelSelector = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
autoSelectFirst = true,
|
||||
}: JudgeModelSelectorProps) => {
|
||||
const { data: modelList } = useModelList(ModelTypeEnum.textGeneration)
|
||||
const resource = useEvaluationResource(resourceType, resourceId)
|
||||
const setJudgeModel = useEvaluationStore(state => state.setJudgeModel)
|
||||
const selectedModel = decodeModelSelection(resource.judgeModelId)
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSelectFirst || resource.judgeModelId || !modelList.length)
|
||||
return
|
||||
|
||||
const firstProvider = modelList[0]
|
||||
const firstModel = firstProvider.models[0]
|
||||
if (!firstProvider || !firstModel)
|
||||
return
|
||||
|
||||
setJudgeModel(resourceType, resourceId, encodeModelSelection(firstProvider.provider, firstModel.model))
|
||||
}, [autoSelectFirst, modelList, resource.judgeModelId, resourceId, resourceType, setJudgeModel])
|
||||
|
||||
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
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import type { EvaluationResourceProps } from '../../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import BatchTestPanel from '../batch-test-panel'
|
||||
import ConditionsSection from '../conditions-section'
|
||||
import JudgeModelSelector from '../judge-model-selector'
|
||||
import MetricSection from '../metric-section'
|
||||
import SectionHeader, { InlineSectionHeader } from '../section-header'
|
||||
|
||||
const NonPipelineEvaluation = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
}: EvaluationResourceProps) => {
|
||||
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]"
|
||||
/>
|
||||
<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
|
||||
@@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
|
||||
import type { EvaluationResourceProps } from '../../types'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useAvailableEvaluationMetrics } from '@/service/use-evaluation'
|
||||
import { getEvaluationMockConfig } from '../../mock'
|
||||
import { isEvaluationRunnable, useEvaluationResource, useEvaluationStore } from '../../store'
|
||||
import JudgeModelSelector from '../judge-model-selector'
|
||||
import PipelineHistoryTable from '../pipeline/pipeline-history-table'
|
||||
import PipelineMetricItem from '../pipeline/pipeline-metric-item'
|
||||
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)
|
||||
const addBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric)
|
||||
const removeMetric = useEvaluationStore(state => state.removeMetric)
|
||||
const updateMetricThreshold = useEvaluationStore(state => state.updateMetricThreshold)
|
||||
const setUploadedFileName = useEvaluationStore(state => state.setUploadedFileName)
|
||||
const runBatchTest = useEvaluationStore(state => state.runBatchTest)
|
||||
const { data: availableMetricsData } = useAvailableEvaluationMetrics()
|
||||
const resource = useEvaluationResource(resourceType, resourceId)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
const builtinMetricMap = useMemo(() => new Map(
|
||||
resource.metrics
|
||||
.filter(metric => metric.kind === 'builtin')
|
||||
.map(metric => [metric.optionId, metric]),
|
||||
), [resource.metrics])
|
||||
const availableMetricIds = useMemo(() => new Set(availableMetricsData?.metrics ?? []), [availableMetricsData?.metrics])
|
||||
const availableBuiltinMetrics = useMemo(() => {
|
||||
return config.builtinMetrics.filter(metric =>
|
||||
availableMetricIds.has(metric.id) || builtinMetricMap.has(metric.id),
|
||||
)
|
||||
}, [availableMetricIds, builtinMetricMap, config.builtinMetrics])
|
||||
const isConfigReady = !!resource.judgeModelId && builtinMetricMap.size > 0
|
||||
const isRunnable = isEvaluationRunnable(resource)
|
||||
|
||||
useEffect(() => {
|
||||
ensureResource(resourceType, resourceId)
|
||||
}, [ensureResource, resourceId, resourceType])
|
||||
|
||||
const handleToggleMetric = (metricId: string) => {
|
||||
const selectedMetric = builtinMetricMap.get(metricId)
|
||||
if (selectedMetric) {
|
||||
removeMetric(resourceType, resourceId, selectedMetric.id)
|
||||
return
|
||||
}
|
||||
|
||||
addBuiltinMetric(resourceType, resourceId, metricId)
|
||||
}
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
const content = ['case_id,input,expected', '1,Example input,Example output'].join('\n')
|
||||
const link = document.createElement('a')
|
||||
link.href = `data:text/csv;charset=utf-8,${encodeURIComponent(content)}`
|
||||
link.download = config.templateFileName
|
||||
link.click()
|
||||
}
|
||||
|
||||
const handleUploadAndRun = () => {
|
||||
if (!isRunnable) {
|
||||
toast.warning(t('batch.validation'))
|
||||
return
|
||||
}
|
||||
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background-default xl:flex-row">
|
||||
<div className="flex min-h-0 flex-col border-b border-divider-subtle bg-background-default xl:w-[450px] xl:shrink-0 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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</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}
|
||||
autoSelectFirst={false}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<InlineSectionHeader title={t('metrics.title')} tooltip={t('metrics.description')} />
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{availableBuiltinMetrics.map((metric) => {
|
||||
const selectedMetric = builtinMetricMap.get(metric.id)
|
||||
|
||||
return (
|
||||
<PipelineMetricItem
|
||||
key={metric.id}
|
||||
metric={metric}
|
||||
selected={!!selectedMetric}
|
||||
threshold={selectedMetric?.threshold}
|
||||
disabledCondition
|
||||
onToggle={() => handleToggleMetric(metric.id)}
|
||||
onThresholdChange={value => updateMetricThreshold(resourceType, resourceId, selectedMetric?.id ?? '', value)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
className="flex-1 justify-center"
|
||||
variant="secondary"
|
||||
disabled={!isConfigReady}
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
<span aria-hidden="true" className="mr-1 i-ri-file-excel-2-line h-4 w-4" />
|
||||
{t('batch.downloadTemplate')}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 justify-center"
|
||||
variant="primary"
|
||||
disabled={!isConfigReady}
|
||||
onClick={handleUploadAndRun}
|
||||
>
|
||||
{t('pipeline.uploadAndRun')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
hidden
|
||||
type="file"
|
||||
accept=".csv,.xlsx"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file)
|
||||
return
|
||||
|
||||
setUploadedFileName(resourceType, resourceId, file.name)
|
||||
runBatchTest(resourceType, resourceId)
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-divider-subtle" />
|
||||
|
||||
<PipelineHistoryTable
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 bg-background-default">
|
||||
<PipelineResultsPanel />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PipelineEvaluation
|
||||
@@ -0,0 +1,229 @@
|
||||
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 mockUseAvailableEvaluationMetrics = vi.hoisted(() => vi.fn())
|
||||
const mockUseEvaluationNodeInfoMutation = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/service/use-evaluation', () => ({
|
||||
useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args),
|
||||
useAvailableEvaluationMetrics: (...args: unknown[]) => mockUseAvailableEvaluationMetrics(...args),
|
||||
useEvaluationNodeInfoMutation: (...args: unknown[]) => mockUseEvaluationNodeInfoMutation(...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: {} })
|
||||
|
||||
mockUseAvailableEvaluationMetrics.mockReturnValue({
|
||||
data: {
|
||||
metrics: ['answer-correctness'],
|
||||
},
|
||||
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,
|
||||
})
|
||||
|
||||
mockUseEvaluationNodeInfoMutation.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
|
||||
options?.onSuccess?.({
|
||||
'answer-correctness': [
|
||||
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// 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 show only unselected nodes in the add-node dropdown and append the selected node', () => {
|
||||
// Arrange
|
||||
mockUseEvaluationNodeInfoMutation.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
|
||||
options?.onSuccess?.({
|
||||
'answer-correctness': [
|
||||
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
|
||||
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
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
|
||||
mockUseEvaluationNodeInfoMutation.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
|
||||
options?.onSuccess?.({
|
||||
'answer-correctness': [
|
||||
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
|
||||
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
|
||||
import type { NodeInfo } from '@/types/evaluation'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useEvaluationStore } from '../../store'
|
||||
import { dedupeNodeInfoList, getMetricVisual, getNodeVisual, 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
|
||||
|
||||
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 pb-1 pt-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 text-text-secondary system-md-medium">{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 hover:text-text-secondary focus-visible:opacity-100 group-hover: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 pb-3 pt-1">
|
||||
{selectedNodeInfoList.length
|
||||
? selectedNodeInfoList.map((nodeInfo) => {
|
||||
const nodeVisual = getNodeVisual(nodeInfo)
|
||||
const nodeToneClasses = getToneClasses(nodeVisual.tone)
|
||||
|
||||
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"
|
||||
>
|
||||
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-md border-[0.45px] border-divider-subtle shadow-xs shadow-shadow-shadow-3', nodeToneClasses.solid)}>
|
||||
<span aria-hidden="true" className={cn(nodeVisual.icon, 'h-3.5 w-3.5')} />
|
||||
</div>
|
||||
<span className="px-1 text-text-primary system-xs-regular">{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={() => updateBuiltinMetric(
|
||||
resourceType,
|
||||
resourceId,
|
||||
metric.optionId,
|
||||
selectedNodeInfoList.filter(item => item.node_id !== nodeInfo.node_id),
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-close-line h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
: (
|
||||
<span className="px-1 text-text-tertiary system-xs-regular">{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) => {
|
||||
const nodeVisual = getNodeVisual(nodeInfo)
|
||||
const nodeToneClasses = getToneClasses(nodeVisual.tone)
|
||||
|
||||
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">
|
||||
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-md border-[0.45px] border-divider-subtle shadow-xs shadow-shadow-shadow-3', nodeToneClasses.solid)}>
|
||||
<span aria-hidden="true" className={cn(nodeVisual.icon, 'h-3.5 w-3.5')} />
|
||||
</div>
|
||||
<span className="truncate text-text-secondary system-sm-medium">{nodeInfo.title}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BuiltinMetricCard
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { cn } from '@/utils/classnames'
|
||||
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
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import type { EvaluationResourceProps } from '../../types'
|
||||
import type { NodeInfo } from '@/types/evaluation'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAvailableEvaluationMetrics, useEvaluationNodeInfoMutation } from '@/service/use-evaluation'
|
||||
import { useEvaluationResource } from '../../store'
|
||||
import MetricSelector from '../metric-selector'
|
||||
import { toEvaluationTargetType } 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,
|
||||
}: EvaluationResourceProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const resource = useEvaluationResource(resourceType, resourceId)
|
||||
const [nodeInfoMap, setNodeInfoMap] = useState<Record<string, NodeInfo[]>>({})
|
||||
const hasMetrics = resource.metrics.length > 0
|
||||
const hasBuiltinMetrics = resource.metrics.some(metric => metric.kind === 'builtin')
|
||||
const shouldLoadNodeInfo = resourceType !== 'datasets' && !!resourceId && hasBuiltinMetrics
|
||||
const { data: availableMetricsData } = useAvailableEvaluationMetrics(shouldLoadNodeInfo)
|
||||
const { mutate: loadNodeInfo } = useEvaluationNodeInfoMutation()
|
||||
const availableMetricIds = useMemo(() => availableMetricsData?.metrics ?? [], [availableMetricsData?.metrics])
|
||||
const availableMetricIdsKey = availableMetricIds.join(',')
|
||||
const resolvedNodeInfoMap = shouldLoadNodeInfo ? nodeInfoMap : {}
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldLoadNodeInfo || availableMetricIds.length === 0)
|
||||
return
|
||||
|
||||
let isActive = true
|
||||
|
||||
loadNodeInfo(
|
||||
{
|
||||
params: {
|
||||
targetType: toEvaluationTargetType(resourceType),
|
||||
targetId: resourceId,
|
||||
},
|
||||
body: {
|
||||
metrics: availableMetricIds,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (!isActive)
|
||||
return
|
||||
|
||||
setNodeInfoMap(data)
|
||||
},
|
||||
onError: () => {
|
||||
if (!isActive)
|
||||
return
|
||||
|
||||
setNodeInfoMap({})
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [availableMetricIds, availableMetricIdsKey, loadNodeInfo, resourceId, resourceType, shouldLoadNodeInfo])
|
||||
|
||||
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' ? (resolvedNodeInfoMap[metric.optionId] ?? []) : undefined}
|
||||
/>
|
||||
))}
|
||||
<MetricSelector
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
triggerClassName="rounded-md px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default MetricSection
|
||||
@@ -0,0 +1,39 @@
|
||||
'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
|
||||
@@ -0,0 +1,18 @@
|
||||
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
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent } from 'react'
|
||||
import type { MetricSelectorProps } from './types'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/app/components/base/ui/popover'
|
||||
import { cn } from '@/utils/classnames'
|
||||
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,
|
||||
triggerVariant = 'ghost-accent',
|
||||
triggerClassName,
|
||||
triggerStyle = 'button',
|
||||
}: 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 [nodeInfoMap, setNodeInfoMap] = useState<Record<string, Array<{ node_id: string, title: string, type: string }>>>({})
|
||||
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,
|
||||
nodeInfoMap,
|
||||
setNodeInfoMap,
|
||||
})
|
||||
|
||||
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={(
|
||||
triggerStyle === 'text'
|
||||
? (
|
||||
<button type="button" className={cn('inline-flex items-center text-text-accent system-sm-medium', triggerClassName)}>
|
||||
<span aria-hidden="true" className="i-ri-add-line mr-1 h-4 w-4" />
|
||||
{t('metrics.add')}
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<Button variant={triggerVariant} className={triggerClassName}>
|
||||
<span aria-hidden="true" className="i-ri-add-line mr-1 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
|
||||
@@ -0,0 +1,26 @@
|
||||
type SelectorEmptyStateProps = {
|
||||
message: string
|
||||
}
|
||||
|
||||
const EmptySearchStateIcon = () => {
|
||||
return (
|
||||
<div className="relative h-8 w-8 text-text-quaternary">
|
||||
<span aria-hidden="true" className="i-ri-search-line absolute bottom-0 right-0 h-6 w-6" />
|
||||
<span aria-hidden="true" className="absolute left-0 top-[9px] h-[2px] w-[7px] rounded-full bg-current opacity-80" />
|
||||
<span aria-hidden="true" className="absolute left-0 top-[16px] 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="text-text-secondary system-sm-regular">{message}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectorEmptyState
|
||||
@@ -0,0 +1,33 @@
|
||||
type SelectorFooterProps = {
|
||||
title: string
|
||||
description: string
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const SelectorFooter = ({
|
||||
title,
|
||||
description,
|
||||
disabled = false,
|
||||
onClick,
|
||||
}: SelectorFooterProps) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="relative flex items-center gap-3 overflow-hidden border-t border-divider-subtle bg-background-default-subtle px-4 py-5 text-left enabled:hover:bg-state-base-hover-alt disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="absolute -left-6 -top-6 h-28 w-28 rounded-full bg-util-colors-indigo-indigo-100 opacity-50 blur-2xl" />
|
||||
<div className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-[0px_3px_10px_-2px_rgba(9,9,11,0.08),0px_2px_4px_-2px_rgba(9,9,11,0.06)]">
|
||||
<span aria-hidden="true" className="i-ri-add-line h-[18px] w-[18px] text-text-tertiary" />
|
||||
</div>
|
||||
<div className="relative min-w-0">
|
||||
<div className="text-text-secondary system-sm-semibold">{title}</div>
|
||||
<div className="mt-0.5 text-text-tertiary system-xs-regular">{description}</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectorFooter
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { EvaluationMetric } from '../../types'
|
||||
import type { MetricSelectorSection } from './types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getMetricVisual, getNodeVisual, getToneClasses } from './utils'
|
||||
|
||||
type SelectorMetricSectionProps = {
|
||||
section: MetricSelectorSection
|
||||
index: number
|
||||
addedMetric?: EvaluationMetric
|
||||
isExpanded: boolean
|
||||
isShowingAllNodes: boolean
|
||||
onToggleExpanded: () => void
|
||||
onToggleShowAllNodes: () => void
|
||||
onToggleNodeSelection: (metricId: string, nodeInfo: MetricSelectorSection['visibleNodes'][number]) => void
|
||||
t: TFunction<'evaluation'>
|
||||
}
|
||||
|
||||
const SelectorMetricSection = ({
|
||||
section,
|
||||
index,
|
||||
addedMetric,
|
||||
isExpanded,
|
||||
isShowingAllNodes,
|
||||
onToggleExpanded,
|
||||
onToggleShowAllNodes,
|
||||
onToggleNodeSelection,
|
||||
t,
|
||||
}: SelectorMetricSectionProps) => {
|
||||
const { metric, visibleNodes, hasNoNodeInfo } = section
|
||||
const selectedNodeIds = new Set(
|
||||
addedMetric?.nodeInfoList?.length
|
||||
? addedMetric.nodeInfoList.map(nodeInfo => nodeInfo.node_id)
|
||||
: [],
|
||||
)
|
||||
const metricVisual = getMetricVisual(metric.id)
|
||||
const toneClasses = getToneClasses(metricVisual.tone)
|
||||
const hasMoreNodes = visibleNodes.length > 3
|
||||
const shownNodes = hasMoreNodes && !isShowingAllNodes ? visibleNodes.slice(0, 3) : visibleNodes
|
||||
|
||||
return (
|
||||
<div data-testid={`evaluation-metric-option-${metric.id}`}>
|
||||
{index > 0 && (
|
||||
<div className="px-3 pt-1">
|
||||
<div className="h-px w-full bg-divider-subtle" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-4 pb-1 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
onClick={onToggleExpanded}
|
||||
>
|
||||
<div className={cn('flex h-[18px] w-[18px] items-center justify-center rounded-md', toneClasses.soft)}>
|
||||
<span aria-hidden="true" className={cn(metricVisual.icon, 'h-3.5 w-3.5')} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate text-text-secondary system-xs-medium-uppercase">{metric.label}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('i-ri-arrow-down-s-line h-4 w-4 text-text-quaternary transition-transform', !isExpanded && '-rotate-90')}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button type="button" className="p-px text-text-quaternary">
|
||||
<span aria-hidden="true" className="i-ri-question-line h-[14px] w-[14px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-1 py-1">
|
||||
{hasNoNodeInfo && (
|
||||
<div className="px-3 pb-2 pt-0.5 text-text-tertiary system-sm-regular">
|
||||
{t('metrics.noNodesInWorkflow')}
|
||||
</div>
|
||||
)}
|
||||
{shownNodes.map((nodeInfo) => {
|
||||
const nodeVisual = getNodeVisual(nodeInfo)
|
||||
const nodeToneClasses = getToneClasses(nodeVisual.tone)
|
||||
const isAdded = addedMetric
|
||||
? addedMetric.nodeInfoList?.length
|
||||
? selectedNodeIds.has(nodeInfo.node_id)
|
||||
: true
|
||||
: false
|
||||
|
||||
return (
|
||||
<button
|
||||
key={nodeInfo.node_id}
|
||||
data-testid={`evaluation-metric-node-${metric.id}-${nodeInfo.node_id}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-state-base-hover-alt',
|
||||
isAdded && 'opacity-50',
|
||||
)}
|
||||
onClick={() => onToggleNodeSelection(metric.id, nodeInfo)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 pr-1">
|
||||
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-md border-[0.45px] border-divider-subtle shadow-xs shadow-shadow-shadow-3', nodeToneClasses.solid)}>
|
||||
<span aria-hidden="true" className={cn(nodeVisual.icon, 'h-3.5 w-3.5')} />
|
||||
</div>
|
||||
<span className="truncate text-[13px] font-medium leading-4 text-text-secondary">
|
||||
{nodeInfo.title}
|
||||
</span>
|
||||
</div>
|
||||
{isAdded && (
|
||||
<span className="shrink-0 px-1 text-text-quaternary system-xs-regular">{t('metrics.added')}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{hasMoreNodes && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left hover:bg-state-base-hover-alt"
|
||||
onClick={onToggleShowAllNodes}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 pr-1">
|
||||
<div className="flex items-center px-1 text-text-tertiary">
|
||||
<span aria-hidden="true" className={cn(isShowingAllNodes ? 'i-ri-subtract-line' : 'i-ri-more-line', 'h-4 w-4')} />
|
||||
</div>
|
||||
<span className="truncate text-text-tertiary system-xs-regular">
|
||||
{isShowingAllNodes ? t('metrics.showLess') : t('metrics.showMore')}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectorMetricSection
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { EvaluationMetric, EvaluationResourceProps, MetricOption } from '../../types'
|
||||
import type { NodeInfo } from '@/types/evaluation'
|
||||
|
||||
export type MetricSelectorProps = EvaluationResourceProps & {
|
||||
triggerVariant?: 'primary' | 'warning' | 'secondary' | 'secondary-accent' | 'ghost' | 'ghost-accent' | 'tertiary'
|
||||
triggerClassName?: string
|
||||
triggerStyle?: 'button' | 'text'
|
||||
}
|
||||
|
||||
export type MetricVisualTone = 'indigo' | 'green'
|
||||
|
||||
export type MetricSelectorSection = {
|
||||
metric: MetricOption
|
||||
hasNoNodeInfo: boolean
|
||||
visibleNodes: NodeInfo[]
|
||||
}
|
||||
|
||||
export type BuiltinMetricMap = Map<string, EvaluationMetric>
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { BuiltinMetricMap, MetricSelectorSection } from './types'
|
||||
import type { NodeInfo } from '@/types/evaluation'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useAvailableEvaluationMetrics, useEvaluationNodeInfoMutation } from '@/service/use-evaluation'
|
||||
import { getEvaluationMockConfig } from '../../mock'
|
||||
import { useEvaluationResource, useEvaluationStore } from '../../store'
|
||||
import {
|
||||
buildMetricOption,
|
||||
dedupeNodeInfoList,
|
||||
toEvaluationTargetType,
|
||||
} from './utils'
|
||||
|
||||
type UseMetricSelectorDataOptions = {
|
||||
open: boolean
|
||||
query: string
|
||||
resourceType: 'apps' | 'datasets' | 'snippets'
|
||||
resourceId: string
|
||||
nodeInfoMap: Record<string, NodeInfo[]>
|
||||
setNodeInfoMap: (value: Record<string, NodeInfo[]>) => void
|
||||
}
|
||||
|
||||
type UseMetricSelectorDataResult = {
|
||||
builtinMetricMap: BuiltinMetricMap
|
||||
filteredSections: MetricSelectorSection[]
|
||||
isRemoteLoading: boolean
|
||||
toggleNodeSelection: (metricId: string, nodeInfo: NodeInfo) => void
|
||||
}
|
||||
|
||||
export const useMetricSelectorData = ({
|
||||
open,
|
||||
query,
|
||||
resourceType,
|
||||
resourceId,
|
||||
nodeInfoMap,
|
||||
setNodeInfoMap,
|
||||
}: UseMetricSelectorDataOptions): UseMetricSelectorDataResult => {
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
const metrics = useEvaluationResource(resourceType, resourceId).metrics
|
||||
const addBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric)
|
||||
const removeMetric = useEvaluationStore(state => state.removeMetric)
|
||||
const { data: availableMetricsData, isLoading: isAvailableMetricsLoading } = useAvailableEvaluationMetrics(open)
|
||||
const { mutate: loadNodeInfo, isPending: isNodeInfoLoading } = useEvaluationNodeInfoMutation()
|
||||
|
||||
const builtinMetrics = useMemo(() => {
|
||||
return metrics.filter(metric => metric.kind === 'builtin')
|
||||
}, [metrics])
|
||||
|
||||
const builtinMetricMap = useMemo(() => {
|
||||
return new Map(builtinMetrics.map(metric => [metric.optionId, metric] as const))
|
||||
}, [builtinMetrics])
|
||||
|
||||
const availableMetricIds = useMemo(() => availableMetricsData?.metrics ?? [], [availableMetricsData?.metrics])
|
||||
const availableMetricIdsKey = availableMetricIds.join(',')
|
||||
|
||||
const resolvedMetrics = useMemo(() => {
|
||||
const metricsMap = new Map(config.builtinMetrics.map(metric => [metric.id, metric] as const))
|
||||
|
||||
return availableMetricIds.map(metricId => metricsMap.get(metricId) ?? buildMetricOption(metricId))
|
||||
}, [availableMetricIds, config.builtinMetrics])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
|
||||
if (resourceType === 'datasets' || !resourceId || availableMetricIds.length === 0)
|
||||
return
|
||||
|
||||
let isActive = true
|
||||
|
||||
loadNodeInfo(
|
||||
{
|
||||
params: {
|
||||
targetType: toEvaluationTargetType(resourceType),
|
||||
targetId: resourceId,
|
||||
},
|
||||
body: {
|
||||
metrics: availableMetricIds,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (!isActive)
|
||||
return
|
||||
|
||||
setNodeInfoMap(data)
|
||||
},
|
||||
onError: () => {
|
||||
if (!isActive)
|
||||
return
|
||||
|
||||
setNodeInfoMap({})
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [availableMetricIds, availableMetricIdsKey, loadNodeInfo, open, resourceId, resourceType, setNodeInfoMap])
|
||||
|
||||
const filteredSections = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
|
||||
return resolvedMetrics.map((metric) => {
|
||||
const metricMatches = !keyword
|
||||
|| metric.label.toLowerCase().includes(keyword)
|
||||
|| metric.description.toLowerCase().includes(keyword)
|
||||
const metricNodes = nodeInfoMap[metric.id] ?? []
|
||||
const supportsNodeSelection = resourceType !== 'datasets'
|
||||
const hasNoNodeInfo = supportsNodeSelection && metricNodes.length === 0
|
||||
|
||||
if (hasNoNodeInfo) {
|
||||
if (!metricMatches)
|
||||
return null
|
||||
|
||||
return {
|
||||
metric,
|
||||
hasNoNodeInfo: true,
|
||||
visibleNodes: [] as NodeInfo[],
|
||||
}
|
||||
}
|
||||
|
||||
const visibleNodes = metricMatches
|
||||
? metricNodes
|
||||
: metricNodes.filter((nodeInfo) => {
|
||||
return nodeInfo.title.toLowerCase().includes(keyword)
|
||||
|| nodeInfo.type.toLowerCase().includes(keyword)
|
||||
|| nodeInfo.node_id.toLowerCase().includes(keyword)
|
||||
})
|
||||
|
||||
if (!metricMatches && visibleNodes.length === 0)
|
||||
return null
|
||||
|
||||
return {
|
||||
metric,
|
||||
hasNoNodeInfo: false,
|
||||
visibleNodes,
|
||||
}
|
||||
}).filter(section => !!section)
|
||||
}, [nodeInfoMap, query, resolvedMetrics, resourceType])
|
||||
|
||||
const toggleNodeSelection = (metricId: string, nodeInfo: NodeInfo) => {
|
||||
const addedMetric = builtinMetricMap.get(metricId)
|
||||
const currentSelectedNodes = addedMetric?.nodeInfoList ?? []
|
||||
|
||||
const nextSelectedNodes = addedMetric && currentSelectedNodes.length === 0
|
||||
? [nodeInfo]
|
||||
: currentSelectedNodes.some(item => item.node_id === nodeInfo.node_id)
|
||||
? currentSelectedNodes.filter(item => item.node_id !== nodeInfo.node_id)
|
||||
: dedupeNodeInfoList([...currentSelectedNodes, nodeInfo])
|
||||
|
||||
if (addedMetric && nextSelectedNodes.length === 0) {
|
||||
removeMetric(resourceType, resourceId, addedMetric.id)
|
||||
return
|
||||
}
|
||||
|
||||
addBuiltinMetric(resourceType, resourceId, metricId, nextSelectedNodes)
|
||||
}
|
||||
|
||||
return {
|
||||
builtinMetricMap,
|
||||
filteredSections,
|
||||
isRemoteLoading: isAvailableMetricsLoading || isNodeInfoLoading,
|
||||
toggleNodeSelection,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { MetricOption } from '../../types'
|
||||
import type { MetricVisualTone } from './types'
|
||||
import type { EvaluationTargetType, NodeInfo } from '@/types/evaluation'
|
||||
|
||||
export const toEvaluationTargetType = (resourceType: 'apps' | 'snippets'): EvaluationTargetType => {
|
||||
return resourceType === 'snippets' ? 'snippets' : 'apps'
|
||||
}
|
||||
|
||||
const humanizeMetricId = (metricId: string) => {
|
||||
return metricId
|
||||
.split(/[-_]/g)
|
||||
.filter(Boolean)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export const buildMetricOption = (metricId: string): MetricOption => ({
|
||||
id: metricId,
|
||||
label: humanizeMetricId(metricId),
|
||||
description: '',
|
||||
valueType: 'number',
|
||||
})
|
||||
|
||||
export const getMetricVisual = (metricId: string): { icon: string, tone: MetricVisualTone } => {
|
||||
if (['context-precision', 'context-recall'].includes(metricId)) {
|
||||
return {
|
||||
icon: metricId === 'context-recall' ? 'i-ri-arrow-go-back-line' : 'i-ri-focus-2-line',
|
||||
tone: 'green',
|
||||
}
|
||||
}
|
||||
|
||||
if (metricId === 'faithfulness')
|
||||
return { icon: 'i-ri-anchor-line', tone: 'indigo' }
|
||||
|
||||
if (metricId === 'tool-correctness')
|
||||
return { icon: 'i-ri-tools-line', tone: 'indigo' }
|
||||
|
||||
if (metricId === 'task-completion')
|
||||
return { icon: 'i-ri-task-line', tone: 'indigo' }
|
||||
|
||||
if (metricId === 'argument-correctness')
|
||||
return { icon: 'i-ri-scales-3-line', tone: 'indigo' }
|
||||
|
||||
return { icon: 'i-ri-checkbox-circle-line', tone: 'indigo' }
|
||||
}
|
||||
|
||||
export const getNodeVisual = (nodeInfo: NodeInfo): { icon: string, tone: MetricVisualTone } => {
|
||||
const normalizedType = nodeInfo.type.toLowerCase()
|
||||
const normalizedTitle = nodeInfo.title.toLowerCase()
|
||||
|
||||
if (normalizedType.includes('retriev') || normalizedTitle.includes('retriev') || normalizedTitle.includes('knowledge'))
|
||||
return { icon: 'i-ri-book-open-line', tone: 'green' }
|
||||
|
||||
if (normalizedType.includes('agent') || normalizedTitle.includes('agent'))
|
||||
return { icon: 'i-ri-user-star-line', tone: 'indigo' }
|
||||
|
||||
return { icon: 'i-ri-ai-generate-2', tone: 'indigo' }
|
||||
}
|
||||
|
||||
export const getToneClasses = (tone: MetricVisualTone) => {
|
||||
if (tone === 'green') {
|
||||
return {
|
||||
soft: 'bg-util-colors-green-green-50 text-util-colors-green-green-500',
|
||||
solid: 'bg-util-colors-green-green-500 text-white',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
soft: 'bg-util-colors-indigo-indigo-50 text-util-colors-indigo-indigo-500',
|
||||
solid: 'bg-util-colors-indigo-indigo-500 text-white',
|
||||
}
|
||||
}
|
||||
|
||||
export const dedupeNodeInfoList = (nodeInfoList: NodeInfo[]) => {
|
||||
return Array.from(new Map(nodeInfoList.map(nodeInfo => [nodeInfo.node_id, nodeInfo])).values())
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import type { EvaluationResourceProps } from '../../types'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useEvaluationResource } from '../../store'
|
||||
|
||||
const PipelineHistoryTable = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
}: EvaluationResourceProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const resource = useEvaluationResource(resourceType, resourceId)
|
||||
const [query, setQuery] = useState('')
|
||||
const statusLabels = {
|
||||
running: t('batch.status.running'),
|
||||
success: t('batch.status.success'),
|
||||
failed: t('batch.status.failed'),
|
||||
}
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
if (!keyword)
|
||||
return resource.batchRecords
|
||||
|
||||
return resource.batchRecords.filter(record =>
|
||||
record.fileName.toLowerCase().includes(keyword)
|
||||
|| record.summary.toLowerCase().includes(keyword),
|
||||
)
|
||||
}, [query, resource.batchRecords])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex items-center justify-between gap-3 px-6 pt-4 pb-2">
|
||||
<div className="system-xl-semibold text-text-primary">{t('history.title')}</div>
|
||||
<div className="w-[160px] shrink-0 sm:w-[200px]">
|
||||
<Input
|
||||
value={query}
|
||||
showLeftIcon
|
||||
placeholder={t('history.searchPlaceholder')}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 px-4 pb-4">
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border border-effects-highlight bg-background-default">
|
||||
<div className="grid grid-cols-[minmax(0,1.8fr)_80px_80px_80px_40px] rounded-t-lg bg-background-section px-2 py-1">
|
||||
<div className="flex items-center gap-1 px-2 system-xs-medium-uppercase text-text-tertiary">
|
||||
<span>{t('history.columns.time')}</span>
|
||||
<span aria-hidden="true" className="i-ri-arrow-down-line h-3 w-3" />
|
||||
</div>
|
||||
<div className="px-2 system-xs-medium-uppercase text-text-tertiary">{t('history.columns.creator')}</div>
|
||||
<div className="px-2 system-xs-medium-uppercase text-text-tertiary">{t('history.columns.version')}</div>
|
||||
<div className="px-2 text-center system-xs-medium-uppercase text-text-tertiary">{t('history.columns.status')}</div>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{filteredRecords.length > 0 && (
|
||||
<div className="divide-y divide-divider-subtle">
|
||||
{filteredRecords.map(record => (
|
||||
<div
|
||||
key={record.id}
|
||||
className="grid grid-cols-[minmax(0,1.8fr)_80px_80px_80px_40px] items-center px-2 py-2"
|
||||
>
|
||||
<div className="truncate px-2 system-sm-regular text-text-secondary">{record.startedAt}</div>
|
||||
<div className="truncate px-2 system-sm-regular text-text-secondary">{t('history.creatorYou')}</div>
|
||||
<div className="truncate px-2 system-sm-regular text-text-secondary">{t('history.latestVersion')}</div>
|
||||
<div className="flex justify-center px-2">
|
||||
<Badge
|
||||
className={cn(
|
||||
record.status === 'failed' && 'badge-warning',
|
||||
record.status === 'success' && 'badge-accent',
|
||||
)}
|
||||
>
|
||||
{record.status === 'running'
|
||||
? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span aria-hidden="true" className="i-ri-loader-4-line h-3 w-3 animate-spin" />
|
||||
{statusLabels.running}
|
||||
</span>
|
||||
)
|
||||
: statusLabels[record.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-text-quaternary hover:bg-state-base-hover"
|
||||
aria-label={record.summary}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-more-2-line h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredRecords.length === 0 && (
|
||||
<div className="flex h-full min-h-[321px] flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<span aria-hidden="true" className="i-ri-history-line h-5 w-5 text-text-quaternary" />
|
||||
<div className="system-sm-medium text-text-quaternary">{t('history.empty')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PipelineHistoryTable
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
|
||||
import type { MetricOption } from '../../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { DEFAULT_PIPELINE_METRIC_THRESHOLD } from '../../store-utils'
|
||||
|
||||
type PipelineMetricItemProps = {
|
||||
metric: MetricOption
|
||||
selected: boolean
|
||||
onToggle: () => void
|
||||
disabledCondition: boolean
|
||||
threshold?: number
|
||||
onThresholdChange: (value: number) => void
|
||||
}
|
||||
|
||||
const PipelineMetricItem = ({
|
||||
metric,
|
||||
selected,
|
||||
onToggle,
|
||||
disabledCondition,
|
||||
threshold = DEFAULT_PIPELINE_METRIC_THRESHOLD,
|
||||
onThresholdChange,
|
||||
}: PipelineMetricItemProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 px-1 py-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-2 text-left"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<Checkbox checked={selected} />
|
||||
<span className="truncate system-sm-medium text-text-secondary">{metric.label}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="flex h-4 w-4 items-center justify-center text-text-quaternary">
|
||||
<span aria-hidden="true" className="i-ri-question-line h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{metric.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</button>
|
||||
|
||||
{selected
|
||||
? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="system-xs-medium text-text-accent">{t('pipeline.passIf')}</span>
|
||||
<div className="w-[52px]">
|
||||
<Input
|
||||
value={String(threshold)}
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={(event) => {
|
||||
const parsedValue = Number(event.target.value)
|
||||
if (!Number.isNaN(parsedValue))
|
||||
onThresholdChange(parsedValue)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabledCondition}
|
||||
className={cn(
|
||||
'system-xs-medium text-text-tertiary',
|
||||
disabledCondition && 'cursor-not-allowed text-components-button-secondary-accent-text-disabled',
|
||||
)}
|
||||
>
|
||||
+ Condition
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PipelineMetricItem
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const PipelineResultsPanel = () => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[360px] flex-1 items-center justify-center xl:min-h-0">
|
||||
<div className="flex flex-col items-center gap-4 px-4 text-center">
|
||||
<span aria-hidden="true" className="i-ri-file-list-3-line h-12 w-12 text-text-quaternary" />
|
||||
<div className="system-md-medium text-text-quaternary">{t('results.empty')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PipelineResultsPanel
|
||||
76
web/app/components/evaluation/components/section-header.tsx
Normal file
76
web/app/components/evaluation/components/section-header.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type SectionHeaderProps = {
|
||||
title: string
|
||||
description?: ReactNode
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
titleClassName?: string
|
||||
descriptionClassName?: string
|
||||
}
|
||||
|
||||
type InlineSectionHeaderProps = {
|
||||
title: string
|
||||
tooltip?: ReactNode
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const SectionHeader = ({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
titleClassName,
|
||||
descriptionClassName,
|
||||
}: SectionHeaderProps) => {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-start justify-between gap-3', className)}>
|
||||
<div>
|
||||
<div className={cn('text-text-primary system-xl-semibold', titleClassName)}>{title}</div>
|
||||
{description && <div className={cn('mt-1 text-text-tertiary system-sm-regular', descriptionClassName)}>{description}</div>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const InlineSectionHeader = ({
|
||||
title,
|
||||
tooltip,
|
||||
action,
|
||||
className,
|
||||
}: InlineSectionHeaderProps) => {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-center justify-between gap-3', className)}>
|
||||
<div className="flex min-h-6 items-center gap-1">
|
||||
<div className="text-text-primary system-md-semibold">{title}</div>
|
||||
{tooltip && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-4 w-4 items-center justify-center text-text-quaternary transition-colors hover:text-text-tertiary"
|
||||
aria-label={title}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-question-line h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SectionHeader
|
||||
46
web/app/components/evaluation/index.tsx
Normal file
46
web/app/components/evaluation/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import type { EvaluationResourceProps } from './types'
|
||||
import { useEffect } from 'react'
|
||||
import { useEvaluationConfig } from '@/service/use-evaluation'
|
||||
import NonPipelineEvaluation from './components/layout/non-pipeline-evaluation'
|
||||
import PipelineEvaluation from './components/layout/pipeline-evaluation'
|
||||
import { useEvaluationStore } from './store'
|
||||
|
||||
const Evaluation = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
}: EvaluationResourceProps) => {
|
||||
const { data: config } = useEvaluationConfig(resourceType, resourceId)
|
||||
const ensureResource = useEvaluationStore(state => state.ensureResource)
|
||||
const hydrateResource = useEvaluationStore(state => state.hydrateResource)
|
||||
|
||||
useEffect(() => {
|
||||
ensureResource(resourceType, resourceId)
|
||||
}, [ensureResource, resourceId, resourceType])
|
||||
|
||||
useEffect(() => {
|
||||
if (!config)
|
||||
return
|
||||
|
||||
hydrateResource(resourceType, resourceId, config)
|
||||
}, [config, hydrateResource, resourceId, resourceType])
|
||||
|
||||
if (resourceType === 'datasets') {
|
||||
return (
|
||||
<PipelineEvaluation
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NonPipelineEvaluation
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Evaluation
|
||||
178
web/app/components/evaluation/mock.ts
Normal file
178
web/app/components/evaluation/mock.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type {
|
||||
EvaluationFieldOption,
|
||||
EvaluationMockConfig,
|
||||
EvaluationResourceType,
|
||||
MetricOption,
|
||||
} from './types'
|
||||
|
||||
const judgeModels = [
|
||||
{
|
||||
id: 'gpt-4.1-mini',
|
||||
label: 'GPT-4.1 mini',
|
||||
provider: 'OpenAI',
|
||||
},
|
||||
{
|
||||
id: 'claude-3-7-sonnet',
|
||||
label: 'Claude 3.7 Sonnet',
|
||||
provider: 'Anthropic',
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.0-flash',
|
||||
label: 'Gemini 2.0 Flash',
|
||||
provider: 'Google',
|
||||
},
|
||||
]
|
||||
|
||||
const builtinMetrics: MetricOption[] = [
|
||||
{
|
||||
id: 'answer-correctness',
|
||||
label: 'Answer Correctness',
|
||||
description: 'Compares the response with the expected answer and scores factual alignment.',
|
||||
valueType: 'number',
|
||||
},
|
||||
{
|
||||
id: 'faithfulness',
|
||||
label: 'Faithfulness',
|
||||
description: 'Checks whether the answer stays grounded in the retrieved evidence.',
|
||||
valueType: 'number',
|
||||
},
|
||||
{
|
||||
id: 'relevance',
|
||||
label: 'Relevance',
|
||||
description: 'Evaluates how directly the answer addresses the original request.',
|
||||
valueType: 'number',
|
||||
},
|
||||
{
|
||||
id: 'latency',
|
||||
label: 'Latency',
|
||||
description: 'Captures runtime responsiveness for the full execution path.',
|
||||
valueType: 'number',
|
||||
},
|
||||
{
|
||||
id: 'token-usage',
|
||||
label: 'Token Usage',
|
||||
description: 'Tracks prompt and completion token consumption for the run.',
|
||||
valueType: 'number',
|
||||
},
|
||||
{
|
||||
id: 'tool-success-rate',
|
||||
label: 'Tool Success Rate',
|
||||
description: 'Measures whether each required tool invocation finishes without failure.',
|
||||
valueType: 'number',
|
||||
},
|
||||
]
|
||||
|
||||
const pipelineBuiltinMetrics: MetricOption[] = [
|
||||
{
|
||||
id: 'context-precision',
|
||||
label: 'Context Precision',
|
||||
description: 'Measures whether retrieved chunks stay tightly aligned to the request.',
|
||||
valueType: 'number',
|
||||
},
|
||||
{
|
||||
id: 'context-recall',
|
||||
label: 'Context Recall',
|
||||
description: 'Checks whether the retrieval result includes the evidence needed to answer.',
|
||||
valueType: 'number',
|
||||
},
|
||||
{
|
||||
id: 'context-relevance',
|
||||
label: 'Context Relevance',
|
||||
description: 'Scores how useful the retrieved context is for downstream generation.',
|
||||
valueType: 'number',
|
||||
},
|
||||
]
|
||||
|
||||
const workflowOptions = [
|
||||
{
|
||||
id: 'workflow-precision-review',
|
||||
label: 'Precision Review Workflow',
|
||||
description: 'Custom evaluator for nuanced quality review.',
|
||||
targetVariables: [
|
||||
{ id: 'query', label: 'query' },
|
||||
{ id: 'answer', label: 'answer' },
|
||||
{ id: 'reference', label: 'reference' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'workflow-risk-review',
|
||||
label: 'Risk Review Workflow',
|
||||
description: 'Custom evaluator for policy and escalation checks.',
|
||||
targetVariables: [
|
||||
{ id: 'input', label: 'input' },
|
||||
{ id: 'output', label: 'output' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const workflowFields: EvaluationFieldOption[] = [
|
||||
{ id: 'app.input.query', label: 'Query', group: 'App Input', type: 'string' },
|
||||
{ id: 'app.input.locale', label: 'Locale', group: 'App Input', type: 'enum', options: [{ value: 'en-US', label: 'en-US' }, { value: 'zh-Hans', label: 'zh-Hans' }] },
|
||||
{ id: 'app.output.answer', label: 'Answer', group: 'App Output', type: 'string' },
|
||||
{ id: 'app.output.score', label: 'Score', group: 'App Output', type: 'number' },
|
||||
{ id: 'system.has_context', label: 'Has Context', group: 'System', type: 'boolean' },
|
||||
]
|
||||
|
||||
const pipelineFields: EvaluationFieldOption[] = [
|
||||
{ id: 'dataset.input.document_id', label: 'Document ID', group: 'Dataset', type: 'string' },
|
||||
{ id: 'dataset.input.chunk_count', label: 'Chunk Count', group: 'Dataset', type: 'number' },
|
||||
{ id: 'retrieval.output.hit_rate', label: 'Hit Rate', group: 'Retrieval', type: 'number' },
|
||||
{ id: 'retrieval.output.source', label: 'Source', group: 'Retrieval', type: 'enum', options: [{ value: 'bm25', label: 'BM25' }, { value: 'hybrid', label: 'Hybrid' }] },
|
||||
{ id: 'pipeline.output.published', label: 'Published', group: 'Output', type: 'boolean' },
|
||||
]
|
||||
|
||||
const snippetFields: EvaluationFieldOption[] = [
|
||||
{ id: 'snippet.input.blog_url', label: 'Blog URL', group: 'Snippet Input', type: 'string' },
|
||||
{ id: 'snippet.input.platforms', label: 'Platforms', group: 'Snippet Input', type: 'string' },
|
||||
{ id: 'snippet.output.content', label: 'Generated Content', group: 'Snippet Output', type: 'string' },
|
||||
{ id: 'snippet.output.length', label: 'Output Length', group: 'Snippet Output', type: 'number' },
|
||||
{ id: 'system.requires_review', label: 'Requires Review', group: 'System', type: 'boolean' },
|
||||
]
|
||||
|
||||
export const getEvaluationMockConfig = (resourceType: EvaluationResourceType): EvaluationMockConfig => {
|
||||
if (resourceType === 'datasets') {
|
||||
return {
|
||||
judgeModels,
|
||||
builtinMetrics: pipelineBuiltinMetrics,
|
||||
workflowOptions,
|
||||
fieldOptions: pipelineFields,
|
||||
templateFileName: 'pipeline-evaluation-template.csv',
|
||||
batchRequirements: [
|
||||
'Include one row per retrieval scenario.',
|
||||
'Provide the expected source or target chunk for each case.',
|
||||
'Keep numeric metrics in plain number format.',
|
||||
],
|
||||
historySummaryLabel: 'Pipeline evaluation batch',
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceType === 'snippets') {
|
||||
return {
|
||||
judgeModels,
|
||||
builtinMetrics,
|
||||
workflowOptions,
|
||||
fieldOptions: snippetFields,
|
||||
templateFileName: 'snippet-evaluation-template.csv',
|
||||
batchRequirements: [
|
||||
'Include one row per snippet execution case.',
|
||||
'Provide the expected final content or acceptance rule.',
|
||||
'Keep optional fields empty when not used.',
|
||||
],
|
||||
historySummaryLabel: 'Snippet evaluation batch',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
judgeModels,
|
||||
builtinMetrics,
|
||||
workflowOptions,
|
||||
fieldOptions: workflowFields,
|
||||
templateFileName: 'workflow-evaluation-template.csv',
|
||||
batchRequirements: [
|
||||
'Include one row per workflow test case.',
|
||||
'Provide both user input and expected answer when available.',
|
||||
'Keep boolean columns as true or false.',
|
||||
],
|
||||
historySummaryLabel: 'Workflow evaluation batch',
|
||||
}
|
||||
}
|
||||
493
web/app/components/evaluation/store-utils.ts
Normal file
493
web/app/components/evaluation/store-utils.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
import type {
|
||||
BatchTestRecord,
|
||||
ComparisonOperator,
|
||||
CustomMetricMapping,
|
||||
EvaluationMetric,
|
||||
EvaluationResourceState,
|
||||
EvaluationResourceType,
|
||||
JudgmentConditionItem,
|
||||
JudgmentConfig,
|
||||
MetricOption,
|
||||
} from './types'
|
||||
import type {
|
||||
EvaluationConfig,
|
||||
EvaluationCustomizedMetric,
|
||||
EvaluationDefaultMetric,
|
||||
EvaluationJudgmentCondition,
|
||||
EvaluationJudgmentConditionValue,
|
||||
EvaluationJudgmentConfig,
|
||||
NodeInfo,
|
||||
} from '@/types/evaluation'
|
||||
import { getEvaluationMockConfig } from './mock'
|
||||
import {
|
||||
buildConditionMetricOptions,
|
||||
encodeModelSelection,
|
||||
getComparisonOperators,
|
||||
getDefaultComparisonOperator,
|
||||
requiresComparisonValue,
|
||||
} from './utils'
|
||||
|
||||
type EvaluationStoreResources = Record<string, EvaluationResourceState>
|
||||
|
||||
export const DEFAULT_PIPELINE_METRIC_THRESHOLD = 0.85
|
||||
|
||||
const createId = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
const humanizeMetricId = (metricId: string) => {
|
||||
return metricId
|
||||
.split(/[-_]/g)
|
||||
.filter(Boolean)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const resolveMetricOption = (resourceType: EvaluationResourceType, metricId: string): MetricOption => {
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
return config.builtinMetrics.find(metric => metric.id === metricId) ?? {
|
||||
id: metricId,
|
||||
label: humanizeMetricId(metricId),
|
||||
description: '',
|
||||
valueType: 'number',
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeNodeInfoList = (value: NodeInfo[] | undefined): NodeInfo[] => {
|
||||
if (!value?.length)
|
||||
return []
|
||||
|
||||
return value
|
||||
.map((item) => {
|
||||
const nodeId = typeof item.node_id === 'string' ? item.node_id : ''
|
||||
const title = typeof item.title === 'string' ? item.title : nodeId
|
||||
const type = typeof item.type === 'string' ? item.type : ''
|
||||
|
||||
if (!nodeId)
|
||||
return null
|
||||
|
||||
return {
|
||||
node_id: nodeId,
|
||||
title,
|
||||
type,
|
||||
}
|
||||
})
|
||||
.filter((item): item is NodeInfo => !!item)
|
||||
}
|
||||
|
||||
const normalizeDefaultMetrics = (
|
||||
resourceType: EvaluationResourceType,
|
||||
value: EvaluationDefaultMetric[] | null | undefined,
|
||||
): EvaluationMetric[] => {
|
||||
if (!value?.length)
|
||||
return []
|
||||
|
||||
return value
|
||||
.map((item) => {
|
||||
const metricId = typeof item.metric === 'string' ? item.metric : ''
|
||||
if (!metricId)
|
||||
return null
|
||||
|
||||
const metricOption = resolveMetricOption(resourceType, metricId)
|
||||
return createBuiltinMetric(metricOption, normalizeNodeInfoList(item.node_info_list ?? []))
|
||||
})
|
||||
.filter((item): item is EvaluationMetric => !!item)
|
||||
}
|
||||
|
||||
const normalizeCustomMetricMappings = (
|
||||
value: EvaluationCustomizedMetric['input_fields'],
|
||||
): CustomMetricMapping[] => {
|
||||
if (!value)
|
||||
return []
|
||||
|
||||
return Object.entries(value)
|
||||
.filter((entry): entry is [string, string] => {
|
||||
const [, outputVariableId] = entry
|
||||
return typeof outputVariableId === 'string' && !!outputVariableId
|
||||
})
|
||||
.map(([inputVariableId, outputVariableId]) => createCustomMetricMapping(inputVariableId, outputVariableId))
|
||||
}
|
||||
|
||||
const normalizeCustomMetricOutputs = (
|
||||
value: EvaluationCustomizedMetric['output_fields'],
|
||||
) => {
|
||||
if (!value)
|
||||
return []
|
||||
|
||||
return value
|
||||
.map((output) => {
|
||||
const id = typeof output.variable === 'string' ? output.variable : ''
|
||||
if (!id)
|
||||
return null
|
||||
|
||||
return {
|
||||
id,
|
||||
valueType: typeof output.value_type === 'string' ? output.value_type : null,
|
||||
}
|
||||
})
|
||||
.filter((output): output is { id: string, valueType: string | null } => !!output)
|
||||
}
|
||||
|
||||
const normalizeCustomMetric = (
|
||||
value: EvaluationCustomizedMetric | null | undefined,
|
||||
): EvaluationMetric[] => {
|
||||
if (!value)
|
||||
return []
|
||||
|
||||
const workflowId = typeof value.evaluation_workflow_id === 'string' ? value.evaluation_workflow_id : null
|
||||
if (!workflowId)
|
||||
return []
|
||||
|
||||
const customMetric = createCustomMetric()
|
||||
|
||||
return [{
|
||||
...customMetric,
|
||||
customConfig: customMetric.customConfig
|
||||
? {
|
||||
...customMetric.customConfig,
|
||||
workflowId,
|
||||
mappings: normalizeCustomMetricMappings(value.input_fields),
|
||||
outputs: normalizeCustomMetricOutputs(value.output_fields),
|
||||
}
|
||||
: customMetric.customConfig,
|
||||
}]
|
||||
}
|
||||
|
||||
const normalizeVariableSelector = (value: string[] | undefined): [string, string] | null => {
|
||||
if (!Array.isArray(value) || value.length < 2)
|
||||
return null
|
||||
|
||||
const [scope, metricName] = value
|
||||
return typeof scope === 'string' && !!scope && typeof metricName === 'string' && !!metricName
|
||||
? [scope, metricName]
|
||||
: null
|
||||
}
|
||||
|
||||
const getNormalizedConditionValue = (
|
||||
operator: ComparisonOperator,
|
||||
previousValue: EvaluationJudgmentConditionValue | string | number | boolean | null | undefined,
|
||||
) => {
|
||||
if (!requiresComparisonValue(operator))
|
||||
return null
|
||||
|
||||
if (Array.isArray(previousValue))
|
||||
return previousValue.filter((item): item is string => typeof item === 'string' && !!item)
|
||||
|
||||
if (typeof previousValue === 'boolean')
|
||||
return previousValue
|
||||
|
||||
if (typeof previousValue === 'number')
|
||||
return String(previousValue)
|
||||
|
||||
return typeof previousValue === 'string' ? previousValue : null
|
||||
}
|
||||
|
||||
const normalizeConditionItem = (
|
||||
value: EvaluationJudgmentCondition,
|
||||
metrics: EvaluationMetric[],
|
||||
): JudgmentConditionItem | null => {
|
||||
const variableSelector = normalizeVariableSelector(value.variable_selector)
|
||||
if (!variableSelector)
|
||||
return null
|
||||
|
||||
const metricOption = buildConditionMetricOptions(metrics).find(option =>
|
||||
option.variableSelector[0] === variableSelector[0] && option.variableSelector[1] === variableSelector[1],
|
||||
)
|
||||
if (!metricOption)
|
||||
return null
|
||||
|
||||
const allowedOperators = getComparisonOperators(metricOption.valueType)
|
||||
const rawOperator = typeof value.comparison_operator === 'string' ? value.comparison_operator : ''
|
||||
const comparisonOperator = allowedOperators.includes(rawOperator as ComparisonOperator)
|
||||
? rawOperator as ComparisonOperator
|
||||
: getDefaultComparisonOperator(metricOption.valueType)
|
||||
|
||||
return {
|
||||
id: createId('condition'),
|
||||
variableSelector,
|
||||
comparisonOperator,
|
||||
value: getConditionValue(metricOption.valueType, comparisonOperator, value.value),
|
||||
}
|
||||
}
|
||||
|
||||
const createEmptyJudgmentConfig = (): JudgmentConfig => {
|
||||
return {
|
||||
logicalOperator: 'and',
|
||||
conditions: [],
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeJudgmentConfig = (
|
||||
config: EvaluationConfig,
|
||||
metrics: EvaluationMetric[],
|
||||
): JudgmentConfig => {
|
||||
const rawJudgmentConfig: EvaluationJudgmentConfig | null | undefined = config.judgment_config
|
||||
|
||||
if (!rawJudgmentConfig)
|
||||
return createEmptyJudgmentConfig()
|
||||
|
||||
const conditions = (rawJudgmentConfig.conditions ?? [])
|
||||
.map(condition => normalizeConditionItem(condition, metrics))
|
||||
.filter((condition): condition is JudgmentConditionItem => !!condition)
|
||||
|
||||
return {
|
||||
logicalOperator: rawJudgmentConfig.logical_operator === 'or' ? 'or' : 'and',
|
||||
conditions,
|
||||
}
|
||||
}
|
||||
|
||||
export const buildResourceKey = (resourceType: EvaluationResourceType, resourceId: string) => `${resourceType}:${resourceId}`
|
||||
|
||||
export const requiresConditionValue = (operator: ComparisonOperator) => {
|
||||
return requiresComparisonValue(operator)
|
||||
}
|
||||
|
||||
export function getConditionValue(
|
||||
valueType: EvaluationMetric['valueType'] | undefined,
|
||||
operator: ComparisonOperator,
|
||||
previousValue?: EvaluationJudgmentConditionValue | string | number | boolean | null,
|
||||
) {
|
||||
if (!valueType || !requiresConditionValue(operator))
|
||||
return null
|
||||
|
||||
if (valueType === 'boolean')
|
||||
return typeof previousValue === 'boolean' ? previousValue : null
|
||||
|
||||
if (operator === 'in' || operator === 'not in') {
|
||||
if (Array.isArray(previousValue))
|
||||
return previousValue.filter((item): item is string => typeof item === 'string' && !!item)
|
||||
|
||||
return typeof previousValue === 'string' && previousValue
|
||||
? previousValue.split(',').map(item => item.trim()).filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
return getNormalizedConditionValue(operator, previousValue)
|
||||
}
|
||||
|
||||
export function createBuiltinMetric(
|
||||
metric: MetricOption,
|
||||
nodeInfoList: NodeInfo[] = [],
|
||||
threshold = DEFAULT_PIPELINE_METRIC_THRESHOLD,
|
||||
): EvaluationMetric {
|
||||
return {
|
||||
id: createId('metric'),
|
||||
optionId: metric.id,
|
||||
kind: 'builtin',
|
||||
label: metric.label,
|
||||
description: metric.description,
|
||||
valueType: metric.valueType,
|
||||
threshold,
|
||||
nodeInfoList,
|
||||
}
|
||||
}
|
||||
|
||||
function createCustomMetricMapping(
|
||||
inputVariableId: string | null = null,
|
||||
outputVariableId: string | null = null,
|
||||
): CustomMetricMapping {
|
||||
return {
|
||||
id: createId('mapping'),
|
||||
inputVariableId,
|
||||
outputVariableId,
|
||||
}
|
||||
}
|
||||
|
||||
export const syncCustomMetricMappings = (
|
||||
mappings: CustomMetricMapping[],
|
||||
inputVariableIds: string[],
|
||||
) => {
|
||||
const mappingByInputVariableId = new Map(
|
||||
mappings
|
||||
.filter(mapping => !!mapping.inputVariableId)
|
||||
.map(mapping => [mapping.inputVariableId, mapping]),
|
||||
)
|
||||
|
||||
return inputVariableIds.map((inputVariableId) => {
|
||||
const existingMapping = mappingByInputVariableId.get(inputVariableId)
|
||||
return existingMapping
|
||||
? {
|
||||
...existingMapping,
|
||||
inputVariableId,
|
||||
}
|
||||
: createCustomMetricMapping(inputVariableId, null)
|
||||
})
|
||||
}
|
||||
|
||||
export function createCustomMetric(): EvaluationMetric {
|
||||
return {
|
||||
id: createId('metric'),
|
||||
optionId: createId('custom'),
|
||||
kind: 'custom-workflow',
|
||||
label: 'Custom Evaluator',
|
||||
description: 'Map workflow variables to your evaluation inputs.',
|
||||
valueType: 'number',
|
||||
customConfig: {
|
||||
workflowId: null,
|
||||
workflowAppId: null,
|
||||
workflowName: null,
|
||||
mappings: [],
|
||||
outputs: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const buildConditionItem = (
|
||||
metrics: EvaluationMetric[],
|
||||
variableSelector?: [string, string] | null,
|
||||
): JudgmentConditionItem => {
|
||||
const metricOptions = buildConditionMetricOptions(metrics)
|
||||
const metricOption = variableSelector
|
||||
? metricOptions.find(option =>
|
||||
option.variableSelector[0] === variableSelector[0]
|
||||
&& option.variableSelector[1] === variableSelector[1],
|
||||
) ?? metricOptions[0]
|
||||
: metricOptions[0]
|
||||
const comparisonOperator = metricOption ? getDefaultComparisonOperator(metricOption.valueType) : 'is'
|
||||
|
||||
return {
|
||||
id: createId('condition'),
|
||||
variableSelector: metricOption?.variableSelector ?? null,
|
||||
comparisonOperator,
|
||||
value: getConditionValue(metricOption?.valueType, comparisonOperator),
|
||||
}
|
||||
}
|
||||
|
||||
export const syncJudgmentConfigWithMetrics = (
|
||||
judgmentConfig: JudgmentConfig,
|
||||
metrics: EvaluationMetric[],
|
||||
): JudgmentConfig => {
|
||||
const metricOptions = buildConditionMetricOptions(metrics)
|
||||
|
||||
return {
|
||||
logicalOperator: judgmentConfig.logicalOperator,
|
||||
conditions: judgmentConfig.conditions
|
||||
.map((condition) => {
|
||||
const metricOption = metricOptions.find(option =>
|
||||
option.variableSelector[0] === condition.variableSelector?.[0]
|
||||
&& option.variableSelector[1] === condition.variableSelector?.[1],
|
||||
)
|
||||
if (!metricOption)
|
||||
return null
|
||||
|
||||
const allowedOperators = getComparisonOperators(metricOption.valueType)
|
||||
const comparisonOperator = allowedOperators.includes(condition.comparisonOperator)
|
||||
? condition.comparisonOperator
|
||||
: getDefaultComparisonOperator(metricOption.valueType)
|
||||
|
||||
return {
|
||||
...condition,
|
||||
comparisonOperator,
|
||||
value: getConditionValue(metricOption.valueType, comparisonOperator, condition.value),
|
||||
}
|
||||
})
|
||||
.filter((condition): condition is JudgmentConditionItem => !!condition),
|
||||
}
|
||||
}
|
||||
|
||||
export const buildInitialState = (_resourceType: EvaluationResourceType): EvaluationResourceState => {
|
||||
return {
|
||||
judgeModelId: null,
|
||||
metrics: [],
|
||||
judgmentConfig: createEmptyJudgmentConfig(),
|
||||
activeBatchTab: 'input-fields',
|
||||
uploadedFileName: null,
|
||||
batchRecords: [],
|
||||
}
|
||||
}
|
||||
|
||||
export const buildStateFromEvaluationConfig = (
|
||||
resourceType: EvaluationResourceType,
|
||||
config: EvaluationConfig,
|
||||
): EvaluationResourceState => {
|
||||
const defaultMetrics = normalizeDefaultMetrics(resourceType, config.default_metrics)
|
||||
const customMetrics = normalizeCustomMetric(config.customized_metrics)
|
||||
const metrics = [...defaultMetrics, ...customMetrics]
|
||||
|
||||
return {
|
||||
...buildInitialState(resourceType),
|
||||
judgeModelId: config.evaluation_model && config.evaluation_model_provider
|
||||
? encodeModelSelection(config.evaluation_model_provider, config.evaluation_model)
|
||||
: null,
|
||||
metrics,
|
||||
judgmentConfig: normalizeJudgmentConfig(config, metrics),
|
||||
}
|
||||
}
|
||||
|
||||
const getResourceState = (
|
||||
resources: EvaluationStoreResources,
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
) => {
|
||||
const resourceKey = buildResourceKey(resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resourceKey,
|
||||
resource: resources[resourceKey] ?? buildInitialState(resourceType),
|
||||
}
|
||||
}
|
||||
|
||||
export const updateResourceState = (
|
||||
resources: EvaluationStoreResources,
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
updater: (resource: EvaluationResourceState) => EvaluationResourceState,
|
||||
) => {
|
||||
const { resource, resourceKey } = getResourceState(resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
...resources,
|
||||
[resourceKey]: updater(resource),
|
||||
}
|
||||
}
|
||||
|
||||
export const updateMetric = (
|
||||
metrics: EvaluationMetric[],
|
||||
metricId: string,
|
||||
updater: (metric: EvaluationMetric) => EvaluationMetric,
|
||||
) => metrics.map(metric => metric.id === metricId ? updater(metric) : metric)
|
||||
|
||||
export const createBatchTestRecord = (
|
||||
resourceType: EvaluationResourceType,
|
||||
uploadedFileName: string | null | undefined,
|
||||
): BatchTestRecord => {
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
return {
|
||||
id: createId('batch'),
|
||||
fileName: uploadedFileName ?? config.templateFileName,
|
||||
status: 'running',
|
||||
startedAt: new Date().toLocaleTimeString(),
|
||||
summary: config.historySummaryLabel,
|
||||
}
|
||||
}
|
||||
|
||||
export const isCustomMetricConfigured = (metric: EvaluationMetric) => {
|
||||
if (metric.kind !== 'custom-workflow')
|
||||
return true
|
||||
|
||||
if (!metric.customConfig?.workflowId)
|
||||
return false
|
||||
|
||||
return metric.customConfig.mappings.length > 0
|
||||
&& metric.customConfig.mappings.every(mapping => !!mapping.inputVariableId && !!mapping.outputVariableId)
|
||||
}
|
||||
|
||||
export const isEvaluationRunnable = (state: EvaluationResourceState) => {
|
||||
return !!state.judgeModelId
|
||||
&& state.metrics.length > 0
|
||||
&& state.metrics.every(isCustomMetricConfigured)
|
||||
}
|
||||
|
||||
export const getAllowedOperators = (
|
||||
metrics: EvaluationMetric[],
|
||||
variableSelector: [string, string] | null,
|
||||
) => {
|
||||
const metricOption = buildConditionMetricOptions(metrics).find(option =>
|
||||
option.variableSelector[0] === variableSelector?.[0]
|
||||
&& option.variableSelector[1] === variableSelector?.[1],
|
||||
)
|
||||
|
||||
if (!metricOption)
|
||||
return ['is'] as ComparisonOperator[]
|
||||
|
||||
return getComparisonOperators(metricOption.valueType)
|
||||
}
|
||||
430
web/app/components/evaluation/store.ts
Normal file
430
web/app/components/evaluation/store.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import type {
|
||||
ComparisonOperator,
|
||||
EvaluationResourceState,
|
||||
EvaluationResourceType,
|
||||
} from './types'
|
||||
import type { EvaluationConfig, NodeInfo } from '@/types/evaluation'
|
||||
import { create } from 'zustand'
|
||||
import { getEvaluationMockConfig } from './mock'
|
||||
import {
|
||||
buildConditionItem,
|
||||
buildInitialState,
|
||||
buildResourceKey,
|
||||
buildStateFromEvaluationConfig,
|
||||
createBatchTestRecord,
|
||||
createBuiltinMetric,
|
||||
createCustomMetric,
|
||||
getAllowedOperators as getAllowedOperatorsFromUtils,
|
||||
getConditionValue,
|
||||
isCustomMetricConfigured as isCustomMetricConfiguredFromUtils,
|
||||
isEvaluationRunnable as isEvaluationRunnableFromUtils,
|
||||
requiresConditionValue as requiresConditionValueFromUtils,
|
||||
syncCustomMetricMappings as syncCustomMetricMappingsFromUtils,
|
||||
syncJudgmentConfigWithMetrics,
|
||||
updateMetric,
|
||||
updateResourceState,
|
||||
} from './store-utils'
|
||||
import { buildConditionMetricOptions } from './utils'
|
||||
|
||||
type EvaluationStore = {
|
||||
resources: Record<string, EvaluationResourceState>
|
||||
ensureResource: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
hydrateResource: (resourceType: EvaluationResourceType, resourceId: string, config: EvaluationConfig) => void
|
||||
setJudgeModel: (resourceType: EvaluationResourceType, resourceId: string, judgeModelId: string) => void
|
||||
addBuiltinMetric: (resourceType: EvaluationResourceType, resourceId: string, optionId: string, nodeInfoList?: NodeInfo[]) => void
|
||||
updateMetricThreshold: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, threshold: number) => void
|
||||
addCustomMetric: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
removeMetric: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
|
||||
setCustomMetricWorkflow: (
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
metricId: string,
|
||||
workflow: { workflowId: string, workflowAppId: string, workflowName: string },
|
||||
) => void
|
||||
syncCustomMetricMappings: (
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
metricId: string,
|
||||
inputVariableIds: string[],
|
||||
) => void
|
||||
syncCustomMetricOutputs: (
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
metricId: string,
|
||||
outputs: Array<{ id: string, valueType: string | null }>,
|
||||
) => void
|
||||
updateCustomMetricMapping: (
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
metricId: string,
|
||||
mappingId: string,
|
||||
patch: { inputVariableId?: string | null, outputVariableId?: string | null },
|
||||
) => void
|
||||
setConditionLogicalOperator: (resourceType: EvaluationResourceType, resourceId: string, logicalOperator: 'and' | 'or') => void
|
||||
addCondition: (
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
variableSelector?: [string, string] | null,
|
||||
) => void
|
||||
removeCondition: (resourceType: EvaluationResourceType, resourceId: string, conditionId: string) => void
|
||||
updateConditionMetric: (resourceType: EvaluationResourceType, resourceId: string, conditionId: string, variableSelector: [string, string]) => void
|
||||
updateConditionOperator: (resourceType: EvaluationResourceType, resourceId: string, conditionId: string, operator: ComparisonOperator) => void
|
||||
updateConditionValue: (
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
conditionId: string,
|
||||
value: string | string[] | boolean | null,
|
||||
) => void
|
||||
setBatchTab: (resourceType: EvaluationResourceType, resourceId: string, tab: EvaluationResourceState['activeBatchTab']) => void
|
||||
setUploadedFileName: (resourceType: EvaluationResourceType, resourceId: string, uploadedFileName: string | null) => void
|
||||
runBatchTest: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
}
|
||||
|
||||
const initialResourceCache: Record<string, EvaluationResourceState> = {}
|
||||
|
||||
export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
|
||||
resources: {},
|
||||
ensureResource: (resourceType, resourceId) => {
|
||||
const resourceKey = buildResourceKey(resourceType, resourceId)
|
||||
if (get().resources[resourceKey])
|
||||
return
|
||||
|
||||
set(state => ({
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: buildInitialState(resourceType),
|
||||
},
|
||||
}))
|
||||
},
|
||||
hydrateResource: (resourceType, resourceId, config) => {
|
||||
set(state => ({
|
||||
resources: {
|
||||
...state.resources,
|
||||
[buildResourceKey(resourceType, resourceId)]: {
|
||||
...buildStateFromEvaluationConfig(resourceType, config),
|
||||
activeBatchTab: state.resources[buildResourceKey(resourceType, resourceId)]?.activeBatchTab ?? 'input-fields',
|
||||
uploadedFileName: state.resources[buildResourceKey(resourceType, resourceId)]?.uploadedFileName ?? null,
|
||||
batchRecords: state.resources[buildResourceKey(resourceType, resourceId)]?.batchRecords ?? [],
|
||||
},
|
||||
},
|
||||
}))
|
||||
},
|
||||
setJudgeModel: (resourceType, resourceId, judgeModelId) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
judgeModelId,
|
||||
})),
|
||||
}))
|
||||
},
|
||||
addBuiltinMetric: (resourceType, resourceId, optionId, nodeInfoList = []) => {
|
||||
const option = getEvaluationMockConfig(resourceType).builtinMetrics.find(metric => metric.id === optionId)
|
||||
if (!option)
|
||||
return
|
||||
|
||||
set((state) => {
|
||||
return {
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, (currentResource) => {
|
||||
const metrics = currentResource.metrics.some(metric => metric.optionId === optionId && metric.kind === 'builtin')
|
||||
? currentResource.metrics.map(metric => metric.optionId === optionId && metric.kind === 'builtin'
|
||||
? {
|
||||
...metric,
|
||||
nodeInfoList,
|
||||
}
|
||||
: metric)
|
||||
: [...currentResource.metrics, createBuiltinMetric(option, nodeInfoList)]
|
||||
|
||||
return {
|
||||
...currentResource,
|
||||
metrics,
|
||||
judgmentConfig: syncJudgmentConfigWithMetrics(currentResource.judgmentConfig, metrics),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
},
|
||||
updateMetricThreshold: (resourceType, resourceId, metricId, threshold) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
metrics: updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
threshold,
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
},
|
||||
addCustomMetric: (resourceType, resourceId) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, (resource) => {
|
||||
const metrics = resource.metrics.some(metric => metric.kind === 'custom-workflow')
|
||||
? resource.metrics
|
||||
: [...resource.metrics, createCustomMetric()]
|
||||
|
||||
return {
|
||||
...resource,
|
||||
metrics,
|
||||
judgmentConfig: syncJudgmentConfigWithMetrics(resource.judgmentConfig, metrics),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
},
|
||||
removeMetric: (resourceType, resourceId, metricId) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, (resource) => {
|
||||
const metrics = resource.metrics.filter(metric => metric.id !== metricId)
|
||||
|
||||
return {
|
||||
...resource,
|
||||
metrics,
|
||||
judgmentConfig: syncJudgmentConfigWithMetrics(resource.judgmentConfig, metrics),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
},
|
||||
setCustomMetricWorkflow: (resourceType, resourceId, metricId, workflow) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, (resource) => {
|
||||
const metrics = updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
workflowId: workflow.workflowId,
|
||||
workflowAppId: workflow.workflowAppId,
|
||||
workflowName: workflow.workflowName,
|
||||
mappings: metric.customConfig.mappings.map(mapping => ({
|
||||
...mapping,
|
||||
outputVariableId: null,
|
||||
})),
|
||||
outputs: [],
|
||||
}
|
||||
: metric.customConfig,
|
||||
}))
|
||||
|
||||
return {
|
||||
...resource,
|
||||
metrics,
|
||||
judgmentConfig: syncJudgmentConfigWithMetrics(resource.judgmentConfig, metrics),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
},
|
||||
syncCustomMetricMappings: (resourceType, resourceId, metricId, inputVariableIds) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
metrics: updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
mappings: syncCustomMetricMappingsFromUtils(metric.customConfig.mappings, inputVariableIds),
|
||||
}
|
||||
: metric.customConfig,
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
},
|
||||
syncCustomMetricOutputs: (resourceType, resourceId, metricId, outputs) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, (resource) => {
|
||||
const metrics = updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
outputs,
|
||||
}
|
||||
: metric.customConfig,
|
||||
}))
|
||||
|
||||
return {
|
||||
...resource,
|
||||
metrics,
|
||||
judgmentConfig: syncJudgmentConfigWithMetrics(resource.judgmentConfig, metrics),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
},
|
||||
updateCustomMetricMapping: (resourceType, resourceId, metricId, mappingId, patch) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
metrics: updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
mappings: metric.customConfig.mappings.map(mapping => mapping.id === mappingId ? { ...mapping, ...patch } : mapping),
|
||||
}
|
||||
: metric.customConfig,
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
},
|
||||
setConditionLogicalOperator: (resourceType, resourceId, logicalOperator) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
judgmentConfig: {
|
||||
...resource.judgmentConfig,
|
||||
logicalOperator,
|
||||
},
|
||||
})),
|
||||
}))
|
||||
},
|
||||
addCondition: (resourceType, resourceId, variableSelector) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
judgmentConfig: {
|
||||
...resource.judgmentConfig,
|
||||
conditions: [...resource.judgmentConfig.conditions, buildConditionItem(resource.metrics, variableSelector)],
|
||||
},
|
||||
})),
|
||||
}))
|
||||
},
|
||||
removeCondition: (resourceType, resourceId, conditionId) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
judgmentConfig: {
|
||||
...resource.judgmentConfig,
|
||||
conditions: resource.judgmentConfig.conditions.filter(condition => condition.id !== conditionId),
|
||||
},
|
||||
})),
|
||||
}))
|
||||
},
|
||||
updateConditionMetric: (resourceType, resourceId, conditionId, variableSelector) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, (resource) => {
|
||||
const allowedOperators = getAllowedOperatorsFromUtils(resource.metrics, variableSelector)
|
||||
const comparisonOperator = allowedOperators[0]
|
||||
const metricOption = buildConditionMetricOptions(resource.metrics).find(option =>
|
||||
option.variableSelector[0] === variableSelector[0] && option.variableSelector[1] === variableSelector[1],
|
||||
)
|
||||
|
||||
return {
|
||||
...resource,
|
||||
judgmentConfig: {
|
||||
...resource.judgmentConfig,
|
||||
conditions: resource.judgmentConfig.conditions.map(condition => condition.id === conditionId
|
||||
? {
|
||||
...condition,
|
||||
variableSelector,
|
||||
comparisonOperator,
|
||||
value: getConditionValue(metricOption?.valueType, comparisonOperator),
|
||||
}
|
||||
: condition),
|
||||
},
|
||||
}
|
||||
}),
|
||||
}))
|
||||
},
|
||||
updateConditionOperator: (resourceType, resourceId, conditionId, operator) => {
|
||||
set((state) => {
|
||||
return {
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, currentResource => ({
|
||||
...currentResource,
|
||||
judgmentConfig: {
|
||||
...currentResource.judgmentConfig,
|
||||
conditions: currentResource.judgmentConfig.conditions.map((condition) => {
|
||||
if (condition.id !== conditionId)
|
||||
return condition
|
||||
|
||||
const metricOption = buildConditionMetricOptions(currentResource.metrics)
|
||||
.find(option =>
|
||||
option.variableSelector[0] === condition.variableSelector?.[0]
|
||||
&& option.variableSelector[1] === condition.variableSelector?.[1],
|
||||
)
|
||||
|
||||
return {
|
||||
...condition,
|
||||
comparisonOperator: operator,
|
||||
value: getConditionValue(metricOption?.valueType, operator, condition.value),
|
||||
}
|
||||
}),
|
||||
},
|
||||
})),
|
||||
}
|
||||
})
|
||||
},
|
||||
updateConditionValue: (resourceType, resourceId, conditionId, value) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
judgmentConfig: {
|
||||
...resource.judgmentConfig,
|
||||
conditions: resource.judgmentConfig.conditions.map(condition => condition.id === conditionId ? { ...condition, value } : condition),
|
||||
},
|
||||
})),
|
||||
}))
|
||||
},
|
||||
setBatchTab: (resourceType, resourceId, tab) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
activeBatchTab: tab,
|
||||
})),
|
||||
}))
|
||||
},
|
||||
setUploadedFileName: (resourceType, resourceId, uploadedFileName) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
uploadedFileName,
|
||||
})),
|
||||
}))
|
||||
},
|
||||
runBatchTest: (resourceType, resourceId) => {
|
||||
const { uploadedFileName } = get().resources[buildResourceKey(resourceType, resourceId)] ?? buildInitialState(resourceType)
|
||||
const nextRecord = createBatchTestRecord(resourceType, uploadedFileName)
|
||||
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
activeBatchTab: 'history',
|
||||
batchRecords: [nextRecord, ...resource.batchRecords],
|
||||
})),
|
||||
}))
|
||||
|
||||
window.setTimeout(() => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
batchRecords: resource.batchRecords.map(record => record.id === nextRecord.id
|
||||
? {
|
||||
...record,
|
||||
status: resource.metrics.length > 1 ? 'success' : 'failed',
|
||||
}
|
||||
: record),
|
||||
})),
|
||||
}))
|
||||
}, 1200)
|
||||
},
|
||||
}))
|
||||
|
||||
export const useEvaluationResource = (resourceType: EvaluationResourceType, resourceId: string) => {
|
||||
const resourceKey = buildResourceKey(resourceType, resourceId)
|
||||
return useEvaluationStore(state => state.resources[resourceKey] ?? (initialResourceCache[resourceKey] ??= buildInitialState(resourceType)))
|
||||
}
|
||||
|
||||
export const getAllowedOperators = (
|
||||
metrics: EvaluationResourceState['metrics'],
|
||||
variableSelector: [string, string] | null,
|
||||
) => {
|
||||
return getAllowedOperatorsFromUtils(metrics, variableSelector)
|
||||
}
|
||||
|
||||
export const isCustomMetricConfigured = (metric: EvaluationResourceState['metrics'][number]) => {
|
||||
return isCustomMetricConfiguredFromUtils(metric)
|
||||
}
|
||||
|
||||
export const isEvaluationRunnable = (state: EvaluationResourceState) => {
|
||||
return isEvaluationRunnableFromUtils(state)
|
||||
}
|
||||
|
||||
export const requiresConditionValue = (operator: ComparisonOperator) => {
|
||||
return requiresConditionValueFromUtils(operator)
|
||||
}
|
||||
151
web/app/components/evaluation/types.ts
Normal file
151
web/app/components/evaluation/types.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { NodeInfo } from '@/types/evaluation'
|
||||
|
||||
export type EvaluationResourceType = 'apps' | 'datasets' | 'snippets'
|
||||
|
||||
export type EvaluationResourceProps = {
|
||||
resourceType: EvaluationResourceType
|
||||
resourceId: string
|
||||
}
|
||||
|
||||
export type MetricKind = 'builtin' | 'custom-workflow'
|
||||
|
||||
export type BatchTestTab = 'input-fields' | 'history'
|
||||
|
||||
export type FieldType = 'string' | 'number' | 'boolean' | 'enum'
|
||||
|
||||
export type ConditionMetricValueType = 'string' | 'number' | 'boolean'
|
||||
|
||||
export type ComparisonOperator
|
||||
= | 'contains'
|
||||
| 'not contains'
|
||||
| 'start with'
|
||||
| 'end with'
|
||||
| 'is'
|
||||
| 'is not'
|
||||
| 'empty'
|
||||
| 'not empty'
|
||||
| 'in'
|
||||
| 'not in'
|
||||
| '='
|
||||
| '≠'
|
||||
| '>'
|
||||
| '<'
|
||||
| '≥'
|
||||
| '≤'
|
||||
| 'is null'
|
||||
| 'is not null'
|
||||
|
||||
export type JudgeModelOption = {
|
||||
id: string
|
||||
label: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type MetricOption = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
valueType: ConditionMetricValueType
|
||||
}
|
||||
|
||||
export type EvaluationWorkflowOption = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
targetVariables: Array<{
|
||||
id: string
|
||||
label: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type EvaluationFieldOption = {
|
||||
id: string
|
||||
label: string
|
||||
group: string
|
||||
type: FieldType
|
||||
options?: Array<{
|
||||
value: string
|
||||
label: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type CustomMetricMapping = {
|
||||
id: string
|
||||
inputVariableId: string | null
|
||||
outputVariableId: string | null
|
||||
}
|
||||
|
||||
export type CustomMetricConfig = {
|
||||
workflowId: string | null
|
||||
workflowAppId: string | null
|
||||
workflowName: string | null
|
||||
mappings: CustomMetricMapping[]
|
||||
outputs: Array<{
|
||||
id: string
|
||||
valueType: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export type EvaluationMetric = {
|
||||
id: string
|
||||
optionId: string
|
||||
kind: MetricKind
|
||||
label: string
|
||||
description: string
|
||||
valueType: ConditionMetricValueType
|
||||
threshold?: number
|
||||
nodeInfoList?: NodeInfo[]
|
||||
customConfig?: CustomMetricConfig
|
||||
}
|
||||
|
||||
export type JudgmentConditionItem = {
|
||||
id: string
|
||||
variableSelector: [string, string] | null
|
||||
comparisonOperator: ComparisonOperator
|
||||
value: string | string[] | boolean | null
|
||||
}
|
||||
|
||||
export type JudgmentConfig = {
|
||||
logicalOperator: 'and' | 'or'
|
||||
conditions: JudgmentConditionItem[]
|
||||
}
|
||||
|
||||
export type ConditionMetricOption = {
|
||||
id: string
|
||||
groupLabel: string
|
||||
itemLabel: string
|
||||
valueType: ConditionMetricValueType
|
||||
variableSelector: [string, string]
|
||||
}
|
||||
|
||||
export type ConditionMetricOptionGroup = {
|
||||
label: string
|
||||
options: ConditionMetricOption[]
|
||||
}
|
||||
|
||||
export type BatchTestRecord = {
|
||||
id: string
|
||||
fileName: string
|
||||
status: 'running' | 'success' | 'failed'
|
||||
startedAt: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export type EvaluationResourceState = {
|
||||
judgeModelId: string | null
|
||||
metrics: EvaluationMetric[]
|
||||
judgmentConfig: JudgmentConfig
|
||||
activeBatchTab: BatchTestTab
|
||||
uploadedFileName: string | null
|
||||
batchRecords: BatchTestRecord[]
|
||||
}
|
||||
|
||||
export type EvaluationMockConfig = {
|
||||
judgeModels: JudgeModelOption[]
|
||||
builtinMetrics: MetricOption[]
|
||||
workflowOptions: EvaluationWorkflowOption[]
|
||||
fieldOptions: EvaluationFieldOption[]
|
||||
templateFileName: string
|
||||
batchRequirements: string[]
|
||||
historySummaryLabel: string
|
||||
}
|
||||
131
web/app/components/evaluation/utils.ts
Normal file
131
web/app/components/evaluation/utils.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type {
|
||||
ComparisonOperator,
|
||||
ConditionMetricOption,
|
||||
ConditionMetricOptionGroup,
|
||||
ConditionMetricValueType,
|
||||
EvaluationMetric,
|
||||
} from './types'
|
||||
|
||||
export const TAB_CLASS_NAME = 'flex-1 rounded-lg px-3 py-2 text-left system-sm-medium'
|
||||
|
||||
const rawOperatorLabels = new Set<ComparisonOperator>(['=', '≠', '>', '<', '≥', '≤'])
|
||||
|
||||
const noValueOperators = new Set<ComparisonOperator>(['empty', 'not empty', 'is null', 'is not null'])
|
||||
|
||||
export const encodeModelSelection = (provider: string, model: string) => `${provider}::${model}`
|
||||
|
||||
export const decodeModelSelection = (judgeModelId: string | null) => {
|
||||
if (!judgeModelId)
|
||||
return undefined
|
||||
|
||||
const [provider, model] = judgeModelId.split('::')
|
||||
if (!provider || !model)
|
||||
return undefined
|
||||
|
||||
return { provider, model }
|
||||
}
|
||||
|
||||
export const getComparisonOperatorLabel = (
|
||||
operator: ComparisonOperator,
|
||||
t: TFunction,
|
||||
) => {
|
||||
if (rawOperatorLabels.has(operator))
|
||||
return operator
|
||||
|
||||
return t(`nodes.ifElse.comparisonOperator.${operator}` as never, { ns: 'workflow' } as never) as unknown as string
|
||||
}
|
||||
|
||||
export const requiresComparisonValue = (operator: ComparisonOperator) => {
|
||||
return !noValueOperators.has(operator)
|
||||
}
|
||||
|
||||
const getMetricValueType = (valueType: string | null | undefined): ConditionMetricValueType => {
|
||||
if (valueType === 'number' || valueType === 'integer')
|
||||
return 'number'
|
||||
|
||||
if (valueType === 'boolean')
|
||||
return 'boolean'
|
||||
|
||||
return 'string'
|
||||
}
|
||||
|
||||
export const getComparisonOperators = (valueType: ConditionMetricValueType): ComparisonOperator[] => {
|
||||
if (valueType === 'number')
|
||||
return ['=', '≠', '>', '<', '≥', '≤', 'is null', 'is not null']
|
||||
|
||||
if (valueType === 'boolean')
|
||||
return ['is', 'is not', 'is null', 'is not null']
|
||||
|
||||
return ['contains', 'not contains', 'start with', 'end with', 'is', 'is not', 'empty', 'not empty', 'in', 'not in', 'is null', 'is not null']
|
||||
}
|
||||
|
||||
export const getDefaultComparisonOperator = (valueType: ConditionMetricValueType): ComparisonOperator => {
|
||||
return getComparisonOperators(valueType)[0]
|
||||
}
|
||||
|
||||
export const buildConditionMetricOptions = (metrics: EvaluationMetric[]): ConditionMetricOption[] => {
|
||||
return metrics.flatMap((metric) => {
|
||||
if (metric.kind === 'builtin') {
|
||||
return (metric.nodeInfoList ?? []).map((nodeInfo) => {
|
||||
return {
|
||||
id: `${nodeInfo.node_id}:${metric.optionId}`,
|
||||
groupLabel: metric.label,
|
||||
itemLabel: nodeInfo.title || nodeInfo.node_id,
|
||||
valueType: metric.valueType,
|
||||
variableSelector: [nodeInfo.node_id, metric.optionId] as [string, string],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const customConfig = metric.customConfig
|
||||
|
||||
if (!customConfig?.workflowId)
|
||||
return []
|
||||
|
||||
return customConfig.outputs.map((output) => {
|
||||
return {
|
||||
id: `${customConfig.workflowId}:${output.id}`,
|
||||
groupLabel: customConfig.workflowName ?? metric.label,
|
||||
itemLabel: output.id,
|
||||
valueType: getMetricValueType(output.valueType),
|
||||
variableSelector: [customConfig.workflowId, output.id] as [string, string],
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const groupConditionMetricOptions = (metricOptions: ConditionMetricOption[]): ConditionMetricOptionGroup[] => {
|
||||
const groups = metricOptions.reduce<Map<string, ConditionMetricOption[]>>((acc, option) => {
|
||||
acc.set(option.groupLabel, [...(acc.get(option.groupLabel) ?? []), option])
|
||||
return acc
|
||||
}, new Map())
|
||||
|
||||
return Array.from(groups.entries()).map(([label, options]) => ({
|
||||
label,
|
||||
options,
|
||||
}))
|
||||
}
|
||||
|
||||
const conditionMetricValueTypeTranslationKeys = {
|
||||
string: 'conditions.valueTypes.string',
|
||||
number: 'conditions.valueTypes.number',
|
||||
boolean: 'conditions.valueTypes.boolean',
|
||||
} as const
|
||||
|
||||
export const getConditionMetricValueTypeTranslationKey = (
|
||||
valueType: ConditionMetricValueType,
|
||||
) => {
|
||||
return conditionMetricValueTypeTranslationKeys[valueType]
|
||||
}
|
||||
|
||||
export const serializeVariableSelector = (value: [string, string] | null | undefined) => {
|
||||
return value ? JSON.stringify(value) : ''
|
||||
}
|
||||
|
||||
export const isSelectorEqual = (
|
||||
left: [string, string] | null | undefined,
|
||||
right: [string, string] | null | undefined,
|
||||
) => {
|
||||
return left?.[0] === right?.[0] && left?.[1] === right?.[1]
|
||||
}
|
||||
@@ -107,7 +107,7 @@ const AppNav = () => {
|
||||
icon={<RiRobot2Line className="h-4 w-4" />}
|
||||
activeIcon={<RiRobot2Fill className="h-4 w-4" />}
|
||||
text={t('menus.apps', { ns: 'common' })}
|
||||
activeSegment={['apps', 'app']}
|
||||
activeSegment={['apps', 'app', 'snippets']}
|
||||
link="/apps"
|
||||
curNav={appDetail}
|
||||
navigationItems={navItems}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user