Compare commits

...

13 Commits

Author SHA1 Message Date
Stephen Zhou
cb9f4bb100 build: include packages in docker build (#34532)
Some checks are pending
autofix.ci / autofix (push) Waiting to run
Build and Push API & Web / build (api, {{defaultContext}}:api, Dockerfile, DIFY_API_IMAGE_NAME, linux/amd64, ubuntu-latest, build-api-amd64) (push) Waiting to run
Build and Push API & Web / build (api, {{defaultContext}}:api, Dockerfile, DIFY_API_IMAGE_NAME, linux/arm64, ubuntu-24.04-arm, build-api-arm64) (push) Waiting to run
Build and Push API & Web / build (web, {{defaultContext}}, web/Dockerfile, DIFY_WEB_IMAGE_NAME, linux/amd64, ubuntu-latest, build-web-amd64) (push) Waiting to run
Build and Push API & Web / build (web, {{defaultContext}}, web/Dockerfile, DIFY_WEB_IMAGE_NAME, linux/arm64, ubuntu-24.04-arm, build-web-arm64) (push) Waiting to run
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Blocked by required conditions
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Blocked by required conditions
Main CI Pipeline / Skip Duplicate Checks (push) Waiting to run
Main CI Pipeline / Check Changed Files (push) Blocked by required conditions
Main CI Pipeline / Run API Tests (push) Blocked by required conditions
Main CI Pipeline / Skip API Tests (push) Blocked by required conditions
Main CI Pipeline / API Tests (push) Blocked by required conditions
Main CI Pipeline / Run Web Tests (push) Blocked by required conditions
Main CI Pipeline / Skip Web Tests (push) Blocked by required conditions
Main CI Pipeline / Web Tests (push) Blocked by required conditions
Main CI Pipeline / Run Web Full-Stack E2E (push) Blocked by required conditions
Main CI Pipeline / Skip Web Full-Stack E2E (push) Blocked by required conditions
Main CI Pipeline / Web Full-Stack E2E (push) Blocked by required conditions
Main CI Pipeline / Style Check (push) Blocked by required conditions
Main CI Pipeline / Run VDB Tests (push) Blocked by required conditions
Main CI Pipeline / Skip VDB Tests (push) Blocked by required conditions
Main CI Pipeline / VDB Tests (push) Blocked by required conditions
Main CI Pipeline / Run DB Migration Test (push) Blocked by required conditions
Main CI Pipeline / Skip DB Migration Test (push) Blocked by required conditions
Main CI Pipeline / DB Migration Test (push) Blocked by required conditions
Trigger i18n Sync on Push / trigger (push) Waiting to run
2026-04-03 13:40:16 +00:00
YBoy
8a398f3105 refactor(api): type messages cleanup stats with MessagesCleanStatsDict TypedDict (#34527) 2026-04-03 12:29:41 +00:00
YBoy
0f051d5886 refactor(api): type celery sqlcommenter tags with CelerySqlcommenterTagsDict TypedDict (#34526) 2026-04-03 12:06:15 +00:00
Renzo
e85d9a0d72 refactor: select in dataset_service (DatasetService class) (#34525)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 12:01:31 +00:00
Renzo
06dde4f503 refactor: select in account_service (TenantService class) (#34499)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-04-03 11:03:45 +00:00
Coding On Star
83d4176785 test: add unit tests for app store and annotation components, enhancing coverage for state management and UI interactions (#34510)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 09:09:59 +00:00
yyh
c94951b2f8 refactor(web): migrate notion page selectors to tanstack virtual (#34508)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 07:03:12 +00:00
Matt Van Horn
a9cf8f6c5d refactor(web): replace react-syntax-highlighter with shiki (#33473)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 06:40:26 +00:00
YBoy
64ddec0d67 refactor(api): type annotation service dicts with TypedDict (#34482)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-04-03 06:25:52 +00:00
Renzo
da3b0caf5e refactor: select in account_service (RegisterService class) (#34500)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, {{defaultContext}}:api, Dockerfile, DIFY_API_IMAGE_NAME, linux/amd64, ubuntu-latest, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, {{defaultContext}}:api, Dockerfile, DIFY_API_IMAGE_NAME, linux/arm64, ubuntu-24.04-arm, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, {{defaultContext}}, web/Dockerfile, DIFY_WEB_IMAGE_NAME, linux/amd64, ubuntu-latest, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, {{defaultContext}}, web/Dockerfile, DIFY_WEB_IMAGE_NAME, linux/arm64, ubuntu-24.04-arm, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Skip Duplicate Checks (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / Run API Tests (push) Has been cancelled
Main CI Pipeline / Skip API Tests (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Run Web Tests (push) Has been cancelled
Main CI Pipeline / Skip Web Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Run Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Skip Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / Run VDB Tests (push) Has been cancelled
Main CI Pipeline / Skip VDB Tests (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / Run DB Migration Test (push) Has been cancelled
Main CI Pipeline / Skip DB Migration Test (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 06:21:26 +00:00
Stephen Zhou
4fedd43af5 chore: update code-inspector-plugin to 1.5.1 (#34506) 2026-04-03 05:34:03 +00:00
yyh
a263f28e19 fix(web): restore ui select public exports (#34501) 2026-04-03 04:42:02 +00:00
Stephen Zhou
d53862f135 chore: override lodash (#34502) 2026-04-03 04:40:46 +00:00
212 changed files with 17787 additions and 5616 deletions

View File

@@ -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()}"

View File

@@ -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

View File

@@ -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 = (

View File

@@ -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):

View File

@@ -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,

View File

@@ -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")

View File

@@ -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")

View File

@@ -5,7 +5,6 @@
"prepare": "vp config"
},
"devDependencies": {
"taze": "catalog:",
"vite-plus": "catalog:"
},
"engines": {

560
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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',
],
})

View File

@@ -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

View File

@@ -7,6 +7,9 @@
!web/**
!e2e/
!e2e/package.json
!packages/
!packages/**/
!packages/**/package.json
!sdks/
!sdks/nodejs-client/
!sdks/nodejs-client/package.json

View 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 }

View 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')
})
})

View File

@@ -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 = {

View File

@@ -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', () => {

View File

@@ -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')

View File

@@ -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()
})
})

View File

@@ -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')

View File

@@ -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(),

View File

@@ -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', () => {

View File

@@ -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[] = []

View File

@@ -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()

View File

@@ -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

View File

@@ -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: () => ({

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

@@ -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 }

View File

@@ -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: () => ({

View File

@@ -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',

View File

@@ -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,

View File

@@ -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 },
}))
})
})
})

