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_USERNAME=postgres/DB_USERNAME=root/' .env
# hoverkraft-tech/compose-action@v2.6.0 only waits for `docker compose up -d`
# to return (container processes started); it does not wait on healthcheck
# status. mysql:8.0's first-time init takes 15-30s, so without an explicit
# wait the migration runs while InnoDB is still initialising and gets
# killed with "Lost connection during query". Poll a real SELECT until it
# succeeds.
- name: Wait for MySQL to accept queries
run: |
set +e
for i in $(seq 1 60); do
if docker run --rm --network host mysql:8.0 \
mysql -h 127.0.0.1 -P 3306 -uroot -pdifyai123456 \
-e 'SELECT 1' >/dev/null 2>&1; then
echo "MySQL ready after ${i}s"
exit 0
fi
sleep 1
done
echo "MySQL not ready after 60s; dumping container logs:"
docker compose -f docker/docker-compose.middleware.yaml --profile mysql logs --tail=200 db_mysql
exit 1
- name: Run DB Migration
env:
DEBUG: true

View File

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

View File

@ -38,6 +38,48 @@ class HitTestingPayload(BaseModel):
class DatasetsHitTestingBase:
@staticmethod
def _normalize_hit_testing_query(query: Any) -> str:
"""Return the user-visible query string from legacy and current response shapes."""
if isinstance(query, str):
return query
if isinstance(query, dict):
content = query.get("content")
if isinstance(content, str):
return content
raise ValueError("Invalid hit testing query response")
@staticmethod
def _normalize_hit_testing_records(records: Any) -> list[dict[str, Any]]:
"""Coerce nullable collection fields into lists before response validation."""
if not isinstance(records, list):
return []
normalized_records: list[dict[str, Any]] = []
for record in records:
if not isinstance(record, dict):
continue
normalized_record = dict(record)
segment = normalized_record.get("segment")
if isinstance(segment, dict):
normalized_segment = dict(segment)
if normalized_segment.get("keywords") is None:
normalized_segment["keywords"] = []
normalized_record["segment"] = normalized_segment
if normalized_record.get("child_chunks") is None:
normalized_record["child_chunks"] = []
if normalized_record.get("files") is None:
normalized_record["files"] = []
normalized_records.append(normalized_record)
return normalized_records
@staticmethod
def get_and_validate_dataset(dataset_id: str):
assert isinstance(current_user, Account)
@ -75,7 +117,12 @@ class DatasetsHitTestingBase:
attachment_ids=args.get("attachment_ids"),
limit=10,
)
return {"query": response["query"], "records": marshal(response["records"], hit_testing_record_fields)}
return {
"query": DatasetsHitTestingBase._normalize_hit_testing_query(response.get("query")),
"records": DatasetsHitTestingBase._normalize_hit_testing_records(
marshal(response.get("records", []), hit_testing_record_fields)
),
}
except services.errors.index.IndexNotInitializedError:
raise DatasetNotInitializedError()
except ProviderTokenNotInitError as ex:

View File

