mirror of
https://github.com/langgenius/dify.git
synced 2026-04-03 14:42:48 +00:00
Compare commits
13 Commits
codex/dify
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb9f4bb100 | ||
|
|
8a398f3105 | ||
|
|
0f051d5886 | ||
|
|
e85d9a0d72 | ||
|
|
06dde4f503 | ||
|
|
83d4176785 | ||
|
|
c94951b2f8 | ||
|
|
a9cf8f6c5d | ||
|
|
64ddec0d67 | ||
|
|
da3b0caf5e | ||
|
|
4fedd43af5 | ||
|
|
a263f28e19 | ||
|
|
d53862f135 |
@@ -11,7 +11,7 @@ SQLAlchemy instrumentor appends comments to SQL statements.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from celery.signals import task_postrun, task_prerun
|
||||
from opentelemetry import context
|
||||
@@ -24,9 +24,17 @@ _SQLCOMMENTER_CONTEXT_KEY = "SQLCOMMENTER_ORM_TAGS_AND_VALUES"
|
||||
_TOKEN_ATTR = "_dify_sqlcommenter_context_token"
|
||||
|
||||
|
||||
def _build_celery_sqlcommenter_tags(task: Any) -> dict[str, str | int]:
|
||||
class CelerySqlcommenterTagsDict(TypedDict, total=False):
|
||||
framework: str
|
||||
task_name: str
|
||||
traceparent: str
|
||||
celery_retries: int
|
||||
routing_key: str
|
||||
|
||||
|
||||
def _build_celery_sqlcommenter_tags(task: Any) -> CelerySqlcommenterTagsDict:
|
||||
"""Build SQL commenter tags from the current Celery task and OpenTelemetry context."""
|
||||
tags: dict[str, str | int] = {}
|
||||
tags: CelerySqlcommenterTagsDict = {}
|
||||
|
||||
try:
|
||||
tags["framework"] = f"celery:{_get_celery_version()}"
|
||||
|
||||
@@ -8,7 +8,7 @@ from hashlib import sha256
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
@@ -1069,11 +1069,11 @@ class TenantService:
|
||||
@staticmethod
|
||||
def create_owner_tenant_if_not_exist(account: Account, name: str | None = None, is_setup: bool | None = False):
|
||||
"""Check if user have a workspace or not"""
|
||||
available_ta = (
|
||||
db.session.query(TenantAccountJoin)
|
||||
.filter_by(account_id=account.id)
|
||||
available_ta = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.account_id == account.id)
|
||||
.order_by(TenantAccountJoin.id.asc())
|
||||
.first()
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if available_ta:
|
||||
@@ -1104,7 +1104,11 @@ class TenantService:
|
||||
logger.error("Tenant %s has already an owner.", tenant.id)
|
||||
raise Exception("Tenant already has an owner.")
|
||||
|
||||
ta = db.session.query(TenantAccountJoin).filter_by(tenant_id=tenant.id, account_id=account.id).first()
|
||||
ta = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == account.id)
|
||||
.limit(1)
|
||||
)
|
||||
if ta:
|
||||
ta.role = TenantAccountRole(role)
|
||||
else:
|
||||
@@ -1119,11 +1123,12 @@ class TenantService:
|
||||
@staticmethod
|
||||
def get_join_tenants(account: Account) -> list[Tenant]:
|
||||
"""Get account join tenants"""
|
||||
return (
|
||||
db.session.query(Tenant)
|
||||
.join(TenantAccountJoin, Tenant.id == TenantAccountJoin.tenant_id)
|
||||
.where(TenantAccountJoin.account_id == account.id, Tenant.status == TenantStatus.NORMAL)
|
||||
.all()
|
||||
return list(
|
||||
db.session.scalars(
|
||||
select(Tenant)
|
||||
.join(TenantAccountJoin, Tenant.id == TenantAccountJoin.tenant_id)
|
||||
.where(TenantAccountJoin.account_id == account.id, Tenant.status == TenantStatus.NORMAL)
|
||||
).all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -1133,7 +1138,11 @@ class TenantService:
|
||||
if not tenant:
|
||||
raise TenantNotFoundError("Tenant not found.")
|
||||
|
||||
ta = db.session.query(TenantAccountJoin).filter_by(tenant_id=tenant.id, account_id=account.id).first()
|
||||
ta = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == account.id)
|
||||
.limit(1)
|
||||
)
|
||||
if ta:
|
||||
tenant.role = ta.role
|
||||
else:
|
||||
@@ -1148,23 +1157,25 @@ class TenantService:
|
||||
if tenant_id is None:
|
||||
raise ValueError("Tenant ID must be provided.")
|
||||
|
||||
tenant_account_join = (
|
||||
db.session.query(TenantAccountJoin)
|
||||
tenant_account_join = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.join(Tenant, TenantAccountJoin.tenant_id == Tenant.id)
|
||||
.where(
|
||||
TenantAccountJoin.account_id == account.id,
|
||||
TenantAccountJoin.tenant_id == tenant_id,
|
||||
Tenant.status == TenantStatus.NORMAL,
|
||||
)
|
||||
.first()
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if not tenant_account_join:
|
||||
raise AccountNotLinkTenantError("Tenant not found or account is not a member of the tenant.")
|
||||
else:
|
||||
db.session.query(TenantAccountJoin).where(
|
||||
TenantAccountJoin.account_id == account.id, TenantAccountJoin.tenant_id != tenant_id
|
||||
).update({"current": False})
|
||||
db.session.execute(
|
||||
update(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.account_id == account.id, TenantAccountJoin.tenant_id != tenant_id)
|
||||
.values(current=False)
|
||||
)
|
||||
tenant_account_join.current = True
|
||||
# Set the current tenant for the account
|
||||
account.set_tenant_id(tenant_account_join.tenant_id)
|
||||
@@ -1173,8 +1184,8 @@ class TenantService:
|
||||
@staticmethod
|
||||
def get_tenant_members(tenant: Tenant) -> list[Account]:
|
||||
"""Get tenant members"""
|
||||
query = (
|
||||
db.session.query(Account, TenantAccountJoin.role)
|
||||
stmt = (
|
||||
select(Account, TenantAccountJoin.role)
|
||||
.select_from(Account)
|
||||
.join(TenantAccountJoin, Account.id == TenantAccountJoin.account_id)
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id)
|
||||
@@ -1183,7 +1194,7 @@ class TenantService:
|
||||
# Initialize an empty list to store the updated accounts
|
||||
updated_accounts = []
|
||||
|
||||
for account, role in query:
|
||||
for account, role in db.session.execute(stmt):
|
||||
account.role = role
|
||||
updated_accounts.append(account)
|
||||
|
||||
@@ -1192,8 +1203,8 @@ class TenantService:
|
||||
@staticmethod
|
||||
def get_dataset_operator_members(tenant: Tenant) -> list[Account]:
|
||||
"""Get dataset admin members"""
|
||||
query = (
|
||||
db.session.query(Account, TenantAccountJoin.role)
|
||||
stmt = (
|
||||
select(Account, TenantAccountJoin.role)
|
||||
.select_from(Account)
|
||||
.join(TenantAccountJoin, Account.id == TenantAccountJoin.account_id)
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id)
|
||||
@@ -1203,7 +1214,7 @@ class TenantService:
|
||||
# Initialize an empty list to store the updated accounts
|
||||
updated_accounts = []
|
||||
|
||||
for account, role in query:
|
||||
for account, role in db.session.execute(stmt):
|
||||
account.role = role
|
||||
updated_accounts.append(account)
|
||||
|
||||
@@ -1216,26 +1227,31 @@ class TenantService:
|
||||
raise ValueError("all roles must be TenantAccountRole")
|
||||
|
||||
return (
|
||||
db.session.query(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.role.in_([role.value for role in roles]))
|
||||
.first()
|
||||
db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(
|
||||
TenantAccountJoin.tenant_id == tenant.id,
|
||||
TenantAccountJoin.role.in_([role.value for role in roles]),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_user_role(account: Account, tenant: Tenant) -> TenantAccountRole | None:
|
||||
"""Get the role of the current account for a given tenant"""
|
||||
join = (
|
||||
db.session.query(TenantAccountJoin)
|
||||
join = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == account.id)
|
||||
.first()
|
||||
.limit(1)
|
||||
)
|
||||
return TenantAccountRole(join.role) if join else None
|
||||
|
||||
@staticmethod
|
||||
def get_tenant_count() -> int:
|
||||
"""Get tenant count"""
|
||||
return cast(int, db.session.query(func.count(Tenant.id)).scalar())
|
||||
return cast(int, db.session.scalar(select(func.count(Tenant.id))))
|
||||
|
||||
@staticmethod
|
||||
def check_member_permission(tenant: Tenant, operator: Account, member: Account | None, action: str):
|
||||
@@ -1252,7 +1268,11 @@ class TenantService:
|
||||
if operator.id == member.id:
|
||||
raise CannotOperateSelfError("Cannot operate self.")
|
||||
|
||||
ta_operator = db.session.query(TenantAccountJoin).filter_by(tenant_id=tenant.id, account_id=operator.id).first()
|
||||
ta_operator = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == operator.id)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if not ta_operator or ta_operator.role not in perms[action]:
|
||||
raise NoPermissionError(f"No permission to {action} member.")
|
||||
@@ -1270,7 +1290,11 @@ class TenantService:
|
||||
|
||||
TenantService.check_member_permission(tenant, operator, account, "remove")
|
||||
|
||||
ta = db.session.query(TenantAccountJoin).filter_by(tenant_id=tenant.id, account_id=account.id).first()
|
||||
ta = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == account.id)
|
||||
.limit(1)
|
||||
)
|
||||
if not ta:
|
||||
raise MemberNotInTenantError("Member not in tenant.")
|
||||
|
||||
@@ -1285,7 +1309,12 @@ class TenantService:
|
||||
should_delete_account = False
|
||||
if account.status == AccountStatus.PENDING:
|
||||
# autoflush flushes ta deletion before this query, so 0 means no remaining joins
|
||||
remaining_joins = db.session.query(TenantAccountJoin).filter_by(account_id=account_id).count()
|
||||
remaining_joins = (
|
||||
db.session.scalar(
|
||||
select(func.count(TenantAccountJoin.id)).where(TenantAccountJoin.account_id == account_id)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if remaining_joins == 0:
|
||||
db.session.delete(account)
|
||||
should_delete_account = True
|
||||
@@ -1320,8 +1349,10 @@ class TenantService:
|
||||
"""Update member role"""
|
||||
TenantService.check_member_permission(tenant, operator, member, "update")
|
||||
|
||||
target_member_join = (
|
||||
db.session.query(TenantAccountJoin).filter_by(tenant_id=tenant.id, account_id=member.id).first()
|
||||
target_member_join = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == member.id)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if not target_member_join:
|
||||
@@ -1332,8 +1363,10 @@ class TenantService:
|
||||
|
||||
if new_role == "owner":
|
||||
# Find the current owner and change their role to 'admin'
|
||||
current_owner_join = (
|
||||
db.session.query(TenantAccountJoin).filter_by(tenant_id=tenant.id, role="owner").first()
|
||||
current_owner_join = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.role == "owner")
|
||||
.limit(1)
|
||||
)
|
||||
if current_owner_join:
|
||||
current_owner_join.role = TenantAccountRole.ADMIN
|
||||
@@ -1392,10 +1425,10 @@ class RegisterService:
|
||||
db.session.add(dify_setup)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.query(DifySetup).delete()
|
||||
db.session.query(TenantAccountJoin).delete()
|
||||
db.session.query(Account).delete()
|
||||
db.session.query(Tenant).delete()
|
||||
db.session.execute(delete(DifySetup))
|
||||
db.session.execute(delete(TenantAccountJoin))
|
||||
db.session.execute(delete(Account))
|
||||
db.session.execute(delete(Tenant))
|
||||
db.session.commit()
|
||||
|
||||
logger.exception("Setup account failed, email: %s, name: %s", email, name)
|
||||
@@ -1496,7 +1529,11 @@ class RegisterService:
|
||||
TenantService.switch_tenant(account, tenant.id)
|
||||
else:
|
||||
TenantService.check_member_permission(tenant, inviter, account, "add")
|
||||
ta = db.session.query(TenantAccountJoin).filter_by(tenant_id=tenant.id, account_id=account.id).first()
|
||||
ta = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == account.id)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if not ta:
|
||||
TenantService.create_tenant_member(tenant, account, role)
|
||||
@@ -1553,21 +1590,18 @@ class RegisterService:
|
||||
if not invitation_data:
|
||||
return None
|
||||
|
||||
tenant = (
|
||||
db.session.query(Tenant)
|
||||
.where(Tenant.id == invitation_data["workspace_id"], Tenant.status == "normal")
|
||||
.first()
|
||||
tenant = db.session.scalar(
|
||||
select(Tenant).where(Tenant.id == invitation_data["workspace_id"], Tenant.status == "normal").limit(1)
|
||||
)
|
||||
|
||||
if not tenant:
|
||||
return None
|
||||
|
||||
tenant_account = (
|
||||
db.session.query(Account, TenantAccountJoin.role)
|
||||
tenant_account = db.session.execute(
|
||||
select(Account, TenantAccountJoin.role)
|
||||
.join(TenantAccountJoin, Account.id == TenantAccountJoin.account_id)
|
||||
.where(Account.email == invitation_data["email"], TenantAccountJoin.tenant_id == tenant.id)
|
||||
.first()
|
||||
)
|
||||
).first()
|
||||
|
||||
if not tenant_account:
|
||||
return None
|
||||
|
||||
@@ -4,6 +4,8 @@ import uuid
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from typing import TypedDict
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.exceptions import NotFound
|
||||
@@ -23,6 +25,27 @@ from tasks.annotation.enable_annotation_reply_task import enable_annotation_repl
|
||||
from tasks.annotation.update_annotation_to_index_task import update_annotation_to_index_task
|
||||
|
||||
|
||||
class AnnotationJobStatusDict(TypedDict):
|
||||
job_id: str
|
||||
job_status: str
|
||||
|
||||
|
||||
class EmbeddingModelDict(TypedDict):
|
||||
embedding_provider_name: str
|
||||
embedding_model_name: str
|
||||
|
||||
|
||||
class AnnotationSettingDict(TypedDict):
|
||||
id: str
|
||||
enabled: bool
|
||||
score_threshold: float
|
||||
embedding_model: EmbeddingModelDict | dict
|
||||
|
||||
|
||||
class AnnotationSettingDisabledDict(TypedDict):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class AppAnnotationService:
|
||||
@classmethod
|
||||
def up_insert_app_annotation_from_message(cls, args: dict, app_id: str) -> MessageAnnotation:
|
||||
@@ -85,7 +108,7 @@ class AppAnnotationService:
|
||||
return annotation
|
||||
|
||||
@classmethod
|
||||
def enable_app_annotation(cls, args: dict, app_id: str):
|
||||
def enable_app_annotation(cls, args: dict, app_id: str) -> AnnotationJobStatusDict:
|
||||
enable_app_annotation_key = f"enable_app_annotation_{str(app_id)}"
|
||||
cache_result = redis_client.get(enable_app_annotation_key)
|
||||
if cache_result is not None:
|
||||
@@ -109,7 +132,7 @@ class AppAnnotationService:
|
||||
return {"job_id": job_id, "job_status": "waiting"}
|
||||
|
||||
@classmethod
|
||||
def disable_app_annotation(cls, app_id: str):
|
||||
def disable_app_annotation(cls, app_id: str) -> AnnotationJobStatusDict:
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
disable_app_annotation_key = f"disable_app_annotation_{str(app_id)}"
|
||||
cache_result = redis_client.get(disable_app_annotation_key)
|
||||
@@ -567,7 +590,7 @@ class AppAnnotationService:
|
||||
db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def get_app_annotation_setting_by_app_id(cls, app_id: str):
|
||||
def get_app_annotation_setting_by_app_id(cls, app_id: str) -> AnnotationSettingDict | AnnotationSettingDisabledDict:
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
# get app info
|
||||
app = (
|
||||
@@ -602,7 +625,9 @@ class AppAnnotationService:
|
||||
return {"enabled": False}
|
||||
|
||||
@classmethod
|
||||
def update_app_annotation_setting(cls, app_id: str, annotation_setting_id: str, args: dict):
|
||||
def update_app_annotation_setting(
|
||||
cls, app_id: str, annotation_setting_id: str, args: dict
|
||||
) -> AnnotationSettingDict:
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
# get app info
|
||||
app = (
|
||||
|
||||
@@ -14,7 +14,7 @@ from graphon.file import helpers as file_helpers
|
||||
from graphon.model_runtime.entities.model_entities import ModelFeature, ModelType
|
||||
from graphon.model_runtime.model_providers.__base.text_embedding_model import TextEmbeddingModel
|
||||
from redis.exceptions import LockNotOwnedError
|
||||
from sqlalchemy import exists, func, select
|
||||
from sqlalchemy import exists, func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
@@ -114,9 +114,11 @@ class DatasetService:
|
||||
|
||||
if user:
|
||||
# get permitted dataset ids
|
||||
dataset_permission = (
|
||||
db.session.query(DatasetPermission).filter_by(account_id=user.id, tenant_id=tenant_id).all()
|
||||
)
|
||||
dataset_permission = db.session.scalars(
|
||||
select(DatasetPermission).where(
|
||||
DatasetPermission.account_id == user.id, DatasetPermission.tenant_id == tenant_id
|
||||
)
|
||||
).all()
|
||||
permitted_dataset_ids = {dp.dataset_id for dp in dataset_permission} if dataset_permission else None
|
||||
|
||||
if user.current_role == TenantAccountRole.DATASET_OPERATOR:
|
||||
@@ -182,13 +184,12 @@ class DatasetService:
|
||||
@staticmethod
|
||||
def get_process_rules(dataset_id):
|
||||
# get the latest process rule
|
||||
dataset_process_rule = (
|
||||
db.session.query(DatasetProcessRule)
|
||||
dataset_process_rule = db.session.execute(
|
||||
select(DatasetProcessRule)
|
||||
.where(DatasetProcessRule.dataset_id == dataset_id)
|
||||
.order_by(DatasetProcessRule.created_at.desc())
|
||||
.limit(1)
|
||||
.one_or_none()
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if dataset_process_rule:
|
||||
mode = dataset_process_rule.mode
|
||||
rules = dataset_process_rule.rules_dict
|
||||
@@ -225,7 +226,7 @@ class DatasetService:
|
||||
summary_index_setting: dict | None = None,
|
||||
):
|
||||
# check if dataset name already exists
|
||||
if db.session.query(Dataset).filter_by(name=name, tenant_id=tenant_id).first():
|
||||
if db.session.scalar(select(Dataset).where(Dataset.name == name, Dataset.tenant_id == tenant_id).limit(1)):
|
||||
raise DatasetNameDuplicateError(f"Dataset with name {name} already exists.")
|
||||
embedding_model = None
|
||||
if indexing_technique == IndexTechniqueType.HIGH_QUALITY:
|
||||
@@ -300,17 +301,17 @@ class DatasetService:
|
||||
):
|
||||
if rag_pipeline_dataset_create_entity.name:
|
||||
# check if dataset name already exists
|
||||
if (
|
||||
db.session.query(Dataset)
|
||||
.filter_by(name=rag_pipeline_dataset_create_entity.name, tenant_id=tenant_id)
|
||||
.first()
|
||||
if db.session.scalar(
|
||||
select(Dataset)
|
||||
.where(Dataset.name == rag_pipeline_dataset_create_entity.name, Dataset.tenant_id == tenant_id)
|
||||
.limit(1)
|
||||
):
|
||||
raise DatasetNameDuplicateError(
|
||||
f"Dataset with name {rag_pipeline_dataset_create_entity.name} already exists."
|
||||
)
|
||||
else:
|
||||
# generate a random name as Untitled 1 2 3 ...
|
||||
datasets = db.session.query(Dataset).filter_by(tenant_id=tenant_id).all()
|
||||
datasets = db.session.scalars(select(Dataset).where(Dataset.tenant_id == tenant_id)).all()
|
||||
names = [dataset.name for dataset in datasets]
|
||||
rag_pipeline_dataset_create_entity.name = generate_incremental_name(
|
||||
names,
|
||||
@@ -344,7 +345,7 @@ class DatasetService:
|
||||
|
||||
@staticmethod
|
||||
def get_dataset(dataset_id) -> Dataset | None:
|
||||
dataset: Dataset | None = db.session.query(Dataset).filter_by(id=dataset_id).first()
|
||||
dataset: Dataset | None = db.session.get(Dataset, dataset_id)
|
||||
return dataset
|
||||
|
||||
@staticmethod
|
||||
@@ -466,14 +467,14 @@ class DatasetService:
|
||||
|
||||
@staticmethod
|
||||
def _has_dataset_same_name(tenant_id: str, dataset_id: str, name: str):
|
||||
dataset = (
|
||||
db.session.query(Dataset)
|
||||
dataset = db.session.scalar(
|
||||
select(Dataset)
|
||||
.where(
|
||||
Dataset.id != dataset_id,
|
||||
Dataset.name == name,
|
||||
Dataset.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
.limit(1)
|
||||
)
|
||||
return dataset is not None
|
||||
|
||||
@@ -596,7 +597,7 @@ class DatasetService:
|
||||
filtered_data["icon_info"] = data.get("icon_info")
|
||||
|
||||
# Update dataset in database
|
||||
db.session.query(Dataset).filter_by(id=dataset.id).update(filtered_data)
|
||||
db.session.execute(update(Dataset).where(Dataset.id == dataset.id).values(**filtered_data))
|
||||
db.session.commit()
|
||||
|
||||
# Reload dataset to get updated values
|
||||
@@ -631,7 +632,7 @@ class DatasetService:
|
||||
if dataset.runtime_mode != DatasetRuntimeMode.RAG_PIPELINE:
|
||||
return
|
||||
|
||||
pipeline = db.session.query(Pipeline).filter_by(id=dataset.pipeline_id).first()
|
||||
pipeline = db.session.get(Pipeline, dataset.pipeline_id)
|
||||
if not pipeline:
|
||||
return
|
||||
|
||||
@@ -1138,8 +1139,10 @@ class DatasetService:
|
||||
if dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM:
|
||||
# For partial team permission, user needs explicit permission or be the creator
|
||||
if dataset.created_by != user.id:
|
||||
user_permission = (
|
||||
db.session.query(DatasetPermission).filter_by(dataset_id=dataset.id, account_id=user.id).first()
|
||||
user_permission = db.session.scalar(
|
||||
select(DatasetPermission)
|
||||
.where(DatasetPermission.dataset_id == dataset.id, DatasetPermission.account_id == user.id)
|
||||
.limit(1)
|
||||
)
|
||||
if not user_permission:
|
||||
logger.debug("User %s does not have permission to access dataset %s", user.id, dataset.id)
|
||||
@@ -1161,7 +1164,9 @@ class DatasetService:
|
||||
elif dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM:
|
||||
if not any(
|
||||
dp.dataset_id == dataset.id
|
||||
for dp in db.session.query(DatasetPermission).filter_by(account_id=user.id).all()
|
||||
for dp in db.session.scalars(
|
||||
select(DatasetPermission).where(DatasetPermission.account_id == user.id)
|
||||
).all()
|
||||
):
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
|
||||
@@ -1175,12 +1180,11 @@ class DatasetService:
|
||||
|
||||
@staticmethod
|
||||
def get_related_apps(dataset_id: str):
|
||||
return (
|
||||
db.session.query(AppDatasetJoin)
|
||||
return db.session.scalars(
|
||||
select(AppDatasetJoin)
|
||||
.where(AppDatasetJoin.dataset_id == dataset_id)
|
||||
.order_by(db.desc(AppDatasetJoin.created_at))
|
||||
.all()
|
||||
)
|
||||
.order_by(AppDatasetJoin.created_at.desc())
|
||||
).all()
|
||||
|
||||
@staticmethod
|
||||
def update_dataset_api_status(dataset_id: str, status: bool):
|
||||
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
import random
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from typing import TYPE_CHECKING, TypedDict, cast
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import delete, select, tuple_
|
||||
@@ -158,6 +158,13 @@ class MessagesCleanupMetrics:
|
||||
self._record(self._job_duration_seconds, job_duration_seconds, attributes)
|
||||
|
||||
|
||||
class MessagesCleanStatsDict(TypedDict):
|
||||
batches: int
|
||||
total_messages: int
|
||||
filtered_messages: int
|
||||
total_deleted: int
|
||||
|
||||
|
||||
class MessagesCleanService:
|
||||
"""
|
||||
Service for cleaning expired messages based on retention policies.
|
||||
@@ -299,7 +306,7 @@ class MessagesCleanService:
|
||||
task_label=task_label,
|
||||
)
|
||||
|
||||
def run(self) -> dict[str, int]:
|
||||
def run(self) -> MessagesCleanStatsDict:
|
||||
"""
|
||||
Execute the message cleanup operation.
|
||||
|
||||
@@ -319,7 +326,7 @@ class MessagesCleanService:
|
||||
job_duration_seconds=time.monotonic() - run_start,
|
||||
)
|
||||
|
||||
def _clean_messages_by_time_range(self) -> dict[str, int]:
|
||||
def _clean_messages_by_time_range(self) -> MessagesCleanStatsDict:
|
||||
"""
|
||||
Clean messages within a time range using cursor-based pagination.
|
||||
|
||||
@@ -334,7 +341,7 @@ class MessagesCleanService:
|
||||
Returns:
|
||||
Dict with statistics: batches, filtered_messages, total_deleted
|
||||
"""
|
||||
stats = {
|
||||
stats: MessagesCleanStatsDict = {
|
||||
"batches": 0,
|
||||
"total_messages": 0,
|
||||
"filtered_messages": 0,
|
||||
|
||||
@@ -556,12 +556,8 @@ class TestTenantService:
|
||||
# Setup test data
|
||||
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
|
||||
|
||||
# Setup smart database query mock - no existing tenant joins
|
||||
query_results = {
|
||||
("TenantAccountJoin", "account_id", "user-123"): None,
|
||||
("TenantAccountJoin", "tenant_id", "tenant-456"): None, # For has_roles check
|
||||
}
|
||||
ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
|
||||
# Mock scalar to return None (no existing tenant joins)
|
||||
mock_db_dependencies["db"].session.scalar.return_value = None
|
||||
|
||||
# Setup external service mocks
|
||||
mock_external_service_dependencies[
|
||||
@@ -650,9 +646,8 @@ class TestTenantService:
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
|
||||
|
||||
# Setup smart database query mock - no existing member
|
||||
query_results = {("TenantAccountJoin", "tenant_id", "tenant-456"): None}
|
||||
ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
|
||||
# Mock scalar to return None (no existing member)
|
||||
mock_db_dependencies["db"].session.scalar.return_value = None
|
||||
|
||||
# Mock database operations
|
||||
mock_db_dependencies["db"].session.add = MagicMock()
|
||||
@@ -693,16 +688,8 @@ class TestTenantService:
|
||||
tenant_id="tenant-456", account_id="operator-123", role="owner"
|
||||
)
|
||||
|
||||
query_mock_permission = MagicMock()
|
||||
query_mock_permission.filter_by.return_value.first.return_value = mock_operator_join
|
||||
|
||||
query_mock_ta = MagicMock()
|
||||
query_mock_ta.filter_by.return_value.first.return_value = mock_ta
|
||||
|
||||
query_mock_count = MagicMock()
|
||||
query_mock_count.filter_by.return_value.count.return_value = 0
|
||||
|
||||
mock_db.session.query.side_effect = [query_mock_permission, query_mock_ta, query_mock_count]
|
||||
# scalar calls: permission check, ta lookup, remaining count
|
||||
mock_db.session.scalar.side_effect = [mock_operator_join, mock_ta, 0]
|
||||
|
||||
with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync:
|
||||
mock_sync.return_value = True
|
||||
@@ -741,17 +728,8 @@ class TestTenantService:
|
||||
tenant_id="tenant-456", account_id="operator-123", role="owner"
|
||||
)
|
||||
|
||||
query_mock_permission = MagicMock()
|
||||
query_mock_permission.filter_by.return_value.first.return_value = mock_operator_join
|
||||
|
||||
query_mock_ta = MagicMock()
|
||||
query_mock_ta.filter_by.return_value.first.return_value = mock_ta
|
||||
|
||||
# Remaining join count = 1 (still in another workspace)
|
||||
query_mock_count = MagicMock()
|
||||
query_mock_count.filter_by.return_value.count.return_value = 1
|
||||
|
||||
mock_db.session.query.side_effect = [query_mock_permission, query_mock_ta, query_mock_count]
|
||||
# scalar calls: permission check, ta lookup, remaining count = 1
|
||||
mock_db.session.scalar.side_effect = [mock_operator_join, mock_ta, 1]
|
||||
|
||||
with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync:
|
||||
mock_sync.return_value = True
|
||||
@@ -781,13 +759,8 @@ class TestTenantService:
|
||||
tenant_id="tenant-456", account_id="operator-123", role="owner"
|
||||
)
|
||||
|
||||
query_mock_permission = MagicMock()
|
||||
query_mock_permission.filter_by.return_value.first.return_value = mock_operator_join
|
||||
|
||||
query_mock_ta = MagicMock()
|
||||
query_mock_ta.filter_by.return_value.first.return_value = mock_ta
|
||||
|
||||
mock_db.session.query.side_effect = [query_mock_permission, query_mock_ta]
|
||||
# scalar calls: permission check, ta lookup (no count needed for active member)
|
||||
mock_db.session.scalar.side_effect = [mock_operator_join, mock_ta]
|
||||
|
||||
with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync:
|
||||
mock_sync.return_value = True
|
||||
@@ -810,13 +783,8 @@ class TestTenantService:
|
||||
|
||||
# Mock the complex query in switch_tenant method
|
||||
with patch("services.account_service.db") as mock_db:
|
||||
# Mock the join query that returns the tenant_account_join
|
||||
mock_query = MagicMock()
|
||||
mock_where = MagicMock()
|
||||
mock_where.first.return_value = mock_tenant_join
|
||||
mock_query.where.return_value = mock_where
|
||||
mock_query.join.return_value = mock_query
|
||||
mock_db.session.query.return_value = mock_query
|
||||
# Mock scalar for the join query
|
||||
mock_db.session.scalar.return_value = mock_tenant_join
|
||||
|
||||
# Execute test
|
||||
TenantService.switch_tenant(mock_account, "tenant-456")
|
||||
@@ -851,20 +819,8 @@ class TestTenantService:
|
||||
|
||||
# Mock the database queries in update_member_role method
|
||||
with patch("services.account_service.db") as mock_db:
|
||||
# Mock the first query for operator permission check
|
||||
mock_query1 = MagicMock()
|
||||
mock_filter1 = MagicMock()
|
||||
mock_filter1.first.return_value = mock_operator_join
|
||||
mock_query1.filter_by.return_value = mock_filter1
|
||||
|
||||
# Mock the second query for target member
|
||||
mock_query2 = MagicMock()
|
||||
mock_filter2 = MagicMock()
|
||||
mock_filter2.first.return_value = mock_target_join
|
||||
mock_query2.filter_by.return_value = mock_filter2
|
||||
|
||||
# Make the query method return different mocks for different calls
|
||||
mock_db.session.query.side_effect = [mock_query1, mock_query2]
|
||||
# scalar calls: permission check, target member lookup
|
||||
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join]
|
||||
|
||||
# Execute test
|
||||
TenantService.update_member_role(mock_tenant, mock_member, "admin", mock_operator)
|
||||
@@ -886,9 +842,7 @@ class TestTenantService:
|
||||
tenant_id="tenant-456", account_id="operator-123", role="owner"
|
||||
)
|
||||
|
||||
# Setup smart database query mock
|
||||
query_results = {("TenantAccountJoin", "tenant_id", "tenant-456"): mock_operator_join}
|
||||
ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
|
||||
mock_db_dependencies["db"].session.scalar.return_value = mock_operator_join
|
||||
|
||||
# Execute test - should not raise exception
|
||||
TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "add")
|
||||
@@ -1034,7 +988,7 @@ class TestRegisterService:
|
||||
)
|
||||
|
||||
# Verify rollback operations were called
|
||||
mock_db_dependencies["db"].session.query.assert_called()
|
||||
mock_db_dependencies["db"].session.execute.assert_called()
|
||||
|
||||
# ==================== Registration Tests ====================
|
||||
|
||||
@@ -1599,10 +1553,8 @@ class TestRegisterService:
|
||||
mock_session_class.return_value.__exit__.return_value = None
|
||||
mock_lookup.return_value = mock_existing_account
|
||||
|
||||
# Mock the db.session.query for TenantAccountJoin
|
||||
mock_db_query = MagicMock()
|
||||
mock_db_query.filter_by.return_value.first.return_value = None # No existing member
|
||||
mock_db_dependencies["db"].session.query.return_value = mock_db_query
|
||||
# Mock scalar for TenantAccountJoin lookup - no existing member
|
||||
mock_db_dependencies["db"].session.scalar.return_value = None
|
||||
|
||||
# Mock TenantService methods
|
||||
with (
|
||||
@@ -1777,14 +1729,9 @@ class TestRegisterService:
|
||||
}
|
||||
mock_get_invitation_by_token.return_value = invitation_data
|
||||
|
||||
# Mock database queries - complex query mocking
|
||||
mock_query1 = MagicMock()
|
||||
mock_query1.where.return_value.first.return_value = mock_tenant
|
||||
|
||||
mock_query2 = MagicMock()
|
||||
mock_query2.join.return_value.where.return_value.first.return_value = (mock_account, "normal")
|
||||
|
||||
mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
|
||||
# Mock scalar for tenant lookup, execute for account+role lookup
|
||||
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
|
||||
mock_db_dependencies["db"].session.execute.return_value.first.return_value = (mock_account, "normal")
|
||||
|
||||
# Execute test
|
||||
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
|
||||
@@ -1816,10 +1763,8 @@ class TestRegisterService:
|
||||
}
|
||||
mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
|
||||
|
||||
# Mock database queries - no tenant found
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value.first.return_value = None
|
||||
mock_db_dependencies["db"].session.query.return_value = mock_query
|
||||
# Mock scalar for tenant lookup - not found
|
||||
mock_db_dependencies["db"].session.scalar.return_value = None
|
||||
|
||||
# Execute test
|
||||
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
|
||||
@@ -1842,14 +1787,9 @@ class TestRegisterService:
|
||||
}
|
||||
mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
|
||||
|
||||
# Mock database queries
|
||||
mock_query1 = MagicMock()
|
||||
mock_query1.filter.return_value.first.return_value = mock_tenant
|
||||
|
||||
mock_query2 = MagicMock()
|
||||
mock_query2.join.return_value.where.return_value.first.return_value = None # No account found
|
||||
|
||||
mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
|
||||
# Mock scalar for tenant, execute for account+role
|
||||
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
|
||||
mock_db_dependencies["db"].session.execute.return_value.first.return_value = None # No account found
|
||||
|
||||
# Execute test
|
||||
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
|
||||
@@ -1875,14 +1815,9 @@ class TestRegisterService:
|
||||
}
|
||||
mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
|
||||
|
||||
# Mock database queries
|
||||
mock_query1 = MagicMock()
|
||||
mock_query1.filter.return_value.first.return_value = mock_tenant
|
||||
|
||||
mock_query2 = MagicMock()
|
||||
mock_query2.join.return_value.where.return_value.first.return_value = (mock_account, "normal")
|
||||
|
||||
mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
|
||||
# Mock scalar for tenant, execute for account+role
|
||||
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
|
||||
mock_db_dependencies["db"].session.execute.return_value.first.return_value = (mock_account, "normal")
|
||||
|
||||
# Execute test
|
||||
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
|
||||
|
||||
@@ -62,7 +62,7 @@ class TestDatasetServiceQueries:
|
||||
self, mock_dataset_query_dependencies
|
||||
):
|
||||
user = DatasetServiceUnitDataFactory.create_user_mock(role=TenantAccountRole.DATASET_OPERATOR)
|
||||
mock_dataset_query_dependencies["db"].session.query.return_value.filter_by.return_value.all.return_value = []
|
||||
mock_dataset_query_dependencies["db"].session.scalars.return_value.all.return_value = []
|
||||
|
||||
items, total = DatasetService.get_datasets(page=1, per_page=20, tenant_id="tenant-1", user=user)
|
||||
|
||||
@@ -108,9 +108,7 @@ class TestDatasetServiceQueries:
|
||||
dataset_process_rule.rules_dict = {"delimiter": "\n"}
|
||||
|
||||
with patch("services.dataset_service.db") as mock_db:
|
||||
(
|
||||
mock_db.session.query.return_value.where.return_value.order_by.return_value.limit.return_value.one_or_none.return_value
|
||||
) = dataset_process_rule
|
||||
(mock_db.session.execute.return_value.scalar_one_or_none.return_value) = dataset_process_rule
|
||||
|
||||
result = DatasetService.get_process_rules("dataset-1")
|
||||
|
||||
@@ -118,9 +116,7 @@ class TestDatasetServiceQueries:
|
||||
|
||||
def test_get_process_rules_falls_back_to_default_rules_when_missing(self):
|
||||
with patch("services.dataset_service.db") as mock_db:
|
||||
(
|
||||
mock_db.session.query.return_value.where.return_value.order_by.return_value.limit.return_value.one_or_none.return_value
|
||||
) = None
|
||||
(mock_db.session.execute.return_value.scalar_one_or_none.return_value) = None
|
||||
|
||||
result = DatasetService.get_process_rules("dataset-1")
|
||||
|
||||
@@ -151,7 +147,7 @@ class TestDatasetServiceQueries:
|
||||
dataset = DatasetServiceUnitDataFactory.create_dataset_mock()
|
||||
|
||||
with patch("services.dataset_service.db") as mock_db:
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = dataset
|
||||
mock_db.session.get.return_value = dataset
|
||||
|
||||
result = DatasetService.get_dataset(dataset.id)
|
||||
|
||||
@@ -308,7 +304,7 @@ class TestDatasetServiceCreationAndUpdate:
|
||||
account = SimpleNamespace(id="user-1")
|
||||
|
||||
with patch("services.dataset_service.db") as mock_db:
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = object()
|
||||
mock_db.session.scalar.return_value = object()
|
||||
|
||||
with pytest.raises(DatasetNameDuplicateError, match="Dataset with name Dataset already exists"):
|
||||
DatasetService.create_empty_dataset("tenant-1", "Dataset", None, "economy", account)
|
||||
@@ -319,6 +315,7 @@ class TestDatasetServiceCreationAndUpdate:
|
||||
|
||||
with (
|
||||
patch("services.dataset_service.db") as mock_db,
|
||||
patch("services.dataset_service.select"),
|
||||
patch(
|
||||
"services.dataset_service.Dataset",
|
||||
side_effect=lambda **kwargs: SimpleNamespace(id="dataset-1", **kwargs),
|
||||
@@ -326,7 +323,7 @@ class TestDatasetServiceCreationAndUpdate:
|
||||
patch("services.dataset_service.ModelManager") as model_manager_cls,
|
||||
patch.object(DatasetService, "check_embedding_model_setting") as check_embedding,
|
||||
):
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
mock_db.session.scalar.return_value = None
|
||||
model_manager_cls.for_tenant.return_value.get_default_model_instance.return_value = default_embedding_model
|
||||
|
||||
dataset = DatasetService.create_empty_dataset(
|
||||
@@ -355,6 +352,7 @@ class TestDatasetServiceCreationAndUpdate:
|
||||
|
||||
with (
|
||||
patch("services.dataset_service.db") as mock_db,
|
||||
patch("services.dataset_service.select"),
|
||||
patch(
|
||||
"services.dataset_service.Dataset",
|
||||
side_effect=lambda **kwargs: SimpleNamespace(id="dataset-1", **kwargs),
|
||||
@@ -368,7 +366,7 @@ class TestDatasetServiceCreationAndUpdate:
|
||||
patch.object(DatasetService, "check_embedding_model_setting") as check_embedding,
|
||||
patch.object(DatasetService, "check_reranking_model_setting") as check_reranking,
|
||||
):
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
mock_db.session.scalar.return_value = None
|
||||
model_manager_cls.for_tenant.return_value.get_model_instance.return_value = embedding_model
|
||||
|
||||
dataset = DatasetService.create_empty_dataset(
|
||||
@@ -412,7 +410,7 @@ class TestDatasetServiceCreationAndUpdate:
|
||||
)
|
||||
|
||||
with patch("services.dataset_service.db") as mock_db:
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = object()
|
||||
mock_db.session.scalar.return_value = object()
|
||||
|
||||
with pytest.raises(DatasetNameDuplicateError, match="Existing Dataset already exists"):
|
||||
DatasetService.create_empty_rag_pipeline_dataset("tenant-1", entity)
|
||||
@@ -435,12 +433,13 @@ class TestDatasetServiceCreationAndUpdate:
|
||||
|
||||
with (
|
||||
patch("services.dataset_service.db") as mock_db,
|
||||
patch("services.dataset_service.select"),
|
||||
patch("services.dataset_service.current_user", SimpleNamespace(id="user-1")),
|
||||
patch("services.dataset_service.generate_incremental_name", return_value="Untitled 2") as generate_name,
|
||||
patch("services.dataset_service.Pipeline", side_effect=pipeline_factory),
|
||||
patch("services.dataset_service.Dataset", side_effect=dataset_factory),
|
||||
):
|
||||
mock_db.session.query.return_value.filter_by.return_value.all.return_value = [
|
||||
mock_db.session.scalars.return_value.all.return_value = [
|
||||
SimpleNamespace(name="Untitled"),
|
||||
SimpleNamespace(name="Untitled 1"),
|
||||
]
|
||||
@@ -465,7 +464,7 @@ class TestDatasetServiceCreationAndUpdate:
|
||||
patch("services.dataset_service.db") as mock_db,
|
||||
patch("services.dataset_service.current_user", SimpleNamespace(id=None)),
|
||||
):
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
with pytest.raises(ValueError, match="Current user or current user id not found"):
|
||||
DatasetService.create_empty_rag_pipeline_dataset("tenant-1", entity)
|
||||
@@ -520,7 +519,7 @@ class TestDatasetServiceCreationAndUpdate:
|
||||
|
||||
def test_has_dataset_same_name_returns_true_when_query_matches(self):
|
||||
with patch("services.dataset_service.db") as mock_db:
|
||||
mock_db.session.query.return_value.where.return_value.first.return_value = object()
|
||||
mock_db.session.scalar.return_value = object()
|
||||
|
||||
result = DatasetService._has_dataset_same_name("tenant-1", "dataset-1", "Dataset")
|
||||
|
||||
@@ -630,7 +629,7 @@ class TestDatasetServiceCreationAndUpdate:
|
||||
result = DatasetService._update_internal_dataset(dataset, update_payload.copy(), user)
|
||||
|
||||
assert result is dataset
|
||||
updated_values = mock_db.session.query.return_value.filter_by.return_value.update.call_args.args[0]
|
||||
updated_values = mock_db.session.execute.call_args.args[0].compile().params
|
||||
assert updated_values["name"] == "Updated Dataset"
|
||||
assert updated_values["description"] is None
|
||||
assert updated_values["retrieval_model"] == {"top_k": 4}
|
||||
@@ -658,13 +657,13 @@ class TestDatasetServiceCreationAndUpdate:
|
||||
with patch("services.dataset_service.db") as mock_db:
|
||||
DatasetService._update_pipeline_knowledge_base_node_data(dataset, "user-1")
|
||||
|
||||
mock_db.session.query.assert_not_called()
|
||||
mock_db.session.get.assert_not_called()
|
||||
|
||||
def test_update_pipeline_knowledge_base_node_data_returns_when_pipeline_is_missing(self):
|
||||
dataset = SimpleNamespace(runtime_mode="rag_pipeline", pipeline_id="pipeline-1")
|
||||
|
||||
with patch("services.dataset_service.db") as mock_db:
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
mock_db.session.get.return_value = None
|
||||
|
||||
DatasetService._update_pipeline_knowledge_base_node_data(dataset, "user-1")
|
||||
|
||||
@@ -703,7 +702,7 @@ class TestDatasetServiceCreationAndUpdate:
|
||||
patch("services.dataset_service.RagPipelineService", return_value=rag_pipeline_service),
|
||||
patch("services.dataset_service.Workflow.new", return_value=new_workflow) as workflow_new,
|
||||
):
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = pipeline
|
||||
mock_db.session.get.return_value = pipeline
|
||||
|
||||
DatasetService._update_pipeline_knowledge_base_node_data(dataset, "user-1")
|
||||
|
||||
@@ -725,7 +724,7 @@ class TestDatasetServiceCreationAndUpdate:
|
||||
patch("services.dataset_service.db") as mock_db,
|
||||
patch("services.dataset_service.RagPipelineService", return_value=rag_pipeline_service),
|
||||
):
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = pipeline
|
||||
mock_db.session.get.return_value = pipeline
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
DatasetService._update_pipeline_knowledge_base_node_data(dataset, "user-1")
|
||||
@@ -1364,7 +1363,7 @@ class TestDatasetServicePermissionsAndLifecycle:
|
||||
)
|
||||
|
||||
with patch("services.dataset_service.db") as mock_db:
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
with pytest.raises(NoPermissionError, match="do not have permission"):
|
||||
DatasetService.check_dataset_permission(dataset, user)
|
||||
@@ -1382,7 +1381,7 @@ class TestDatasetServicePermissionsAndLifecycle:
|
||||
with patch("services.dataset_service.db") as mock_db:
|
||||
DatasetService.check_dataset_permission(dataset, user)
|
||||
|
||||
mock_db.session.query.assert_not_called()
|
||||
mock_db.session.scalar.assert_not_called()
|
||||
|
||||
def test_check_dataset_permission_allows_partial_team_member_with_binding(self):
|
||||
dataset = DatasetServiceUnitDataFactory.create_dataset_mock(
|
||||
@@ -1395,7 +1394,7 @@ class TestDatasetServicePermissionsAndLifecycle:
|
||||
)
|
||||
|
||||
with patch("services.dataset_service.db") as mock_db:
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = object()
|
||||
mock_db.session.scalar.return_value = object()
|
||||
|
||||
DatasetService.check_dataset_permission(dataset, user)
|
||||
|
||||
@@ -1427,7 +1426,7 @@ class TestDatasetServicePermissionsAndLifecycle:
|
||||
)
|
||||
|
||||
with patch("services.dataset_service.db") as mock_db:
|
||||
mock_db.session.query.return_value.filter_by.return_value.all.return_value = []
|
||||
mock_db.session.scalars.return_value.all.return_value = []
|
||||
|
||||
with pytest.raises(NoPermissionError, match="do not have permission"):
|
||||
DatasetService.check_dataset_operator_permission(user=user, dataset=dataset)
|
||||
@@ -1446,9 +1445,7 @@ class TestDatasetServicePermissionsAndLifecycle:
|
||||
def test_get_related_apps_returns_ordered_query_results(self):
|
||||
with patch("services.dataset_service.db") as mock_db:
|
||||
mock_db.desc.side_effect = lambda column: column
|
||||
mock_db.session.query.return_value.where.return_value.order_by.return_value.all.return_value = [
|
||||
"relation-1"
|
||||
]
|
||||
mock_db.session.scalars.return_value.all.return_value = ["relation-1"]
|
||||
|
||||
result = DatasetService.get_related_apps("dataset-1")
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"prepare": "vp config"
|
||||
},
|
||||
"devDependencies": {
|
||||
"taze": "catalog:",
|
||||
"vite-plus": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
560
pnpm-lock.yaml
generated
560
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@ overrides:
|
||||
is-generator-function: npm:@nolyfill/is-generator-function@^1.0.44
|
||||
is-typed-array: npm:@nolyfill/is-typed-array@^1.0.44
|
||||
isarray: npm:@nolyfill/isarray@^1.0.44
|
||||
lodash@>=4.0.0 <= 4.17.23: 4.18.0
|
||||
lodash-es@>=4.0.0 <= 4.17.23: 4.18.0
|
||||
object.assign: npm:@nolyfill/object.assign@^1.0.44
|
||||
object.entries: npm:@nolyfill/object.entries@^1.0.44
|
||||
@@ -131,6 +132,7 @@ catalog:
|
||||
"@tanstack/react-form-devtools": 0.2.20
|
||||
"@tanstack/react-query": 5.96.1
|
||||
"@tanstack/react-query-devtools": 5.96.1
|
||||
"@tanstack/react-virtual": 3.13.23
|
||||
"@testing-library/dom": 10.4.1
|
||||
"@testing-library/jest-dom": 6.9.1
|
||||
"@testing-library/react": 16.3.2
|
||||
@@ -146,8 +148,6 @@ catalog:
|
||||
"@types/qs": 6.15.0
|
||||
"@types/react": 19.2.14
|
||||
"@types/react-dom": 19.2.3
|
||||
"@types/react-syntax-highlighter": 15.5.13
|
||||
"@types/react-window": 1.8.8
|
||||
"@types/sortablejs": 1.15.9
|
||||
"@typescript-eslint/eslint-plugin": 8.58.0
|
||||
"@typescript-eslint/parser": 8.58.0
|
||||
@@ -162,7 +162,7 @@ catalog:
|
||||
class-variance-authority: 0.7.1
|
||||
clsx: 2.1.1
|
||||
cmdk: 1.1.1
|
||||
code-inspector-plugin: 1.5.0
|
||||
code-inspector-plugin: 1.5.1
|
||||
copy-to-clipboard: 3.3.3
|
||||
cron-parser: 5.5.0
|
||||
dayjs: 1.11.20
|
||||
@@ -187,6 +187,7 @@ catalog:
|
||||
fast-deep-equal: 3.1.3
|
||||
foxact: 0.3.0
|
||||
happy-dom: 20.8.9
|
||||
hast-util-to-jsx-runtime: 2.3.6
|
||||
hono: 4.12.10
|
||||
html-entities: 2.6.0
|
||||
html-to-image: 1.11.13
|
||||
@@ -227,14 +228,13 @@ catalog:
|
||||
react-pdf-highlighter: 8.0.0-rc.0
|
||||
react-server-dom-webpack: 19.2.4
|
||||
react-sortablejs: 6.1.4
|
||||
react-syntax-highlighter: 15.6.6
|
||||
react-textarea-autosize: 8.5.9
|
||||
react-window: 1.8.11
|
||||
reactflow: 11.11.4
|
||||
remark-breaks: 4.0.0
|
||||
remark-directive: 4.0.0
|
||||
scheduler: 0.27.0
|
||||
sharp: 0.34.5
|
||||
shiki: 4.0.2
|
||||
sortablejs: 1.15.7
|
||||
std-semver: 1.0.8
|
||||
storybook: 10.3.4
|
||||
@@ -242,7 +242,6 @@ catalog:
|
||||
string-ts: 2.3.1
|
||||
tailwind-merge: 3.5.0
|
||||
tailwindcss: 4.2.2
|
||||
taze: 19.11.0
|
||||
tldts: 7.0.27
|
||||
tsdown: 0.21.7
|
||||
tsx: 4.21.0
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineConfig } from 'taze'
|
||||
|
||||
export default defineConfig({
|
||||
exclude: [
|
||||
// We are going to replace these
|
||||
'react-syntax-highlighter',
|
||||
'react-window',
|
||||
'@types/react-window',
|
||||
],
|
||||
})
|
||||
@@ -25,6 +25,7 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/
|
||||
COPY web/package.json /app/web/
|
||||
COPY e2e/package.json /app/e2e/
|
||||
COPY sdks/nodejs-client/package.json /app/sdks/nodejs-client/
|
||||
COPY packages /app/packages
|
||||
|
||||
# Use packageManager from package.json
|
||||
RUN corepack install
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
!web/**
|
||||
!e2e/
|
||||
!e2e/package.json
|
||||
!packages/
|
||||
!packages/**/
|
||||
!packages/**/package.json
|
||||
!sdks/
|
||||
!sdks/nodejs-client/
|
||||
!sdks/nodejs-client/package.json
|
||||
|
||||
36
web/__mocks__/@tanstack/react-virtual.ts
Normal file
36
web/__mocks__/@tanstack/react-virtual.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
const mockVirtualizer = ({
|
||||
count,
|
||||
estimateSize,
|
||||
}: {
|
||||
count: number
|
||||
estimateSize?: (index: number) => number
|
||||
}) => {
|
||||
const getSize = (index: number) => estimateSize?.(index) ?? 0
|
||||
|
||||
return {
|
||||
getTotalSize: () => Array.from({ length: count }).reduce<number>((total, _, index) => total + getSize(index), 0),
|
||||
getVirtualItems: () => {
|
||||
let start = 0
|
||||
|
||||
return Array.from({ length: count }).map((_, index) => {
|
||||
const size = getSize(index)
|
||||
const virtualItem = {
|
||||
end: start + size,
|
||||
index,
|
||||
key: index,
|
||||
size,
|
||||
start,
|
||||
}
|
||||
|
||||
start += size
|
||||
return virtualItem
|
||||
})
|
||||
},
|
||||
measureElement: vi.fn(),
|
||||
scrollToIndex: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
export { mockVirtualizer as useVirtualizer }
|
||||
69
web/app/components/app/__tests__/store.spec.ts
Normal file
69
web/app/components/app/__tests__/store.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useStore } from '../store'
|
||||
|
||||
const resetStore = () => {
|
||||
useStore.setState({
|
||||
appDetail: undefined,
|
||||
appSidebarExpand: '',
|
||||
currentLogItem: undefined,
|
||||
currentLogModalActiveTab: 'DETAIL',
|
||||
showPromptLogModal: false,
|
||||
showAgentLogModal: false,
|
||||
showMessageLogModal: false,
|
||||
showAppConfigureFeaturesModal: false,
|
||||
})
|
||||
}
|
||||
|
||||
describe('app store', () => {
|
||||
beforeEach(() => {
|
||||
resetStore()
|
||||
})
|
||||
|
||||
it('should expose the default state', () => {
|
||||
expect(useStore.getState()).toEqual(expect.objectContaining({
|
||||
appDetail: undefined,
|
||||
appSidebarExpand: '',
|
||||
currentLogItem: undefined,
|
||||
currentLogModalActiveTab: 'DETAIL',
|
||||
showPromptLogModal: false,
|
||||
showAgentLogModal: false,
|
||||
showMessageLogModal: false,
|
||||
showAppConfigureFeaturesModal: false,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should update every mutable field through its actions', () => {
|
||||
const appDetail = { id: 'app-1' } as ReturnType<typeof useStore.getState>['appDetail']
|
||||
const currentLogItem = { id: 'message-1' } as ReturnType<typeof useStore.getState>['currentLogItem']
|
||||
|
||||
useStore.getState().setAppDetail(appDetail)
|
||||
useStore.getState().setAppSidebarExpand('logs')
|
||||
useStore.getState().setCurrentLogItem(currentLogItem)
|
||||
useStore.getState().setCurrentLogModalActiveTab('MESSAGE')
|
||||
useStore.getState().setShowPromptLogModal(true)
|
||||
useStore.getState().setShowAgentLogModal(true)
|
||||
useStore.getState().setShowAppConfigureFeaturesModal(true)
|
||||
|
||||
expect(useStore.getState()).toEqual(expect.objectContaining({
|
||||
appDetail,
|
||||
appSidebarExpand: 'logs',
|
||||
currentLogItem,
|
||||
currentLogModalActiveTab: 'MESSAGE',
|
||||
showPromptLogModal: true,
|
||||
showAgentLogModal: true,
|
||||
showAppConfigureFeaturesModal: true,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should reset the active tab when the message log modal closes', () => {
|
||||
useStore.getState().setCurrentLogModalActiveTab('TRACE')
|
||||
useStore.getState().setShowMessageLogModal(true)
|
||||
|
||||
expect(useStore.getState().showMessageLogModal).toBe(true)
|
||||
expect(useStore.getState().currentLogModalActiveTab).toBe('TRACE')
|
||||
|
||||
useStore.getState().setShowMessageLogModal(false)
|
||||
|
||||
expect(useStore.getState().showMessageLogModal).toBe(false)
|
||||
expect(useStore.getState().currentLogModalActiveTab).toBe('DETAIL')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import BatchAction from './batch-action'
|
||||
import BatchAction from '../batch-action'
|
||||
|
||||
describe('BatchAction', () => {
|
||||
const baseProps = {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import EmptyElement from './empty-element'
|
||||
import EmptyElement from '../empty-element'
|
||||
|
||||
describe('EmptyElement', () => {
|
||||
it('should render the empty state copy and supporting icon', () => {
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
import type { Mock } from 'vitest'
|
||||
import type { QueryParam } from './filter'
|
||||
import type { QueryParam } from '../filter'
|
||||
import type { AnnotationsCountResponse } from '@/models/log'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import * as useLogModule from '@/service/use-log'
|
||||
import Filter from './filter'
|
||||
import Filter from '../filter'
|
||||
|
||||
vi.mock('@/service/use-log')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { Mock } from 'vitest'
|
||||
import type { AnnotationItem } from './type'
|
||||
import type { AnnotationItem } from '../type'
|
||||
import type { App } from '@/types/app'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
@@ -9,13 +10,16 @@ import {
|
||||
addAnnotation,
|
||||
delAnnotation,
|
||||
delAnnotations,
|
||||
editAnnotation,
|
||||
fetchAnnotationConfig,
|
||||
fetchAnnotationList,
|
||||
queryAnnotationJobStatus,
|
||||
updateAnnotationScore,
|
||||
updateAnnotationStatus,
|
||||
} from '@/service/annotation'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import Annotation from './index'
|
||||
import { JobStatus } from './type'
|
||||
import Annotation from '../index'
|
||||
import { AnnotationEnableStatus, JobStatus } from '../type'
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounce: (value: any) => value,
|
||||
@@ -37,29 +41,32 @@ vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./filter', () => ({
|
||||
vi.mock('../filter', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="filter">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./empty-element', () => ({
|
||||
vi.mock('../empty-element', () => ({
|
||||
default: () => <div data-testid="empty-element" />,
|
||||
}))
|
||||
|
||||
vi.mock('./header-opts', () => ({
|
||||
vi.mock('../header-opts', () => ({
|
||||
default: (props: any) => (
|
||||
<div data-testid="header-opts">
|
||||
<button data-testid="trigger-add" onClick={() => props.onAdd({ question: 'new question', answer: 'new answer' })}>
|
||||
add
|
||||
</button>
|
||||
<button data-testid="trigger-added" onClick={() => props.onAdded()}>
|
||||
added
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
let latestListProps: any
|
||||
|
||||
vi.mock('./list', () => ({
|
||||
vi.mock('../list', () => ({
|
||||
default: (props: any) => {
|
||||
latestListProps = props
|
||||
if (!props.list.length)
|
||||
@@ -74,7 +81,7 @@ vi.mock('./list', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./view-annotation-modal', () => ({
|
||||
vi.mock('../view-annotation-modal', () => ({
|
||||
default: (props: any) => {
|
||||
if (!props.isShow)
|
||||
return null
|
||||
@@ -82,14 +89,40 @@ vi.mock('./view-annotation-modal', () => ({
|
||||
<div data-testid="view-modal">
|
||||
<div>{props.item.question}</div>
|
||||
<button data-testid="view-modal-remove" onClick={props.onRemove}>remove</button>
|
||||
<button data-testid="view-modal-save" onClick={() => props.onSave('Edited question', 'Edited answer')}>save</button>
|
||||
<button data-testid="view-modal-close" onClick={props.onHide}>close</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', () => ({ default: (props: any) => props.isShow ? <div data-testid="config-modal" /> : null }))
|
||||
vi.mock('@/app/components/billing/annotation-full/modal', () => ({ default: (props: any) => props.show ? <div data-testid="annotation-full-modal" /> : null }))
|
||||
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', () => ({
|
||||
default: (props: any) => props.isShow
|
||||
? (
|
||||
<div data-testid="config-modal">
|
||||
<button
|
||||
data-testid="config-save"
|
||||
onClick={() => props.onSave({
|
||||
embedding_model_name: 'next-model',
|
||||
embedding_provider_name: 'next-provider',
|
||||
}, 0.7)}
|
||||
>
|
||||
save-config
|
||||
</button>
|
||||
<button data-testid="config-hide" onClick={props.onHide}>hide-config</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
vi.mock('@/app/components/billing/annotation-full/modal', () => ({
|
||||
default: (props: any) => props.show
|
||||
? (
|
||||
<div data-testid="annotation-full-modal">
|
||||
<button data-testid="hide-annotation-full-modal" onClick={props.onHide}>hide-full</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
vi.spyOn(toast, 'success').mockImplementation((message, options) => {
|
||||
@@ -111,9 +144,12 @@ vi.spyOn(toast, 'info').mockImplementation((message, options) => {
|
||||
const addAnnotationMock = addAnnotation as Mock
|
||||
const delAnnotationMock = delAnnotation as Mock
|
||||
const delAnnotationsMock = delAnnotations as Mock
|
||||
const editAnnotationMock = editAnnotation as Mock
|
||||
const fetchAnnotationConfigMock = fetchAnnotationConfig as Mock
|
||||
const fetchAnnotationListMock = fetchAnnotationList as Mock
|
||||
const queryAnnotationJobStatusMock = queryAnnotationJobStatus as Mock
|
||||
const updateAnnotationScoreMock = updateAnnotationScore as Mock
|
||||
const updateAnnotationStatusMock = updateAnnotationStatus as Mock
|
||||
const useProviderContextMock = useProviderContext as Mock
|
||||
|
||||
const appDetail = {
|
||||
@@ -146,6 +182,9 @@ describe('Annotation', () => {
|
||||
})
|
||||
fetchAnnotationListMock.mockResolvedValue({ data: [], total: 0 })
|
||||
queryAnnotationJobStatusMock.mockResolvedValue({ job_status: JobStatus.completed })
|
||||
updateAnnotationStatusMock.mockResolvedValue({ job_id: 'job-1' })
|
||||
updateAnnotationScoreMock.mockResolvedValue(undefined)
|
||||
editAnnotationMock.mockResolvedValue(undefined)
|
||||
useProviderContextMock.mockReturnValue({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 0 },
|
||||
@@ -251,4 +290,166 @@ describe('Annotation', () => {
|
||||
expect(latestListProps.selectedIds).toEqual([annotation.id])
|
||||
})
|
||||
})
|
||||
|
||||
it('should show the annotation-full modal when enabling annotations exceeds the plan quota', async () => {
|
||||
useProviderContextMock.mockReturnValue({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
},
|
||||
enableBilling: true,
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
const toggle = await screen.findByRole('switch')
|
||||
fireEvent.click(toggle)
|
||||
|
||||
expect(screen.getByTestId('annotation-full-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('hide-annotation-full-modal'))
|
||||
expect(screen.queryByTestId('annotation-full-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable annotations and refetch config after the async job completes', async () => {
|
||||
fetchAnnotationConfigMock.mockResolvedValueOnce({
|
||||
id: 'config-id',
|
||||
enabled: true,
|
||||
embedding_model: {
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
},
|
||||
score_threshold: 0.5,
|
||||
}).mockResolvedValueOnce({
|
||||
id: 'config-id',
|
||||
enabled: false,
|
||||
embedding_model: {
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
},
|
||||
score_threshold: 0.5,
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
const toggle = await screen.findByRole('switch')
|
||||
await waitFor(() => {
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
fireEvent.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateAnnotationStatusMock).toHaveBeenCalledWith(
|
||||
appDetail.id,
|
||||
AnnotationEnableStatus.disable,
|
||||
expect.objectContaining({
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
}),
|
||||
0.5,
|
||||
)
|
||||
expect(queryAnnotationJobStatusMock).toHaveBeenCalledWith(appDetail.id, AnnotationEnableStatus.disable, 'job-1')
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'common.api.actionSuccess',
|
||||
type: 'success',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should save annotation config changes and update the score when the modal confirms', async () => {
|
||||
fetchAnnotationConfigMock.mockResolvedValue({
|
||||
id: 'config-id',
|
||||
enabled: false,
|
||||
embedding_model: {
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
},
|
||||
score_threshold: 0.5,
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
const toggle = await screen.findByRole('switch')
|
||||
fireEvent.click(toggle)
|
||||
|
||||
expect(screen.getByTestId('config-modal')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('config-save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateAnnotationStatusMock).toHaveBeenCalledWith(
|
||||
appDetail.id,
|
||||
AnnotationEnableStatus.enable,
|
||||
{
|
||||
embedding_model_name: 'next-model',
|
||||
embedding_provider_name: 'next-provider',
|
||||
},
|
||||
0.7,
|
||||
)
|
||||
expect(updateAnnotationScoreMock).toHaveBeenCalledWith(appDetail.id, 'config-id', 0.7)
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'common.api.actionSuccess',
|
||||
type: 'success',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should refresh the list from the header shortcut and allow saving or closing the view modal', async () => {
|
||||
const annotation = createAnnotation()
|
||||
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
|
||||
|
||||
renderComponent()
|
||||
|
||||
await screen.findByTestId('list')
|
||||
fireEvent.click(screen.getByTestId('list-view'))
|
||||
|
||||
fireEvent.click(screen.getByTestId('view-modal-save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editAnnotationMock).toHaveBeenCalledWith(appDetail.id, annotation.id, {
|
||||
question: 'Edited question',
|
||||
answer: 'Edited answer',
|
||||
})
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('view-modal-close'))
|
||||
expect(screen.queryByTestId('view-modal')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('trigger-added'))
|
||||
|
||||
expect(fetchAnnotationListMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear selections on cancel and hide the config modal when requested', async () => {
|
||||
const annotation = createAnnotation()
|
||||
fetchAnnotationConfigMock.mockResolvedValue({
|
||||
id: 'config-id',
|
||||
enabled: true,
|
||||
embedding_model: {
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
},
|
||||
score_threshold: 0.5,
|
||||
})
|
||||
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
|
||||
|
||||
renderComponent()
|
||||
|
||||
await screen.findByTestId('list')
|
||||
|
||||
await act(async () => {
|
||||
latestListProps.onSelectedIdsChange([annotation.id])
|
||||
})
|
||||
await act(async () => {
|
||||
latestListProps.onCancel()
|
||||
})
|
||||
|
||||
expect(latestListProps.selectedIds).toEqual([])
|
||||
|
||||
const configButton = document.querySelector('.action-btn') as HTMLButtonElement
|
||||
fireEvent.click(configButton)
|
||||
expect(await screen.findByTestId('config-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('config-hide'))
|
||||
expect(screen.queryByTestId('config-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AnnotationItem } from './type'
|
||||
import type { AnnotationItem } from '../type'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import List from './list'
|
||||
import List from '../list'
|
||||
|
||||
const mockFormatTime = vi.fn(() => 'formatted-time')
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Mock } from 'vitest'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import AddAnnotationModal from './index'
|
||||
import AddAnnotationModal from '../index'
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import EditItem, { EditItemType } from './index'
|
||||
import EditItem, { EditItemType } from '../index'
|
||||
|
||||
describe('AddAnnotationModal/EditItem', () => {
|
||||
it('should render query inputs with user avatar and placeholder strings', () => {
|
||||
@@ -1,10 +1,11 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { Mock } from 'vitest'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import CSVDownload from './csv-downloader'
|
||||
import CSVDownload from '../csv-downloader'
|
||||
|
||||
const downloaderProps: any[] = []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Props } from './csv-uploader'
|
||||
import type { Props } from '../csv-uploader'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import CSVUploader from './csv-uploader'
|
||||
import CSVUploader from '../csv-uploader'
|
||||
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
notify: vi.fn(),
|
||||
@@ -75,6 +75,20 @@ describe('CSVUploader', () => {
|
||||
expect(dropZone.className).not.toContain('border-components-dropzone-border-accent')
|
||||
})
|
||||
|
||||
it('should handle drag over and clear dragging state when leaving through the overlay', () => {
|
||||
renderComponent()
|
||||
const { dropZone, dropContainer } = getDropElements()
|
||||
|
||||
fireEvent.dragEnter(dropContainer)
|
||||
const dragLayer = dropContainer.querySelector('.absolute') as HTMLDivElement
|
||||
|
||||
fireEvent.dragOver(dropContainer)
|
||||
fireEvent.dragLeave(dragLayer)
|
||||
|
||||
expect(dropZone.className).not.toContain('border-components-dropzone-border-accent')
|
||||
expect(dropZone.className).not.toContain('bg-components-dropzone-bg-accent')
|
||||
})
|
||||
|
||||
it('should ignore drop events without dataTransfer', () => {
|
||||
renderComponent()
|
||||
const { dropContainer } = getDropElements()
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { IBatchModalProps } from './index'
|
||||
import type { IBatchModalProps } from '../index'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation'
|
||||
import BatchModal, { ProcessStatus } from './index'
|
||||
import BatchModal, { ProcessStatus } from '../index'
|
||||
|
||||
vi.mock('@/service/annotation', () => ({
|
||||
annotationBatchImport: vi.fn(),
|
||||
@@ -15,13 +15,13 @@ vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./csv-downloader', () => ({
|
||||
vi.mock('../csv-downloader', () => ({
|
||||
default: () => <div data-testid="csv-downloader-stub" />,
|
||||
}))
|
||||
|
||||
let lastUploadedFile: File | undefined
|
||||
|
||||
vi.mock('./csv-uploader', () => ({
|
||||
vi.mock('../csv-uploader', () => ({
|
||||
default: ({ file, updateFile }: { file?: File, updateFile: (file?: File) => void }) => (
|
||||
<div>
|
||||
<button
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import ClearAllAnnotationsConfirmModal from './index'
|
||||
import ClearAllAnnotationsConfirmModal from '../index'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import EditAnnotationModal from './index'
|
||||
import EditAnnotationModal from '../index'
|
||||
|
||||
const { mockAddAnnotation, mockEditAnnotation } = vi.hoisted(() => ({
|
||||
mockAddAnnotation: vi.fn(),
|
||||
@@ -51,11 +51,6 @@ describe('EditAnnotationModal', () => {
|
||||
onRemove: vi.fn(),
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
toastSuccessSpy.mockRestore()
|
||||
toastErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAddAnnotation.mockResolvedValue({
|
||||
@@ -65,6 +60,11 @@ describe('EditAnnotationModal', () => {
|
||||
mockEditAnnotation.mockResolvedValue({})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
toastSuccessSpy.mockRestore()
|
||||
toastErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render modal when isShow is true', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import EditItem, { EditItemType, EditTitle } from './index'
|
||||
import EditItem, { EditItemType, EditTitle } from '../index'
|
||||
|
||||
describe('EditTitle', () => {
|
||||
it('should render title content correctly', () => {
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { Mock } from 'vitest'
|
||||
import type { AnnotationItemBasic } from '../type'
|
||||
import type { AnnotationItemBasic } from '../../type'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@@ -8,7 +9,7 @@ import * as React from 'react'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
|
||||
import HeaderOptions from './index'
|
||||
import HeaderOptions from '../index'
|
||||
|
||||
vi.mock('@headlessui/react', () => {
|
||||
type PopoverContextValue = { open: boolean, setOpen: (open: boolean) => void }
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import RemoveAnnotationConfirmModal from './index'
|
||||
import RemoveAnnotationConfirmModal from '../index'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { AnnotationItem, HitHistoryItem } from '../type'
|
||||
import type { AnnotationItem, HitHistoryItem } from '../../type'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { fetchHitHistoryList } from '@/service/annotation'
|
||||
import ViewAnnotationModal from './index'
|
||||
import ViewAnnotationModal from '../index'
|
||||
|
||||
const mockFormatTime = vi.fn(() => 'formatted-time')
|
||||
|
||||
@@ -17,7 +17,7 @@ vi.mock('@/service/annotation', () => ({
|
||||
fetchHitHistoryList: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../edit-annotation-modal/edit-item', () => {
|
||||
vi.mock('../../edit-annotation-modal/edit-item', () => {
|
||||
const EditItemType = {
|
||||
Query: 'query',
|
||||
Answer: 'answer',
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
@@ -5,11 +6,11 @@ import userEvent from '@testing-library/user-event'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import AccessControlDialog from './access-control-dialog'
|
||||
import AccessControlItem from './access-control-item'
|
||||
import AddMemberOrGroupDialog from './add-member-or-group-pop'
|
||||
import AccessControl from './index'
|
||||
import SpecificGroupsOrMembers from './specific-groups-or-members'
|
||||
import AccessControlDialog from '../access-control-dialog'
|
||||
import AccessControlItem from '../access-control-item'
|
||||
import AddMemberOrGroupDialog from '../add-member-or-group-pop'
|
||||
import AccessControl from '../index'
|
||||
import SpecificGroupsOrMembers from '../specific-groups-or-members'
|
||||
|
||||
const mockUseAppWhiteListSubjects = vi.fn()
|
||||
const mockUseSearchForWhiteListCandidates = vi.fn()
|
||||
@@ -18,6 +19,9 @@ const mockUseUpdateAccessMode = vi.fn(() => ({
|
||||
isPending: false,
|
||||
mutateAsync: mockMutateAsync,
|
||||
}))
|
||||
const intersectionObserverMocks = vi.hoisted(() => ({
|
||||
callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: <T,>(selector: (value: { userProfile: { email: string, id?: string, name?: string, avatar?: string, avatar_url?: string, is_password_set?: boolean } }) => T) => selector({
|
||||
@@ -105,6 +109,10 @@ const memberSubject: Subject = {
|
||||
|
||||
beforeAll(() => {
|
||||
class MockIntersectionObserver {
|
||||
constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) {
|
||||
intersectionObserverMocks.callback = callback
|
||||
}
|
||||
|
||||
observe = vi.fn(() => undefined)
|
||||
disconnect = vi.fn(() => undefined)
|
||||
unobserve = vi.fn(() => undefined)
|
||||
@@ -281,6 +289,39 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
|
||||
})
|
||||
|
||||
it('should update the keyword, fetch the next page, and support deselection and breadcrumb reset', async () => {
|
||||
const fetchNextPage = vi.fn()
|
||||
mockUseSearchForWhiteListCandidates.mockReturnValue({
|
||||
isLoading: false,
|
||||
isFetchingNextPage: true,
|
||||
fetchNextPage,
|
||||
data: { pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: true }] },
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
await user.type(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder'), 'Group')
|
||||
expect(document.querySelector('.spin-animation')).toBeInTheDocument()
|
||||
|
||||
const groupCheckbox = screen.getByText(baseGroup.name).closest('div')?.previousElementSibling as HTMLElement
|
||||
fireEvent.click(groupCheckbox)
|
||||
fireEvent.click(groupCheckbox)
|
||||
|
||||
const memberCheckbox = screen.getByText(baseMember.name).parentElement?.previousElementSibling as HTMLElement
|
||||
fireEvent.click(memberCheckbox)
|
||||
fireEvent.click(memberCheckbox)
|
||||
|
||||
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
|
||||
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.allMembers'))
|
||||
|
||||
expect(useAccessControlStore.getState().specificGroups).toEqual([])
|
||||
expect(useAccessControlStore.getState().specificMembers).toEqual([])
|
||||
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([])
|
||||
expect(fetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show empty state when no candidates are returned', async () => {
|
||||
mockUseSearchForWhiteListCandidates.mockReturnValue({
|
||||
isLoading: false,
|
||||
@@ -0,0 +1,140 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import FeaturesWrappedAppPublisher from '../features-wrapper'
|
||||
|
||||
const mockSetFeatures = vi.fn()
|
||||
const mockOnPublish = vi.fn()
|
||||
const mockAppPublisherProps = vi.hoisted(() => ({
|
||||
current: null as null | Record<string, any>,
|
||||
}))
|
||||
|
||||
const mockFeatures = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false, opening_statement: '', suggested_questions: [] as string[] },
|
||||
moderation: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
file: {
|
||||
image: {
|
||||
detail: 'high',
|
||||
enabled: false,
|
||||
number_limits: 3,
|
||||
transfer_methods: ['local_file', 'remote_url'],
|
||||
},
|
||||
enabled: false,
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.png'],
|
||||
allowed_file_upload_methods: ['local_file'],
|
||||
number_limits: 3,
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/app-publisher', () => ({
|
||||
default: (props: Record<string, any>) => {
|
||||
mockAppPublisherProps.current = props
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => props.onPublish?.({ id: 'model-1' })}>publish-through-wrapper</button>
|
||||
<button onClick={() => props.onRestore?.()}>restore-through-wrapper</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeatures: (selector: (state: { features: typeof mockFeatures }) => unknown) => selector({ features: mockFeatures }),
|
||||
useFeaturesStore: () => ({
|
||||
getState: () => ({
|
||||
features: mockFeatures,
|
||||
setFeatures: mockSetFeatures,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('FeaturesWrappedAppPublisher', () => {
|
||||
const publishedConfig = {
|
||||
modelConfig: {
|
||||
more_like_this: { enabled: true },
|
||||
opening_statement: 'Hello there',
|
||||
suggested_questions: ['Q1'],
|
||||
sensitive_word_avoidance: { enabled: true },
|
||||
speech_to_text: { enabled: true },
|
||||
text_to_speech: { enabled: true },
|
||||
suggested_questions_after_answer: { enabled: true },
|
||||
retriever_resource: { enabled: true },
|
||||
annotation_reply: { enabled: true },
|
||||
file_upload: {
|
||||
enabled: true,
|
||||
image: {
|
||||
enabled: true,
|
||||
detail: 'low',
|
||||
number_limits: 5,
|
||||
transfer_methods: ['remote_url'],
|
||||
},
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.jpg'],
|
||||
allowed_file_upload_methods: ['remote_url'],
|
||||
number_limits: 5,
|
||||
},
|
||||
resetAppConfig: vi.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppPublisherProps.current = null
|
||||
})
|
||||
|
||||
it('should pass current features through to onPublish', async () => {
|
||||
render(
|
||||
<FeaturesWrappedAppPublisher
|
||||
publishedConfig={publishedConfig as any}
|
||||
onPublish={mockOnPublish}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('publish-through-wrapper'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnPublish).toHaveBeenCalledWith({ id: 'model-1' }, mockFeatures)
|
||||
})
|
||||
})
|
||||
|
||||
it('should restore published features after confirmation', async () => {
|
||||
render(
|
||||
<FeaturesWrappedAppPublisher
|
||||
publishedConfig={publishedConfig as any}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('restore-through-wrapper'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(publishedConfig.modelConfig.resetAppConfig).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetFeatures).toHaveBeenCalledWith(expect.objectContaining({
|
||||
moreLikeThis: { enabled: true },
|
||||
opening: {
|
||||
enabled: true,
|
||||
opening_statement: 'Hello there',
|
||||
suggested_questions: ['Q1'],
|
||||
},
|
||||
moderation: { enabled: true },
|
||||
speech2text: { enabled: true },
|
||||
text2speech: { enabled: true },
|
||||
suggested: { enabled: true },
|
||||
citation: { enabled: true },
|
||||
annotationReply: { enabled: true },
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
455
web/app/components/app/app-publisher/__tests__/index.spec.tsx
Normal file
455
web/app/components/app/app-publisher/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
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 { basePath } from '@/utils/var'
|
||||
import AppPublisher from '../index'
|
||||
|
||||
const mockOnPublish = vi.fn()
|
||||
const mockOnToggle = vi.fn()
|
||||
const mockSetAppDetail = vi.fn()
|
||||
const mockTrackEvent = vi.fn()
|
||||
const mockRefetch = vi.fn()
|
||||
const mockOpenAsyncWindow = vi.fn()
|
||||
const mockFetchInstalledAppList = vi.fn()
|
||||
const mockFetchAppDetailDirect = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
|
||||
const sectionProps = vi.hoisted(() => ({
|
||||
summary: null as null | Record<string, any>,
|
||||
access: null as null | Record<string, any>,
|
||||
actions: null as null | Record<string, any>,
|
||||
}))
|
||||
const ahooksMocks = vi.hoisted(() => ({
|
||||
keyPressHandlers: [] as Array<(event: { preventDefault: () => void }) => void>,
|
||||
}))
|
||||
|
||||
let mockAppDetail: Record<string, any> | null = null
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', async () => {
|
||||
return {
|
||||
useKeyPress: (_keys: unknown, handler: (event: { preventDefault: () => void }) => void) => {
|
||||
ahooksMocks.keyPressHandlers.push(handler)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { appDetail: Record<string, any> | null, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({
|
||||
appDetail: mockAppDetail,
|
||||
setAppDetail: mockSetAppDetail,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: { enabled: boolean } } }) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
webapp_auth: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: () => 'moments ago',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => mockOpenAsyncWindow,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useGetUserCanAccessApp: () => ({
|
||||
data: { result: true },
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
useAppWhiteListSubjects: () => ({
|
||||
data: { groups: [], members: [] },
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/explore', () => ({
|
||||
fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/overview/embedded', () => ({
|
||||
default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => (isShow
|
||||
? (
|
||||
<div data-testid="embedded-modal">
|
||||
embedded modal
|
||||
<button onClick={onClose}>close-embedded-modal</button>
|
||||
</div>
|
||||
)
|
||||
: null),
|
||||
}))
|
||||
|
||||
vi.mock('../../app-access-control', () => ({
|
||||
default: ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
|
||||
<div data-testid="access-control">
|
||||
<button onClick={() => void onConfirm()}>confirm-access-control</button>
|
||||
<button onClick={onClose}>close-access-control</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
|
||||
const ReactModule = await vi.importActual<typeof import('react')>('react')
|
||||
const OpenContext = ReactModule.createContext(false)
|
||||
|
||||
return {
|
||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
|
||||
<OpenContext.Provider value={open}>
|
||||
<div>{children}</div>
|
||||
</OpenContext.Provider>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
|
||||
<div onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
|
||||
const open = ReactModule.useContext(OpenContext)
|
||||
return open ? <div>{children}</div> : null
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../sections', () => ({
|
||||
PublisherSummarySection: (props: Record<string, any>) => {
|
||||
sectionProps.summary = props
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => void props.handlePublish()}>publisher-summary-publish</button>
|
||||
<button onClick={() => void props.handleRestore()}>publisher-summary-restore</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
PublisherAccessSection: (props: Record<string, any>) => {
|
||||
sectionProps.access = props
|
||||
return <button onClick={props.onClick}>publisher-access-control</button>
|
||||
},
|
||||
PublisherActionsSection: (props: Record<string, any>) => {
|
||||
sectionProps.actions = props
|
||||
return (
|
||||
<div>
|
||||
<button onClick={props.handleEmbed}>publisher-embed</button>
|
||||
<button onClick={() => void props.handleOpenInExplore()}>publisher-open-in-explore</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('AppPublisher', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ahooksMocks.keyPressHandlers.length = 0
|
||||
sectionProps.summary = null
|
||||
sectionProps.access = null
|
||||
sectionProps.actions = null
|
||||
mockAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Demo App',
|
||||
mode: AppModeEnum.CHAT,
|
||||
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
site: {
|
||||
app_base_url: 'https://example.com',
|
||||
access_token: 'token-1',
|
||||
},
|
||||
}
|
||||
mockFetchInstalledAppList.mockResolvedValue({
|
||||
installed_apps: [{ id: 'installed-1' }],
|
||||
})
|
||||
mockFetchAppDetailDirect.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
access_mode: AccessMode.PUBLIC,
|
||||
})
|
||||
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
|
||||
await resolver()
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the publish popover and refetch access permission data', async () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
onToggle={mockOnToggle}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
|
||||
expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument()
|
||||
expect(mockOnToggle).toHaveBeenCalledWith(true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should publish and track the publish event', async () => {
|
||||
mockOnPublish.mockResolvedValue(undefined)
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
onPublish={mockOnPublish}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-summary-publish'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnPublish).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('app_published_time', expect.objectContaining({
|
||||
action_mode: 'app',
|
||||
app_id: 'app-1',
|
||||
app_name: 'Demo App',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the embedded modal from the actions section', () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-embed'))
|
||||
|
||||
expect(screen.getByTestId('embedded-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close embedded and access control panels through child callbacks', async () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-embed'))
|
||||
fireEvent.click(screen.getByText('close-embedded-modal'))
|
||||
expect(screen.queryByTestId('embedded-modal')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-access-control'))
|
||||
expect(screen.getByTestId('access-control')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('close-access-control'))
|
||||
expect(screen.queryByTestId('access-control')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should refresh app detail after access control confirmation', async () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-access-control'))
|
||||
|
||||
expect(screen.getByTestId('access-control')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('confirm-access-control'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
|
||||
expect(mockSetAppDetail).toHaveBeenCalledWith({
|
||||
id: 'app-1',
|
||||
access_mode: AccessMode.PUBLIC,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the installed explore page through the async window helper', async () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-open-in-explore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenAsyncWindow).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetchInstalledAppList).toHaveBeenCalledWith('app-1')
|
||||
expect(sectionProps.actions?.appURL).toBe(`https://example.com${basePath}/chat/token-1`)
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore the trigger when the publish button is disabled', () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
disabled
|
||||
publishedAt={Date.now()}
|
||||
onToggle={mockOnToggle}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish').parentElement?.parentElement as HTMLElement)
|
||||
|
||||
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
|
||||
expect(mockOnToggle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should publish from the keyboard shortcut and restore the popover state', async () => {
|
||||
const preventDefault = vi.fn()
|
||||
const onRestore = vi.fn().mockResolvedValue(undefined)
|
||||
mockOnPublish.mockResolvedValue(undefined)
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
onPublish={mockOnPublish}
|
||||
onRestore={onRestore}
|
||||
/>,
|
||||
)
|
||||
|
||||
ahooksMocks.keyPressHandlers[0]({ preventDefault })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(mockOnPublish).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-summary-restore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRestore).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the popover open when restore fails and reset published state after publish failures', async () => {
|
||||
const preventDefault = vi.fn()
|
||||
const onRestore = vi.fn().mockRejectedValue(new Error('restore failed'))
|
||||
mockOnPublish.mockRejectedValueOnce(new Error('publish failed'))
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
onPublish={mockOnPublish}
|
||||
onRestore={onRestore}
|
||||
/>,
|
||||
)
|
||||
|
||||
ahooksMocks.keyPressHandlers[0]({ preventDefault })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(mockOnPublish).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-summary-restore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRestore).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should report missing explore installations', async () => {
|
||||
mockFetchInstalledAppList.mockResolvedValueOnce({
|
||||
installed_apps: [],
|
||||
})
|
||||
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => {
|
||||
try {
|
||||
await resolver()
|
||||
}
|
||||
catch (error) {
|
||||
options.onError(error as Error)
|
||||
}
|
||||
})
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-open-in-explore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('No app found in Explore')
|
||||
})
|
||||
})
|
||||
|
||||
it('should report explore errors when the app cannot be opened', async () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
id: undefined,
|
||||
}
|
||||
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => {
|
||||
try {
|
||||
await resolver()
|
||||
}
|
||||
catch (error) {
|
||||
options.onError(error as Error)
|
||||
}
|
||||
})
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-open-in-explore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('App not found')
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep access control open when app detail is unavailable during confirmation', async () => {
|
||||
mockAppDetail = null
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-access-control'))
|
||||
fireEvent.click(screen.getByText('confirm-access-control'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAppDetailDirect).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(screen.getByTestId('access-control')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import PublishWithMultipleModel from '../publish-with-multiple-model'
|
||||
|
||||
const mockUseProviderContext = vi.fn()
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockUseProviderContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('../../header/account-setting/model-provider-page/model-icon', () => ({
|
||||
default: ({ modelName }: { modelName: string }) => <span data-testid="model-icon">{modelName}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
|
||||
const ReactModule = await vi.importActual<typeof import('react')>('react')
|
||||
const OpenContext = ReactModule.createContext(false)
|
||||
|
||||
return {
|
||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
|
||||
<OpenContext.Provider value={open}>
|
||||
<div data-testid="portal-root">{children}</div>
|
||||
</OpenContext.Provider>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode, onClick?: () => void, className?: string }) => (
|
||||
<div className={className} onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
|
||||
const open = ReactModule.useContext(OpenContext)
|
||||
return open ? <div className={className}>{children}</div> : null
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('PublishWithMultipleModel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
textGenerationModelList: [
|
||||
{
|
||||
provider: 'openai',
|
||||
models: [
|
||||
{
|
||||
model: 'gpt-4o',
|
||||
label: {
|
||||
en_US: 'GPT-4o',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable the trigger when no valid model configuration is available', () => {
|
||||
render(
|
||||
<PublishWithMultipleModel
|
||||
multipleModelConfigs={[
|
||||
{
|
||||
id: 'config-1',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-3',
|
||||
parameters: {},
|
||||
},
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'operation.applyConfig' })).toBeDisabled()
|
||||
expect(screen.queryByText('publishAs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open matching model options and call onSelect', () => {
|
||||
const handleSelect = vi.fn()
|
||||
const modelConfig = {
|
||||
id: 'config-1',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
parameters: { temperature: 0.7 },
|
||||
}
|
||||
|
||||
render(
|
||||
<PublishWithMultipleModel
|
||||
multipleModelConfigs={[modelConfig]}
|
||||
onSelect={handleSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'operation.applyConfig' }))
|
||||
|
||||
expect(screen.getByText('publishAs')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('GPT-4o'))
|
||||
|
||||
expect(handleSelect).toHaveBeenCalledWith(expect.objectContaining(modelConfig))
|
||||
})
|
||||
})
|
||||
266
web/app/components/app/app-publisher/__tests__/sections.spec.tsx
Normal file
266
web/app/components/app/app-publisher/__tests__/sections.spec.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AccessModeDisplay, PublisherAccessSection, PublisherActionsSection, PublisherSummarySection } from '../sections'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../publish-with-multiple-model', () => ({
|
||||
default: ({ onSelect }: { onSelect: (item: Record<string, unknown>) => void }) => (
|
||||
<button type="button" onClick={() => onSelect({ model: 'gpt-4o' })}>publish-multiple-model</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../suggested-action', () => ({
|
||||
default: ({ children, onClick, link, disabled }: { children: ReactNode, onClick?: () => void, link?: string, disabled?: boolean }) => (
|
||||
<button type="button" data-link={link} disabled={disabled} onClick={onClick}>{children}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({
|
||||
default: (props: Record<string, unknown>) => (
|
||||
<div>
|
||||
workflow-tool-configure
|
||||
<span>{String(props.disabledReason || '')}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('app-publisher sections', () => {
|
||||
it('should render restore controls for published chat apps', () => {
|
||||
const handleRestore = vi.fn()
|
||||
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={false}
|
||||
draftUpdatedAt={Date.now()}
|
||||
formatTimeFromNow={() => '3 minutes ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={handleRestore}
|
||||
isChatApp
|
||||
multipleModelConfigs={[]}
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={Date.now()}
|
||||
publishShortcut={['ctrl', '⇧', 'P']}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.restore'))
|
||||
expect(handleRestore).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should expose the access control warning when subjects are missing', () => {
|
||||
render(
|
||||
<PublisherAccessSection
|
||||
enabled
|
||||
isAppAccessSet={false}
|
||||
isLoading={false}
|
||||
accessMode={AccessMode.SPECIFIC_GROUPS_MEMBERS}
|
||||
onClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('publishApp.notSet')).toBeInTheDocument()
|
||||
expect(screen.getByText('publishApp.notSetDesc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the publish update action when the draft has not been published yet', () => {
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={false}
|
||||
draftUpdatedAt={Date.now()}
|
||||
formatTimeFromNow={() => '1 minute ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={vi.fn()}
|
||||
isChatApp={false}
|
||||
multipleModelConfigs={[]}
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
publishShortcut={['ctrl', '⇧', 'P']}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.publishUpdate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple-model publishing', () => {
|
||||
const handlePublish = vi.fn()
|
||||
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel
|
||||
draftUpdatedAt={Date.now()}
|
||||
formatTimeFromNow={() => '1 minute ago'}
|
||||
handlePublish={handlePublish}
|
||||
handleRestore={vi.fn()}
|
||||
isChatApp={false}
|
||||
multipleModelConfigs={[{ id: '1' } as any]}
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
publishShortcut={['ctrl', '⇧', 'P']}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('publish-multiple-model'))
|
||||
|
||||
expect(handlePublish).toHaveBeenCalledWith({ model: 'gpt-4o' })
|
||||
})
|
||||
|
||||
it('should render the upgrade hint when the start node limit is exceeded', () => {
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={false}
|
||||
draftUpdatedAt={Date.now()}
|
||||
formatTimeFromNow={() => '1 minute ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={vi.fn()}
|
||||
isChatApp={false}
|
||||
multipleModelConfigs={[]}
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
publishShortcut={['ctrl', '⇧', 'P']}
|
||||
startNodeLimitExceeded
|
||||
upgradeHighlightStyle={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('publishLimit.startNodeDesc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading access state and access mode labels when enabled', () => {
|
||||
const { rerender } = render(
|
||||
<PublisherAccessSection
|
||||
enabled
|
||||
isAppAccessSet
|
||||
isLoading
|
||||
accessMode={AccessMode.PUBLIC}
|
||||
onClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(document.querySelector('.spin-animation')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<PublisherAccessSection
|
||||
enabled
|
||||
isAppAccessSet
|
||||
isLoading={false}
|
||||
accessMode={AccessMode.PUBLIC}
|
||||
onClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('accessControlDialog.accessItems.anyone')).toBeInTheDocument()
|
||||
expect(render(<AccessModeDisplay />).container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render workflow actions, batch run links, and workflow tool configuration', () => {
|
||||
const handleOpenInExplore = vi.fn()
|
||||
const handleEmbed = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{
|
||||
id: 'workflow-app',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
icon: '⚙️',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#fff',
|
||||
name: 'Workflow App',
|
||||
description: 'Workflow description',
|
||||
}}
|
||||
appURL="https://example.com/app"
|
||||
disabledFunctionButton={false}
|
||||
disabledFunctionTooltip="disabled"
|
||||
handleEmbed={handleEmbed}
|
||||
handleOpenInExplore={handleOpenInExplore}
|
||||
handlePublish={vi.fn()}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
inputs={[]}
|
||||
missingStartNode={false}
|
||||
onRefreshData={vi.fn()}
|
||||
outputs={[]}
|
||||
published={true}
|
||||
publishedAt={Date.now()}
|
||||
toolPublished
|
||||
workflowToolAvailable={false}
|
||||
workflowToolMessage="workflow-disabled"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.batchRunApp')).toHaveAttribute('data-link', 'https://example.com/app?mode=batch')
|
||||
fireEvent.click(screen.getByText('common.openInExplore'))
|
||||
expect(handleOpenInExplore).toHaveBeenCalled()
|
||||
expect(screen.getByText('workflow-tool-configure')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow-disabled')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<PublisherActionsSection
|
||||
appDetail={{
|
||||
id: 'chat-app',
|
||||
mode: AppModeEnum.CHAT,
|
||||
name: 'Chat App',
|
||||
}}
|
||||
appURL="https://example.com/app?foo=bar"
|
||||
disabledFunctionButton
|
||||
disabledFunctionTooltip="disabled"
|
||||
handleEmbed={handleEmbed}
|
||||
handleOpenInExplore={handleOpenInExplore}
|
||||
handlePublish={vi.fn()}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
inputs={[]}
|
||||
missingStartNode
|
||||
onRefreshData={vi.fn()}
|
||||
outputs={[]}
|
||||
published={false}
|
||||
publishedAt={Date.now()}
|
||||
toolPublished={false}
|
||||
workflowToolAvailable
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.embedIntoSite'))
|
||||
expect(handleEmbed).toHaveBeenCalled()
|
||||
expect(screen.getByText('common.accessAPIReference')).toBeDisabled()
|
||||
|
||||
rerender(
|
||||
<PublisherActionsSection
|
||||
appDetail={{ id: 'trigger-app', mode: AppModeEnum.WORKFLOW }}
|
||||
appURL="https://example.com/app"
|
||||
disabledFunctionButton={false}
|
||||
handleEmbed={handleEmbed}
|
||||
handleOpenInExplore={handleOpenInExplore}
|
||||
handlePublish={vi.fn()}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode
|
||||
inputs={[]}
|
||||
missingStartNode={false}
|
||||
outputs={[]}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
toolPublished={false}
|
||||
workflowToolAvailable
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('common.runApp')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SuggestedAction from '../suggested-action'
|
||||
|
||||
describe('SuggestedAction', () => {
|
||||
it('should render an enabled external link', () => {
|
||||
render(
|
||||
<SuggestedAction link="https://example.com/docs">
|
||||
Open docs
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Open docs' })
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/docs')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
it('should block clicks when disabled', () => {
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction link="https://example.com/docs" disabled onClick={handleClick}>
|
||||
Disabled action
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
const link = screen.getByText('Disabled action').closest('a') as HTMLAnchorElement
|
||||
fireEvent.click(link)
|
||||
|
||||
expect(link).not.toHaveAttribute('href')
|
||||
expect(handleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should forward click events when enabled', () => {
|
||||
const handleClick = vi.fn((event: ReactMouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
render(
|
||||
<SuggestedAction link="https://example.com/docs" onClick={handleClick}>
|
||||
Enabled action
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Enabled action' }))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
70
web/app/components/app/app-publisher/__tests__/utils.spec.ts
Normal file
70
web/app/components/app/app-publisher/__tests__/utils.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import {
|
||||
getDisabledFunctionTooltip,
|
||||
getPublisherAppMode,
|
||||
getPublisherAppUrl,
|
||||
isPublisherAccessConfigured,
|
||||
} from '../utils'
|
||||
|
||||
describe('app-publisher utils', () => {
|
||||
describe('getPublisherAppMode', () => {
|
||||
it('should normalize chat-like apps to chat mode', () => {
|
||||
expect(getPublisherAppMode(AppModeEnum.AGENT_CHAT)).toBe(AppModeEnum.CHAT)
|
||||
})
|
||||
|
||||
it('should keep completion mode unchanged', () => {
|
||||
expect(getPublisherAppMode(AppModeEnum.COMPLETION)).toBe(AppModeEnum.COMPLETION)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPublisherAppUrl', () => {
|
||||
it('should build the published app url from site info', () => {
|
||||
expect(getPublisherAppUrl({
|
||||
appBaseUrl: 'https://example.com',
|
||||
accessToken: 'token-1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
})).toBe(`https://example.com${basePath}/chat/token-1`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPublisherAccessConfigured', () => {
|
||||
it('should require members or groups for specific access mode', () => {
|
||||
expect(isPublisherAccessConfigured(
|
||||
{ access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
{ groups: [], members: [] },
|
||||
)).toBe(false)
|
||||
})
|
||||
|
||||
it('should treat public access as configured', () => {
|
||||
expect(isPublisherAccessConfigured(
|
||||
{ access_mode: AccessMode.PUBLIC },
|
||||
{ groups: [], members: [] },
|
||||
)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDisabledFunctionTooltip', () => {
|
||||
const t = ((key: string) => key) as unknown as TFunction
|
||||
|
||||
it('should prioritize the unpublished hint', () => {
|
||||
expect(getDisabledFunctionTooltip({
|
||||
t,
|
||||
publishedAt: undefined,
|
||||
missingStartNode: false,
|
||||
noAccessPermission: false,
|
||||
})).toBe('notPublishedYet')
|
||||
})
|
||||
|
||||
it('should return the access error when the app is published but blocked', () => {
|
||||
expect(getDisabledFunctionTooltip({
|
||||
t,
|
||||
publishedAt: Date.now(),
|
||||
missingStartNode: false,
|
||||
noAccessPermission: true,
|
||||
})).toBe('noAccessPermission')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import VersionInfoModal from '../version-info-modal'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('VersionInfoModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should prefill the fields from the current version info', () => {
|
||||
render(
|
||||
<VersionInfoModal
|
||||
isOpen
|
||||
versionInfo={{
|
||||
id: 'version-1',
|
||||
marked_name: 'Release 1',
|
||||
marked_comment: 'Initial release',
|
||||
} as any}
|
||||
onClose={vi.fn()}
|
||||
onPublish={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('Release 1')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Initial release')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reject overlong titles', () => {
|
||||
const handlePublish = vi.fn()
|
||||
|
||||
render(
|
||||
<VersionInfoModal
|
||||
isOpen
|
||||
onClose={vi.fn()}
|
||||
onPublish={handlePublish}
|
||||
/>,
|
||||
)
|
||||
|
||||
const [titleInput] = screen.getAllByRole('textbox')
|
||||
fireEvent.change(titleInput, { target: { value: 'a'.repeat(16) } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('versionHistory.editField.titleLengthLimit')
|
||||
expect(handlePublish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should publish valid values and close the modal', () => {
|
||||
const handlePublish = vi.fn()
|
||||
const handleClose = vi.fn()
|
||||
|
||||
render(
|
||||
<VersionInfoModal
|
||||
isOpen
|
||||
versionInfo={{
|
||||
id: 'version-2',
|
||||
marked_name: 'Old title',
|
||||
marked_comment: 'Old notes',
|
||||
} as any}
|
||||
onClose={handleClose}
|
||||
onPublish={handlePublish}
|
||||
/>,
|
||||
)
|
||||
|
||||
const [titleInput, notesInput] = screen.getAllByRole('textbox')
|
||||
fireEvent.change(titleInput, { target: { value: 'Release 2' } })
|
||||
fireEvent.change(notesInput, { target: { value: 'Updated notes' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
|
||||
|
||||
expect(handlePublish).toHaveBeenCalledWith({
|
||||
title: 'Release 2',
|
||||
releaseNotes: 'Updated notes',
|
||||
id: 'version-2',
|
||||
})
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should validate release note length and clear previous errors before publishing', () => {
|
||||
const handlePublish = vi.fn()
|
||||
const handleClose = vi.fn()
|
||||
|
||||
render(
|
||||
<VersionInfoModal
|
||||
isOpen
|
||||
versionInfo={{
|
||||
id: 'version-3',
|
||||
marked_name: 'Old title',
|
||||
marked_comment: 'Old notes',
|
||||
} as any}
|
||||
onClose={handleClose}
|
||||
onPublish={handlePublish}
|
||||
/>,
|
||||
)
|
||||
|
||||
const [titleInput, notesInput] = screen.getAllByRole('textbox')
|
||||
|
||||
fireEvent.change(titleInput, { target: { value: 'a'.repeat(16) } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
|
||||
expect(toast.error).toHaveBeenCalledWith('versionHistory.editField.titleLengthLimit')
|
||||
|
||||
fireEvent.change(titleInput, { target: { value: 'Release 3' } })
|
||||
fireEvent.change(notesInput, { target: { value: 'b'.repeat(101) } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
|
||||
expect(toast.error).toHaveBeenCalledWith('versionHistory.editField.releaseNotesLengthLimit')
|
||||
|
||||
fireEvent.change(notesInput, { target: { value: 'Stable release notes' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
|
||||
|
||||
expect(handlePublish).toHaveBeenCalledWith({
|
||||
title: 'Release 3',
|
||||
releaseNotes: 'Stable release notes',
|
||||
id: 'version-3',
|
||||
})
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ModelAndParameter } from '../configuration/debug/types'
|
||||
import type { InputVar, Variable } from '@/app/components/workflow/types'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import {
|
||||
@@ -15,15 +14,11 @@ import EmbeddedModal from '@/app/components/app/overview/embedded'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
|
||||
import { appDefaultIconBackground } from '@/config'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
@@ -33,54 +28,19 @@ import { fetchAppDetailDirect } from '@/service/apps'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import Divider from '../../base/divider'
|
||||
import Loading from '../../base/loading'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
import { toast } from '../../base/ui/toast'
|
||||
import ShortcutsName from '../../workflow/shortcuts-name'
|
||||
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
|
||||
import AccessControl from '../app-access-control'
|
||||
import PublishWithMultipleModel from './publish-with-multiple-model'
|
||||
import SuggestedAction from './suggested-action'
|
||||
|
||||
type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'>
|
||||
|
||||
const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: string }> = {
|
||||
[AccessMode.ORGANIZATION]: {
|
||||
label: 'organization',
|
||||
icon: 'i-ri-building-line',
|
||||
},
|
||||
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: {
|
||||
label: 'specific',
|
||||
icon: 'i-ri-lock-line',
|
||||
},
|
||||
[AccessMode.PUBLIC]: {
|
||||
label: 'anyone',
|
||||
icon: 'i-ri-global-line',
|
||||
},
|
||||
[AccessMode.EXTERNAL_MEMBERS]: {
|
||||
label: 'external',
|
||||
icon: 'i-ri-verified-badge-line',
|
||||
},
|
||||
}
|
||||
|
||||
const AccessModeDisplay: React.FC<{ mode?: AccessMode }> = ({ mode }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!mode || !ACCESS_MODE_MAP[mode])
|
||||
return null
|
||||
|
||||
const { icon, label } = ACCESS_MODE_MAP[mode]
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={`${icon} h-4 w-4 shrink-0 text-text-secondary`} />
|
||||
<div className="grow truncate">
|
||||
<span className="text-text-secondary system-sm-medium">{t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
import {
|
||||
PublisherAccessSection,
|
||||
PublisherActionsSection,
|
||||
PublisherSummarySection,
|
||||
} from './sections'
|
||||
import {
|
||||
getDisabledFunctionTooltip,
|
||||
getPublisherAppUrl,
|
||||
isPublisherAccessConfigured,
|
||||
} from './utils'
|
||||
|
||||
export type AppPublisherProps = {
|
||||
disabled?: boolean
|
||||
@@ -143,32 +103,28 @@ const AppPublisher = ({
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
|
||||
|
||||
const appMode = (appDetail?.mode !== AppModeEnum.COMPLETION && appDetail?.mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : appDetail.mode
|
||||
const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}`
|
||||
const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode })
|
||||
const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT)
|
||||
|
||||
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)
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const isAppAccessSet = useMemo(() => {
|
||||
if (appDetail && appAccessSubjects) {
|
||||
return !(appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
|
||||
}
|
||||
return true
|
||||
}, [appAccessSubjects, appDetail])
|
||||
const isAppAccessSet = useMemo(() => isPublisherAccessConfigured(appDetail, appAccessSubjects), [appAccessSubjects, appDetail])
|
||||
|
||||
const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp])
|
||||
const noAccessPermission = useMemo(() => Boolean(
|
||||
systemFeatures.webapp_auth.enabled
|
||||
&& appDetail
|
||||
&& appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS
|
||||
&& !userCanAccessApp?.result,
|
||||
), [systemFeatures, appDetail, userCanAccessApp])
|
||||
const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission])
|
||||
|
||||
const disabledFunctionTooltip = useMemo(() => {
|
||||
if (!publishedAt)
|
||||
return t('notPublishedYet', { ns: 'app' })
|
||||
if (missingStartNode)
|
||||
return t('noUserInputNode', { ns: 'app' })
|
||||
if (noAccessPermission)
|
||||
return t('noAccessPermission', { ns: 'app' })
|
||||
}, [missingStartNode, noAccessPermission, publishedAt, t])
|
||||
const disabledFunctionTooltip = useMemo(() => getDisabledFunctionTooltip({
|
||||
t,
|
||||
publishedAt,
|
||||
missingStartNode,
|
||||
noAccessPermission,
|
||||
}), [missingStartNode, noAccessPermission, publishedAt, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (systemFeatures.webapp_auth.enabled && open && appDetail)
|
||||
@@ -244,9 +200,9 @@ const AppPublisher = ({
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
const hasPublishedVersion = !!publishedAt
|
||||
const workflowToolDisabled = !hasPublishedVersion || !workflowToolAvailable
|
||||
const workflowToolMessage = workflowToolDisabled ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) : undefined
|
||||
const showStartNodeLimitHint = Boolean(startNodeLimitExceeded)
|
||||
const workflowToolMessage = !hasPublishedVersion || !workflowToolAvailable
|
||||
? t('common.workflowAsToolDisabledHint', { ns: 'workflow' })
|
||||
: undefined
|
||||
const upgradeHighlightStyle = useMemo(() => ({
|
||||
background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
@@ -277,199 +233,51 @@ const AppPublisher = ({
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-11">
|
||||
<div className="w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
|
||||
<div className="p-4 pt-3">
|
||||
<div className="flex h-6 items-center text-text-tertiary system-xs-medium-uppercase">
|
||||
{publishedAt ? t('common.latestPublished', { ns: 'workflow' }) : t('common.currentDraftUnpublished', { ns: 'workflow' })}
|
||||
</div>
|
||||
{publishedAt
|
||||
? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center text-text-secondary system-sm-medium">
|
||||
{t('common.publishedAt', { ns: 'workflow' })}
|
||||
{' '}
|
||||
{formatTimeFromNow(publishedAt)}
|
||||
</div>
|
||||
{isChatApp && (
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
onClick={handleRestore}
|
||||
disabled={published}
|
||||
>
|
||||
{t('common.restore', { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center text-text-secondary system-sm-medium">
|
||||
{t('common.autoSaved', { ns: 'workflow' })}
|
||||
{' '}
|
||||
·
|
||||
{Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)}
|
||||
</div>
|
||||
)}
|
||||
{debugWithMultipleModel
|
||||
? (
|
||||
<PublishWithMultipleModel
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onSelect={item => handlePublish(item)}
|
||||
// textGenerationModelList={textGenerationModelList}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-3 w-full"
|
||||
onClick={() => handlePublish()}
|
||||
disabled={publishDisabled || published}
|
||||
>
|
||||
{
|
||||
published
|
||||
? t('common.published', { ns: 'workflow' })
|
||||
: (
|
||||
<div className="flex gap-1">
|
||||
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
|
||||
<ShortcutsName keys={PUBLISH_SHORTCUT} bgColor="white" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Button>
|
||||
{showStartNodeLimitHint && (
|
||||
<div className="mt-3 flex flex-col items-stretch">
|
||||
<p
|
||||
className="text-sm font-semibold leading-5 text-transparent"
|
||||
style={upgradeHighlightStyle}
|
||||
>
|
||||
<span className="block">{t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })}</span>
|
||||
<span className="block">{t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-4 text-text-secondary">
|
||||
{t('publishLimit.startNodeDesc', { ns: 'workflow' })}
|
||||
</p>
|
||||
<UpgradeBtn
|
||||
isShort
|
||||
className="mb-[12px] mt-[9px] h-[32px] w-[93px] self-start"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))
|
||||
? <div className="py-2"><Loading /></div>
|
||||
: (
|
||||
<>
|
||||
<Divider className="my-0" />
|
||||
{systemFeatures.webapp_auth.enabled && (
|
||||
<div className="p-4 pt-3">
|
||||
<div className="flex h-6 items-center">
|
||||
<p className="text-text-tertiary system-xs-medium">{t('publishApp.title', { ns: 'app' })}</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2 hover:bg-primary-50 hover:text-text-accent"
|
||||
onClick={() => {
|
||||
setShowAppAccessControl(true)
|
||||
}}
|
||||
>
|
||||
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
|
||||
<AccessModeDisplay mode={appDetail?.access_mode} />
|
||||
</div>
|
||||
{!isAppAccessSet && <p className="shrink-0 text-text-tertiary system-xs-regular">{t('publishApp.notSet', { ns: 'app' })}</p>}
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span className="i-ri-arrow-right-s-line h-4 w-4 text-text-quaternary" />
|
||||
</div>
|
||||
</div>
|
||||
{!isAppAccessSet && <p className="mt-1 text-text-warning system-xs-regular">{t('publishApp.notSetDesc', { ns: 'app' })}</p>}
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
// Hide run/batch run app buttons when there is a trigger node.
|
||||
!hasTriggerNode && (
|
||||
<div className="flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3">
|
||||
<Tooltip triggerClassName="flex" disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={disabledFunctionButton}
|
||||
link={appURL}
|
||||
icon={<span className="i-ri-play-circle-line h-4 w-4" />}
|
||||
>
|
||||
{t('common.runApp', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION
|
||||
? (
|
||||
<Tooltip triggerClassName="flex" disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={disabledFunctionButton}
|
||||
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
||||
icon={<span className="i-ri-play-list-2-line h-4 w-4" />}
|
||||
>
|
||||
{t('common.batchRunApp', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
)
|
||||
: (
|
||||
<SuggestedAction
|
||||
onClick={() => {
|
||||
setEmbeddingModalOpen(true)
|
||||
handleTrigger()
|
||||
}}
|
||||
disabled={!publishedAt}
|
||||
icon={<CodeBrowser className="h-4 w-4" />}
|
||||
>
|
||||
{t('common.embedIntoSite', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
)}
|
||||
<Tooltip triggerClassName="flex" disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
if (publishedAt)
|
||||
handleOpenInExplore()
|
||||
}}
|
||||
disabled={disabledFunctionButton}
|
||||
icon={<span className="i-ri-planet-line h-4 w-4" />}
|
||||
>
|
||||
{t('common.openInExplore', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
<Tooltip triggerClassName="flex" disabled={!!publishedAt && !missingStartNode} popupContent={!publishedAt ? t('notPublishedYet', { ns: 'app' }) : t('noUserInputNode', { ns: 'app' })} asChild={false}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={!publishedAt || missingStartNode}
|
||||
link="./develop"
|
||||
icon={<span className="i-ri-terminal-box-line h-4 w-4" />}
|
||||
>
|
||||
{t('common.accessAPIReference', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && (
|
||||
<WorkflowToolConfigureButton
|
||||
disabled={workflowToolDisabled}
|
||||
published={!!toolPublished}
|
||||
detailNeedUpdate={!!toolPublished && published}
|
||||
workflowAppId={appDetail?.id}
|
||||
icon={{
|
||||
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
|
||||
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
|
||||
}}
|
||||
name={appDetail?.name}
|
||||
description={appDetail?.description}
|
||||
inputs={inputs}
|
||||
outputs={outputs}
|
||||
handlePublish={handlePublish}
|
||||
onRefreshData={onRefreshData}
|
||||
disabledReason={workflowToolMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)}
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={debugWithMultipleModel}
|
||||
draftUpdatedAt={draftUpdatedAt}
|
||||
formatTimeFromNow={formatTimeFromNow}
|
||||
handlePublish={handlePublish}
|
||||
handleRestore={handleRestore}
|
||||
isChatApp={isChatApp}
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
publishDisabled={publishDisabled}
|
||||
published={published}
|
||||
publishedAt={publishedAt}
|
||||
publishShortcut={PUBLISH_SHORTCUT}
|
||||
startNodeLimitExceeded={startNodeLimitExceeded}
|
||||
upgradeHighlightStyle={upgradeHighlightStyle}
|
||||
/>
|
||||
<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
|
||||
|
||||
360
web/app/components/app/app-publisher/sections.tsx
Normal file
360
web/app/components/app/app-publisher/sections.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
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 { 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,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/app/components/base/ui/tooltip'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
|
||||
import { appDefaultIconBackground } from '@/config'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import ShortcutsName from '../../workflow/shortcuts-name'
|
||||
import PublishWithMultipleModel from './publish-with-multiple-model'
|
||||
import SuggestedAction from './suggested-action'
|
||||
import { ACCESS_MODE_MAP } from './utils'
|
||||
|
||||
type SummarySectionProps = Pick<AppPublisherProps, | 'debugWithMultipleModel'
|
||||
| 'draftUpdatedAt'
|
||||
| 'multipleModelConfigs'
|
||||
| 'publishDisabled'
|
||||
| 'publishedAt'
|
||||
| 'startNodeLimitExceeded'> & {
|
||||
formatTimeFromNow: (value: number) => string
|
||||
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
|
||||
handleRestore: () => Promise<void>
|
||||
isChatApp: boolean
|
||||
published: boolean
|
||||
publishShortcut: string[]
|
||||
upgradeHighlightStyle: CSSProperties
|
||||
}
|
||||
|
||||
type AccessSectionProps = {
|
||||
enabled: boolean
|
||||
isAppAccessSet: boolean
|
||||
isLoading: boolean
|
||||
accessMode?: keyof typeof ACCESS_MODE_MAP
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
type ActionsSectionProps = Pick<AppPublisherProps, | 'hasHumanInputNode'
|
||||
| 'hasTriggerNode'
|
||||
| 'inputs'
|
||||
| 'missingStartNode'
|
||||
| 'onRefreshData'
|
||||
| 'toolPublished'
|
||||
| 'outputs'
|
||||
| 'publishedAt'
|
||||
| 'workflowToolAvailable'> & {
|
||||
appDetail: {
|
||||
id?: string
|
||||
icon?: string
|
||||
icon_type?: string | null
|
||||
icon_background?: string | null
|
||||
description?: string
|
||||
mode?: AppModeEnum
|
||||
name?: string
|
||||
} | null | undefined
|
||||
appURL: string
|
||||
disabledFunctionButton: boolean
|
||||
disabledFunctionTooltip?: string
|
||||
handleEmbed: () => void
|
||||
handleOpenInExplore: () => void
|
||||
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
|
||||
published: boolean
|
||||
workflowToolMessage?: string
|
||||
}
|
||||
|
||||
export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!mode || !ACCESS_MODE_MAP[mode])
|
||||
return null
|
||||
|
||||
const { icon, label } = ACCESS_MODE_MAP[mode]
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={`${icon} h-4 w-4 shrink-0 text-text-secondary`} />
|
||||
<div className="grow truncate">
|
||||
<span className="system-sm-medium text-text-secondary">{t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const PublisherSummarySection = ({
|
||||
debugWithMultipleModel = false,
|
||||
draftUpdatedAt,
|
||||
formatTimeFromNow,
|
||||
handlePublish,
|
||||
handleRestore,
|
||||
isChatApp,
|
||||
multipleModelConfigs = [],
|
||||
publishDisabled = false,
|
||||
published,
|
||||
publishedAt,
|
||||
publishShortcut,
|
||||
startNodeLimitExceeded = false,
|
||||
upgradeHighlightStyle,
|
||||
}: SummarySectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="p-4 pt-3">
|
||||
<div className="flex h-6 items-center system-xs-medium-uppercase text-text-tertiary">
|
||||
{publishedAt ? t('common.latestPublished', { ns: 'workflow' }) : t('common.currentDraftUnpublished', { ns: 'workflow' })}
|
||||
</div>
|
||||
{publishedAt
|
||||
? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center system-sm-medium text-text-secondary">
|
||||
{t('common.publishedAt', { ns: 'workflow' })}
|
||||
{' '}
|
||||
{formatTimeFromNow(publishedAt)}
|
||||
</div>
|
||||
{isChatApp && (
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
onClick={handleRestore}
|
||||
disabled={published}
|
||||
>
|
||||
{t('common.restore', { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center system-sm-medium text-text-secondary">
|
||||
{t('common.autoSaved', { ns: 'workflow' })}
|
||||
{' '}
|
||||
·
|
||||
{Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)}
|
||||
</div>
|
||||
)}
|
||||
{debugWithMultipleModel
|
||||
? (
|
||||
<PublishWithMultipleModel
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onSelect={item => handlePublish(item)}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-3 w-full"
|
||||
onClick={() => handlePublish()}
|
||||
disabled={publishDisabled || published}
|
||||
>
|
||||
{published
|
||||
? t('common.published', { ns: 'workflow' })
|
||||
: (
|
||||
<div className="flex gap-1">
|
||||
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
|
||||
<ShortcutsName keys={publishShortcut} bgColor="white" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
{startNodeLimitExceeded && (
|
||||
<div className="mt-3 flex flex-col items-stretch">
|
||||
<p
|
||||
className="text-sm leading-5 font-semibold text-transparent"
|
||||
style={upgradeHighlightStyle}
|
||||
>
|
||||
<span className="block">{t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })}</span>
|
||||
<span className="block">{t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-4 text-text-secondary">
|
||||
{t('publishLimit.startNodeDesc', { ns: 'workflow' })}
|
||||
</p>
|
||||
<UpgradeBtn
|
||||
isShort
|
||||
className="mt-[9px] mb-[12px] h-[32px] w-[93px] self-start"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PublisherAccessSection = ({
|
||||
enabled,
|
||||
isAppAccessSet,
|
||||
isLoading,
|
||||
accessMode,
|
||||
onClick,
|
||||
}: AccessSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (isLoading)
|
||||
return <div className="py-2"><Loading /></div>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider className="my-0" />
|
||||
{enabled && (
|
||||
<div className="p-4 pt-3">
|
||||
<div className="flex h-6 items-center">
|
||||
<p className="system-xs-medium text-text-tertiary">{t('publishApp.title', { ns: 'app' })}</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pr-2 pl-2.5 hover:bg-primary-50 hover:text-text-accent"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
|
||||
<AccessModeDisplay mode={accessMode} />
|
||||
</div>
|
||||
{!isAppAccessSet && <p className="shrink-0 system-xs-regular text-text-tertiary">{t('publishApp.notSet', { ns: 'app' })}</p>}
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span className="i-ri-arrow-right-s-line h-4 w-4 text-text-quaternary" />
|
||||
</div>
|
||||
</div>
|
||||
{!isAppAccessSet && <p className="mt-1 system-xs-regular text-text-warning">{t('publishApp.notSetDesc', { ns: 'app' })}</p>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
disabledFunctionButton,
|
||||
disabledFunctionTooltip,
|
||||
handleEmbed,
|
||||
handleOpenInExplore,
|
||||
handlePublish,
|
||||
hasHumanInputNode = false,
|
||||
hasTriggerNode = false,
|
||||
inputs,
|
||||
missingStartNode = false,
|
||||
onRefreshData,
|
||||
outputs,
|
||||
published,
|
||||
publishedAt,
|
||||
toolPublished,
|
||||
workflowToolAvailable = true,
|
||||
workflowToolMessage,
|
||||
}: ActionsSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (hasTriggerNode)
|
||||
return null
|
||||
|
||||
const workflowToolDisabled = !publishedAt || !workflowToolAvailable
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3">
|
||||
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={disabledFunctionButton}
|
||||
link={appURL}
|
||||
icon={<span className="i-ri-play-circle-line h-4 w-4" />}
|
||||
>
|
||||
{t('common.runApp', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION
|
||||
? (
|
||||
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={disabledFunctionButton}
|
||||
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
||||
icon={<span className="i-ri-play-list-2-line h-4 w-4" />}
|
||||
>
|
||||
{t('common.batchRunApp', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
)
|
||||
: (
|
||||
<SuggestedAction
|
||||
onClick={handleEmbed}
|
||||
disabled={!publishedAt}
|
||||
icon={<CodeBrowser className="h-4 w-4" />}
|
||||
>
|
||||
{t('common.embedIntoSite', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
)}
|
||||
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
if (publishedAt)
|
||||
handleOpenInExplore()
|
||||
}}
|
||||
disabled={disabledFunctionButton}
|
||||
icon={<span className="i-ri-planet-line h-4 w-4" />}
|
||||
>
|
||||
{t('common.openInExplore', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
<ActionTooltip
|
||||
disabled={!publishedAt || missingStartNode}
|
||||
tooltip={!publishedAt ? t('notPublishedYet', { ns: 'app' }) : t('noUserInputNode', { ns: 'app' })}
|
||||
>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={!publishedAt || missingStartNode}
|
||||
link="./develop"
|
||||
icon={<span className="i-ri-terminal-box-line h-4 w-4" />}
|
||||
>
|
||||
{t('common.accessAPIReference', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && (
|
||||
<WorkflowToolConfigureButton
|
||||
disabled={workflowToolDisabled}
|
||||
published={!!toolPublished}
|
||||
detailNeedUpdate={!!toolPublished && published}
|
||||
workflowAppId={appDetail?.id ?? ''}
|
||||
icon={{
|
||||
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
|
||||
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
|
||||
}}
|
||||
name={appDetail?.name ?? ''}
|
||||
description={appDetail?.description ?? ''}
|
||||
inputs={inputs}
|
||||
outputs={outputs}
|
||||
handlePublish={handlePublish}
|
||||
onRefreshData={onRefreshData}
|
||||
disabledReason={workflowToolMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
web/app/components/app/app-publisher/utils.ts
Normal file
84
web/app/components/app/app-publisher/utils.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
type AccessSubjectsLike = {
|
||||
groups?: unknown[]
|
||||
members?: unknown[]
|
||||
} | null | undefined
|
||||
|
||||
type AppDetailLike = {
|
||||
access_mode?: AccessMode
|
||||
mode?: AppModeEnum
|
||||
}
|
||||
|
||||
type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'>
|
||||
|
||||
export const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: string }> = {
|
||||
[AccessMode.ORGANIZATION]: {
|
||||
label: 'organization',
|
||||
icon: 'i-ri-building-line',
|
||||
},
|
||||
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: {
|
||||
label: 'specific',
|
||||
icon: 'i-ri-lock-line',
|
||||
},
|
||||
[AccessMode.PUBLIC]: {
|
||||
label: 'anyone',
|
||||
icon: 'i-ri-global-line',
|
||||
},
|
||||
[AccessMode.EXTERNAL_MEMBERS]: {
|
||||
label: 'external',
|
||||
icon: 'i-ri-verified-badge-line',
|
||||
},
|
||||
}
|
||||
|
||||
export const getPublisherAppMode = (mode?: AppModeEnum) => {
|
||||
if (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW)
|
||||
return AppModeEnum.CHAT
|
||||
|
||||
return mode
|
||||
}
|
||||
|
||||
export const getPublisherAppUrl = ({
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
mode,
|
||||
}: {
|
||||
appBaseUrl: string
|
||||
accessToken: string
|
||||
mode?: AppModeEnum
|
||||
}) => `${appBaseUrl}${basePath}/${getPublisherAppMode(mode)}/${accessToken}`
|
||||
|
||||
export const isPublisherAccessConfigured = (appDetail: AppDetailLike | null | undefined, appAccessSubjects: AccessSubjectsLike) => {
|
||||
if (!appDetail || !appAccessSubjects)
|
||||
return true
|
||||
|
||||
if (appDetail.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
return true
|
||||
|
||||
return Boolean(appAccessSubjects.groups?.length || appAccessSubjects.members?.length)
|
||||
}
|
||||
|
||||
export const getDisabledFunctionTooltip = ({
|
||||
t,
|
||||
publishedAt,
|
||||
missingStartNode,
|
||||
noAccessPermission,
|
||||
}: {
|
||||
t: TFunction
|
||||
publishedAt?: number
|
||||
missingStartNode: boolean
|
||||
noAccessPermission: boolean
|
||||
}) => {
|
||||
if (!publishedAt)
|
||||
return t('notPublishedYet', { ns: 'app' })
|
||||
if (missingStartNode)
|
||||
return t('noUserInputNode', { ns: 'app' })
|
||||
if (noAccessPermission)
|
||||
return t('noAccessPermission', { ns: 'app' })
|
||||
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { ConfigurationViewModel } from '../hooks/use-configuration'
|
||||
import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
|
||||
import type ConfigContext from '@/context/debug-configuration'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { AppModeEnum, ModelModeType } from '@/types/app'
|
||||
import ConfigurationView from '../configuration-view'
|
||||
|
||||
vi.mock('@/app/components/app/app-publisher/features-wrapper', () => ({
|
||||
default: () => <div data-testid="app-publisher" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/config', () => ({
|
||||
default: () => <div data-testid="config-panel" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/debug', () => ({
|
||||
default: () => <div data-testid="debug-panel" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/config/agent-setting-button', () => ({
|
||||
default: () => <div data-testid="agent-setting-button" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
|
||||
default: () => <div data-testid="model-parameter-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/dataset-config/select-dataset', () => ({
|
||||
default: () => <div data-testid="select-dataset" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/config-prompt/conversation-history/edit-modal', () => ({
|
||||
default: () => <div data-testid="history-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/new-feature-panel', () => ({
|
||||
default: () => <div data-testid="feature-panel" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/plugin-dependency', () => ({
|
||||
default: () => <div data-testid="plugin-dependency" />,
|
||||
}))
|
||||
|
||||
const createContextValue = (): ComponentProps<typeof ConfigContext.Provider>['value'] => ({
|
||||
appId: 'app-1',
|
||||
isAPIKeySet: true,
|
||||
isTrailFinished: false,
|
||||
mode: AppModeEnum.CHAT,
|
||||
modelModeType: ModelModeType.chat,
|
||||
promptMode: 'simple' as never,
|
||||
setPromptMode: vi.fn(),
|
||||
isAdvancedMode: false,
|
||||
isAgent: false,
|
||||
isFunctionCall: false,
|
||||
isOpenAI: false,
|
||||
collectionList: [],
|
||||
canReturnToSimpleMode: false,
|
||||
setCanReturnToSimpleMode: vi.fn(),
|
||||
chatPromptConfig: { prompt: [] } as never,
|
||||
completionPromptConfig: {
|
||||
prompt: { text: '' },
|
||||
conversation_histories_role: {
|
||||
user_prefix: 'user',
|
||||
assistant_prefix: 'assistant',
|
||||
},
|
||||
} as never,
|
||||
currentAdvancedPrompt: [],
|
||||
setCurrentAdvancedPrompt: vi.fn(),
|
||||
showHistoryModal: vi.fn(),
|
||||
conversationHistoriesRole: {
|
||||
user_prefix: 'user',
|
||||
assistant_prefix: 'assistant',
|
||||
},
|
||||
setConversationHistoriesRole: vi.fn(),
|
||||
hasSetBlockStatus: {
|
||||
context: false,
|
||||
history: true,
|
||||
query: true,
|
||||
},
|
||||
conversationId: '',
|
||||
setConversationId: vi.fn(),
|
||||
introduction: '',
|
||||
setIntroduction: vi.fn(),
|
||||
suggestedQuestions: [],
|
||||
setSuggestedQuestions: vi.fn(),
|
||||
controlClearChatMessage: 0,
|
||||
setControlClearChatMessage: vi.fn(),
|
||||
prevPromptConfig: {
|
||||
prompt_template: '',
|
||||
prompt_variables: [],
|
||||
},
|
||||
setPrevPromptConfig: vi.fn(),
|
||||
moreLikeThisConfig: { enabled: false },
|
||||
setMoreLikeThisConfig: vi.fn(),
|
||||
suggestedQuestionsAfterAnswerConfig: { enabled: false },
|
||||
setSuggestedQuestionsAfterAnswerConfig: vi.fn(),
|
||||
speechToTextConfig: { enabled: false },
|
||||
setSpeechToTextConfig: vi.fn(),
|
||||
textToSpeechConfig: { enabled: false, voice: '', language: '' },
|
||||
setTextToSpeechConfig: vi.fn(),
|
||||
citationConfig: { enabled: false },
|
||||
setCitationConfig: vi.fn(),
|
||||
annotationConfig: {
|
||||
id: '',
|
||||
enabled: false,
|
||||
score_threshold: 0.5,
|
||||
embedding_model: {
|
||||
embedding_model_name: '',
|
||||
embedding_provider_name: '',
|
||||
},
|
||||
},
|
||||
setAnnotationConfig: vi.fn(),
|
||||
moderationConfig: { enabled: false },
|
||||
setModerationConfig: vi.fn(),
|
||||
externalDataToolsConfig: [],
|
||||
setExternalDataToolsConfig: vi.fn(),
|
||||
formattingChanged: false,
|
||||
setFormattingChanged: vi.fn(),
|
||||
inputs: {},
|
||||
setInputs: vi.fn(),
|
||||
query: '',
|
||||
setQuery: vi.fn(),
|
||||
completionParams: {},
|
||||
setCompletionParams: vi.fn(),
|
||||
modelConfig: {
|
||||
provider: 'openai',
|
||||
model_id: 'gpt-4o',
|
||||
mode: ModelModeType.chat,
|
||||
configs: {
|
||||
prompt_template: '',
|
||||
prompt_variables: [],
|
||||
},
|
||||
chat_prompt_config: null,
|
||||
completion_prompt_config: null,
|
||||
opening_statement: '',
|
||||
more_like_this: null,
|
||||
suggested_questions: [],
|
||||
suggested_questions_after_answer: null,
|
||||
speech_to_text: null,
|
||||
text_to_speech: null,
|
||||
file_upload: null,
|
||||
retriever_resource: null,
|
||||
sensitive_word_avoidance: null,
|
||||
annotation_reply: null,
|
||||
external_data_tools: [],
|
||||
system_parameters: {
|
||||
audio_file_size_limit: 1,
|
||||
file_size_limit: 1,
|
||||
image_file_size_limit: 1,
|
||||
video_file_size_limit: 1,
|
||||
workflow_file_upload_limit: 1,
|
||||
},
|
||||
dataSets: [],
|
||||
agentConfig: {
|
||||
enabled: false,
|
||||
strategy: 'react',
|
||||
max_iteration: 1,
|
||||
tools: [],
|
||||
},
|
||||
} as never,
|
||||
setModelConfig: vi.fn(),
|
||||
dataSets: [],
|
||||
setDataSets: vi.fn(),
|
||||
showSelectDataSet: vi.fn(),
|
||||
datasetConfigs: {
|
||||
retrieval_model: 'multiple',
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
datasets: { datasets: [] },
|
||||
} as never,
|
||||
datasetConfigsRef: { current: {} as never },
|
||||
setDatasetConfigs: vi.fn(),
|
||||
hasSetContextVar: false,
|
||||
isShowVisionConfig: false,
|
||||
visionConfig: {
|
||||
enabled: false,
|
||||
number_limits: 1,
|
||||
detail: 'low',
|
||||
transfer_methods: ['local_file'],
|
||||
} as never,
|
||||
setVisionConfig: vi.fn(),
|
||||
isAllowVideoUpload: false,
|
||||
isShowDocumentConfig: false,
|
||||
isShowAudioConfig: false,
|
||||
rerankSettingModalOpen: false,
|
||||
setRerankSettingModalOpen: vi.fn(),
|
||||
})
|
||||
|
||||
const createViewModel = (overrides: Partial<ConfigurationViewModel> = {}): ConfigurationViewModel => ({
|
||||
appPublisherProps: {
|
||||
publishDisabled: false,
|
||||
publishedAt: 0,
|
||||
debugWithMultipleModel: false,
|
||||
multipleModelConfigs: [],
|
||||
onPublish: vi.fn(),
|
||||
publishedConfig: {
|
||||
modelConfig: createContextValue().modelConfig,
|
||||
completionParams: {},
|
||||
},
|
||||
resetAppConfig: vi.fn(),
|
||||
} as ComponentProps<typeof AppPublisher>,
|
||||
contextValue: createContextValue(),
|
||||
featuresData: {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
|
||||
moderation: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
text2speech: { enabled: false, voice: '', language: '' },
|
||||
file: { enabled: false, image: { enabled: false, detail: 'high', number_limits: 3, transfer_methods: ['local_file'] } } as never,
|
||||
suggested: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
},
|
||||
isAgent: false,
|
||||
isAdvancedMode: false,
|
||||
isMobile: false,
|
||||
isShowDebugPanel: false,
|
||||
isShowHistoryModal: false,
|
||||
isShowSelectDataSet: false,
|
||||
modelConfig: createContextValue().modelConfig,
|
||||
multipleModelConfigs: [],
|
||||
onAutoAddPromptVariable: vi.fn(),
|
||||
onAgentSettingChange: vi.fn(),
|
||||
onCloseFeaturePanel: vi.fn(),
|
||||
onCloseHistoryModal: vi.fn(),
|
||||
onCloseSelectDataSet: vi.fn(),
|
||||
onCompletionParamsChange: vi.fn(),
|
||||
onConfirmUseGPT4: vi.fn(),
|
||||
onEnableMultipleModelDebug: vi.fn(),
|
||||
onFeaturesChange: vi.fn(),
|
||||
onHideDebugPanel: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onOpenAccountSettings: vi.fn(),
|
||||
onOpenDebugPanel: vi.fn(),
|
||||
onSaveHistory: vi.fn(),
|
||||
onSelectDataSets: vi.fn(),
|
||||
promptVariables: [],
|
||||
selectedIds: [],
|
||||
showAppConfigureFeaturesModal: false,
|
||||
showLoading: false,
|
||||
showUseGPT4Confirm: false,
|
||||
setShowUseGPT4Confirm: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ConfigurationView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render a loading state before configuration data is ready', () => {
|
||||
render(<ConfigurationView {...createViewModel({ showLoading: true })} />)
|
||||
|
||||
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('app-publisher')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open the mobile debug panel from the header button', () => {
|
||||
const onOpenDebugPanel = vi.fn()
|
||||
render(<ConfigurationView {...createViewModel({ isMobile: true, onOpenDebugPanel })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /appDebug.operation.debugConfig/i }))
|
||||
|
||||
expect(onOpenDebugPanel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should close the GPT-4 confirmation dialog when cancel is clicked', () => {
|
||||
const setShowUseGPT4Confirm = vi.fn()
|
||||
render(<ConfigurationView {...createViewModel({ showUseGPT4Confirm: true, setShowUseGPT4Confirm })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation.cancel/i }))
|
||||
|
||||
expect(setShowUseGPT4Confirm).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useConfiguration } from '../hooks/use-configuration'
|
||||
import Configuration from '../index'
|
||||
|
||||
const mockView = vi.fn((_: unknown) => <div data-testid="configuration-view" />)
|
||||
|
||||
vi.mock('../configuration-view', () => ({
|
||||
default: (props: unknown) => mockView(props),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-configuration', () => ({
|
||||
useConfiguration: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Configuration entry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should pass the hook view model into ConfigurationView', () => {
|
||||
const viewModel = {
|
||||
showLoading: true,
|
||||
}
|
||||
vi.mocked(useConfiguration).mockReturnValue(viewModel as never)
|
||||
|
||||
render(<Configuration />)
|
||||
|
||||
expect(useConfiguration).toHaveBeenCalledTimes(1)
|
||||
expect(mockView).toHaveBeenCalledWith(viewModel)
|
||||
})
|
||||
})
|
||||
226
web/app/components/app/configuration/__tests__/utils.spec.ts
Normal file
226
web/app/components/app/configuration/__tests__/utils.spec.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import type { ModelConfig } from '@/models/debug'
|
||||
import { AppModeEnum, ModelModeType, Resolution, TransferMethod } from '@/types/app'
|
||||
import { buildConfigurationFeaturesData, getConfigurationPublishingState, withCollectionIconBasePath } from '../utils'
|
||||
|
||||
const createModelConfig = (overrides: Partial<ModelConfig> = {}): ModelConfig => ({
|
||||
provider: 'openai',
|
||||
model_id: 'gpt-4o',
|
||||
mode: ModelModeType.chat,
|
||||
configs: {
|
||||
prompt_template: 'Hello',
|
||||
prompt_variables: [],
|
||||
},
|
||||
chat_prompt_config: {
|
||||
prompt: [],
|
||||
} as ModelConfig['chat_prompt_config'],
|
||||
completion_prompt_config: {
|
||||
prompt: { text: '' },
|
||||
conversation_histories_role: {
|
||||
user_prefix: 'user',
|
||||
assistant_prefix: 'assistant',
|
||||
},
|
||||
} as ModelConfig['completion_prompt_config'],
|
||||
opening_statement: '',
|
||||
more_like_this: { enabled: false },
|
||||
suggested_questions: [],
|
||||
suggested_questions_after_answer: { enabled: false },
|
||||
speech_to_text: { enabled: false },
|
||||
text_to_speech: { enabled: false, voice: '', language: '' },
|
||||
file_upload: null,
|
||||
retriever_resource: { enabled: false },
|
||||
sensitive_word_avoidance: { enabled: false },
|
||||
annotation_reply: null,
|
||||
external_data_tools: [],
|
||||
system_parameters: {
|
||||
audio_file_size_limit: 1,
|
||||
file_size_limit: 1,
|
||||
image_file_size_limit: 1,
|
||||
video_file_size_limit: 1,
|
||||
workflow_file_upload_limit: 1,
|
||||
},
|
||||
dataSets: [],
|
||||
agentConfig: {
|
||||
enabled: false,
|
||||
strategy: 'react',
|
||||
max_iteration: 1,
|
||||
tools: [],
|
||||
} as ModelConfig['agentConfig'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('configuration utils', () => {
|
||||
describe('withCollectionIconBasePath', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should prefix relative collection icons with the base path', () => {
|
||||
const result = withCollectionIconBasePath([
|
||||
{ id: 'tool-1', icon: '/icons/tool.svg' },
|
||||
{ id: 'tool-2', icon: '/console/icons/prefixed.svg' },
|
||||
] as never, '/console')
|
||||
|
||||
expect(result[0].icon).toBe('/console/icons/tool.svg')
|
||||
expect(result[1].icon).toBe('/console/icons/prefixed.svg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildConfigurationFeaturesData', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should derive feature toggles and upload fallbacks from model config', () => {
|
||||
const result = buildConfigurationFeaturesData(createModelConfig({
|
||||
opening_statement: 'Welcome',
|
||||
suggested_questions: ['How are you?'],
|
||||
file_upload: {
|
||||
enabled: true,
|
||||
image: {
|
||||
enabled: true,
|
||||
detail: Resolution.low,
|
||||
number_limits: 2,
|
||||
transfer_methods: [TransferMethod.local_file],
|
||||
},
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.png'],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
number_limits: 2,
|
||||
},
|
||||
}), undefined)
|
||||
|
||||
expect(result.opening).toEqual({
|
||||
enabled: true,
|
||||
opening_statement: 'Welcome',
|
||||
suggested_questions: ['How are you?'],
|
||||
})
|
||||
expect(result.file).toBeDefined()
|
||||
expect(result.file!.enabled).toBe(true)
|
||||
expect(result.file!.image!.detail).toBe(Resolution.low)
|
||||
expect(result.file!.allowed_file_extensions).toEqual(['.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getConfigurationPublishingState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should block publish when advanced completion mode is missing required blocks', () => {
|
||||
const result = getConfigurationPublishingState({
|
||||
chatPromptConfig: {
|
||||
prompt: [],
|
||||
} as never,
|
||||
completionPromptConfig: {
|
||||
prompt: { text: 'Answer' },
|
||||
conversation_histories_role: {
|
||||
user_prefix: 'user',
|
||||
assistant_prefix: 'assistant',
|
||||
},
|
||||
} as never,
|
||||
hasSetBlockStatus: {
|
||||
context: false,
|
||||
history: false,
|
||||
query: false,
|
||||
},
|
||||
hasSetContextVar: false,
|
||||
hasSelectedDataSets: false,
|
||||
isAdvancedMode: true,
|
||||
mode: AppModeEnum.CHAT,
|
||||
modelModeType: ModelModeType.completion,
|
||||
promptTemplate: 'ignored',
|
||||
})
|
||||
|
||||
expect(result.promptEmpty).toBe(false)
|
||||
expect(result.cannotPublish).toBe(true)
|
||||
})
|
||||
|
||||
it('should require a context variable only for completion apps with selected datasets', () => {
|
||||
const result = getConfigurationPublishingState({
|
||||
chatPromptConfig: {
|
||||
prompt: [],
|
||||
} as never,
|
||||
completionPromptConfig: {
|
||||
prompt: { text: 'Completion prompt' },
|
||||
conversation_histories_role: {
|
||||
user_prefix: 'user',
|
||||
assistant_prefix: 'assistant',
|
||||
},
|
||||
} as never,
|
||||
hasSetBlockStatus: {
|
||||
context: false,
|
||||
history: true,
|
||||
query: true,
|
||||
},
|
||||
hasSetContextVar: false,
|
||||
hasSelectedDataSets: true,
|
||||
isAdvancedMode: false,
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
modelModeType: ModelModeType.completion,
|
||||
promptTemplate: 'Prompt',
|
||||
})
|
||||
|
||||
expect(result.promptEmpty).toBe(false)
|
||||
expect(result.cannotPublish).toBe(false)
|
||||
expect(result.contextVarEmpty).toBe(true)
|
||||
})
|
||||
|
||||
it('should treat advanced completion chat prompts as empty when every segment is blank', () => {
|
||||
const result = getConfigurationPublishingState({
|
||||
chatPromptConfig: {
|
||||
prompt: [{ text: '' }, { text: '' }],
|
||||
} as never,
|
||||
completionPromptConfig: {
|
||||
prompt: { text: 'ignored' },
|
||||
conversation_histories_role: {
|
||||
user_prefix: 'user',
|
||||
assistant_prefix: 'assistant',
|
||||
},
|
||||
} as never,
|
||||
hasSetBlockStatus: {
|
||||
context: true,
|
||||
history: true,
|
||||
query: true,
|
||||
},
|
||||
hasSetContextVar: true,
|
||||
hasSelectedDataSets: false,
|
||||
isAdvancedMode: true,
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
modelModeType: ModelModeType.chat,
|
||||
promptTemplate: 'ignored',
|
||||
})
|
||||
|
||||
expect(result.promptEmpty).toBe(true)
|
||||
expect(result.cannotPublish).toBe(true)
|
||||
})
|
||||
|
||||
it('should treat advanced completion text prompts as empty when the completion prompt is missing', () => {
|
||||
const result = getConfigurationPublishingState({
|
||||
chatPromptConfig: {
|
||||
prompt: [{ text: 'ignored' }],
|
||||
} as never,
|
||||
completionPromptConfig: {
|
||||
prompt: { text: '' },
|
||||
conversation_histories_role: {
|
||||
user_prefix: 'user',
|
||||
assistant_prefix: 'assistant',
|
||||
},
|
||||
} as never,
|
||||
hasSetBlockStatus: {
|
||||
context: true,
|
||||
history: true,
|
||||
query: true,
|
||||
},
|
||||
hasSetContextVar: true,
|
||||
hasSelectedDataSets: false,
|
||||
isAdvancedMode: true,
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
modelModeType: ModelModeType.completion,
|
||||
promptTemplate: 'ignored',
|
||||
})
|
||||
|
||||
expect(result.promptEmpty).toBe(true)
|
||||
expect(result.cannotPublish).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import FeaturePanel from './index'
|
||||
import FeaturePanel from '../index'
|
||||
|
||||
describe('FeaturePanel', () => {
|
||||
// Rendering behavior for standard layout.
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import GroupName from './index'
|
||||
import GroupName from '../index'
|
||||
|
||||
describe('GroupName', () => {
|
||||
beforeEach(() => {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import OperationBtn from './index'
|
||||
import OperationBtn from '../index'
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiAddLine: (props: { className?: string }) => (
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import VarHighlight, { varHighlightHTML } from './index'
|
||||
import VarHighlight, { varHighlightHTML } from '../index'
|
||||
|
||||
describe('VarHighlight', () => {
|
||||
beforeEach(() => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import CannotQueryDataset from './cannot-query-dataset'
|
||||
import CannotQueryDataset from '../cannot-query-dataset'
|
||||
|
||||
describe('CannotQueryDataset WarningMask', () => {
|
||||
it('should render dataset warning copy and action button', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import FormattingChanged from './formatting-changed'
|
||||
import FormattingChanged from '../formatting-changed'
|
||||
|
||||
describe('FormattingChanged WarningMask', () => {
|
||||
it('should display translation text and both actions', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import HasNotSetAPI from './has-not-set-api'
|
||||
import HasNotSetAPI from '../has-not-set-api'
|
||||
|
||||
describe('HasNotSetAPI', () => {
|
||||
it('should render the empty state copy', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import WarningMask from './index'
|
||||
import WarningMask from '../index'
|
||||
|
||||
describe('WarningMask', () => {
|
||||
// Rendering of title, description, and footer content
|
||||
@@ -0,0 +1,228 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PromptRole } from '@/models/debug'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AdvancedPromptInput from '../advanced-prompt-input'
|
||||
|
||||
const mockEmit = vi.fn()
|
||||
const mockSetShowExternalDataToolModal = vi.fn()
|
||||
const mockSetModelConfig = vi.fn()
|
||||
const mockOnTypeChange = vi.fn()
|
||||
const mockOnChange = vi.fn()
|
||||
const mockOnDelete = vi.fn()
|
||||
const mockOnHideContextMissingTip = vi.fn()
|
||||
const mockCopy = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: (...args: unknown[]) => mockCopy(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@remixicon/react')>()
|
||||
return {
|
||||
...actual,
|
||||
RiDeleteBinLine: ({ onClick }: { onClick: () => void }) => (
|
||||
<button onClick={onClick}>delete-prompt</button>
|
||||
),
|
||||
RiErrorWarningFill: () => <span>warning-icon</span>,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/files', () => ({
|
||||
Copy: ({ onClick }: { onClick: () => void }) => (
|
||||
<button onClick={onClick}>copy-prompt</button>
|
||||
),
|
||||
CopyCheck: () => <span>copy-checked</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
emit: (...args: unknown[]) => mockEmit(...args),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowExternalDataToolModal: mockSetShowExternalDataToolModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../message-type-selector', () => ({
|
||||
default: ({ onChange, value }: { onChange: (value: PromptRole) => void, value: PromptRole }) => (
|
||||
<button onClick={() => onChange('assistant' as PromptRole)}>{`selector:${value}`}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor', () => ({
|
||||
default: (props: {
|
||||
onBlur: () => void
|
||||
onChange: (value: string) => void
|
||||
externalToolBlock: { onAddExternalTool: () => void }
|
||||
}) => (
|
||||
<div>
|
||||
<button onClick={() => props.onChange('Updated {{new_var}}')}>change-advanced</button>
|
||||
<button onClick={props.onBlur}>blur-advanced</button>
|
||||
<button onClick={props.externalToolBlock.onAddExternalTool}>open-advanced-tool-modal</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../prompt-editor-height-resize-wrap', () => ({
|
||||
default: ({ children, footer }: { children: ReactNode, footer: ReactNode }) => (
|
||||
<div>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createContextValue = () => ({
|
||||
mode: AppModeEnum.CHAT,
|
||||
hasSetBlockStatus: {
|
||||
context: false,
|
||||
history: false,
|
||||
query: false,
|
||||
},
|
||||
modelConfig: {
|
||||
configs: {
|
||||
prompt_variables: [
|
||||
{ key: 'existing_var', name: 'Existing', type: 'string', required: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
setModelConfig: mockSetModelConfig,
|
||||
conversationHistoriesRole: {
|
||||
user_prefix: 'user',
|
||||
assistant_prefix: 'assistant',
|
||||
},
|
||||
showHistoryModal: vi.fn(),
|
||||
dataSets: [],
|
||||
showSelectDataSet: vi.fn(),
|
||||
externalDataToolsConfig: [],
|
||||
}) as any
|
||||
|
||||
describe('AdvancedPromptInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should delegate prompt text and role changes to the parent callbacks', () => {
|
||||
render(
|
||||
<ConfigContext.Provider value={createContextValue()}>
|
||||
<AdvancedPromptInput
|
||||
type={'user' as PromptRole}
|
||||
isChatMode
|
||||
value="Hello"
|
||||
onChange={mockOnChange}
|
||||
onTypeChange={mockOnTypeChange}
|
||||
canDelete
|
||||
onDelete={mockOnDelete}
|
||||
promptVariables={[]}
|
||||
isContextMissing={false}
|
||||
onHideContextMissingTip={mockOnHideContextMissingTip}
|
||||
/>
|
||||
</ConfigContext.Provider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('change-advanced'))
|
||||
fireEvent.click(screen.getByText('selector:user'))
|
||||
fireEvent.click(screen.getByText('copy-prompt'))
|
||||
fireEvent.click(screen.getByText('delete-prompt'))
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('Updated {{new_var}}')
|
||||
expect(mockOnTypeChange).toHaveBeenCalledWith('assistant')
|
||||
expect(mockCopy).toHaveBeenCalledWith('Hello')
|
||||
expect(mockOnDelete).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should add newly discovered variables after blur confirmation', () => {
|
||||
render(
|
||||
<ConfigContext.Provider value={createContextValue()}>
|
||||
<AdvancedPromptInput
|
||||
type={'user' as PromptRole}
|
||||
isChatMode
|
||||
value="Hello {{new_var}}"
|
||||
onChange={mockOnChange}
|
||||
onTypeChange={mockOnTypeChange}
|
||||
canDelete={false}
|
||||
onDelete={mockOnDelete}
|
||||
promptVariables={[]}
|
||||
isContextMissing={false}
|
||||
onHideContextMissingTip={mockOnHideContextMissingTip}
|
||||
/>
|
||||
</ConfigContext.Provider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('blur-advanced'))
|
||||
fireEvent.click(screen.getByText('operation.add'))
|
||||
|
||||
expect(mockSetModelConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||
configs: expect.objectContaining({
|
||||
prompt_variables: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: 'new_var',
|
||||
name: 'new_var',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should open the external data tool modal and validate duplicates', () => {
|
||||
render(
|
||||
<ConfigContext.Provider value={createContextValue()}>
|
||||
<AdvancedPromptInput
|
||||
type={'user' as PromptRole}
|
||||
isChatMode
|
||||
value="Hello"
|
||||
onChange={mockOnChange}
|
||||
onTypeChange={mockOnTypeChange}
|
||||
canDelete={false}
|
||||
onDelete={mockOnDelete}
|
||||
promptVariables={[
|
||||
{ key: 'existing_var', name: 'Existing', type: 'string', required: true },
|
||||
]}
|
||||
isContextMissing={false}
|
||||
onHideContextMissingTip={mockOnHideContextMissingTip}
|
||||
/>
|
||||
</ConfigContext.Provider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('open-advanced-tool-modal'))
|
||||
|
||||
const modalConfig = mockSetShowExternalDataToolModal.mock.calls[0][0]
|
||||
expect(modalConfig.onValidateBeforeSaveCallback({ variable: 'existing_var' })).toBe(false)
|
||||
expect(mockToastError).toHaveBeenCalledWith('varKeyError.keyAlreadyExists')
|
||||
|
||||
modalConfig.onSaveCallback({
|
||||
label: 'Search',
|
||||
variable: 'search_api',
|
||||
})
|
||||
|
||||
expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'ADD_EXTERNAL_DATA_TOOL',
|
||||
}))
|
||||
expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payload: 'search_api',
|
||||
type: INSERT_VARIABLE_VALUE_BLOCK_COMMAND,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IPromptProps } from './index'
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { IPromptProps } from '../index'
|
||||
import type { PromptItem, PromptVariable } from '@/models/debug'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
@@ -6,7 +7,7 @@ import { MAX_PROMPT_MESSAGE_LENGTH } from '@/config'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { PromptRole } from '@/models/debug'
|
||||
import { AppModeEnum, ModelModeType } from '@/types/app'
|
||||
import Prompt from './index'
|
||||
import Prompt from '../index'
|
||||
|
||||
type DebugConfiguration = {
|
||||
isAdvancedMode: boolean
|
||||
@@ -30,7 +31,7 @@ const defaultPromptVariables: PromptVariable[] = [
|
||||
|
||||
let mockSimplePromptInputProps: IPromptProps | null = null
|
||||
|
||||
vi.mock('./simple-prompt-input', () => ({
|
||||
vi.mock('../simple-prompt-input', () => ({
|
||||
default: (props: IPromptProps) => {
|
||||
mockSimplePromptInputProps = props
|
||||
return (
|
||||
@@ -65,7 +66,7 @@ type AdvancedMessageInputProps = {
|
||||
noResize?: boolean
|
||||
}
|
||||
|
||||
vi.mock('./advanced-prompt-input', () => ({
|
||||
vi.mock('../advanced-prompt-input', () => ({
|
||||
default: (props: AdvancedMessageInputProps) => {
|
||||
return (
|
||||
<div
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { PromptRole } from '@/models/debug'
|
||||
import MessageTypeSelector from './message-type-selector'
|
||||
import MessageTypeSelector from '../message-type-selector'
|
||||
|
||||
describe('MessageTypeSelector', () => {
|
||||
beforeEach(() => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap'
|
||||
import PromptEditorHeightResizeWrap from '../prompt-editor-height-resize-wrap'
|
||||
|
||||
describe('PromptEditorHeightResizeWrap', () => {
|
||||
beforeEach(() => {
|
||||
@@ -0,0 +1,320 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import Prompt from '../simple-prompt-input'
|
||||
|
||||
const mockEmit = vi.fn()
|
||||
const mockSetFeatures = vi.fn()
|
||||
const mockSetShowExternalDataToolModal = vi.fn()
|
||||
const mockSetModelConfig = vi.fn()
|
||||
const mockSetPrevPromptConfig = vi.fn()
|
||||
const mockSetIntroduction = vi.fn()
|
||||
const mockOnChange = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
__esModule: true,
|
||||
default: () => 'desktop',
|
||||
MediaType: {
|
||||
mobile: 'mobile',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeaturesStore: () => ({
|
||||
getState: () => ({
|
||||
features: {
|
||||
opening: {
|
||||
enabled: false,
|
||||
opening_statement: '',
|
||||
},
|
||||
},
|
||||
setFeatures: mockSetFeatures,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
emit: (...args: unknown[]) => mockEmit(...args),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowExternalDataToolModal: mockSetShowExternalDataToolModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/config/automatic/automatic-btn', () => ({
|
||||
default: ({ onClick }: { onClick: () => void }) => <button onClick={onClick}>automatic-btn</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/config/automatic/get-automatic-res', () => ({
|
||||
default: ({ onFinished }: { onFinished: (value: Record<string, unknown>) => void }) => (
|
||||
<button onClick={() => onFinished({ modified: 'auto prompt', variables: ['city'], opening_statement: 'hello there' })}>
|
||||
finish-automatic
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor', () => ({
|
||||
default: (props: {
|
||||
onBlur: () => void
|
||||
onChange: (value: string) => void
|
||||
contextBlock: { datasets: Array<{ id: string, name: string, type: string }> }
|
||||
variableBlock: { variables: Array<{ name: string, value: string }> }
|
||||
queryBlock: { selectable: boolean }
|
||||
externalToolBlock: {
|
||||
onAddExternalTool: () => void
|
||||
externalTools: Array<{ name: string, variableName: string }>
|
||||
}
|
||||
}) => (
|
||||
<div>
|
||||
<div>{`datasets:${props.contextBlock.datasets.map(item => item.name).join(',')}`}</div>
|
||||
<div>{`variables:${props.variableBlock.variables.map(item => item.value).join(',')}`}</div>
|
||||
<div>{`external-tools:${props.externalToolBlock.externalTools.map(item => item.variableName).join(',')}`}</div>
|
||||
<div>{`query-selectable:${String(props.queryBlock.selectable)}`}</div>
|
||||
<button onClick={() => props.onChange('Hello {{new_var}}')}>change-prompt</button>
|
||||
<button onClick={props.onBlur}>blur-prompt</button>
|
||||
<button onClick={props.externalToolBlock.onAddExternalTool}>open-tool-modal</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../prompt-editor-height-resize-wrap', () => ({
|
||||
default: ({ children, footer }: { children: ReactNode, footer: ReactNode }) => (
|
||||
<div>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createContextValue = (overrides: Record<string, unknown> = {}) => ({
|
||||
appId: 'app-1',
|
||||
modelConfig: {
|
||||
configs: {
|
||||
prompt_template: 'Hello {{new_var}}',
|
||||
prompt_variables: [
|
||||
{ key: 'existing_var', name: 'Existing', type: 'string', required: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
dataSets: [],
|
||||
setModelConfig: mockSetModelConfig,
|
||||
setPrevPromptConfig: mockSetPrevPromptConfig,
|
||||
setIntroduction: mockSetIntroduction,
|
||||
hasSetBlockStatus: {
|
||||
context: false,
|
||||
history: false,
|
||||
query: false,
|
||||
},
|
||||
showSelectDataSet: vi.fn(),
|
||||
externalDataToolsConfig: [],
|
||||
...overrides,
|
||||
}) as any
|
||||
|
||||
describe('SimplePromptInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should prompt to add new variables discovered from the prompt template', () => {
|
||||
render(
|
||||
<ConfigContext.Provider value={createContextValue()}>
|
||||
<Prompt
|
||||
mode={AppModeEnum.CHAT}
|
||||
promptTemplate="Hello {{new_var}}"
|
||||
promptVariables={[]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
</ConfigContext.Provider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('blur-prompt'))
|
||||
|
||||
expect(screen.getByText('autoAddVar')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('operation.add'))
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('Hello {{new_var}}', [
|
||||
expect.objectContaining({
|
||||
key: 'new_var',
|
||||
name: 'new_var',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('should open the external data tool modal and emit insert events after save', () => {
|
||||
render(
|
||||
<ConfigContext.Provider value={createContextValue()}>
|
||||
<Prompt
|
||||
mode={AppModeEnum.CHAT}
|
||||
promptTemplate="Hello"
|
||||
promptVariables={[
|
||||
{ key: 'existing_var', name: 'Existing', type: 'string', required: true },
|
||||
]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
</ConfigContext.Provider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('open-tool-modal'))
|
||||
|
||||
expect(mockSetShowExternalDataToolModal).toHaveBeenCalledTimes(1)
|
||||
const modalConfig = mockSetShowExternalDataToolModal.mock.calls[0][0]
|
||||
|
||||
expect(modalConfig.onValidateBeforeSaveCallback({ variable: 'existing_var' })).toBe(false)
|
||||
expect(mockToastError).toHaveBeenCalledWith('varKeyError.keyAlreadyExists')
|
||||
expect(modalConfig.onValidateBeforeSaveCallback({ variable: 'fresh_var' })).toBe(true)
|
||||
|
||||
modalConfig.onSaveCallback(undefined)
|
||||
expect(mockEmit).not.toHaveBeenCalled()
|
||||
|
||||
modalConfig.onSaveCallback({
|
||||
label: 'Search',
|
||||
variable: 'search_api',
|
||||
})
|
||||
|
||||
expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'ADD_EXTERNAL_DATA_TOOL',
|
||||
}))
|
||||
expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payload: 'search_api',
|
||||
type: INSERT_VARIABLE_VALUE_BLOCK_COMMAND,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should apply automatic generation results to prompt and opening statement', () => {
|
||||
render(
|
||||
<ConfigContext.Provider value={createContextValue()}>
|
||||
<Prompt
|
||||
mode={AppModeEnum.CHAT}
|
||||
promptTemplate="Hello"
|
||||
promptVariables={[]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
</ConfigContext.Provider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('automatic-btn'))
|
||||
fireEvent.click(screen.getByText('finish-automatic'))
|
||||
|
||||
expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payload: 'auto prompt',
|
||||
type: 'PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER',
|
||||
}))
|
||||
expect(mockSetModelConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||
configs: expect.objectContaining({
|
||||
prompt_template: 'auto prompt',
|
||||
prompt_variables: [
|
||||
expect.objectContaining({ key: 'city', name: 'city' }),
|
||||
],
|
||||
}),
|
||||
}))
|
||||
expect(mockSetPrevPromptConfig).toHaveBeenCalled()
|
||||
expect(mockSetIntroduction).toHaveBeenCalledWith('hello there')
|
||||
expect(mockSetFeatures).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should expose dataset and external tool metadata to the editor', () => {
|
||||
render(
|
||||
<ConfigContext.Provider value={createContextValue({
|
||||
dataSets: [{ id: 'dataset-1', name: 'Knowledge Base', data_source_type: 'file' }],
|
||||
hasSetBlockStatus: {
|
||||
context: false,
|
||||
history: false,
|
||||
query: true,
|
||||
},
|
||||
modelConfig: {
|
||||
configs: {
|
||||
prompt_template: 'Hello {{existing_var}}',
|
||||
prompt_variables: [
|
||||
{ key: 'existing_var', name: 'Existing', type: 'string', required: true },
|
||||
{ key: 'search_api', name: 'Search API', type: 'api', required: false, icon: 'search', icon_background: '#fff' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Prompt
|
||||
mode={AppModeEnum.CHAT}
|
||||
promptTemplate="Hello {{existing_var}}"
|
||||
promptVariables={[
|
||||
{ key: 'existing_var', name: 'Existing', type: 'string', required: true },
|
||||
{ key: 'search_api', name: 'Search API', type: 'api', required: false },
|
||||
]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
</ConfigContext.Provider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('datasets:Knowledge Base')).toBeInTheDocument()
|
||||
expect(screen.getByText('variables:existing_var')).toBeInTheDocument()
|
||||
expect(screen.getByText('external-tools:search_api')).toBeInTheDocument()
|
||||
expect(screen.getByText('query-selectable:false')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should skip external tool variables and incomplete prompt variables when deciding whether to auto add', () => {
|
||||
render(
|
||||
<ConfigContext.Provider value={createContextValue({
|
||||
externalDataToolsConfig: [{ variable: 'search_api' }],
|
||||
})}
|
||||
>
|
||||
<Prompt
|
||||
mode={AppModeEnum.CHAT}
|
||||
promptTemplate="Hello {{search_api}} {{existing_var}}"
|
||||
promptVariables={[
|
||||
{ key: 'existing_var', name: 'Existing', type: 'string', required: true },
|
||||
]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
</ConfigContext.Provider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('change-prompt'))
|
||||
expect(mockOnChange).toHaveBeenCalledWith('Hello {{new_var}}', [])
|
||||
|
||||
fireEvent.click(screen.getByText('blur-prompt'))
|
||||
expect(mockOnChange).toHaveBeenLastCalledWith('Hello {{search_api}} {{existing_var}}', [])
|
||||
})
|
||||
|
||||
it('should keep invalid prompt variables in the confirmation flow', () => {
|
||||
render(
|
||||
<ConfigContext.Provider value={createContextValue()}>
|
||||
<Prompt
|
||||
mode={AppModeEnum.CHAT}
|
||||
promptTemplate="Hello {{existing_var}}"
|
||||
promptVariables={[
|
||||
{ key: 'existing_var', name: '', type: 'string', required: true },
|
||||
]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
</ConfigContext.Provider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('blur-prompt'))
|
||||
expect(screen.getByText('autoAddVar')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('operation.cancel'))
|
||||
expect(mockOnChange).toHaveBeenCalledWith('Hello {{existing_var}}', [])
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import ConfirmAddVar from './index'
|
||||
import ConfirmAddVar from '../index'
|
||||
|
||||
vi.mock('../../base/var-highlight', () => ({
|
||||
vi.mock('../../../base/var-highlight', () => ({
|
||||
default: ({ name }: { name: string }) => <span data-testid="var-highlight">{name}</span>,
|
||||
}))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ConversationHistoriesRole } from '@/models/debug'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import EditModal from './edit-modal'
|
||||
import EditModal from '../edit-modal'
|
||||
|
||||
vi.mock('@/app/components/base/modal', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import HistoryPanel from './history-panel'
|
||||
import HistoryPanel from '../history-panel'
|
||||
|
||||
vi.mock('@/app/components/app/configuration/base/operation-btn', () => ({
|
||||
default: ({ onClick }: { onClick: () => void }) => (
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IConfigVarProps } from './index'
|
||||
import type { IConfigVarProps } from '../index'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
@@ -9,7 +9,7 @@ import { toast } from '@/app/components/base/ui/toast'
|
||||
import DebugConfigurationContext from '@/context/debug-configuration'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from './index'
|
||||
import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from '../index'
|
||||
|
||||
const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
|
||||
|
||||
@@ -393,5 +393,94 @@ describe('ConfigVar', () => {
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('should update an api variable with the modal save callback', () => {
|
||||
const onPromptVariablesChange = vi.fn()
|
||||
const apiVar = createPromptVariable({
|
||||
key: 'api_var',
|
||||
name: 'API Var',
|
||||
type: 'api',
|
||||
})
|
||||
|
||||
renderConfigVar({
|
||||
promptVariables: [apiVar],
|
||||
onPromptVariablesChange,
|
||||
})
|
||||
|
||||
const item = screen.getByTitle('api_var · API Var')
|
||||
const itemContainer = item.closest('div.group')
|
||||
expect(itemContainer).not.toBeNull()
|
||||
|
||||
const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6')
|
||||
fireEvent.click(actionButtons[0])
|
||||
|
||||
const modalState = setShowExternalDataToolModal.mock.calls.at(-1)?.[0]
|
||||
|
||||
act(() => {
|
||||
modalState.onSaveCallback?.({
|
||||
variable: 'next_api_var',
|
||||
label: 'Next API Var',
|
||||
enabled: true,
|
||||
type: 'api',
|
||||
config: { endpoint: '/search' },
|
||||
icon: 'tool-icon',
|
||||
icon_background: '#fff',
|
||||
})
|
||||
})
|
||||
|
||||
expect(onPromptVariablesChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
key: 'next_api_var',
|
||||
name: 'Next API Var',
|
||||
type: 'api',
|
||||
icon: 'tool-icon',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('should ignore empty external tool saves and reject duplicate variable names during validation', () => {
|
||||
const onPromptVariablesChange = vi.fn()
|
||||
const firstVar = createPromptVariable({
|
||||
key: 'api_var',
|
||||
name: 'API Var',
|
||||
type: 'api',
|
||||
})
|
||||
const secondVar = createPromptVariable({
|
||||
key: 'existing_var',
|
||||
name: 'Existing Var',
|
||||
type: 'string',
|
||||
})
|
||||
|
||||
renderConfigVar({
|
||||
promptVariables: [firstVar, secondVar],
|
||||
onPromptVariablesChange,
|
||||
})
|
||||
|
||||
const item = screen.getByTitle('api_var · API Var')
|
||||
const itemContainer = item.closest('div.group')
|
||||
expect(itemContainer).not.toBeNull()
|
||||
|
||||
const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6')
|
||||
fireEvent.click(actionButtons[0])
|
||||
|
||||
const modalState = setShowExternalDataToolModal.mock.calls.at(-1)?.[0]
|
||||
|
||||
act(() => {
|
||||
modalState.onSaveCallback?.(undefined)
|
||||
})
|
||||
|
||||
expect(onPromptVariablesChange).not.toHaveBeenCalled()
|
||||
|
||||
const isValid = modalState.onValidateBeforeSaveCallback?.({
|
||||
variable: 'existing_var',
|
||||
label: 'Duplicated',
|
||||
enabled: true,
|
||||
type: 'api',
|
||||
config: {},
|
||||
})
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import ConfigModalFormFields from '../form-fields'
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileUploaderInAttachmentWrapper: ({ onChange }: { onChange: (files: Array<Record<string, unknown>>) => void }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([
|
||||
{ fileId: 'file-1', type: 'local_file', url: 'https://example.com/file.png' },
|
||||
{ fileId: 'file-2', type: 'remote_url', url: 'https://example.com/file-2.png' },
|
||||
])}
|
||||
>
|
||||
upload-file
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/file-upload-setting', () => ({
|
||||
default: ({ onChange, isMultiple }: { onChange: (payload: Record<string, unknown>) => void, isMultiple: boolean }) => (
|
||||
<button type="button" onClick={() => onChange({ number_limits: isMultiple ? 3 : 1 })}>
|
||||
{isMultiple ? 'multi-file-setting' : 'single-file-setting'}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ onChange }: { onChange: (value: string) => void }) => (
|
||||
<button type="button" onClick={() => onChange('{\n "type": "object"\n}')}>json-editor</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/checkbox', () => ({
|
||||
default: ({ onCheck, checked }: { onCheck: () => void, checked: boolean }) => (
|
||||
<button type="button" onClick={onCheck}>{checked ? 'checked' : 'unchecked'}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/select', () => ({
|
||||
default: ({ onSelect }: { onSelect: (item: { value: string }) => void }) => (
|
||||
<button type="button" onClick={() => onSelect({ value: 'beta' })}>legacy-select</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/select', () => ({
|
||||
Select: ({ value, onValueChange, children }: { value: string, onValueChange: (value: string) => void, children: ReactNode }) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onValueChange(value === 'true' ? 'false' : 'beta')}>{`ui-select:${value}`}</button>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectValue: () => <span>select-value</span>,
|
||||
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../field', () => ({
|
||||
default: ({ children, title }: { children: ReactNode, title: string }) => (
|
||||
<div>
|
||||
<span>{title}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../type-select', () => ({
|
||||
default: ({ onSelect }: { onSelect: (item: { value: InputVarType }) => void }) => (
|
||||
<button type="button" onClick={() => onSelect({ value: InputVarType.select })}>type-selector</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../config-select', () => ({
|
||||
default: ({ onChange }: { onChange: (value: string[]) => void }) => (
|
||||
<button type="button" onClick={() => onChange(['alpha', 'beta'])}>config-select</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../config-string', () => ({
|
||||
default: ({ onChange }: { onChange: (value: number) => void }) => (
|
||||
<button type="button" onClick={() => onChange(64)}>config-string</button>
|
||||
),
|
||||
}))
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const createPayloadChangeHandler = () => vi.fn<(value: unknown) => void>()
|
||||
|
||||
const createBaseProps = () => {
|
||||
const payloadChangeHandlers: Record<string, ReturnType<typeof createPayloadChangeHandler>> = {
|
||||
default: createPayloadChangeHandler(),
|
||||
hide: createPayloadChangeHandler(),
|
||||
label: createPayloadChangeHandler(),
|
||||
max_length: createPayloadChangeHandler(),
|
||||
options: createPayloadChangeHandler(),
|
||||
required: createPayloadChangeHandler(),
|
||||
}
|
||||
|
||||
return {
|
||||
checkboxDefaultSelectValue: 'false',
|
||||
isStringInput: false,
|
||||
jsonSchemaStr: '',
|
||||
maxLength: 32,
|
||||
modelId: 'gpt-4o',
|
||||
onFilePayloadChange: vi.fn(),
|
||||
onJSONSchemaChange: vi.fn(),
|
||||
onPayloadChange: (key: string) => {
|
||||
if (!payloadChangeHandlers[key])
|
||||
payloadChangeHandlers[key] = createPayloadChangeHandler()
|
||||
return payloadChangeHandlers[key]
|
||||
},
|
||||
onTypeChange: vi.fn(),
|
||||
onVarKeyBlur: vi.fn(),
|
||||
onVarNameChange: vi.fn(),
|
||||
options: undefined as string[] | undefined,
|
||||
selectOptions: [],
|
||||
tempPayload: {
|
||||
type: InputVarType.textInput,
|
||||
label: 'Question',
|
||||
variable: 'question',
|
||||
required: false,
|
||||
hide: false,
|
||||
} as any,
|
||||
t,
|
||||
payloadChangeHandlers,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConfigModalFormFields', () => {
|
||||
it('should update paragraph, number, checkbox, and select defaults', () => {
|
||||
const paragraphProps = createBaseProps()
|
||||
paragraphProps.tempPayload = { ...paragraphProps.tempPayload, type: InputVarType.paragraph, default: 'hello' }
|
||||
render(<ConfigModalFormFields {...paragraphProps} />)
|
||||
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated paragraph' } })
|
||||
expect(paragraphProps.payloadChangeHandlers.default).toHaveBeenCalledWith('updated paragraph')
|
||||
|
||||
const numberProps = createBaseProps()
|
||||
numberProps.tempPayload = { ...numberProps.tempPayload, type: InputVarType.number, default: '1' }
|
||||
render(<ConfigModalFormFields {...numberProps} />)
|
||||
fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '2' } })
|
||||
expect(numberProps.payloadChangeHandlers.default).toHaveBeenCalledWith('2')
|
||||
|
||||
const checkboxProps = createBaseProps()
|
||||
checkboxProps.tempPayload = { ...checkboxProps.tempPayload, type: InputVarType.checkbox, default: false }
|
||||
checkboxProps.checkboxDefaultSelectValue = 'true'
|
||||
render(<ConfigModalFormFields {...checkboxProps} />)
|
||||
fireEvent.click(screen.getByText('ui-select:true'))
|
||||
expect(checkboxProps.payloadChangeHandlers.default).toHaveBeenCalledWith(false)
|
||||
|
||||
const selectProps = createBaseProps()
|
||||
selectProps.tempPayload = { ...selectProps.tempPayload, type: InputVarType.select, default: 'alpha' }
|
||||
selectProps.options = ['alpha', 'beta']
|
||||
render(<ConfigModalFormFields {...selectProps} />)
|
||||
fireEvent.click(screen.getByText('config-select'))
|
||||
fireEvent.click(screen.getByText('ui-select:alpha'))
|
||||
expect(selectProps.payloadChangeHandlers.options).toHaveBeenCalledWith(['alpha', 'beta'])
|
||||
expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith('beta')
|
||||
})
|
||||
|
||||
it('should wire file, json schema, and visibility controls', () => {
|
||||
const singleFileProps = createBaseProps()
|
||||
singleFileProps.tempPayload = {
|
||||
...singleFileProps.tempPayload,
|
||||
type: InputVarType.singleFile,
|
||||
allowed_file_types: ['document'],
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_upload_methods: ['remote_url'],
|
||||
}
|
||||
render(<ConfigModalFormFields {...singleFileProps} />)
|
||||
fireEvent.click(screen.getByText('single-file-setting'))
|
||||
fireEvent.click(screen.getByText('upload-file'))
|
||||
fireEvent.click(screen.getAllByText('unchecked')[0])
|
||||
fireEvent.click(screen.getAllByText('unchecked')[1])
|
||||
|
||||
expect(singleFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 1 })
|
||||
expect(singleFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith(expect.objectContaining({
|
||||
fileId: 'file-1',
|
||||
}))
|
||||
expect(singleFileProps.payloadChangeHandlers.required).toHaveBeenCalledWith(true)
|
||||
expect(singleFileProps.payloadChangeHandlers.hide).toHaveBeenCalledWith(true)
|
||||
|
||||
const multiFileProps = createBaseProps()
|
||||
multiFileProps.tempPayload = {
|
||||
...multiFileProps.tempPayload,
|
||||
type: InputVarType.multiFiles,
|
||||
allowed_file_types: ['document'],
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_upload_methods: ['remote_url'],
|
||||
}
|
||||
render(<ConfigModalFormFields {...multiFileProps} />)
|
||||
fireEvent.click(screen.getByText('multi-file-setting'))
|
||||
fireEvent.click(screen.getAllByText('upload-file')[1])
|
||||
expect(multiFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 3 })
|
||||
expect(multiFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ fileId: 'file-1' }),
|
||||
expect.objectContaining({ fileId: 'file-2' }),
|
||||
])
|
||||
|
||||
const jsonProps = createBaseProps()
|
||||
jsonProps.tempPayload = { ...jsonProps.tempPayload, type: InputVarType.jsonObject }
|
||||
render(<ConfigModalFormFields {...jsonProps} />)
|
||||
fireEvent.click(screen.getByText('json-editor'))
|
||||
expect(jsonProps.onJSONSchemaChange).toHaveBeenCalledWith('{\n "type": "object"\n}')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useStore } from '@/app/components/app/store'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import DebugConfigurationContext from '@/context/debug-configuration'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import ConfigModal from '../index'
|
||||
|
||||
const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
|
||||
let latestFormProps: Record<string, any> | null = null
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../form-fields', () => ({
|
||||
default: (props: Record<string, any>) => {
|
||||
latestFormProps = props
|
||||
return (
|
||||
<div data-testid="config-form-fields">
|
||||
<div data-testid="payload-type">{String(props.tempPayload.type)}</div>
|
||||
<div data-testid="payload-label">{String(props.tempPayload.label ?? '')}</div>
|
||||
<div data-testid="payload-schema">{String(props.tempPayload.json_schema ?? '')}</div>
|
||||
<div data-testid="payload-default">{String(props.tempPayload.default ?? '')}</div>
|
||||
<button data-testid="invalid-key-blur" onClick={() => props.onVarKeyBlur({ target: { value: 'invalid key' } })}>invalid-key-blur</button>
|
||||
<button data-testid="valid-key-blur" onClick={() => props.onVarKeyBlur({ target: { value: 'auto_label' } })}>valid-key-blur</button>
|
||||
<button
|
||||
data-testid="invalid-name-change"
|
||||
onClick={() => props.onVarNameChange({
|
||||
target: {
|
||||
value: 'invalid-key!',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0,
|
||||
setSelectionRange: vi.fn(),
|
||||
},
|
||||
})}
|
||||
>
|
||||
invalid-name-change
|
||||
</button>
|
||||
<button data-testid="valid-json-change" onClick={() => props.onJSONSchemaChange('{\n \"foo\": \"bar\"\n}')}>valid-json-change</button>
|
||||
<button data-testid="empty-json-change" onClick={() => props.onJSONSchemaChange(' ')}>empty-json-change</button>
|
||||
<button data-testid="invalid-json-change" onClick={() => props.onJSONSchemaChange('{invalid-json}')}>invalid-json-change</button>
|
||||
<button data-testid="type-change" onClick={() => props.onTypeChange({ value: InputVarType.singleFile })}>type-change</button>
|
||||
<button data-testid="file-payload-change" onClick={() => props.onFilePayloadChange({ ...props.tempPayload, default: 'file-default' })}>file-payload-change</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const createPayload = (overrides: Partial<InputVar> = {}): InputVar => ({
|
||||
type: InputVarType.textInput,
|
||||
label: '',
|
||||
variable: 'question',
|
||||
required: false,
|
||||
hide: false,
|
||||
options: [],
|
||||
default: 'hello',
|
||||
max_length: 32,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderConfigModal = (payload: InputVar = createPayload()) => render(
|
||||
<DebugConfigurationContext.Provider value={{
|
||||
mode: AppModeEnum.CHAT,
|
||||
dataSets: [],
|
||||
modelConfig: { model_id: 'model-1' },
|
||||
} as any}
|
||||
>
|
||||
<ConfigModal
|
||||
isCreate
|
||||
isShow
|
||||
payload={payload}
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
</DebugConfigurationContext.Provider>,
|
||||
)
|
||||
|
||||
describe('ConfigModal logic', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestFormProps = null
|
||||
useStore.setState({
|
||||
appDetail: {
|
||||
mode: AppModeEnum.CHAT,
|
||||
} as App & Partial<AppSSO>,
|
||||
})
|
||||
})
|
||||
|
||||
it('should surface validation errors from invalid variable name callbacks', async () => {
|
||||
renderConfigModal()
|
||||
|
||||
fireEvent.click(screen.getByTestId('invalid-key-blur'))
|
||||
fireEvent.click(screen.getByTestId('invalid-name-change'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep the existing label when blur runs on a payload that already has one', async () => {
|
||||
renderConfigModal(createPayload({ label: 'Existing label' }))
|
||||
|
||||
fireEvent.click(screen.getByTestId('valid-key-blur'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('payload-label')).toHaveTextContent('Existing label')
|
||||
})
|
||||
})
|
||||
|
||||
it('should derive payload fields from mocked form-field callbacks', async () => {
|
||||
renderConfigModal()
|
||||
|
||||
fireEvent.click(screen.getByTestId('valid-key-blur'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('payload-label')).toHaveTextContent('auto_label')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('valid-json-change'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('payload-schema')).toHaveTextContent(/"foo": "bar"/)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('invalid-json-change'))
|
||||
expect(screen.getByTestId('payload-schema')).toHaveTextContent(/"foo": "bar"/)
|
||||
|
||||
fireEvent.click(screen.getByTestId('empty-json-change'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('payload-schema')).toHaveTextContent('')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('type-change'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('payload-type')).toHaveTextContent(InputVarType.singleFile)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('file-payload-change'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('payload-default')).toHaveTextContent('file-default')
|
||||
})
|
||||
|
||||
expect(latestFormProps?.modelId).toBe('model-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useStore } from '@/app/components/app/store'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import ConfigModal from '../index'
|
||||
|
||||
const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
|
||||
|
||||
const createPayload = (overrides: Partial<InputVar> = {}): InputVar => ({
|
||||
type: InputVarType.textInput,
|
||||
label: '',
|
||||
variable: 'question',
|
||||
required: false,
|
||||
hide: false,
|
||||
options: [],
|
||||
default: 'hello',
|
||||
max_length: 32,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ConfigModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useStore.setState({
|
||||
appDetail: {
|
||||
mode: AppModeEnum.CHAT,
|
||||
} as App & Partial<AppSSO>,
|
||||
})
|
||||
})
|
||||
|
||||
it('should copy the variable name into the label when the label is empty', () => {
|
||||
render(
|
||||
<ConfigModal
|
||||
isCreate
|
||||
isShow
|
||||
payload={createPayload()}
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textboxes = screen.getAllByRole('textbox')
|
||||
fireEvent.blur(textboxes[0], { target: { value: 'question' } })
|
||||
|
||||
expect(textboxes[1]).toHaveValue('question')
|
||||
})
|
||||
|
||||
it('should submit the edited payload when the form is valid', () => {
|
||||
const onConfirm = vi.fn()
|
||||
render(
|
||||
<ConfigModal
|
||||
isCreate
|
||||
isShow
|
||||
payload={createPayload({ label: 'Question' })}
|
||||
onClose={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated default' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
||||
default: 'updated default',
|
||||
label: 'Question',
|
||||
variable: 'question',
|
||||
}), undefined)
|
||||
})
|
||||
|
||||
it('should block save when the label is missing', () => {
|
||||
render(
|
||||
<ConfigModal
|
||||
isCreate
|
||||
isShow
|
||||
payload={createPayload({ label: '' })}
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(toastErrorSpy).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.labelNameRequired')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import TypeSelector from '../type-select'
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
|
||||
<button type="button" onClick={onClick}>{children}</button>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/input-var-type-icon', () => ({
|
||||
default: ({ type }: { type: string }) => <span>{type}</span>,
|
||||
}))
|
||||
|
||||
describe('TypeSelector', () => {
|
||||
it('should toggle open state and select a new variable type', () => {
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<TypeSelector
|
||||
value="text-input"
|
||||
onSelect={onSelect}
|
||||
items={[
|
||||
{ value: 'text-input' as any, name: 'Text' },
|
||||
{ value: 'number' as any, name: 'Number' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getByText('Number'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith({ value: 'number', name: 'Number' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants'
|
||||
import { ChangeType, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import {
|
||||
buildSelectOptions,
|
||||
createPayloadForType,
|
||||
getCheckboxDefaultSelectValue,
|
||||
getJsonSchemaEditorValue,
|
||||
isJsonSchemaEmpty,
|
||||
isStringInputType,
|
||||
normalizeSelectDefaultValue,
|
||||
parseCheckboxSelectValue,
|
||||
updatePayloadField,
|
||||
validateConfigModalPayload,
|
||||
} from '../utils'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
|
||||
type: InputVarType.textInput,
|
||||
label: 'Question',
|
||||
variable: 'question',
|
||||
required: false,
|
||||
options: [],
|
||||
hide: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('config-modal utils', () => {
|
||||
describe('payload helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should clear the default value when options no longer include it', () => {
|
||||
const payload = createInputVar({
|
||||
type: InputVarType.select,
|
||||
default: 'beta',
|
||||
options: ['alpha', 'beta'],
|
||||
})
|
||||
|
||||
const nextPayload = updatePayloadField(payload, 'options', ['alpha'])
|
||||
|
||||
expect(nextPayload.default).toBeUndefined()
|
||||
expect(nextPayload.options).toEqual(['alpha'])
|
||||
})
|
||||
|
||||
it('should seed upload defaults when switching to multi-file input', () => {
|
||||
const payload = createInputVar({
|
||||
type: InputVarType.textInput,
|
||||
default: 'hello',
|
||||
})
|
||||
|
||||
const nextPayload = createPayloadForType(payload, InputVarType.multiFiles)
|
||||
|
||||
expect(nextPayload.type).toBe(InputVarType.multiFiles)
|
||||
expect(nextPayload.max_length).toBe(DEFAULT_FILE_UPLOAD_SETTING.max_length)
|
||||
expect(nextPayload.allowed_file_types).toEqual(DEFAULT_FILE_UPLOAD_SETTING.allowed_file_types)
|
||||
expect(nextPayload.default).toBe('hello')
|
||||
})
|
||||
|
||||
it('should clear the default value when switching to a select input type', () => {
|
||||
const payload = createInputVar({
|
||||
type: InputVarType.textInput,
|
||||
default: 'hello',
|
||||
})
|
||||
|
||||
const nextPayload = createPayloadForType(payload, InputVarType.select)
|
||||
|
||||
expect(nextPayload.type).toBe(InputVarType.select)
|
||||
expect(nextPayload.default).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should normalize empty select defaults to undefined', () => {
|
||||
const nextPayload = normalizeSelectDefaultValue(createInputVar({
|
||||
type: InputVarType.select,
|
||||
default: '',
|
||||
}))
|
||||
|
||||
expect(nextPayload.default).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should parse checkbox default values and normalize json schema editor content', () => {
|
||||
expect(parseCheckboxSelectValue('true')).toBe(true)
|
||||
expect(parseCheckboxSelectValue('false')).toBe(false)
|
||||
expect(getJsonSchemaEditorValue(InputVarType.jsonObject, { type: 'object' } as never)).toBe(JSON.stringify({ type: 'object' }, null, 2))
|
||||
expect(getJsonSchemaEditorValue(InputVarType.textInput, '{"type":"object"}')).toBe('')
|
||||
expect(getJsonSchemaEditorValue(InputVarType.jsonObject, '{"type":"object"}')).toBe('{"type":"object"}')
|
||||
})
|
||||
|
||||
it('should fall back to an empty editor value when json schema serialization fails', () => {
|
||||
const circular: Record<string, unknown> = {}
|
||||
circular.self = circular
|
||||
|
||||
expect(getJsonSchemaEditorValue(InputVarType.jsonObject, circular as never)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('derived values', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should expose upload and json options only when supported', () => {
|
||||
const options = buildSelectOptions({
|
||||
isBasicApp: false,
|
||||
supportFile: true,
|
||||
t,
|
||||
})
|
||||
|
||||
expect(options.map(option => option.value)).toEqual(expect.arrayContaining([
|
||||
InputVarType.singleFile,
|
||||
InputVarType.multiFiles,
|
||||
InputVarType.jsonObject,
|
||||
]))
|
||||
})
|
||||
|
||||
it('should derive checkbox defaults from boolean and string values', () => {
|
||||
expect(getCheckboxDefaultSelectValue(true)).toBe('true')
|
||||
expect(getCheckboxDefaultSelectValue('TRUE')).toBe('true')
|
||||
expect(getCheckboxDefaultSelectValue(undefined)).toBe('false')
|
||||
})
|
||||
|
||||
it('should detect blank json schema values', () => {
|
||||
expect(isJsonSchemaEmpty(undefined)).toBe(true)
|
||||
expect(isJsonSchemaEmpty(' ')).toBe(true)
|
||||
expect(isJsonSchemaEmpty('{}')).toBe(false)
|
||||
expect(isJsonSchemaEmpty({ type: 'object' } as never)).toBe(false)
|
||||
expect(isStringInputType(InputVarType.textInput)).toBe(true)
|
||||
expect(isStringInputType(InputVarType.paragraph)).toBe(true)
|
||||
expect(isStringInputType(InputVarType.number)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should reject duplicate select options', () => {
|
||||
const checkVariableName = vi.fn(() => true)
|
||||
|
||||
const result = validateConfigModalPayload({
|
||||
tempPayload: createInputVar({
|
||||
type: InputVarType.select,
|
||||
options: ['alpha', 'alpha'],
|
||||
}),
|
||||
checkVariableName,
|
||||
payload: createInputVar({
|
||||
variable: 'question',
|
||||
}),
|
||||
t,
|
||||
})
|
||||
|
||||
expect(result.errorMessage).toBe('variableConfig.errorMsg.optionRepeat')
|
||||
expect(checkVariableName).toHaveBeenCalledWith('question')
|
||||
})
|
||||
|
||||
it('should require custom extensions when custom file types are enabled', () => {
|
||||
const result = validateConfigModalPayload({
|
||||
tempPayload: createInputVar({
|
||||
type: InputVarType.singleFile,
|
||||
allowed_file_types: [SupportUploadFileTypes.custom],
|
||||
allowed_file_extensions: [],
|
||||
}),
|
||||
checkVariableName: () => true,
|
||||
payload: createInputVar(),
|
||||
t,
|
||||
})
|
||||
|
||||
expect(result.errorMessage).toBe('errorMsg.fieldRequired')
|
||||
})
|
||||
|
||||
it('should require at least one select option and supported file types', () => {
|
||||
const selectResult = validateConfigModalPayload({
|
||||
tempPayload: createInputVar({
|
||||
type: InputVarType.select,
|
||||
options: [],
|
||||
}),
|
||||
checkVariableName: () => true,
|
||||
payload: createInputVar(),
|
||||
t,
|
||||
})
|
||||
|
||||
const fileResult = validateConfigModalPayload({
|
||||
tempPayload: createInputVar({
|
||||
type: InputVarType.singleFile,
|
||||
allowed_file_types: [],
|
||||
}),
|
||||
checkVariableName: () => true,
|
||||
payload: createInputVar(),
|
||||
t,
|
||||
})
|
||||
|
||||
expect(selectResult.errorMessage).toBe('variableConfig.errorMsg.atLeastOneOption')
|
||||
expect(fileResult.errorMessage).toBe('errorMsg.fieldRequired')
|
||||
})
|
||||
|
||||
it('should reject invalid json schema definitions', () => {
|
||||
const invalidResult = validateConfigModalPayload({
|
||||
tempPayload: createInputVar({
|
||||
type: InputVarType.jsonObject,
|
||||
json_schema: '{',
|
||||
}),
|
||||
payload: createInputVar(),
|
||||
checkVariableName: () => true,
|
||||
t,
|
||||
})
|
||||
|
||||
const nonObjectResult = validateConfigModalPayload({
|
||||
tempPayload: createInputVar({
|
||||
type: InputVarType.jsonObject,
|
||||
json_schema: JSON.stringify({ type: 'string' }),
|
||||
}),
|
||||
payload: createInputVar(),
|
||||
checkVariableName: () => true,
|
||||
t,
|
||||
})
|
||||
|
||||
expect(invalidResult.errorMessage).toBe('variableConfig.errorMsg.jsonSchemaInvalid')
|
||||
expect(nonObjectResult.errorMessage).toBe('variableConfig.errorMsg.jsonSchemaMustBeObject')
|
||||
})
|
||||
|
||||
it('should normalize blank json schema and return rename metadata', () => {
|
||||
const result = validateConfigModalPayload({
|
||||
tempPayload: createInputVar({
|
||||
type: InputVarType.jsonObject,
|
||||
variable: 'question_new',
|
||||
json_schema: ' ',
|
||||
}),
|
||||
payload: createInputVar({
|
||||
variable: 'question_old',
|
||||
}),
|
||||
checkVariableName: () => true,
|
||||
t,
|
||||
})
|
||||
|
||||
expect(result.errorMessage).toBeUndefined()
|
||||
expect(result.payloadToSave).toEqual(expect.objectContaining({
|
||||
json_schema: undefined,
|
||||
variable: 'question_new',
|
||||
}))
|
||||
expect(result.moreInfo).toEqual({
|
||||
type: ChangeType.changeVarName,
|
||||
payload: {
|
||||
beforeKey: 'question_old',
|
||||
afterKey: 'question_new',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should stop validation when the variable name checker rejects the payload', () => {
|
||||
const result = validateConfigModalPayload({
|
||||
tempPayload: createInputVar({
|
||||
variable: 'invalid_name',
|
||||
}),
|
||||
payload: createInputVar({
|
||||
variable: 'question',
|
||||
}),
|
||||
checkVariableName: () => false,
|
||||
t,
|
||||
})
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
'use client'
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import type { Item as SelectOptionItem } from './type-select'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { InputVar, UploadFileSetting } from '@/app/components/workflow/types'
|
||||
import * as React from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/app/components/base/ui/select'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import ConfigSelect from '../config-select'
|
||||
import ConfigString from '../config-string'
|
||||
import { jsonConfigPlaceHolder } from './config'
|
||||
import Field from './field'
|
||||
import TypeSelector from './type-select'
|
||||
import { CHECKBOX_DEFAULT_FALSE_VALUE, CHECKBOX_DEFAULT_TRUE_VALUE, TEXT_MAX_LENGTH } from './utils'
|
||||
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string
|
||||
const EMPTY_SELECT_VALUE = '__empty__'
|
||||
|
||||
type ConfigModalFormFieldsProps = {
|
||||
checkboxDefaultSelectValue: string
|
||||
isStringInput: boolean
|
||||
jsonSchemaStr: string
|
||||
maxLength?: number
|
||||
modelId: string
|
||||
onFilePayloadChange: (payload: UploadFileSetting) => void
|
||||
onJSONSchemaChange: (value: string) => void
|
||||
onPayloadChange: (key: string) => (value: unknown) => void
|
||||
onTypeChange: (item: SelectOptionItem) => void
|
||||
onVarKeyBlur: (event: ChangeEvent<HTMLInputElement>) => void
|
||||
onVarNameChange: (event: ChangeEvent<HTMLInputElement>) => void
|
||||
options?: string[]
|
||||
selectOptions: SelectOptionItem[]
|
||||
tempPayload: InputVar
|
||||
t: Translate
|
||||
}
|
||||
|
||||
const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
checkboxDefaultSelectValue,
|
||||
isStringInput,
|
||||
jsonSchemaStr,
|
||||
maxLength,
|
||||
modelId,
|
||||
onFilePayloadChange,
|
||||
onJSONSchemaChange,
|
||||
onPayloadChange,
|
||||
onTypeChange,
|
||||
onVarKeyBlur,
|
||||
onVarNameChange,
|
||||
options,
|
||||
selectOptions,
|
||||
tempPayload,
|
||||
t,
|
||||
}) => {
|
||||
const { type, label, variable } = tempPayload
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Field title={t('variableConfig.fieldType', { ns: 'appDebug' })}>
|
||||
<TypeSelector value={type} items={selectOptions} onSelect={onTypeChange} />
|
||||
</Field>
|
||||
|
||||
<Field title={t('variableConfig.varName', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
value={variable}
|
||||
onChange={onVarNameChange}
|
||||
onBlur={onVarKeyBlur}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
|
||||
/>
|
||||
</Field>
|
||||
<Field title={t('variableConfig.labelName', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
value={label as string}
|
||||
onChange={e => onPayloadChange('label')(e.target.value)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{isStringInput && (
|
||||
<Field title={t('variableConfig.maxLength', { ns: 'appDebug' })}>
|
||||
<ConfigString
|
||||
maxLength={type === InputVarType.textInput ? TEXT_MAX_LENGTH : Infinity}
|
||||
modelId={modelId}
|
||||
value={maxLength}
|
||||
onChange={onPayloadChange('max_length')}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === InputVarType.textInput && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
value={tempPayload.default || ''}
|
||||
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === InputVarType.paragraph && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Textarea
|
||||
value={String(tempPayload.default ?? '')}
|
||||
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === InputVarType.number && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
type="number"
|
||||
value={tempPayload.default || ''}
|
||||
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === InputVarType.checkbox && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Select value={checkboxDefaultSelectValue} onValueChange={value => onPayloadChange('default')(value === CHECKBOX_DEFAULT_TRUE_VALUE)}>
|
||||
<SelectTrigger size="large" className="w-full">
|
||||
<SelectValue placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
|
||||
<SelectItem value={CHECKBOX_DEFAULT_TRUE_VALUE}>{t('variableConfig.startChecked', { ns: 'appDebug' })}</SelectItem>
|
||||
<SelectItem value={CHECKBOX_DEFAULT_FALSE_VALUE}>{t('variableConfig.noDefaultSelected', { ns: 'appDebug' })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === InputVarType.select && (
|
||||
<>
|
||||
<Field title={t('variableConfig.options', { ns: 'appDebug' })}>
|
||||
<ConfigSelect options={options || []} onChange={onPayloadChange('options')} />
|
||||
</Field>
|
||||
{options && options.length > 0 && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Select
|
||||
key={`default-select-${options.join('-')}`}
|
||||
value={tempPayload.default ? String(tempPayload.default) : EMPTY_SELECT_VALUE}
|
||||
onValueChange={value => onPayloadChange('default')(value === EMPTY_SELECT_VALUE ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger size="large" className="w-full">
|
||||
<SelectValue placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
|
||||
<SelectItem value={EMPTY_SELECT_VALUE}>{t('variableConfig.noDefaultValue', { ns: 'appDebug' })}</SelectItem>
|
||||
{options.filter(option => option.trim() !== '').map(option => (
|
||||
<SelectItem key={option} value={option}>{option}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{[InputVarType.singleFile, InputVarType.multiFiles].includes(type) && (
|
||||
<>
|
||||
<FileUploadSetting
|
||||
payload={tempPayload as UploadFileSetting}
|
||||
onChange={onFilePayloadChange}
|
||||
isMultiple={type === InputVarType.multiFiles}
|
||||
/>
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={(type === InputVarType.singleFile ? (tempPayload.default ? [tempPayload.default] : []) : (tempPayload.default || [])) as unknown as FileEntity[]}
|
||||
onChange={(files) => {
|
||||
if (type === InputVarType.singleFile)
|
||||
onPayloadChange('default')(files?.[0] || undefined)
|
||||
else
|
||||
onPayloadChange('default')(files || undefined)
|
||||
}}
|
||||
fileConfig={{
|
||||
allowed_file_types: tempPayload.allowed_file_types || [SupportUploadFileTypes.document],
|
||||
allowed_file_extensions: tempPayload.allowed_file_extensions || [],
|
||||
allowed_file_upload_methods: tempPayload.allowed_file_upload_methods || [TransferMethod.remote_url],
|
||||
number_limits: type === InputVarType.singleFile ? 1 : tempPayload.max_length || 5,
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === InputVarType.jsonObject && (
|
||||
<Field title={t('variableConfig.jsonSchema', { ns: 'appDebug' })} isOptional>
|
||||
<CodeEditor
|
||||
language={CodeLanguage.json}
|
||||
value={jsonSchemaStr}
|
||||
onChange={onJSONSchemaChange}
|
||||
noWrapper
|
||||
className="h-[80px] overflow-y-auto radius-lg bg-components-input-bg-normal p-1"
|
||||
placeholder={<div className="whitespace-pre">{jsonConfigPlaceHolder}</div>}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="mt-5! flex h-6 items-center space-x-2">
|
||||
<Checkbox checked={tempPayload.required} disabled={tempPayload.hide} onCheck={() => onPayloadChange('required')(!tempPayload.required)} />
|
||||
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.required', { ns: 'appDebug' })}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5! flex h-6 items-center space-x-2">
|
||||
<Checkbox checked={tempPayload.hide} disabled={tempPayload.required} onCheck={() => onPayloadChange('hide')(!tempPayload.hide)} />
|
||||
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.hide', { ns: 'appDebug' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ConfigModalFormFields)
|
||||
@@ -1,56 +1,29 @@
|
||||
'use client'
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import type { Item as SelectItem } from './type-select'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { InputVar, MoreInfo, UploadFileSetting } from '@/app/components/workflow/types'
|
||||
import { produce } from 'immer'
|
||||
import type { InputVar, InputVarType, MoreInfo } from '@/app/components/workflow/types'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import { ChangeType, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { AppModeEnum, TransferMethod } from '@/types/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
|
||||
import ConfigSelect from '../config-select'
|
||||
import ConfigString from '../config-string'
|
||||
import ModalFoot from '../modal-foot'
|
||||
import { jsonConfigPlaceHolder } from './config'
|
||||
import Field from './field'
|
||||
import TypeSelector from './type-select'
|
||||
|
||||
const TEXT_MAX_LENGTH = 256
|
||||
const CHECKBOX_DEFAULT_TRUE_VALUE = 'true'
|
||||
const CHECKBOX_DEFAULT_FALSE_VALUE = 'false'
|
||||
|
||||
const getCheckboxDefaultSelectValue = (value: InputVar['default']) => {
|
||||
if (typeof value === 'boolean')
|
||||
return value ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
|
||||
if (typeof value === 'string')
|
||||
return value.toLowerCase() === CHECKBOX_DEFAULT_TRUE_VALUE ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
|
||||
return CHECKBOX_DEFAULT_FALSE_VALUE
|
||||
}
|
||||
|
||||
const parseCheckboxSelectValue = (value: string) =>
|
||||
value === CHECKBOX_DEFAULT_TRUE_VALUE
|
||||
|
||||
const normalizeSelectDefaultValue = (inputVar: InputVar) => {
|
||||
if (inputVar.type === InputVarType.select && inputVar.default === '')
|
||||
return { ...inputVar, default: undefined }
|
||||
return inputVar
|
||||
}
|
||||
import ConfigModalFormFields from './form-fields'
|
||||
import {
|
||||
buildSelectOptions,
|
||||
createPayloadForType,
|
||||
getCheckboxDefaultSelectValue,
|
||||
getJsonSchemaEditorValue,
|
||||
isStringInputType,
|
||||
normalizeSelectDefaultValue,
|
||||
updatePayloadField,
|
||||
validateConfigModalPayload,
|
||||
} from './utils'
|
||||
|
||||
type IConfigModalProps = {
|
||||
isCreate?: boolean
|
||||
@@ -73,28 +46,18 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
const { modelConfig } = useContext(ConfigContext)
|
||||
const { t } = useTranslation()
|
||||
const [tempPayload, setTempPayload] = useState<InputVar>(() => normalizeSelectDefaultValue(payload || getNewVarInWorkflow('') as any))
|
||||
const { type, label, variable, options, max_length } = tempPayload
|
||||
const { type, options, max_length } = tempPayload
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW
|
||||
const jsonSchemaStr = useMemo(() => {
|
||||
const isJsonObject = type === InputVarType.jsonObject
|
||||
if (!isJsonObject || !tempPayload.json_schema)
|
||||
return ''
|
||||
try {
|
||||
return tempPayload.json_schema
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
}
|
||||
}, [tempPayload.json_schema])
|
||||
const jsonSchemaStr = useMemo(() => getJsonSchemaEditorValue(type, tempPayload.json_schema), [tempPayload.json_schema, type])
|
||||
useEffect(() => {
|
||||
// To fix the first input element auto focus, then directly close modal will raise error
|
||||
if (isShow)
|
||||
modalRef.current?.focus()
|
||||
}, [isShow])
|
||||
|
||||
const isStringInput = type === InputVarType.textInput || type === InputVarType.paragraph
|
||||
const isStringInput = isStringInputType(type)
|
||||
const checkVariableName = useCallback((value: string, canBeEmpty?: boolean) => {
|
||||
const { isValid, errorMessageKey } = checkKeys([value], canBeEmpty)
|
||||
if (!isValid) {
|
||||
@@ -105,21 +68,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
}, [t])
|
||||
const handlePayloadChange = useCallback((key: string) => {
|
||||
return (value: any) => {
|
||||
setTempPayload((prev) => {
|
||||
const newPayload = {
|
||||
...prev,
|
||||
[key]: value,
|
||||
}
|
||||
|
||||
// Clear default value if modified options no longer include current default
|
||||
if (key === 'options' && prev.default) {
|
||||
const optionsArray = Array.isArray(value) ? value : []
|
||||
if (!optionsArray.includes(prev.default))
|
||||
newPayload.default = undefined
|
||||
}
|
||||
|
||||
return newPayload
|
||||
})
|
||||
setTempPayload(prev => updatePayloadField(prev, key, value))
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -138,65 +87,15 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
}
|
||||
}, [handlePayloadChange])
|
||||
|
||||
const selectOptions: SelectItem[] = [
|
||||
{
|
||||
name: t('variableConfig.text-input', { ns: 'appDebug' }),
|
||||
value: InputVarType.textInput,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.paragraph', { ns: 'appDebug' }),
|
||||
value: InputVarType.paragraph,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.select', { ns: 'appDebug' }),
|
||||
value: InputVarType.select,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.number', { ns: 'appDebug' }),
|
||||
value: InputVarType.number,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.checkbox', { ns: 'appDebug' }),
|
||||
value: InputVarType.checkbox,
|
||||
},
|
||||
...(supportFile
|
||||
? [
|
||||
{
|
||||
name: t('variableConfig.single-file', { ns: 'appDebug' }),
|
||||
value: InputVarType.singleFile,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.multi-files', { ns: 'appDebug' }),
|
||||
value: InputVarType.multiFiles,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...((!isBasicApp)
|
||||
? [{
|
||||
name: t('variableConfig.json', { ns: 'appDebug' }),
|
||||
value: InputVarType.jsonObject,
|
||||
}]
|
||||
: []),
|
||||
]
|
||||
const selectOptions: SelectItem[] = useMemo(() => buildSelectOptions({
|
||||
isBasicApp,
|
||||
supportFile,
|
||||
t,
|
||||
}), [isBasicApp, supportFile, t])
|
||||
|
||||
const handleTypeChange = useCallback((item: SelectItem) => {
|
||||
const type = item.value as InputVarType
|
||||
|
||||
const newPayload = produce(tempPayload, (draft) => {
|
||||
draft.type = type
|
||||
if (type === InputVarType.select)
|
||||
draft.default = undefined
|
||||
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
|
||||
(Object.keys(DEFAULT_FILE_UPLOAD_SETTING)).forEach((key) => {
|
||||
if (key !== 'max_length')
|
||||
(draft as any)[key] = (DEFAULT_FILE_UPLOAD_SETTING as any)[key]
|
||||
})
|
||||
if (type === InputVarType.multiFiles)
|
||||
draft.max_length = DEFAULT_FILE_UPLOAD_SETTING.max_length
|
||||
}
|
||||
})
|
||||
setTempPayload(newPayload)
|
||||
}, [tempPayload])
|
||||
setTempPayload(prev => createPayloadForType(prev, item.value as InputVarType))
|
||||
}, [])
|
||||
|
||||
const handleVarKeyBlur = useCallback((e: any) => {
|
||||
const varName = e.target.value
|
||||
@@ -224,98 +123,21 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
|
||||
const checkboxDefaultSelectValue = useMemo(() => getCheckboxDefaultSelectValue(tempPayload.default), [tempPayload.default])
|
||||
|
||||
const isJsonSchemaEmpty = (value: InputVar['json_schema']) => {
|
||||
if (value === null || value === undefined) {
|
||||
return true
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return false
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed === ''
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
const jsonSchemaValue = tempPayload.json_schema
|
||||
const isSchemaEmpty = isJsonSchemaEmpty(jsonSchemaValue)
|
||||
const normalizedJsonSchema = isSchemaEmpty ? undefined : jsonSchemaValue
|
||||
const { errorMessage, moreInfo, payloadToSave } = validateConfigModalPayload({
|
||||
tempPayload,
|
||||
payload,
|
||||
checkVariableName,
|
||||
t,
|
||||
})
|
||||
|
||||
// if the input type is jsonObject and the schema is empty as determined by `isJsonSchemaEmpty`,
|
||||
// remove the `json_schema` field from the payload by setting its value to `undefined`.
|
||||
const payloadToSave = tempPayload.type === InputVarType.jsonObject && isSchemaEmpty
|
||||
? { ...tempPayload, json_schema: undefined }
|
||||
: tempPayload
|
||||
|
||||
const moreInfo = tempPayload.variable === payload?.variable
|
||||
? undefined
|
||||
: {
|
||||
type: ChangeType.changeVarName,
|
||||
payload: { beforeKey: payload?.variable || '', afterKey: tempPayload.variable },
|
||||
}
|
||||
|
||||
const isVariableNameValid = checkVariableName(tempPayload.variable)
|
||||
if (!isVariableNameValid)
|
||||
return
|
||||
|
||||
if (!tempPayload.label) {
|
||||
toast.error(t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }))
|
||||
if (errorMessage) {
|
||||
toast.error(errorMessage)
|
||||
return
|
||||
}
|
||||
if (isStringInput || type === InputVarType.number) {
|
||||
|
||||
if (payloadToSave)
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
else if (type === InputVarType.select) {
|
||||
if (options?.length === 0) {
|
||||
toast.error(t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }))
|
||||
return
|
||||
}
|
||||
const obj: Record<string, boolean> = {}
|
||||
let hasRepeatedItem = false
|
||||
options?.forEach((o) => {
|
||||
if (obj[o]) {
|
||||
hasRepeatedItem = true
|
||||
return
|
||||
}
|
||||
obj[o] = true
|
||||
})
|
||||
if (hasRepeatedItem) {
|
||||
toast.error(t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }))
|
||||
return
|
||||
}
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
else if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
|
||||
if (tempPayload.allowed_file_types?.length === 0) {
|
||||
const errorMessages = t('errorMsg.fieldRequired', { ns: 'workflow', field: t('variableConfig.file.supportFileTypes', { ns: 'appDebug' }) })
|
||||
toast.error(errorMessages)
|
||||
return
|
||||
}
|
||||
if (tempPayload.allowed_file_types?.includes(SupportUploadFileTypes.custom) && !tempPayload.allowed_file_extensions?.length) {
|
||||
const errorMessages = t('errorMsg.fieldRequired', { ns: 'workflow', field: t('variableConfig.file.custom.name', { ns: 'appDebug' }) })
|
||||
toast.error(errorMessages)
|
||||
return
|
||||
}
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
else if (type === InputVarType.jsonObject) {
|
||||
if (!isSchemaEmpty && typeof normalizedJsonSchema === 'string') {
|
||||
try {
|
||||
const schema = JSON.parse(normalizedJsonSchema)
|
||||
if (schema?.type !== 'object') {
|
||||
toast.error(t('variableConfig.errorMsg.jsonSchemaMustBeObject', { ns: 'appDebug' }))
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {
|
||||
toast.error(t('variableConfig.errorMsg.jsonSchemaInvalid', { ns: 'appDebug' }))
|
||||
return
|
||||
}
|
||||
}
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
else {
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -325,165 +147,23 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="mb-8" ref={modalRef} tabIndex={-1}>
|
||||
<div className="space-y-2">
|
||||
<Field title={t('variableConfig.fieldType', { ns: 'appDebug' })}>
|
||||
<TypeSelector value={type} items={selectOptions} onSelect={handleTypeChange} />
|
||||
</Field>
|
||||
|
||||
<Field title={t('variableConfig.varName', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
value={variable}
|
||||
onChange={handleVarNameChange}
|
||||
onBlur={handleVarKeyBlur}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
|
||||
/>
|
||||
</Field>
|
||||
<Field title={t('variableConfig.labelName', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
value={label as string}
|
||||
onChange={e => handlePayloadChange('label')(e.target.value)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{isStringInput && (
|
||||
<Field title={t('variableConfig.maxLength', { ns: 'appDebug' })}>
|
||||
<ConfigString maxLength={type === InputVarType.textInput ? TEXT_MAX_LENGTH : Infinity} modelId={modelConfig.model_id} value={max_length} onChange={handlePayloadChange('max_length')} />
|
||||
</Field>
|
||||
|
||||
)}
|
||||
|
||||
{/* Default value for text input */}
|
||||
{type === InputVarType.textInput && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
value={tempPayload.default || ''}
|
||||
onChange={e => handlePayloadChange('default')(e.target.value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Default value for paragraph */}
|
||||
{type === InputVarType.paragraph && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Textarea
|
||||
value={String(tempPayload.default ?? '')}
|
||||
onChange={e => handlePayloadChange('default')(e.target.value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Default value for number input */}
|
||||
{type === InputVarType.number && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Input
|
||||
type="number"
|
||||
value={tempPayload.default || ''}
|
||||
onChange={e => handlePayloadChange('default')(e.target.value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })!}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === InputVarType.checkbox && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<SimpleSelect
|
||||
className="w-full"
|
||||
optionWrapClassName="max-h-[140px] overflow-y-auto"
|
||||
items={[
|
||||
{ value: CHECKBOX_DEFAULT_TRUE_VALUE, name: t('variableConfig.startChecked', { ns: 'appDebug' }) },
|
||||
{ value: CHECKBOX_DEFAULT_FALSE_VALUE, name: t('variableConfig.noDefaultSelected', { ns: 'appDebug' }) },
|
||||
]}
|
||||
defaultValue={checkboxDefaultSelectValue}
|
||||
onSelect={item => handlePayloadChange('default')(parseCheckboxSelectValue(String(item.value)))}
|
||||
placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })}
|
||||
allowSearch={false}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === InputVarType.select && (
|
||||
<>
|
||||
<Field title={t('variableConfig.options', { ns: 'appDebug' })}>
|
||||
<ConfigSelect options={options || []} onChange={handlePayloadChange('options')} />
|
||||
</Field>
|
||||
{options && options.length > 0 && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<SimpleSelect
|
||||
key={`default-select-${options.join('-')}`}
|
||||
className="w-full"
|
||||
optionWrapClassName="max-h-[140px] overflow-y-auto"
|
||||
items={[
|
||||
{ value: '', name: t('variableConfig.noDefaultValue', { ns: 'appDebug' }) },
|
||||
...options.filter(opt => opt.trim() !== '').map(option => ({
|
||||
value: option,
|
||||
name: option,
|
||||
})),
|
||||
]}
|
||||
defaultValue={tempPayload.default || ''}
|
||||
onSelect={item => handlePayloadChange('default')(item.value === '' ? undefined : item.value)}
|
||||
placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })}
|
||||
allowSearch={false}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{[InputVarType.singleFile, InputVarType.multiFiles].includes(type) && (
|
||||
<>
|
||||
<FileUploadSetting
|
||||
payload={tempPayload as UploadFileSetting}
|
||||
onChange={(p: UploadFileSetting) => setTempPayload(p as InputVar)}
|
||||
isMultiple={type === InputVarType.multiFiles}
|
||||
/>
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={(type === InputVarType.singleFile ? (tempPayload.default ? [tempPayload.default] : []) : (tempPayload.default || [])) as unknown as FileEntity[]}
|
||||
onChange={(files) => {
|
||||
if (type === InputVarType.singleFile)
|
||||
handlePayloadChange('default')(files?.[0] || undefined)
|
||||
else
|
||||
handlePayloadChange('default')(files || undefined)
|
||||
}}
|
||||
fileConfig={{
|
||||
allowed_file_types: tempPayload.allowed_file_types || [SupportUploadFileTypes.document],
|
||||
allowed_file_extensions: tempPayload.allowed_file_extensions || [],
|
||||
allowed_file_upload_methods: tempPayload.allowed_file_upload_methods || [TransferMethod.remote_url],
|
||||
number_limits: type === InputVarType.singleFile ? 1 : tempPayload.max_length || 5,
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === InputVarType.jsonObject && (
|
||||
<Field title={t('variableConfig.jsonSchema', { ns: 'appDebug' })} isOptional>
|
||||
<CodeEditor
|
||||
language={CodeLanguage.json}
|
||||
value={jsonSchemaStr}
|
||||
onChange={handleJSONSchemaChange}
|
||||
noWrapper
|
||||
className="bg h-[80px] overflow-y-auto radius-lg bg-components-input-bg-normal p-1"
|
||||
placeholder={
|
||||
<div className="whitespace-pre">{jsonConfigPlaceHolder}</div>
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="mt-5! flex h-6 items-center space-x-2">
|
||||
<Checkbox checked={tempPayload.required} disabled={tempPayload.hide} onCheck={() => handlePayloadChange('required')(!tempPayload.required)} />
|
||||
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.required', { ns: 'appDebug' })}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5! flex h-6 items-center space-x-2">
|
||||
<Checkbox checked={tempPayload.hide} disabled={tempPayload.required} onCheck={() => handlePayloadChange('hide')(!tempPayload.hide)} />
|
||||
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.hide', { ns: 'appDebug' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ConfigModalFormFields
|
||||
checkboxDefaultSelectValue={checkboxDefaultSelectValue}
|
||||
isStringInput={isStringInput}
|
||||
jsonSchemaStr={jsonSchemaStr}
|
||||
maxLength={max_length}
|
||||
modelId={modelConfig.model_id}
|
||||
onFilePayloadChange={payload => setTempPayload(payload as InputVar)}
|
||||
onJSONSchemaChange={handleJSONSchemaChange}
|
||||
onPayloadChange={handlePayloadChange}
|
||||
onTypeChange={handleTypeChange}
|
||||
onVarKeyBlur={handleVarKeyBlur}
|
||||
onVarNameChange={handleVarNameChange}
|
||||
options={options}
|
||||
selectOptions={selectOptions}
|
||||
tempPayload={tempPayload}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
<ModalFoot
|
||||
onConfirm={handleConfirm}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { Item as SelectItem } from './type-select'
|
||||
import type { InputVar, MoreInfo } from '@/app/components/workflow/types'
|
||||
import { produce } from 'immer'
|
||||
import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants'
|
||||
import { ChangeType, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
|
||||
export const TEXT_MAX_LENGTH = 256
|
||||
export const CHECKBOX_DEFAULT_TRUE_VALUE = 'true'
|
||||
export const CHECKBOX_DEFAULT_FALSE_VALUE = 'false'
|
||||
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string
|
||||
|
||||
type ValidateConfigModalPayloadOptions = {
|
||||
tempPayload: InputVar
|
||||
payload?: InputVar
|
||||
checkVariableName: (value: string, canBeEmpty?: boolean) => boolean
|
||||
t: Translate
|
||||
}
|
||||
|
||||
type ValidateConfigModalPayloadResult = {
|
||||
payloadToSave?: InputVar
|
||||
moreInfo?: MoreInfo
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export const isStringInputType = (type: InputVarType) =>
|
||||
type === InputVarType.textInput || type === InputVarType.paragraph
|
||||
|
||||
export const getCheckboxDefaultSelectValue = (value: InputVar['default'] | boolean) => {
|
||||
if (typeof value === 'boolean')
|
||||
return value ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
|
||||
if (typeof value === 'string')
|
||||
return value.toLowerCase() === CHECKBOX_DEFAULT_TRUE_VALUE ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
|
||||
return CHECKBOX_DEFAULT_FALSE_VALUE
|
||||
}
|
||||
|
||||
export const parseCheckboxSelectValue = (value: string) =>
|
||||
value === CHECKBOX_DEFAULT_TRUE_VALUE
|
||||
|
||||
export const normalizeSelectDefaultValue = (inputVar: InputVar) => {
|
||||
if (inputVar.type === InputVarType.select && inputVar.default === '')
|
||||
return { ...inputVar, default: undefined }
|
||||
return inputVar
|
||||
}
|
||||
|
||||
export const getJsonSchemaEditorValue = (type: InputVarType, jsonSchema?: InputVar['json_schema']) => {
|
||||
if (type !== InputVarType.jsonObject || !jsonSchema)
|
||||
return ''
|
||||
|
||||
try {
|
||||
if (typeof jsonSchema !== 'string')
|
||||
return JSON.stringify(jsonSchema, null, 2)
|
||||
|
||||
return jsonSchema
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const isJsonSchemaEmpty = (value: InputVar['json_schema']) => {
|
||||
if (value === null || value === undefined)
|
||||
return true
|
||||
if (typeof value !== 'string')
|
||||
return false
|
||||
return value.trim() === ''
|
||||
}
|
||||
|
||||
export const updatePayloadField = (payload: InputVar, key: string, value: unknown) => {
|
||||
const nextPayload = {
|
||||
...payload,
|
||||
[key]: value,
|
||||
} as InputVar
|
||||
|
||||
if (key === 'options' && payload.default) {
|
||||
const options = Array.isArray(value) ? value : []
|
||||
if (!options.includes(payload.default))
|
||||
nextPayload.default = undefined
|
||||
}
|
||||
|
||||
return nextPayload
|
||||
}
|
||||
|
||||
export const createPayloadForType = (payload: InputVar, type: InputVarType) => {
|
||||
return produce(payload, (draft) => {
|
||||
draft.type = type
|
||||
if (type === InputVarType.select)
|
||||
draft.default = undefined
|
||||
|
||||
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
|
||||
(Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array<keyof typeof DEFAULT_FILE_UPLOAD_SETTING>).forEach((key) => {
|
||||
if (key !== 'max_length')
|
||||
draft[key] = DEFAULT_FILE_UPLOAD_SETTING[key] as never
|
||||
})
|
||||
|
||||
if (type === InputVarType.multiFiles)
|
||||
draft.max_length = DEFAULT_FILE_UPLOAD_SETTING.max_length
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const buildSelectOptions = ({
|
||||
isBasicApp,
|
||||
supportFile,
|
||||
t,
|
||||
}: {
|
||||
isBasicApp: boolean
|
||||
supportFile?: boolean
|
||||
t: Translate
|
||||
}): SelectItem[] => {
|
||||
return [
|
||||
{
|
||||
name: t('variableConfig.text-input', { ns: 'appDebug' }),
|
||||
value: InputVarType.textInput,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.paragraph', { ns: 'appDebug' }),
|
||||
value: InputVarType.paragraph,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.select', { ns: 'appDebug' }),
|
||||
value: InputVarType.select,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.number', { ns: 'appDebug' }),
|
||||
value: InputVarType.number,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.checkbox', { ns: 'appDebug' }),
|
||||
value: InputVarType.checkbox,
|
||||
},
|
||||
...(supportFile
|
||||
? [
|
||||
{
|
||||
name: t('variableConfig.single-file', { ns: 'appDebug' }),
|
||||
value: InputVarType.singleFile,
|
||||
},
|
||||
{
|
||||
name: t('variableConfig.multi-files', { ns: 'appDebug' }),
|
||||
value: InputVarType.multiFiles,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isBasicApp
|
||||
? [
|
||||
{
|
||||
name: t('variableConfig.json', { ns: 'appDebug' }),
|
||||
value: InputVarType.jsonObject,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
export const validateConfigModalPayload = ({
|
||||
tempPayload,
|
||||
payload,
|
||||
checkVariableName,
|
||||
t,
|
||||
}: ValidateConfigModalPayloadOptions): ValidateConfigModalPayloadResult => {
|
||||
const jsonSchemaValue = tempPayload.json_schema
|
||||
const schemaEmpty = isJsonSchemaEmpty(jsonSchemaValue)
|
||||
const normalizedJsonSchema = schemaEmpty ? undefined : jsonSchemaValue
|
||||
const payloadToSave = tempPayload.type === InputVarType.jsonObject && schemaEmpty
|
||||
? { ...tempPayload, json_schema: undefined }
|
||||
: tempPayload
|
||||
|
||||
const moreInfo = tempPayload.variable === payload?.variable
|
||||
? undefined
|
||||
: {
|
||||
type: ChangeType.changeVarName,
|
||||
payload: { beforeKey: payload?.variable || '', afterKey: tempPayload.variable },
|
||||
}
|
||||
|
||||
if (!checkVariableName(tempPayload.variable))
|
||||
return {}
|
||||
|
||||
if (!tempPayload.label) {
|
||||
return {
|
||||
errorMessage: t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }),
|
||||
}
|
||||
}
|
||||
|
||||
if (tempPayload.type === InputVarType.select) {
|
||||
if (!tempPayload.options?.length) {
|
||||
return {
|
||||
errorMessage: t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }),
|
||||
}
|
||||
}
|
||||
|
||||
const duplicated = new Set<string>()
|
||||
const hasRepeatedItem = tempPayload.options.some((option) => {
|
||||
if (duplicated.has(option))
|
||||
return true
|
||||
|
||||
duplicated.add(option)
|
||||
return false
|
||||
})
|
||||
|
||||
if (hasRepeatedItem) {
|
||||
return {
|
||||
errorMessage: t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(tempPayload.type)) {
|
||||
if (!tempPayload.allowed_file_types?.length) {
|
||||
return {
|
||||
errorMessage: t('errorMsg.fieldRequired', {
|
||||
ns: 'workflow',
|
||||
field: t('variableConfig.file.supportFileTypes', { ns: 'appDebug' }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (tempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !tempPayload.allowed_file_extensions?.length) {
|
||||
return {
|
||||
errorMessage: t('errorMsg.fieldRequired', {
|
||||
ns: 'workflow',
|
||||
field: t('variableConfig.file.custom.name', { ns: 'appDebug' }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tempPayload.type === InputVarType.jsonObject && !schemaEmpty && typeof normalizedJsonSchema === 'string') {
|
||||
try {
|
||||
const schema = JSON.parse(normalizedJsonSchema)
|
||||
if (schema?.type !== 'object') {
|
||||
return {
|
||||
errorMessage: t('variableConfig.errorMsg.jsonSchemaMustBeObject', { ns: 'appDebug' }),
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return {
|
||||
errorMessage: t('variableConfig.errorMsg.jsonSchemaInvalid', { ns: 'appDebug' }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
payloadToSave,
|
||||
moreInfo,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ConfigSelect from './index'
|
||||
import ConfigSelect from '../index'
|
||||
|
||||
vi.mock('react-sortablejs', () => ({
|
||||
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
ReactSortable: ({
|
||||
children,
|
||||
list,
|
||||
setList,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
list: Array<{ id: number, name: string }>
|
||||
setList: (list: Array<{ id: number, name: string }>) => void
|
||||
}) => (
|
||||
<div>
|
||||
<button onClick={() => setList([...list].reverse())}>reorder-options</button>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ConfigSelect Component', () => {
|
||||
@@ -58,6 +71,18 @@ describe('ConfigSelect Component', () => {
|
||||
expect(firstInput.closest('div')).toHaveClass('border-components-input-border-active')
|
||||
})
|
||||
|
||||
it('updates option values and clears focus styles on blur', () => {
|
||||
render(<ConfigSelect {...defaultProps} />)
|
||||
const firstInput = screen.getByDisplayValue('Option 1')
|
||||
|
||||
fireEvent.change(firstInput, { target: { value: 'Updated option' } })
|
||||
expect(defaultProps.onChange).toHaveBeenCalledWith(['Updated option', 'Option 2'])
|
||||
|
||||
fireEvent.focus(firstInput)
|
||||
fireEvent.blur(firstInput)
|
||||
expect(firstInput.closest('div')).not.toHaveClass('border-components-input-border-active')
|
||||
})
|
||||
|
||||
it('applies delete hover styles', () => {
|
||||
render(<ConfigSelect {...defaultProps} />)
|
||||
const optionContainer = screen.getByDisplayValue('Option 1').closest('div')
|
||||
@@ -67,6 +92,8 @@ describe('ConfigSelect Component', () => {
|
||||
return
|
||||
fireEvent.mouseEnter(deleteButton)
|
||||
expect(optionContainer).toHaveClass('border-components-input-border-destructive')
|
||||
fireEvent.mouseLeave(deleteButton)
|
||||
expect(optionContainer).not.toHaveClass('border-components-input-border-destructive')
|
||||
})
|
||||
|
||||
it('renders empty state correctly', () => {
|
||||
@@ -75,4 +102,12 @@ describe('ConfigSelect Component', () => {
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.variableConfig.addOption')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reorders options through the sortable callback', () => {
|
||||
render(<ConfigSelect {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('reorder-options'))
|
||||
|
||||
expect(defaultProps.onChange).toHaveBeenCalledWith(['Option 2', 'Option 1'])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IConfigStringProps } from './index'
|
||||
import type { IConfigStringProps } from '../index'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import ConfigString from './index'
|
||||
import ConfigString from '../index'
|
||||
|
||||
const renderConfigString = (props?: Partial<IConfigStringProps>) => {
|
||||
const onChange = vi.fn()
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import SelectTypeItem from './index'
|
||||
import SelectTypeItem from '../index'
|
||||
|
||||
describe('SelectTypeItem', () => {
|
||||
// Rendering pathways based on type and selection state
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { Mock } from 'vitest'
|
||||
import type { FeatureStoreState } from '@/app/components/base/features/store'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
@@ -6,9 +7,9 @@ import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
import ConfigVision from './index'
|
||||
import ParamConfig from './param-config'
|
||||
import ParamConfigContent from './param-config-content'
|
||||
import ConfigVision from '../index'
|
||||
import ParamConfig from '../param-config'
|
||||
import ParamConfigContent from '../param-config-content'
|
||||
|
||||
const mockUseContext = vi.fn()
|
||||
vi.mock('use-context-selector', async (importOriginal) => {
|
||||
@@ -1,12 +1,13 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { AgentConfig } from '@/models/debug'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { AgentStrategy } from '@/types/app'
|
||||
import AgentSettingButton from './agent-setting-button'
|
||||
import AgentSettingButton from '../agent-setting-button'
|
||||
|
||||
let latestAgentSettingProps: any
|
||||
vi.mock('./agent/agent-setting', () => ({
|
||||
vi.mock('../agent/agent-setting', () => ({
|
||||
default: (props: any) => {
|
||||
latestAgentSettingProps = props
|
||||
return (
|
||||
@@ -1,9 +1,10 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { FeatureStoreState } from '@/app/components/base/features/store'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import ConfigAudio from './config-audio'
|
||||
import ConfigAudio from '../config-audio'
|
||||
|
||||
const mockUseContext = vi.fn()
|
||||
vi.mock('use-context-selector', async (importOriginal) => {
|
||||
@@ -1,9 +1,10 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { FeatureStoreState } from '@/app/components/base/features/store'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import ConfigDocument from './config-document'
|
||||
import ConfigDocument from '../config-document'
|
||||
|
||||
const mockUseContext = vi.fn()
|
||||
vi.mock('use-context-selector', async (importOriginal) => {
|
||||
@@ -1,10 +1,11 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { ModelConfig, PromptVariable } from '@/models/debug'
|
||||
import type { ToolItem } from '@/types/app'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import * as useContextSelector from 'use-context-selector'
|
||||
import { AgentStrategy, AppModeEnum, ModelModeType } from '@/types/app'
|
||||
import Config from './index'
|
||||
import Config from '../index'
|
||||
|
||||
vi.mock('use-context-selector', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('use-context-selector')>()
|
||||
@@ -15,7 +16,7 @@ vi.mock('use-context-selector', async (importOriginal) => {
|
||||
})
|
||||
|
||||
const mockFormattingDispatcher = vi.fn()
|
||||
vi.mock('../debug/hooks', () => ({
|
||||
vi.mock('../../debug/hooks', () => ({
|
||||
useFormattingChangedDispatcher: () => mockFormattingDispatcher,
|
||||
}))
|
||||
|
||||
@@ -35,28 +36,28 @@ vi.mock('@/app/components/app/configuration/config-var', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../dataset-config', () => ({
|
||||
vi.mock('../../dataset-config', () => ({
|
||||
default: () => <div data-testid="dataset-config" />,
|
||||
}))
|
||||
|
||||
vi.mock('./agent/agent-tools', () => ({
|
||||
vi.mock('../agent/agent-tools', () => ({
|
||||
default: () => <div data-testid="agent-tools" />,
|
||||
}))
|
||||
|
||||
vi.mock('../config-vision', () => ({
|
||||
vi.mock('../../config-vision', () => ({
|
||||
default: () => <div data-testid="config-vision" />,
|
||||
}))
|
||||
|
||||
vi.mock('./config-document', () => ({
|
||||
vi.mock('../config-document', () => ({
|
||||
default: () => <div data-testid="config-document" />,
|
||||
}))
|
||||
|
||||
vi.mock('./config-audio', () => ({
|
||||
vi.mock('../config-audio', () => ({
|
||||
default: () => <div data-testid="config-audio" />,
|
||||
}))
|
||||
|
||||
let latestHistoryPanelProps: any
|
||||
vi.mock('../config-prompt/conversation-history/history-panel', () => ({
|
||||
vi.mock('../../config-prompt/conversation-history/history-panel', () => ({
|
||||
default: (props: any) => {
|
||||
latestHistoryPanelProps = props
|
||||
return <div data-testid="history-panel" />
|
||||
@@ -2,7 +2,7 @@ import type { AgentConfig } from '@/models/debug'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { MAX_ITERATIONS_NUM } from '@/config'
|
||||
import AgentSetting from './index'
|
||||
import AgentSetting from '../index'
|
||||
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ahooks')>()
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import ItemPanel from './item-panel'
|
||||
import ItemPanel from '../item-panel'
|
||||
|
||||
describe('AgentSetting/ItemPanel', () => {
|
||||
it('should render icon, name, and children content', () => {
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type {
|
||||
PropsWithChildren,
|
||||
} from 'react'
|
||||
import type { Mock } from 'vitest'
|
||||
import type SettingBuiltInToolType from './setting-built-in-tool'
|
||||
import type SettingBuiltInToolType from '../setting-built-in-tool'
|
||||
import type { Tool, ToolParameter } from '@/app/components/tools/types'
|
||||
import type ToolPickerType from '@/app/components/workflow/block-selector/tool-picker'
|
||||
import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types'
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
} from '@/config'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { ModelModeType } from '@/types/app'
|
||||
import AgentTools from './index'
|
||||
import AgentTools from '../index'
|
||||
|
||||
const formattingDispatcherMock = vi.fn()
|
||||
vi.mock('@/app/components/app/configuration/debug/hooks', () => ({
|
||||
@@ -94,7 +95,7 @@ const SettingBuiltInToolMock = (props: SettingBuiltInToolProps) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
vi.mock('./setting-built-in-tool', () => ({
|
||||
vi.mock('../setting-built-in-tool', () => ({
|
||||
default: (props: SettingBuiltInToolProps) => <SettingBuiltInToolMock {...props} />,
|
||||
}))
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { Tool, ToolParameter } from '@/app/components/tools/types'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import SettingBuiltInTool from './setting-built-in-tool'
|
||||
import SettingBuiltInTool from '../setting-built-in-tool'
|
||||
|
||||
const fetchModelToolList = vi.fn()
|
||||
const fetchBuiltInToolList = vi.fn()
|
||||
@@ -3,7 +3,7 @@ import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { AgentStrategy } from '@/types/app'
|
||||
import AssistantTypePicker from './index'
|
||||
import AssistantTypePicker from '../index'
|
||||
|
||||
// Test utilities
|
||||
const defaultAgentConfig: AgentConfig = {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import AutomaticBtn from './automatic-btn'
|
||||
import AutomaticBtn from '../automatic-btn'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
@@ -0,0 +1,280 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import GetAutomaticRes from '../get-automatic-res'
|
||||
|
||||
const mockGenerateBasicAppFirstTimeRule = vi.fn()
|
||||
const mockGenerateRule = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
|
||||
let mockDefaultModel: {
|
||||
model: string
|
||||
provider: {
|
||||
provider: string
|
||||
}
|
||||
} | null = null
|
||||
|
||||
let mockInstructionTemplate: { data: string } | undefined
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
|
||||
defaultModel: mockDefaultModel,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useGenerateRuleTemplate: () => ({
|
||||
data: mockInstructionTemplate,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/debug', () => ({
|
||||
generateBasicAppFirstTimeRule: (...args: unknown[]) => mockGenerateBasicAppFirstTimeRule(...args),
|
||||
generateRule: (...args: unknown[]) => mockGenerateRule(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
|
||||
default: ({
|
||||
setModel,
|
||||
onCompletionParamsChange,
|
||||
}: {
|
||||
setModel: (value: { modelId: string, provider: string, mode?: string, features?: string[] }) => void
|
||||
onCompletionParamsChange: (value: Record<string, unknown>) => void
|
||||
}) => (
|
||||
<div>
|
||||
<button onClick={() => setModel({ modelId: 'gpt-4o-mini', provider: 'openai', mode: 'chat' })}>change-model</button>
|
||||
<button onClick={() => onCompletionParamsChange({ temperature: 0.3 })}>change-params</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../instruction-editor', () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="basic-editor">{value}</div>
|
||||
<button onClick={() => onChange('basic instruction')}>set-basic-instruction</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../instruction-editor-in-workflow', () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="workflow-editor">{value}</div>
|
||||
<button onClick={() => onChange('workflow instruction')}>set-workflow-instruction</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../idea-output', () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="idea-output">{value}</div>
|
||||
<button onClick={() => onChange('ideal output')}>set-idea-output</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../res-placeholder', () => ({
|
||||
default: () => <div>result-placeholder</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../result', () => ({
|
||||
default: ({
|
||||
current,
|
||||
onApply,
|
||||
}: {
|
||||
current: { modified?: string, prompt?: string }
|
||||
onApply: () => void
|
||||
}) => (
|
||||
<div data-testid="result-panel">
|
||||
<div>{current.modified || current.prompt}</div>
|
||||
<button onClick={onApply}>apply-result</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('GetAutomaticRes', () => {
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnFinished = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
mockDefaultModel = {
|
||||
model: 'gpt-4.1-mini',
|
||||
provider: {
|
||||
provider: 'openai',
|
||||
},
|
||||
}
|
||||
mockInstructionTemplate = undefined
|
||||
})
|
||||
|
||||
it('should initialize from template suggestions and persist model updates', async () => {
|
||||
mockInstructionTemplate = { data: 'template instruction' }
|
||||
|
||||
render(
|
||||
<GetAutomaticRes
|
||||
mode={AppModeEnum.CHAT}
|
||||
isShow
|
||||
onClose={mockOnClose}
|
||||
onFinished={mockOnFinished}
|
||||
flowId="flow-1"
|
||||
isBasicMode
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('basic-editor')).toHaveTextContent('template instruction')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('generate.template.pythonDebugger.name'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('basic-editor')).toHaveTextContent('generate.template.pythonDebugger.instruction')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('change-model'))
|
||||
expect(localStorage.getItem('auto-gen-model')).toContain('"name":"gpt-4o-mini"')
|
||||
|
||||
fireEvent.click(screen.getByText('change-params'))
|
||||
expect(localStorage.getItem('auto-gen-model')).toContain('"temperature":0.3')
|
||||
})
|
||||
|
||||
it('should block generation when instruction is empty', () => {
|
||||
render(
|
||||
<GetAutomaticRes
|
||||
mode={AppModeEnum.CHAT}
|
||||
isShow
|
||||
onClose={mockOnClose}
|
||||
onFinished={mockOnFinished}
|
||||
flowId="flow-1"
|
||||
isBasicMode
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('generate.generate'))
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith('errorMsg.fieldRequired')
|
||||
expect(mockGenerateBasicAppFirstTimeRule).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('result-placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should generate a basic prompt and apply the confirmed result', async () => {
|
||||
mockGenerateBasicAppFirstTimeRule.mockResolvedValue({
|
||||
prompt: 'generated prompt',
|
||||
variables: ['city'],
|
||||
opening_statement: 'hello there',
|
||||
})
|
||||
|
||||
render(
|
||||
<GetAutomaticRes
|
||||
mode={AppModeEnum.CHAT}
|
||||
isShow
|
||||
onClose={mockOnClose}
|
||||
onFinished={mockOnFinished}
|
||||
flowId="flow-1"
|
||||
isBasicMode
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('set-basic-instruction'))
|
||||
fireEvent.click(screen.getByText('generate.generate'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGenerateBasicAppFirstTimeRule).toHaveBeenCalledWith(expect.objectContaining({
|
||||
instruction: 'basic instruction',
|
||||
no_variable: false,
|
||||
}))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('result-panel')).toHaveTextContent('generated prompt')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('apply-result'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('generate.overwriteTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
|
||||
|
||||
expect(mockOnFinished).toHaveBeenCalledWith(expect.objectContaining({
|
||||
modified: 'generated prompt',
|
||||
variables: ['city'],
|
||||
opening_statement: 'hello there',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should request workflow generation and surface service errors', async () => {
|
||||
mockGenerateRule.mockResolvedValue({
|
||||
error: 'generation failed',
|
||||
modified: 'unused',
|
||||
})
|
||||
|
||||
render(
|
||||
<GetAutomaticRes
|
||||
mode={AppModeEnum.ADVANCED_CHAT}
|
||||
isShow
|
||||
onClose={mockOnClose}
|
||||
onFinished={mockOnFinished}
|
||||
flowId="flow-1"
|
||||
nodeId="node-1"
|
||||
editorId="editor-1"
|
||||
currentPrompt="current prompt"
|
||||
isBasicMode={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('set-workflow-instruction'))
|
||||
fireEvent.click(screen.getByText('set-idea-output'))
|
||||
fireEvent.click(screen.getByText('generate.generate'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGenerateRule).toHaveBeenCalledWith(expect.objectContaining({
|
||||
flow_id: 'flow-1',
|
||||
node_id: 'node-1',
|
||||
current: 'current prompt',
|
||||
instruction: 'workflow instruction',
|
||||
ideal_output: 'ideal output',
|
||||
}))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('generation failed')
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('result-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import IdeaOutput from '../idea-output'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('IdeaOutput', () => {
|
||||
it('should toggle the fold state and propagate textarea changes when expanded', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<IdeaOutput value="Initial idea" onChange={onChange} />)
|
||||
|
||||
expect(screen.queryByPlaceholderText('generate.idealOutputPlaceholder')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('generate.idealOutput'))
|
||||
|
||||
const textarea = screen.getByPlaceholderText('generate.idealOutputPlaceholder')
|
||||
fireEvent.change(textarea, { target: { value: 'Updated idea' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('Updated idea')
|
||||
|
||||
fireEvent.click(screen.getByText('generate.idealOutput'))
|
||||
|
||||
expect(screen.queryByPlaceholderText('generate.idealOutputPlaceholder')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import InstructionEditorInWorkflow from '../instruction-editor-in-workflow'
|
||||
import { GeneratorType } from '../types'
|
||||
|
||||
const mockUseAvailableVarList = vi.fn()
|
||||
const mockUseWorkflowVariableType = vi.fn()
|
||||
const mockGetState = vi.fn()
|
||||
const mockInstructionEditor = vi.fn()
|
||||
const filterResults: boolean[] = []
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: mockGetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowVariableType: () => mockUseWorkflowVariableType(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
|
||||
default: (nodeId: string, options: { filterVar: (payload: { type: VarType }, selector: string[]) => boolean }) => {
|
||||
filterResults.push(
|
||||
options.filterVar({ type: VarType.string }, ['node-1']),
|
||||
options.filterVar({ type: VarType.file }, ['node-1']),
|
||||
options.filterVar({ type: VarType.arrayFile }, ['node-1']),
|
||||
options.filterVar({ type: VarType.string }, ['node-x']),
|
||||
)
|
||||
return mockUseAvailableVarList(nodeId, options)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../instruction-editor', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mockInstructionEditor(props)
|
||||
return <div data-testid="instruction-editor">{String(props.editorKey)}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
describe('InstructionEditorInWorkflow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
filterResults.length = 0
|
||||
mockGetState.mockReturnValue({
|
||||
nodesWithInspectVars: [{ nodeId: 'node-1' }],
|
||||
})
|
||||
mockUseWorkflowVariableType.mockReturnValue('var-type-fn')
|
||||
mockUseAvailableVarList.mockReturnValue({
|
||||
availableVars: [{ variable: 'query' }],
|
||||
availableNodes: [{ id: 'node-1', data: { title: 'Node 1', type: 'llm' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter workflow variables and forward the resolved props to the editor', () => {
|
||||
render(
|
||||
<InstructionEditorInWorkflow
|
||||
nodeId="current-node"
|
||||
value="instruction"
|
||||
editorKey="editor-1"
|
||||
onChange={vi.fn()}
|
||||
generatorType={GeneratorType.prompt}
|
||||
isShowCurrentBlock
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('instruction-editor')).toHaveTextContent('editor-1')
|
||||
expect(filterResults).toEqual([true, false, false, false])
|
||||
expect(mockUseAvailableVarList).toHaveBeenCalledWith('current-node', expect.objectContaining({
|
||||
onlyLeafNodeVar: false,
|
||||
}))
|
||||
expect(mockInstructionEditor).toHaveBeenCalledWith(expect.objectContaining({
|
||||
value: 'instruction',
|
||||
availableVars: [{ variable: 'query' }],
|
||||
availableNodes: [{ id: 'node-1', data: { title: 'Node 1', type: 'llm' } }],
|
||||
getVarType: 'var-type-fn',
|
||||
isShowCurrentBlock: true,
|
||||
isShowLastRunBlock: true,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import InstructionEditor from '../instruction-editor'
|
||||
import { GeneratorType } from '../types'
|
||||
|
||||
const mockEmit = vi.fn()
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
emit: (...args: unknown[]) => mockEmit(...args),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor', () => ({
|
||||
default: (props: {
|
||||
onChange: (value: string) => void
|
||||
placeholder: ReactNode
|
||||
currentBlock: { show: boolean }
|
||||
errorMessageBlock: { show: boolean }
|
||||
lastRunBlock: { show: boolean }
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="prompt-placeholder">{props.placeholder}</div>
|
||||
<div data-testid="current-block">{String(props.currentBlock.show)}</div>
|
||||
<div data-testid="error-block">{String(props.errorMessageBlock.show)}</div>
|
||||
<div data-testid="last-run-block">{String(props.lastRunBlock.show)}</div>
|
||||
<button onClick={() => props.onChange('updated instruction')}>change-instruction</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('InstructionEditor', () => {
|
||||
const baseProps = {
|
||||
editorKey: 'editor-1',
|
||||
value: 'hello',
|
||||
onChange: vi.fn(),
|
||||
availableVars: [],
|
||||
availableNodes: [],
|
||||
isShowCurrentBlock: true,
|
||||
isShowLastRunBlock: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the prompt placeholder and forward text changes', () => {
|
||||
render(
|
||||
<InstructionEditor
|
||||
{...baseProps}
|
||||
generatorType={GeneratorType.prompt}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('generate.instructionPlaceHolderTitle')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('current-block')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('error-block')).toHaveTextContent('false')
|
||||
|
||||
fireEvent.click(screen.getByText('change-instruction'))
|
||||
|
||||
expect(baseProps.onChange).toHaveBeenCalledWith('updated instruction')
|
||||
})
|
||||
|
||||
it('should render the code placeholder and emit quick insert events', () => {
|
||||
render(
|
||||
<InstructionEditor
|
||||
{...baseProps}
|
||||
generatorType={GeneratorType.code}
|
||||
isShowLastRunBlock
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('generate.codeGenInstructionPlaceHolderLine')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('error-block')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('last-run-block')).toHaveTextContent('true')
|
||||
|
||||
fireEvent.click(screen.getByText('generate.insertContext'))
|
||||
|
||||
expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
instanceId: 'editor-1',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import PromptResInWorkflow from '../prompt-res-in-workflow'
|
||||
|
||||
const mockUseAvailableVarList = vi.fn()
|
||||
const mockPromptRes = vi.fn()
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
|
||||
default: (...args: unknown[]) => mockUseAvailableVarList(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../prompt-res', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mockPromptRes(props)
|
||||
return <div data-testid="prompt-res">{String(props.value)}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
describe('PromptResInWorkflow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAvailableVarList.mockReturnValue({
|
||||
availableVars: [{ variable: 'query' }],
|
||||
availableNodes: [
|
||||
{
|
||||
id: 'node-1',
|
||||
data: { title: 'Retriever', type: BlockEnum.KnowledgeRetrieval },
|
||||
width: 120,
|
||||
height: 80,
|
||||
position: { x: 1, y: 2 },
|
||||
},
|
||||
{
|
||||
id: 'start-node',
|
||||
data: { title: 'Start node', type: BlockEnum.Start },
|
||||
width: 100,
|
||||
height: 60,
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should build the workflow variable map and include the synthetic start node entry', () => {
|
||||
render(<PromptResInWorkflow value="prompt" nodeId="node-a" />)
|
||||
|
||||
expect(screen.getByTestId('prompt-res')).toHaveTextContent('prompt')
|
||||
expect(mockUseAvailableVarList).toHaveBeenCalledWith('node-a', expect.objectContaining({
|
||||
onlyLeafNodeVar: false,
|
||||
}))
|
||||
expect(mockPromptRes).toHaveBeenCalledWith(expect.objectContaining({
|
||||
workflowVariableBlock: expect.objectContaining({
|
||||
show: true,
|
||||
variables: [{ variable: 'query' }],
|
||||
workflowNodesMap: expect.objectContaining({
|
||||
'node-1': expect.objectContaining({
|
||||
title: 'Retriever',
|
||||
type: BlockEnum.KnowledgeRetrieval,
|
||||
}),
|
||||
'sys': expect.objectContaining({
|
||||
title: 'blocks.start',
|
||||
type: BlockEnum.Start,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should fall back to an empty variable list when the workflow hook returns no variables', () => {
|
||||
mockUseAvailableVarList.mockReturnValueOnce({
|
||||
availableVars: undefined,
|
||||
availableNodes: [],
|
||||
})
|
||||
|
||||
render(<PromptResInWorkflow value="fallback" nodeId="node-b" />)
|
||||
|
||||
expect(mockPromptRes).toHaveBeenCalledWith(expect.objectContaining({
|
||||
workflowVariableBlock: expect.objectContaining({
|
||||
variables: [],
|
||||
workflowNodesMap: {},
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import PromptRes from '../prompt-res'
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor', () => ({
|
||||
default: ({ value, workflowVariableBlock }: { value: string, workflowVariableBlock: { show: boolean } }) => (
|
||||
<div data-testid="prompt-editor" data-show={String(workflowVariableBlock.show)}>
|
||||
{value}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('PromptRes', () => {
|
||||
it('should render the prompt value and remount when the value changes', () => {
|
||||
const nowSpy = vi.spyOn(Date, 'now')
|
||||
.mockReturnValueOnce(1000)
|
||||
.mockReturnValueOnce(2000)
|
||||
|
||||
const { rerender } = render(
|
||||
<PromptRes
|
||||
value="alpha"
|
||||
workflowVariableBlock={{ show: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('prompt-editor')).toHaveTextContent('alpha')
|
||||
expect(screen.getByTestId('prompt-editor')).toHaveAttribute('data-show', 'false')
|
||||
|
||||
rerender(
|
||||
<PromptRes
|
||||
value="beta"
|
||||
workflowVariableBlock={{ show: true }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('prompt-editor')).toHaveTextContent('beta')
|
||||
expect(screen.getByTestId('prompt-editor')).toHaveAttribute('data-show', 'true')
|
||||
|
||||
nowSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import Result from '../result'
|
||||
import { GeneratorType } from '../types'
|
||||
|
||||
const mockCopy = vi.fn()
|
||||
const mockPromptRes = vi.fn()
|
||||
const mockPromptResInWorkflow = vi.fn()
|
||||
const mockSetCurrentVersionIndex = vi.fn()
|
||||
const mockOnApply = vi.fn()
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: (...args: unknown[]) => mockCopy(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../prompt-res', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mockPromptRes(props)
|
||||
return <div data-testid="prompt-res">{String(props.value)}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../prompt-res-in-workflow', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mockPromptResInWorkflow(props)
|
||||
return <div data-testid="prompt-res-in-workflow">{String(props.value)}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor', () => ({
|
||||
default: ({ value }: { value: string }) => <div data-testid="code-editor">{value}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../prompt-toast', () => ({
|
||||
default: ({ message }: { message: string }) => <div data-testid="prompt-toast">{message}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../version-selector', () => ({
|
||||
default: ({ value, versionLen, onChange }: { value: number, versionLen: number, onChange: (index: number) => void }) => (
|
||||
<button data-testid="version-selector" onClick={() => onChange(versionLen - 1)}>
|
||||
version-
|
||||
{value}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
const baseProps = {
|
||||
current: {
|
||||
modified: 'generated output',
|
||||
message: 'optimization note',
|
||||
},
|
||||
currentVersionIndex: 0,
|
||||
setCurrentVersionIndex: mockSetCurrentVersionIndex,
|
||||
versions: [{ modified: 'v1' }, { modified: 'v2' }],
|
||||
onApply: mockOnApply,
|
||||
}
|
||||
|
||||
describe('Result', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the basic prompt result and support copying or applying it', () => {
|
||||
render(
|
||||
<Result
|
||||
{...baseProps}
|
||||
isBasicMode
|
||||
generatorType={GeneratorType.prompt}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('prompt-toast')).toHaveTextContent('optimization note')
|
||||
expect(screen.getByTestId('prompt-res')).toHaveTextContent('generated output')
|
||||
|
||||
fireEvent.click(screen.getByTestId('version-selector'))
|
||||
fireEvent.click(screen.getAllByRole('button')[1])
|
||||
fireEvent.click(screen.getByText('generate.apply'))
|
||||
|
||||
expect(mockSetCurrentVersionIndex).toHaveBeenCalledWith(1)
|
||||
expect(mockCopy).toHaveBeenCalledWith('generated output')
|
||||
expect(toast.success).toHaveBeenCalledWith('actionMsg.copySuccessfully')
|
||||
expect(mockOnApply).toHaveBeenCalled()
|
||||
expect(mockPromptRes).toHaveBeenCalledWith(expect.objectContaining({
|
||||
workflowVariableBlock: { show: false },
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render workflow prompt results through PromptResInWorkflow when basic mode is disabled', () => {
|
||||
render(
|
||||
<Result
|
||||
{...baseProps}
|
||||
nodeId="node-1"
|
||||
generatorType={GeneratorType.prompt}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('prompt-res-in-workflow')).toHaveTextContent('generated output')
|
||||
expect(mockPromptResInWorkflow).toHaveBeenCalledWith(expect.objectContaining({
|
||||
nodeId: 'node-1',
|
||||
value: 'generated output',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render code results with the code editor for non-prompt generators', () => {
|
||||
render(
|
||||
<Result
|
||||
{...baseProps}
|
||||
generatorType={GeneratorType.code}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('code-editor')).toHaveTextContent('generated output')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import VersionSelector from '../version-selector'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('VersionSelector', () => {
|
||||
it('should not open the selector when only one version exists', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<VersionSelector
|
||||
versionLen={1}
|
||||
value={0}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('generate.version 1 · generate.latest'))
|
||||
|
||||
expect(screen.queryByText('generate.versions')).not.toBeInTheDocument()
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open the selector and switch versions when multiple versions exist', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<VersionSelector
|
||||
versionLen={3}
|
||||
value={2}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('generate.version 3 · generate.latest'))
|
||||
|
||||
expect(screen.getByText('generate.versions')).toBeInTheDocument()
|
||||
expect(screen.getByText('generate.version 1')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('generate.version 1'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(0)
|
||||
expect(screen.queryByText('generate.versions')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,254 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import GetCodeGeneratorResModal from '../get-code-generator-res'
|
||||
|
||||
const mockGenerateRule = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
|
||||
let mockDefaultModel: {
|
||||
model: string
|
||||
provider: {
|
||||
provider: string
|
||||
}
|
||||
} | null = null
|
||||
|
||||
let mockInstructionTemplate: { data: string } | undefined
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
|
||||
defaultModel: mockDefaultModel,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useGenerateRuleTemplate: () => ({
|
||||
data: mockInstructionTemplate,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/debug', () => ({
|
||||
generateRule: (...args: unknown[]) => mockGenerateRule(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
|
||||
default: ({
|
||||
setModel,
|
||||
onCompletionParamsChange,
|
||||
}: {
|
||||
setModel: (value: { modelId: string, provider: string, mode?: string, features?: string[] }) => void
|
||||
onCompletionParamsChange: (value: Record<string, unknown>) => void
|
||||
}) => (
|
||||
<div>
|
||||
<button onClick={() => setModel({ modelId: 'gpt-4o-mini', provider: 'openai', mode: 'chat' })}>change-model</button>
|
||||
<button onClick={() => onCompletionParamsChange({ temperature: 0.2 })}>change-params</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../automatic/instruction-editor-in-workflow', () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="workflow-editor">{value}</div>
|
||||
<button onClick={() => onChange('code instruction')}>set-code-instruction</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../automatic/idea-output', () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="idea-output">{value}</div>
|
||||
<button onClick={() => onChange('code output')}>set-code-output</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../automatic/res-placeholder', () => ({
|
||||
default: () => <div>code-result-placeholder</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../../automatic/result', () => ({
|
||||
default: ({
|
||||
current,
|
||||
onApply,
|
||||
}: {
|
||||
current: { modified?: string, code?: string }
|
||||
onApply: () => void
|
||||
}) => (
|
||||
<div data-testid="code-result-panel">
|
||||
<div>{current.modified || current.code}</div>
|
||||
<button onClick={onApply}>apply-code-result</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('GetCodeGeneratorResModal', () => {
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnFinished = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
mockDefaultModel = {
|
||||
model: 'gpt-4.1-mini',
|
||||
provider: {
|
||||
provider: 'openai',
|
||||
},
|
||||
}
|
||||
mockInstructionTemplate = undefined
|
||||
})
|
||||
|
||||
it('should initialize from template suggestions and persist model updates', async () => {
|
||||
mockInstructionTemplate = { data: 'code template' }
|
||||
|
||||
render(
|
||||
<GetCodeGeneratorResModal
|
||||
flowId="flow-1"
|
||||
nodeId="node-1"
|
||||
currentCode="print(1)"
|
||||
mode={AppModeEnum.CHAT}
|
||||
isShow
|
||||
codeLanguages={CodeLanguage.python3}
|
||||
onClose={mockOnClose}
|
||||
onFinished={mockOnFinished}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('workflow-editor')).toHaveTextContent('code template')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('change-model'))
|
||||
expect(localStorage.getItem('auto-gen-model')).toContain('"name":"gpt-4o-mini"')
|
||||
|
||||
fireEvent.click(screen.getByText('change-params'))
|
||||
expect(localStorage.getItem('auto-gen-model')).toContain('"temperature":0.2')
|
||||
})
|
||||
|
||||
it('should block generation when instruction is empty', () => {
|
||||
render(
|
||||
<GetCodeGeneratorResModal
|
||||
flowId="flow-1"
|
||||
nodeId="node-1"
|
||||
currentCode="print(1)"
|
||||
mode={AppModeEnum.CHAT}
|
||||
isShow
|
||||
codeLanguages={CodeLanguage.python3}
|
||||
onClose={mockOnClose}
|
||||
onFinished={mockOnFinished}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('codegen.generate'))
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith('errorMsg.fieldRequired')
|
||||
expect(mockGenerateRule).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('code-result-placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should generate code, normalize code payloads, and apply the confirmed result', async () => {
|
||||
mockGenerateRule.mockResolvedValue({
|
||||
code: 'print("hello")',
|
||||
})
|
||||
|
||||
render(
|
||||
<GetCodeGeneratorResModal
|
||||
flowId="flow-1"
|
||||
nodeId="node-1"
|
||||
currentCode="print(1)"
|
||||
mode={AppModeEnum.CHAT}
|
||||
isShow
|
||||
codeLanguages={CodeLanguage.python3}
|
||||
onClose={mockOnClose}
|
||||
onFinished={mockOnFinished}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('set-code-instruction'))
|
||||
fireEvent.click(screen.getByText('set-code-output'))
|
||||
fireEvent.click(screen.getByText('codegen.generate'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGenerateRule).toHaveBeenCalledWith(expect.objectContaining({
|
||||
flow_id: 'flow-1',
|
||||
node_id: 'node-1',
|
||||
current: 'print(1)',
|
||||
instruction: 'code instruction',
|
||||
ideal_output: 'code output',
|
||||
language: 'python',
|
||||
}))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('code-result-panel')).toHaveTextContent('print("hello")')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('apply-code-result'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('codegen.overwriteConfirmTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
|
||||
|
||||
expect(mockOnFinished).toHaveBeenCalledWith(expect.objectContaining({
|
||||
modified: 'print("hello")',
|
||||
code: 'print("hello")',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should surface service errors without creating a result version', async () => {
|
||||
mockGenerateRule.mockResolvedValue({
|
||||
error: 'generation failed',
|
||||
modified: 'unused',
|
||||
})
|
||||
|
||||
render(
|
||||
<GetCodeGeneratorResModal
|
||||
flowId="flow-1"
|
||||
nodeId="node-1"
|
||||
currentCode="print(1)"
|
||||
mode={AppModeEnum.CHAT}
|
||||
isShow
|
||||
codeLanguages={CodeLanguage.javascript}
|
||||
onClose={mockOnClose}
|
||||
onFinished={mockOnFinished}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('set-code-instruction'))
|
||||
fireEvent.click(screen.getByText('codegen.generate'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('generation failed')
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('code-result-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
235
web/app/components/app/configuration/configuration-view.tsx
Normal file
235
web/app/components/app/configuration/configuration-view.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { ConfigurationViewModel } from './hooks/use-configuration'
|
||||
import { CodeBracketIcon } from '@heroicons/react/20/solid'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
|
||||
import Config from '@/app/components/app/configuration/config'
|
||||
import EditHistoryModal from '@/app/components/app/configuration/config-prompt/conversation-history/edit-modal'
|
||||
import AgentSettingButton from '@/app/components/app/configuration/config/agent-setting-button'
|
||||
import SelectDataSet from '@/app/components/app/configuration/dataset-config/select-dataset'
|
||||
import Debug from '@/app/components/app/configuration/debug'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Drawer from '@/app/components/base/drawer'
|
||||
import { FeaturesProvider } from '@/app/components/base/features'
|
||||
import NewFeaturePanel from '@/app/components/base/features/new-feature-panel'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@/app/components/base/ui/alert-dialog'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
import PluginDependency from '@/app/components/workflow/plugin-dependency'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { MittProvider } from '@/context/mitt-context-provider'
|
||||
import { AppModeEnum, ModelModeType } from '@/types/app'
|
||||
|
||||
const ConfigurationView: FC<ConfigurationViewModel> = ({
|
||||
appPublisherProps,
|
||||
contextValue,
|
||||
featuresData,
|
||||
isAgent,
|
||||
isAdvancedMode,
|
||||
isMobile,
|
||||
isShowDebugPanel,
|
||||
isShowHistoryModal,
|
||||
isShowSelectDataSet,
|
||||
modelConfig,
|
||||
multipleModelConfigs,
|
||||
onAutoAddPromptVariable,
|
||||
onAgentSettingChange,
|
||||
onCloseFeaturePanel,
|
||||
onCloseHistoryModal,
|
||||
onCloseSelectDataSet,
|
||||
onCompletionParamsChange,
|
||||
onConfirmUseGPT4,
|
||||
onEnableMultipleModelDebug,
|
||||
onFeaturesChange,
|
||||
onHideDebugPanel,
|
||||
onModelChange,
|
||||
onMultipleModelConfigsChange,
|
||||
onOpenAccountSettings,
|
||||
onOpenDebugPanel,
|
||||
onSaveHistory,
|
||||
onSelectDataSets,
|
||||
promptVariables,
|
||||
selectedIds,
|
||||
showAppConfigureFeaturesModal,
|
||||
showLoading,
|
||||
showUseGPT4Confirm,
|
||||
setShowUseGPT4Confirm,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const debugWithMultipleModel = appPublisherProps.debugWithMultipleModel
|
||||
|
||||
if (showLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loading type="area" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigContext.Provider value={contextValue}>
|
||||
<FeaturesProvider features={featuresData}>
|
||||
<MittProvider>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="relative flex h-[200px] grow pt-14">
|
||||
<div className="bg-default-subtle absolute top-0 left-0 h-14 w-full">
|
||||
<div className="flex h-14 items-center justify-between px-6">
|
||||
<div className="flex items-center">
|
||||
<div className="system-xl-semibold text-text-primary">{t('orchestrate', { ns: 'appDebug' })}</div>
|
||||
<div className="flex h-[14px] items-center space-x-1 text-xs">
|
||||
{isAdvancedMode && (
|
||||
<div className="ml-1 flex h-5 items-center rounded-md border border-components-button-secondary-border px-1.5 system-xs-medium-uppercase text-text-tertiary uppercase">
|
||||
{t('promptMode.advanced', { ns: 'appDebug' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isAgent && (
|
||||
<AgentSettingButton
|
||||
isChatModel={contextValue.modelModeType === ModelModeType.chat}
|
||||
agentConfig={modelConfig.agentConfig}
|
||||
isFunctionCall={contextValue.isFunctionCall}
|
||||
onAgentSettingChange={onAgentSettingChange}
|
||||
/>
|
||||
)}
|
||||
{!debugWithMultipleModel && (
|
||||
<>
|
||||
<ModelParameterModal
|
||||
isAdvancedMode={isAdvancedMode}
|
||||
provider={modelConfig.provider}
|
||||
completionParams={contextValue.completionParams}
|
||||
modelId={modelConfig.model_id}
|
||||
setModel={onModelChange}
|
||||
onCompletionParamsChange={onCompletionParamsChange}
|
||||
debugWithMultipleModel={debugWithMultipleModel}
|
||||
onDebugWithMultipleModelChange={onEnableMultipleModelDebug}
|
||||
/>
|
||||
<Divider type="vertical" className="mx-2 h-[14px]" />
|
||||
</>
|
||||
)}
|
||||
{isMobile && (
|
||||
<Button className="mr-2 h-8! text-[13px]! font-medium" onClick={onOpenDebugPanel}>
|
||||
<span className="mr-1">{t('operation.debugConfig', { ns: 'appDebug' })}</span>
|
||||
<CodeBracketIcon className="h-4 w-4 text-text-tertiary" />
|
||||
</Button>
|
||||
)}
|
||||
<AppPublisher {...appPublisherProps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex h-full w-full shrink-0 flex-col sm:w-1/2 ${debugWithMultipleModel && 'max-w-[560px]'}`}>
|
||||
<Config />
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<div className="relative flex h-full w-1/2 grow flex-col overflow-y-auto" style={{ borderColor: 'rgba(0, 0, 0, 0.02)' }}>
|
||||
<div className="flex grow flex-col rounded-tl-2xl border-t-[0.5px] border-l-[0.5px] border-components-panel-border bg-chatbot-bg">
|
||||
<Debug
|
||||
isAPIKeySet={contextValue.isAPIKeySet}
|
||||
onSetting={onOpenAccountSettings}
|
||||
inputs={contextValue.inputs}
|
||||
modelParameterParams={{
|
||||
setModel: onModelChange,
|
||||
onCompletionParamsChange,
|
||||
}}
|
||||
debugWithMultipleModel={!!debugWithMultipleModel}
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onMultipleModelConfigsChange={onMultipleModelConfigsChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showUseGPT4Confirm && (
|
||||
<AlertDialog open={showUseGPT4Confirm} onOpenChange={open => !open && setShowUseGPT4Confirm(false)}>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col items-start gap-2 self-stretch px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
|
||||
{t('trailUseGPT4Info.title', { ns: 'appDebug' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
|
||||
{t('trailUseGPT4Info.description', { ns: 'appDebug' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton destructive={false}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton variant="primary" destructive={false} onClick={onConfirmUseGPT4}>
|
||||
{t('operation.confirm', { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
{isShowSelectDataSet && (
|
||||
<SelectDataSet
|
||||
isShow={isShowSelectDataSet}
|
||||
onClose={onCloseSelectDataSet}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={onSelectDataSets}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isShowHistoryModal && (
|
||||
<EditHistoryModal
|
||||
isShow={isShowHistoryModal}
|
||||
saveLoading={false}
|
||||
onClose={onCloseHistoryModal}
|
||||
data={contextValue.completionPromptConfig.conversation_histories_role}
|
||||
onSave={onSaveHistory}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMobile && (
|
||||
<Drawer showClose isOpen={isShowDebugPanel} onClose={onHideDebugPanel} mask footer={null}>
|
||||
<Debug
|
||||
isAPIKeySet={contextValue.isAPIKeySet}
|
||||
onSetting={onOpenAccountSettings}
|
||||
inputs={contextValue.inputs}
|
||||
modelParameterParams={{
|
||||
setModel: onModelChange,
|
||||
onCompletionParamsChange,
|
||||
}}
|
||||
debugWithMultipleModel={!!debugWithMultipleModel}
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onMultipleModelConfigsChange={onMultipleModelConfigsChange}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
{showAppConfigureFeaturesModal && (
|
||||
<NewFeaturePanel
|
||||
show
|
||||
inWorkflow={false}
|
||||
showFileUpload={false}
|
||||
isChatMode={contextValue.mode !== AppModeEnum.COMPLETION}
|
||||
disabled={false}
|
||||
onChange={onFeaturesChange}
|
||||
onClose={onCloseFeaturePanel}
|
||||
promptVariables={promptVariables}
|
||||
onAutoAddPromptVariable={onAutoAddPromptVariable}
|
||||
/>
|
||||
)}
|
||||
<PluginDependency />
|
||||
</MittProvider>
|
||||
</FeaturesProvider>
|
||||
</ConfigContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ConfigurationView)
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ContrlBtnGroup from './index'
|
||||
import ContrlBtnGroup from '../index'
|
||||
|
||||
describe('ContrlBtnGroup', () => {
|
||||
beforeEach(() => {
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import type { DatasetConfigs } from '@/models/debug'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { ComparisonOperator, LogicalOperator } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
|
||||
@@ -8,7 +9,7 @@ import { getSelectedDatasetsMode } from '@/app/components/workflow/nodes/knowled
|
||||
import { DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { AppModeEnum, ModelModeType, RETRIEVE_TYPE } from '@/types/app'
|
||||
import { hasEditPermissionForDataset } from '@/utils/permission'
|
||||
import DatasetConfig from './index'
|
||||
import DatasetConfig from '../index'
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/utils', () => ({
|
||||
@@ -48,7 +49,7 @@ vi.mock('@/utils/permission', () => ({
|
||||
hasEditPermissionForDataset: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
vi.mock('../debug/hooks', () => ({
|
||||
vi.mock('../../debug/hooks', () => ({
|
||||
useFormattingChangedDispatcher: vi.fn(() => vi.fn()),
|
||||
}))
|
||||
|
||||
@@ -79,7 +80,7 @@ vi.mock('uuid', () => ({
|
||||
}))
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./card-item', () => ({
|
||||
vi.mock('../card-item', () => ({
|
||||
default: ({ config, onRemove, onSave, editable }: any) => (
|
||||
<div data-testid={`card-item-${config.id}`}>
|
||||
<span>{config.name}</span>
|
||||
@@ -89,7 +90,7 @@ vi.mock('./card-item', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./params-config', () => ({
|
||||
vi.mock('../params-config', () => ({
|
||||
default: ({ disabled, selectedDatasets }: any) => (
|
||||
<button data-testid="params-config" disabled={disabled}>
|
||||
Params (
|
||||
@@ -99,7 +100,7 @@ vi.mock('./params-config', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./context-var', () => ({
|
||||
vi.mock('../context-var', () => ({
|
||||
default: ({ value, options, onChange }: any) => (
|
||||
<select data-testid="context-var" value={value} onChange={e => onChange(e.target.value)}>
|
||||
<option value="">Select context variable</option>
|
||||
@@ -119,6 +120,8 @@ vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/components/metadata
|
||||
handleRemoveCondition,
|
||||
handleUpdateCondition,
|
||||
handleToggleConditionLogicalOperator,
|
||||
handleMetadataModelChange,
|
||||
handleMetadataCompletionParamsChange,
|
||||
}: any) => (
|
||||
<div data-testid="metadata-filter">
|
||||
<span data-testid="metadata-list-count">{metadataList.length}</span>
|
||||
@@ -130,6 +133,9 @@ vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/components/metadata
|
||||
<button onClick={() => handleAddCondition({ name: 'test', type: 'string' })}>
|
||||
Add Condition
|
||||
</button>
|
||||
<button onClick={() => handleAddCondition({ id: 'priority', name: 'priority', type: 'number' })}>
|
||||
Add Number Condition
|
||||
</button>
|
||||
<button onClick={() => handleRemoveCondition('condition-id')}>
|
||||
Remove Condition
|
||||
</button>
|
||||
@@ -139,6 +145,12 @@ vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/components/metadata
|
||||
<button onClick={handleToggleConditionLogicalOperator}>
|
||||
Toggle Operator
|
||||
</button>
|
||||
<button onClick={() => handleMetadataModelChange({ provider: 'openai', modelId: 'gpt-4o-mini' })}>
|
||||
Change Metadata Model
|
||||
</button>
|
||||
<button onClick={() => handleMetadataCompletionParamsChange({ temperature: 0.3 })}>
|
||||
Change Metadata Params
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@@ -566,6 +578,53 @@ describe('DatasetConfig', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should append numeric metadata conditions with the equal operator when conditions already exist', async () => {
|
||||
renderDatasetConfig({
|
||||
dataSets: [createMockDataset()],
|
||||
datasetConfigs: {
|
||||
...mockConfigContext.datasetConfigs,
|
||||
metadata_filtering_conditions: {
|
||||
logical_operator: LogicalOperator.and,
|
||||
conditions: [{
|
||||
id: 'condition-id',
|
||||
metadata_id: 'category',
|
||||
name: 'category',
|
||||
comparison_operator: ComparisonOperator.is,
|
||||
}],
|
||||
},
|
||||
},
|
||||
datasetConfigsRef: {
|
||||
current: {
|
||||
...mockConfigContext.datasetConfigsRef.current,
|
||||
metadata_filtering_conditions: {
|
||||
logical_operator: LogicalOperator.and,
|
||||
conditions: [{
|
||||
id: 'condition-id',
|
||||
metadata_id: 'category',
|
||||
name: 'category',
|
||||
comparison_operator: ComparisonOperator.is,
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await userEvent.click(within(screen.getByTestId('metadata-filter')).getByText('Add Number Condition'))
|
||||
|
||||
expect(mockConfigContext.setDatasetConfigs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata_filtering_conditions: expect.objectContaining({
|
||||
conditions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'priority',
|
||||
comparison_operator: ComparisonOperator.equal,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle removing metadata conditions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const dataset = createMockDataset()
|
||||
@@ -824,8 +883,19 @@ describe('DatasetConfig', () => {
|
||||
},
|
||||
})
|
||||
|
||||
// The component would need to expose this functionality through the metadata filter
|
||||
expect(screen.getByTestId('metadata-filter')).toBeInTheDocument()
|
||||
const metadataFilter = screen.getByTestId('metadata-filter')
|
||||
expect(metadataFilter).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(metadataFilter).getByText('Change Metadata Model'))
|
||||
|
||||
expect(mockConfigContext.setDatasetConfigs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
metadata_model_config: {
|
||||
provider: 'openai',
|
||||
name: 'gpt-4o-mini',
|
||||
mode: AppModeEnum.CHAT,
|
||||
completion_params: { temperature: 0.7 },
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle metadata completion params change', () => {
|
||||
@@ -844,7 +914,16 @@ describe('DatasetConfig', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('metadata-filter')).toBeInTheDocument()
|
||||
const metadataFilter = screen.getByTestId('metadata-filter')
|
||||
expect(metadataFilter).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(metadataFilter).getByText('Change Metadata Params'))
|
||||
|
||||
expect(mockConfigContext.setDatasetConfigs).toHaveBeenCalledWith(expect.objectContaining({
|
||||
metadata_model_config: {
|
||||
completion_params: { temperature: 0.3 },
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,9 +8,9 @@ import userEvent from '@testing-library/user-event'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import Item from './index'
|
||||
import Item from '../index'
|
||||
|
||||
vi.mock('../settings-modal', () => ({
|
||||
vi.mock('../../settings-modal', () => ({
|
||||
default: ({ onSave, onCancel, currentDataset }: { currentDataset: DataSet, onCancel: () => void, onSave: (newDataset: DataSet) => void }) => (
|
||||
<div>
|
||||
<div>Mock settings modal</div>
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Props } from './var-picker'
|
||||
import type { Props } from '../var-picker'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import ContextVar from './index'
|
||||
import ContextVar from '../index'
|
||||
|
||||
// Mock external dependencies only
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Props } from './var-picker'
|
||||
import type { Props } from '../var-picker'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import VarPicker from './var-picker'
|
||||
import VarPicker from '../var-picker'
|
||||
|
||||
// Mock external dependencies only
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType, RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app'
|
||||
import ConfigContent from './config-content'
|
||||
import ConfigContent from '../config-content'
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => {
|
||||
type Props = {
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user