Merge branch 'main' into copilot/fix-css-issue

This commit is contained in:
Crazywoola 2026-04-29 10:44:49 +08:00 committed by GitHub
commit c61df1942f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1213 additions and 622 deletions

View File

@ -110,6 +110,28 @@ jobs:
sed -i 's/DB_PORT=5432/DB_PORT=3306/' .env sed -i 's/DB_PORT=5432/DB_PORT=3306/' .env
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=root/' .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 - name: Run DB Migration
env: env:
DEBUG: true DEBUG: true

View File

@ -13,7 +13,7 @@ concurrency:
jobs: jobs:
test: test:
name: Web Full-Stack E2E name: Web Full-Stack E2E
runs-on: depot-ubuntu-24.04 runs-on: depot-ubuntu-24.04-4
defaults: defaults:
run: run:
shell: bash shell: bash

View File

@ -38,6 +38,48 @@ class HitTestingPayload(BaseModel):
class DatasetsHitTestingBase: 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 @staticmethod
def get_and_validate_dataset(dataset_id: str): def get_and_validate_dataset(dataset_id: str):
assert isinstance(current_user, Account) assert isinstance(current_user, Account)
@ -75,7 +117,12 @@ class DatasetsHitTestingBase:
attachment_ids=args.get("attachment_ids"), attachment_ids=args.get("attachment_ids"),
limit=10, 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: except services.errors.index.IndexNotInitializedError:
raise DatasetNotInitializedError() raise DatasetNotInitializedError()
except ProviderTokenNotInitError as ex: except ProviderTokenNotInitError as ex:

View File