@ -468,15 +468,98 @@ class DocumentAddByFileApi(DatasetApiResource):
return documents_and_batch_fields, 200
def _update_document_by_file(tenant_id: str, dataset_id: UUID, document_id: UUID) -> tuple[Mapping[str, object], int]:
"""Update a document from an uploaded file for canonical and deprecated routes."""
dataset_id_str = str(dataset_id)
tenant_id_str = str(tenant_id)
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id_str, Dataset.id == dataset_id_str).limit(1)
)
if not dataset:
raise ValueError("Dataset does not exist.")
if dataset.provider == "external":
raise ValueError("External datasets are not supported.")
args: dict[str, object] = {}
if "data" in request.form:
args = json.loads(request.form["data"])
if "doc_form" not in args:
args["doc_form"] = dataset.chunk_structure or "text_model"
if "doc_language" not in args:
args["doc_language"] = "English"
# indexing_technique is already set in dataset since this is an update
args["indexing_technique"] = dataset.indexing_technique
if "file" in request.files:
# save file info
file = request.files["file"]
if len(request.files) > 1:
raise TooManyFilesError()
if not file.filename:
raise FilenameNotExistsError
if not current_user:
raise ValueError("current_user is required")
try:
upload_file = FileService(db.engine).upload_file(
filename=file.filename,
content=file.read(),
mimetype=file.mimetype,
user=current_user,
source="datasets",
)
except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description)
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
data_source = {
"type": "upload_file",
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
}
args["data_source"] = data_source
# validate args
args["original_document_id"] = str(document_id)
knowledge_config = KnowledgeConfig.model_validate(args)
DocumentService.document_create_args_validate(knowledge_config)
try:
documents, _ = DocumentService.save_document_with_dataset_id(
dataset=dataset,
knowledge_config=knowledge_config,
account=dataset.created_by_account,
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
created_from="api",
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
document = documents[0]
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": document.batch}
return documents_and_batch_fields, 200
@service_api_ns.route(
"/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update_by_file",
"/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update-by-file",
)
class DocumentUpdateByFileApi(DatasetApiResource):
"""Resource for update documents."""
class DeprecatedDocumentUpdateByFileApi(DatasetApiResource):
"""Deprecated resource aliases for file document updates."""
@service_api_ns.doc("update_document_by_file")
@service_api_ns.doc(description="Update an existing document by uploading a file")
@service_api_ns.doc("update_document_by_file_deprecated")
@service_api_ns.doc(deprecated=True)
@service_api_ns.doc(
description=(
"Deprecated legacy alias for updating an existing document by uploading a file. "
"Use PATCH /datasets/{dataset_id}/documents/{document_id} instead."
)
)
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
@service_api_ns.doc(
responses={
@ -487,82 +570,9 @@ class DocumentUpdateByFileApi(DatasetApiResource):
)
@cloud_edition_billing_resource_check("vector_space", "dataset")
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id, document_id):
"""Update document by upload file."""
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
)
if not dataset:
raise ValueError("Dataset does not exist.")
if dataset.provider == "external":
raise ValueError("External datasets are not supported.")
args = {}
if "data" in request.form:
args = json.loads(request.form["data"])
if "doc_form" not in args:
args["doc_form"] = dataset.chunk_structure or "text_model"
if "doc_language" not in args:
args["doc_language"] = "English"
# get dataset info
dataset_id = str(dataset_id)
tenant_id = str(tenant_id)
# indexing_technique is already set in dataset since this is an update
args["indexing_technique"] = dataset.indexing_technique
if "file" in request.files:
# save file info
file = request.files["file"]
if len(request.files) > 1:
raise TooManyFilesError()
if not file.filename:
raise FilenameNotExistsError
if not current_user:
raise ValueError("current_user is required")
try:
upload_file = FileService(db.engine).upload_file(
filename=file.filename,
content=file.read(),
mimetype=file.mimetype,
user=current_user,
source="datasets",
)
except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description)
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
data_source = {
"type": "upload_file",
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
}
args["data_source"] = data_source
# validate args
args["original_document_id"] = str(document_id)
knowledge_config = KnowledgeConfig.model_validate(args)
DocumentService.document_create_args_validate(knowledge_config)
try:
documents, _ = DocumentService.save_document_with_dataset_id(
dataset=dataset,
knowledge_config=knowledge_config,
account=dataset.created_by_account,
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
created_from="api",
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
document = documents[0]
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": document.batch}
return documents_and_batch_fields, 200
def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
"""Update document by file through the deprecated file-update aliases."""
return _update_document_by_file(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
@service_api_ns.route("/datasets/<uuid:dataset_id>/documents")
@ -876,6 +886,22 @@ class DocumentApi(DatasetApiResource):
return response
@service_api_ns.doc("update_document_by_file")
@service_api_ns.doc(description="Update an existing document by uploading a file")
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
@service_api_ns.doc(
responses={
200: "Document updated successfully",
401: "Unauthorized - invalid API token",
404: "Document not found",
}
)
@cloud_edition_billing_resource_check("vector_space", "dataset")
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def patch(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
"""Update document by file on the canonical document resource."""
return _update_document_by_file(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
@service_api_ns.doc("delete_document")
@service_api_ns.doc(description="Delete a document")
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})

View File

@ -134,6 +134,42 @@ class TestPerformHitTesting:
assert result["query"] == "hello"
assert result["records"] == []
def test_success_normalizes_legacy_query_and_nullable_list_fields(self, dataset):
response = {
"query": {"content": "hello"},
"records": [
{
"segment": {"id": "segment-1", "keywords": None},
"child_chunks": None,
"files": None,
"score": 0.8,
}
],
}
with (
patch.object(
HitTestingService,
"retrieve",
return_value=response,
),
patch(
"controllers.console.datasets.hit_testing_base.marshal",
return_value=response["records"],
),
):
result = DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"})
assert result["query"] == "hello"
assert result["records"] == [
{
"segment": {"id": "segment-1", "keywords": []},
"child_chunks": [],
"files": [],
"score": 0.8,
}
]
def test_index_not_initialized(self, dataset):
with patch.object(
HitTestingService,

View File

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

View File

@ -171,6 +171,57 @@ class TestHitTestingApiPost:
assert passed_retrieval_model["search_method"] == "semantic_search"
assert passed_retrieval_model["top_k"] == 10
@patch("controllers.service_api.dataset.hit_testing.service_api_ns")
@patch("controllers.console.datasets.hit_testing_base.marshal")
@patch("controllers.console.datasets.hit_testing_base.HitTestingService")
@patch("controllers.console.datasets.hit_testing_base.DatasetService")
@patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account))
def test_post_normalizes_legacy_query_and_nullable_list_fields(
self,
mock_current_user,
mock_dataset_svc,
mock_hit_svc,
mock_marshal,
mock_ns,
app,
):
"""Test service API normalizes legacy query shape and nullable list fields."""
dataset_id = str(uuid.uuid4())
tenant_id = str(uuid.uuid4())
mock_dataset = Mock()
mock_dataset.id = dataset_id
mock_dataset_svc.get_dataset.return_value = mock_dataset
mock_dataset_svc.check_dataset_permission.return_value = None
mock_hit_svc.retrieve.return_value = {"query": {"content": "legacy query"}, "records": ["placeholder"]}
mock_hit_svc.hit_testing_args_check.return_value = None
mock_marshal.return_value = [
{
"segment": {"id": "segment-1", "keywords": None},
"child_chunks": None,
"files": None,
"score": 0.9,
}
]
mock_ns.payload = {"query": "legacy query"}
with app.test_request_context():
api = HitTestingApi()
response = HitTestingApi.post.__wrapped__(api, tenant_id, dataset_id)
assert response["query"] == "legacy query"
assert response["records"] == [
{
"segment": {"id": "segment-1", "keywords": []},
"child_chunks": [],
"files": [],
"score": 0.9,
}
]
@patch("controllers.service_api.dataset.hit_testing.service_api_ns")
@patch("controllers.console.datasets.hit_testing_base.DatasetService")
@patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account))

View File

@ -1,14 +1,12 @@
"""Primarily used for testing merged cell scenarios"""
import gc
import io
import os
import tempfile
import warnings
from collections import UserDict
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import MagicMock
import pytest
from docx import Document
@ -377,23 +375,21 @@ def test_close_is_idempotent():
extractor.temp_file.close.assert_called_once()
def test_close_handles_async_close_mock():
async def _async_close() -> None:
return None
def test_close_closes_awaitable_close_result():
extractor = object.__new__(WordExtractor)
extractor._closed = False
extractor.temp_file = MagicMock()
extractor.temp_file.close = AsyncMock()
close_result = _async_close()
extractor.temp_file.close = MagicMock(return_value=close_result)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
extractor.close()
gc.collect()
extractor.close()
assert close_result.cr_frame is None
extractor.temp_file.close.assert_called_once()
assert not [
warning
for warning in caught
if issubclass(warning.category, RuntimeWarning) and "AsyncMockMixin._execute_mock_call" in str(warning.message)
]
def test_extract_images_handles_invalid_external_cases(monkeypatch):

