mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
Merge branch 'main' into copilot/fix-css-issue
This commit is contained in:
commit
c61df1942f
22
.github/workflows/db-migration-test.yml
vendored
22
.github/workflows/db-migration-test.yml
vendored
@ -110,6 +110,28 @@ jobs:
|
||||
sed -i 's/DB_PORT=5432/DB_PORT=3306/' .env
|
||||
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=root/' .env
|
||||
|
||||
# hoverkraft-tech/compose-action@v2.6.0 only waits for `docker compose up -d`
|
||||
# to return (container processes started); it does not wait on healthcheck
|
||||
# status. mysql:8.0's first-time init takes 15-30s, so without an explicit
|
||||
# wait the migration runs while InnoDB is still initialising and gets
|
||||
# killed with "Lost connection during query". Poll a real SELECT until it
|
||||
# succeeds.
|
||||
- name: Wait for MySQL to accept queries
|
||||
run: |
|
||||
set +e
|
||||
for i in $(seq 1 60); do
|
||||
if docker run --rm --network host mysql:8.0 \
|
||||
mysql -h 127.0.0.1 -P 3306 -uroot -pdifyai123456 \
|
||||
-e 'SELECT 1' >/dev/null 2>&1; then
|
||||
echo "MySQL ready after ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "MySQL not ready after 60s; dumping container logs:"
|
||||
docker compose -f docker/docker-compose.middleware.yaml --profile mysql logs --tail=200 db_mysql
|
||||
exit 1
|
||||
|
||||
- name: Run DB Migration
|
||||
env:
|
||||
DEBUG: true
|
||||
|
||||
2
.github/workflows/web-e2e.yml
vendored
2
.github/workflows/web-e2e.yml
vendored
@ -13,7 +13,7 @@ concurrency:
|
||||
jobs:
|
||||
test:
|
||||
name: Web Full-Stack E2E
|
||||
runs-on: depot-ubuntu-24.04
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
@ -38,6 +38,48 @@ class HitTestingPayload(BaseModel):
|
||||
|
||||
|
||||
class DatasetsHitTestingBase:
|
||||
@staticmethod
|
||||
def _normalize_hit_testing_query(query: Any) -> str:
|
||||
"""Return the user-visible query string from legacy and current response shapes."""
|
||||
if isinstance(query, str):
|
||||
return query
|
||||
|
||||
if isinstance(query, dict):
|
||||
content = query.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
|
||||
raise ValueError("Invalid hit testing query response")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_hit_testing_records(records: Any) -> list[dict[str, Any]]:
|
||||
"""Coerce nullable collection fields into lists before response validation."""
|
||||
if not isinstance(records, list):
|
||||
return []
|
||||
|
||||
normalized_records: list[dict[str, Any]] = []
|
||||
for record in records:
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
|
||||
normalized_record = dict(record)
|
||||
segment = normalized_record.get("segment")
|
||||
if isinstance(segment, dict):
|
||||
normalized_segment = dict(segment)
|
||||
if normalized_segment.get("keywords") is None:
|
||||
normalized_segment["keywords"] = []
|
||||
normalized_record["segment"] = normalized_segment
|
||||
|
||||
if normalized_record.get("child_chunks") is None:
|
||||
normalized_record["child_chunks"] = []
|
||||
|
||||
if normalized_record.get("files") is None:
|
||||
normalized_record["files"] = []
|
||||
|
||||
normalized_records.append(normalized_record)
|
||||
|
||||
return normalized_records
|
||||
|
||||
@staticmethod
|
||||
def get_and_validate_dataset(dataset_id: str):
|
||||
assert isinstance(current_user, Account)
|
||||
@ -75,7 +117,12 @@ class DatasetsHitTestingBase:
|
||||
attachment_ids=args.get("attachment_ids"),
|
||||
limit=10,
|
||||
)
|
||||
return {"query": response["query"], "records": marshal(response["records"], hit_testing_record_fields)}
|
||||
return {
|
||||
"query": DatasetsHitTestingBase._normalize_hit_testing_query(response.get("query")),
|
||||
"records": DatasetsHitTestingBase._normalize_hit_testing_records(
|
||||
marshal(response.get("records", []), hit_testing_record_fields)
|
||||
),
|
||||
}
|
||||
except services.errors.index.IndexNotInitializedError:
|
||||
raise DatasetNotInitializedError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
|
||||
@ -468,15 +468,98 @@ class DocumentAddByFileApi(DatasetApiResource):
|
||||
return documents_and_batch_fields, 200
|
||||
|
||||
|
||||
def _update_document_by_file(tenant_id: str, dataset_id: UUID, document_id: UUID) -> tuple[Mapping[str, object], int]:
|
||||
"""Update a document from an uploaded file for canonical and deprecated routes."""
|
||||
dataset_id_str = str(dataset_id)
|
||||
tenant_id_str = str(tenant_id)
|
||||
dataset = db.session.scalar(
|
||||
select(Dataset).where(Dataset.tenant_id == tenant_id_str, Dataset.id == dataset_id_str).limit(1)
|
||||
)
|
||||
|
||||
if not dataset:
|
||||
raise ValueError("Dataset does not exist.")
|
||||
|
||||
if dataset.provider == "external":
|
||||
raise ValueError("External datasets are not supported.")
|
||||
|
||||
args: dict[str, object] = {}
|
||||
if "data" in request.form:
|
||||
args = json.loads(request.form["data"])
|
||||
if "doc_form" not in args:
|
||||
args["doc_form"] = dataset.chunk_structure or "text_model"
|
||||
if "doc_language" not in args:
|
||||
args["doc_language"] = "English"
|
||||
|
||||
# indexing_technique is already set in dataset since this is an update
|
||||
args["indexing_technique"] = dataset.indexing_technique
|
||||
|
||||
if "file" in request.files:
|
||||
# save file info
|
||||
file = request.files["file"]
|
||||
|
||||
if len(request.files) > 1:
|
||||
raise TooManyFilesError()
|
||||
|
||||
if not file.filename:
|
||||
raise FilenameNotExistsError
|
||||
|
||||
if not current_user:
|
||||
raise ValueError("current_user is required")
|
||||
|
||||
try:
|
||||
upload_file = FileService(db.engine).upload_file(
|
||||
filename=file.filename,
|
||||
content=file.read(),
|
||||
mimetype=file.mimetype,
|
||||
user=current_user,
|
||||
source="datasets",
|
||||
)
|
||||
except services.errors.file.FileTooLargeError as file_too_large_error:
|
||||
raise FileTooLargeError(file_too_large_error.description)
|
||||
except services.errors.file.UnsupportedFileTypeError:
|
||||
raise UnsupportedFileTypeError()
|
||||
data_source = {
|
||||
"type": "upload_file",
|
||||
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
|
||||
}
|
||||
args["data_source"] = data_source
|
||||
|
||||
# validate args
|
||||
args["original_document_id"] = str(document_id)
|
||||
|
||||
knowledge_config = KnowledgeConfig.model_validate(args)
|
||||
DocumentService.document_create_args_validate(knowledge_config)
|
||||
|
||||
try:
|
||||
documents, _ = DocumentService.save_document_with_dataset_id(
|
||||
dataset=dataset,
|
||||
knowledge_config=knowledge_config,
|
||||
account=dataset.created_by_account,
|
||||
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
|
||||
created_from="api",
|
||||
)
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
document = documents[0]
|
||||
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": document.batch}
|
||||
return documents_and_batch_fields, 200
|
||||
|
||||
|
||||
@service_api_ns.route(
|
||||
"/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update_by_file",
|
||||
"/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update-by-file",
|
||||
)
|
||||
class DocumentUpdateByFileApi(DatasetApiResource):
|
||||
"""Resource for update documents."""
|
||||
class DeprecatedDocumentUpdateByFileApi(DatasetApiResource):
|
||||
"""Deprecated resource aliases for file document updates."""
|
||||
|
||||
@service_api_ns.doc("update_document_by_file")
|
||||
@service_api_ns.doc(description="Update an existing document by uploading a file")
|
||||
@service_api_ns.doc("update_document_by_file_deprecated")
|
||||
@service_api_ns.doc(deprecated=True)
|
||||
@service_api_ns.doc(
|
||||
description=(
|
||||
"Deprecated legacy alias for updating an existing document by uploading a file. "
|
||||
"Use PATCH /datasets/{dataset_id}/documents/{document_id} instead."
|
||||
)
|
||||
)
|
||||
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
@ -487,82 +570,9 @@ class DocumentUpdateByFileApi(DatasetApiResource):
|
||||
)
|
||||
@cloud_edition_billing_resource_check("vector_space", "dataset")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
|
||||
def post(self, tenant_id, dataset_id, document_id):
|
||||
"""Update document by upload file."""
|
||||
dataset = db.session.scalar(
|
||||
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
|
||||
)
|
||||
|
||||
if not dataset:
|
||||
raise ValueError("Dataset does not exist.")
|
||||
|
||||
if dataset.provider == "external":
|
||||
raise ValueError("External datasets are not supported.")
|
||||
|
||||
args = {}
|
||||
if "data" in request.form:
|
||||
args = json.loads(request.form["data"])
|
||||
if "doc_form" not in args:
|
||||
args["doc_form"] = dataset.chunk_structure or "text_model"
|
||||
if "doc_language" not in args:
|
||||
args["doc_language"] = "English"
|
||||
|
||||
# get dataset info
|
||||
dataset_id = str(dataset_id)
|
||||
tenant_id = str(tenant_id)
|
||||
|
||||
# indexing_technique is already set in dataset since this is an update
|
||||
args["indexing_technique"] = dataset.indexing_technique
|
||||
|
||||
if "file" in request.files:
|
||||
# save file info
|
||||
file = request.files["file"]
|
||||
|
||||
if len(request.files) > 1:
|
||||
raise TooManyFilesError()
|
||||
|
||||
if not file.filename:
|
||||
raise FilenameNotExistsError
|
||||
|
||||
if not current_user:
|
||||
raise ValueError("current_user is required")
|
||||
|
||||
try:
|
||||
upload_file = FileService(db.engine).upload_file(
|
||||
filename=file.filename,
|
||||
content=file.read(),
|
||||
mimetype=file.mimetype,
|
||||
user=current_user,
|
||||
source="datasets",
|
||||
)
|
||||
except services.errors.file.FileTooLargeError as file_too_large_error:
|
||||
raise FileTooLargeError(file_too_large_error.description)
|
||||
except services.errors.file.UnsupportedFileTypeError:
|
||||
raise UnsupportedFileTypeError()
|
||||
data_source = {
|
||||
"type": "upload_file",
|
||||
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
|
||||
}
|
||||
args["data_source"] = data_source
|
||||
# validate args
|
||||
args["original_document_id"] = str(document_id)
|
||||
|
||||
knowledge_config = KnowledgeConfig.model_validate(args)
|
||||
DocumentService.document_create_args_validate(knowledge_config)
|
||||
|
||||
try:
|
||||
documents, _ = DocumentService.save_document_with_dataset_id(
|
||||
dataset=dataset,
|
||||
knowledge_config=knowledge_config,
|
||||
account=dataset.created_by_account,
|
||||
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
|
||||
created_from="api",
|
||||
)
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
document = documents[0]
|
||||
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": document.batch}
|
||||
return documents_and_batch_fields, 200
|
||||
def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
|
||||
"""Update document by file through the deprecated file-update aliases."""
|
||||
return _update_document_by_file(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
|
||||
|
||||
|
||||
@service_api_ns.route("/datasets/<uuid:dataset_id>/documents")
|
||||
@ -876,6 +886,22 @@ class DocumentApi(DatasetApiResource):
|
||||
|
||||
return response
|
||||
|
||||
@service_api_ns.doc("update_document_by_file")
|
||||
@service_api_ns.doc(description="Update an existing document by uploading a file")
|
||||
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Document updated successfully",
|
||||
401: "Unauthorized - invalid API token",
|
||||
404: "Document not found",
|
||||
}
|
||||
)
|
||||
@cloud_edition_billing_resource_check("vector_space", "dataset")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
|
||||
def patch(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
|
||||
"""Update document by file on the canonical document resource."""
|
||||
return _update_document_by_file(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
|
||||
|
||||
@service_api_ns.doc("delete_document")
|
||||
@service_api_ns.doc(description="Delete a document")
|
||||
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
|
||||
|
||||
@ -134,6 +134,42 @@ class TestPerformHitTesting:
|
||||
assert result["query"] == "hello"
|
||||
assert result["records"] == []
|
||||
|
||||
def test_success_normalizes_legacy_query_and_nullable_list_fields(self, dataset):
|
||||
response = {
|
||||
"query": {"content": "hello"},
|
||||
"records": [
|
||||
{
|
||||
"segment": {"id": "segment-1", "keywords": None},
|
||||
"child_chunks": None,
|
||||
"files": None,
|
||||
"score": 0.8,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
HitTestingService,
|
||||
"retrieve",
|
||||
return_value=response,
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.hit_testing_base.marshal",
|
||||
return_value=response["records"],
|
||||
),
|
||||
):
|
||||
result = DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"})
|
||||
|
||||
assert result["query"] == "hello"
|
||||
assert result["records"] == [
|
||||
{
|
||||
"segment": {"id": "segment-1", "keywords": []},
|
||||
"child_chunks": [],
|
||||
"files": [],
|
||||
"score": 0.8,
|
||||
}
|
||||
]
|
||||
|
||||
def test_index_not_initialized(self, dataset):
|
||||
with patch.object(
|
||||
HitTestingService,
|
||||
|
||||
@ -23,6 +23,7 @@ from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from controllers.service_api.dataset.document import (
|
||||
DeprecatedDocumentAddByTextApi,
|
||||
DeprecatedDocumentUpdateByFileApi,
|
||||
DeprecatedDocumentUpdateByTextApi,
|
||||
DocumentAddByFileApi,
|
||||
DocumentAddByTextApi,
|
||||
@ -32,7 +33,6 @@ from controllers.service_api.dataset.document import (
|
||||
DocumentListQuery,
|
||||
DocumentTextCreatePayload,
|
||||
DocumentTextUpdate,
|
||||
DocumentUpdateByFileApi,
|
||||
DocumentUpdateByTextApi,
|
||||
InvalidMetadataError,
|
||||
)
|
||||
@ -1095,8 +1095,8 @@ class TestArchivedDocumentImmutableError:
|
||||
assert error.code == 403
|
||||
|
||||
|
||||
class TestDocumentTextRouteDeprecation:
|
||||
"""Test that legacy underscore text routes stay marked deprecated."""
|
||||
class TestDocumentRouteDeprecation:
|
||||
"""Test that legacy document routes stay marked deprecated."""
|
||||
|
||||
def test_create_by_text_legacy_alias_is_deprecated(self):
|
||||
"""Ensure only the legacy create-by-text alias is marked deprecated."""
|
||||
@ -1108,10 +1108,15 @@ class TestDocumentTextRouteDeprecation:
|
||||
assert DeprecatedDocumentUpdateByTextApi.post.__apidoc__["deprecated"] is True
|
||||
assert DocumentUpdateByTextApi.post.__apidoc__.get("deprecated") is not True
|
||||
|
||||
def test_update_by_file_legacy_aliases_are_deprecated(self):
|
||||
"""Ensure only the legacy file-update aliases are marked deprecated."""
|
||||
assert DeprecatedDocumentUpdateByFileApi.post.__apidoc__["deprecated"] is True
|
||||
assert DocumentApi.patch.__apidoc__.get("deprecated") is not True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoint tests for DocumentUpdateByTextApi, DocumentAddByFileApi,
|
||||
# DocumentUpdateByFileApi.
|
||||
# and the canonical/deprecated document file update routes.
|
||||
#
|
||||
# These controllers use ``@cloud_edition_billing_resource_check`` (does NOT
|
||||
# preserve ``__wrapped__``) and ``@cloud_edition_billing_rate_limit_check``
|
||||
@ -1359,13 +1364,52 @@ class TestDocumentAddByFileApiPost:
|
||||
api.post(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id)
|
||||
|
||||
|
||||
class TestDocumentUpdateByFileApiPost:
|
||||
"""Test suite for DocumentUpdateByFileApi.post() endpoint.
|
||||
class TestDocumentUpdateByFileApiPatch:
|
||||
"""Test suite for the canonical document file update endpoint.
|
||||
|
||||
``post`` is wrapped by ``@cloud_edition_billing_resource_check`` and
|
||||
``patch`` is wrapped by ``@cloud_edition_billing_resource_check`` and
|
||||
``@cloud_edition_billing_rate_limit_check``.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("route_name", ["update_by_file", "update-by-file"])
|
||||
@patch("controllers.service_api.dataset.document._update_document_by_file")
|
||||
@patch("controllers.service_api.wraps.FeatureService")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_update_by_file_deprecated_aliases_delegate_to_shared_handler(
|
||||
self,
|
||||
mock_validate_token,
|
||||
mock_feature_svc,
|
||||
mock_update_document_by_file,
|
||||
route_name,
|
||||
app,
|
||||
mock_tenant,
|
||||
mock_dataset,
|
||||
):
|
||||
"""Test legacy POST aliases still dispatch while marked deprecated."""
|
||||
_setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
|
||||
mock_update_document_by_file.return_value = ({"document": {"id": "doc-1"}, "batch": "batch-1"}, 200)
|
||||
|
||||
doc_id = str(uuid.uuid4())
|
||||
with app.test_request_context(
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}/{route_name}",
|
||||
method="POST",
|
||||
headers={"Authorization": "Bearer test_token"},
|
||||
):
|
||||
api = DeprecatedDocumentUpdateByFileApi()
|
||||
response, status = api.post(
|
||||
tenant_id=mock_tenant.id,
|
||||
dataset_id=mock_dataset.id,
|
||||
document_id=doc_id,
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
assert response["batch"] == "batch-1"
|
||||
mock_update_document_by_file.assert_called_once_with(
|
||||
tenant_id=mock_tenant.id,
|
||||
dataset_id=mock_dataset.id,
|
||||
document_id=doc_id,
|
||||
)
|
||||
|
||||
@patch("controllers.service_api.dataset.document.db")
|
||||
@patch("controllers.service_api.wraps.FeatureService")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@ -1387,15 +1431,15 @@ class TestDocumentUpdateByFileApiPost:
|
||||
doc_id = str(uuid.uuid4())
|
||||
data = {"file": (BytesIO(b"content"), "test.pdf", "application/pdf")}
|
||||
with app.test_request_context(
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_file",
|
||||
method="POST",
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}",
|
||||
method="PATCH",
|
||||
content_type="multipart/form-data",
|
||||
data=data,
|
||||
headers={"Authorization": "Bearer test_token"},
|
||||
):
|
||||
api = DocumentUpdateByFileApi()
|
||||
api = DocumentApi()
|
||||
with pytest.raises(ValueError, match="Dataset does not exist"):
|
||||
api.post(
|
||||
api.patch(
|
||||
tenant_id=mock_tenant.id,
|
||||
dataset_id=mock_dataset.id,
|
||||
document_id=doc_id,
|
||||
@ -1423,15 +1467,15 @@ class TestDocumentUpdateByFileApiPost:
|
||||
doc_id = str(uuid.uuid4())
|
||||
data = {"file": (BytesIO(b"content"), "test.pdf", "application/pdf")}
|
||||
with app.test_request_context(
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_file",
|
||||
method="POST",
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}",
|
||||
method="PATCH",
|
||||
content_type="multipart/form-data",
|
||||
data=data,
|
||||
headers={"Authorization": "Bearer test_token"},
|
||||
):
|
||||
api = DocumentUpdateByFileApi()
|
||||
api = DocumentApi()
|
||||
with pytest.raises(ValueError, match="External datasets"):
|
||||
api.post(
|
||||
api.patch(
|
||||
tenant_id=mock_tenant.id,
|
||||
dataset_id=mock_dataset.id,
|
||||
document_id=doc_id,
|
||||
@ -1482,14 +1526,14 @@ class TestDocumentUpdateByFileApiPost:
|
||||
doc_id = str(uuid.uuid4())
|
||||
data = {"file": (BytesIO(b"file content"), "test.pdf", "application/pdf")}
|
||||
with app.test_request_context(
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_file",
|
||||
method="POST",
|
||||
f"/datasets/{mock_dataset.id}/documents/{doc_id}",
|
||||
method="PATCH",
|
||||
content_type="multipart/form-data",
|
||||
data=data,
|
||||
headers={"Authorization": "Bearer test_token"},
|
||||
):
|
||||
api = DocumentUpdateByFileApi()
|
||||
response, status = api.post(
|
||||
api = DocumentApi()
|
||||
response, status = api.patch(
|
||||
tenant_id=mock_tenant.id,
|
||||
dataset_id=mock_dataset.id,
|
||||
document_id=doc_id,
|
||||
|
||||
@ -171,6 +171,57 @@ class TestHitTestingApiPost:
|
||||
assert passed_retrieval_model["search_method"] == "semantic_search"
|
||||
assert passed_retrieval_model["top_k"] == 10
|
||||
|
||||
@patch("controllers.service_api.dataset.hit_testing.service_api_ns")
|
||||
@patch("controllers.console.datasets.hit_testing_base.marshal")
|
||||
@patch("controllers.console.datasets.hit_testing_base.HitTestingService")
|
||||
@patch("controllers.console.datasets.hit_testing_base.DatasetService")
|
||||
@patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account))
|
||||
def test_post_normalizes_legacy_query_and_nullable_list_fields(
|
||||
self,
|
||||
mock_current_user,
|
||||
mock_dataset_svc,
|
||||
mock_hit_svc,
|
||||
mock_marshal,
|
||||
mock_ns,
|
||||
app,
|
||||
):
|
||||
"""Test service API normalizes legacy query shape and nullable list fields."""
|
||||
dataset_id = str(uuid.uuid4())
|
||||
tenant_id = str(uuid.uuid4())
|
||||
|
||||
mock_dataset = Mock()
|
||||
mock_dataset.id = dataset_id
|
||||
|
||||
mock_dataset_svc.get_dataset.return_value = mock_dataset
|
||||
mock_dataset_svc.check_dataset_permission.return_value = None
|
||||
|
||||
mock_hit_svc.retrieve.return_value = {"query": {"content": "legacy query"}, "records": ["placeholder"]}
|
||||
mock_hit_svc.hit_testing_args_check.return_value = None
|
||||
mock_marshal.return_value = [
|
||||
{
|
||||
"segment": {"id": "segment-1", "keywords": None},
|
||||
"child_chunks": None,
|
||||
"files": None,
|
||||
"score": 0.9,
|
||||
}
|
||||
]
|
||||
|
||||
mock_ns.payload = {"query": "legacy query"}
|
||||
|
||||
with app.test_request_context():
|
||||
api = HitTestingApi()
|
||||
response = HitTestingApi.post.__wrapped__(api, tenant_id, dataset_id)
|
||||
|
||||
assert response["query"] == "legacy query"
|
||||
assert response["records"] == [
|
||||
{
|
||||
"segment": {"id": "segment-1", "keywords": []},
|
||||
"child_chunks": [],
|
||||
"files": [],
|
||||
"score": 0.9,
|
||||
}
|
||||
]
|
||||
|
||||
@patch("controllers.service_api.dataset.hit_testing.service_api_ns")
|
||||
@patch("controllers.console.datasets.hit_testing_base.DatasetService")
|
||||
@patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account))
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
"""Primarily used for testing merged cell scenarios"""
|
||||
|
||||
import gc
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import warnings
|
||||
from collections import UserDict
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from docx import Document
|
||||
@ -377,23 +375,21 @@ def test_close_is_idempotent():
|
||||
extractor.temp_file.close.assert_called_once()
|
||||
|
||||
|
||||
def test_close_handles_async_close_mock():
|
||||
async def _async_close() -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_close_closes_awaitable_close_result():
|
||||
extractor = object.__new__(WordExtractor)
|
||||
extractor._closed = False
|
||||
extractor.temp_file = MagicMock()
|
||||
extractor.temp_file.close = AsyncMock()
|
||||
close_result = _async_close()
|
||||
extractor.temp_file.close = MagicMock(return_value=close_result)
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
extractor.close()
|
||||
gc.collect()
|
||||
extractor.close()
|
||||
|
||||
assert close_result.cr_frame is None
|
||||
extractor.temp_file.close.assert_called_once()
|
||||
assert not [
|
||||
warning
|
||||
for warning in caught
|
||||
if issubclass(warning.category, RuntimeWarning) and "AsyncMockMixin._execute_mock_call" in str(warning.message)
|
||||
]
|
||||
|
||||
|
||||
def test_extract_images_handles_invalid_external_cases(monkeypatch):
|
||||
|
||||
@ -59,19 +59,25 @@ services:
|
||||
- ${MYSQL_HOST_VOLUME:-./volumes/mysql/data}:/var/lib/mysql
|
||||
ports:
|
||||
- "${EXPOSE_MYSQL_PORT:-3306}:3306"
|
||||
# mysqladmin ping passes during mysql:8.0's TCP-listening stage even while
|
||||
# the server is still finalising init, leading to "Lost connection during
|
||||
# query" on the first real query. Verify with a real SELECT instead.
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"mysqladmin",
|
||||
"ping",
|
||||
"-u",
|
||||
"root",
|
||||
"mysql",
|
||||
"-h",
|
||||
"127.0.0.1",
|
||||
"-uroot",
|
||||
"-p${DB_PASSWORD:-difyai123456}",
|
||||
"-e",
|
||||
"SELECT 1",
|
||||
]
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 20s
|
||||
|
||||
# The redis cache.
|
||||
redis:
|
||||
|
||||
@ -17,3 +17,10 @@ Feature: Share app publicly
|
||||
Given a workflow app has been published and shared via API
|
||||
When I open the shared app URL
|
||||
Then the shared app page should be accessible
|
||||
|
||||
@unauthenticated
|
||||
Scenario: Run a shared workflow app without authentication
|
||||
Given a workflow app has been published and shared via API
|
||||
When I open the shared app URL
|
||||
And I run the shared workflow app
|
||||
Then the shared workflow run should succeed
|
||||
|
||||
@ -37,3 +37,15 @@ Then('the shared app page should be accessible', async function (this: DifyWorld
|
||||
await expect(this.getPage()).toHaveURL(/\/(workflow|chat)\/[a-zA-Z0-9]+/, { timeout: 15_000 })
|
||||
await expect(this.getPage().locator('body')).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
When('I run the shared workflow app', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const runButton = page.getByTestId('run-button')
|
||||
|
||||
await expect(runButton).toBeEnabled({ timeout: 15_000 })
|
||||
await runButton.click()
|
||||
})
|
||||
|
||||
Then('the shared workflow run should succeed', async function (this: DifyWorld) {
|
||||
await expect(this.getPage().getByTestId('status-icon-success')).toBeVisible({ timeout: 55_000 })
|
||||
})
|
||||
|
||||
@ -12,8 +12,10 @@ Given('a minimal runnable workflow draft has been synced', async function (this:
|
||||
|
||||
When('I run the workflow', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
await page.getByText('Test Run').click()
|
||||
await expect(page.getByText('Running').first()).toBeVisible({ timeout: 15_000 })
|
||||
const testRunButton = page.getByText('Test Run')
|
||||
|
||||
await expect(testRunButton).toBeVisible({ timeout: 15_000 })
|
||||
await testRunButton.click()
|
||||
})
|
||||
|
||||
Then('the workflow run should succeed', async function (this: DifyWorld) {
|
||||
|
||||
@ -3506,11 +3506,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/reference-setting-modal/auto-update-setting/strategy-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 2
|
||||
|
||||
@ -43,7 +43,7 @@ describe('OptionListItem', () => {
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('listitem')
|
||||
const item = screen.getByRole('button')
|
||||
expect(item).toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
|
||||
@ -54,7 +54,7 @@ describe('OptionListItem', () => {
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('listitem')
|
||||
const item = screen.getByRole('button')
|
||||
expect(item).not.toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
})
|
||||
@ -100,7 +100,7 @@ describe('OptionListItem', () => {
|
||||
Clickable
|
||||
</OptionListItem>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@ -111,7 +111,7 @@ describe('OptionListItem', () => {
|
||||
Item
|
||||
</OptionListItem>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' })
|
||||
})
|
||||
@ -126,7 +126,7 @@ describe('OptionListItem', () => {
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('listitem')
|
||||
const item = screen.getByRole('button')
|
||||
fireEvent.click(item)
|
||||
fireEvent.click(item)
|
||||
fireEvent.click(item)
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import OptionList from '../option-list'
|
||||
|
||||
describe('OptionList', () => {
|
||||
it('should render a scrollable list with hidden scrollbar styles', () => {
|
||||
render(
|
||||
<OptionList>
|
||||
<li>Item</li>
|
||||
</OptionList>,
|
||||
)
|
||||
|
||||
const list = screen.getByRole('list')
|
||||
|
||||
expect(list).toHaveClass('overflow-y-auto')
|
||||
expect(list).toHaveClass('[scrollbar-width:none]')
|
||||
expect(list).toHaveClass('[&::-webkit-scrollbar]:hidden')
|
||||
})
|
||||
|
||||
it('should append caller className after default classes', () => {
|
||||
render(
|
||||
<OptionList className="custom-list">
|
||||
<li>Item</li>
|
||||
</OptionList>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('list')).toHaveClass('custom-list')
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,4 @@
|
||||
import type { FC } from 'react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
@ -7,7 +7,8 @@ type OptionListItemProps = {
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
noAutoScroll?: boolean
|
||||
} & React.LiHTMLAttributes<HTMLLIElement>
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const OptionListItem: FC<OptionListItemProps> = ({
|
||||
isSelected,
|
||||
@ -25,16 +26,21 @@ const OptionListItem: FC<OptionListItemProps> = ({
|
||||
return (
|
||||
<li
|
||||
ref={listItemRef}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center justify-center rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text',
|
||||
isSelected ? 'bg-components-button-ghost-bg-hover' : 'hover:bg-components-button-ghost-bg-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
listItemRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center justify-center rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text outline-hidden',
|
||||
'focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:ring-inset',
|
||||
isSelected ? 'bg-components-button-ghost-bg-hover' : 'hover:bg-components-button-ghost-bg-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
listItemRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
|
||||
type OptionListProps = {
|
||||
children: ReactNode
|
||||
} & HTMLAttributes<HTMLUListElement>
|
||||
|
||||
const optionListClassName = cn(
|
||||
'flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]',
|
||||
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
||||
)
|
||||
|
||||
const OptionList = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: OptionListProps) => {
|
||||
return (
|
||||
<ul className={cn(optionListClassName, className)} {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(OptionList)
|
||||
@ -64,13 +64,13 @@ describe('TimePickerOptions', () => {
|
||||
it('should render selected hour in the list', () => {
|
||||
const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') })
|
||||
render(<Options {...props} />)
|
||||
const selectedHour = screen.getAllByRole('listitem').find(item => item.textContent === '05')
|
||||
const selectedHour = screen.getAllByRole('button').find(item => item.textContent === '05')
|
||||
expect(selectedHour)!.toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
it('should render selected minute in the list', () => {
|
||||
const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') })
|
||||
render(<Options {...props} />)
|
||||
const selectedMinute = screen.getAllByRole('listitem').find(item => item.textContent === '30')
|
||||
const selectedMinute = screen.getAllByRole('button').find(item => item.textContent === '30')
|
||||
expect(selectedMinute)!.toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { TimeOptionsProps } from '../types'
|
||||
import * as React from 'react'
|
||||
import OptionList from '../common/option-list'
|
||||
import OptionListItem from '../common/option-list-item'
|
||||
import { useTimeOptions } from '../hooks'
|
||||
|
||||
@ -16,7 +17,7 @@ const Options: FC<TimeOptionsProps> = ({
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-x-1 p-2">
|
||||
{/* Hour */}
|
||||
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
|
||||
<OptionList>
|
||||
{
|
||||
hourOptions.map((hour) => {
|
||||
const isSelected = selectedTime?.format('hh') === hour
|
||||
@ -31,9 +32,9 @@ const Options: FC<TimeOptionsProps> = ({
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</OptionList>
|
||||
{/* Minute */}
|
||||
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
|
||||
<OptionList>
|
||||
{
|
||||
(minuteFilter ? minuteFilter(minuteOptions) : minuteOptions).map((minute) => {
|
||||
const isSelected = selectedTime?.format('mm') === minute
|
||||
@ -48,9 +49,9 @@ const Options: FC<TimeOptionsProps> = ({
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</OptionList>
|
||||
{/* Period */}
|
||||
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
|
||||
<OptionList>
|
||||
{
|
||||
periodOptions.map((period) => {
|
||||
const isSelected = selectedTime?.format('A') === period
|
||||
@ -66,7 +67,7 @@ const Options: FC<TimeOptionsProps> = ({
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</OptionList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { YearAndMonthPickerOptionsProps } from '../types'
|
||||
import * as React from 'react'
|
||||
import OptionList from '../common/option-list'
|
||||
import OptionListItem from '../common/option-list-item'
|
||||
import { useMonths, useYearOptions } from '../hooks'
|
||||
|
||||
@ -16,7 +17,7 @@ const Options: FC<YearAndMonthPickerOptionsProps> = ({
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-1 p-2">
|
||||
{/* Month Picker */}
|
||||
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
|
||||
<OptionList>
|
||||
{
|
||||
months.map((month, index) => {
|
||||
const isSelected = selectedMonth === index
|
||||
@ -31,9 +32,9 @@ const Options: FC<YearAndMonthPickerOptionsProps> = ({
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</OptionList>
|
||||
{/* Year Picker */}
|
||||
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
|
||||
<OptionList>
|
||||
{
|
||||
yearOptions.map((year) => {
|
||||
const isSelected = selectedYear === year
|
||||
@ -48,7 +49,7 @@ const Options: FC<YearAndMonthPickerOptionsProps> = ({
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</OptionList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -42,7 +42,6 @@ const LanguageSelect: FC<ILanguageSelectProps> = ({
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="w-max"
|
||||
listClassName="no-scrollbar"
|
||||
>
|
||||
{supportedLanguages.map(({ prompt_name }) => (
|
||||
<SelectItem key={prompt_name} value={prompt_name}>
|
||||
|
||||
@ -55,7 +55,14 @@ vi.mock('../../hooks', async () => {
|
||||
})
|
||||
|
||||
vi.mock('../popup-item', () => ({
|
||||
default: ({ model }: { model: Model }) => <div>{model.provider}</div>,
|
||||
default: ({ model }: { model: Model }) => (
|
||||
<div>
|
||||
<span>{model.provider}</span>
|
||||
{model.models.map(modelItem => (
|
||||
<span key={modelItem.model}>{modelItem.model}</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
@ -207,6 +214,156 @@ describe('Popup', () => {
|
||||
expect((input as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('should show matching models when searching by model name', () => {
|
||||
renderPopup(
|
||||
<Popup
|
||||
modelList={[
|
||||
makeModel({
|
||||
models: [makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } })],
|
||||
}),
|
||||
makeModel({
|
||||
provider: 'anthropic',
|
||||
label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' },
|
||||
models: [makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } })],
|
||||
}),
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
|
||||
{ target: { value: 'claude' } },
|
||||
)
|
||||
|
||||
expect(screen.queryByText('openai')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('anthropic')).toBeInTheDocument()
|
||||
expect(screen.getByText('claude-3')).toBeInTheDocument()
|
||||
expect(screen.queryByText('gpt-4')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('No model found for \u201Cclaude\u201D')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show empty search placeholder when no provider or model name matches', () => {
|
||||
renderPopup(
|
||||
<Popup
|
||||
modelList={[
|
||||
makeModel({
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
models: [
|
||||
makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } }),
|
||||
],
|
||||
}),
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
|
||||
{ target: { value: 'mistral' } },
|
||||
)
|
||||
|
||||
expect(screen.getByText('No model found for \u201Cmistral\u201D'))!.toBeInTheDocument()
|
||||
expect(screen.queryByText('openai')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('gpt-4')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show all models of a provider when searching by provider label', () => {
|
||||
renderPopup(
|
||||
<Popup
|
||||
modelList={[
|
||||
makeModel({
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
models: [
|
||||
makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } }),
|
||||
makeModelItem({ model: 'gpt-4o', label: { en_US: 'GPT-4o', zh_Hans: 'GPT-4o' } }),
|
||||
],
|
||||
}),
|
||||
makeModel({
|
||||
provider: 'anthropic',
|
||||
label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' },
|
||||
models: [
|
||||
makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } }),
|
||||
],
|
||||
}),
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
|
||||
{ target: { value: 'openai' } },
|
||||
)
|
||||
|
||||
expect(screen.getByText('openai'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('gpt-4'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('gpt-4o'))!.toBeInTheDocument()
|
||||
expect(screen.queryByText('anthropic')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('claude-3')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should match by model provider key when model label does not contain the search text', () => {
|
||||
renderPopup(
|
||||
<Popup
|
||||
modelList={[
|
||||
makeModel({
|
||||
provider: 'azure_openai',
|
||||
label: { en_US: 'Azure', zh_Hans: 'Azure' },
|
||||
models: [
|
||||
makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } }),
|
||||
],
|
||||
}),
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
|
||||
{ target: { value: 'openai' } },
|
||||
)
|
||||
|
||||
expect(screen.getByText('azure_openai'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('gpt-4'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should still apply scope features when matching by provider label', () => {
|
||||
mockSupportFunctionCall.mockReturnValue(false)
|
||||
|
||||
renderPopup(
|
||||
<Popup
|
||||
modelList={[
|
||||
makeModel({
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
models: [
|
||||
makeModelItem({ model: 'gpt-4', features: [ModelFeatureEnum.vision] }),
|
||||
makeModelItem({ model: 'gpt-4-tool', features: [ModelFeatureEnum.toolCall] }),
|
||||
],
|
||||
}),
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
scopeFeatures={[ModelFeatureEnum.toolCall]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
|
||||
{ target: { value: 'openai' } },
|
||||
)
|
||||
|
||||
expect(screen.getByText('No model found for \u201Copenai\u201D'))!.toBeInTheDocument()
|
||||
expect(screen.queryByText('gpt-4')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('gpt-4-tool')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show compatible-only helper text when no scope features are applied', () => {
|
||||
renderPopup(
|
||||
<Popup
|
||||
@ -219,8 +376,8 @@ describe('Popup', () => {
|
||||
expect(screen.queryByText('common.modelProvider.selector.onlyCompatibleModelsShown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show compatible-only helper banner when scope features are applied', () => {
|
||||
const { container } = renderPopup(
|
||||
it('should show compatible-only helper text when scope features are applied', () => {
|
||||
renderPopup(
|
||||
<Popup
|
||||
modelList={[makeModel()]}
|
||||
onSelect={vi.fn()}
|
||||
@ -231,7 +388,26 @@ describe('Popup', () => {
|
||||
|
||||
expect(screen.getByTestId('compatible-models-banner'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.selector.onlyCompatibleModelsShown'))!.toBeInTheDocument()
|
||||
expect(container.querySelector('.i-ri-information-2-fill'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep search and footer outside the scrollable model list', () => {
|
||||
renderPopup(
|
||||
<Popup
|
||||
modelList={[makeModel()]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
scopeFeatures={[ModelFeatureEnum.vision]}
|
||||
/>,
|
||||
)
|
||||
|
||||
const scrollRegion = screen.getByRole('region', { name: 'common.modelProvider.models' })
|
||||
const searchInput = screen.getByPlaceholderText('datasetSettings.form.searchModel')
|
||||
const settingsButton = screen.getByRole('button', { name: /common\.modelProvider\.selector\.modelProviderSettings/ })
|
||||
|
||||
expect(scrollRegion)!.toBeInTheDocument()
|
||||
expect(scrollRegion).not.toContainElement(searchInput)
|
||||
expect(scrollRegion).not.toContainElement(settingsButton)
|
||||
expect(scrollRegion).toContainElement(screen.getByTestId('compatible-models-banner'))
|
||||
})
|
||||
|
||||
it('should filter by scope features including toolCall and non-toolCall checks', () => {
|
||||
|
||||
@ -88,7 +88,7 @@ const ModelSelector: FC<ModelSelectorProps> = ({
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
className={popupClassName}
|
||||
popupClassName="overflow-hidden rounded-lg"
|
||||
popupClassName="overflow-hidden rounded-xl"
|
||||
popupProps={{ style: { minWidth: '320px', width: 'var(--anchor-width, auto)' } }}
|
||||
>
|
||||
<Popup
|
||||
|
||||
@ -0,0 +1,98 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { modelNameMap, providerIconMap } from '../utils'
|
||||
|
||||
type MarketplaceSectionProps = {
|
||||
marketplaceProviders: ModelProviderQuotaGetPaid[]
|
||||
marketplaceCollapsed: boolean
|
||||
installingProvider: ModelProviderQuotaGetPaid | null
|
||||
isMarketplacePluginsLoading: boolean
|
||||
theme?: string
|
||||
onMarketplaceCollapsedChange: (collapsed: boolean) => void
|
||||
onInstallPlugin: (key: ModelProviderQuotaGetPaid) => void | Promise<void>
|
||||
}
|
||||
|
||||
const MarketplaceSection: FC<MarketplaceSectionProps> = ({
|
||||
marketplaceProviders,
|
||||
marketplaceCollapsed,
|
||||
installingProvider,
|
||||
isMarketplacePluginsLoading,
|
||||
theme,
|
||||
onMarketplaceCollapsedChange,
|
||||
onInstallPlugin,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (marketplaceProviders.length === 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-2">
|
||||
<div className="h-px bg-divider-subtle" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex h-[22px] items-center pr-2 pl-4">
|
||||
<div
|
||||
className="flex flex-1 cursor-pointer items-center system-sm-medium text-text-primary"
|
||||
onClick={() => onMarketplaceCollapsedChange(!marketplaceCollapsed)}
|
||||
>
|
||||
{t('modelProvider.selector.fromMarketplace', { ns: 'common' })}
|
||||
<span className={cn('i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 text-text-quaternary', marketplaceCollapsed && '-rotate-90')} />
|
||||
</div>
|
||||
</div>
|
||||
{!marketplaceCollapsed && (
|
||||
<div className="px-1 pb-1">
|
||||
{marketplaceProviders.map((key) => {
|
||||
const Icon = providerIconMap[key]
|
||||
const isInstalling = installingProvider === key
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-lg py-0.5 pr-0.5 pl-3 hover:bg-state-base-hover"
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2 py-0.5">
|
||||
<Icon className="h-5 w-5 shrink-0 rounded-md" />
|
||||
<span className="system-sm-regular text-text-secondary">{modelNameMap[key]}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className={cn(
|
||||
'shrink-0 backdrop-blur-[5px]',
|
||||
!isInstalling && 'hidden group-hover:flex',
|
||||
)}
|
||||
disabled={isInstalling || isMarketplacePluginsLoading}
|
||||
onClick={() => onInstallPlugin(key)}
|
||||
>
|
||||
{isInstalling && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin" />}
|
||||
{isInstalling
|
||||
? t('installModal.installing', { ns: 'plugin' })
|
||||
: t('modelProvider.selector.install', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<a
|
||||
className="flex cursor-pointer items-center gap-0.5 px-3 py-1.5"
|
||||
href={getMarketplaceUrl('', { theme })}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="flex-1 system-xs-regular text-text-accent">
|
||||
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
|
||||
</span>
|
||||
<span className="i-ri-arrow-right-up-line h-3! w-3! text-text-accent" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MarketplaceSection
|
||||
@ -0,0 +1,39 @@
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ModelSelectorEmptyStateProps = {
|
||||
onConfigure: () => void
|
||||
}
|
||||
|
||||
const ModelSelectorEmptyState: FC<ModelSelectorEmptyStateProps> = ({
|
||||
onConfigure,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="mx-2 flex flex-col gap-2 rounded-[10px] bg-linear-to-r from-state-base-hover to-background-gradient-mask-transparent p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-[5px]">
|
||||
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="system-sm-medium text-text-secondary">
|
||||
{t('modelProvider.selector.noProviderConfigured', { ns: 'common' })}
|
||||
</p>
|
||||
<p className="system-xs-regular text-text-tertiary">
|
||||
{t('modelProvider.selector.noProviderConfiguredDesc', { ns: 'common' })}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-[108px]"
|
||||
onClick={onConfigure}
|
||||
>
|
||||
{t('modelProvider.selector.configure', { ns: 'common' })}
|
||||
<span className="i-ri-arrow-right-line h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelSelectorEmptyState
|
||||
@ -107,7 +107,8 @@ const PopupItem: FC<PopupItemProps> = ({
|
||||
|
||||
return (
|
||||
<div className="mb-1">
|
||||
<div className="sticky top-12 z-2 flex h-[22px] items-center justify-between bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
|
||||
{/* Keep the sticky provider header above model rows while the list scrolls. */}
|
||||
<div className="sticky top-0 z-1 flex h-[22px] items-center justify-between bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
|
||||
<div
|
||||
className="flex cursor-pointer items-center"
|
||||
onClick={() => setCollapsed(prev => !prev)}
|
||||
|
||||
@ -0,0 +1,130 @@
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import {
|
||||
ScrollAreaContent,
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '@langgenius/dify-ui/scroll-area'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ModelSelectorPopupFrameProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const ModelSelectorPopupFrame: FC<ModelSelectorPopupFrameProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex max-h-[min(624px,var(--available-height,624px))] flex-col overflow-hidden rounded-xl bg-components-panel-bg">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ModelSelectorSearchHeaderProps = {
|
||||
searchText: string
|
||||
onSearchTextChange: (value: string) => void
|
||||
}
|
||||
|
||||
export const ModelSelectorSearchHeader: FC<ModelSelectorSearchHeaderProps> = ({
|
||||
searchText,
|
||||
onSearchTextChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="shrink-0 bg-components-panel-bg px-2 pt-2 pb-1">
|
||||
<div className={`
|
||||
flex h-8 items-center rounded-lg border px-2
|
||||
${searchText ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-transparent bg-components-input-bg-normal'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
mr-0.5 i-ri-search-line h-4 w-4 shrink-0
|
||||
${searchText ? 'text-text-tertiary' : 'text-text-quaternary'}
|
||||
`}
|
||||
/>
|
||||
<input
|
||||
className="block h-[18px] grow appearance-none bg-transparent px-1 text-[13px] text-text-primary outline-hidden"
|
||||
placeholder={t('form.searchModel', { ns: 'datasetSettings' }) || ''}
|
||||
value={searchText}
|
||||
onChange={e => onSearchTextChange(e.target.value)}
|
||||
/>
|
||||
{
|
||||
searchText && (
|
||||
<span
|
||||
className="ml-1.5 i-custom-vender-solid-general-x-circle h-[14px] w-[14px] shrink-0 cursor-pointer text-text-quaternary"
|
||||
onClick={() => onSearchTextChange('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ModelSelectorScrollBodyProps = {
|
||||
children: ReactNode
|
||||
label: string
|
||||
}
|
||||
|
||||
export const ModelSelectorScrollBody: FC<ModelSelectorScrollBodyProps> = ({
|
||||
children,
|
||||
label,
|
||||
}) => {
|
||||
return (
|
||||
<ScrollAreaRoot className="relative min-h-0 overflow-hidden overscroll-contain">
|
||||
<ScrollAreaViewport
|
||||
aria-label={label}
|
||||
className="max-h-[calc(min(624px,var(--available-height,624px))-84px)] overscroll-contain"
|
||||
role="region"
|
||||
>
|
||||
<ScrollAreaContent className="min-w-0">
|
||||
{children}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
{/* Keep the overlay scrollbar above sticky provider headers inside this scroll area. */}
|
||||
<ScrollAreaScrollbar className="z-2 data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1">
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export const CompatibleModelsNotice = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="compatible-models-banner"
|
||||
className="px-4 py-2 system-xs-regular text-text-tertiary"
|
||||
>
|
||||
{t('modelProvider.selector.onlyCompatibleModelsShown', { ns: 'common' })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ModelProviderSettingsFooterProps = {
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export const ModelProviderSettingsFooter: FC<ModelProviderSettingsFooterProps> = ({
|
||||
onOpenSettings,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-divider-subtle p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
<span className="i-ri-equalizer-2-line h-4 w-4 shrink-0" />
|
||||
<span className="system-xs-medium">{t('modelProvider.selector.modelProviderSettings', { ns: 'common' })}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -5,8 +5,6 @@ import type {
|
||||
ModelItem,
|
||||
} from '../declarations'
|
||||
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
@ -19,7 +17,6 @@ import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
|
||||
import { supportFunctionCall } from '@/utils/tool-call'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import {
|
||||
CustomConfigurationStatusEnum,
|
||||
ModelFeatureEnum,
|
||||
@ -29,8 +26,17 @@ import { useLanguage, useMarketplaceAllPlugins } from '../hooks'
|
||||
import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert'
|
||||
import { useTrialCredits } from '../provider-added-card/use-trial-credits'
|
||||
import { providerSupportsCredits } from '../supports-credits'
|
||||
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap, providerIconMap, providerKeyToPluginId } from '../utils'
|
||||
import { MODEL_PROVIDER_QUOTA_GET_PAID, providerKeyToPluginId } from '../utils'
|
||||
import MarketplaceSection from './marketplace-section'
|
||||
import ModelSelectorEmptyState from './popup-empty-state'
|
||||
import PopupItem from './popup-item'
|
||||
import {
|
||||
CompatibleModelsNotice,
|
||||
ModelProviderSettingsFooter,
|
||||
ModelSelectorPopupFrame,
|
||||
ModelSelectorScrollBody,
|
||||
ModelSelectorSearchHeader,
|
||||
} from './popup-layout'
|
||||
|
||||
type PopupProps = {
|
||||
defaultModel?: DefaultModel
|
||||
@ -137,18 +143,26 @@ const Popup: FC<PopupProps> = ({
|
||||
}, [aiCreditVisibleProviders, installedProviderMap, modelList])
|
||||
|
||||
const filteredModelList = useMemo(() => {
|
||||
const normalizedSearch = searchText.toLowerCase()
|
||||
const matchesLabel = (label: Record<string, string>) => {
|
||||
if (label[language] !== undefined)
|
||||
return label[language].toLowerCase().includes(normalizedSearch)
|
||||
return Object.values(label).some(value =>
|
||||
value.toLowerCase().includes(normalizedSearch),
|
||||
)
|
||||
}
|
||||
|
||||
const filtered = installedModelList.map((model) => {
|
||||
const matchesProviderSearch = !searchText
|
||||
|| model.provider.toLowerCase().includes(searchText.toLowerCase())
|
||||
|| Object.values(model.label).some(label => label.toLowerCase().includes(searchText.toLowerCase()))
|
||||
const providerMatched = !!searchText && (
|
||||
matchesLabel(model.label)
|
||||
|| model.provider.toLowerCase().includes(normalizedSearch)
|
||||
)
|
||||
|
||||
const filteredModels = model.models
|
||||
.filter((modelItem) => {
|
||||
if (modelItem.label[language] !== undefined)
|
||||
return modelItem.label[language].toLowerCase().includes(searchText.toLowerCase())
|
||||
return Object.values(modelItem.label).some(label =>
|
||||
label.toLowerCase().includes(searchText.toLowerCase()),
|
||||
)
|
||||
if (!searchText || providerMatched)
|
||||
return true
|
||||
return matchesLabel(modelItem.label)
|
||||
})
|
||||
.filter((modelItem) => {
|
||||
if (scopeFeatures.length === 0)
|
||||
@ -159,8 +173,12 @@ const Popup: FC<PopupProps> = ({
|
||||
return modelItem.features?.includes(feature) ?? false
|
||||
})
|
||||
})
|
||||
if (!matchesProviderSearch || (filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider)))
|
||||
if (
|
||||
(searchText && filteredModels.length === 0)
|
||||
|| (!searchText && filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { ...model, models: filteredModels }
|
||||
}).filter((model): model is Model => model !== null)
|
||||
@ -181,166 +199,59 @@ const Popup: FC<PopupProps> = ({
|
||||
return MODEL_PROVIDER_QUOTA_GET_PAID.filter(key => !installedProviders.has(key))
|
||||
}, [modelProviders])
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
onHide()
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
|
||||
}, [onHide, setShowAccountSettingModal])
|
||||
|
||||
return (
|
||||
<div className="no-scrollbar max-h-[480px] overflow-y-auto">
|
||||
<div className="sticky top-0 z-10 bg-components-panel-bg pt-3 pr-2 pb-1 pl-3">
|
||||
<div className={`
|
||||
flex h-8 items-center rounded-lg border pr-[10px] pl-[9px]
|
||||
${searchText ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-transparent bg-components-input-bg-normal'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
mr-[7px] i-ri-search-line h-[14px] w-[14px] shrink-0
|
||||
${searchText ? 'text-text-tertiary' : 'text-text-quaternary'}
|
||||
`}
|
||||
/>
|
||||
<input
|
||||
className="block h-[18px] grow appearance-none bg-transparent text-[13px] text-text-primary outline-hidden"
|
||||
placeholder={t('form.searchModel', { ns: 'datasetSettings' }) || ''}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
/>
|
||||
{
|
||||
searchText && (
|
||||
<span
|
||||
className="ml-1.5 i-custom-vender-solid-general-x-circle h-[14px] w-[14px] shrink-0 cursor-pointer text-text-quaternary"
|
||||
onClick={() => setSearchText('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{scopeFeatures.length > 0 && (
|
||||
<div
|
||||
data-testid="compatible-models-banner"
|
||||
className="mt-2 flex items-center gap-1 rounded-lg bg-background-section-burn px-2.5 py-2"
|
||||
>
|
||||
<span className="i-ri-information-2-fill h-4 w-4 shrink-0 text-text-accent" />
|
||||
<p className="system-xs-medium text-text-secondary">
|
||||
{t('modelProvider.selector.onlyCompatibleModelsShown', { ns: 'common' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ModelSelectorPopupFrame>
|
||||
<ModelSelectorSearchHeader
|
||||
searchText={searchText}
|
||||
onSearchTextChange={setSearchText}
|
||||
/>
|
||||
{showCreditsExhaustedAlert && (
|
||||
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
|
||||
)}
|
||||
<div className="pr-1 pb-1 pl-3">
|
||||
{
|
||||
filteredModelList.map(model => (
|
||||
<PopupItem
|
||||
key={model.provider}
|
||||
defaultModel={defaultModel}
|
||||
model={model}
|
||||
onSelect={onSelect}
|
||||
onHide={onHide}
|
||||
<ModelSelectorScrollBody label={t('modelProvider.models', { ns: 'common' })}>
|
||||
<div className="pb-1">
|
||||
{
|
||||
filteredModelList.map(model => (
|
||||
<PopupItem
|
||||
key={model.provider}
|
||||
defaultModel={defaultModel}
|
||||
model={model}
|
||||
onSelect={onSelect}
|
||||
onHide={onHide}
|
||||
/>
|
||||
))
|
||||
}
|
||||
{!filteredModelList.length && !installedModelList.length && (
|
||||
<ModelSelectorEmptyState
|
||||
onConfigure={handleOpenSettings}
|
||||
/>
|
||||
))
|
||||
}
|
||||
{!filteredModelList.length && !installedModelList.length && (
|
||||
<div className="flex flex-col gap-2 rounded-[10px] bg-linear-to-r from-state-base-hover to-background-gradient-mask-transparent p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-[5px]">
|
||||
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
|
||||
)}
|
||||
{!filteredModelList.length && installedModelList.length > 0 && (
|
||||
<div className="px-3 py-1.5 text-center text-xs leading-[18px] break-all text-text-tertiary">
|
||||
{`No model found for \u201C${searchText}\u201D`}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="system-sm-medium text-text-secondary">
|
||||
{t('modelProvider.selector.noProviderConfigured', { ns: 'common' })}
|
||||
</p>
|
||||
<p className="system-xs-regular text-text-tertiary">
|
||||
{t('modelProvider.selector.noProviderConfiguredDesc', { ns: 'common' })}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-[108px]"
|
||||
onClick={() => {
|
||||
onHide()
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
|
||||
}}
|
||||
>
|
||||
{t('modelProvider.selector.configure', { ns: 'common' })}
|
||||
<span className="i-ri-arrow-right-line h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!filteredModelList.length && installedModelList.length > 0 && (
|
||||
<div className="px-3 py-1.5 text-center text-xs leading-[18px] break-all text-text-tertiary">
|
||||
{`No model found for \u201C${searchText}\u201D`}
|
||||
</div>
|
||||
)}
|
||||
{marketplaceProviders.length > 0 && (
|
||||
<>
|
||||
<div className="mx-2 my-1 border-t border-divider-subtle" />
|
||||
<div className="mb-1">
|
||||
<div className="flex h-[22px] items-center px-3">
|
||||
<div
|
||||
className="flex flex-1 cursor-pointer items-center system-sm-medium text-text-primary"
|
||||
onClick={() => setMarketplaceCollapsed(prev => !prev)}
|
||||
>
|
||||
{t('modelProvider.selector.fromMarketplace', { ns: 'common' })}
|
||||
<span className={cn('i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 text-text-quaternary', marketplaceCollapsed && '-rotate-90')} />
|
||||
</div>
|
||||
</div>
|
||||
{!marketplaceCollapsed && (
|
||||
<>
|
||||
{marketplaceProviders.map((key) => {
|
||||
const Icon = providerIconMap[key]
|
||||
const isInstalling = installingProvider === key
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-lg py-0.5 pr-0.5 pl-3 hover:bg-state-base-hover"
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2 py-0.5">
|
||||
<Icon className="h-5 w-5 shrink-0 rounded-md" />
|
||||
<span className="system-sm-regular text-text-secondary">{modelNameMap[key]}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className={cn(
|
||||
'shrink-0 backdrop-blur-[5px]',
|
||||
!isInstalling && 'hidden group-hover:flex',
|
||||
)}
|
||||
disabled={isInstalling || isMarketplacePluginsLoading}
|
||||
onClick={() => handleInstallPlugin(key)}
|
||||
>
|
||||
{isInstalling && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin" />}
|
||||
{isInstalling
|
||||
? t('installModal.installing', { ns: 'plugin' })
|
||||
: t('modelProvider.selector.install', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<a
|
||||
className="flex cursor-pointer items-center gap-0.5 px-3 pt-1.5"
|
||||
href={getMarketplaceUrl('', { theme })}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="flex-1 system-xs-regular text-text-accent">
|
||||
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
|
||||
</span>
|
||||
<span className="i-ri-arrow-right-up-line h-3! w-3! text-text-accent" />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="sticky bottom-0 flex cursor-pointer items-center gap-1 rounded-b-lg border-t border-divider-subtle bg-components-panel-bg px-3 py-2 text-text-tertiary hover:text-text-secondary"
|
||||
onClick={() => {
|
||||
onHide()
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-equalizer-2-line h-4 w-4 shrink-0" />
|
||||
<span className="system-xs-medium">{t('modelProvider.selector.modelProviderSettings', { ns: 'common' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{scopeFeatures.length > 0 && (
|
||||
<CompatibleModelsNotice />
|
||||
)}
|
||||
<MarketplaceSection
|
||||
marketplaceProviders={marketplaceProviders}
|
||||
marketplaceCollapsed={marketplaceCollapsed}
|
||||
installingProvider={installingProvider}
|
||||
isMarketplacePluginsLoading={isMarketplacePluginsLoading}
|
||||
theme={theme}
|
||||
onMarketplaceCollapsedChange={setMarketplaceCollapsed}
|
||||
onInstallPlugin={handleInstallPlugin}
|
||||
/>
|
||||
</div>
|
||||
</ModelSelectorScrollBody>
|
||||
<ModelProviderSettingsFooter onOpenSettings={handleOpenSettings} />
|
||||
</ModelSelectorPopupFrame>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import PluginItem from './plugin-item'
|
||||
|
||||
type PluginSectionProps = {
|
||||
@ -43,7 +44,14 @@ const PluginSection: FC<PluginSectionProps> = ({
|
||||
)
|
||||
{headerAction}
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-x-hidden overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<ScrollArea
|
||||
className="max-h-[300px] overflow-hidden"
|
||||
label={title}
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'min-w-0',
|
||||
}}
|
||||
>
|
||||
{plugins.map(plugin => (
|
||||
<PluginItem
|
||||
key={plugin.plugin_unique_identifier}
|
||||
@ -59,7 +67,7 @@ const PluginSection: FC<PluginSectionProps> = ({
|
||||
: undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { PluginStatus } from '@/app/components/plugins/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import ErrorPluginItem from './error-plugin-item'
|
||||
@ -86,7 +87,14 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
|
||||
{t('task.clearAll', { ns: 'plugin' })}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-x-hidden overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<ScrollArea
|
||||
className="max-h-[300px] overflow-hidden"
|
||||
label={t('task.installedError', { ns: 'plugin', errorLength: errorPlugins.length })}
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'min-w-0',
|
||||
}}
|
||||
>
|
||||
{errorPlugins.map(plugin => (
|
||||
<ErrorPluginItem
|
||||
key={plugin.plugin_unique_identifier}
|
||||
@ -96,7 +104,7 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
|
||||
onClear={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -117,7 +117,7 @@ const PluginTasks = () => {
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="[scrollbar-width:none] overflow-visible border-0 bg-transparent p-0 shadow-none backdrop-blur-none [&::-webkit-scrollbar]:hidden"
|
||||
popupClassName="overflow-visible border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<PluginTaskList
|
||||
runningPlugins={runningPlugins}
|
||||
|
||||
@ -2,6 +2,7 @@ import type { AutoUpdateConfig } from '../types'
|
||||
import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import dayjs from 'dayjs'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
@ -803,165 +804,103 @@ describe('auto-update-setting', () => {
|
||||
})
|
||||
|
||||
describe('StrategyPicker (strategy-picker.tsx)', () => {
|
||||
const defaultProps = {
|
||||
value: AUTO_UPDATE_STRATEGY.disabled,
|
||||
onChange: vi.fn(),
|
||||
const i18nKeyByStrategy: Record<AUTO_UPDATE_STRATEGY, 'disabled' | 'fixOnly' | 'latest'> = {
|
||||
[AUTO_UPDATE_STRATEGY.disabled]: 'disabled',
|
||||
[AUTO_UPDATE_STRATEGY.fixOnly]: 'fixOnly',
|
||||
[AUTO_UPDATE_STRATEGY.latest]: 'latest',
|
||||
}
|
||||
|
||||
const triggerName = (strategy: AUTO_UPDATE_STRATEGY) =>
|
||||
new RegExp(`plugin\\.autoUpdate\\.strategy\\.${i18nKeyByStrategy[strategy]}\\.name`, 'i')
|
||||
|
||||
const findOption = async (key: 'disabled' | 'fixOnly' | 'latest') => {
|
||||
const options = await screen.findAllByRole('menuitemradio')
|
||||
const option = options.find(item =>
|
||||
item.textContent?.includes(`plugin.autoUpdate.strategy.${key}.name`),
|
||||
)
|
||||
if (!option)
|
||||
throw new Error(`Strategy option "${key}" not found`)
|
||||
return option
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render trigger button with current strategy label', () => {
|
||||
// Act
|
||||
render(<StrategyPicker {...defaultProps} value={AUTO_UPDATE_STRATEGY.disabled} />)
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.disabled\.name/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render dropdown content when closed', () => {
|
||||
// Act
|
||||
render(<StrategyPicker {...defaultProps} />)
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all strategy options when open', () => {
|
||||
// Arrange
|
||||
mockPortalOpen = true
|
||||
it('should render all strategy options when open', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
|
||||
|
||||
// Act
|
||||
render(<StrategyPicker {...defaultProps} />)
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) }))
|
||||
|
||||
// Wait for portal to open
|
||||
if (mockPortalOpen) {
|
||||
// Assert all options visible (use getAllByText for strategy name as it appears in both trigger and dropdown)
|
||||
expect(screen.getAllByText('plugin.autoUpdate.strategy.disabled.name').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('plugin.autoUpdate.strategy.fixOnly.name')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.autoUpdate.strategy.latest.name')).toBeInTheDocument()
|
||||
}
|
||||
const options = await screen.findAllByRole('menuitemradio')
|
||||
expect(options).toHaveLength(3)
|
||||
expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.disabled.name'))).toBe(true)
|
||||
expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.fixOnly.name'))).toBe(true)
|
||||
expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.latest.name'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should toggle dropdown when trigger is clicked', () => {
|
||||
// Act
|
||||
render(<StrategyPicker {...defaultProps} />)
|
||||
|
||||
// Assert - initially closed
|
||||
expect(mockPortalOpen).toBe(false)
|
||||
|
||||
// Act - click trigger
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// Assert - portal trigger element should still be in document
|
||||
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange with fixOnly when Bug Fixes Only option is clicked', () => {
|
||||
// Arrange - force portal content to be visible for testing option selection
|
||||
forcePortalContentVisible = true
|
||||
const onChange = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />)
|
||||
|
||||
// Find and click the "Bug Fixes Only" option
|
||||
const fixOnlyOption = screen.getByText('plugin.autoUpdate.strategy.fixOnly.name').closest('div[class*="cursor-pointer"]')
|
||||
expect(fixOnlyOption).toBeInTheDocument()
|
||||
fireEvent.click(fixOnlyOption!)
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.fixOnly)
|
||||
})
|
||||
|
||||
it('should call onChange with latest when Latest Version option is clicked', () => {
|
||||
// Arrange - force portal content to be visible for testing option selection
|
||||
forcePortalContentVisible = true
|
||||
const onChange = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />)
|
||||
|
||||
// Find and click the "Latest Version" option
|
||||
const latestOption = screen.getByText('plugin.autoUpdate.strategy.latest.name').closest('div[class*="cursor-pointer"]')
|
||||
expect(latestOption).toBeInTheDocument()
|
||||
fireEvent.click(latestOption!)
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.latest)
|
||||
})
|
||||
|
||||
it('should call onChange with disabled when Disabled option is clicked', () => {
|
||||
// Arrange - force portal content to be visible for testing option selection
|
||||
forcePortalContentVisible = true
|
||||
const onChange = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={onChange} />)
|
||||
|
||||
// Find and click the "Disabled" option - need to find the one in the dropdown, not the button
|
||||
const disabledOptions = screen.getAllByText('plugin.autoUpdate.strategy.disabled.name')
|
||||
// The second one should be in the dropdown
|
||||
const dropdownOption = disabledOptions.find(el => el.closest('div[class*="cursor-pointer"]'))
|
||||
expect(dropdownOption).toBeInTheDocument()
|
||||
fireEvent.click(dropdownOption!.closest('div[class*="cursor-pointer"]')!)
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.disabled)
|
||||
})
|
||||
|
||||
it('should stop event propagation when option is clicked', () => {
|
||||
// Arrange - force portal content to be visible
|
||||
forcePortalContentVisible = true
|
||||
const onChange = vi.fn()
|
||||
const parentClickHandler = vi.fn()
|
||||
|
||||
// Act
|
||||
render(
|
||||
<div onClick={parentClickHandler}>
|
||||
<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
// Click an option
|
||||
const fixOnlyOption = screen.getByText('plugin.autoUpdate.strategy.fixOnly.name').closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(fixOnlyOption!)
|
||||
|
||||
// Assert - onChange is called but parent click handler should not propagate
|
||||
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.fixOnly)
|
||||
})
|
||||
|
||||
it('should render check icon for currently selected option', () => {
|
||||
// Arrange - force portal content to be visible
|
||||
forcePortalContentVisible = true
|
||||
|
||||
// Act - render with fixOnly selected
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />)
|
||||
|
||||
// Assert - RiCheckLine should be rendered (check icon)
|
||||
// Find all "Bug Fixes Only" texts and get the one in the dropdown (has cursor-pointer parent)
|
||||
const allFixOnlyTexts = screen.getAllByText('plugin.autoUpdate.strategy.fixOnly.name')
|
||||
const dropdownOption = allFixOnlyTexts.find(el => el.closest('div[class*="cursor-pointer"]'))
|
||||
const optionContainer = dropdownOption?.closest('div[class*="cursor-pointer"]')
|
||||
expect(optionContainer).toBeInTheDocument()
|
||||
// The check icon SVG should exist within the option
|
||||
expect(optionContainer?.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render check icon for non-selected options', () => {
|
||||
// Arrange - force portal content to be visible
|
||||
forcePortalContentVisible = true
|
||||
|
||||
// Act - render with disabled selected
|
||||
it('should open and close the menu when the trigger is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
|
||||
|
||||
// Assert - check the Latest Version option should not have check icon
|
||||
const latestOption = screen.getByText('plugin.autoUpdate.strategy.latest.name').closest('div[class*="cursor-pointer"]')
|
||||
// The svg should only be in selected option, not in non-selected
|
||||
const checkIconContainer = latestOption?.querySelector('div.mr-1')
|
||||
// Non-selected option should have empty check icon container
|
||||
expect(checkIconContainer?.querySelector('svg')).toBeNull()
|
||||
const trigger = screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) })
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(trigger)
|
||||
expect(await screen.findByRole('menu')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each<[AUTO_UPDATE_STRATEGY, 'disabled' | 'fixOnly' | 'latest', AUTO_UPDATE_STRATEGY]>([
|
||||
[AUTO_UPDATE_STRATEGY.disabled, 'fixOnly', AUTO_UPDATE_STRATEGY.fixOnly],
|
||||
[AUTO_UPDATE_STRATEGY.disabled, 'latest', AUTO_UPDATE_STRATEGY.latest],
|
||||
[AUTO_UPDATE_STRATEGY.fixOnly, 'disabled', AUTO_UPDATE_STRATEGY.disabled],
|
||||
])('should call onChange with %s -> %s when option is selected', async (initial, optionKey, expected) => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<StrategyPicker value={initial} onChange={onChange} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: triggerName(initial) }))
|
||||
await user.click(await findOption(optionKey))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('should mark only the currently selected option with aria-checked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.fixOnly) }))
|
||||
|
||||
const options = await screen.findAllByRole('menuitemradio')
|
||||
const checked = options.filter(o => o.getAttribute('aria-checked') === 'true')
|
||||
|
||||
expect(checked).toHaveLength(1)
|
||||
expect(checked[0]).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name')
|
||||
})
|
||||
|
||||
it('should render the check indicator inside the selected option only', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.fixOnly) }))
|
||||
|
||||
const fixOnlyOption = await findOption('fixOnly')
|
||||
const latestOption = await findOption('latest')
|
||||
|
||||
expect(fixOnlyOption.querySelector('.i-ri-check-line')).toBeInTheDocument()
|
||||
expect(latestOption.querySelector('.i-ri-check-line')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1280,7 +1219,9 @@ describe('auto-update-setting', () => {
|
||||
render(<AutoUpdateSetting {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.fixOnly\.name/i }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show time picker when strategy is not disabled', () => {
|
||||
@ -1407,16 +1348,27 @@ describe('auto-update-setting', () => {
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with updated strategy when strategy changes', () => {
|
||||
it('should call onChange with updated strategy when strategy changes', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const payload = createMockAutoUpdateConfig()
|
||||
const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly })
|
||||
|
||||
// Act
|
||||
render(<AutoUpdateSetting payload={payload} onChange={onChange} />)
|
||||
|
||||
// Assert - component renders with strategy picker
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.fixOnly\.name/i }),
|
||||
)
|
||||
const latestOption = (await screen.findAllByRole('menuitemradio')).find(item =>
|
||||
item.textContent?.includes('plugin.autoUpdate.strategy.latest.name'),
|
||||
)!
|
||||
await user.click(latestOption)
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ strategy_setting: AUTO_UPDATE_STRATEGY.latest }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onChange with updated time when time changes', () => {
|
||||
|
||||
@ -1,62 +1,12 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import StrategyPicker from '../strategy-picker'
|
||||
import { AUTO_UPDATE_STRATEGY } from '../types'
|
||||
|
||||
let portalOpen = false
|
||||
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => <span data-testid="picker-button">{children}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
|
||||
const _React = await import('react')
|
||||
return {
|
||||
PortalToFollowElem: ({
|
||||
open,
|
||||
children,
|
||||
}: {
|
||||
open: boolean
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
portalOpen = open
|
||||
return <div>{children}</div>
|
||||
},
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: (event: { stopPropagation: () => void, nativeEvent: { stopImmediatePropagation: () => void } }) => void
|
||||
}) => (
|
||||
<button
|
||||
data-testid="trigger"
|
||||
onClick={() => onClick({
|
||||
stopPropagation: vi.fn(),
|
||||
nativeEvent: { stopImmediatePropagation: vi.fn() },
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
PortalToFollowElemContent: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => portalOpen ? <div data-testid="portal-content">{children}</div> : null,
|
||||
}
|
||||
})
|
||||
const triggerName = (key: string) => new RegExp(`plugin\\.autoUpdate\\.strategy\\.${key}\\.name`, 'i')
|
||||
|
||||
describe('StrategyPicker', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
portalOpen = false
|
||||
})
|
||||
|
||||
it('renders the selected strategy label in the trigger', () => {
|
||||
render(
|
||||
<StrategyPicker
|
||||
@ -65,10 +15,12 @@ describe('StrategyPicker', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('trigger')).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name')
|
||||
expect(screen.getByRole('button', { name: triggerName('fixOnly') })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the option list when the trigger is clicked', () => {
|
||||
it('opens the option list when the trigger is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<StrategyPicker
|
||||
value={AUTO_UPDATE_STRATEGY.disabled}
|
||||
@ -76,14 +28,33 @@ describe('StrategyPicker', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
await user.click(screen.getByRole('button', { name: triggerName('disabled') }))
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('portal-content').querySelectorAll('svg')).toHaveLength(1)
|
||||
const options = await screen.findAllByRole('menuitemradio')
|
||||
expect(options).toHaveLength(3)
|
||||
expect(screen.getByText('plugin.autoUpdate.strategy.latest.description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onChange when a new strategy is selected', () => {
|
||||
it('marks only the currently selected strategy as checked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<StrategyPicker
|
||||
value={AUTO_UPDATE_STRATEGY.fixOnly}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: triggerName('fixOnly') }))
|
||||
|
||||
const checkedOptions = (await screen.findAllByRole('menuitemradio'))
|
||||
.filter(item => item.getAttribute('aria-checked') === 'true')
|
||||
|
||||
expect(checkedOptions).toHaveLength(1)
|
||||
expect(checkedOptions[0]).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name')
|
||||
})
|
||||
|
||||
it('calls onChange and closes the menu when a new strategy is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<StrategyPicker
|
||||
@ -92,9 +63,12 @@ describe('StrategyPicker', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
fireEvent.click(screen.getByText('plugin.autoUpdate.strategy.latest.name'))
|
||||
await user.click(screen.getByRole('button', { name: triggerName('disabled') }))
|
||||
const latestOption = (await screen.findAllByRole('menuitemradio'))
|
||||
.find(item => item.textContent?.includes('plugin.autoUpdate.strategy.latest.name'))!
|
||||
await user.click(latestOption)
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.latest)
|
||||
expect(await screen.findByRole('button', { name: triggerName('disabled') })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
} from '@remixicon/react'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRadioItemIndicator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { AUTO_UPDATE_STRATEGY } from './types'
|
||||
|
||||
const i18nPrefix = 'autoUpdate.strategy'
|
||||
@ -42,58 +41,48 @@ const StrategyPicker = ({
|
||||
},
|
||||
]
|
||||
const selectedOption = options.find(option => option.value === value)
|
||||
const handleValueChange = (nextValue: string) => {
|
||||
onChange(nextValue as AUTO_UPDATE_STRATEGY)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-end"
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.nativeEvent.stopImmediatePropagation()
|
||||
setOpen(v => !v)
|
||||
}}
|
||||
<DropdownMenuTrigger render={<Button size="small" />}>
|
||||
{selectedOption?.label}
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-3.5 w-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="top-end"
|
||||
sideOffset={4}
|
||||
className="z-99"
|
||||
popupClassName="w-[280px] p-1"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
<DropdownMenuRadioGroup
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
{selectedOption?.label}
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-99">
|
||||
<div className="w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="flex cursor-pointer rounded-lg p-2 pr-3 hover:bg-state-base-hover"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.nativeEvent.stopImmediatePropagation()
|
||||
onChange(option.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="mr-1 w-4 shrink-0">
|
||||
{
|
||||
value === option.value && (
|
||||
<RiCheckLine className="h-4 w-4 text-text-accent" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="mb-0.5 system-sm-semibold text-text-secondary">{option.label}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{option.description}</div>
|
||||
</div>
|
||||
{options.map(option => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="mx-0 h-auto items-start gap-1 p-2 pr-3"
|
||||
>
|
||||
<div className="mr-1 flex w-4 shrink-0 justify-center pt-0.5">
|
||||
<DropdownMenuRadioItemIndicator className="ml-0" />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<div className="grow">
|
||||
<div className="mb-0.5 system-sm-semibold text-text-secondary">{option.label}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{option.description}</div>
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
"analysis.ms": "мс",
|
||||
"analysis.title": "Анализ",
|
||||
"analysis.tokenPS": "Токен/с",
|
||||
"analysis.tokenUsage.consumed": "Потрачено",
|
||||
"analysis.tokenUsage.consumed": "Потреблено",
|
||||
"analysis.tokenUsage.explanation": "Отражает ежедневное использование токенов языковой модели для приложения, полезно для целей контроля затрат.",
|
||||
"analysis.tokenUsage.title": "Использование токенов",
|
||||
"analysis.totalConversations.explanation": "Ежедневное количество чатов с LLM; проектирование/отладка не учитываются.",
|
||||
@ -62,7 +62,7 @@
|
||||
"overview.appInfo.enableTooltip.description": "Чтобы включить эту функцию, добавьте на холст узел ввода пользователя. (Может уже существовать в черновике, вступает в силу после публикации)",
|
||||
"overview.appInfo.enableTooltip.learnMore": "Узнать больше",
|
||||
"overview.appInfo.explanation": "Готовое к использованию веб-приложение ИИ",
|
||||
"overview.appInfo.launch": "Баркас",
|
||||
"overview.appInfo.launch": "Запустить",
|
||||
"overview.appInfo.preUseReminder": "Пожалуйста, включите веб-приложение перед продолжением.",
|
||||
"overview.appInfo.preview": "Предварительный просмотр",
|
||||
"overview.appInfo.qrcode.download": "Скачать QR-код",
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
{
|
||||
"embedding.automatic": "Автоматически",
|
||||
"embedding.childMaxTokens": "Ребёнок",
|
||||
"embedding.childMaxTokens": "Наследник",
|
||||
"embedding.completed": "Встраивание завершено",
|
||||
"embedding.custom": "Пользовательский",
|
||||
"embedding.docName": "Предварительная обработка документа",
|
||||
"embedding.docName": "Имя документа",
|
||||
"embedding.economy": "Экономичный режим",
|
||||
"embedding.error": "Ошибка расчета эмбеддингов",
|
||||
"embedding.estimate": "Оценочное потребление",
|
||||
"embedding.hierarchical": "Родитель-дочерний",
|
||||
"embedding.estimate": "Оценка",
|
||||
"embedding.hierarchical": "Иерархический",
|
||||
"embedding.highQuality": "Режим высокого качества",
|
||||
"embedding.mode": "Правило сегментации",
|
||||
"embedding.parentMaxTokens": "Родитель",
|
||||
@ -16,7 +16,7 @@
|
||||
"embedding.previewTip": "Предварительный просмотр абзацев будет доступен после завершения расчета эмбеддингов",
|
||||
"embedding.processing": "Расчет эмбеддингов...",
|
||||
"embedding.resume": "Возобновить обработку",
|
||||
"embedding.segmentLength": "Длина фрагментов",
|
||||
"embedding.segmentLength": "Длина сегментов",
|
||||
"embedding.segments": "Абзацы",
|
||||
"embedding.stop": "Остановить обработку",
|
||||
"embedding.textCleaning": "Предварительная очистка текста",
|
||||
@ -279,25 +279,25 @@
|
||||
"metadata.type.webPage": "Веб-страница",
|
||||
"metadata.type.wikipediaEntry": "Статья в Википедии",
|
||||
"segment.addAnother": "Добавить еще один",
|
||||
"segment.addChildChunk": "Добавить дочерний чанк",
|
||||
"segment.addChunk": "Добавить чанк",
|
||||
"segment.addChildChunk": "Добавить дочерний фрагмент",
|
||||
"segment.addChunk": "Добавить фрагмент",
|
||||
"segment.addKeyWord": "Добавить ключевое слово",
|
||||
"segment.allFilesUploaded": "Все файлы должны быть загружены перед сохранением",
|
||||
"segment.answerEmpty": "Ответ не может быть пустым",
|
||||
"segment.answerPlaceholder": "добавьте ответ здесь",
|
||||
"segment.characters_one": "характер",
|
||||
"segment.characters_other": "письмена",
|
||||
"segment.childChunk": "Чайлд-Чанк",
|
||||
"segment.childChunkAdded": "Добавлен 1 дочерний чанк",
|
||||
"segment.childChunks_one": "ДОЧЕРНИЙ ЧАНК",
|
||||
"segment.childChunks_other": "ДЕТСКИЕ КУСОЧКИ",
|
||||
"segment.chunk": "Ломоть",
|
||||
"segment.chunkAdded": "Добавлен 1 блок",
|
||||
"segment.chunkDetail": "Деталь Чанка",
|
||||
"segment.chunks_one": "ЛОМОТЬ",
|
||||
"segment.chunks_other": "КУСКИ",
|
||||
"segment.characters_one": "символ",
|
||||
"segment.characters_other": "символы",
|
||||
"segment.childChunk": "Дочерний фрагмент",
|
||||
"segment.childChunkAdded": "Добавлен 1 дочерний фрагмент",
|
||||
"segment.childChunks_one": "ДОЧЕРНИЙ ФРАГМЕНТ",
|
||||
"segment.childChunks_other": "ДОЧЕРНИЕ ФРАГМЕНТЫ",
|
||||
"segment.chunk": "Фрагмент",
|
||||
"segment.chunkAdded": "Добавлен 1 фрагмент",
|
||||
"segment.chunkDetail": "Детали фрагмента",
|
||||
"segment.chunks_one": "ФРАГМЕНТ",
|
||||
"segment.chunks_other": "ФРАГМЕНТЫ",
|
||||
"segment.clearFilter": "Очистить фильтр",
|
||||
"segment.collapseChunks": "Сворачивание кусков",
|
||||
"segment.collapseChunks": "Свернуть фрагменты",
|
||||
"segment.contentEmpty": "Содержимое не может быть пустым",
|
||||
"segment.contentPlaceholder": "добавьте содержимое здесь",
|
||||
"segment.dateTimeFormat": "MM/DD/YYYY HH:mm",
|
||||
@ -307,15 +307,15 @@
|
||||
"segment.editParentChunk": "Редактирование родительского блока",
|
||||
"segment.edited": "ОТРЕДАКТИРОВАНЫ",
|
||||
"segment.editedAt": "Отредактировано в",
|
||||
"segment.empty": "Чанк не найден",
|
||||
"segment.expandChunks": "Развернуть чанки",
|
||||
"segment.empty": "Фрагмент не найден",
|
||||
"segment.expandChunks": "Развернуть фрагменты",
|
||||
"segment.hitCount": "Количество обращений",
|
||||
"segment.keywordDuplicate": "Ключевое слово уже существует",
|
||||
"segment.keywordEmpty": "Ключевое слово не может быть пустым",
|
||||
"segment.keywordError": "Максимальная длина ключевого слова - 20",
|
||||
"segment.keywords": "Ключевые слова",
|
||||
"segment.newChildChunk": "Новый дочерний чанк",
|
||||
"segment.newChunk": "Новый чанк",
|
||||
"segment.newChildChunk": "Новый дочерний фрагмент",
|
||||
"segment.newChunk": "Новый фрагмент",
|
||||
"segment.newQaSegment": "Новый сегмент вопрос-ответ",
|
||||
"segment.newTextSegment": "Новый текстовый сегмент",
|
||||
"segment.paragraphs": "Абзацы",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"blocks.agent": "Агент",
|
||||
"blocks.answer": "Ответ",
|
||||
"blocks.assigner": "Назначение переменной",
|
||||
"blocks.assigner": "Назначение переменных",
|
||||
"blocks.code": "Код",
|
||||
"blocks.datasource": "Источник данных",
|
||||
"blocks.datasource-empty": "Пустой источник данных",
|
||||
@ -17,10 +17,10 @@
|
||||
"blocks.list-operator": "Оператор списка",
|
||||
"blocks.llm": "LLM",
|
||||
"blocks.loop": "Цикл",
|
||||
"blocks.loop-end": "Выйти из цикла",
|
||||
"blocks.loop-end": "Конец цикла",
|
||||
"blocks.loop-start": "Начало цикла",
|
||||
"blocks.originalStartNode": "исходный начальный узел",
|
||||
"blocks.parameter-extractor": "Извлечение параметров",
|
||||
"blocks.parameter-extractor": "Экстрактор параметров",
|
||||
"blocks.question-classifier": "Классификатор вопросов",
|
||||
"blocks.start": "Начало",
|
||||
"blocks.template-transform": "Шаблон",
|
||||
@ -29,7 +29,7 @@
|
||||
"blocks.trigger-schedule": "Триггер расписания",
|
||||
"blocks.trigger-webhook": "Вебхук-триггер",
|
||||
"blocks.variable-aggregator": "Агрегатор переменных",
|
||||
"blocks.variable-assigner": "Агрегатор переменных",
|
||||
"blocks.variable-assigner": "Назначение переменных",
|
||||
"blocksAbout.agent": "Вызов больших языковых моделей для ответа на вопросы или обработки естественного языка",
|
||||
"blocksAbout.answer": "Определите содержимое ответа в чате",
|
||||
"blocksAbout.assigner": "Узел назначения переменной используется для назначения значений записываемым переменным (например, переменным разговора).",
|
||||
@ -485,7 +485,7 @@
|
||||
"nodes.common.pluginNotInstalled": "Плагин не установлен",
|
||||
"nodes.common.pluginsNotInstalled": "{{count}} плагинов не установлено",
|
||||
"nodes.common.retry.maxRetries": "максимальное количество повторных попыток",
|
||||
"nodes.common.retry.ms": "госпожа",
|
||||
"nodes.common.retry.ms": "мс",
|
||||
"nodes.common.retry.retries": "{{num}} Повторных попыток",
|
||||
"nodes.common.retry.retry": "Снова пробовать",
|
||||
"nodes.common.retry.retryFailed": "Повторная попытка не удалась",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user