@ -468,15 +468,98 @@ class DocumentAddByFileApi(DatasetApiResource):
return documents_and_batch_fields, 200 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( @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",
"/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): class DeprecatedDocumentUpdateByFileApi(DatasetApiResource):
"""Resource for update documents.""" """Deprecated resource aliases for file document updates."""
@service_api_ns.doc("update_document_by_file") @service_api_ns.doc("update_document_by_file_deprecated")
@service_api_ns.doc(description="Update an existing document by uploading a file") @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(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
@service_api_ns.doc( @service_api_ns.doc(
responses={ responses={
@ -487,82 +570,9 @@ class DocumentUpdateByFileApi(DatasetApiResource):
) )
@cloud_edition_billing_resource_check("vector_space", "dataset") @cloud_edition_billing_resource_check("vector_space", "dataset")
@cloud_edition_billing_rate_limit_check("knowledge", "dataset") @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id, document_id): def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
"""Update document by upload file.""" """Update document by file through the deprecated file-update aliases."""
dataset = db.session.scalar( return _update_document_by_file(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
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
@service_api_ns.route("/datasets/<uuid:dataset_id>/documents") @service_api_ns.route("/datasets/<uuid:dataset_id>/documents")
@ -876,6 +886,22 @@ class DocumentApi(DatasetApiResource):
return response 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("delete_document")
@service_api_ns.doc(description="Delete a document") @service_api_ns.doc(description="Delete a document")
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"}) @service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})

View File

@ -134,6 +134,42 @@ class TestPerformHitTesting:
assert result["query"] == "hello" assert result["query"] == "hello"
assert result["records"] == [] 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): def test_index_not_initialized(self, dataset):
with patch.object( with patch.object(
HitTestingService, HitTestingService,

View File

@ -23,6 +23,7 @@ from werkzeug.exceptions import Forbidden, NotFound
from controllers.service_api.dataset.document import ( from controllers.service_api.dataset.document import (
DeprecatedDocumentAddByTextApi, DeprecatedDocumentAddByTextApi,
DeprecatedDocumentUpdateByFileApi,
DeprecatedDocumentUpdateByTextApi, DeprecatedDocumentUpdateByTextApi,
DocumentAddByFileApi, DocumentAddByFileApi,
DocumentAddByTextApi, DocumentAddByTextApi,
@ -32,7 +33,6 @@ from controllers.service_api.dataset.document import (
DocumentListQuery, DocumentListQuery,
DocumentTextCreatePayload, DocumentTextCreatePayload,
DocumentTextUpdate, DocumentTextUpdate,
DocumentUpdateByFileApi,
DocumentUpdateByTextApi, DocumentUpdateByTextApi,
InvalidMetadataError, InvalidMetadataError,
) )
@ -1095,8 +1095,8 @@ class TestArchivedDocumentImmutableError:
assert error.code == 403 assert error.code == 403
class TestDocumentTextRouteDeprecation: class TestDocumentRouteDeprecation:
"""Test that legacy underscore text routes stay marked deprecated.""" """Test that legacy document routes stay marked deprecated."""
def test_create_by_text_legacy_alias_is_deprecated(self): def test_create_by_text_legacy_alias_is_deprecated(self):
"""Ensure only the legacy create-by-text alias is marked deprecated.""" """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 DeprecatedDocumentUpdateByTextApi.post.__apidoc__["deprecated"] is True
assert DocumentUpdateByTextApi.post.__apidoc__.get("deprecated") is not 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, # Endpoint tests for DocumentUpdateByTextApi, DocumentAddByFileApi,
# DocumentUpdateByFileApi. # and the canonical/deprecated document file update routes.
# #
# These controllers use ``@cloud_edition_billing_resource_check`` (does NOT # These controllers use ``@cloud_edition_billing_resource_check`` (does NOT
# preserve ``__wrapped__``) and ``@cloud_edition_billing_rate_limit_check`` # 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) api.post(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id)
class TestDocumentUpdateByFileApiPost: class TestDocumentUpdateByFileApiPatch:
"""Test suite for DocumentUpdateByFileApi.post() endpoint. """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``. ``@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.dataset.document.db")
@patch("controllers.service_api.wraps.FeatureService") @patch("controllers.service_api.wraps.FeatureService")
@patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.validate_and_get_api_token")
@ -1387,15 +1431,15 @@ class TestDocumentUpdateByFileApiPost:
doc_id = str(uuid.uuid4()) doc_id = str(uuid.uuid4())
data = {"file": (BytesIO(b"content"), "test.pdf", "application/pdf")} data = {"file": (BytesIO(b"content"), "test.pdf", "application/pdf")}
with app.test_request_context( with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_file", f"/datasets/{mock_dataset.id}/documents/{doc_id}",
method="POST", method="PATCH",
content_type="multipart/form-data", content_type="multipart/form-data",
data=data, data=data,
headers={"Authorization": "Bearer test_token"}, headers={"Authorization": "Bearer test_token"},
): ):
api = DocumentUpdateByFileApi() api = DocumentApi()
with pytest.raises(ValueError, match="Dataset does not exist"): with pytest.raises(ValueError, match="Dataset does not exist"):
api.post( api.patch(
tenant_id=mock_tenant.id, tenant_id=mock_tenant.id,
dataset_id=mock_dataset.id, dataset_id=mock_dataset.id,
document_id=doc_id, document_id=doc_id,
@ -1423,15 +1467,15 @@ class TestDocumentUpdateByFileApiPost:
doc_id = str(uuid.uuid4()) doc_id = str(uuid.uuid4())
data = {"file": (BytesIO(b"content"), "test.pdf", "application/pdf")} data = {"file": (BytesIO(b"content"), "test.pdf", "application/pdf")}
with app.test_request_context( with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_file", f"/datasets/{mock_dataset.id}/documents/{doc_id}",
method="POST", method="PATCH",
content_type="multipart/form-data", content_type="multipart/form-data",
data=data, data=data,
headers={"Authorization": "Bearer test_token"}, headers={"Authorization": "Bearer test_token"},
): ):
api = DocumentUpdateByFileApi() api = DocumentApi()
with pytest.raises(ValueError, match="External datasets"): with pytest.raises(ValueError, match="External datasets"):
api.post( api.patch(
tenant_id=mock_tenant.id, tenant_id=mock_tenant.id,
dataset_id=mock_dataset.id, dataset_id=mock_dataset.id,
document_id=doc_id, document_id=doc_id,
@ -1482,14 +1526,14 @@ class TestDocumentUpdateByFileApiPost:
doc_id = str(uuid.uuid4()) doc_id = str(uuid.uuid4())
data = {"file": (BytesIO(b"file content"), "test.pdf", "application/pdf")} data = {"file": (BytesIO(b"file content"), "test.pdf", "application/pdf")}
with app.test_request_context( with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_file", f"/datasets/{mock_dataset.id}/documents/{doc_id}",
method="POST", method="PATCH",
content_type="multipart/form-data", content_type="multipart/form-data",
data=data, data=data,
headers={"Authorization": "Bearer test_token"}, headers={"Authorization": "Bearer test_token"},
): ):
api = DocumentUpdateByFileApi() api = DocumentApi()
response, status = api.post( response, status = api.patch(
tenant_id=mock_tenant.id, tenant_id=mock_tenant.id,
dataset_id=mock_dataset.id, dataset_id=mock_dataset.id,
document_id=doc_id, document_id=doc_id,

View File

@ -171,6 +171,57 @@ class TestHitTestingApiPost:
assert passed_retrieval_model["search_method"] == "semantic_search" assert passed_retrieval_model["search_method"] == "semantic_search"
assert passed_retrieval_model["top_k"] == 10 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.service_api.dataset.hit_testing.service_api_ns")
@patch("controllers.console.datasets.hit_testing_base.DatasetService") @patch("controllers.console.datasets.hit_testing_base.DatasetService")
@patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account)) @patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account))

View File

@ -1,14 +1,12 @@
"""Primarily used for testing merged cell scenarios""" """Primarily used for testing merged cell scenarios"""
import gc
import io import io
import os import os
import tempfile import tempfile
import warnings
from collections import UserDict from collections import UserDict
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import MagicMock
import pytest import pytest
from docx import Document from docx import Document
@ -377,23 +375,21 @@ def test_close_is_idempotent():
extractor.temp_file.close.assert_called_once() 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 = object.__new__(WordExtractor)
extractor._closed = False extractor._closed = False
extractor.temp_file = MagicMock() 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: extractor.close()
warnings.simplefilter("always")
extractor.close()
gc.collect()
assert close_result.cr_frame is None
extractor.temp_file.close.assert_called_once() 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): def test_extract_images_handles_invalid_external_cases(monkeypatch):

View File

@ -59,19 +59,25 @@ services:
- ${MYSQL_HOST_VOLUME:-./volumes/mysql/data}:/var/lib/mysql - ${MYSQL_HOST_VOLUME:-./volumes/mysql/data}:/var/lib/mysql
ports: ports:
- "${EXPOSE_MYSQL_PORT:-3306}:3306" - "${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: healthcheck:
test: test:
[ [
"CMD", "CMD",
"mysqladmin", "mysql",
"ping", "-h",
"-u", "127.0.0.1",
"root", "-uroot",
"-p${DB_PASSWORD:-difyai123456}", "-p${DB_PASSWORD:-difyai123456}",
"-e",
"SELECT 1",
] ]
interval: 1s interval: 1s
timeout: 3s timeout: 3s
retries: 30 retries: 30
start_period: 20s
# The redis cache. # The redis cache.
redis: redis:

View File

@ -17,3 +17,10 @@ Feature: Share app publicly
Given a workflow app has been published and shared via API Given a workflow app has been published and shared via API
When I open the shared app URL When I open the shared app URL
Then the shared app page should be accessible 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

View File

@ -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()).toHaveURL(/\/(workflow|chat)\/[a-zA-Z0-9]+/, { timeout: 15_000 })
await expect(this.getPage().locator('body')).toBeVisible({ timeout: 10_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 })
})

View File

@ -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) { When('I run the workflow', async function (this: DifyWorld) {
const page = this.getPage() const page = this.getPage()
await page.getByText('Test Run').click() const testRunButton = page.getByText('Test Run')
await expect(page.getByText('Running').first()).toBeVisible({ timeout: 15_000 })
await expect(testRunButton).toBeVisible({ timeout: 15_000 })
await testRunButton.click()
}) })
Then('the workflow run should succeed', async function (this: DifyWorld) { Then('the workflow run should succeed', async function (this: DifyWorld) {

View File

@ -3506,11 +3506,6 @@
"count": 1 "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": { "web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": {
"erasable-syntax-only/enums": { "erasable-syntax-only/enums": {
"count": 2 "count": 2

View File

@ -43,7 +43,7 @@ describe('OptionListItem', () => {
</OptionListItem>, </OptionListItem>,
) )
const item = screen.getByRole('listitem') const item = screen.getByRole('button')
expect(item).toHaveClass('bg-components-button-ghost-bg-hover') expect(item).toHaveClass('bg-components-button-ghost-bg-hover')
}) })
@ -54,7 +54,7 @@ describe('OptionListItem', () => {
</OptionListItem>, </OptionListItem>,
) )
const item = screen.getByRole('listitem') const item = screen.getByRole('button')
expect(item).not.toHaveClass('bg-components-button-ghost-bg-hover') expect(item).not.toHaveClass('bg-components-button-ghost-bg-hover')
}) })
}) })
@ -100,7 +100,7 @@ describe('OptionListItem', () => {
Clickable Clickable
</OptionListItem>, </OptionListItem>,
) )
fireEvent.click(screen.getByRole('listitem')) fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1) expect(handleClick).toHaveBeenCalledTimes(1)
}) })
@ -111,7 +111,7 @@ describe('OptionListItem', () => {
Item Item
</OptionListItem>, </OptionListItem>,
) )
fireEvent.click(screen.getByRole('listitem')) fireEvent.click(screen.getByRole('button'))
expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' }) expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' })
}) })
@ -126,7 +126,7 @@ describe('OptionListItem', () => {
</OptionListItem>, </OptionListItem>,
) )
const item = screen.getByRole('listitem') const item = screen.getByRole('button')
fireEvent.click(item) fireEvent.click(item)
fireEvent.click(item) fireEvent.click(item)
fireEvent.click(item) fireEvent.click(item)

View File

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

View File

@ -1,4 +1,4 @@
import type { FC } from 'react' import type { FC, ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react' import * as React from 'react'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
@ -7,7 +7,8 @@ type OptionListItemProps = {
isSelected: boolean isSelected: boolean
onClick: () => void onClick: () => void
noAutoScroll?: boolean noAutoScroll?: boolean
} & React.LiHTMLAttributes<HTMLLIElement> children: ReactNode
}
const OptionListItem: FC<OptionListItemProps> = ({ const OptionListItem: FC<OptionListItemProps> = ({
isSelected, isSelected,
@ -25,16 +26,21 @@ const OptionListItem: FC<OptionListItemProps> = ({
return ( return (
<li <li
ref={listItemRef} 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> </li>
) )
} }

View File

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

View File

@ -64,13 +64,13 @@ describe('TimePickerOptions', () => {
it('should render selected hour in the list', () => { it('should render selected hour in the list', () => {
const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') }) const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') })
render(<Options {...props} />) 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') expect(selectedHour)!.toHaveClass('bg-components-button-ghost-bg-hover')
}) })
it('should render selected minute in the list', () => { it('should render selected minute in the list', () => {
const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') }) const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') })
render(<Options {...props} />) 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') expect(selectedMinute)!.toHaveClass('bg-components-button-ghost-bg-hover')
}) })

View File

@ -1,6 +1,7 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { TimeOptionsProps } from '../types' import type { TimeOptionsProps } from '../types'
import * as React from 'react' import * as React from 'react'
import OptionList from '../common/option-list'
import OptionListItem from '../common/option-list-item' import OptionListItem from '../common/option-list-item'
import { useTimeOptions } from '../hooks' import { useTimeOptions } from '../hooks'
@ -16,7 +17,7 @@ const Options: FC<TimeOptionsProps> = ({
return ( return (
<div className="grid grid-cols-3 gap-x-1 p-2"> <div className="grid grid-cols-3 gap-x-1 p-2">
{/* Hour */} {/* Hour */}
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]"> <OptionList>
{ {
hourOptions.map((hour) => { hourOptions.map((hour) => {
const isSelected = selectedTime?.format('hh') === hour const isSelected = selectedTime?.format('hh') === hour
@ -31,9 +32,9 @@ const Options: FC<TimeOptionsProps> = ({
) )
}) })
} }
</ul> </OptionList>
{/* Minute */} {/* 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) => { (minuteFilter ? minuteFilter(minuteOptions) : minuteOptions).map((minute) => {
const isSelected = selectedTime?.format('mm') === minute const isSelected = selectedTime?.format('mm') === minute
@ -48,9 +49,9 @@ const Options: FC<TimeOptionsProps> = ({
) )
}) })
} }
</ul> </OptionList>
{/* Period */} {/* Period */}
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]"> <OptionList>
{ {
periodOptions.map((period) => { periodOptions.map((period) => {
const isSelected = selectedTime?.format('A') === period const isSelected = selectedTime?.format('A') === period
@ -66,7 +67,7 @@ const Options: FC<TimeOptionsProps> = ({
) )
}) })
} }
</ul> </OptionList>
</div> </div>
) )
} }

View File

@ -1,6 +1,7 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { YearAndMonthPickerOptionsProps } from '../types' import type { YearAndMonthPickerOptionsProps } from '../types'
import * as React from 'react' import * as React from 'react'
import OptionList from '../common/option-list'
import OptionListItem from '../common/option-list-item' import OptionListItem from '../common/option-list-item'
import { useMonths, useYearOptions } from '../hooks' import { useMonths, useYearOptions } from '../hooks'
@ -16,7 +17,7 @@ const Options: FC<YearAndMonthPickerOptionsProps> = ({
return ( return (
<div className="grid grid-cols-2 gap-x-1 p-2"> <div className="grid grid-cols-2 gap-x-1 p-2">
{/* Month Picker */} {/* 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) => { months.map((month, index) => {
const isSelected = selectedMonth === index const isSelected = selectedMonth === index
@ -31,9 +32,9 @@ const Options: FC<YearAndMonthPickerOptionsProps> = ({
) )
}) })
} }
</ul> </OptionList>
{/* Year Picker */} {/* Year Picker */}
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]"> <OptionList>
{ {
yearOptions.map((year) => { yearOptions.map((year) => {
const isSelected = selectedYear === year const isSelected = selectedYear === year
@ -48,7 +49,7 @@ const Options: FC<YearAndMonthPickerOptionsProps> = ({
) )
}) })
} }
</ul> </OptionList>
</div> </div>
) )
} }

View File

@ -42,7 +42,6 @@ const LanguageSelect: FC<ILanguageSelectProps> = ({
placement="bottom-start" placement="bottom-start"
sideOffset={4} sideOffset={4}
popupClassName="w-max" popupClassName="w-max"
listClassName="no-scrollbar"
> >
{supportedLanguages.map(({ prompt_name }) => ( {supportedLanguages.map(({ prompt_name }) => (
<SelectItem key={prompt_name} value={prompt_name}> <SelectItem key={prompt_name} value={prompt_name}>

View File

@ -55,7 +55,14 @@ vi.mock('../../hooks', async () => {
}) })
vi.mock('../popup-item', () => ({ 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', () => ({ vi.mock('@/context/provider-context', () => ({
@ -207,6 +214,156 @@ describe('Popup', () => {
expect((input as HTMLInputElement).value).toBe('') 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', () => { it('should not show compatible-only helper text when no scope features are applied', () => {
renderPopup( renderPopup(
<Popup <Popup
@ -219,8 +376,8 @@ describe('Popup', () => {
expect(screen.queryByText('common.modelProvider.selector.onlyCompatibleModelsShown')).not.toBeInTheDocument() expect(screen.queryByText('common.modelProvider.selector.onlyCompatibleModelsShown')).not.toBeInTheDocument()
}) })
it('should show compatible-only helper banner when scope features are applied', () => { it('should show compatible-only helper text when scope features are applied', () => {
const { container } = renderPopup( renderPopup(
<Popup <Popup
modelList={[makeModel()]} modelList={[makeModel()]}
onSelect={vi.fn()} onSelect={vi.fn()}
@ -231,7 +388,26 @@ describe('Popup', () => {
expect(screen.getByTestId('compatible-models-banner'))!.toBeInTheDocument() expect(screen.getByTestId('compatible-models-banner'))!.toBeInTheDocument()
expect(screen.getByText('common.modelProvider.selector.onlyCompatibleModelsShown'))!.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', () => { it('should filter by scope features including toolCall and non-toolCall checks', () => {

View File

@ -88,7 +88,7 @@ const ModelSelector: FC<ModelSelectorProps> = ({
placement="bottom-start" placement="bottom-start"
sideOffset={4} sideOffset={4}
className={popupClassName} className={popupClassName}
popupClassName="overflow-hidden rounded-lg" popupClassName="overflow-hidden rounded-xl"
popupProps={{ style: { minWidth: '320px', width: 'var(--anchor-width, auto)' } }} popupProps={{ style: { minWidth: '320px', width: 'var(--anchor-width, auto)' } }}
> >
<Popup <Popup

View File

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

View File

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

View File

@ -107,7 +107,8 @@ const PopupItem: FC<PopupItemProps> = ({
return ( return (
<div className="mb-1"> <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 <div
className="flex cursor-pointer items-center" className="flex cursor-pointer items-center"
onClick={() => setCollapsed(prev => !prev)} onClick={() => setCollapsed(prev => !prev)}

View File

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

View File

@ -5,8 +5,6 @@ import type {
ModelItem, ModelItem,
} from '../declarations' } from '../declarations'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider' 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 { useSuspenseQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
@ -19,7 +17,6 @@ import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/service/system-features' import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins' import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
import { supportFunctionCall } from '@/utils/tool-call' import { supportFunctionCall } from '@/utils/tool-call'
import { getMarketplaceUrl } from '@/utils/var'
import { import {
CustomConfigurationStatusEnum, CustomConfigurationStatusEnum,
ModelFeatureEnum, ModelFeatureEnum,
@ -29,8 +26,17 @@ import { useLanguage, useMarketplaceAllPlugins } from '../hooks'
import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert' import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert'
import { useTrialCredits } from '../provider-added-card/use-trial-credits' import { useTrialCredits } from '../provider-added-card/use-trial-credits'
import { providerSupportsCredits } from '../supports-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 PopupItem from './popup-item'
import {
CompatibleModelsNotice,
ModelProviderSettingsFooter,
ModelSelectorPopupFrame,
ModelSelectorScrollBody,
ModelSelectorSearchHeader,
} from './popup-layout'
type PopupProps = { type PopupProps = {
defaultModel?: DefaultModel defaultModel?: DefaultModel
@ -137,18 +143,26 @@ const Popup: FC<PopupProps> = ({
}, [aiCreditVisibleProviders, installedProviderMap, modelList]) }, [aiCreditVisibleProviders, installedProviderMap, modelList])
const filteredModelList = useMemo(() => { 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 filtered = installedModelList.map((model) => {
const matchesProviderSearch = !searchText const providerMatched = !!searchText && (
|| model.provider.toLowerCase().includes(searchText.toLowerCase()) matchesLabel(model.label)
|| Object.values(model.label).some(label => label.toLowerCase().includes(searchText.toLowerCase())) || model.provider.toLowerCase().includes(normalizedSearch)
)
const filteredModels = model.models const filteredModels = model.models
.filter((modelItem) => { .filter((modelItem) => {
if (modelItem.label[language] !== undefined) if (!searchText || providerMatched)
return modelItem.label[language].toLowerCase().includes(searchText.toLowerCase()) return true
return Object.values(modelItem.label).some(label => return matchesLabel(modelItem.label)
label.toLowerCase().includes(searchText.toLowerCase()),
)
}) })
.filter((modelItem) => { .filter((modelItem) => {
if (scopeFeatures.length === 0) if (scopeFeatures.length === 0)
@ -159,8 +173,12 @@ const Popup: FC<PopupProps> = ({
return modelItem.features?.includes(feature) ?? false 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 null
}
return { ...model, models: filteredModels } return { ...model, models: filteredModels }
}).filter((model): model is Model => model !== null) }).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)) return MODEL_PROVIDER_QUOTA_GET_PAID.filter(key => !installedProviders.has(key))
}, [modelProviders]) }, [modelProviders])
const handleOpenSettings = useCallback(() => {
onHide()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}, [onHide, setShowAccountSettingModal])
return ( return (
<div className="no-scrollbar max-h-[480px] overflow-y-auto"> <ModelSelectorPopupFrame>
<div className="sticky top-0 z-10 bg-components-panel-bg pt-3 pr-2 pb-1 pl-3"> <ModelSelectorSearchHeader
<div className={` searchText={searchText}
flex h-8 items-center rounded-lg border pr-[10px] pl-[9px] onSearchTextChange={setSearchText}
${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>
{showCreditsExhaustedAlert && ( {showCreditsExhaustedAlert && (
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} /> <CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
)} )}
<div className="pr-1 pb-1 pl-3"> <ModelSelectorScrollBody label={t('modelProvider.models', { ns: 'common' })}>
{ <div className="pb-1">
filteredModelList.map(model => ( {
<PopupItem filteredModelList.map(model => (
key={model.provider} <PopupItem
defaultModel={defaultModel} key={model.provider}
model={model} defaultModel={defaultModel}
onSelect={onSelect} model={model}
onHide={onHide} onSelect={onSelect}
onHide={onHide}
/>
))
}
{!filteredModelList.length && !installedModelList.length && (
<ModelSelectorEmptyState
onConfigure={handleOpenSettings}
/> />
)) )}
} {!filteredModelList.length && installedModelList.length > 0 && (
{!filteredModelList.length && !installedModelList.length && ( <div className="px-3 py-1.5 text-center text-xs leading-[18px] break-all text-text-tertiary">
<div className="flex flex-col gap-2 rounded-[10px] bg-linear-to-r from-state-base-hover to-background-gradient-mask-transparent p-4"> {`No model found for \u201C${searchText}\u201D`}
<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>
<div className="flex flex-col gap-1"> )}
<p className="system-sm-medium text-text-secondary"> {scopeFeatures.length > 0 && (
{t('modelProvider.selector.noProviderConfigured', { ns: 'common' })} <CompatibleModelsNotice />
</p> )}
<p className="system-xs-regular text-text-tertiary"> <MarketplaceSection
{t('modelProvider.selector.noProviderConfiguredDesc', { ns: 'common' })} marketplaceProviders={marketplaceProviders}
</p> marketplaceCollapsed={marketplaceCollapsed}
</div> installingProvider={installingProvider}
<Button isMarketplacePluginsLoading={isMarketplacePluginsLoading}
variant="primary" theme={theme}
className="w-[108px]" onMarketplaceCollapsedChange={setMarketplaceCollapsed}
onClick={() => { onInstallPlugin={handleInstallPlugin}
onHide() />
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER }) </div>
}} </ModelSelectorScrollBody>
> <ModelProviderSettingsFooter onOpenSettings={handleOpenSettings} />
{t('modelProvider.selector.configure', { ns: 'common' })} </ModelSelectorPopupFrame>
<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>
) )
} }

View File

@ -1,6 +1,7 @@
import type { FC, ReactNode } from 'react' import type { FC, ReactNode } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types' import type { PluginStatus } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config' import type { Locale } from '@/i18n-config'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import PluginItem from './plugin-item' import PluginItem from './plugin-item'
type PluginSectionProps = { type PluginSectionProps = {
@ -43,7 +44,14 @@ const PluginSection: FC<PluginSectionProps> = ({
) )
{headerAction} {headerAction}
</div> </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 => ( {plugins.map(plugin => (
<PluginItem <PluginItem
key={plugin.plugin_unique_identifier} key={plugin.plugin_unique_identifier}
@ -59,7 +67,7 @@ const PluginSection: FC<PluginSectionProps> = ({
: undefined} : undefined}
/> />
))} ))}
</div> </ScrollArea>
</> </>
) )
} }

View File

@ -1,6 +1,7 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types' import type { PluginStatus } from '@/app/components/plugins/types'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useGetLanguage } from '@/context/i18n' import { useGetLanguage } from '@/context/i18n'
import ErrorPluginItem from './error-plugin-item' import ErrorPluginItem from './error-plugin-item'
@ -86,7 +87,14 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
{t('task.clearAll', { ns: 'plugin' })} {t('task.clearAll', { ns: 'plugin' })}
</Button> </Button>
</div> </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 => ( {errorPlugins.map(plugin => (
<ErrorPluginItem <ErrorPluginItem
key={plugin.plugin_unique_identifier} key={plugin.plugin_unique_identifier}
@ -96,7 +104,7 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
onClear={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)} onClear={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
/> />
))} ))}
</div> </ScrollArea>
</> </>
)} )}
</div> </div>

View File

@ -117,7 +117,7 @@ const PluginTasks = () => {
<DropdownMenuContent <DropdownMenuContent
placement="bottom-end" placement="bottom-end"
sideOffset={4} 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 <PluginTaskList
runningPlugins={runningPlugins} runningPlugins={runningPlugins}

View File

@ -2,6 +2,7 @@ import type { AutoUpdateConfig } from '../types'
import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types' import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone' import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc' import utc from 'dayjs/plugin/utc'
@ -803,165 +804,103 @@ describe('auto-update-setting', () => {
}) })
describe('StrategyPicker (strategy-picker.tsx)', () => { describe('StrategyPicker (strategy-picker.tsx)', () => {
const defaultProps = { const i18nKeyByStrategy: Record<AUTO_UPDATE_STRATEGY, 'disabled' | 'fixOnly' | 'latest'> = {
value: AUTO_UPDATE_STRATEGY.disabled, [AUTO_UPDATE_STRATEGY.disabled]: 'disabled',
onChange: vi.fn(), [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', () => { describe('Rendering', () => {
it('should render trigger button with current strategy label', () => { it('should render trigger button with current strategy label', () => {
// Act render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
render(<StrategyPicker {...defaultProps} value={AUTO_UPDATE_STRATEGY.disabled} />)
// Assert expect(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.disabled\.name/i })).toBeInTheDocument()
}) })
it('should not render dropdown content when closed', () => { it('should not render dropdown content when closed', () => {
// Act render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
render(<StrategyPicker {...defaultProps} />)
// Assert expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
}) })
it('should render all strategy options when open', () => { it('should render all strategy options when open', async () => {
// Arrange const user = userEvent.setup()
mockPortalOpen = true render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
// Act await user.click(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) }))
render(<StrategyPicker {...defaultProps} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
// Wait for portal to open const options = await screen.findAllByRole('menuitemradio')
if (mockPortalOpen) { expect(options).toHaveLength(3)
// Assert all options visible (use getAllByText for strategy name as it appears in both trigger and dropdown) expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.disabled.name'))).toBe(true)
expect(screen.getAllByText('plugin.autoUpdate.strategy.disabled.name').length).toBeGreaterThanOrEqual(1) expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.fixOnly.name'))).toBe(true)
expect(screen.getByText('plugin.autoUpdate.strategy.fixOnly.name')).toBeInTheDocument() expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.latest.name'))).toBe(true)
expect(screen.getByText('plugin.autoUpdate.strategy.latest.name')).toBeInTheDocument()
}
}) })
}) })
describe('User Interactions', () => { describe('User Interactions', () => {
it('should toggle dropdown when trigger is clicked', () => { it('should open and close the menu when the trigger is clicked', async () => {
// Act const user = userEvent.setup()
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
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />) render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
// Assert - check the Latest Version option should not have check icon const trigger = screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) })
const latestOption = screen.getByText('plugin.autoUpdate.strategy.latest.name').closest('div[class*="cursor-pointer"]') expect(screen.queryByRole('menu')).not.toBeInTheDocument()
// The svg should only be in selected option, not in non-selected
const checkIconContainer = latestOption?.querySelector('div.mr-1') await user.click(trigger)
// Non-selected option should have empty check icon container expect(await screen.findByRole('menu')).toBeInTheDocument()
expect(checkIconContainer?.querySelector('svg')).toBeNull() })
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} />) render(<AutoUpdateSetting {...defaultProps} />)
// Assert // 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', () => { it('should show time picker when strategy is not disabled', () => {
@ -1407,16 +1348,27 @@ describe('auto-update-setting', () => {
}) })
describe('User Interactions', () => { 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 // Arrange
const user = userEvent.setup()
const onChange = vi.fn() const onChange = vi.fn()
const payload = createMockAutoUpdateConfig() const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly })
// Act // Act
render(<AutoUpdateSetting payload={payload} onChange={onChange} />) render(<AutoUpdateSetting payload={payload} onChange={onChange} />)
// Assert - component renders with strategy picker await user.click(
expect(screen.getByTestId('portal-elem')).toBeInTheDocument() 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', () => { it('should call onChange with updated time when time changes', () => {

View File

@ -1,62 +1,12 @@
import { fireEvent, render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import StrategyPicker from '../strategy-picker' import StrategyPicker from '../strategy-picker'
import { AUTO_UPDATE_STRATEGY } from '../types' import { AUTO_UPDATE_STRATEGY } from '../types'
let portalOpen = false const triggerName = (key: string) => new RegExp(`plugin\\.autoUpdate\\.strategy\\.${key}\\.name`, 'i')
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,
}
})
describe('StrategyPicker', () => { describe('StrategyPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
portalOpen = false
})
it('renders the selected strategy label in the trigger', () => { it('renders the selected strategy label in the trigger', () => {
render( render(
<StrategyPicker <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( render(
<StrategyPicker <StrategyPicker
value={AUTO_UPDATE_STRATEGY.disabled} 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() const options = await screen.findAllByRole('menuitemradio')
expect(screen.getByTestId('portal-content').querySelectorAll('svg')).toHaveLength(1) expect(options).toHaveLength(3)
expect(screen.getByText('plugin.autoUpdate.strategy.latest.description')).toBeInTheDocument() 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() const onChange = vi.fn()
render( render(
<StrategyPicker <StrategyPicker
@ -92,9 +63,12 @@ describe('StrategyPicker', () => {
/>, />,
) )
fireEvent.click(screen.getByTestId('trigger')) await user.click(screen.getByRole('button', { name: triggerName('disabled') }))
fireEvent.click(screen.getByText('plugin.autoUpdate.strategy.latest.name')) 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(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.latest)
expect(await screen.findByRole('button', { name: triggerName('disabled') })).toBeInTheDocument()
}) })
}) })

View File

@ -1,15 +1,14 @@
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { import {
RiArrowDownSLine, DropdownMenu,
RiCheckLine, DropdownMenuContent,
} from '@remixicon/react' DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { AUTO_UPDATE_STRATEGY } from './types' import { AUTO_UPDATE_STRATEGY } from './types'
const i18nPrefix = 'autoUpdate.strategy' const i18nPrefix = 'autoUpdate.strategy'
@ -42,58 +41,48 @@ const StrategyPicker = ({
}, },
] ]
const selectedOption = options.find(option => option.value === value) const selectedOption = options.find(option => option.value === value)
const handleValueChange = (nextValue: string) => {
onChange(nextValue as AUTO_UPDATE_STRATEGY)
setOpen(false)
}
return ( return (
<PortalToFollowElem <DropdownMenu
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
placement="top-end"
offset={4}
> >
<PortalToFollowElemTrigger onClick={(e) => { <DropdownMenuTrigger render={<Button size="small" />}>
e.stopPropagation() {selectedOption?.label}
e.nativeEvent.stopImmediatePropagation() <span aria-hidden className="i-ri-arrow-down-s-line h-3.5 w-3.5" />
setOpen(v => !v) </DropdownMenuTrigger>
}} <DropdownMenuContent
placement="top-end"
sideOffset={4}
className="z-99"
popupClassName="w-[280px] p-1"
> >
<Button <DropdownMenuRadioGroup
size="small" value={value}
onValueChange={handleValueChange}
> >
{selectedOption?.label} {options.map(option => (
<RiArrowDownSLine className="h-3.5 w-3.5" /> <DropdownMenuRadioItem
</Button> key={option.value}
</PortalToFollowElemTrigger> value={option.value}
<PortalToFollowElemContent className="z-99"> className="mx-0 h-auto items-start gap-1 p-2 pr-3"
<div className="w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg"> >
{ <div className="mr-1 flex w-4 shrink-0 justify-center pt-0.5">
options.map(option => ( <DropdownMenuRadioItemIndicator className="ml-0" />
<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>
</div> </div>
)) <div className="grow">
} <div className="mb-0.5 system-sm-semibold text-text-secondary">{option.label}</div>
</div> <div className="system-xs-regular text-text-tertiary">{option.description}</div>
</PortalToFollowElemContent> </div>
</PortalToFollowElem> </DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
) )
} }

View File

@ -10,7 +10,7 @@
"analysis.ms": "мс", "analysis.ms": "мс",
"analysis.title": "Анализ", "analysis.title": "Анализ",
"analysis.tokenPS": "Токен/с", "analysis.tokenPS": "Токен/с",
"analysis.tokenUsage.consumed": "Потрачено", "analysis.tokenUsage.consumed": "Потреблено",
"analysis.tokenUsage.explanation": "Отражает ежедневное использование токенов языковой модели для приложения, полезно для целей контроля затрат.", "analysis.tokenUsage.explanation": "Отражает ежедневное использование токенов языковой модели для приложения, полезно для целей контроля затрат.",
"analysis.tokenUsage.title": "Использование токенов", "analysis.tokenUsage.title": "Использование токенов",
"analysis.totalConversations.explanation": "Ежедневное количество чатов с LLM; проектирование/отладка не учитываются.", "analysis.totalConversations.explanation": "Ежедневное количество чатов с LLM; проектирование/отладка не учитываются.",
@ -62,7 +62,7 @@
"overview.appInfo.enableTooltip.description": "Чтобы включить эту функцию, добавьте на холст узел ввода пользователя. (Может уже существовать в черновике, вступает в силу после публикации)", "overview.appInfo.enableTooltip.description": "Чтобы включить эту функцию, добавьте на холст узел ввода пользователя. (Может уже существовать в черновике, вступает в силу после публикации)",
"overview.appInfo.enableTooltip.learnMore": "Узнать больше", "overview.appInfo.enableTooltip.learnMore": "Узнать больше",
"overview.appInfo.explanation": "Готовое к использованию веб-приложение ИИ", "overview.appInfo.explanation": "Готовое к использованию веб-приложение ИИ",
"overview.appInfo.launch": "Баркас", "overview.appInfo.launch": "Запустить",
"overview.appInfo.preUseReminder": "Пожалуйста, включите веб-приложение перед продолжением.", "overview.appInfo.preUseReminder": "Пожалуйста, включите веб-приложение перед продолжением.",
"overview.appInfo.preview": "Предварительный просмотр", "overview.appInfo.preview": "Предварительный просмотр",
"overview.appInfo.qrcode.download": "Скачать QR-код", "overview.appInfo.qrcode.download": "Скачать QR-код",

View File

@ -1,13 +1,13 @@
{ {
"embedding.automatic": "Автоматически", "embedding.automatic": "Автоматически",
"embedding.childMaxTokens": "Ребёнок", "embedding.childMaxTokens": "Наследник",
"embedding.completed": "Встраивание завершено", "embedding.completed": "Встраивание завершено",
"embedding.custom": "Пользовательский", "embedding.custom": "Пользовательский",
"embedding.docName": "Предварительная обработка документа", "embedding.docName": "Имя документа",
"embedding.economy": "Экономичный режим", "embedding.economy": "Экономичный режим",
"embedding.error": "Ошибка расчета эмбеддингов", "embedding.error": "Ошибка расчета эмбеддингов",
"embedding.estimate": "Оценочное потребление", "embedding.estimate": "Оценка",
"embedding.hierarchical": "Родитель-дочерний", "embedding.hierarchical": "Иерархический",
"embedding.highQuality": "Режим высокого качества", "embedding.highQuality": "Режим высокого качества",
"embedding.mode": "Правило сегментации", "embedding.mode": "Правило сегментации",
"embedding.parentMaxTokens": "Родитель", "embedding.parentMaxTokens": "Родитель",
@ -16,7 +16,7 @@
"embedding.previewTip": "Предварительный просмотр абзацев будет доступен после завершения расчета эмбеддингов", "embedding.previewTip": "Предварительный просмотр абзацев будет доступен после завершения расчета эмбеддингов",
"embedding.processing": "Расчет эмбеддингов...", "embedding.processing": "Расчет эмбеддингов...",
"embedding.resume": "Возобновить обработку", "embedding.resume": "Возобновить обработку",
"embedding.segmentLength": "Длина фрагментов", "embedding.segmentLength": "Длина сегментов",
"embedding.segments": "Абзацы", "embedding.segments": "Абзацы",
"embedding.stop": "Остановить обработку", "embedding.stop": "Остановить обработку",
"embedding.textCleaning": "Предварительная очистка текста", "embedding.textCleaning": "Предварительная очистка текста",
@ -279,25 +279,25 @@
"metadata.type.webPage": "Веб-страница", "metadata.type.webPage": "Веб-страница",
"metadata.type.wikipediaEntry": "Статья в Википедии", "metadata.type.wikipediaEntry": "Статья в Википедии",
"segment.addAnother": "Добавить еще один", "segment.addAnother": "Добавить еще один",
"segment.addChildChunk": "Добавить дочерний чанк", "segment.addChildChunk": "Добавить дочерний фрагмент",
"segment.addChunk": "Добавить чанк", "segment.addChunk": "Добавить фрагмент",
"segment.addKeyWord": "Добавить ключевое слово", "segment.addKeyWord": "Добавить ключевое слово",
"segment.allFilesUploaded": "Все файлы должны быть загружены перед сохранением", "segment.allFilesUploaded": "Все файлы должны быть загружены перед сохранением",
"segment.answerEmpty": "Ответ не может быть пустым", "segment.answerEmpty": "Ответ не может быть пустым",
"segment.answerPlaceholder": "добавьте ответ здесь", "segment.answerPlaceholder": "добавьте ответ здесь",
"segment.characters_one": "характер", "segment.characters_one": "символ",
"segment.characters_other": "письмена", "segment.characters_other": "символы",
"segment.childChunk": "Чайлд-Чанк", "segment.childChunk": "Дочерний фрагмент",
"segment.childChunkAdded": "Добавлен 1 дочерний чанк", "segment.childChunkAdded": "Добавлен 1 дочерний фрагмент",
"segment.childChunks_one": "ДОЧЕРНИЙ ЧАНК", "segment.childChunks_one": "ДОЧЕРНИЙ ФРАГМЕНТ",
"segment.childChunks_other": ЕТСКИЕ КУСОЧКИ", "segment.childChunks_other": ОЧЕРНИЕ ФРАГМЕНТЫ",
"segment.chunk": "Ломоть", "segment.chunk": "Фрагмент",
"segment.chunkAdded": "Добавлен 1 блок", "segment.chunkAdded": "Добавлен 1 фрагмент",
"segment.chunkDetail": "Деталь Чанка", "segment.chunkDetail": "Детали фрагмента",
"segment.chunks_one": "ЛОМОТЬ", "segment.chunks_one": "ФРАГМЕНТ",
"segment.chunks_other": "КУСКИ", "segment.chunks_other": "ФРАГМЕНТЫ",
"segment.clearFilter": "Очистить фильтр", "segment.clearFilter": "Очистить фильтр",
"segment.collapseChunks": "Сворачивание кусков", "segment.collapseChunks": "Свернуть фрагменты",
"segment.contentEmpty": "Содержимое не может быть пустым", "segment.contentEmpty": "Содержимое не может быть пустым",
"segment.contentPlaceholder": "добавьте содержимое здесь", "segment.contentPlaceholder": "добавьте содержимое здесь",
"segment.dateTimeFormat": "MM/DD/YYYY HH:mm", "segment.dateTimeFormat": "MM/DD/YYYY HH:mm",
@ -307,15 +307,15 @@
"segment.editParentChunk": "Редактирование родительского блока", "segment.editParentChunk": "Редактирование родительского блока",
"segment.edited": "ОТРЕДАКТИРОВАНЫ", "segment.edited": "ОТРЕДАКТИРОВАНЫ",
"segment.editedAt": "Отредактировано в", "segment.editedAt": "Отредактировано в",
"segment.empty": "Чанк не найден", "segment.empty": "Фрагмент не найден",
"segment.expandChunks": "Развернуть чанки", "segment.expandChunks": "Развернуть фрагменты",
"segment.hitCount": "Количество обращений", "segment.hitCount": "Количество обращений",
"segment.keywordDuplicate": "Ключевое слово уже существует", "segment.keywordDuplicate": "Ключевое слово уже существует",
"segment.keywordEmpty": "Ключевое слово не может быть пустым", "segment.keywordEmpty": "Ключевое слово не может быть пустым",
"segment.keywordError": "Максимальная длина ключевого слова - 20", "segment.keywordError": "Максимальная длина ключевого слова - 20",
"segment.keywords": "Ключевые слова", "segment.keywords": "Ключевые слова",
"segment.newChildChunk": "Новый дочерний чанк", "segment.newChildChunk": "Новый дочерний фрагмент",
"segment.newChunk": "Новый чанк", "segment.newChunk": "Новый фрагмент",
"segment.newQaSegment": "Новый сегмент вопрос-ответ", "segment.newQaSegment": "Новый сегмент вопрос-ответ",
"segment.newTextSegment": "Новый текстовый сегмент", "segment.newTextSegment": "Новый текстовый сегмент",
"segment.paragraphs": "Абзацы", "segment.paragraphs": "Абзацы",

View File

@ -1,7 +1,7 @@
{ {
"blocks.agent": "Агент", "blocks.agent": "Агент",
"blocks.answer": "Ответ", "blocks.answer": "Ответ",
"blocks.assigner": "Назначение переменной", "blocks.assigner": "Назначение переменных",
"blocks.code": "Код", "blocks.code": "Код",
"blocks.datasource": "Источник данных", "blocks.datasource": "Источник данных",
"blocks.datasource-empty": "Пустой источник данных", "blocks.datasource-empty": "Пустой источник данных",
@ -17,10 +17,10 @@
"blocks.list-operator": "Оператор списка", "blocks.list-operator": "Оператор списка",
"blocks.llm": "LLM", "blocks.llm": "LLM",
"blocks.loop": "Цикл", "blocks.loop": "Цикл",
"blocks.loop-end": "Выйти из цикла", "blocks.loop-end": "Конец цикла",
"blocks.loop-start": "Начало цикла", "blocks.loop-start": "Начало цикла",
"blocks.originalStartNode": "исходный начальный узел", "blocks.originalStartNode": "исходный начальный узел",
"blocks.parameter-extractor": "Извлечение параметров", "blocks.parameter-extractor": "Экстрактор параметров",
"blocks.question-classifier": "Классификатор вопросов", "blocks.question-classifier": "Классификатор вопросов",
"blocks.start": "Начало", "blocks.start": "Начало",
"blocks.template-transform": "Шаблон", "blocks.template-transform": "Шаблон",
@ -29,7 +29,7 @@
"blocks.trigger-schedule": "Триггер расписания", "blocks.trigger-schedule": "Триггер расписания",
"blocks.trigger-webhook": "Вебхук-триггер", "blocks.trigger-webhook": "Вебхук-триггер",
"blocks.variable-aggregator": "Агрегатор переменных", "blocks.variable-aggregator": "Агрегатор переменных",
"blocks.variable-assigner": "Агрегатор переменных", "blocks.variable-assigner": "Назначение переменных",
"blocksAbout.agent": "Вызов больших языковых моделей для ответа на вопросы или обработки естественного языка", "blocksAbout.agent": "Вызов больших языковых моделей для ответа на вопросы или обработки естественного языка",
"blocksAbout.answer": "Определите содержимое ответа в чате", "blocksAbout.answer": "Определите содержимое ответа в чате",
"blocksAbout.assigner": "Узел назначения переменной используется для назначения значений записываемым переменным (например, переменным разговора).", "blocksAbout.assigner": "Узел назначения переменной используется для назначения значений записываемым переменным (например, переменным разговора).",
@ -485,7 +485,7 @@
"nodes.common.pluginNotInstalled": "Плагин не установлен", "nodes.common.pluginNotInstalled": "Плагин не установлен",
"nodes.common.pluginsNotInstalled": "{{count}} плагинов не установлено", "nodes.common.pluginsNotInstalled": "{{count}} плагинов не установлено",
"nodes.common.retry.maxRetries": "максимальное количество повторных попыток", "nodes.common.retry.maxRetries": "максимальное количество повторных попыток",
"nodes.common.retry.ms": "госпожа", "nodes.common.retry.ms": "мс",
"nodes.common.retry.retries": "{{num}} Повторных попыток", "nodes.common.retry.retries": "{{num}} Повторных попыток",
"nodes.common.retry.retry": "Снова пробовать", "nodes.common.retry.retry": "Снова пробовать",
"nodes.common.retry.retryFailed": "Повторная попытка не удалась", "nodes.common.retry.retryFailed": "Повторная попытка не удалась",