View File

@ -59,19 +59,25 @@ services:
- ${MYSQL_HOST_VOLUME:-./volumes/mysql/data}:/var/lib/mysql
ports:
- "${EXPOSE_MYSQL_PORT:-3306}:3306"
# mysqladmin ping passes during mysql:8.0's TCP-listening stage even while
# the server is still finalising init, leading to "Lost connection during
# query" on the first real query. Verify with a real SELECT instead.
healthcheck:
test:
[
"CMD",
"mysqladmin",
"ping",
"-u",
"root",
"mysql",
"-h",
"127.0.0.1",
"-uroot",
"-p${DB_PASSWORD:-difyai123456}",
"-e",
"SELECT 1",
]
interval: 1s
timeout: 3s
retries: 30
start_period: 20s
# The redis cache.
redis:

View File

@ -17,3 +17,10 @@ Feature: Share app publicly
Given a workflow app has been published and shared via API
When I open the shared app URL
Then the shared app page should be accessible
@unauthenticated
Scenario: Run a shared workflow app without authentication
Given a workflow app has been published and shared via API
When I open the shared app URL
And I run the shared workflow app
Then the shared workflow run should succeed

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

View File

@ -3506,11 +3506,6 @@
"count": 1
}
},
"web/app/components/plugins/reference-setting-modal/auto-update-setting/strategy-picker.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": {
"erasable-syntax-only/enums": {
"count": 2

View File

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

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 * as React from 'react'
import { useEffect, useRef } from 'react'
@ -7,7 +7,8 @@ type OptionListItemProps = {
isSelected: boolean
onClick: () => void
noAutoScroll?: boolean
} & React.LiHTMLAttributes<HTMLLIElement>
children: ReactNode
}
const OptionListItem: FC<OptionListItemProps> = ({
isSelected,
@ -25,16 +26,21 @@ const OptionListItem: FC<OptionListItemProps> = ({
return (
<li
ref={listItemRef}
className={cn(
'flex cursor-pointer items-center justify-center rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text',
isSelected ? 'bg-components-button-ghost-bg-hover' : 'hover:bg-components-button-ghost-bg-hover',
)}
onClick={() => {
listItemRef.current?.scrollIntoView({ behavior: 'smooth' })
onClick()
}}
>
{children}
<button
type="button"
className={cn(
'flex w-full cursor-pointer items-center justify-center rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text outline-hidden',
'focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:ring-inset',
isSelected ? 'bg-components-button-ghost-bg-hover' : 'hover:bg-components-button-ghost-bg-hover',
)}
onClick={() => {
listItemRef.current?.scrollIntoView({ behavior: 'smooth' })
onClick()
}}
>
{children}
</button>
</li>
)
}

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', () => {
const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') })
render(<Options {...props} />)
const selectedHour = screen.getAllByRole('listitem').find(item => item.textContent === '05')
const selectedHour = screen.getAllByRole('button').find(item => item.textContent === '05')
expect(selectedHour)!.toHaveClass('bg-components-button-ghost-bg-hover')
})
it('should render selected minute in the list', () => {
const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') })
render(<Options {...props} />)
const selectedMinute = screen.getAllByRole('listitem').find(item => item.textContent === '30')
const selectedMinute = screen.getAllByRole('button').find(item => item.textContent === '30')
expect(selectedMinute)!.toHaveClass('bg-components-button-ghost-bg-hover')
})

View File

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

View File

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

View File

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

View File

@ -55,7 +55,14 @@ vi.mock('../../hooks', async () => {
})
vi.mock('../popup-item', () => ({
default: ({ model }: { model: Model }) => <div>{model.provider}</div>,
default: ({ model }: { model: Model }) => (
<div>
<span>{model.provider}</span>
{model.models.map(modelItem => (
<span key={modelItem.model}>{modelItem.model}</span>
))}
</div>
),
}))
vi.mock('@/context/provider-context', () => ({
@ -207,6 +214,156 @@ describe('Popup', () => {
expect((input as HTMLInputElement).value).toBe('')
})
it('should show matching models when searching by model name', () => {
renderPopup(
<Popup
modelList={[
makeModel({
models: [makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } })],
}),
makeModel({
provider: 'anthropic',
label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' },
models: [makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } })],
}),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
fireEvent.change(
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
{ target: { value: 'claude' } },
)
expect(screen.queryByText('openai')).not.toBeInTheDocument()
expect(screen.getByText('anthropic')).toBeInTheDocument()
expect(screen.getByText('claude-3')).toBeInTheDocument()
expect(screen.queryByText('gpt-4')).not.toBeInTheDocument()
expect(screen.queryByText('No model found for \u201Cclaude\u201D')).not.toBeInTheDocument()
})
it('should show empty search placeholder when no provider or model name matches', () => {
renderPopup(
<Popup
modelList={[
makeModel({
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [
makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } }),
],
}),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
fireEvent.change(
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
{ target: { value: 'mistral' } },
)
expect(screen.getByText('No model found for \u201Cmistral\u201D'))!.toBeInTheDocument()
expect(screen.queryByText('openai')).not.toBeInTheDocument()
expect(screen.queryByText('gpt-4')).not.toBeInTheDocument()
})
it('should show all models of a provider when searching by provider label', () => {
renderPopup(
<Popup
modelList={[
makeModel({
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [
makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } }),
makeModelItem({ model: 'gpt-4o', label: { en_US: 'GPT-4o', zh_Hans: 'GPT-4o' } }),
],
}),
makeModel({
provider: 'anthropic',
label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' },
models: [
makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } }),
],
}),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
fireEvent.change(
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
{ target: { value: 'openai' } },
)
expect(screen.getByText('openai'))!.toBeInTheDocument()
expect(screen.getByText('gpt-4'))!.toBeInTheDocument()
expect(screen.getByText('gpt-4o'))!.toBeInTheDocument()
expect(screen.queryByText('anthropic')).not.toBeInTheDocument()
expect(screen.queryByText('claude-3')).not.toBeInTheDocument()
})
it('should match by model provider key when model label does not contain the search text', () => {
renderPopup(
<Popup
modelList={[
makeModel({
provider: 'azure_openai',
label: { en_US: 'Azure', zh_Hans: 'Azure' },
models: [
makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } }),
],
}),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
fireEvent.change(
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
{ target: { value: 'openai' } },
)
expect(screen.getByText('azure_openai'))!.toBeInTheDocument()
expect(screen.getByText('gpt-4'))!.toBeInTheDocument()
})
it('should still apply scope features when matching by provider label', () => {
mockSupportFunctionCall.mockReturnValue(false)
renderPopup(
<Popup
modelList={[
makeModel({
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [
makeModelItem({ model: 'gpt-4', features: [ModelFeatureEnum.vision] }),
makeModelItem({ model: 'gpt-4-tool', features: [ModelFeatureEnum.toolCall] }),
],
}),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
scopeFeatures={[ModelFeatureEnum.toolCall]}
/>,
)
fireEvent.change(
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
{ target: { value: 'openai' } },
)
expect(screen.getByText('No model found for \u201Copenai\u201D'))!.toBeInTheDocument()
expect(screen.queryByText('gpt-4')).not.toBeInTheDocument()
expect(screen.queryByText('gpt-4-tool')).not.toBeInTheDocument()
})
it('should not show compatible-only helper text when no scope features are applied', () => {
renderPopup(
<Popup
@ -219,8 +376,8 @@ describe('Popup', () => {
expect(screen.queryByText('common.modelProvider.selector.onlyCompatibleModelsShown')).not.toBeInTheDocument()
})
it('should show compatible-only helper banner when scope features are applied', () => {
const { container } = renderPopup(
it('should show compatible-only helper text when scope features are applied', () => {
renderPopup(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
@ -231,7 +388,26 @@ describe('Popup', () => {
expect(screen.getByTestId('compatible-models-banner'))!.toBeInTheDocument()
expect(screen.getByText('common.modelProvider.selector.onlyCompatibleModelsShown'))!.toBeInTheDocument()
expect(container.querySelector('.i-ri-information-2-fill'))!.toBeInTheDocument()
})
it('should keep search and footer outside the scrollable model list', () => {
renderPopup(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
scopeFeatures={[ModelFeatureEnum.vision]}
/>,
)
const scrollRegion = screen.getByRole('region', { name: 'common.modelProvider.models' })
const searchInput = screen.getByPlaceholderText('datasetSettings.form.searchModel')
const settingsButton = screen.getByRole('button', { name: /common\.modelProvider\.selector\.modelProviderSettings/ })
expect(scrollRegion)!.toBeInTheDocument()
expect(scrollRegion).not.toContainElement(searchInput)
expect(scrollRegion).not.toContainElement(settingsButton)
expect(scrollRegion).toContainElement(screen.getByTestId('compatible-models-banner'))
})
it('should filter by scope features including toolCall and non-toolCall checks', () => {

View File

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

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 (
<div className="mb-1">
<div className="sticky top-12 z-2 flex h-[22px] items-center justify-between bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
{/* Keep the sticky provider header above model rows while the list scrolls. */}
<div className="sticky top-0 z-1 flex h-[22px] items-center justify-between bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
<div
className="flex cursor-pointer items-center"
onClick={() => setCollapsed(prev => !prev)}

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,
} from '../declarations'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { useCallback, useMemo, useState } from 'react'
@ -19,7 +17,6 @@ import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
import { supportFunctionCall } from '@/utils/tool-call'
import { getMarketplaceUrl } from '@/utils/var'
import {
CustomConfigurationStatusEnum,
ModelFeatureEnum,
@ -29,8 +26,17 @@ import { useLanguage, useMarketplaceAllPlugins } from '../hooks'
import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert'
import { useTrialCredits } from '../provider-added-card/use-trial-credits'
import { providerSupportsCredits } from '../supports-credits'
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap, providerIconMap, providerKeyToPluginId } from '../utils'
import { MODEL_PROVIDER_QUOTA_GET_PAID, providerKeyToPluginId } from '../utils'
import MarketplaceSection from './marketplace-section'
import ModelSelectorEmptyState from './popup-empty-state'
import PopupItem from './popup-item'
import {
CompatibleModelsNotice,
ModelProviderSettingsFooter,
ModelSelectorPopupFrame,
ModelSelectorScrollBody,
ModelSelectorSearchHeader,
} from './popup-layout'
type PopupProps = {
defaultModel?: DefaultModel
@ -137,18 +143,26 @@ const Popup: FC<PopupProps> = ({
}, [aiCreditVisibleProviders, installedProviderMap, modelList])
const filteredModelList = useMemo(() => {
const normalizedSearch = searchText.toLowerCase()
const matchesLabel = (label: Record<string, string>) => {
if (label[language] !== undefined)
return label[language].toLowerCase().includes(normalizedSearch)
return Object.values(label).some(value =>
value.toLowerCase().includes(normalizedSearch),
)
}
const filtered = installedModelList.map((model) => {
const matchesProviderSearch = !searchText
|| model.provider.toLowerCase().includes(searchText.toLowerCase())
|| Object.values(model.label).some(label => label.toLowerCase().includes(searchText.toLowerCase()))
const providerMatched = !!searchText && (
matchesLabel(model.label)
|| model.provider.toLowerCase().includes(normalizedSearch)
)
const filteredModels = model.models
.filter((modelItem) => {
if (modelItem.label[language] !== undefined)
return modelItem.label[language].toLowerCase().includes(searchText.toLowerCase())
return Object.values(modelItem.label).some(label =>
label.toLowerCase().includes(searchText.toLowerCase()),
)
if (!searchText || providerMatched)
return true
return matchesLabel(modelItem.label)
})
.filter((modelItem) => {
if (scopeFeatures.length === 0)
@ -159,8 +173,12 @@ const Popup: FC<PopupProps> = ({
return modelItem.features?.includes(feature) ?? false
})
})
if (!matchesProviderSearch || (filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider)))
if (
(searchText && filteredModels.length === 0)
|| (!searchText && filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider))
) {
return null
}
return { ...model, models: filteredModels }
}).filter((model): model is Model => model !== null)
@ -181,166 +199,59 @@ const Popup: FC<PopupProps> = ({
return MODEL_PROVIDER_QUOTA_GET_PAID.filter(key => !installedProviders.has(key))
}, [modelProviders])
const handleOpenSettings = useCallback(() => {
onHide()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}, [onHide, setShowAccountSettingModal])
return (
<div className="no-scrollbar max-h-[480px] overflow-y-auto">
<div className="sticky top-0 z-10 bg-components-panel-bg pt-3 pr-2 pb-1 pl-3">
<div className={`
flex h-8 items-center rounded-lg border pr-[10px] pl-[9px]
${searchText ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-transparent bg-components-input-bg-normal'}
`}
>
<span
className={`
mr-[7px] i-ri-search-line h-[14px] w-[14px] shrink-0
${searchText ? 'text-text-tertiary' : 'text-text-quaternary'}
`}
/>
<input
className="block h-[18px] grow appearance-none bg-transparent text-[13px] text-text-primary outline-hidden"
placeholder={t('form.searchModel', { ns: 'datasetSettings' }) || ''}
value={searchText}
onChange={e => setSearchText(e.target.value)}
/>
{
searchText && (
<span
className="ml-1.5 i-custom-vender-solid-general-x-circle h-[14px] w-[14px] shrink-0 cursor-pointer text-text-quaternary"
onClick={() => setSearchText('')}
/>
)
}
</div>
{scopeFeatures.length > 0 && (
<div
data-testid="compatible-models-banner"
className="mt-2 flex items-center gap-1 rounded-lg bg-background-section-burn px-2.5 py-2"
>
<span className="i-ri-information-2-fill h-4 w-4 shrink-0 text-text-accent" />
<p className="system-xs-medium text-text-secondary">
{t('modelProvider.selector.onlyCompatibleModelsShown', { ns: 'common' })}
</p>
</div>
)}
</div>
<ModelSelectorPopupFrame>
<ModelSelectorSearchHeader
searchText={searchText}
onSearchTextChange={setSearchText}
/>
{showCreditsExhaustedAlert && (
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
)}
<div className="pr-1 pb-1 pl-3">
{
filteredModelList.map(model => (
<PopupItem
key={model.provider}
defaultModel={defaultModel}
model={model}
onSelect={onSelect}
onHide={onHide}
<ModelSelectorScrollBody label={t('modelProvider.models', { ns: 'common' })}>
<div className="pb-1">
{
filteredModelList.map(model => (
<PopupItem
key={model.provider}
defaultModel={defaultModel}
model={model}
onSelect={onSelect}
onHide={onHide}
/>
))
}
{!filteredModelList.length && !installedModelList.length && (
<ModelSelectorEmptyState
onConfigure={handleOpenSettings}
/>
))
}
{!filteredModelList.length && !installedModelList.length && (
<div className="flex flex-col gap-2 rounded-[10px] bg-linear-to-r from-state-base-hover to-background-gradient-mask-transparent p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-[5px]">
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
)}
{!filteredModelList.length && installedModelList.length > 0 && (
<div className="px-3 py-1.5 text-center text-xs leading-[18px] break-all text-text-tertiary">
{`No model found for \u201C${searchText}\u201D`}
</div>
<div className="flex flex-col gap-1">
<p className="system-sm-medium text-text-secondary">
{t('modelProvider.selector.noProviderConfigured', { ns: 'common' })}
</p>
<p className="system-xs-regular text-text-tertiary">
{t('modelProvider.selector.noProviderConfiguredDesc', { ns: 'common' })}
</p>
</div>
<Button
variant="primary"
className="w-[108px]"
onClick={() => {
onHide()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}}
>
{t('modelProvider.selector.configure', { ns: 'common' })}
<span className="i-ri-arrow-right-line h-4 w-4" />
</Button>
</div>
)}
{!filteredModelList.length && installedModelList.length > 0 && (
<div className="px-3 py-1.5 text-center text-xs leading-[18px] break-all text-text-tertiary">
{`No model found for \u201C${searchText}\u201D`}
</div>
)}
{marketplaceProviders.length > 0 && (
<>
<div className="mx-2 my-1 border-t border-divider-subtle" />
<div className="mb-1">
<div className="flex h-[22px] items-center px-3">
<div
className="flex flex-1 cursor-pointer items-center system-sm-medium text-text-primary"
onClick={() => setMarketplaceCollapsed(prev => !prev)}
>
{t('modelProvider.selector.fromMarketplace', { ns: 'common' })}
<span className={cn('i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 text-text-quaternary', marketplaceCollapsed && '-rotate-90')} />
</div>
</div>
{!marketplaceCollapsed && (
<>
{marketplaceProviders.map((key) => {
const Icon = providerIconMap[key]
const isInstalling = installingProvider === key
return (
<div
key={key}
className="group flex cursor-pointer items-center gap-1 rounded-lg py-0.5 pr-0.5 pl-3 hover:bg-state-base-hover"
>
<div className="flex flex-1 items-center gap-2 py-0.5">
<Icon className="h-5 w-5 shrink-0 rounded-md" />
<span className="system-sm-regular text-text-secondary">{modelNameMap[key]}</span>
</div>
<Button
variant="secondary"
size="small"
className={cn(
'shrink-0 backdrop-blur-[5px]',
!isInstalling && 'hidden group-hover:flex',
)}
disabled={isInstalling || isMarketplacePluginsLoading}
onClick={() => handleInstallPlugin(key)}
>
{isInstalling && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin" />}
{isInstalling
? t('installModal.installing', { ns: 'plugin' })
: t('modelProvider.selector.install', { ns: 'common' })}
</Button>
</div>
)
})}
<a
className="flex cursor-pointer items-center gap-0.5 px-3 pt-1.5"
href={getMarketplaceUrl('', { theme })}
target="_blank"
rel="noopener noreferrer"
>
<span className="flex-1 system-xs-regular text-text-accent">
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
</span>
<span className="i-ri-arrow-right-up-line h-3! w-3! text-text-accent" />
</a>
</>
)}
</div>
</>
)}
</div>
<div
className="sticky bottom-0 flex cursor-pointer items-center gap-1 rounded-b-lg border-t border-divider-subtle bg-components-panel-bg px-3 py-2 text-text-tertiary hover:text-text-secondary"
onClick={() => {
onHide()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}}
>
<span className="i-ri-equalizer-2-line h-4 w-4 shrink-0" />
<span className="system-xs-medium">{t('modelProvider.selector.modelProviderSettings', { ns: 'common' })}</span>
</div>
</div>
)}
{scopeFeatures.length > 0 && (
<CompatibleModelsNotice />
)}
<MarketplaceSection
marketplaceProviders={marketplaceProviders}
marketplaceCollapsed={marketplaceCollapsed}
installingProvider={installingProvider}
isMarketplacePluginsLoading={isMarketplacePluginsLoading}
theme={theme}
onMarketplaceCollapsedChange={setMarketplaceCollapsed}
onInstallPlugin={handleInstallPlugin}
/>
</div>
</ModelSelectorScrollBody>
<ModelProviderSettingsFooter onOpenSettings={handleOpenSettings} />
</ModelSelectorPopupFrame>
)
}

View File

@ -1,6 +1,7 @@
import type { FC, ReactNode } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import PluginItem from './plugin-item'
type PluginSectionProps = {
@ -43,7 +44,14 @@ const PluginSection: FC<PluginSectionProps> = ({
)
{headerAction}
</div>
<div className="max-h-[300px] overflow-x-hidden overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<ScrollArea
className="max-h-[300px] overflow-hidden"
label={title}
slotClassNames={{
viewport: 'overscroll-contain',
content: 'min-w-0',
}}
>
{plugins.map(plugin => (
<PluginItem
key={plugin.plugin_unique_identifier}
@ -59,7 +67,7 @@ const PluginSection: FC<PluginSectionProps> = ({
: undefined}
/>
))}
</div>
</ScrollArea>
</>
)
}

View File

@ -1,6 +1,7 @@
import type { FC } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types'
import { Button } from '@langgenius/dify-ui/button'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useTranslation } from 'react-i18next'
import { useGetLanguage } from '@/context/i18n'
import ErrorPluginItem from './error-plugin-item'
@ -86,7 +87,14 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
{t('task.clearAll', { ns: 'plugin' })}
</Button>
</div>
<div className="max-h-[300px] overflow-x-hidden overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<ScrollArea
className="max-h-[300px] overflow-hidden"
label={t('task.installedError', { ns: 'plugin', errorLength: errorPlugins.length })}
slotClassNames={{
viewport: 'overscroll-contain',
content: 'min-w-0',
}}
>
{errorPlugins.map(plugin => (
<ErrorPluginItem
key={plugin.plugin_unique_identifier}
@ -96,7 +104,7 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
onClear={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
/>
))}
</div>
</ScrollArea>
</>
)}
</div>

View File

@ -117,7 +117,7 @@ const PluginTasks = () => {
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="[scrollbar-width:none] overflow-visible border-0 bg-transparent p-0 shadow-none backdrop-blur-none [&::-webkit-scrollbar]:hidden"
popupClassName="overflow-visible border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<PluginTaskList
runningPlugins={runningPlugins}

View File

@ -2,6 +2,7 @@ import type { AutoUpdateConfig } from '../types'
import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
@ -803,165 +804,103 @@ describe('auto-update-setting', () => {
})
describe('StrategyPicker (strategy-picker.tsx)', () => {
const defaultProps = {
value: AUTO_UPDATE_STRATEGY.disabled,
onChange: vi.fn(),
const i18nKeyByStrategy: Record<AUTO_UPDATE_STRATEGY, 'disabled' | 'fixOnly' | 'latest'> = {
[AUTO_UPDATE_STRATEGY.disabled]: 'disabled',
[AUTO_UPDATE_STRATEGY.fixOnly]: 'fixOnly',
[AUTO_UPDATE_STRATEGY.latest]: 'latest',
}
const triggerName = (strategy: AUTO_UPDATE_STRATEGY) =>
new RegExp(`plugin\\.autoUpdate\\.strategy\\.${i18nKeyByStrategy[strategy]}\\.name`, 'i')
const findOption = async (key: 'disabled' | 'fixOnly' | 'latest') => {
const options = await screen.findAllByRole('menuitemradio')
const option = options.find(item =>
item.textContent?.includes(`plugin.autoUpdate.strategy.${key}.name`),
)
if (!option)
throw new Error(`Strategy option "${key}" not found`)
return option
}
describe('Rendering', () => {
it('should render trigger button with current strategy label', () => {
// Act
render(<StrategyPicker {...defaultProps} value={AUTO_UPDATE_STRATEGY.disabled} />)
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
// Assert
expect(screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.disabled\.name/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) })).toBeInTheDocument()
})
it('should not render dropdown content when closed', () => {
// Act
render(<StrategyPicker {...defaultProps} />)
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
// Assert
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('should render all strategy options when open', () => {
// Arrange
mockPortalOpen = true
it('should render all strategy options when open', async () => {
const user = userEvent.setup()
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
// Act
render(<StrategyPicker {...defaultProps} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) }))
// Wait for portal to open
if (mockPortalOpen) {
// Assert all options visible (use getAllByText for strategy name as it appears in both trigger and dropdown)
expect(screen.getAllByText('plugin.autoUpdate.strategy.disabled.name').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('plugin.autoUpdate.strategy.fixOnly.name')).toBeInTheDocument()
expect(screen.getByText('plugin.autoUpdate.strategy.latest.name')).toBeInTheDocument()
}
const options = await screen.findAllByRole('menuitemradio')
expect(options).toHaveLength(3)
expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.disabled.name'))).toBe(true)
expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.fixOnly.name'))).toBe(true)
expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.latest.name'))).toBe(true)
})
})
describe('User Interactions', () => {
it('should toggle dropdown when trigger is clicked', () => {
// Act
render(<StrategyPicker {...defaultProps} />)
// Assert - initially closed
expect(mockPortalOpen).toBe(false)
// Act - click trigger
fireEvent.click(screen.getByTestId('portal-trigger'))
// Assert - portal trigger element should still be in document
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
})
it('should call onChange with fixOnly when Bug Fixes Only option is clicked', () => {
// Arrange - force portal content to be visible for testing option selection
forcePortalContentVisible = true
const onChange = vi.fn()
// Act
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />)
// Find and click the "Bug Fixes Only" option
const fixOnlyOption = screen.getByText('plugin.autoUpdate.strategy.fixOnly.name').closest('div[class*="cursor-pointer"]')
expect(fixOnlyOption).toBeInTheDocument()
fireEvent.click(fixOnlyOption!)
// Assert
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.fixOnly)
})
it('should call onChange with latest when Latest Version option is clicked', () => {
// Arrange - force portal content to be visible for testing option selection
forcePortalContentVisible = true
const onChange = vi.fn()
// Act
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />)
// Find and click the "Latest Version" option
const latestOption = screen.getByText('plugin.autoUpdate.strategy.latest.name').closest('div[class*="cursor-pointer"]')
expect(latestOption).toBeInTheDocument()
fireEvent.click(latestOption!)
// Assert
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.latest)
})
it('should call onChange with disabled when Disabled option is clicked', () => {
// Arrange - force portal content to be visible for testing option selection
forcePortalContentVisible = true
const onChange = vi.fn()
// Act
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={onChange} />)
// Find and click the "Disabled" option - need to find the one in the dropdown, not the button
const disabledOptions = screen.getAllByText('plugin.autoUpdate.strategy.disabled.name')
// The second one should be in the dropdown
const dropdownOption = disabledOptions.find(el => el.closest('div[class*="cursor-pointer"]'))
expect(dropdownOption).toBeInTheDocument()
fireEvent.click(dropdownOption!.closest('div[class*="cursor-pointer"]')!)
// Assert
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.disabled)
})
it('should stop event propagation when option is clicked', () => {
// Arrange - force portal content to be visible
forcePortalContentVisible = true
const onChange = vi.fn()
const parentClickHandler = vi.fn()
// Act
render(
<div onClick={parentClickHandler}>
<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />
</div>,
)
// Click an option
const fixOnlyOption = screen.getByText('plugin.autoUpdate.strategy.fixOnly.name').closest('div[class*="cursor-pointer"]')
fireEvent.click(fixOnlyOption!)
// Assert - onChange is called but parent click handler should not propagate
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.fixOnly)
})
it('should render check icon for currently selected option', () => {
// Arrange - force portal content to be visible
forcePortalContentVisible = true
// Act - render with fixOnly selected
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />)
// Assert - RiCheckLine should be rendered (check icon)
// Find all "Bug Fixes Only" texts and get the one in the dropdown (has cursor-pointer parent)
const allFixOnlyTexts = screen.getAllByText('plugin.autoUpdate.strategy.fixOnly.name')
const dropdownOption = allFixOnlyTexts.find(el => el.closest('div[class*="cursor-pointer"]'))
const optionContainer = dropdownOption?.closest('div[class*="cursor-pointer"]')
expect(optionContainer).toBeInTheDocument()
// The check icon SVG should exist within the option
expect(optionContainer?.querySelector('svg')).toBeInTheDocument()
})
it('should not render check icon for non-selected options', () => {
// Arrange - force portal content to be visible
forcePortalContentVisible = true
// Act - render with disabled selected
it('should open and close the menu when the trigger is clicked', async () => {
const user = userEvent.setup()
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
// Assert - check the Latest Version option should not have check icon
const latestOption = screen.getByText('plugin.autoUpdate.strategy.latest.name').closest('div[class*="cursor-pointer"]')
// The svg should only be in selected option, not in non-selected
const checkIconContainer = latestOption?.querySelector('div.mr-1')
// Non-selected option should have empty check icon container
expect(checkIconContainer?.querySelector('svg')).toBeNull()
const trigger = screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) })
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
await user.click(trigger)
expect(await screen.findByRole('menu')).toBeInTheDocument()
})
it.each<[AUTO_UPDATE_STRATEGY, 'disabled' | 'fixOnly' | 'latest', AUTO_UPDATE_STRATEGY]>([
[AUTO_UPDATE_STRATEGY.disabled, 'fixOnly', AUTO_UPDATE_STRATEGY.fixOnly],
[AUTO_UPDATE_STRATEGY.disabled, 'latest', AUTO_UPDATE_STRATEGY.latest],
[AUTO_UPDATE_STRATEGY.fixOnly, 'disabled', AUTO_UPDATE_STRATEGY.disabled],
])('should call onChange with %s -> %s when option is selected', async (initial, optionKey, expected) => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<StrategyPicker value={initial} onChange={onChange} />)
await user.click(screen.getByRole('button', { name: triggerName(initial) }))
await user.click(await findOption(optionKey))
expect(onChange).toHaveBeenCalledWith(expected)
})
it('should mark only the currently selected option with aria-checked', async () => {
const user = userEvent.setup()
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />)
await user.click(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.fixOnly) }))
const options = await screen.findAllByRole('menuitemradio')
const checked = options.filter(o => o.getAttribute('aria-checked') === 'true')
expect(checked).toHaveLength(1)
expect(checked[0]).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name')
})
it('should render the check indicator inside the selected option only', async () => {
const user = userEvent.setup()
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />)
await user.click(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.fixOnly) }))
const fixOnlyOption = await findOption('fixOnly')
const latestOption = await findOption('latest')
expect(fixOnlyOption.querySelector('.i-ri-check-line')).toBeInTheDocument()
expect(latestOption.querySelector('.i-ri-check-line')).toBeNull()
})
})
})
@ -1280,7 +1219,9 @@ describe('auto-update-setting', () => {
render(<AutoUpdateSetting {...defaultProps} />)
// Assert
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.fixOnly\.name/i }),
).toBeInTheDocument()
})
it('should show time picker when strategy is not disabled', () => {
@ -1407,16 +1348,27 @@ describe('auto-update-setting', () => {
})
describe('User Interactions', () => {
it('should call onChange with updated strategy when strategy changes', () => {
it('should call onChange with updated strategy when strategy changes', async () => {
// Arrange
const user = userEvent.setup()
const onChange = vi.fn()
const payload = createMockAutoUpdateConfig()
const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly })
// Act
render(<AutoUpdateSetting payload={payload} onChange={onChange} />)
// Assert - component renders with strategy picker
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
await user.click(
screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.fixOnly\.name/i }),
)
const latestOption = (await screen.findAllByRole('menuitemradio')).find(item =>
item.textContent?.includes('plugin.autoUpdate.strategy.latest.name'),
)!
await user.click(latestOption)
// Assert
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ strategy_setting: AUTO_UPDATE_STRATEGY.latest }),
)
})
it('should call onChange with updated time when time changes', () => {

View File

@ -1,62 +1,12 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import StrategyPicker from '../strategy-picker'
import { AUTO_UPDATE_STRATEGY } from '../types'
let portalOpen = false
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({
children,
}: {
children: React.ReactNode
}) => <span data-testid="picker-button">{children}</span>,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const _React = await import('react')
return {
PortalToFollowElem: ({
open,
children,
}: {
open: boolean
children: React.ReactNode
}) => {
portalOpen = open
return <div>{children}</div>
},
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick: (event: { stopPropagation: () => void, nativeEvent: { stopImmediatePropagation: () => void } }) => void
}) => (
<button
data-testid="trigger"
onClick={() => onClick({
stopPropagation: vi.fn(),
nativeEvent: { stopImmediatePropagation: vi.fn() },
})}
>
{children}
</button>
),
PortalToFollowElemContent: ({
children,
}: {
children: React.ReactNode
}) => portalOpen ? <div data-testid="portal-content">{children}</div> : null,
}
})
const triggerName = (key: string) => new RegExp(`plugin\\.autoUpdate\\.strategy\\.${key}\\.name`, 'i')
describe('StrategyPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
portalOpen = false
})
it('renders the selected strategy label in the trigger', () => {
render(
<StrategyPicker
@ -65,10 +15,12 @@ describe('StrategyPicker', () => {
/>,
)
expect(screen.getByTestId('trigger')).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name')
expect(screen.getByRole('button', { name: triggerName('fixOnly') })).toBeInTheDocument()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('opens the option list when the trigger is clicked', () => {
it('opens the option list when the trigger is clicked', async () => {
const user = userEvent.setup()
render(
<StrategyPicker
value={AUTO_UPDATE_STRATEGY.disabled}
@ -76,14 +28,33 @@ describe('StrategyPicker', () => {
/>,
)
fireEvent.click(screen.getByTestId('trigger'))
await user.click(screen.getByRole('button', { name: triggerName('disabled') }))
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByTestId('portal-content').querySelectorAll('svg')).toHaveLength(1)
const options = await screen.findAllByRole('menuitemradio')
expect(options).toHaveLength(3)
expect(screen.getByText('plugin.autoUpdate.strategy.latest.description')).toBeInTheDocument()
})
it('calls onChange when a new strategy is selected', () => {
it('marks only the currently selected strategy as checked', async () => {
const user = userEvent.setup()
render(
<StrategyPicker
value={AUTO_UPDATE_STRATEGY.fixOnly}
onChange={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: triggerName('fixOnly') }))
const checkedOptions = (await screen.findAllByRole('menuitemradio'))
.filter(item => item.getAttribute('aria-checked') === 'true')
expect(checkedOptions).toHaveLength(1)
expect(checkedOptions[0]).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name')
})
it('calls onChange and closes the menu when a new strategy is selected', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<StrategyPicker
@ -92,9 +63,12 @@ describe('StrategyPicker', () => {
/>,
)
fireEvent.click(screen.getByTestId('trigger'))
fireEvent.click(screen.getByText('plugin.autoUpdate.strategy.latest.name'))
await user.click(screen.getByRole('button', { name: triggerName('disabled') }))
const latestOption = (await screen.findAllByRole('menuitemradio'))
.find(item => item.textContent?.includes('plugin.autoUpdate.strategy.latest.name'))!
await user.click(latestOption)
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.latest)
expect(await screen.findByRole('button', { name: triggerName('disabled') })).toBeInTheDocument()
})
})

View File

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

View File

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

View File

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

View File

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