Merge branch 'main' of github.com:langgenius/dify into feat/log-formatter

This commit is contained in:
Byron Wang 2025-12-25 14:18:26 +08:00
commit c37d4b765f
No known key found for this signature in database
GPG Key ID: 335E934E215AD579
214 changed files with 1139 additions and 265 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

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

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

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

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

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

View File

@ -11,7 +11,7 @@ import {
RiPlayCircleLine,
} from '@remixicon/react'
import { useBoolean, useDebounceFn } from 'ahooks'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useState } from 'react'

View File

@ -1,6 +1,6 @@
import type { BuiltInMetadataItem, MetadataItemWithValue } from '../types'
import type { FullDocumentDetail } from '@/models/datasets'
import { get } from 'lodash-es'
import { get } from 'es-toolkit/compat'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import { RiArrowLeftLine } from '@remixicon/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

@ -4,7 +4,7 @@ import type { MouseEventHandler } from 'react'
import type { AppIconSelection } from '../../base/app-icon-picker'
import type { DataSet } from '@/models/datasets'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'

View File

@ -2,7 +2,7 @@
import type { AppIconType } from '@/types/app'
import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks'
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

@ -1,6 +1,6 @@
import type { FC } from 'react'
import type { ApiBasedExtension } from '@/models/common'
import { noop } from 'lodash-es'
import { noop } from 'es-toolkit/compat'
import { 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 { FC } from 'react'
import type { DataSourceNotion as TDataSourceNotion } from '@/models/common'
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

@ -3,7 +3,7 @@ import type { FC } from 'react'
import {
RiDeleteBinLine,
} 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

@ -1,4 +1,4 @@
import type { DebouncedFunc } from 'lodash-es'
import type { DebouncedFunc } from 'es-toolkit/compat'
import type { ValidateCallback, ValidatedStatusState, ValidateValue } from './declarations'
import { useDebounceFn } from 'ahooks'
import { useState } from 'react'

View File

@ -1,6 +1,6 @@
'use client'
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'

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