View 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()
})
})

View File

@@ -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))
})
})

View 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()
})
})

View File

@@ -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)
})
})

View 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')
})
})
})

View File

@@ -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)
})
})

View File

@@ -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

View 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>
)
}

View 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
}

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View 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)
})
})
})

View File

@@ -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.

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import GroupName from './index'
import GroupName from '../index'
describe('GroupName', () => {
beforeEach(() => {

View File

@@ -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 }) => (

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import VarHighlight, { varHighlightHTML } from './index'
import VarHighlight, { varHighlightHTML } from '../index'
describe('VarHighlight', () => {
beforeEach(() => {

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

@@ -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

View File

@@ -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,
}))
})
})

View File

@@ -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

View File

@@ -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(() => {

View File

@@ -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(() => {

View File

@@ -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}}', [])
})
})

View File

@@ -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>,
}))

View File

@@ -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>,

View File

@@ -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 }) => (

View File

@@ -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()
})
})
})

View File

@@ -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}')
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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' })
})
})

View File

@@ -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({})
})
})
})

View File

@@ -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)

View File

@@ -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}

View File

@@ -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,
}
}

View File

@@ -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'])
})
})

View File

@@ -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()

View File

@@ -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

View File

@@ -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) => {

View File

@@ -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 (

View File

@@ -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) => {

View File

@@ -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) => {

View File

@@ -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" />

View File

@@ -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')>()

View File

@@ -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', () => {

View File

@@ -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} />,
}))

View File

@@ -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()

View File

@@ -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 = {

View File

@@ -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: () => ({

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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,
}))
})
})

View File

@@ -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',
}))
})
})

View File

@@ -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: {},
}),
}))
})
})

View File

@@ -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()
})
})

View File

@@ -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')
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View 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)

View File

@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import ContrlBtnGroup from './index'
import ContrlBtnGroup from '../index'
describe('ContrlBtnGroup', () => {
beforeEach(() => {

View File

@@ -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 },
},
}))
})
})

View File

@@ -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>

View File

@@ -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', () => ({

View File

@@ -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', () => ({

View File

@@ -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