Compare commits

...

11 Commits

Author SHA1 Message Date
yyh
6c9304d7ef feat: implement parent message validation for service API
Added a new validation method to check parent message IDs in the MessageBasedAppGenerator class, ensuring proper handling of UUID_NIL and conversation existence. Updated related app generators and added unit tests for comprehensive coverage.
2025-12-25 15:36:18 +08:00
yyh
df09acb74b feat: chat messages api support parent message id 2025-12-25 15:21:44 +08:00
zxhlyh
fb14644a79 fix: workflow past version data sync (#30139) 2025-12-25 11:53:33 +08:00
-LAN-
1ebc17850b fix(api): force download for HTML previews (#30090)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-12-25 11:43:07 +08:00
Shemol
e6e439f54c feat(web): add unit tests for Badge component (#30096)
Signed-off-by: SherlockShemol <shemol@163.com>
2025-12-25 11:25:21 +08:00
-LAN-
d3b7d06be4 ci: generate docker compose in autofix (#30105) 2025-12-25 11:22:54 +08:00
Shemol
29e7e822d7 test: Add comprehensive test suite for Chip component (#30119)
Signed-off-by: SherlockShemol <shemol@163.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
2025-12-25 10:40:04 +08:00
倉嶋 将矢
a26b2d74d4 fix: allow None values in VariableMessage validation (#30082) 2025-12-25 10:20:25 +08:00
倉嶋 将矢
9000fa1a88 fix: handle list content type in Parameter Extraction node (#30070) 2025-12-25 10:19:50 +08:00
yyh
3cbbb06dc4 chore(web): migrate lodash-es to es-toolkit compat (#30126) 2025-12-25 09:44:57 +08:00
Maries
fdaeec7f7d fix: trigger subscription delete not working for non-auto-created credentials (#30122)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Translate i18n Files Based on English / check-and-update (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 20:23:52 +08:00
230 changed files with 1524 additions and 306 deletions

View File

@@ -13,12 +13,28 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check Docker Compose inputs
id: docker-compose-changes
uses: tj-actions/changed-files@v46
with:
files: |
docker/generate_docker_compose
docker/.env.example
docker/docker-compose-template.yaml
docker/docker-compose.yaml
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- uses: astral-sh/setup-uv@v6
- name: Generate Docker Compose
if: steps.docker-compose-changes.outputs.any_changed == 'true'
run: |
cd docker
./generate_docker_compose
- run: |
cd api
uv sync --dev

View File

@@ -108,36 +108,6 @@ jobs:
working-directory: ./web
run: pnpm run type-check:tsgo
docker-compose-template:
name: Docker Compose Template
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Check changed files
id: changed-files
uses: tj-actions/changed-files@v46
with:
files: |
docker/generate_docker_compose
docker/.env.example
docker/docker-compose-template.yaml
docker/docker-compose.yaml
- name: Generate Docker Compose
if: steps.changed-files.outputs.any_changed == 'true'
run: |
cd docker
./generate_docker_compose
- name: Check for changes
if: steps.changed-files.outputs.any_changed == 'true'
run: git diff --exit-code
superlinter:
name: SuperLinter
runs-on: ubuntu-latest

View File

@@ -0,0 +1,57 @@
import os
from email.message import Message
from urllib.parse import quote
from flask import Response
HTML_MIME_TYPES = frozenset({"text/html", "application/xhtml+xml"})
HTML_EXTENSIONS = frozenset({"html", "htm"})
def _normalize_mime_type(mime_type: str | None) -> str:
if not mime_type:
return ""
message = Message()
message["Content-Type"] = mime_type
return message.get_content_type().strip().lower()
def _is_html_extension(extension: str | None) -> bool:
if not extension:
return False
return extension.lstrip(".").lower() in HTML_EXTENSIONS
def is_html_content(mime_type: str | None, filename: str | None, extension: str | None = None) -> bool:
normalized_mime_type = _normalize_mime_type(mime_type)
if normalized_mime_type in HTML_MIME_TYPES:
return True
if _is_html_extension(extension):
return True
if filename:
return _is_html_extension(os.path.splitext(filename)[1])
return False
def enforce_download_for_html(
response: Response,
*,
mime_type: str | None,
filename: str | None,
extension: str | None = None,
) -> bool:
if not is_html_content(mime_type, filename, extension):
return False
if filename:
encoded_filename = quote(filename)
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
else:
response.headers["Content-Disposition"] = "attachment"
response.headers["Content-Type"] = "application/octet-stream"
response.headers["X-Content-Type-Options"] = "nosniff"
return True

View File

@@ -7,6 +7,7 @@ from werkzeug.exceptions import NotFound
import services
from controllers.common.errors import UnsupportedFileTypeError
from controllers.common.file_response import enforce_download_for_html
from controllers.files import files_ns
from extensions.ext_database import db
from services.account_service import TenantService
@@ -138,6 +139,13 @@ class FilePreviewApi(Resource):
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
response.headers["Content-Type"] = "application/octet-stream"
enforce_download_for_html(
response,
mime_type=upload_file.mime_type,
filename=upload_file.name,
extension=upload_file.extension,
)
return response

View File

@@ -6,6 +6,7 @@ from pydantic import BaseModel, Field
from werkzeug.exceptions import Forbidden, NotFound
from controllers.common.errors import UnsupportedFileTypeError
from controllers.common.file_response import enforce_download_for_html
from controllers.files import files_ns
from core.tools.signature import verify_tool_file_signature
from core.tools.tool_file_manager import ToolFileManager
@@ -78,4 +79,11 @@ class ToolFileApi(Resource):
encoded_filename = quote(tool_file.name)
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
enforce_download_for_html(
response,
mime_type=tool_file.mimetype,
filename=tool_file.name,
extension=extension,
)
return response

View File

@@ -4,10 +4,11 @@ from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, ValidationInfo, field_validator
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
import services
from constants import UUID_NIL
from controllers.common.schema import register_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import (
@@ -33,8 +34,11 @@ from libs import helper
from models.model import App, AppMode, EndUser
from services.app_generate_service import AppGenerateService
from services.app_task_service import AppTaskService
from services.conversation_service import ConversationService
from services.errors.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError
from services.errors.message import MessageNotExistsError
from services.message_service import MessageService
logger = logging.getLogger(__name__)
@@ -53,14 +57,18 @@ class ChatRequestPayload(BaseModel):
files: list[dict[str, Any]] | None = None
response_mode: Literal["blocking", "streaming"] | None = None
conversation_id: str | None = Field(default=None, description="Conversation UUID")
parent_message_id: str | None = Field(default=None, description="Parent message UUID")
retriever_from: str = Field(default="dev")
auto_generate_name: bool = Field(default=True, description="Auto generate conversation name")
workflow_id: str | None = Field(default=None, description="Workflow ID for advanced chat")
@field_validator("conversation_id", mode="before")
@field_validator("conversation_id", "parent_message_id", mode="before")
@classmethod
def normalize_conversation_id(cls, value: str | UUID | None) -> str | None:
"""Allow missing or blank conversation IDs; enforce UUID format when provided."""
def normalize_uuid_fields(cls, value: str | UUID | None, info: ValidationInfo) -> str | None:
"""Allow missing or blank UUID fields; enforce UUID format when provided."""
if isinstance(value, UUID):
return str(value)
if isinstance(value, str):
value = value.strip()
@@ -70,7 +78,36 @@ class ChatRequestPayload(BaseModel):
try:
return helper.uuid_value(value)
except ValueError as exc:
raise ValueError("conversation_id must be a valid UUID") from exc
raise ValueError(f"{info.field_name} must be a valid UUID") from exc
def _validate_parent_message_request(
*,
app_model: App,
end_user: EndUser,
conversation_id: str | None,
parent_message_id: str | None,
) -> None:
if not parent_message_id or parent_message_id == UUID_NIL:
return
if not conversation_id:
raise BadRequest("conversation_id is required when parent_message_id is provided.")
try:
conversation = ConversationService.get_conversation(
app_model=app_model, conversation_id=conversation_id, user=end_user
)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
try:
parent_message = MessageService.get_message(app_model=app_model, user=end_user, message_id=parent_message_id)
except MessageNotExistsError:
raise NotFound("Message Not Exists.")
if parent_message.conversation_id != conversation.id:
raise BadRequest("parent_message_id does not belong to the conversation.")
register_schema_models(service_api_ns, CompletionRequestPayload, ChatRequestPayload)
@@ -205,6 +242,13 @@ class ChatApi(Resource):
streaming = payload.response_mode == "streaming"
_validate_parent_message_request(
app_model=app_model,
end_user=end_user,
conversation_id=args.get("conversation_id"),
parent_message_id=args.get("parent_message_id"),
)
try:
response = AppGenerateService.generate(
app_model=app_model, user=end_user, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=streaming

View File

@@ -5,6 +5,7 @@ from flask import Response, request
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.file_response import enforce_download_for_html
from controllers.common.schema import register_schema_model
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import (
@@ -183,6 +184,13 @@ class FilePreviewApi(Resource):
# Override content-type for downloads to force download
response.headers["Content-Type"] = "application/octet-stream"
enforce_download_for_html(
response,
mime_type=upload_file.mime_type,
filename=upload_file.name,
extension=upload_file.extension,
)
# Add caching headers for performance
response.headers["Cache-Control"] = "public, max-age=3600" # Cache for 1 hour

View File

@@ -12,7 +12,6 @@ from sqlalchemy.orm import Session, sessionmaker
import contexts
from configs import dify_config
from constants import UUID_NIL
from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
from core.app.apps.advanced_chat.app_runner import AdvancedChatAppRunner
@@ -127,6 +126,14 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
app_model=app_model, conversation_id=conversation_id, user=user
)
self._validate_parent_message_for_service_api(
app_model=app_model,
user=user,
conversation=conversation,
parent_message_id=args.get("parent_message_id"),
invoke_from=invoke_from,
)
# parse files
# TODO(QuantumGhost): Move file parsing logic to the API controller layer
# for better separation of concerns.
@@ -168,7 +175,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
),
query=query,
files=list(file_objs),
parent_message_id=args.get("parent_message_id") if invoke_from != InvokeFrom.SERVICE_API else UUID_NIL,
parent_message_id=self._resolve_parent_message_id(args, invoke_from),
user_id=user.id,
stream=streaming,
invoke_from=invoke_from,

View File

@@ -9,7 +9,6 @@ from flask import Flask, current_app
from pydantic import ValidationError
from configs import dify_config
from constants import UUID_NIL
from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter
from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
from core.app.apps.agent_chat.app_config_manager import AgentChatAppConfigManager
@@ -105,6 +104,14 @@ class AgentChatAppGenerator(MessageBasedAppGenerator):
conversation = ConversationService.get_conversation(
app_model=app_model, conversation_id=conversation_id, user=user
)
self._validate_parent_message_for_service_api(
app_model=app_model,
user=user,
conversation=conversation,
parent_message_id=args.get("parent_message_id"),
invoke_from=invoke_from,
)
# get app model config
app_model_config = self._get_app_model_config(app_model=app_model, conversation=conversation)
@@ -163,7 +170,7 @@ class AgentChatAppGenerator(MessageBasedAppGenerator):
),
query=query,
files=list(file_objs),
parent_message_id=args.get("parent_message_id") if invoke_from != InvokeFrom.SERVICE_API else UUID_NIL,
parent_message_id=self._resolve_parent_message_id(args, invoke_from),
user_id=user.id,
stream=streaming,
invoke_from=invoke_from,

View File

@@ -8,7 +8,6 @@ from flask import Flask, copy_current_request_context, current_app
from pydantic import ValidationError
from configs import dify_config
from constants import UUID_NIL
from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter
from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
@@ -97,6 +96,14 @@ class ChatAppGenerator(MessageBasedAppGenerator):
conversation = ConversationService.get_conversation(
app_model=app_model, conversation_id=conversation_id, user=user
)
self._validate_parent_message_for_service_api(
app_model=app_model,
user=user,
conversation=conversation,
parent_message_id=args.get("parent_message_id"),
invoke_from=invoke_from,
)
# get app model config
app_model_config = self._get_app_model_config(app_model=app_model, conversation=conversation)
@@ -156,7 +163,7 @@ class ChatAppGenerator(MessageBasedAppGenerator):
),
query=query,
files=list(file_objs),
parent_message_id=args.get("parent_message_id") if invoke_from != InvokeFrom.SERVICE_API else UUID_NIL,
parent_message_id=self._resolve_parent_message_id(args, invoke_from),
user_id=user.id,
invoke_from=invoke_from,
extras=extras,

View File

@@ -1,11 +1,12 @@
import json
import logging
from collections.abc import Generator
from typing import Union, cast
from collections.abc import Generator, Mapping
from typing import Any, Union, cast
from sqlalchemy import select
from sqlalchemy.orm import Session
from constants import UUID_NIL
from core.app.app_config.entities import EasyUIBasedAppConfig, EasyUIBasedAppModelConfigFrom
from core.app.apps.base_app_generator import BaseAppGenerator
from core.app.apps.base_app_queue_manager import AppQueueManager
@@ -34,6 +35,7 @@ from models.model import App, AppMode, AppModelConfig, Conversation, EndUser, Me
from services.errors.app_model_config import AppModelConfigBrokenError
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import MessageNotExistsError
from services.message_service import MessageService
logger = logging.getLogger(__name__)
@@ -84,6 +86,34 @@ class MessageBasedAppGenerator(BaseAppGenerator):
logger.exception("Failed to handle response, conversation_id: %s", conversation.id)
raise e
def _resolve_parent_message_id(self, args: Mapping[str, Any], invoke_from: InvokeFrom) -> str | None:
parent_message_id = args.get("parent_message_id")
if invoke_from == InvokeFrom.SERVICE_API and not parent_message_id:
return UUID_NIL
return parent_message_id
def _validate_parent_message_for_service_api(
self,
*,
app_model: App,
user: Union[Account, EndUser],
conversation: Conversation | None,
parent_message_id: str | None,
invoke_from: InvokeFrom,
) -> None:
if invoke_from != InvokeFrom.SERVICE_API:
return
if not parent_message_id or parent_message_id == UUID_NIL:
return
if not conversation:
raise ConversationNotExistsError("Conversation not exists")
parent_message = MessageService.get_message(app_model=app_model, user=user, message_id=parent_message_id)
if parent_message.conversation_id != conversation.id:
raise MessageNotExistsError("Message not exists")
def _get_app_model_config(self, app_model: App, conversation: Conversation | None = None) -> AppModelConfig:
if conversation:
stmt = select(AppModelConfig).where(

View File

@@ -2,9 +2,8 @@ from collections.abc import Mapping, Sequence
from enum import StrEnum
from typing import TYPE_CHECKING, Any, Optional
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
from pydantic import BaseModel, ConfigDict, Field
from constants import UUID_NIL
from core.app.app_config.entities import EasyUIBasedAppConfig, WorkflowUIBasedAppConfig
from core.entities.provider_configuration import ProviderModelBundle
from core.file import File, FileUploadConfig
@@ -158,20 +157,12 @@ class ConversationAppGenerateEntity(AppGenerateEntity):
parent_message_id: str | None = Field(
default=None,
description=(
"Starting from v0.9.0, parent_message_id is used to support message regeneration for internal chat API."
"For service API, we need to ensure its forward compatibility, "
"so passing in the parent_message_id as request arg is not supported for now. "
"It needs to be set to UUID_NIL so that the subsequent processing will treat it as legacy messages."
"Starting from v0.9.0, parent_message_id is used to support message regeneration "
"and branching in chat APIs."
"For service API, when it is omitted, the system treats it as UUID_NIL to preserve legacy linear history."
),
)
@field_validator("parent_message_id")
@classmethod
def validate_parent_message_id(cls, v, info: ValidationInfo):
if info.data.get("invoke_from") == InvokeFrom.SERVICE_API and v != UUID_NIL:
raise ValueError("parent_message_id should be UUID_NIL for service API")
return v
class ChatAppGenerateEntity(ConversationAppGenerateEntity, EasyUIBasedAppGenerateEntity):
"""

View File

@@ -153,11 +153,11 @@ class ToolInvokeMessage(BaseModel):
@classmethod
def transform_variable_value(cls, values):
"""
Only basic types and lists are allowed.
Only basic types, lists, and None are allowed.
"""
value = values.get("variable_value")
if not isinstance(value, dict | list | str | int | float | bool):
raise ValueError("Only basic types and lists are allowed.")
if value is not None and not isinstance(value, dict | list | str | int | float | bool):
raise ValueError("Only basic types, lists, and None are allowed.")
# if stream is true, the value must be a string
if values.get("stream"):

View File

@@ -281,7 +281,7 @@ class ParameterExtractorNode(Node[ParameterExtractorNodeData]):
# handle invoke result
text = invoke_result.message.content or ""
text = invoke_result.message.get_text_content()
if not isinstance(text, str):
raise InvalidTextContentTypeError(f"Invalid text content type: {type(text)}. Expected str.")

View File

@@ -359,10 +359,6 @@ class TriggerProviderService:
raise ValueError(f"Trigger provider subscription {subscription_id} not found")
credential_type: CredentialType = CredentialType.of(subscription.credential_type)
is_auto_created: bool = credential_type in [CredentialType.OAUTH2, CredentialType.API_KEY]
if not is_auto_created:
return None
provider_id = TriggerProviderID(subscription.provider_id)
provider_controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider(
tenant_id=tenant_id, provider_id=provider_id
@@ -372,17 +368,20 @@ class TriggerProviderService:
controller=provider_controller,
subscription=subscription,
)
try:
TriggerManager.unsubscribe_trigger(
tenant_id=tenant_id,
user_id=subscription.user_id,
provider_id=provider_id,
subscription=subscription.to_entity(),
credentials=encrypter.decrypt(subscription.credentials),
credential_type=credential_type,
)
except Exception as e:
logger.exception("Error unsubscribing trigger", exc_info=e)
is_auto_created: bool = credential_type in [CredentialType.OAUTH2, CredentialType.API_KEY]
if is_auto_created:
try:
TriggerManager.unsubscribe_trigger(
tenant_id=tenant_id,
user_id=subscription.user_id,
provider_id=provider_id,
subscription=subscription.to_entity(),
credentials=encrypter.decrypt(subscription.credentials),
credential_type=credential_type,
)
except Exception as e:
logger.exception("Error unsubscribing trigger", exc_info=e)
session.delete(subscription)
# Clear cache

View File

@@ -0,0 +1,46 @@
from flask import Response
from controllers.common.file_response import enforce_download_for_html, is_html_content
class TestFileResponseHelpers:
def test_is_html_content_detects_mime_type(self):
mime_type = "text/html; charset=UTF-8"
result = is_html_content(mime_type, filename="file.txt", extension="txt")
assert result is True
def test_is_html_content_detects_extension(self):
result = is_html_content("text/plain", filename="report.html", extension=None)
assert result is True
def test_enforce_download_for_html_sets_headers(self):
response = Response("payload", mimetype="text/html")
updated = enforce_download_for_html(
response,
mime_type="text/html",
filename="unsafe.html",
extension="html",
)
assert updated is True
assert "attachment" in response.headers["Content-Disposition"]
assert response.headers["Content-Type"] == "application/octet-stream"
assert response.headers["X-Content-Type-Options"] == "nosniff"
def test_enforce_download_for_html_no_change_for_non_html(self):
response = Response("payload", mimetype="text/plain")
updated = enforce_download_for_html(
response,
mime_type="text/plain",
filename="notes.txt",
extension="txt",
)
assert updated is False
assert "Content-Disposition" not in response.headers
assert "X-Content-Type-Options" not in response.headers

View File

@@ -0,0 +1,130 @@
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from werkzeug.exceptions import BadRequest, NotFound
from constants import UUID_NIL
from controllers.service_api.app.completion import _validate_parent_message_request
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import MessageNotExistsError
def test_validate_parent_message_skips_when_missing():
app_model = object()
end_user = object()
with (
patch("controllers.service_api.app.completion.ConversationService.get_conversation") as get_conversation,
patch("controllers.service_api.app.completion.MessageService.get_message") as get_message,
):
_validate_parent_message_request(
app_model=app_model, end_user=end_user, conversation_id=None, parent_message_id=None
)
get_conversation.assert_not_called()
get_message.assert_not_called()
def test_validate_parent_message_skips_uuid_nil():
app_model = object()
end_user = object()
with (
patch("controllers.service_api.app.completion.ConversationService.get_conversation") as get_conversation,
patch("controllers.service_api.app.completion.MessageService.get_message") as get_message,
):
_validate_parent_message_request(
app_model=app_model,
end_user=end_user,
conversation_id=None,
parent_message_id=UUID_NIL,
)
get_conversation.assert_not_called()
get_message.assert_not_called()
def test_validate_parent_message_requires_conversation_id():
app_model = object()
end_user = object()
with pytest.raises(BadRequest):
_validate_parent_message_request(
app_model=app_model, end_user=end_user, conversation_id=None, parent_message_id="parent-id"
)
def test_validate_parent_message_missing_conversation_raises_not_found():
app_model = object()
end_user = object()
with patch(
"controllers.service_api.app.completion.ConversationService.get_conversation",
side_effect=ConversationNotExistsError(),
):
with pytest.raises(NotFound):
_validate_parent_message_request(
app_model=app_model,
end_user=end_user,
conversation_id="conversation-id",
parent_message_id="parent-id",
)
def test_validate_parent_message_missing_message_raises_not_found():
app_model = object()
end_user = object()
conversation = SimpleNamespace(id="conversation-id")
with (
patch("controllers.service_api.app.completion.ConversationService.get_conversation", return_value=conversation),
patch(
"controllers.service_api.app.completion.MessageService.get_message",
side_effect=MessageNotExistsError(),
),
):
with pytest.raises(NotFound):
_validate_parent_message_request(
app_model=app_model,
end_user=end_user,
conversation_id="conversation-id",
parent_message_id="parent-id",
)
def test_validate_parent_message_mismatch_conversation_raises_bad_request():
app_model = object()
end_user = object()
conversation = SimpleNamespace(id="conversation-id")
message = SimpleNamespace(conversation_id="different-id")
with (
patch("controllers.service_api.app.completion.ConversationService.get_conversation", return_value=conversation),
patch("controllers.service_api.app.completion.MessageService.get_message", return_value=message),
):
with pytest.raises(BadRequest):
_validate_parent_message_request(
app_model=app_model,
end_user=end_user,
conversation_id="conversation-id",
parent_message_id="parent-id",
)
def test_validate_parent_message_matches_conversation():
app_model = object()
end_user = object()
conversation = SimpleNamespace(id="conversation-id")
message = SimpleNamespace(conversation_id="conversation-id")
with (
patch("controllers.service_api.app.completion.ConversationService.get_conversation", return_value=conversation),
patch("controllers.service_api.app.completion.MessageService.get_message", return_value=message),
):
_validate_parent_message_request(
app_model=app_model,
end_user=end_user,
conversation_id="conversation-id",
parent_message_id="parent-id",
)

View File

@@ -23,3 +23,24 @@ def test_chat_request_payload_validates_uuid():
def test_chat_request_payload_rejects_invalid_uuid():
with pytest.raises(ValidationError):
ChatRequestPayload.model_validate({"inputs": {}, "query": "hello", "conversation_id": "invalid"})
def test_chat_request_payload_accepts_blank_parent_message_id():
payload = ChatRequestPayload.model_validate({"inputs": {}, "query": "hello", "parent_message_id": ""})
assert payload.parent_message_id is None
def test_chat_request_payload_validates_parent_message_id_uuid():
parent_message_id = str(uuid.uuid4())
payload = ChatRequestPayload.model_validate(
{"inputs": {}, "query": "hello", "parent_message_id": parent_message_id}
)
assert payload.parent_message_id == parent_message_id
def test_chat_request_payload_rejects_invalid_parent_message_id():
with pytest.raises(ValidationError):
ChatRequestPayload.model_validate({"inputs": {}, "query": "hello", "parent_message_id": "invalid"})

View File

@@ -41,6 +41,7 @@ class TestFilePreviewApi:
upload_file = Mock(spec=UploadFile)
upload_file.id = str(uuid.uuid4())
upload_file.name = "test_file.jpg"
upload_file.extension = "jpg"
upload_file.mime_type = "image/jpeg"
upload_file.size = 1024
upload_file.key = "storage/key/test_file.jpg"
@@ -210,6 +211,19 @@ class TestFilePreviewApi:
assert mock_upload_file.name in response.headers["Content-Disposition"]
assert response.headers["Content-Type"] == "application/octet-stream"
def test_build_file_response_html_forces_attachment(self, file_preview_api, mock_upload_file):
"""Test HTML files are forced to download"""
mock_generator = Mock()
mock_upload_file.mime_type = "text/html"
mock_upload_file.name = "unsafe.html"
mock_upload_file.extension = "html"
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
assert "attachment" in response.headers["Content-Disposition"]
assert response.headers["Content-Type"] == "application/octet-stream"
assert response.headers["X-Content-Type-Options"] == "nosniff"
def test_build_file_response_audio_video(self, file_preview_api, mock_upload_file):
"""Test file response building for audio/video files"""
mock_generator = Mock()

View File

@@ -0,0 +1,90 @@
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from constants import UUID_NIL
from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
from core.app.entities.app_invoke_entities import InvokeFrom
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import MessageNotExistsError
def test_validate_parent_message_service_api_skips_missing():
generator = MessageBasedAppGenerator()
with patch("core.app.apps.message_based_app_generator.MessageService.get_message") as get_message:
generator._validate_parent_message_for_service_api(
app_model=object(),
user=object(),
conversation=None,
parent_message_id=None,
invoke_from=InvokeFrom.SERVICE_API,
)
get_message.assert_not_called()
def test_validate_parent_message_service_api_skips_uuid_nil():
generator = MessageBasedAppGenerator()
with patch("core.app.apps.message_based_app_generator.MessageService.get_message") as get_message:
generator._validate_parent_message_for_service_api(
app_model=object(),
user=object(),
conversation=None,
parent_message_id=UUID_NIL,
invoke_from=InvokeFrom.SERVICE_API,
)
get_message.assert_not_called()
def test_validate_parent_message_service_api_requires_conversation():
generator = MessageBasedAppGenerator()
with pytest.raises(ConversationNotExistsError):
generator._validate_parent_message_for_service_api(
app_model=object(),
user=object(),
conversation=None,
parent_message_id="parent-id",
invoke_from=InvokeFrom.SERVICE_API,
)
def test_validate_parent_message_service_api_mismatch_conversation():
generator = MessageBasedAppGenerator()
conversation = SimpleNamespace(id="conversation-id")
parent_message = SimpleNamespace(conversation_id="different-id")
with patch(
"core.app.apps.message_based_app_generator.MessageService.get_message",
return_value=parent_message,
):
with pytest.raises(MessageNotExistsError):
generator._validate_parent_message_for_service_api(
app_model=object(),
user=object(),
conversation=conversation,
parent_message_id="parent-id",
invoke_from=InvokeFrom.SERVICE_API,
)
def test_validate_parent_message_service_api_matches_conversation():
generator = MessageBasedAppGenerator()
conversation = SimpleNamespace(id="conversation-id")
parent_message = SimpleNamespace(conversation_id="conversation-id")
with patch(
"core.app.apps.message_based_app_generator.MessageService.get_message",
return_value=parent_message,
):
generator._validate_parent_message_for_service_api(
app_model=object(),
user=object(),
conversation=conversation,
parent_message_id="parent-id",
invoke_from=InvokeFrom.SERVICE_API,
)

View File

@@ -1,6 +1,6 @@
import type { Plan, UsagePlanInfo } from '@/app/components/billing/type'
import type { ProviderContextState } from '@/context/provider-context'
import { merge, noop } from 'lodash-es'
import { merge, noop } from 'es-toolkit/compat'
import { defaultPlan } from '@/app/components/billing/config'
// Avoid being mocked in tests

View File

@@ -4,7 +4,7 @@ import type { FC } from 'react'
import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types'
import { RiCalendarLine } from '@remixicon/react'
import dayjs from 'dayjs'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useCallback } from 'react'
import Picker from '@/app/components/base/date-and-time-picker/date-picker'

View File

@@ -1,6 +1,6 @@
'use client'
import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'

View File

@@ -1,4 +1,4 @@
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -1,5 +1,5 @@
'use client'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useState } from 'react'

View File

@@ -1,6 +1,6 @@
import type { ResponseError } from '@/service/fetch'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useState } from 'react'

View File

@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -4,7 +4,7 @@ import {
RiAddLine,
RiEditLine,
} from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'

View File

@@ -4,8 +4,8 @@ import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug'
import type { GenRes } from '@/service/debug'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/compat'
import { produce } from 'immer'
import { noop } from 'lodash-es'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -2,7 +2,7 @@
import type { FC } from 'react'
import type { ExternalDataTool } from '@/models/common'
import copy from 'copy-to-clipboard'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'

View File

@@ -52,7 +52,7 @@ vi.mock('../debug/hooks', () => ({
useFormattingChangedDispatcher: vi.fn(() => vi.fn()),
}))
vi.mock('lodash-es', () => ({
vi.mock('es-toolkit/compat', () => ({
intersectionBy: vi.fn((...arrays) => {
// Mock realistic intersection behavior based on metadata name
const validArrays = arrays.filter(Array.isArray)

View File

@@ -8,8 +8,8 @@ import type {
MetadataFilteringModeEnum,
} from '@/app/components/workflow/nodes/knowledge-retrieval/types'
import type { DataSet } from '@/models/datasets'
import { intersectionBy } from 'es-toolkit/compat'
import { produce } from 'immer'
import { intersectionBy } from 'lodash-es'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import type { ModelConfig } from '@/app/components/workflow/types'
import type {
DataSet,
@@ -8,7 +9,6 @@ import type {
import type {
DatasetConfigs,
} from '@/models/debug'
import { noop } from 'lodash-es'
import { memo, useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
@@ -33,17 +33,20 @@ type Props = {
selectedDatasets?: DataSet[]
isInWorkflow?: boolean
singleRetrievalModelConfig?: ModelConfig
onSingleRetrievalModelChange?: (config: ModelConfig) => void
onSingleRetrievalModelParamsChange?: (config: ModelConfig) => void
onSingleRetrievalModelChange?: ModelParameterModalProps['setModel']
onSingleRetrievalModelParamsChange?: ModelParameterModalProps['onCompletionParamsChange']
}
const noopModelChange: ModelParameterModalProps['setModel'] = () => {}
const noopParamsChange: ModelParameterModalProps['onCompletionParamsChange'] = () => {}
const ConfigContent: FC<Props> = ({
datasetConfigs,
onChange,
isInWorkflow,
singleRetrievalModelConfig: singleRetrievalConfig = {} as ModelConfig,
onSingleRetrievalModelChange = noop,
onSingleRetrievalModelParamsChange = noop,
onSingleRetrievalModelChange = noopModelChange,
onSingleRetrievalModelParamsChange = noopParamsChange,
selectedDatasets = [],
}) => {
const { t } = useTranslation()

View File

@@ -1,4 +1,4 @@
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Slider from '@/app/components/base/slider'

View File

@@ -3,7 +3,7 @@ import type { Member } from '@/models/common'
import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { RiCloseLine } from '@remixicon/react'
import { isEqual } from 'lodash-es'
import { isEqual } from 'es-toolkit/compat'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'

View File

@@ -1,7 +1,7 @@
'use client'
import type { ModelAndParameter } from '../types'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { createContext, useContext } from 'use-context-selector'
export type DebugWithMultipleModelContextType = {

View File

@@ -4,7 +4,7 @@ import type {
OnSend,
TextGenerationConfig,
} from '@/app/components/base/text-generation/types'
import { cloneDeep, noop } from 'lodash-es'
import { cloneDeep, noop } from 'es-toolkit/compat'
import { memo } from 'react'
import TextGeneration from '@/app/components/app/text-generate/item'
import { TransferMethod } from '@/app/components/base/chat/types'

View File

@@ -6,7 +6,7 @@ import type {
ChatConfig,
ChatItem,
} from '@/app/components/base/chat/types'
import cloneDeep from 'lodash-es/cloneDeep'
import { cloneDeep } from 'es-toolkit/compat'
import {
useCallback,
useRef,

View File

@@ -11,9 +11,8 @@ import {
RiSparklingFill,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { cloneDeep, noop } from 'es-toolkit/compat'
import { produce, setAutoFreeze } from 'immer'
import { noop } from 'lodash-es'
import cloneDeep from 'lodash-es/cloneDeep'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -1,7 +1,7 @@
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ChatPromptConfig, CompletionPromptConfig, ConversationHistoriesRole, PromptItem } from '@/models/debug'
import { clone } from 'es-toolkit/compat'
import { produce } from 'immer'
import { clone } from 'lodash-es'
import { useState } from 'react'
import { checkHasContextBlock, checkHasHistoryBlock, checkHasQueryBlock, PRE_PROMPT_PLACEHOLDER_TEXT } from '@/app/components/base/prompt-editor/constants'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'

View File

@@ -20,8 +20,8 @@ import type {
import type { ModelConfig as BackendModelConfig, UserInputFormItem, VisionSettings } from '@/types/app'
import { CodeBracketIcon } from '@heroicons/react/20/solid'
import { useBoolean, useGetState } from 'ahooks'
import { clone, isEqual } from 'es-toolkit/compat'
import { produce } from 'immer'
import { clone, isEqual } from 'lodash-es'
import { usePathname } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'

View File

@@ -3,7 +3,7 @@ import type {
CodeBasedExtensionItem,
ExternalDataTool,
} from '@/models/common'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'

View File

@@ -3,7 +3,7 @@
import type { MouseEventHandler } from 'react'
import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -1,7 +1,7 @@
'use client'
import type { AppIconType } from '@/types/app'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -3,7 +3,7 @@ import type { FC } from 'react'
import type { App } from '@/types/app'
import { useDebounce } from 'ahooks'
import dayjs from 'dayjs'
import { omit } from 'lodash-es'
import { omit } from 'es-toolkit/compat'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'

View File

@@ -12,7 +12,7 @@ import { RiCloseLine, RiEditFill } from '@remixicon/react'
import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { get, noop } from 'lodash-es'
import { get, noop } from 'es-toolkit/compat'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'

View File

@@ -2,7 +2,7 @@ import type { RenderOptions } from '@testing-library/react'
import type { Mock, MockedFunction } from 'vitest'
import type { ModalContextState } from '@/context/modal-context'
import { fireEvent, render } from '@testing-library/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { defaultPlan } from '@/app/components/billing/config'
import { useModalContext as actualUseModalContext } from '@/context/modal-context'

View File

@@ -6,7 +6,7 @@ import type { AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDailyM
import dayjs from 'dayjs'
import Decimal from 'decimal.js'
import ReactECharts from 'echarts-for-react'
import { get } from 'lodash-es'
import { get } from 'es-toolkit/compat'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Basic from '@/app/components/app-sidebar/basic'

View File

@@ -2,7 +2,7 @@
import type { App } from '@/types/app'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -5,7 +5,7 @@ import { useDebounce } from 'ahooks'
import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { omit } from 'lodash-es'
import { omit } from 'es-toolkit/compat'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -2,7 +2,7 @@
import type { FC } from 'react'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { AgentIteration, AgentLogDetailResponse } from '@/models/log'
import { flatten, uniq } from 'lodash-es'
import { flatten, uniq } from 'es-toolkit/compat'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -3,7 +3,7 @@ import type { Area } from 'react-easy-crop'
import type { OnImageInput } from './ImageInput'
import type { AppIconType, ImageFile } from '@/types/app'
import { RiImageCircleAiLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'

View File

@@ -0,0 +1,360 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Badge, { BadgeState, BadgeVariants } from './index'
describe('Badge', () => {
describe('Rendering', () => {
it('should render as a div element with badge class', () => {
render(<Badge>Test Badge</Badge>)
const badge = screen.getByText('Test Badge')
expect(badge).toHaveClass('badge')
expect(badge.tagName).toBe('DIV')
})
it.each([
{ children: undefined, label: 'no children' },
{ children: '', label: 'empty string' },
])('should render correctly when provided $label', ({ children }) => {
const { container } = render(<Badge>{children}</Badge>)
expect(container.firstChild).toHaveClass('badge')
})
it('should render React Node children correctly', () => {
render(
<Badge data-testid="badge-with-icon">
<span data-testid="custom-icon">🔔</span>
</Badge>,
)
expect(screen.getByTestId('badge-with-icon')).toBeInTheDocument()
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
})
describe('size prop', () => {
it.each([
{ size: undefined, label: 'medium (default)' },
{ size: 's', label: 'small' },
{ size: 'm', label: 'medium' },
{ size: 'l', label: 'large' },
] as const)('should render with $label size', ({ size }) => {
render(<Badge size={size}>Test</Badge>)
const expectedSize = size || 'm'
expect(screen.getByText('Test')).toHaveClass('badge', `badge-${expectedSize}`)
})
})
describe('state prop', () => {
it.each([
{ state: BadgeState.Warning, label: 'warning', expectedClass: 'badge-warning' },
{ state: BadgeState.Accent, label: 'accent', expectedClass: 'badge-accent' },
])('should render with $label state', ({ state, expectedClass }) => {
render(<Badge state={state}>State Test</Badge>)
expect(screen.getByText('State Test')).toHaveClass(expectedClass)
})
it.each([
{ state: undefined, label: 'default (undefined)' },
{ state: BadgeState.Default, label: 'default (explicit)' },
])('should use default styles when state is $label', ({ state }) => {
render(<Badge state={state}>State Test</Badge>)
const badge = screen.getByText('State Test')
expect(badge).not.toHaveClass('badge-warning', 'badge-accent')
})
})
describe('iconOnly prop', () => {
it.each([
{ size: 's', iconOnly: false, label: 'small with text' },
{ size: 's', iconOnly: true, label: 'small icon-only' },
{ size: 'm', iconOnly: false, label: 'medium with text' },
{ size: 'm', iconOnly: true, label: 'medium icon-only' },
{ size: 'l', iconOnly: false, label: 'large with text' },
{ size: 'l', iconOnly: true, label: 'large icon-only' },
] as const)('should render correctly for $label', ({ size, iconOnly }) => {
const { container } = render(<Badge size={size} iconOnly={iconOnly}>🔔</Badge>)
const badge = screen.getByText('🔔')
// Verify badge renders with correct size
expect(badge).toHaveClass('badge', `badge-${size}`)
// Verify the badge is in the DOM and contains the content
expect(badge).toBeInTheDocument()
expect(container.firstChild).toBe(badge)
})
it('should apply icon-only padding when iconOnly is true', () => {
render(<Badge iconOnly>🔔</Badge>)
// When iconOnly is true, the badge should have uniform padding (all sides equal)
const badge = screen.getByText('🔔')
expect(badge).toHaveClass('p-1')
})
it('should apply asymmetric padding when iconOnly is false', () => {
render(<Badge iconOnly={false}>Badge</Badge>)
// When iconOnly is false, the badge should have different horizontal and vertical padding
const badge = screen.getByText('Badge')
expect(badge).toHaveClass('px-[5px]', 'py-[2px]')
})
})
describe('uppercase prop', () => {
it.each([
{ uppercase: undefined, label: 'default (undefined)', expected: 'system-2xs-medium' },
{ uppercase: false, label: 'explicitly false', expected: 'system-2xs-medium' },
{ uppercase: true, label: 'true', expected: 'system-2xs-medium-uppercase' },
])('should apply $expected class when uppercase is $label', ({ uppercase, expected }) => {
render(<Badge uppercase={uppercase}>Text</Badge>)
expect(screen.getByText('Text')).toHaveClass(expected)
})
})
describe('styleCss prop', () => {
it('should apply custom inline styles correctly', () => {
const customStyles = {
backgroundColor: 'rgb(0, 0, 255)',
color: 'rgb(255, 255, 255)',
padding: '10px',
}
render(<Badge styleCss={customStyles}>Styled Badge</Badge>)
expect(screen.getByText('Styled Badge')).toHaveStyle(customStyles)
})
it('should apply inline styles without overriding core classes', () => {
render(<Badge styleCss={{ backgroundColor: 'rgb(255, 0, 0)', margin: '5px' }}>Custom</Badge>)
const badge = screen.getByText('Custom')
expect(badge).toHaveStyle({ backgroundColor: 'rgb(255, 0, 0)', margin: '5px' })
expect(badge).toHaveClass('badge')
})
})
describe('className prop', () => {
it.each([
{
props: { className: 'custom-badge' },
expected: ['badge', 'custom-badge'],
label: 'single custom class',
},
{
props: { className: 'custom-class another-class', size: 'l' as const },
expected: ['badge', 'badge-l', 'custom-class', 'another-class'],
label: 'multiple classes with size variant',
},
])('should merge $label with default classes', ({ props, expected }) => {
render(<Badge {...props}>Test</Badge>)
expect(screen.getByText('Test')).toHaveClass(...expected)
})
})
describe('HTML attributes passthrough', () => {
it.each([
{ attr: 'data-testid', value: 'custom-badge-id', label: 'data attribute' },
{ attr: 'id', value: 'unique-badge', label: 'id attribute' },
{ attr: 'aria-label', value: 'Notification badge', label: 'aria-label' },
{ attr: 'title', value: 'Hover tooltip', label: 'title attribute' },
{ attr: 'role', value: 'status', label: 'ARIA role' },
])('should pass through $label correctly', ({ attr, value }) => {
render(<Badge {...{ [attr]: value }}>Test</Badge>)
expect(screen.getByText('Test')).toHaveAttribute(attr, value)
})
it('should support multiple HTML attributes simultaneously', () => {
render(
<Badge
data-testid="multi-attr-badge"
id="badge-123"
aria-label="Status indicator"
title="Current status"
>
Test
</Badge>,
)
const badge = screen.getByTestId('multi-attr-badge')
expect(badge).toHaveAttribute('id', 'badge-123')
expect(badge).toHaveAttribute('aria-label', 'Status indicator')
expect(badge).toHaveAttribute('title', 'Current status')
})
})
describe('Event handlers', () => {
it.each([
{ handler: 'onClick', trigger: fireEvent.click, label: 'click' },
{ handler: 'onMouseEnter', trigger: fireEvent.mouseEnter, label: 'mouse enter' },
{ handler: 'onMouseLeave', trigger: fireEvent.mouseLeave, label: 'mouse leave' },
])('should trigger $handler when $label occurs', ({ handler, trigger }) => {
const mockHandler = vi.fn()
render(<Badge {...{ [handler]: mockHandler }}>Badge</Badge>)
trigger(screen.getByText('Badge'))
expect(mockHandler).toHaveBeenCalledTimes(1)
})
it('should handle user interaction flow with multiple events', () => {
const handlers = {
onClick: vi.fn(),
onMouseEnter: vi.fn(),
onMouseLeave: vi.fn(),
}
render(<Badge {...handlers}>Interactive</Badge>)
const badge = screen.getByText('Interactive')
fireEvent.mouseEnter(badge)
fireEvent.click(badge)
fireEvent.mouseLeave(badge)
expect(handlers.onMouseEnter).toHaveBeenCalledTimes(1)
expect(handlers.onClick).toHaveBeenCalledTimes(1)
expect(handlers.onMouseLeave).toHaveBeenCalledTimes(1)
})
it('should pass event object to handler with correct properties', () => {
const handleClick = vi.fn()
render(<Badge onClick={handleClick}>Event Badge</Badge>)
fireEvent.click(screen.getByText('Event Badge'))
expect(handleClick).toHaveBeenCalledWith(expect.objectContaining({
type: 'click',
}))
})
})
describe('Combined props', () => {
it('should correctly apply all props when used together', () => {
render(
<Badge
size="l"
state={BadgeState.Warning}
uppercase
className="custom-badge"
styleCss={{ backgroundColor: 'rgb(0, 0, 255)' }}
data-testid="combined-badge"
>
Full Featured
</Badge>,
)
const badge = screen.getByTestId('combined-badge')
expect(badge).toHaveClass('badge', 'badge-l', 'badge-warning', 'system-2xs-medium-uppercase', 'custom-badge')
expect(badge).toHaveStyle({ backgroundColor: 'rgb(0, 0, 255)' })
expect(badge).toHaveTextContent('Full Featured')
})
it.each([
{
props: { size: 'l' as const, state: BadgeState.Accent },
expected: ['badge', 'badge-l', 'badge-accent'],
label: 'size and state variants',
},
{
props: { iconOnly: true, uppercase: true },
expected: ['badge', 'system-2xs-medium-uppercase'],
label: 'iconOnly and uppercase',
},
])('should combine $label correctly', ({ props, expected }) => {
render(<Badge {...props}>Test</Badge>)
expect(screen.getByText('Test')).toHaveClass(...expected)
})
it('should handle event handlers with combined props', () => {
const handleClick = vi.fn()
render(
<Badge size="s" state={BadgeState.Warning} onClick={handleClick} className="interactive">
Test
</Badge>,
)
const badge = screen.getByText('Test')
expect(badge).toHaveClass('badge', 'badge-s', 'badge-warning', 'interactive')
fireEvent.click(badge)
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
describe('Edge cases', () => {
it.each([
{ children: 42, text: '42', label: 'numeric value' },
{ children: 0, text: '0', label: 'zero' },
])('should render $label correctly', ({ children, text }) => {
render(<Badge>{children}</Badge>)
expect(screen.getByText(text)).toBeInTheDocument()
})
it.each([
{ children: null, label: 'null' },
{ children: false, label: 'boolean false' },
])('should handle $label children without errors', ({ children }) => {
const { container } = render(<Badge>{children}</Badge>)
expect(container.firstChild).toHaveClass('badge')
})
it('should render complex nested content correctly', () => {
render(
<Badge>
<span data-testid="icon">🔔</span>
<span data-testid="count">5</span>
</Badge>,
)
expect(screen.getByTestId('icon')).toBeInTheDocument()
expect(screen.getByTestId('count')).toBeInTheDocument()
})
})
describe('Component metadata and exports', () => {
it('should have correct displayName for debugging', () => {
expect(Badge.displayName).toBe('Badge')
})
describe('BadgeState enum', () => {
it.each([
{ key: 'Warning', value: 'warning' },
{ key: 'Accent', value: 'accent' },
{ key: 'Default', value: '' },
])('should export $key state with value "$value"', ({ key, value }) => {
expect(BadgeState[key as keyof typeof BadgeState]).toBe(value)
})
})
describe('BadgeVariants utility', () => {
it('should be a function', () => {
expect(typeof BadgeVariants).toBe('function')
})
it('should generate base badge class with default medium size', () => {
const result = BadgeVariants({})
expect(result).toContain('badge')
expect(result).toContain('badge-m')
})
it.each([
{ size: 's' },
{ size: 'm' },
{ size: 'l' },
] as const)('should generate correct classes for size=$size', ({ size }) => {
const result = BadgeVariants({ size })
expect(result).toContain('badge')
expect(result).toContain(`badge-${size}`)
})
})
})
})

View File

@@ -1,5 +1,5 @@
import type { ChatItemInTree } from '../types'
import { get } from 'lodash-es'
import { get } from 'es-toolkit/compat'
import { buildChatItemTree, getThreadMessages } from '../utils'
import branchedTestMessages from './branchedTestMessages.json'
import legacyTestMessages from './legacyTestMessages.json'

View File

@@ -14,7 +14,7 @@ import type {
AppMeta,
ConversationItem,
} from '@/models/share'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { createContext, useContext } from 'use-context-selector'
export type ChatWithHistoryContextValue = {

View File

@@ -10,8 +10,8 @@ import type {
ConversationItem,
} from '@/models/share'
import { useLocalStorageState } from 'ahooks'
import { noop } from 'es-toolkit/compat'
import { produce } from 'immer'
import { noop } from 'lodash-es'
import {
useCallback,
useEffect,

View File

@@ -8,8 +8,8 @@ import type { InputForm } from './type'
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { Annotation } from '@/models/log'
import { noop, uniqBy } from 'es-toolkit/compat'
import { produce, setAutoFreeze } from 'immer'
import { noop, uniqBy } from 'lodash-es'
import { useParams, usePathname } from 'next/navigation'
import {
useCallback,

View File

@@ -13,7 +13,7 @@ import type {
import type { InputForm } from './type'
import type { Emoji } from '@/app/components/tools/types'
import type { AppData } from '@/models/share'
import { debounce } from 'lodash-es'
import { debounce } from 'es-toolkit/compat'
import {
memo,
useCallback,

View File

@@ -13,7 +13,7 @@ import type {
AppMeta,
ConversationItem,
} from '@/models/share'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { createContext, useContext } from 'use-context-selector'
export type EmbeddedChatbotContextValue = {

View File

@@ -9,8 +9,8 @@ import type {
ConversationItem,
} from '@/models/share'
import { useLocalStorageState } from 'ahooks'
import { noop } from 'es-toolkit/compat'
import { produce } from 'immer'
import { noop } from 'lodash-es'
import {
useCallback,
useEffect,

View File

@@ -0,0 +1,394 @@
import type { Item } from './index'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import Chip from './index'
afterEach(cleanup)
// Test data factory
const createTestItems = (): Item[] => [
{ value: 'all', name: 'All Items' },
{ value: 'active', name: 'Active' },
{ value: 'archived', name: 'Archived' },
]
describe('Chip', () => {
// Shared test props
let items: Item[]
let onSelect: (item: Item) => void
let onClear: () => void
beforeEach(() => {
vi.clearAllMocks()
items = createTestItems()
onSelect = vi.fn()
onClear = vi.fn()
})
// Helper function to render Chip with default props
const renderChip = (props: Partial<React.ComponentProps<typeof Chip>> = {}) => {
return render(
<Chip
value="all"
items={items}
onSelect={onSelect}
onClear={onClear}
{...props}
/>,
)
}
// Helper function to get the trigger element
const getTrigger = (container: HTMLElement) => {
return container.querySelector('[data-state]')
}
// Helper function to open dropdown panel
const openPanel = (container: HTMLElement) => {
const trigger = getTrigger(container)
if (trigger)
fireEvent.click(trigger)
}
describe('Rendering', () => {
it('should render without crashing', () => {
renderChip()
expect(screen.getByText('All Items')).toBeInTheDocument()
})
it('should display current selected item name', () => {
renderChip({ value: 'active' })
expect(screen.getByText('Active')).toBeInTheDocument()
})
it('should display empty content when value does not match any item', () => {
const { container } = renderChip({ value: 'nonexistent' })
// When value doesn't match, no text should be displayed in trigger
const trigger = getTrigger(container)
// Check that there's no item name text (only icons should be present)
expect(trigger?.textContent?.trim()).toBeFalsy()
})
})
describe('Props', () => {
it('should update displayed item name when value prop changes', () => {
const { rerender } = renderChip({ value: 'all' })
expect(screen.getByText('All Items')).toBeInTheDocument()
rerender(
<Chip
value="archived"
items={items}
onSelect={onSelect}
onClear={onClear}
/>,
)
expect(screen.getByText('Archived')).toBeInTheDocument()
})
it('should show left icon by default', () => {
const { container } = renderChip()
// The filter icon should be visible
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should hide left icon when showLeftIcon is false', () => {
renderChip({ showLeftIcon: false })
// When showLeftIcon is false, there should be no filter icon before the text
const textElement = screen.getByText('All Items')
const parent = textElement.closest('div[data-state]')
const icons = parent?.querySelectorAll('svg')
// Should only have the arrow icon, not the filter icon
expect(icons?.length).toBe(1)
})
it('should render custom left icon', () => {
const CustomIcon = () => <span data-testid="custom-icon"></span>
renderChip({ leftIcon: <CustomIcon /> })
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
it('should apply custom className to trigger', () => {
const customClass = 'custom-chip-class'
const { container } = renderChip({ className: customClass })
const chipElement = container.querySelector(`.${customClass}`)
expect(chipElement).toBeInTheDocument()
})
it('should apply custom panelClassName to dropdown panel', () => {
const customPanelClass = 'custom-panel-class'
const { container } = renderChip({ panelClassName: customPanelClass })
openPanel(container)
// Panel is rendered in a portal, so check document.body
const panel = document.body.querySelector(`.${customPanelClass}`)
expect(panel).toBeInTheDocument()
})
})
describe('State Management', () => {
it('should toggle dropdown panel on trigger click', () => {
const { container } = renderChip()
// Initially closed - check data-state attribute
const trigger = getTrigger(container)
expect(trigger).toHaveAttribute('data-state', 'closed')
// Open panel
openPanel(container)
expect(trigger).toHaveAttribute('data-state', 'open')
// Panel items should be visible
expect(screen.getAllByText('All Items').length).toBeGreaterThan(1)
// Close panel
if (trigger)
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('data-state', 'closed')
})
it('should close panel after selecting an item', () => {
const { container } = renderChip()
openPanel(container)
const trigger = getTrigger(container)
expect(trigger).toHaveAttribute('data-state', 'open')
// Click on an item in the dropdown panel
const activeItems = screen.getAllByText('Active')
// The second one should be in the dropdown
fireEvent.click(activeItems[activeItems.length - 1])
expect(trigger).toHaveAttribute('data-state', 'closed')
})
})
describe('Event Handlers', () => {
it('should call onSelect with correct item when item is clicked', () => {
const { container } = renderChip()
openPanel(container)
// Get all "Active" texts and click the one in the dropdown (should be the last one)
const activeItems = screen.getAllByText('Active')
fireEvent.click(activeItems[activeItems.length - 1])
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(items[1])
})
it('should call onClear when clear button is clicked', () => {
const { container } = renderChip({ value: 'active' })
// Find the close icon (last SVG in the trigger) and click its parent
const trigger = getTrigger(container)
const svgs = trigger?.querySelectorAll('svg')
// The close icon should be the last SVG element
const closeIcon = svgs?.[svgs.length - 1]
const clearButton = closeIcon?.parentElement
expect(clearButton).toBeInTheDocument()
if (clearButton)
fireEvent.click(clearButton)
expect(onClear).toHaveBeenCalledTimes(1)
})
it('should stop event propagation when clear button is clicked', () => {
const { container } = renderChip({ value: 'active' })
const trigger = getTrigger(container)
expect(trigger).toHaveAttribute('data-state', 'closed')
// Find the close icon (last SVG) and click its parent
const svgs = trigger?.querySelectorAll('svg')
const closeIcon = svgs?.[svgs.length - 1]
const clearButton = closeIcon?.parentElement
if (clearButton)
fireEvent.click(clearButton)
// Panel should remain closed
expect(trigger).toHaveAttribute('data-state', 'closed')
expect(onClear).toHaveBeenCalledTimes(1)
})
it('should handle multiple rapid clicks on trigger', () => {
const { container } = renderChip()
const trigger = getTrigger(container)
// Click 1: open
if (trigger)
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('data-state', 'open')
// Click 2: close
if (trigger)
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('data-state', 'closed')
// Click 3: open again
if (trigger)
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('data-state', 'open')
})
})
describe('Conditional Rendering', () => {
it('should show arrow down icon when no value is selected', () => {
const { container } = renderChip({ value: '' })
// Should have SVG icons (filter icon and arrow down icon)
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
})
it('should show clear button when value is selected', () => {
const { container } = renderChip({ value: 'active' })
// When value is selected, there should be an icon (the close icon)
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
})
it('should not show clear button when no value is selected', () => {
const { container } = renderChip({ value: '' })
const trigger = getTrigger(container)
// When value is empty, the trigger should only have 2 SVGs (filter icon + arrow)
// When value is selected, it would have 2 SVGs (filter icon + close icon)
const svgs = trigger?.querySelectorAll('svg')
// Arrow icon should be present, close icon should not
expect(svgs?.length).toBe(2)
// Verify onClear hasn't been called
expect(onClear).not.toHaveBeenCalled()
})
it('should show dropdown content only when panel is open', () => {
const { container } = renderChip()
const trigger = getTrigger(container)
// Closed by default
expect(trigger).toHaveAttribute('data-state', 'closed')
openPanel(container)
expect(trigger).toHaveAttribute('data-state', 'open')
// Items should be duplicated (once in trigger, once in panel)
expect(screen.getAllByText('All Items').length).toBeGreaterThan(1)
})
it('should show check icon on selected item in dropdown', () => {
const { container } = renderChip({ value: 'active' })
openPanel(container)
// Find the dropdown panel items
const allActiveTexts = screen.getAllByText('Active')
// The dropdown item should be the last one
const dropdownItem = allActiveTexts[allActiveTexts.length - 1]
const parentContainer = dropdownItem.parentElement
// The check icon should be a sibling within the parent
const checkIcon = parentContainer?.querySelector('svg')
expect(checkIcon).toBeInTheDocument()
})
it('should render all items in dropdown when open', () => {
const { container } = renderChip()
openPanel(container)
// Each item should appear at least twice (once in potential selected state, once in dropdown)
// Use getAllByText to handle multiple occurrences
expect(screen.getAllByText('All Items').length).toBeGreaterThan(0)
expect(screen.getAllByText('Active').length).toBeGreaterThan(0)
expect(screen.getAllByText('Archived').length).toBeGreaterThan(0)
})
})
describe('Edge Cases', () => {
it('should handle empty items array', () => {
const { container } = renderChip({ items: [], value: '' })
// Trigger should still render
const trigger = container.querySelector('[data-state]')
expect(trigger).toBeInTheDocument()
})
it('should handle value not in items list', () => {
const { container } = renderChip({ value: 'nonexistent' })
const trigger = getTrigger(container)
expect(trigger).toBeInTheDocument()
// The trigger should not display any item name text
expect(trigger?.textContent?.trim()).toBeFalsy()
})
it('should allow selecting already selected item', () => {
const { container } = renderChip({ value: 'active' })
openPanel(container)
// Click on the already selected item in the dropdown
const activeItems = screen.getAllByText('Active')
fireEvent.click(activeItems[activeItems.length - 1])
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(items[1])
})
it('should handle numeric values', () => {
const numericItems: Item[] = [
{ value: 1, name: 'First' },
{ value: 2, name: 'Second' },
{ value: 3, name: 'Third' },
]
const { container } = renderChip({ value: 2, items: numericItems })
expect(screen.getByText('Second')).toBeInTheDocument()
// Open panel and select Third
openPanel(container)
const thirdItems = screen.getAllByText('Third')
fireEvent.click(thirdItems[thirdItems.length - 1])
expect(onSelect).toHaveBeenCalledWith(numericItems[2])
})
it('should handle items with additional properties', () => {
const itemsWithExtra: Item[] = [
{ value: 'a', name: 'Item A', customProp: 'extra1' },
{ value: 'b', name: 'Item B', customProp: 'extra2' },
]
const { container } = renderChip({ value: 'a', items: itemsWithExtra })
expect(screen.getByText('Item A')).toBeInTheDocument()
// Open panel and select Item B
openPanel(container)
const itemBs = screen.getAllByText('Item B')
fireEvent.click(itemBs[itemBs.length - 1])
expect(onSelect).toHaveBeenCalledWith(itemsWithExtra[1])
})
})
})

View File

@@ -4,7 +4,7 @@ import {
RiClipboardLine,
} from '@remixicon/react'
import copy from 'copy-to-clipboard'
import { debounce } from 'lodash-es'
import { debounce } from 'es-toolkit/compat'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -1,6 +1,6 @@
'use client'
import copy from 'copy-to-clipboard'
import { debounce } from 'lodash-es'
import { debounce } from 'es-toolkit/compat'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -3,8 +3,8 @@ import type { InputVar } from '@/app/components/workflow/types'
import type { PromptVariable } from '@/models/debug'
import { RiAddLine, RiAsterisk, RiCloseLine, RiDeleteBinLine, RiDraggable } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/compat'
import { produce } from 'immer'
import { noop } from 'lodash-es'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -2,7 +2,7 @@ import type { ChangeEvent, FC } from 'react'
import type { CodeBasedExtensionItem } from '@/models/common'
import type { ModerationConfig, ModerationContentConfig } from '@/models/debug'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'

View File

@@ -2,8 +2,8 @@ import type { ClipboardEvent } from 'react'
import type { FileEntity } from './types'
import type { FileUpload } from '@/app/components/base/features/types'
import type { FileUploadConfigResponse } from '@/models/common'
import { noop } from 'es-toolkit/compat'
import { produce } from 'immer'
import { noop } from 'lodash-es'
import { useParams } from 'next/navigation'
import {
useCallback,

View File

@@ -1,7 +1,7 @@
import type { FC } from 'react'
import { RiCloseLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat'
import { t } from 'i18next'
import { noop } from 'lodash-es'
import * as React from 'react'
import { useState } from 'react'
import { createPortal } from 'react-dom'

View File

@@ -1,7 +1,7 @@
import type {
FileEntity,
} from './types'
import { isEqual } from 'lodash-es'
import { isEqual } from 'es-toolkit/compat'
import {
createContext,
useContext,

View File

@@ -1,6 +1,6 @@
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'
import { RiCloseLargeLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { cn } from '@/utils/classnames'
type IModal = {

View File

@@ -2,7 +2,7 @@ import { access, appendFile, mkdir, open, readdir, rm, writeFile } from 'node:fs
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseXml } from '@rgrove/parse-xml'
import { camelCase, template } from 'lodash-es'
import { camelCase, template } from 'es-toolkit/compat'
const __dirname = path.dirname(fileURLToPath(import.meta.url))

View File

@@ -1,7 +1,7 @@
import type { FC } from 'react'
import { RiAddBoxLine, RiCloseLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat'
import { t } from 'i18next'
import { noop } from 'lodash-es'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'

View File

@@ -25,8 +25,8 @@ vi.mock('react-i18next', () => ({
}),
}))
// Mock lodash-es debounce
vi.mock('lodash-es', () => ({
// Mock es-toolkit/compat debounce
vi.mock('es-toolkit/compat', () => ({
debounce: (fn: any) => fn,
}))

View File

@@ -2,7 +2,7 @@
import type { InputProps } from '../input'
import { RiClipboardFill, RiClipboardLine } from '@remixicon/react'
import copy from 'copy-to-clipboard'
import { debounce } from 'lodash-es'
import { debounce } from 'es-toolkit/compat'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'class-variance-authority'
import type { ChangeEventHandler, CSSProperties, FocusEventHandler } from 'react'
import { RiCloseCircleFill, RiErrorWarningLine, RiSearchLine } from '@remixicon/react'
import { cva } from 'class-variance-authority'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'

View File

@@ -1,5 +1,5 @@
import type { ReactMarkdownWrapperProps, SimplePluginInfo } from './react-markdown-wrapper'
import { flow } from 'lodash-es'
import { flow } from 'es-toolkit/compat'
import dynamic from 'next/dynamic'
import { cn } from '@/utils/classnames'
import { preprocessLaTeX, preprocessThinkTag } from './markdown-utils'

View File

@@ -3,7 +3,7 @@
* These functions were extracted from the main markdown renderer for better separation of concerns.
* Includes preprocessing for LaTeX and custom "think" tags.
*/
import { flow } from 'lodash-es'
import { flow } from 'es-toolkit/compat'
import { ALLOW_UNSAFE_DATA_SCHEME } from '@/config'
export const preprocessLaTeX = (content: string) => {

View File

@@ -1,6 +1,6 @@
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { Fragment } from 'react'
import { cn } from '@/utils/classnames'
// https://headlessui.com/react/dialog

View File

@@ -1,6 +1,6 @@
import type { ButtonProps } from '@/app/components/base/button'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'

View File

@@ -4,7 +4,7 @@ import type {
IPaginationProps,
PageButtonProps,
} from './type'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { cn } from '@/utils/classnames'
import usePagination from './hook'

View File

@@ -1,8 +1,8 @@
import type { ContextBlockType } from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { noop } from 'es-toolkit/compat'
import { $applyNodeReplacement } from 'lexical'
import { noop } from 'lodash-es'
import {
memo,
useCallback,

View File

@@ -1,12 +1,12 @@
import type { ContextBlockType } from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { noop } from 'es-toolkit/compat'
import {
$insertNodes,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from 'lexical'
import { noop } from 'lodash-es'
import {
memo,
useEffect,

View File

@@ -1,8 +1,8 @@
import type { HistoryBlockType } from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { noop } from 'es-toolkit/compat'
import { $applyNodeReplacement } from 'lexical'
import { noop } from 'lodash-es'
import {
useCallback,
useEffect,

View File

@@ -1,12 +1,12 @@
import type { HistoryBlockType } from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { noop } from 'es-toolkit/compat'
import {
$insertNodes,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from 'lexical'
import { noop } from 'lodash-es'
import {
memo,
useEffect,

View File

@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { cn } from '@/utils/classnames'

View File

@@ -3,7 +3,7 @@ import type { HtmlContentProps } from '@/app/components/base/popover'
import type { Tag } from '@/app/components/base/tag-management/constant'
import { RiAddLine, RiPriceTag3Line } from '@remixicon/react'
import { useUnmount } from 'ahooks'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -2,7 +2,7 @@
import type { Tag } from '@/app/components/base/tag-management/constant'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { act, render, screen, waitFor } from '@testing-library/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import Toast, { ToastProvider, useToastContext } from '.'

View File

@@ -7,7 +7,7 @@ import {
RiErrorWarningFill,
RiInformation2Fill,
} from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { z } from 'zod'
import withValidation from '.'

View File

@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import type { IndexingStatusResponse } from '@/models/datasets'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useEffect, useReducer } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -1,6 +1,6 @@
'use client'
import { useDebounceFn, useKeyPress } from 'ahooks'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useRouter } from 'next/navigation'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -9,7 +9,7 @@ import {
RiArrowLeftLine,
RiSearchEyeLine,
} from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import Image from 'next/image'
import Link from 'next/link'
import * as React from 'react'

View File

@@ -2,7 +2,7 @@
import type { FC } from 'react'
import type { ChunkingMode, FileItem } from '@/models/datasets'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -1,4 +1,4 @@
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { cn } from '@/utils/classnames'
import Drawer from './drawer'

View File

@@ -1,7 +1,7 @@
import type { FC } from 'react'
import { RiLoader2Line } from '@remixicon/react'
import { useCountDown } from 'ahooks'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import * as React from 'react'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -4,7 +4,7 @@ import type { Item } from '@/app/components/base/select'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { ChildChunkDetail, SegmentDetailModel, SegmentUpdater } from '@/models/datasets'
import { useDebounceFn } from 'ahooks'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { usePathname } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'

View File

@@ -4,7 +4,7 @@ import type { inputType, metadataType } from '@/hooks/use-metadata'
import type { CommonResponse } from '@/models/common'
import type { DocType, FullDocumentDetail } from '@/models/datasets'
import { PencilIcon } from '@heroicons/react/24/outline'
import { get } from 'lodash-es'
import { get } from 'es-toolkit/compat'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -1,7 +1,7 @@
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem, CustomFile, FileIndexingEstimateResponse } from '@/models/datasets'
import type { OnlineDriveFile, PublishedPipelineRunPreviewResponse } from '@/models/pipeline'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useRouter } from 'next/navigation'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -9,7 +9,7 @@ import {
RiGlobalLine,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { pick, uniq } from 'lodash-es'
import { pick, uniq } from 'es-toolkit/compat'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'

Some files were not shown because too many files have changed in this diff Show More