From 69589807fdab2ba71fd506a3e73c42442e75c037 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 31 Dec 2025 08:32:55 +0800 Subject: [PATCH 01/25] refactor: Replace direct `process.env.NODE_ENV` checks with `IS_PROD` and `IS_DEV` constants. (#30383) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Asuka Minato --- .../components/base/date-and-time-picker/utils/dayjs.ts | 3 ++- web/app/components/base/error-boundary/index.tsx | 9 +++++---- web/app/components/base/ga/index.tsx | 4 ++-- web/app/components/base/zendesk/index.tsx | 4 ++-- web/app/components/header/github-star/index.tsx | 3 ++- web/service/use-common.ts | 3 ++- 6 files changed, 15 insertions(+), 11 deletions(-) diff --git a/web/app/components/base/date-and-time-picker/utils/dayjs.ts b/web/app/components/base/date-and-time-picker/utils/dayjs.ts index b37808209c..23307895a7 100644 --- a/web/app/components/base/date-and-time-picker/utils/dayjs.ts +++ b/web/app/components/base/date-and-time-picker/utils/dayjs.ts @@ -3,6 +3,7 @@ import type { Day } from '../types' import dayjs from 'dayjs' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' +import { IS_PROD } from '@/config' import tz from '@/utils/timezone.json' dayjs.extend(utc) @@ -131,7 +132,7 @@ export type ToDayjsOptions = { } const warnParseFailure = (value: string) => { - if (process.env.NODE_ENV !== 'production') + if (!IS_PROD) console.warn('[TimePicker] Failed to parse time value', value) } diff --git a/web/app/components/base/error-boundary/index.tsx b/web/app/components/base/error-boundary/index.tsx index b041bba4d6..9cb4b70cf5 100644 --- a/web/app/components/base/error-boundary/index.tsx +++ b/web/app/components/base/error-boundary/index.tsx @@ -4,6 +4,7 @@ import { RiAlertLine, RiBugLine } from '@remixicon/react' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import Button from '@/app/components/base/button' +import { IS_DEV } from '@/config' import { cn } from '@/utils/classnames' type ErrorBoundaryState = { @@ -54,7 +55,7 @@ class ErrorBoundaryInner extends React.Component< } componentDidCatch(error: Error, errorInfo: ErrorInfo) { - if (process.env.NODE_ENV === 'development') { + if (IS_DEV) { console.error('ErrorBoundary caught an error:', error) console.error('Error Info:', errorInfo) } @@ -262,13 +263,13 @@ export function withErrorBoundary

( // Simple error fallback component export const ErrorFallback: React.FC<{ error: Error - resetErrorBoundary: () => void -}> = ({ error, resetErrorBoundary }) => { + resetErrorBoundaryAction: () => void +}> = ({ error, resetErrorBoundaryAction }) => { return (

Oops! Something went wrong

{error.message}

-
diff --git a/web/app/components/base/ga/index.tsx b/web/app/components/base/ga/index.tsx index eb991092e0..03475b3bb4 100644 --- a/web/app/components/base/ga/index.tsx +++ b/web/app/components/base/ga/index.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import { headers } from 'next/headers' import Script from 'next/script' import * as React from 'react' -import { IS_CE_EDITION } from '@/config' +import { IS_CE_EDITION, IS_PROD } from '@/config' export enum GaType { admin = 'admin', @@ -32,7 +32,7 @@ const GA: FC = ({ if (IS_CE_EDITION) return null - const cspHeader = process.env.NODE_ENV === 'production' + const cspHeader = IS_PROD ? (headers() as unknown as UnsafeUnwrappedHeaders).get('content-security-policy') : null const nonce = extractNonceFromCSP(cspHeader) diff --git a/web/app/components/base/zendesk/index.tsx b/web/app/components/base/zendesk/index.tsx index dc6ce1e655..e12a128a02 100644 --- a/web/app/components/base/zendesk/index.tsx +++ b/web/app/components/base/zendesk/index.tsx @@ -1,13 +1,13 @@ import { headers } from 'next/headers' import Script from 'next/script' import { memo } from 'react' -import { IS_CE_EDITION, ZENDESK_WIDGET_KEY } from '@/config' +import { IS_CE_EDITION, IS_PROD, ZENDESK_WIDGET_KEY } from '@/config' const Zendesk = async () => { if (IS_CE_EDITION || !ZENDESK_WIDGET_KEY) return null - const nonce = process.env.NODE_ENV === 'production' ? (await headers()).get('x-nonce') ?? '' : '' + const nonce = IS_PROD ? (await headers()).get('x-nonce') ?? '' : '' return ( <> diff --git a/web/app/components/header/github-star/index.tsx b/web/app/components/header/github-star/index.tsx index 01f30fba31..e91bdcca2c 100644 --- a/web/app/components/header/github-star/index.tsx +++ b/web/app/components/header/github-star/index.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react' import type { GithubRepo } from '@/models/common' import { RiLoader2Line } from '@remixicon/react' import { useQuery } from '@tanstack/react-query' +import { IS_DEV } from '@/config' const defaultData = { stargazers_count: 110918, @@ -21,7 +22,7 @@ const GithubStar: FC<{ className: string }> = (props) => { const { isFetching, isError, data } = useQuery({ queryKey: ['github-star'], queryFn: getStar, - enabled: process.env.NODE_ENV !== 'development', + enabled: !IS_DEV, retry: false, placeholderData: defaultData, }) diff --git a/web/service/use-common.ts b/web/service/use-common.ts index 77bb2a11fa..a1edb041c0 100644 --- a/web/service/use-common.ts +++ b/web/service/use-common.ts @@ -23,6 +23,7 @@ import type { } from '@/models/common' import type { RETRIEVE_METHOD } from '@/types/app' import { useMutation, useQuery } from '@tanstack/react-query' +import { IS_DEV } from '@/config' import { get, post } from './base' import { useInvalid } from './use-base' @@ -85,7 +86,7 @@ export const useUserProfile = () => { profile, meta: { currentVersion: response.headers.get('x-version'), - currentEnv: process.env.NODE_ENV === 'development' + currentEnv: IS_DEV ? 'DEVELOPMENT' : response.headers.get('x-env'), }, From 3a59ae96177139507786f8885624b44ba360c22b Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Wed, 31 Dec 2025 10:10:58 +0800 Subject: [PATCH 02/25] feat: add oauth_new_user flag for frontend when user oauth login (#30370) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: hj24 --- api/controllers/console/auth/oauth.py | 13 ++++++++---- .../controllers/console/auth/test_oauth.py | 20 ++++++++++--------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/api/controllers/console/auth/oauth.py b/api/controllers/console/auth/oauth.py index 7ad1e56373..c20e83d36f 100644 --- a/api/controllers/console/auth/oauth.py +++ b/api/controllers/console/auth/oauth.py @@ -124,7 +124,7 @@ class OAuthCallback(Resource): return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin/invite-settings?invite_token={invite_token}") try: - account = _generate_account(provider, user_info) + account, oauth_new_user = _generate_account(provider, user_info) except AccountNotFoundError: return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Account not found.") except (WorkSpaceNotFoundError, WorkSpaceNotAllowedCreateError): @@ -159,7 +159,10 @@ class OAuthCallback(Resource): ip_address=extract_remote_ip(request), ) - response = redirect(f"{dify_config.CONSOLE_WEB_URL}") + base_url = dify_config.CONSOLE_WEB_URL + query_char = "&" if "?" in base_url else "?" + target_url = f"{base_url}{query_char}oauth_new_user={str(oauth_new_user).lower()}" + response = redirect(target_url) set_access_token_to_cookie(request, response, token_pair.access_token) set_refresh_token_to_cookie(request, response, token_pair.refresh_token) @@ -177,9 +180,10 @@ def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) -> return account -def _generate_account(provider: str, user_info: OAuthUserInfo): +def _generate_account(provider: str, user_info: OAuthUserInfo) -> tuple[Account, bool]: # Get account by openid or email. account = _get_account_by_openid_or_email(provider, user_info) + oauth_new_user = False if account: tenants = TenantService.get_join_tenants(account) @@ -193,6 +197,7 @@ def _generate_account(provider: str, user_info: OAuthUserInfo): tenant_was_created.send(new_tenant) if not account: + oauth_new_user = True if not FeatureService.get_system_features().is_allow_register: if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(user_info.email): raise AccountRegisterError( @@ -220,4 +225,4 @@ def _generate_account(provider: str, user_info: OAuthUserInfo): # Link account AccountService.link_account_integrate(provider, user_info.id, account) - return account + return account, oauth_new_user diff --git a/api/tests/unit_tests/controllers/console/auth/test_oauth.py b/api/tests/unit_tests/controllers/console/auth/test_oauth.py index 399caf8c4d..3ddfcdb832 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_oauth.py +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth.py @@ -171,7 +171,7 @@ class TestOAuthCallback: ): mock_config.CONSOLE_WEB_URL = "http://localhost:3000" mock_get_providers.return_value = {"github": oauth_setup["provider"]} - mock_generate_account.return_value = oauth_setup["account"] + mock_generate_account.return_value = (oauth_setup["account"], True) mock_account_service.login.return_value = oauth_setup["token_pair"] with app.test_request_context("/auth/oauth/github/callback?code=test_code"): @@ -179,7 +179,7 @@ class TestOAuthCallback: oauth_setup["provider"].get_access_token.assert_called_once_with("test_code") oauth_setup["provider"].get_user_info.assert_called_once_with("access_token") - mock_redirect.assert_called_once_with("http://localhost:3000") + mock_redirect.assert_called_once_with("http://localhost:3000?oauth_new_user=true") @pytest.mark.parametrize( ("exception", "expected_error"), @@ -223,7 +223,7 @@ class TestOAuthCallback: # This documents actual behavior. See test_defensive_check_for_closed_account_status for details ( AccountStatus.CLOSED.value, - "http://localhost:3000", + "http://localhost:3000?oauth_new_user=false", ), ], ) @@ -260,7 +260,7 @@ class TestOAuthCallback: account = MagicMock() account.status = account_status account.id = "123" - mock_generate_account.return_value = account + mock_generate_account.return_value = (account, False) # Mock login for CLOSED status mock_token_pair = MagicMock() @@ -296,7 +296,7 @@ class TestOAuthCallback: mock_account = MagicMock() mock_account.status = AccountStatus.PENDING - mock_generate_account.return_value = mock_account + mock_generate_account.return_value = (mock_account, False) mock_token_pair = MagicMock() mock_token_pair.access_token = "jwt_access_token" @@ -360,7 +360,7 @@ class TestOAuthCallback: closed_account.status = AccountStatus.CLOSED closed_account.id = "123" closed_account.name = "Closed Account" - mock_generate_account.return_value = closed_account + mock_generate_account.return_value = (closed_account, False) # Mock successful login (current behavior) mock_token_pair = MagicMock() @@ -374,7 +374,7 @@ class TestOAuthCallback: resource.get("github") # Verify current behavior: login succeeds (this is NOT ideal) - mock_redirect.assert_called_once_with("http://localhost:3000") + mock_redirect.assert_called_once_with("http://localhost:3000?oauth_new_user=false") mock_account_service.login.assert_called_once() # Document expected behavior in comments: @@ -458,8 +458,9 @@ class TestAccountGeneration: with pytest.raises(AccountRegisterError): _generate_account("github", user_info) else: - result = _generate_account("github", user_info) + result, oauth_new_user = _generate_account("github", user_info) assert result == mock_account + assert oauth_new_user == should_create if should_create: mock_register_service.register.assert_called_once_with( @@ -490,9 +491,10 @@ class TestAccountGeneration: mock_tenant_service.create_tenant.return_value = mock_new_tenant with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}): - result = _generate_account("github", user_info) + result, oauth_new_user = _generate_account("github", user_info) assert result == mock_account + assert oauth_new_user is False mock_tenant_service.create_tenant.assert_called_once_with("Test User's Workspace") mock_tenant_service.create_tenant_member.assert_called_once_with( mock_new_tenant, mock_account, role="owner" From de53c78125a76abb97deb2c17eba40d95d796ebb Mon Sep 17 00:00:00 2001 From: quicksand Date: Wed, 31 Dec 2025 10:11:25 +0800 Subject: [PATCH 03/25] fix(web): template creation permission for app templates (#30367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 非法操作 --- .../app/create-app-dialog/app-list/index.spec.tsx | 8 +------- .../components/app/create-app-dialog/app-list/index.tsx | 5 +---- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/web/app/components/app/create-app-dialog/app-list/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/index.spec.tsx index d873b4243e..e0f459ee75 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.spec.tsx @@ -14,13 +14,6 @@ vi.mock('ahooks', () => ({ vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ isCurrentWorkspaceEditor: true }), })) -vi.mock('use-context-selector', async () => { - const actual = await vi.importActual('use-context-selector') - return { - ...actual, - useContext: () => ({ hasEditPermission: true }), - } -}) vi.mock('nuqs', () => ({ useQueryState: () => ['Recommended', vi.fn()], })) @@ -119,6 +112,7 @@ describe('Apps', () => { fireEvent.click(screen.getAllByTestId('app-card')[0]) expect(screen.getByTestId('create-from-template-modal')).toBeInTheDocument() }) + it('shows no template message when list is empty', () => { mockUseExploreAppList.mockReturnValueOnce({ data: { allList: [], categories: [] }, diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index 6a30df0dd9..b967ba7d55 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -8,7 +8,6 @@ import { useRouter } from 'next/navigation' import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import AppTypeSelector from '@/app/components/app/type-selector' import { trackEvent } from '@/app/components/base/amplitude' import Divider from '@/app/components/base/divider' @@ -19,7 +18,6 @@ import CreateAppModal from '@/app/components/explore/create-app-modal' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' -import ExploreContext from '@/context/explore-context' import { DSLImportMode } from '@/models/app' import { importDSL } from '@/service/apps' import { fetchAppDetail } from '@/service/explore' @@ -47,7 +45,6 @@ const Apps = ({ const { t } = useTranslation() const { isCurrentWorkspaceEditor } = useAppContext() const { push } = useRouter() - const { hasEditPermission } = useContext(ExploreContext) const allCategoriesEn = AppCategories.RECOMMENDED const [keywords, setKeywords] = useState('') @@ -214,7 +211,7 @@ const Apps = ({ { setCurrApp(app) setIsShowCreateModal(true) From fb5edd0bf65191fa0c6841795213c8c5d9c9b089 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Wed, 31 Dec 2025 11:24:35 +0900 Subject: [PATCH 04/25] =?UTF-8?q?refactor:=20split=20changes=20for=20api/s?= =?UTF-8?q?ervices/tools/api=5Ftools=5Fmanage=5Fservi=E2=80=A6=20(#29899)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/.ruff.toml | 6 +++++- api/core/tools/utils/parser.py | 2 +- .../tools/api_tools_manage_service.py | 20 ++++++++----------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/api/.ruff.toml b/api/.ruff.toml index 7206f7fa0f..8db0cbcb21 100644 --- a/api/.ruff.toml +++ b/api/.ruff.toml @@ -1,4 +1,8 @@ -exclude = ["migrations/*"] +exclude = [ + "migrations/*", + ".git", + ".git/**", +] line-length = 120 [format] diff --git a/api/core/tools/utils/parser.py b/api/core/tools/utils/parser.py index 3486182192..584975de05 100644 --- a/api/core/tools/utils/parser.py +++ b/api/core/tools/utils/parser.py @@ -378,7 +378,7 @@ class ApiBasedToolSchemaParser: @staticmethod def auto_parse_to_tool_bundle( content: str, extra_info: dict | None = None, warning: dict | None = None - ) -> tuple[list[ApiToolBundle], str]: + ) -> tuple[list[ApiToolBundle], ApiProviderSchemaType]: """ auto parse to tool bundle diff --git a/api/services/tools/api_tools_manage_service.py b/api/services/tools/api_tools_manage_service.py index 250d29f335..c32157919b 100644 --- a/api/services/tools/api_tools_manage_service.py +++ b/api/services/tools/api_tools_manage_service.py @@ -85,7 +85,9 @@ class ApiToolManageService: raise ValueError(f"invalid schema: {str(e)}") @staticmethod - def convert_schema_to_tool_bundles(schema: str, extra_info: dict | None = None) -> tuple[list[ApiToolBundle], str]: + def convert_schema_to_tool_bundles( + schema: str, extra_info: dict | None = None + ) -> tuple[list[ApiToolBundle], ApiProviderSchemaType]: """ convert schema to tool bundles @@ -103,7 +105,7 @@ class ApiToolManageService: provider_name: str, icon: dict, credentials: dict, - schema_type: str, + schema_type: ApiProviderSchemaType, schema: str, privacy_policy: str, custom_disclaimer: str, @@ -112,9 +114,6 @@ class ApiToolManageService: """ create api tool provider """ - if schema_type not in [member.value for member in ApiProviderSchemaType]: - raise ValueError(f"invalid schema type {schema}") - provider_name = provider_name.strip() # check if the provider exists @@ -241,18 +240,15 @@ class ApiToolManageService: original_provider: str, icon: dict, credentials: dict, - schema_type: str, + _schema_type: ApiProviderSchemaType, schema: str, - privacy_policy: str, + privacy_policy: str | None, custom_disclaimer: str, labels: list[str], ): """ update api tool provider """ - if schema_type not in [member.value for member in ApiProviderSchemaType]: - raise ValueError(f"invalid schema type {schema}") - provider_name = provider_name.strip() # check if the provider exists @@ -277,7 +273,7 @@ class ApiToolManageService: provider.icon = json.dumps(icon) provider.schema = schema provider.description = extra_info.get("description", "") - provider.schema_type_str = ApiProviderSchemaType.OPENAPI + provider.schema_type_str = schema_type provider.tools_str = json.dumps(jsonable_encoder(tool_bundles)) provider.privacy_policy = privacy_policy provider.custom_disclaimer = custom_disclaimer @@ -356,7 +352,7 @@ class ApiToolManageService: tool_name: str, credentials: dict, parameters: dict, - schema_type: str, + schema_type: ApiProviderSchemaType, schema: str, ): """ From e6f3528bb05c18598457fa1fe504dc77d9b48726 Mon Sep 17 00:00:00 2001 From: Jasonfish Date: Wed, 31 Dec 2025 10:26:28 +0800 Subject: [PATCH 05/25] fix: Incorrect REDIS ssl variable used for Celery causing Celery unable to start (#29605) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> --- api/extensions/ext_celery.py | 3 +-- api/schedule/queue_monitor_task.py | 5 +++++ .../unit_tests/extensions/test_celery_ssl.py | 15 +++++---------- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/api/extensions/ext_celery.py b/api/extensions/ext_celery.py index 5cf4984709..764df5d178 100644 --- a/api/extensions/ext_celery.py +++ b/api/extensions/ext_celery.py @@ -12,9 +12,8 @@ from dify_app import DifyApp def _get_celery_ssl_options() -> dict[str, Any] | None: """Get SSL configuration for Celery broker/backend connections.""" - # Use REDIS_USE_SSL for consistency with the main Redis client # Only apply SSL if we're using Redis as broker/backend - if not dify_config.REDIS_USE_SSL: + if not dify_config.BROKER_USE_SSL: return None # Check if Celery is actually using Redis diff --git a/api/schedule/queue_monitor_task.py b/api/schedule/queue_monitor_task.py index db610df290..77d6b5a138 100644 --- a/api/schedule/queue_monitor_task.py +++ b/api/schedule/queue_monitor_task.py @@ -16,6 +16,11 @@ celery_redis = Redis( port=redis_config.get("port") or 6379, password=redis_config.get("password") or None, db=int(redis_config.get("virtual_host")) if redis_config.get("virtual_host") else 1, + ssl=bool(dify_config.BROKER_USE_SSL), + ssl_ca_certs=dify_config.REDIS_SSL_CA_CERTS if dify_config.BROKER_USE_SSL else None, + ssl_cert_reqs=getattr(dify_config, "REDIS_SSL_CERT_REQS", None) if dify_config.BROKER_USE_SSL else None, + ssl_certfile=getattr(dify_config, "REDIS_SSL_CERTFILE", None) if dify_config.BROKER_USE_SSL else None, + ssl_keyfile=getattr(dify_config, "REDIS_SSL_KEYFILE", None) if dify_config.BROKER_USE_SSL else None, ) logger = logging.getLogger(__name__) diff --git a/api/tests/unit_tests/extensions/test_celery_ssl.py b/api/tests/unit_tests/extensions/test_celery_ssl.py index fc7a090ef9..d3a4d69f07 100644 --- a/api/tests/unit_tests/extensions/test_celery_ssl.py +++ b/api/tests/unit_tests/extensions/test_celery_ssl.py @@ -8,11 +8,12 @@ class TestCelerySSLConfiguration: """Test suite for Celery SSL configuration.""" def test_get_celery_ssl_options_when_ssl_disabled(self): - """Test SSL options when REDIS_USE_SSL is False.""" - mock_config = MagicMock() - mock_config.REDIS_USE_SSL = False + """Test SSL options when BROKER_USE_SSL is False.""" + from configs import DifyConfig - with patch("extensions.ext_celery.dify_config", mock_config): + dify_config = DifyConfig(CELERY_BROKER_URL="redis://localhost:6379/0") + + with patch("extensions.ext_celery.dify_config", dify_config): from extensions.ext_celery import _get_celery_ssl_options result = _get_celery_ssl_options() @@ -21,7 +22,6 @@ class TestCelerySSLConfiguration: def test_get_celery_ssl_options_when_broker_not_redis(self): """Test SSL options when broker is not Redis.""" mock_config = MagicMock() - mock_config.REDIS_USE_SSL = True mock_config.CELERY_BROKER_URL = "amqp://localhost:5672" with patch("extensions.ext_celery.dify_config", mock_config): @@ -33,7 +33,6 @@ class TestCelerySSLConfiguration: def test_get_celery_ssl_options_with_cert_none(self): """Test SSL options with CERT_NONE requirement.""" mock_config = MagicMock() - mock_config.REDIS_USE_SSL = True mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0" mock_config.REDIS_SSL_CERT_REQS = "CERT_NONE" mock_config.REDIS_SSL_CA_CERTS = None @@ -53,7 +52,6 @@ class TestCelerySSLConfiguration: def test_get_celery_ssl_options_with_cert_required(self): """Test SSL options with CERT_REQUIRED and certificates.""" mock_config = MagicMock() - mock_config.REDIS_USE_SSL = True mock_config.CELERY_BROKER_URL = "rediss://localhost:6380/0" mock_config.REDIS_SSL_CERT_REQS = "CERT_REQUIRED" mock_config.REDIS_SSL_CA_CERTS = "/path/to/ca.crt" @@ -73,7 +71,6 @@ class TestCelerySSLConfiguration: def test_get_celery_ssl_options_with_cert_optional(self): """Test SSL options with CERT_OPTIONAL requirement.""" mock_config = MagicMock() - mock_config.REDIS_USE_SSL = True mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0" mock_config.REDIS_SSL_CERT_REQS = "CERT_OPTIONAL" mock_config.REDIS_SSL_CA_CERTS = "/path/to/ca.crt" @@ -91,7 +88,6 @@ class TestCelerySSLConfiguration: def test_get_celery_ssl_options_with_invalid_cert_reqs(self): """Test SSL options with invalid cert requirement defaults to CERT_NONE.""" mock_config = MagicMock() - mock_config.REDIS_USE_SSL = True mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0" mock_config.REDIS_SSL_CERT_REQS = "INVALID_VALUE" mock_config.REDIS_SSL_CA_CERTS = None @@ -108,7 +104,6 @@ class TestCelerySSLConfiguration: def test_celery_init_applies_ssl_to_broker_and_backend(self): """Test that SSL options are applied to both broker and backend when using Redis.""" mock_config = MagicMock() - mock_config.REDIS_USE_SSL = True mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0" mock_config.CELERY_BACKEND = "redis" mock_config.CELERY_RESULT_BACKEND = "redis://localhost:6379/0" From 925168383b931086d74184894813c602d67dae22 Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Wed, 31 Dec 2025 10:28:14 +0800 Subject: [PATCH 06/25] fix: keyword search now matches both content and keywords fields (#29619) Co-authored-by: Claude Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../console/datasets/datasets_segments.py | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/api/controllers/console/datasets/datasets_segments.py b/api/controllers/console/datasets/datasets_segments.py index e73abc2555..5a536af6d2 100644 --- a/api/controllers/console/datasets/datasets_segments.py +++ b/api/controllers/console/datasets/datasets_segments.py @@ -3,10 +3,12 @@ import uuid from flask import request from flask_restx import Resource, marshal from pydantic import BaseModel, Field -from sqlalchemy import select +from sqlalchemy import String, cast, func, or_, select +from sqlalchemy.dialects.postgresql import JSONB from werkzeug.exceptions import Forbidden, NotFound import services +from configs import dify_config from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.error import ProviderNotInitializeError @@ -143,7 +145,29 @@ class DatasetDocumentSegmentListApi(Resource): query = query.where(DocumentSegment.hit_count >= hit_count_gte) if keyword: - query = query.where(DocumentSegment.content.ilike(f"%{keyword}%")) + # Search in both content and keywords fields + # Use database-specific methods for JSON array search + if dify_config.SQLALCHEMY_DATABASE_URI_SCHEME == "postgresql": + # PostgreSQL: Use jsonb_array_elements_text to properly handle Unicode/Chinese text + keywords_condition = func.array_to_string( + func.array( + select(func.jsonb_array_elements_text(cast(DocumentSegment.keywords, JSONB))) + .correlate(DocumentSegment) + .scalar_subquery() + ), + ",", + ).ilike(f"%{keyword}%") + else: + # MySQL: Cast JSON to string for pattern matching + # MySQL stores Chinese text directly in JSON without Unicode escaping + keywords_condition = cast(DocumentSegment.keywords, String).ilike(f"%{keyword}%") + + query = query.where( + or_( + DocumentSegment.content.ilike(f"%{keyword}%"), + keywords_condition, + ) + ) if args.enabled.lower() != "all": if args.enabled.lower() == "true": From 9007109a6bf97ddd1766e13313a36d16b6d11182 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Wed, 31 Dec 2025 10:30:15 +0800 Subject: [PATCH 07/25] fix: [xxx](xxx) render as xxx](xxx) (#30392) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/rag/cleaner/clean_processor.py | 44 ++-- api/core/tools/utils/text_processing_utils.py | 6 + .../unit_tests/core/rag/cleaner/__init__.py | 0 .../core/rag/cleaner/test_clean_processor.py | 213 ++++++++++++++++++ .../unit_tests/utils/test_text_processing.py | 5 + 5 files changed, 255 insertions(+), 13 deletions(-) create mode 100644 api/tests/unit_tests/core/rag/cleaner/__init__.py create mode 100644 api/tests/unit_tests/core/rag/cleaner/test_clean_processor.py diff --git a/api/core/rag/cleaner/clean_processor.py b/api/core/rag/cleaner/clean_processor.py index 9cb009035b..e182c35b99 100644 --- a/api/core/rag/cleaner/clean_processor.py +++ b/api/core/rag/cleaner/clean_processor.py @@ -27,26 +27,44 @@ class CleanProcessor: pattern = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)" text = re.sub(pattern, "", text) - # Remove URL but keep Markdown image URLs - # First, temporarily replace Markdown image URLs with a placeholder - markdown_image_pattern = r"!\[.*?\]\((https?://[^\s)]+)\)" - placeholders: list[str] = [] + # Remove URL but keep Markdown image URLs and link URLs + # Replace the ENTIRE markdown link/image with a single placeholder to protect + # the link text (which might also be a URL) from being removed + markdown_link_pattern = r"\[([^\]]*)\]\((https?://[^)]+)\)" + markdown_image_pattern = r"!\[.*?\]\((https?://[^)]+)\)" + placeholders: list[tuple[str, str, str]] = [] # (type, text, url) - def replace_with_placeholder(match, placeholders=placeholders): + def replace_markdown_with_placeholder(match, placeholders=placeholders): + link_type = "link" + link_text = match.group(1) + url = match.group(2) + placeholder = f"__MARKDOWN_PLACEHOLDER_{len(placeholders)}__" + placeholders.append((link_type, link_text, url)) + return placeholder + + def replace_image_with_placeholder(match, placeholders=placeholders): + link_type = "image" url = match.group(1) - placeholder = f"__MARKDOWN_IMAGE_URL_{len(placeholders)}__" - placeholders.append(url) - return f"![image]({placeholder})" + placeholder = f"__MARKDOWN_PLACEHOLDER_{len(placeholders)}__" + placeholders.append((link_type, "image", url)) + return placeholder - text = re.sub(markdown_image_pattern, replace_with_placeholder, text) + # Protect markdown links first + text = re.sub(markdown_link_pattern, replace_markdown_with_placeholder, text) + # Then protect markdown images + text = re.sub(markdown_image_pattern, replace_image_with_placeholder, text) # Now remove all remaining URLs - url_pattern = r"https?://[^\s)]+" + url_pattern = r"https?://\S+" text = re.sub(url_pattern, "", text) - # Finally, restore the Markdown image URLs - for i, url in enumerate(placeholders): - text = text.replace(f"__MARKDOWN_IMAGE_URL_{i}__", url) + # Restore the Markdown links and images + for i, (link_type, text_or_alt, url) in enumerate(placeholders): + placeholder = f"__MARKDOWN_PLACEHOLDER_{i}__" + if link_type == "link": + text = text.replace(placeholder, f"[{text_or_alt}]({url})") + else: # image + text = text.replace(placeholder, f"![{text_or_alt}]({url})") return text def filter_string(self, text): diff --git a/api/core/tools/utils/text_processing_utils.py b/api/core/tools/utils/text_processing_utils.py index 0f9a91a111..4bfaa5e49b 100644 --- a/api/core/tools/utils/text_processing_utils.py +++ b/api/core/tools/utils/text_processing_utils.py @@ -4,6 +4,7 @@ import re def remove_leading_symbols(text: str) -> str: """ Remove leading punctuation or symbols from the given text. + Preserves markdown links like [text](url) at the start. Args: text (str): The input text to process. @@ -11,6 +12,11 @@ def remove_leading_symbols(text: str) -> str: Returns: str: The text with leading punctuation or symbols removed. """ + # Check if text starts with a markdown link - preserve it + markdown_link_pattern = r"^\[([^\]]+)\]\((https?://[^)]+)\)" + if re.match(markdown_link_pattern, text): + return text + # Match Unicode ranges for punctuation and symbols # FIXME this pattern is confused quick fix for #11868 maybe refactor it later pattern = r'^[\[\]\u2000-\u2025\u2027-\u206F\u2E00-\u2E7F\u3000-\u300F\u3011-\u303F"#$%&\'()*+,./:;<=>?@^_`~]+' diff --git a/api/tests/unit_tests/core/rag/cleaner/__init__.py b/api/tests/unit_tests/core/rag/cleaner/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/core/rag/cleaner/test_clean_processor.py b/api/tests/unit_tests/core/rag/cleaner/test_clean_processor.py new file mode 100644 index 0000000000..65ee62b8dd --- /dev/null +++ b/api/tests/unit_tests/core/rag/cleaner/test_clean_processor.py @@ -0,0 +1,213 @@ +from core.rag.cleaner.clean_processor import CleanProcessor + + +class TestCleanProcessor: + """Test cases for CleanProcessor.clean method.""" + + def test_clean_default_removal_of_invalid_symbols(self): + """Test default cleaning removes invalid symbols.""" + # Test <| replacement + assert CleanProcessor.clean("text<|with<|invalid", None) == "text replacement + assert CleanProcessor.clean("text|>with|>invalid", None) == "text>with>invalid" + + # Test removal of control characters + text_with_control = "normal\x00text\x1fwith\x07control\x7fchars" + expected = "normaltextwithcontrolchars" + assert CleanProcessor.clean(text_with_control, None) == expected + + # Test U+FFFE removal + text_with_ufffe = "normal\ufffepadding" + expected = "normalpadding" + assert CleanProcessor.clean(text_with_ufffe, None) == expected + + def test_clean_with_none_process_rule(self): + """Test cleaning with None process_rule - only default cleaning applied.""" + text = "Hello<|World\x00" + expected = "Hello becomes >, control chars and U+FFFE are removed + assert CleanProcessor.clean(text, None) == "<<>>" + + def test_clean_multiple_markdown_links_preserved(self): + """Test multiple markdown links are all preserved.""" + process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_urls_emails", "enabled": True}]}} + + text = "[One](https://one.com) [Two](http://two.org) [Three](https://three.net)" + expected = "[One](https://one.com) [Two](http://two.org) [Three](https://three.net)" + assert CleanProcessor.clean(text, process_rule) == expected + + def test_clean_markdown_link_text_as_url(self): + """Test markdown link where the link text itself is a URL.""" + process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_urls_emails", "enabled": True}]}} + + # Link text that looks like URL should be preserved + text = "[https://text-url.com](https://actual-url.com)" + expected = "[https://text-url.com](https://actual-url.com)" + assert CleanProcessor.clean(text, process_rule) == expected + + # Text URL without markdown should be removed + text = "https://text-url.com https://actual-url.com" + expected = " " + assert CleanProcessor.clean(text, process_rule) == expected + + def test_clean_complex_markdown_link_content(self): + """Test markdown links with complex content - known limitation with brackets in link text.""" + process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_urls_emails", "enabled": True}]}} + + # Note: The regex pattern [^\]]* cannot handle ] within link text + # This is a known limitation - the pattern stops at the first ] + text = "[Text with [brackets] and (parens)](https://example.com)" + # Actual behavior: only matches up to first ], URL gets removed + expected = "[Text with [brackets] and (parens)](" + assert CleanProcessor.clean(text, process_rule) == expected + + # Test that properly formatted markdown links work + text = "[Text with (parens) and symbols](https://example.com)" + expected = "[Text with (parens) and symbols](https://example.com)" + assert CleanProcessor.clean(text, process_rule) == expected diff --git a/api/tests/unit_tests/utils/test_text_processing.py b/api/tests/unit_tests/utils/test_text_processing.py index 11e017464a..bf61162a66 100644 --- a/api/tests/unit_tests/utils/test_text_processing.py +++ b/api/tests/unit_tests/utils/test_text_processing.py @@ -15,6 +15,11 @@ from core.tools.utils.text_processing_utils import remove_leading_symbols ("", ""), (" ", " "), ("【测试】", "【测试】"), + # Markdown link preservation - should be preserved if text starts with a markdown link + ("[Google](https://google.com) is a search engine", "[Google](https://google.com) is a search engine"), + ("[Example](http://example.com) some text", "[Example](http://example.com) some text"), + # Leading symbols before markdown link are removed, including the opening bracket [ + ("@[Test](https://example.com)", "Test](https://example.com)"), ], ) def test_remove_leading_symbols(input_text, expected_output): From 64dc98e60703ec4f52cdc5685d97f796363153d5 Mon Sep 17 00:00:00 2001 From: Sai Date: Wed, 31 Dec 2025 10:45:43 +0800 Subject: [PATCH 08/25] fix: workflow incorrectly marked as completed while nodes are still executing (#30251) Co-authored-by: sai <> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../graph_traversal/skip_propagator.py | 1 + .../graph_engine/graph_traversal/__init__.py | 1 + .../graph_traversal/test_skip_propagator.py | 307 ++++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 api/tests/unit_tests/core/workflow/graph_engine/graph_traversal/__init__.py create mode 100644 api/tests/unit_tests/core/workflow/graph_engine/graph_traversal/test_skip_propagator.py diff --git a/api/core/workflow/graph_engine/graph_traversal/skip_propagator.py b/api/core/workflow/graph_engine/graph_traversal/skip_propagator.py index 78f8ecdcdf..b9c9243963 100644 --- a/api/core/workflow/graph_engine/graph_traversal/skip_propagator.py +++ b/api/core/workflow/graph_engine/graph_traversal/skip_propagator.py @@ -60,6 +60,7 @@ class SkipPropagator: if edge_states["has_taken"]: # Enqueue node self._state_manager.enqueue_node(downstream_node_id) + self._state_manager.start_execution(downstream_node_id) return # All edges are skipped, propagate skip to this node diff --git a/api/tests/unit_tests/core/workflow/graph_engine/graph_traversal/__init__.py b/api/tests/unit_tests/core/workflow/graph_engine/graph_traversal/__init__.py new file mode 100644 index 0000000000..cf8811dc2b --- /dev/null +++ b/api/tests/unit_tests/core/workflow/graph_engine/graph_traversal/__init__.py @@ -0,0 +1 @@ +"""Tests for graph traversal components.""" diff --git a/api/tests/unit_tests/core/workflow/graph_engine/graph_traversal/test_skip_propagator.py b/api/tests/unit_tests/core/workflow/graph_engine/graph_traversal/test_skip_propagator.py new file mode 100644 index 0000000000..0019020ede --- /dev/null +++ b/api/tests/unit_tests/core/workflow/graph_engine/graph_traversal/test_skip_propagator.py @@ -0,0 +1,307 @@ +"""Unit tests for skip propagator.""" + +from unittest.mock import MagicMock, create_autospec + +from core.workflow.graph import Edge, Graph +from core.workflow.graph_engine.graph_state_manager import GraphStateManager +from core.workflow.graph_engine.graph_traversal.skip_propagator import SkipPropagator + + +class TestSkipPropagator: + """Test suite for SkipPropagator.""" + + def test_propagate_skip_from_edge_with_unknown_edges_stops_processing(self) -> None: + """When there are unknown incoming edges, propagation should stop.""" + # Arrange + mock_graph = create_autospec(Graph) + mock_state_manager = create_autospec(GraphStateManager) + + # Create a mock edge + mock_edge = MagicMock(spec=Edge) + mock_edge.id = "edge_1" + mock_edge.head = "node_2" + + # Setup graph edges dict + mock_graph.edges = {"edge_1": mock_edge} + + # Setup incoming edges + incoming_edges = [MagicMock(spec=Edge), MagicMock(spec=Edge)] + mock_graph.get_incoming_edges.return_value = incoming_edges + + # Setup state manager to return has_unknown=True + mock_state_manager.analyze_edge_states.return_value = { + "has_unknown": True, + "has_taken": False, + "all_skipped": False, + } + + propagator = SkipPropagator(mock_graph, mock_state_manager) + + # Act + propagator.propagate_skip_from_edge("edge_1") + + # Assert + mock_graph.get_incoming_edges.assert_called_once_with("node_2") + mock_state_manager.analyze_edge_states.assert_called_once_with(incoming_edges) + # Should not call any other state manager methods + mock_state_manager.enqueue_node.assert_not_called() + mock_state_manager.start_execution.assert_not_called() + mock_state_manager.mark_node_skipped.assert_not_called() + + def test_propagate_skip_from_edge_with_taken_edge_enqueues_node(self) -> None: + """When there is at least one taken edge, node should be enqueued.""" + # Arrange + mock_graph = create_autospec(Graph) + mock_state_manager = create_autospec(GraphStateManager) + + # Create a mock edge + mock_edge = MagicMock(spec=Edge) + mock_edge.id = "edge_1" + mock_edge.head = "node_2" + + mock_graph.edges = {"edge_1": mock_edge} + incoming_edges = [MagicMock(spec=Edge)] + mock_graph.get_incoming_edges.return_value = incoming_edges + + # Setup state manager to return has_taken=True + mock_state_manager.analyze_edge_states.return_value = { + "has_unknown": False, + "has_taken": True, + "all_skipped": False, + } + + propagator = SkipPropagator(mock_graph, mock_state_manager) + + # Act + propagator.propagate_skip_from_edge("edge_1") + + # Assert + mock_state_manager.enqueue_node.assert_called_once_with("node_2") + mock_state_manager.start_execution.assert_called_once_with("node_2") + mock_state_manager.mark_node_skipped.assert_not_called() + + def test_propagate_skip_from_edge_with_all_skipped_propagates_to_node(self) -> None: + """When all incoming edges are skipped, should propagate skip to node.""" + # Arrange + mock_graph = create_autospec(Graph) + mock_state_manager = create_autospec(GraphStateManager) + + # Create a mock edge + mock_edge = MagicMock(spec=Edge) + mock_edge.id = "edge_1" + mock_edge.head = "node_2" + + mock_graph.edges = {"edge_1": mock_edge} + incoming_edges = [MagicMock(spec=Edge)] + mock_graph.get_incoming_edges.return_value = incoming_edges + + # Setup state manager to return all_skipped=True + mock_state_manager.analyze_edge_states.return_value = { + "has_unknown": False, + "has_taken": False, + "all_skipped": True, + } + + propagator = SkipPropagator(mock_graph, mock_state_manager) + + # Act + propagator.propagate_skip_from_edge("edge_1") + + # Assert + mock_state_manager.mark_node_skipped.assert_called_once_with("node_2") + mock_state_manager.enqueue_node.assert_not_called() + mock_state_manager.start_execution.assert_not_called() + + def test_propagate_skip_to_node_marks_node_and_outgoing_edges_skipped(self) -> None: + """_propagate_skip_to_node should mark node and all outgoing edges as skipped.""" + # Arrange + mock_graph = create_autospec(Graph) + mock_state_manager = create_autospec(GraphStateManager) + + # Create outgoing edges + edge1 = MagicMock(spec=Edge) + edge1.id = "edge_2" + edge1.head = "node_downstream_1" # Set head for propagate_skip_from_edge + + edge2 = MagicMock(spec=Edge) + edge2.id = "edge_3" + edge2.head = "node_downstream_2" + + # Setup graph edges dict for propagate_skip_from_edge + mock_graph.edges = {"edge_2": edge1, "edge_3": edge2} + mock_graph.get_outgoing_edges.return_value = [edge1, edge2] + + # Setup get_incoming_edges to return empty list to stop recursion + mock_graph.get_incoming_edges.return_value = [] + + propagator = SkipPropagator(mock_graph, mock_state_manager) + + # Use mock to call private method + # Act + propagator._propagate_skip_to_node("node_1") + + # Assert + mock_state_manager.mark_node_skipped.assert_called_once_with("node_1") + mock_state_manager.mark_edge_skipped.assert_any_call("edge_2") + mock_state_manager.mark_edge_skipped.assert_any_call("edge_3") + assert mock_state_manager.mark_edge_skipped.call_count == 2 + # Should recursively propagate from each edge + # Since propagate_skip_from_edge is called, we need to verify it was called + # But we can't directly verify due to recursion. We'll trust the logic. + + def test_skip_branch_paths_marks_unselected_edges_and_propagates(self) -> None: + """skip_branch_paths should mark all unselected edges as skipped and propagate.""" + # Arrange + mock_graph = create_autospec(Graph) + mock_state_manager = create_autospec(GraphStateManager) + + # Create unselected edges + edge1 = MagicMock(spec=Edge) + edge1.id = "edge_1" + edge1.head = "node_downstream_1" + + edge2 = MagicMock(spec=Edge) + edge2.id = "edge_2" + edge2.head = "node_downstream_2" + + unselected_edges = [edge1, edge2] + + # Setup graph edges dict + mock_graph.edges = {"edge_1": edge1, "edge_2": edge2} + # Setup get_incoming_edges to return empty list to stop recursion + mock_graph.get_incoming_edges.return_value = [] + + propagator = SkipPropagator(mock_graph, mock_state_manager) + + # Act + propagator.skip_branch_paths(unselected_edges) + + # Assert + mock_state_manager.mark_edge_skipped.assert_any_call("edge_1") + mock_state_manager.mark_edge_skipped.assert_any_call("edge_2") + assert mock_state_manager.mark_edge_skipped.call_count == 2 + # propagate_skip_from_edge should be called for each edge + # We can't directly verify due to the mock, but the logic is covered + + def test_propagate_skip_from_edge_recursively_propagates_through_graph(self) -> None: + """Skip propagation should recursively propagate through the graph.""" + # Arrange + mock_graph = create_autospec(Graph) + mock_state_manager = create_autospec(GraphStateManager) + + # Create edge chain: edge_1 -> node_2 -> edge_3 -> node_4 + edge1 = MagicMock(spec=Edge) + edge1.id = "edge_1" + edge1.head = "node_2" + + edge3 = MagicMock(spec=Edge) + edge3.id = "edge_3" + edge3.head = "node_4" + + mock_graph.edges = {"edge_1": edge1, "edge_3": edge3} + + # Setup get_incoming_edges to return different values based on node + def get_incoming_edges_side_effect(node_id): + if node_id == "node_2": + return [edge1] + elif node_id == "node_4": + return [edge3] + return [] + + mock_graph.get_incoming_edges.side_effect = get_incoming_edges_side_effect + + # Setup get_outgoing_edges to return different values based on node + def get_outgoing_edges_side_effect(node_id): + if node_id == "node_2": + return [edge3] + elif node_id == "node_4": + return [] # No outgoing edges, stops recursion + return [] + + mock_graph.get_outgoing_edges.side_effect = get_outgoing_edges_side_effect + + # Setup state manager to return all_skipped for both nodes + mock_state_manager.analyze_edge_states.return_value = { + "has_unknown": False, + "has_taken": False, + "all_skipped": True, + } + + propagator = SkipPropagator(mock_graph, mock_state_manager) + + # Act + propagator.propagate_skip_from_edge("edge_1") + + # Assert + # Should mark node_2 as skipped + mock_state_manager.mark_node_skipped.assert_any_call("node_2") + # Should mark edge_3 as skipped + mock_state_manager.mark_edge_skipped.assert_any_call("edge_3") + # Should propagate to node_4 + mock_state_manager.mark_node_skipped.assert_any_call("node_4") + assert mock_state_manager.mark_node_skipped.call_count == 2 + + def test_propagate_skip_from_edge_with_mixed_edge_states_handles_correctly(self) -> None: + """Test with mixed edge states (some unknown, some taken, some skipped).""" + # Arrange + mock_graph = create_autospec(Graph) + mock_state_manager = create_autospec(GraphStateManager) + + mock_edge = MagicMock(spec=Edge) + mock_edge.id = "edge_1" + mock_edge.head = "node_2" + + mock_graph.edges = {"edge_1": mock_edge} + incoming_edges = [MagicMock(spec=Edge), MagicMock(spec=Edge), MagicMock(spec=Edge)] + mock_graph.get_incoming_edges.return_value = incoming_edges + + # Test 1: has_unknown=True, has_taken=False, all_skipped=False + mock_state_manager.analyze_edge_states.return_value = { + "has_unknown": True, + "has_taken": False, + "all_skipped": False, + } + + propagator = SkipPropagator(mock_graph, mock_state_manager) + + # Act + propagator.propagate_skip_from_edge("edge_1") + + # Assert - should stop processing + mock_state_manager.enqueue_node.assert_not_called() + mock_state_manager.mark_node_skipped.assert_not_called() + + # Reset mocks for next test + mock_state_manager.reset_mock() + mock_graph.reset_mock() + + # Test 2: has_unknown=False, has_taken=True, all_skipped=False + mock_state_manager.analyze_edge_states.return_value = { + "has_unknown": False, + "has_taken": True, + "all_skipped": False, + } + + # Act + propagator.propagate_skip_from_edge("edge_1") + + # Assert - should enqueue node + mock_state_manager.enqueue_node.assert_called_once_with("node_2") + mock_state_manager.start_execution.assert_called_once_with("node_2") + + # Reset mocks for next test + mock_state_manager.reset_mock() + mock_graph.reset_mock() + + # Test 3: has_unknown=False, has_taken=False, all_skipped=True + mock_state_manager.analyze_edge_states.return_value = { + "has_unknown": False, + "has_taken": False, + "all_skipped": True, + } + + # Act + propagator.propagate_skip_from_edge("edge_1") + + # Assert - should propagate skip + mock_state_manager.mark_node_skipped.assert_called_once_with("node_2") From 2aaaa4bd34689e8b62b893f50d254db363dd8f0d Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:13:22 +0800 Subject: [PATCH 09/25] feat(web): migrate from es-toolkit/compat to native es-toolkit (#30244) (#30246) --- web/__mocks__/provider-context.ts | 3 ++- .../[appId]/overview/time-range-picker/date-picker.tsx | 2 +- web/app/(shareLayout)/webapp-reset-password/page.tsx | 2 +- .../webapp-signin/components/mail-and-code-auth.tsx | 2 +- .../webapp-signin/components/mail-and-password-auth.tsx | 2 +- .../(commonLayout)/account-page/email-change-modal.tsx | 2 +- .../app/annotation/batch-add-annotation-modal/index.tsx | 2 +- .../app/configuration/base/operation-btn/index.tsx | 2 +- .../app/configuration/config-prompt/simple-prompt-input.tsx | 2 +- .../app/configuration/config/agent/prompt-editor.tsx | 2 +- .../dataset-config/params-config/weighted-score.tsx | 2 +- .../configuration/dataset-config/settings-modal/index.tsx | 2 +- .../debug/debug-with-multiple-model/context.tsx | 2 +- .../debug-with-multiple-model/text-generation-item.tsx | 3 ++- web/app/components/app/configuration/debug/hooks.tsx | 2 +- web/app/components/app/configuration/debug/index.tsx | 3 ++- .../app/configuration/hooks/use-advanced-prompt-config.ts | 2 +- web/app/components/app/configuration/index.tsx | 3 ++- .../app/configuration/tools/external-data-tool-modal.tsx | 2 +- web/app/components/app/create-from-dsl-modal/index.tsx | 2 +- web/app/components/app/duplicate-modal/index.tsx | 2 +- web/app/components/app/log/index.tsx | 2 +- web/app/components/app/log/list.tsx | 3 ++- .../apikey-info-panel/apikey-info-panel.test-utils.tsx | 2 +- web/app/components/app/switch-app-modal/index.tsx | 2 +- web/app/components/app/workflow-log/index.tsx | 2 +- web/app/components/base/agent-log-modal/detail.tsx | 3 ++- web/app/components/base/app-icon-picker/index.tsx | 2 +- web/app/components/base/chat/chat-with-history/context.tsx | 2 +- web/app/components/base/chat/chat-with-history/hooks.tsx | 2 +- web/app/components/base/chat/chat/hooks.ts | 3 ++- web/app/components/base/chat/embedded-chatbot/context.tsx | 2 +- web/app/components/base/chat/embedded-chatbot/hooks.tsx | 2 +- web/app/components/base/emoji-picker/index.tsx | 2 +- .../new-feature-panel/conversation-opener/modal.tsx | 2 +- .../moderation/moderation-setting-modal.tsx | 2 +- web/app/components/base/file-uploader/hooks.ts | 2 +- web/app/components/base/file-uploader/pdf-preview.tsx | 2 +- web/app/components/base/file-uploader/store.tsx | 2 +- web/app/components/base/fullscreen-modal/index.tsx | 2 +- web/app/components/base/image-uploader/image-preview.tsx | 2 +- web/app/components/base/input/index.tsx | 2 +- web/app/components/base/modal/index.tsx | 2 +- web/app/components/base/modal/modal.tsx | 2 +- web/app/components/base/pagination/pagination.tsx | 2 +- .../context-block/context-block-replacement-block.tsx | 2 +- .../base/prompt-editor/plugins/context-block/index.tsx | 2 +- .../history-block/history-block-replacement-block.tsx | 2 +- .../base/prompt-editor/plugins/history-block/index.tsx | 2 +- web/app/components/base/radio-card/index.tsx | 2 +- web/app/components/base/tag-management/panel.tsx | 2 +- web/app/components/base/tag-management/tag-remove-modal.tsx | 2 +- web/app/components/base/toast/index.spec.tsx | 2 +- web/app/components/base/toast/index.tsx | 2 +- .../components/base/with-input-validation/index.spec.tsx | 2 +- .../common/document-status-with-action/index-failed.tsx | 2 +- .../create-options/create-from-dsl-modal/index.tsx | 2 +- web/app/components/datasets/create/step-two/index.tsx | 2 +- .../datasets/documents/detail/batch-modal/index.tsx | 2 +- .../detail/completed/common/full-screen-drawer.tsx | 2 +- .../detail/completed/common/regeneration-modal.tsx | 2 +- .../datasets/documents/detail/completed/index.tsx | 2 +- .../documents/detail/settings/pipeline-settings/index.tsx | 2 +- web/app/components/datasets/documents/list.tsx | 3 ++- web/app/components/datasets/documents/operations.tsx | 2 +- .../datasets/metadata/metadata-dataset/create-content.tsx | 2 +- web/app/components/datasets/rename-modal/index.tsx | 2 +- web/app/components/explore/create-app-modal/index.tsx | 2 +- .../account-setting/api-based-extension-page/modal.tsx | 2 +- .../data-source-page/data-source-notion/index.tsx | 2 +- .../account-setting/data-source-page/panel/config-item.tsx | 2 +- .../members-page/edit-workspace-modal/index.tsx | 2 +- .../account-setting/members-page/invite-modal/index.tsx | 2 +- .../account-setting/members-page/invited-modal/index.tsx | 2 +- .../members-page/transfer-ownership-modal/index.tsx | 2 +- web/app/components/header/account-setting/menu-dialog.tsx | 2 +- web/app/components/header/app-selector/index.tsx | 2 +- web/app/components/plugins/base/deprecation-notice.tsx | 2 +- web/app/components/plugins/marketplace/context.tsx | 3 ++- .../subscription-list/edit/apikey-edit-modal.tsx | 2 +- .../subscription-list/edit/manual-edit-modal.tsx | 2 +- .../subscription-list/edit/oauth-edit-modal.tsx | 2 +- web/app/components/plugins/plugin-page/context.tsx | 2 +- web/app/components/plugins/plugin-page/empty/index.tsx | 2 +- web/app/components/plugins/plugin-page/index.tsx | 2 +- .../plugins/plugin-page/install-plugin-dropdown.tsx | 2 +- .../panel/input-field/field-list/field-list-container.tsx | 2 +- .../components/publish-as-knowledge-pipeline-modal.tsx | 2 +- web/app/components/tools/labels/selector.tsx | 2 +- web/app/components/tools/mcp/modal.tsx | 2 +- .../tools/setting/build-in/config-credentials.tsx | 2 +- .../components/tools/workflow-tool/confirm-modal/index.tsx | 2 +- web/app/components/workflow-app/hooks/use-workflow-run.ts | 2 +- .../workflow/block-selector/market-place-plugin/list.tsx | 2 +- web/app/components/workflow/custom-edge.tsx | 2 +- web/app/components/workflow/dsl-export-confirm-modal.tsx | 2 +- web/app/components/workflow/hooks-store/store.ts | 4 +--- web/app/components/workflow/hooks/use-nodes-layout.ts | 2 +- web/app/components/workflow/index.tsx | 2 +- .../workflow/nodes/_base/components/agent-strategy.tsx | 2 +- .../nodes/_base/components/editor/code-editor/index.tsx | 2 +- .../workflow/nodes/_base/components/file-type-item.tsx | 2 +- .../nodes/_base/components/input-support-select-var.tsx | 2 +- .../workflow/nodes/_base/components/next-step/index.tsx | 2 +- .../workflow/nodes/_base/components/next-step/operator.tsx | 2 +- .../nodes/_base/components/panel-operator/change-block.tsx | 2 +- .../workflow/nodes/_base/components/variable/utils.ts | 3 ++- .../_base/components/variable/var-reference-picker.tsx | 2 +- .../nodes/_base/components/variable/var-reference-vars.tsx | 2 +- .../variable/variable-label/base/variable-label.tsx | 2 +- .../workflow/nodes/_base/hooks/use-one-step-run.ts | 3 ++- .../workflow/nodes/assigner/components/var-list/index.tsx | 2 +- .../nodes/if-else/components/condition-number-input.tsx | 2 +- .../workflow/nodes/if-else/components/condition-wrap.tsx | 2 +- web/app/components/workflow/nodes/iteration/use-config.ts | 2 +- .../metadata/condition-list/condition-value-method.tsx | 2 +- .../components/metadata/metadata-filter/index.tsx | 2 +- .../workflow/nodes/knowledge-retrieval/use-config.ts | 2 +- .../components/workflow/nodes/knowledge-retrieval/utils.ts | 6 ++---- .../json-schema-config-modal/visual-editor/context.tsx | 2 +- .../json-schema-config-modal/visual-editor/hooks.ts | 2 +- .../workflow/nodes/llm/use-single-run-form-params.ts | 2 +- .../nodes/loop/components/condition-number-input.tsx | 2 +- .../nodes/parameter-extractor/use-single-run-form-params.ts | 2 +- .../nodes/question-classifier/components/class-list.tsx | 2 +- .../nodes/question-classifier/use-single-run-form-params.ts | 2 +- .../components/workflow/nodes/start/components/var-item.tsx | 2 +- .../workflow/nodes/tool/components/input-var-list.tsx | 2 +- .../nodes/variable-assigner/components/var-list/index.tsx | 2 +- .../note-editor/plugins/link-editor-plugin/component.tsx | 2 +- .../note-editor/plugins/link-editor-plugin/hooks.ts | 2 +- .../panel/chat-variable-panel/components/variable-item.tsx | 2 +- .../panel/debug-and-preview/conversation-variable-modal.tsx | 3 ++- .../components/workflow/panel/debug-and-preview/index.tsx | 3 ++- web/app/components/workflow/panel/env-panel/env-item.tsx | 2 +- .../workflow/panel/global-variable-panel/item.tsx | 2 +- .../components/workflow/run/utils/format-log/agent/index.ts | 2 +- web/app/components/workflow/run/utils/format-log/index.ts | 2 +- .../workflow/run/utils/format-log/iteration/index.spec.ts | 2 +- .../workflow/run/utils/format-log/loop/index.spec.ts | 2 +- web/app/components/workflow/utils/elk-layout.ts | 2 +- web/app/components/workflow/utils/workflow-init.ts | 4 +--- web/app/components/workflow/workflow-history-store.tsx | 2 +- web/app/education-apply/education-apply-page.tsx | 2 +- web/app/reset-password/page.tsx | 2 +- web/app/signin/components/mail-and-password-auth.tsx | 2 +- web/app/signin/invite-settings/page.tsx | 2 +- web/app/signup/components/input-mail.tsx | 2 +- web/context/app-context.tsx | 2 +- web/context/datasets-context.tsx | 2 +- web/context/debug-configuration.ts | 2 +- web/context/explore-context.ts | 2 +- web/context/mitt-context.tsx | 2 +- web/context/modal-context.tsx | 2 +- web/context/provider-context.tsx | 2 +- web/i18n-config/i18next-config.ts | 2 +- web/package.json | 1 - web/pnpm-lock.yaml | 3 --- web/service/use-plugins.ts | 2 +- web/utils/index.ts | 2 +- 160 files changed, 172 insertions(+), 169 deletions(-) diff --git a/web/__mocks__/provider-context.ts b/web/__mocks__/provider-context.ts index c69a2ad1d2..373c2f86d3 100644 --- a/web/__mocks__/provider-context.ts +++ b/web/__mocks__/provider-context.ts @@ -1,6 +1,7 @@ import type { Plan, UsagePlanInfo } from '@/app/components/billing/type' import type { ProviderContextState } from '@/context/provider-context' -import { merge, noop } from 'es-toolkit/compat' +import { merge } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { defaultPlan } from '@/app/components/billing/config' // Avoid being mocked in tests diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx index 5f72e7df63..368c3dcfc3 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx @@ -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 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useCallback } from 'react' import Picker from '@/app/components/base/date-and-time-picker/date-picker' diff --git a/web/app/(shareLayout)/webapp-reset-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/page.tsx index ec75e15a00..9b9a853cdd 100644 --- a/web/app/(shareLayout)/webapp-reset-password/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/page.tsx @@ -1,6 +1,6 @@ 'use client' import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx index f79911099f..5aa9d9f141 100644 --- a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx @@ -1,4 +1,4 @@ -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx index ae70675e7a..23ac83e76c 100644 --- a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx @@ -1,5 +1,5 @@ 'use client' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useState } from 'react' diff --git a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx index e74ca9ed41..87ca6a689c 100644 --- a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx +++ b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx @@ -1,6 +1,6 @@ import type { ResponseError } from '@/service/fetch' import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useRouter } from 'next/navigation' import * as React from 'react' import { useState } from 'react' diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx index ab487d9b91..be1518b708 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/app/configuration/base/operation-btn/index.tsx b/web/app/components/app/configuration/base/operation-btn/index.tsx index d78d9e81cc..d33b632071 100644 --- a/web/app/components/app/configuration/base/operation-btn/index.tsx +++ b/web/app/components/app/configuration/base/operation-btn/index.tsx @@ -4,7 +4,7 @@ import { RiAddLine, RiEditLine, } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useTranslation } from 'react-i18next' import { cn } from '@/utils/classnames' diff --git a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx index c5c63279f6..bc94f87838 100644 --- a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx @@ -4,7 +4,7 @@ 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 { noop } from 'es-toolkit/function' import { produce } from 'immer' import * as React from 'react' import { useState } from 'react' diff --git a/web/app/components/app/configuration/config/agent/prompt-editor.tsx b/web/app/components/app/configuration/config/agent/prompt-editor.tsx index 49a80aed6d..e9e3b60859 100644 --- a/web/app/components/app/configuration/config/agent/prompt-editor.tsx +++ b/web/app/components/app/configuration/config/agent/prompt-editor.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import type { ExternalDataTool } from '@/models/common' import copy from 'copy-to-clipboard' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' diff --git a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx index 9522d1263a..40beef52e8 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx @@ -1,4 +1,4 @@ -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { memo } from 'react' import { useTranslation } from 'react-i18next' import Slider from '@/app/components/base/slider' diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx index 557eef3ed5..32ca4f99cd 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx @@ -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 'es-toolkit/compat' +import { isEqual } from 'es-toolkit/predicate' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/context.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/context.tsx index 3f0381074f..38f803f8ab 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/context.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/context.tsx @@ -1,7 +1,7 @@ 'use client' import type { ModelAndParameter } from '../types' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { createContext, useContext } from 'use-context-selector' export type DebugWithMultipleModelContextType = { diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx index 9113f782d9..d7918e7ad6 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx @@ -4,7 +4,8 @@ import type { OnSend, TextGenerationConfig, } from '@/app/components/base/text-generation/types' -import { cloneDeep, noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' +import { cloneDeep } from 'es-toolkit/object' import { memo } from 'react' import TextGeneration from '@/app/components/app/text-generate/item' import { TransferMethod } from '@/app/components/base/chat/types' diff --git a/web/app/components/app/configuration/debug/hooks.tsx b/web/app/components/app/configuration/debug/hooks.tsx index e66185e284..e5dba3640d 100644 --- a/web/app/components/app/configuration/debug/hooks.tsx +++ b/web/app/components/app/configuration/debug/hooks.tsx @@ -6,7 +6,7 @@ import type { ChatConfig, ChatItem, } from '@/app/components/base/chat/types' -import { cloneDeep } from 'es-toolkit/compat' +import { cloneDeep } from 'es-toolkit/object' import { useCallback, useRef, diff --git a/web/app/components/app/configuration/debug/index.tsx b/web/app/components/app/configuration/debug/index.tsx index 7144d38470..b97bd68c5d 100644 --- a/web/app/components/app/configuration/debug/index.tsx +++ b/web/app/components/app/configuration/debug/index.tsx @@ -11,7 +11,8 @@ import { RiSparklingFill, } from '@remixicon/react' import { useBoolean } from 'ahooks' -import { cloneDeep, noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' +import { cloneDeep } from 'es-toolkit/object' import { produce, setAutoFreeze } from 'immer' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' diff --git a/web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts b/web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts index 3e8f7c5b3a..55b44653c9 100644 --- a/web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts +++ b/web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts @@ -1,6 +1,6 @@ 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 { clone } from 'es-toolkit/object' import { produce } from 'immer' import { useState } from 'react' import { checkHasContextBlock, checkHasHistoryBlock, checkHasQueryBlock, PRE_PROMPT_PLACEHOLDER_TEXT } from '@/app/components/base/prompt-editor/constants' diff --git a/web/app/components/app/configuration/index.tsx b/web/app/components/app/configuration/index.tsx index 8a53d9b328..919b7c355a 100644 --- a/web/app/components/app/configuration/index.tsx +++ b/web/app/components/app/configuration/index.tsx @@ -20,7 +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 { clone } from 'es-toolkit/object' +import { isEqual } from 'es-toolkit/predicate' import { produce } from 'immer' import { usePathname } from 'next/navigation' import * as React from 'react' diff --git a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx index 57145cc223..71827c4e0d 100644 --- a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx +++ b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx @@ -3,7 +3,7 @@ import type { CodeBasedExtensionItem, ExternalDataTool, } from '@/models/common' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index 05873b85a7..838e9cc03f 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -3,7 +3,7 @@ import type { MouseEventHandler } from 'react' import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react' import { useDebounceFn, useKeyPress } from 'ahooks' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useRouter } from 'next/navigation' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/app/duplicate-modal/index.tsx b/web/app/components/app/duplicate-modal/index.tsx index 315d871178..7d5b122f69 100644 --- a/web/app/components/app/duplicate-modal/index.tsx +++ b/web/app/components/app/duplicate-modal/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { AppIconType } from '@/types/app' import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/app/log/index.tsx b/web/app/components/app/log/index.tsx index d282d61d4e..e96c9ce0c9 100644 --- a/web/app/components/app/log/index.tsx +++ b/web/app/components/app/log/index.tsx @@ -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 'es-toolkit/compat' +import { omit } from 'es-toolkit/object' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index c260568582..a17177bf7e 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -12,7 +12,8 @@ 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 'es-toolkit/compat' +import { get } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' diff --git a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx index 5cffa1143b..17857ec702 100644 --- a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx +++ b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx @@ -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 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { defaultPlan } from '@/app/components/billing/config' import { useModalContext as actualUseModalContext } from '@/context/modal-context' diff --git a/web/app/components/app/switch-app-modal/index.tsx b/web/app/components/app/switch-app-modal/index.tsx index 259f8da0b0..30d7877ed0 100644 --- a/web/app/components/app/switch-app-modal/index.tsx +++ b/web/app/components/app/switch-app-modal/index.tsx @@ -2,7 +2,7 @@ import type { App } from '@/types/app' import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/app/workflow-log/index.tsx b/web/app/components/app/workflow-log/index.tsx index ae7b08320e..79ae2ed83c 100644 --- a/web/app/components/app/workflow-log/index.tsx +++ b/web/app/components/app/workflow-log/index.tsx @@ -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 'es-toolkit/compat' +import { omit } from 'es-toolkit/object' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/base/agent-log-modal/detail.tsx b/web/app/components/base/agent-log-modal/detail.tsx index fff2c3920d..a82a3207b1 100644 --- a/web/app/components/base/agent-log-modal/detail.tsx +++ b/web/app/components/base/agent-log-modal/detail.tsx @@ -2,7 +2,8 @@ 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 'es-toolkit/compat' +import { uniq } from 'es-toolkit/array' +import { flatten } from 'es-toolkit/compat' import * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/base/app-icon-picker/index.tsx b/web/app/components/base/app-icon-picker/index.tsx index 9b9c642d51..4dfad1f6eb 100644 --- a/web/app/components/base/app-icon-picker/index.tsx +++ b/web/app/components/base/app-icon-picker/index.tsx @@ -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 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config' diff --git a/web/app/components/base/chat/chat-with-history/context.tsx b/web/app/components/base/chat/chat-with-history/context.tsx index d1496f8278..49dd06ca52 100644 --- a/web/app/components/base/chat/chat-with-history/context.tsx +++ b/web/app/components/base/chat/chat-with-history/context.tsx @@ -14,7 +14,7 @@ import type { AppMeta, ConversationItem, } from '@/models/share' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { createContext, useContext } from 'use-context-selector' export type ChatWithHistoryContextValue = { diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index 154729ded0..5ff8e61ff6 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -10,7 +10,7 @@ import type { ConversationItem, } from '@/models/share' import { useLocalStorageState } from 'ahooks' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { produce } from 'immer' import { useCallback, diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index 50704d9a0d..9b8a9b11dc 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/web/app/components/base/chat/chat/hooks.ts @@ -8,7 +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 { uniqBy } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { produce, setAutoFreeze } from 'immer' import { useParams, usePathname } from 'next/navigation' import { diff --git a/web/app/components/base/chat/embedded-chatbot/context.tsx b/web/app/components/base/chat/embedded-chatbot/context.tsx index 97d3dd53cf..d690c28dd3 100644 --- a/web/app/components/base/chat/embedded-chatbot/context.tsx +++ b/web/app/components/base/chat/embedded-chatbot/context.tsx @@ -13,7 +13,7 @@ import type { AppMeta, ConversationItem, } from '@/models/share' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { createContext, useContext } from 'use-context-selector' export type EmbeddedChatbotContextValue = { diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.tsx index 7a7cf4ffd3..803e905837 100644 --- a/web/app/components/base/chat/embedded-chatbot/hooks.tsx +++ b/web/app/components/base/chat/embedded-chatbot/hooks.tsx @@ -9,7 +9,7 @@ import type { ConversationItem, } from '@/models/share' import { useLocalStorageState } from 'ahooks' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { produce } from 'immer' import { useCallback, diff --git a/web/app/components/base/emoji-picker/index.tsx b/web/app/components/base/emoji-picker/index.tsx index 99e1cfb7b4..9356efbfeb 100644 --- a/web/app/components/base/emoji-picker/index.tsx +++ b/web/app/components/base/emoji-picker/index.tsx @@ -1,6 +1,6 @@ 'use client' import type { FC } from 'react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx index 6eb31d7476..79520134a4 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx @@ -3,7 +3,7 @@ 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 { noop } from 'es-toolkit/function' import { produce } from 'immer' import * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' diff --git a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx index c352913e30..59b62d0bfd 100644 --- a/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx @@ -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 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' diff --git a/web/app/components/base/file-uploader/hooks.ts b/web/app/components/base/file-uploader/hooks.ts index 95a775f3bb..14e46548d8 100644 --- a/web/app/components/base/file-uploader/hooks.ts +++ b/web/app/components/base/file-uploader/hooks.ts @@ -2,7 +2,7 @@ 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 { noop } from 'es-toolkit/function' import { produce } from 'immer' import { useParams } from 'next/navigation' import { diff --git a/web/app/components/base/file-uploader/pdf-preview.tsx b/web/app/components/base/file-uploader/pdf-preview.tsx index 25644d024e..aab8bcd9d1 100644 --- a/web/app/components/base/file-uploader/pdf-preview.tsx +++ b/web/app/components/base/file-uploader/pdf-preview.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react' import { RiCloseLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { t } from 'i18next' import * as React from 'react' import { useState } from 'react' diff --git a/web/app/components/base/file-uploader/store.tsx b/web/app/components/base/file-uploader/store.tsx index 2172733f20..24015df5cf 100644 --- a/web/app/components/base/file-uploader/store.tsx +++ b/web/app/components/base/file-uploader/store.tsx @@ -1,7 +1,7 @@ import type { FileEntity, } from './types' -import { isEqual } from 'es-toolkit/compat' +import { isEqual } from 'es-toolkit/predicate' import { createContext, useContext, diff --git a/web/app/components/base/fullscreen-modal/index.tsx b/web/app/components/base/fullscreen-modal/index.tsx index cad91b2452..fb2d2fa79b 100644 --- a/web/app/components/base/fullscreen-modal/index.tsx +++ b/web/app/components/base/fullscreen-modal/index.tsx @@ -1,6 +1,6 @@ import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react' import { RiCloseLargeLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { cn } from '@/utils/classnames' type IModal = { diff --git a/web/app/components/base/image-uploader/image-preview.tsx b/web/app/components/base/image-uploader/image-preview.tsx index 794152804e..b6a07c60aa 100644 --- a/web/app/components/base/image-uploader/image-preview.tsx +++ b/web/app/components/base/image-uploader/image-preview.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react' import { RiAddBoxLine, RiCloseLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { t } from 'i18next' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' diff --git a/web/app/components/base/input/index.tsx b/web/app/components/base/input/index.tsx index b529702a65..27adbb3973 100644 --- a/web/app/components/base/input/index.tsx +++ b/web/app/components/base/input/index.tsx @@ -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 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useTranslation } from 'react-i18next' import { cn } from '@/utils/classnames' diff --git a/web/app/components/base/modal/index.tsx b/web/app/components/base/modal/index.tsx index 7270af1c77..e38254b27a 100644 --- a/web/app/components/base/modal/index.tsx +++ b/web/app/components/base/modal/index.tsx @@ -1,6 +1,6 @@ import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react' import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { Fragment } from 'react' import { cn } from '@/utils/classnames' // https://headlessui.com/react/dialog diff --git a/web/app/components/base/modal/modal.tsx b/web/app/components/base/modal/modal.tsx index 1e606f125a..a24ca1765e 100644 --- a/web/app/components/base/modal/modal.tsx +++ b/web/app/components/base/modal/modal.tsx @@ -1,6 +1,6 @@ import type { ButtonProps } from '@/app/components/base/button' import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { memo } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' diff --git a/web/app/components/base/pagination/pagination.tsx b/web/app/components/base/pagination/pagination.tsx index dafe0e4ab9..0eb06b594c 100644 --- a/web/app/components/base/pagination/pagination.tsx +++ b/web/app/components/base/pagination/pagination.tsx @@ -4,7 +4,7 @@ import type { IPaginationProps, PageButtonProps, } from './type' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { cn } from '@/utils/classnames' import usePagination from './hook' diff --git a/web/app/components/base/prompt-editor/plugins/context-block/context-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/context-block/context-block-replacement-block.tsx index c4a246c40d..36e0d7f17b 100644 --- a/web/app/components/base/prompt-editor/plugins/context-block/context-block-replacement-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/context-block/context-block-replacement-block.tsx @@ -1,7 +1,7 @@ import type { ContextBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { $applyNodeReplacement } from 'lexical' import { memo, diff --git a/web/app/components/base/prompt-editor/plugins/context-block/index.tsx b/web/app/components/base/prompt-editor/plugins/context-block/index.tsx index ce3ed4c210..e3382c011d 100644 --- a/web/app/components/base/prompt-editor/plugins/context-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/context-block/index.tsx @@ -1,7 +1,7 @@ import type { ContextBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { $insertNodes, COMMAND_PRIORITY_EDITOR, diff --git a/web/app/components/base/prompt-editor/plugins/history-block/history-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/history-block/history-block-replacement-block.tsx index f62fb6886b..759e654c02 100644 --- a/web/app/components/base/prompt-editor/plugins/history-block/history-block-replacement-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/history-block/history-block-replacement-block.tsx @@ -1,7 +1,7 @@ import type { HistoryBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { $applyNodeReplacement } from 'lexical' import { useCallback, diff --git a/web/app/components/base/prompt-editor/plugins/history-block/index.tsx b/web/app/components/base/prompt-editor/plugins/history-block/index.tsx index dc75fc230d..a1d788c8cd 100644 --- a/web/app/components/base/prompt-editor/plugins/history-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/history-block/index.tsx @@ -1,7 +1,7 @@ import type { HistoryBlockType } from '../../types' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { mergeRegister } from '@lexical/utils' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { $insertNodes, COMMAND_PRIORITY_EDITOR, diff --git a/web/app/components/base/radio-card/index.tsx b/web/app/components/base/radio-card/index.tsx index 678d5b6dee..c349d08a96 100644 --- a/web/app/components/base/radio-card/index.tsx +++ b/web/app/components/base/radio-card/index.tsx @@ -1,6 +1,6 @@ 'use client' import type { FC } from 'react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { cn } from '@/utils/classnames' diff --git a/web/app/components/base/tag-management/panel.tsx b/web/app/components/base/tag-management/panel.tsx index 50f9298963..adf750580f 100644 --- a/web/app/components/base/tag-management/panel.tsx +++ b/web/app/components/base/tag-management/panel.tsx @@ -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 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/base/tag-management/tag-remove-modal.tsx b/web/app/components/base/tag-management/tag-remove-modal.tsx index a0fdf056d5..6ce52e9dd6 100644 --- a/web/app/components/base/tag-management/tag-remove-modal.tsx +++ b/web/app/components/base/tag-management/tag-remove-modal.tsx @@ -2,7 +2,7 @@ import type { Tag } from '@/app/components/base/tag-management/constant' import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' diff --git a/web/app/components/base/toast/index.spec.tsx b/web/app/components/base/toast/index.spec.tsx index 59314063dd..cc5a1e7c6d 100644 --- a/web/app/components/base/toast/index.spec.tsx +++ b/web/app/components/base/toast/index.spec.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react' import { act, render, screen, waitFor } from '@testing-library/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import Toast, { ToastProvider, useToastContext } from '.' diff --git a/web/app/components/base/toast/index.tsx b/web/app/components/base/toast/index.tsx index cf9e1cd909..0ab636efc1 100644 --- a/web/app/components/base/toast/index.tsx +++ b/web/app/components/base/toast/index.tsx @@ -7,7 +7,7 @@ import { RiErrorWarningFill, RiInformation2Fill, } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useEffect, useState } from 'react' import { createRoot } from 'react-dom/client' diff --git a/web/app/components/base/with-input-validation/index.spec.tsx b/web/app/components/base/with-input-validation/index.spec.tsx index b2e67ce056..daf3fd9a74 100644 --- a/web/app/components/base/with-input-validation/index.spec.tsx +++ b/web/app/components/base/with-input-validation/index.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { z } from 'zod' import withValidation from '.' diff --git a/web/app/components/datasets/common/document-status-with-action/index-failed.tsx b/web/app/components/datasets/common/document-status-with-action/index-failed.tsx index a97b74cf2e..b691f4e8c5 100644 --- a/web/app/components/datasets/common/document-status-with-action/index-failed.tsx +++ b/web/app/components/datasets/common/document-status-with-action/index-failed.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' import type { IndexingStatusResponse } from '@/models/datasets' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useEffect, useReducer } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx index 38481f757f..2d187010b8 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx @@ -1,6 +1,6 @@ 'use client' import { useDebounceFn, useKeyPress } from 'ahooks' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useRouter } from 'next/navigation' import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/datasets/create/step-two/index.tsx b/web/app/components/datasets/create/step-two/index.tsx index ecc517ed48..51b5c15178 100644 --- a/web/app/components/datasets/create/step-two/index.tsx +++ b/web/app/components/datasets/create/step-two/index.tsx @@ -9,7 +9,7 @@ import { RiArrowLeftLine, RiSearchEyeLine, } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import Image from 'next/image' import Link from 'next/link' import { useCallback, useEffect, useMemo, useState } from 'react' diff --git a/web/app/components/datasets/documents/detail/batch-modal/index.tsx b/web/app/components/datasets/documents/detail/batch-modal/index.tsx index 080b631bae..8917d85ee7 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/index.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/index.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import type { ChunkingMode, FileItem } from '@/models/datasets' import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.tsx b/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.tsx index 73b5cbdef9..de817b8d07 100644 --- a/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.tsx @@ -1,4 +1,4 @@ -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { cn } from '@/utils/classnames' import Drawer from './drawer' diff --git a/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx b/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx index 11f8b9fc65..23c1e826b7 100644 --- a/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react' import { RiLoader2Line } from '@remixicon/react' import { useCountDown } from 'ahooks' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/datasets/documents/detail/completed/index.tsx b/web/app/components/datasets/documents/detail/completed/index.tsx index 531603a1cf..40c70e34f6 100644 --- a/web/app/components/datasets/documents/detail/completed/index.tsx +++ b/web/app/components/datasets/documents/detail/completed/index.tsx @@ -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 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { usePathname } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx index 76c9e2da96..08e13765e5 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx @@ -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 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useRouter } from 'next/navigation' import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/datasets/documents/list.tsx b/web/app/components/datasets/documents/list.tsx index 0e039bba1a..5fd6cd3a70 100644 --- a/web/app/components/datasets/documents/list.tsx +++ b/web/app/components/datasets/documents/list.tsx @@ -9,7 +9,8 @@ import { RiGlobalLine, } from '@remixicon/react' import { useBoolean } from 'ahooks' -import { pick, uniq } from 'es-toolkit/compat' +import { uniq } from 'es-toolkit/array' +import { pick } from 'es-toolkit/object' import { useRouter } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' diff --git a/web/app/components/datasets/documents/operations.tsx b/web/app/components/datasets/documents/operations.tsx index a873fcd96d..93afec6f8e 100644 --- a/web/app/components/datasets/documents/operations.tsx +++ b/web/app/components/datasets/documents/operations.tsx @@ -11,7 +11,7 @@ import { RiPlayCircleLine, } from '@remixicon/react' import { useBoolean, useDebounceFn } from 'ahooks' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useRouter } from 'next/navigation' import * as React from 'react' import { useCallback, useState } from 'react' diff --git a/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx b/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx index b130267236..ee1b9cbcdc 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' import { RiArrowLeftLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/datasets/rename-modal/index.tsx b/web/app/components/datasets/rename-modal/index.tsx index 589b5a918e..106e35c4ac 100644 --- a/web/app/components/datasets/rename-modal/index.tsx +++ b/web/app/components/datasets/rename-modal/index.tsx @@ -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 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' diff --git a/web/app/components/explore/create-app-modal/index.tsx b/web/app/components/explore/create-app-modal/index.tsx index 1137ae9e74..9bffcc6c69 100644 --- a/web/app/components/explore/create-app-modal/index.tsx +++ b/web/app/components/explore/create-app-modal/index.tsx @@ -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 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/header/account-setting/api-based-extension-page/modal.tsx b/web/app/components/header/account-setting/api-based-extension-page/modal.tsx index e12ecbe756..d857b5fe1f 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/modal.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/modal.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react' import type { ApiBasedExtension } from '@/models/common' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' diff --git a/web/app/components/header/account-setting/data-source-page/data-source-notion/index.tsx b/web/app/components/header/account-setting/data-source-page/data-source-notion/index.tsx index 8f6120c826..0959383f29 100644 --- a/web/app/components/header/account-setting/data-source-page/data-source-notion/index.tsx +++ b/web/app/components/header/account-setting/data-source-page/data-source-notion/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' import type { DataSourceNotion as TDataSourceNotion } from '@/models/common' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/header/account-setting/data-source-page/panel/config-item.tsx b/web/app/components/header/account-setting/data-source-page/panel/config-item.tsx index 06c311ef56..f62c5e147d 100644 --- a/web/app/components/header/account-setting/data-source-page/panel/config-item.tsx +++ b/web/app/components/header/account-setting/data-source-page/panel/config-item.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import { RiDeleteBinLine, } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useTranslation } from 'react-i18next' import { cn } from '@/utils/classnames' diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx index 411c437a75..76f04382bd 100644 --- a/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx @@ -1,6 +1,6 @@ 'use client' import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' diff --git a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx index 964d25e1cb..2d8d138af5 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx @@ -3,7 +3,7 @@ import type { RoleKey } from './role-selector' import type { InvitationResult } from '@/models/common' import { RiCloseLine, RiErrorWarningFill } from '@remixicon/react' import { useBoolean } from 'ahooks' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { ReactMultiEmail } from 'react-multi-email' diff --git a/web/app/components/header/account-setting/members-page/invited-modal/index.tsx b/web/app/components/header/account-setting/members-page/invited-modal/index.tsx index 3f756ab10d..389db4a42d 100644 --- a/web/app/components/header/account-setting/members-page/invited-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/invited-modal/index.tsx @@ -2,7 +2,7 @@ import type { InvitationResult } from '@/models/common' import { XMarkIcon } from '@heroicons/react/24/outline' import { CheckCircleIcon } from '@heroicons/react/24/solid' import { RiQuestionLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx index 1d54167458..be7220da5e 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx @@ -1,5 +1,5 @@ import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useState } from 'react' import { Trans, useTranslation } from 'react-i18next' diff --git a/web/app/components/header/account-setting/menu-dialog.tsx b/web/app/components/header/account-setting/menu-dialog.tsx index cc5adbc18f..847634ed83 100644 --- a/web/app/components/header/account-setting/menu-dialog.tsx +++ b/web/app/components/header/account-setting/menu-dialog.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react' import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { Fragment, useCallback, useEffect } from 'react' import { cn } from '@/utils/classnames' diff --git a/web/app/components/header/app-selector/index.tsx b/web/app/components/header/app-selector/index.tsx index c1b9cd1b83..13677ef7ab 100644 --- a/web/app/components/header/app-selector/index.tsx +++ b/web/app/components/header/app-selector/index.tsx @@ -2,7 +2,7 @@ import type { AppDetailResponse } from '@/models/app' import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react' import { ChevronDownIcon, PlusIcon } from '@heroicons/react/24/solid' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useRouter } from 'next/navigation' import { Fragment, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/plugins/base/deprecation-notice.tsx b/web/app/components/plugins/base/deprecation-notice.tsx index 7e32133045..c2ddfa6975 100644 --- a/web/app/components/plugins/base/deprecation-notice.tsx +++ b/web/app/components/plugins/base/deprecation-notice.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react' import { RiAlertFill } from '@remixicon/react' -import { camelCase } from 'es-toolkit/compat' +import { camelCase } from 'es-toolkit/string' import Link from 'next/link' import * as React from 'react' import { useMemo } from 'react' diff --git a/web/app/components/plugins/marketplace/context.tsx b/web/app/components/plugins/marketplace/context.tsx index 97144630a6..31b6a7f592 100644 --- a/web/app/components/plugins/marketplace/context.tsx +++ b/web/app/components/plugins/marketplace/context.tsx @@ -11,7 +11,8 @@ import type { SearchParams, SearchParamsFromCollection, } from './types' -import { debounce, noop } from 'es-toolkit/compat' +import { debounce } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useCallback, useEffect, diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx index 18896b1f50..a4093ed00b 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx @@ -2,7 +2,7 @@ import type { FormRefObject, FormSchema } from '@/app/components/base/form/types' import type { ParametersSchema, PluginDetail } from '@/app/components/plugins/types' import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' -import { isEqual } from 'es-toolkit/compat' +import { isEqual } from 'es-toolkit/predicate' import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { EncryptedBottom } from '@/app/components/base/encrypted-bottom' diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx index 75ffff781f..262235e6ed 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx @@ -2,7 +2,7 @@ import type { FormRefObject, FormSchema } from '@/app/components/base/form/types' import type { ParametersSchema, PluginDetail } from '@/app/components/plugins/types' import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' -import { isEqual } from 'es-toolkit/compat' +import { isEqual } from 'es-toolkit/predicate' import { useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { BaseForm } from '@/app/components/base/form/components/base' diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx index 3332cd6b03..e57b9c0151 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx @@ -2,7 +2,7 @@ import type { FormRefObject, FormSchema } from '@/app/components/base/form/types' import type { ParametersSchema, PluginDetail } from '@/app/components/plugins/types' import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' -import { isEqual } from 'es-toolkit/compat' +import { isEqual } from 'es-toolkit/predicate' import { useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { BaseForm } from '@/app/components/base/form/components/base' diff --git a/web/app/components/plugins/plugin-page/context.tsx b/web/app/components/plugins/plugin-page/context.tsx index 3d420ca1ab..fea78ae181 100644 --- a/web/app/components/plugins/plugin-page/context.tsx +++ b/web/app/components/plugins/plugin-page/context.tsx @@ -2,7 +2,7 @@ import type { ReactNode, RefObject } from 'react' import type { FilterState } from './filter-management' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useQueryState } from 'nuqs' import { useMemo, diff --git a/web/app/components/plugins/plugin-page/empty/index.tsx b/web/app/components/plugins/plugin-page/empty/index.tsx index 019dc9ec24..7149423d5f 100644 --- a/web/app/components/plugins/plugin-page/empty/index.tsx +++ b/web/app/components/plugins/plugin-page/empty/index.tsx @@ -1,5 +1,5 @@ 'use client' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index 6d8542f5c9..b8fc891254 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -7,7 +7,7 @@ import { RiEqualizer2Line, } from '@remixicon/react' import { useBoolean } from 'ahooks' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx index 7dbd3e3026..322591a363 100644 --- a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx +++ b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx @@ -1,7 +1,7 @@ 'use client' import { RiAddLine, RiArrowDownSLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/field-list-container.tsx b/web/app/components/rag-pipeline/components/panel/input-field/field-list/field-list-container.tsx index 108f3a642f..a14af1f704 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/field-list/field-list-container.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/field-list-container.tsx @@ -1,6 +1,6 @@ import type { SortableItem } from './types' import type { InputVar } from '@/models/pipeline' -import { isEqual } from 'es-toolkit/compat' +import { isEqual } from 'es-toolkit/predicate' import { memo, useCallback, diff --git a/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx b/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx index 4c607810bb..8d8c7f1088 100644 --- a/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx +++ b/web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx @@ -2,7 +2,7 @@ import type { AppIconSelection } from '@/app/components/base/app-icon-picker' import type { IconInfo } from '@/models/datasets' import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' diff --git a/web/app/components/tools/labels/selector.tsx b/web/app/components/tools/labels/selector.tsx index f95ebed72e..08c1216700 100644 --- a/web/app/components/tools/labels/selector.tsx +++ b/web/app/components/tools/labels/selector.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import type { Label } from '@/app/components/tools/labels/constant' import { RiArrowDownSLine } from '@remixicon/react' import { useDebounceFn } from 'ahooks' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' diff --git a/web/app/components/tools/mcp/modal.tsx b/web/app/components/tools/mcp/modal.tsx index 9bf4b351b4..413a2d3948 100644 --- a/web/app/components/tools/mcp/modal.tsx +++ b/web/app/components/tools/mcp/modal.tsx @@ -5,7 +5,7 @@ import type { ToolWithProvider } from '@/app/components/workflow/types' import type { AppIconType } from '@/types/app' import { RiCloseLine, RiEditLine } from '@remixicon/react' import { useHover } from 'ahooks' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/tools/setting/build-in/config-credentials.tsx b/web/app/components/tools/setting/build-in/config-credentials.tsx index cb11c5cf16..863b3ba352 100644 --- a/web/app/components/tools/setting/build-in/config-credentials.tsx +++ b/web/app/components/tools/setting/build-in/config-credentials.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' import type { Collection } from '../../types' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/tools/workflow-tool/confirm-modal/index.tsx b/web/app/components/tools/workflow-tool/confirm-modal/index.tsx index 2abee055bb..0c7e083a56 100644 --- a/web/app/components/tools/workflow-tool/confirm-modal/index.tsx +++ b/web/app/components/tools/workflow-tool/confirm-modal/index.tsx @@ -1,7 +1,7 @@ 'use client' import { RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' diff --git a/web/app/components/workflow-app/hooks/use-workflow-run.ts b/web/app/components/workflow-app/hooks/use-workflow-run.ts index 031e2949cb..49c5d20dde 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-run.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-run.ts @@ -2,7 +2,7 @@ import type AudioPlayer from '@/app/components/base/audio-btn/audio' import type { Node } from '@/app/components/workflow/types' import type { IOtherOptions } from '@/service/base' import type { VersionHistory } from '@/types/workflow' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { produce } from 'immer' import { usePathname } from 'next/navigation' import { useCallback, useRef } from 'react' diff --git a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx b/web/app/components/workflow/block-selector/market-place-plugin/list.tsx index 9927df500d..29f1e77e14 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/list.tsx @@ -2,7 +2,7 @@ import type { RefObject } from 'react' import type { Plugin, PluginCategoryEnum } from '@/app/components/plugins/types' import { RiArrowRightUpLine, RiSearchLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import Link from 'next/link' import { useEffect, useImperativeHandle, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/workflow/custom-edge.tsx b/web/app/components/workflow/custom-edge.tsx index 6427520d81..0440ca0c3e 100644 --- a/web/app/components/workflow/custom-edge.tsx +++ b/web/app/components/workflow/custom-edge.tsx @@ -3,7 +3,7 @@ import type { Edge, OnSelectBlock, } from './types' -import { intersection } from 'es-toolkit/compat' +import { intersection } from 'es-toolkit/array' import { memo, useCallback, diff --git a/web/app/components/workflow/dsl-export-confirm-modal.tsx b/web/app/components/workflow/dsl-export-confirm-modal.tsx index 72f8c5857d..e698de722e 100644 --- a/web/app/components/workflow/dsl-export-confirm-modal.tsx +++ b/web/app/components/workflow/dsl-export-confirm-modal.tsx @@ -1,7 +1,7 @@ 'use client' import type { EnvironmentVariable } from '@/app/components/workflow/types' import { RiCloseLine, RiLock2Line } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/workflow/hooks-store/store.ts b/web/app/components/workflow/hooks-store/store.ts index 3aa4ba3d91..44014fc0d7 100644 --- a/web/app/components/workflow/hooks-store/store.ts +++ b/web/app/components/workflow/hooks-store/store.ts @@ -10,9 +10,7 @@ import type { IOtherOptions } from '@/service/base' import type { SchemaTypeDefinition } from '@/service/use-common' import type { FlowType } from '@/types/common' import type { VarInInspect } from '@/types/workflow' -import { - noop, -} from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useContext } from 'react' import { useStore as useZustandStore, diff --git a/web/app/components/workflow/hooks/use-nodes-layout.ts b/web/app/components/workflow/hooks/use-nodes-layout.ts index 2ad89ff100..0738703791 100644 --- a/web/app/components/workflow/hooks/use-nodes-layout.ts +++ b/web/app/components/workflow/hooks/use-nodes-layout.ts @@ -3,7 +3,7 @@ import type { Node, } from '../types' import ELK from 'elkjs/lib/elk.bundled.js' -import { cloneDeep } from 'es-toolkit/compat' +import { cloneDeep } from 'es-toolkit/object' import { useCallback } from 'react' import { useReactFlow, diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 8eeab43d7e..1543bce714 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -13,7 +13,7 @@ import type { VarInInspect } from '@/types/workflow' import { useEventListener, } from 'ahooks' -import { isEqual } from 'es-toolkit/compat' +import { isEqual } from 'es-toolkit/predicate' import { setAutoFreeze } from 'immer' import dynamic from 'next/dynamic' import { diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx index 8ea786ffb4..cc58176fb6 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx @@ -4,7 +4,7 @@ import type { NodeOutPutVar } from '../../../types' import type { ToolVarInputs } from '../../tool/types' import type { CredentialFormSchema, CredentialFormSchemaNumberInput, CredentialFormSchemaTextInput } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { PluginMeta } from '@/app/components/plugins/types' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import Link from 'next/link' import { memo } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx b/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx index ad5410986c..4714139541 100644 --- a/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' import Editor, { loader } from '@monaco-editor/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useEffect, useMemo, useRef, useState } from 'react' import { diff --git a/web/app/components/workflow/nodes/_base/components/file-type-item.tsx b/web/app/components/workflow/nodes/_base/components/file-type-item.tsx index cef2f76da6..aa73260af6 100644 --- a/web/app/components/workflow/nodes/_base/components/file-type-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/file-type-item.tsx @@ -1,6 +1,6 @@ 'use client' import type { FC } from 'react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/workflow/nodes/_base/components/input-support-select-var.tsx b/web/app/components/workflow/nodes/_base/components/input-support-select-var.tsx index 5a1c467be5..8880aedf80 100644 --- a/web/app/components/workflow/nodes/_base/components/input-support-select-var.tsx +++ b/web/app/components/workflow/nodes/_base/components/input-support-select-var.tsx @@ -5,7 +5,7 @@ import type { NodeOutPutVar, } from '@/app/components/workflow/types' import { useBoolean } from 'ahooks' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/workflow/nodes/_base/components/next-step/index.tsx b/web/app/components/workflow/nodes/_base/components/next-step/index.tsx index 686c2a17c6..0b22d44aac 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/index.tsx @@ -1,7 +1,7 @@ import type { Node, } from '../../../../types' -import { isEqual } from 'es-toolkit/compat' +import { isEqual } from 'es-toolkit/predicate' import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { diff --git a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx index 9b4de96f9f..8484028868 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx @@ -3,7 +3,7 @@ import type { OnSelectBlock, } from '@/app/components/workflow/types' import { RiMoreFill } from '@remixicon/react' -import { intersection } from 'es-toolkit/compat' +import { intersection } from 'es-toolkit/array' import { useCallback, } from 'react' diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx index 6652ce58ec..32f9e9a174 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/change-block.tsx @@ -2,7 +2,7 @@ import type { Node, OnSelectBlock, } from '@/app/components/workflow/types' -import { intersection } from 'es-toolkit/compat' +import { intersection } from 'es-toolkit/array' import { memo, useCallback, diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index 9f033f9b27..9f77be0ce2 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -33,7 +33,8 @@ import type { import type { PromptItem } from '@/models/debug' import type { RAGPipelineVariable } from '@/models/pipeline' import type { SchemaTypeDefinition } from '@/service/use-common' -import { isArray, uniq } from 'es-toolkit/compat' +import { uniq } from 'es-toolkit/array' +import { isArray } from 'es-toolkit/compat' import { produce } from 'immer' import { AGENT_OUTPUT_STRUCT, diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx index 86ec5c3c09..6dfcbaf4d8 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx @@ -11,7 +11,7 @@ import { RiLoader4Line, RiMoreLine, } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { produce } from 'immer' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index a8c1073d93..d44f560e08 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -4,7 +4,7 @@ import type { StructuredOutput } from '../../../llm/types' import type { Field } from '@/app/components/workflow/nodes/llm/types' import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import { useHover } from 'ahooks' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx b/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx index 828f7e5ebe..55d6a14d37 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx @@ -3,7 +3,7 @@ import { RiErrorWarningFill, RiMoreLine, } from '@remixicon/react' -import { capitalize } from 'es-toolkit/compat' +import { capitalize } from 'es-toolkit/string' import { memo } from 'react' import Tooltip from '@/app/components/base/tooltip' import { cn } from '@/utils/classnames' diff --git a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts index d8fd4d2c57..d2d7b6b6d9 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts @@ -1,7 +1,8 @@ import type { CommonNodeType, InputVar, TriggerNodeType, ValueSelector, Var, Variable } from '@/app/components/workflow/types' import type { FlowType } from '@/types/common' import type { NodeRunResult, NodeTracing } from '@/types/workflow' -import { noop, unionBy } from 'es-toolkit/compat' +import { unionBy } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { produce } from 'immer' import { useCallback, useEffect, useRef, useState } from 'react' diff --git a/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx b/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx index 0fd735abbb..202d7469aa 100644 --- a/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx +++ b/web/app/components/workflow/nodes/assigner/components/var-list/index.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import type { AssignerNodeOperation } from '../../types' import type { ValueSelector, Var } from '@/app/components/workflow/types' import { RiDeleteBinLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { produce } from 'immer' import * as React from 'react' import { useCallback } from 'react' diff --git a/web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx b/web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx index ee81d70106..ed538618e3 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx @@ -4,7 +4,7 @@ import type { } from '@/app/components/workflow/types' import { RiArrowDownSLine } from '@remixicon/react' import { useBoolean } from 'ahooks' -import { capitalize } from 'es-toolkit/compat' +import { capitalize } from 'es-toolkit/string' import { memo, useCallback, diff --git a/web/app/components/workflow/nodes/if-else/components/condition-wrap.tsx b/web/app/components/workflow/nodes/if-else/components/condition-wrap.tsx index 5554696480..7ac76dd936 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-wrap.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-wrap.tsx @@ -7,7 +7,7 @@ import { RiDeleteBinLine, RiDraggable, } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/workflow/nodes/iteration/use-config.ts b/web/app/components/workflow/nodes/iteration/use-config.ts index 3106577085..79df409474 100644 --- a/web/app/components/workflow/nodes/iteration/use-config.ts +++ b/web/app/components/workflow/nodes/iteration/use-config.ts @@ -2,7 +2,7 @@ import type { ErrorHandleMode, ValueSelector, Var } from '../../types' import type { IterationNodeType } from './types' import type { Item } from '@/app/components/base/select' import type { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' -import { isEqual } from 'es-toolkit/compat' +import { isEqual } from 'es-toolkit/predicate' import { produce } from 'immer' import { useCallback } from 'react' import { diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-value-method.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-value-method.tsx index 574501a27d..08a316c82a 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-value-method.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-value-method.tsx @@ -1,5 +1,5 @@ import { RiArrowDownSLine } from '@remixicon/react' -import { capitalize } from 'es-toolkit/compat' +import { capitalize } from 'es-toolkit/string' import { useState } from 'react' import Button from '@/app/components/base/button' import { diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx index 3394c1f7a7..88b7ff303c 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx @@ -1,5 +1,5 @@ import type { MetadataShape } from '@/app/components/workflow/nodes/knowledge-retrieval/types' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useCallback, useState, diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts b/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts index 5208200b25..a1662a1e1c 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts @@ -9,7 +9,7 @@ import type { MultipleRetrievalConfig, } from './types' import type { DataSet } from '@/models/datasets' -import { isEqual } from 'es-toolkit/compat' +import { isEqual } from 'es-toolkit/predicate' import { produce } from 'immer' import { useCallback, diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/utils.ts b/web/app/components/workflow/nodes/knowledge-retrieval/utils.ts index d6cd69b39a..a30ec8e735 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/utils.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/utils.ts @@ -3,10 +3,8 @@ import type { DataSet, SelectedDatasetsMode, } from '@/models/datasets' -import { - uniq, - xorBy, -} from 'es-toolkit/compat' +import { uniq } from 'es-toolkit/array' +import { xorBy } from 'es-toolkit/compat' import { DATASET_DEFAULT } from '@/config' import { DEFAULT_WEIGHTED_SCORE, diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context.tsx index 557cddfb61..82a3c27a98 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context.tsx @@ -1,4 +1,4 @@ -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { createContext, useContext, diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts index 1673c80f4f..6159028c21 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts @@ -1,7 +1,7 @@ import type { VisualEditorProps } from '.' import type { Field } from '../../../types' import type { EditData } from './edit-card' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { produce } from 'immer' import Toast from '@/app/components/base/toast' import { ArrayType, Type } from '../../../types' diff --git a/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts b/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts index 580a5d44ad..e0c4c97fad 100644 --- a/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts @@ -2,7 +2,7 @@ import type { RefObject } from 'react' import type { LLMNodeType } from './types' import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form/form' import type { InputVar, PromptItem, Var, Variable } from '@/app/components/workflow/types' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { InputVarType, VarType } from '@/app/components/workflow/types' diff --git a/web/app/components/workflow/nodes/loop/components/condition-number-input.tsx b/web/app/components/workflow/nodes/loop/components/condition-number-input.tsx index ee81d70106..ed538618e3 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-number-input.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-number-input.tsx @@ -4,7 +4,7 @@ import type { } from '@/app/components/workflow/types' import { RiArrowDownSLine } from '@remixicon/react' import { useBoolean } from 'ahooks' -import { capitalize } from 'es-toolkit/compat' +import { capitalize } from 'es-toolkit/string' import { memo, useCallback, diff --git a/web/app/components/workflow/nodes/parameter-extractor/use-single-run-form-params.ts b/web/app/components/workflow/nodes/parameter-extractor/use-single-run-form-params.ts index e736ee6e79..52a4462153 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/parameter-extractor/use-single-run-form-params.ts @@ -2,7 +2,7 @@ import type { RefObject } from 'react' import type { ParameterExtractorNodeType } from './types' import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form/form' import type { InputVar, Var, Variable } from '@/app/components/workflow/types' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { InputVarType, VarType } from '@/app/components/workflow/types' diff --git a/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx b/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx index 724de571b6..5e41b5ee1c 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import type { Topic } from '@/app/components/workflow/nodes/question-classifier/types' import type { ValueSelector, Var } from '@/app/components/workflow/types' import { RiDraggable } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { produce } from 'immer' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' diff --git a/web/app/components/workflow/nodes/question-classifier/use-single-run-form-params.ts b/web/app/components/workflow/nodes/question-classifier/use-single-run-form-params.ts index 2bea1f8318..9b4b05367e 100644 --- a/web/app/components/workflow/nodes/question-classifier/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/question-classifier/use-single-run-form-params.ts @@ -2,7 +2,7 @@ import type { RefObject } from 'react' import type { QuestionClassifierNodeType } from './types' import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form/form' import type { InputVar, Var, Variable } from '@/app/components/workflow/types' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { InputVarType, VarType } from '@/app/components/workflow/types' diff --git a/web/app/components/workflow/nodes/start/components/var-item.tsx b/web/app/components/workflow/nodes/start/components/var-item.tsx index fdb07d0be9..64fd1804b0 100644 --- a/web/app/components/workflow/nodes/start/components/var-item.tsx +++ b/web/app/components/workflow/nodes/start/components/var-item.tsx @@ -5,7 +5,7 @@ import { RiDeleteBinLine, } from '@remixicon/react' import { useBoolean, useHover } from 'ahooks' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import * as React from 'react' import { useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/workflow/nodes/tool/components/input-var-list.tsx b/web/app/components/workflow/nodes/tool/components/input-var-list.tsx index 9bd01ac74e..96a878b506 100644 --- a/web/app/components/workflow/nodes/tool/components/input-var-list.tsx +++ b/web/app/components/workflow/nodes/tool/components/input-var-list.tsx @@ -4,7 +4,7 @@ import type { ToolVarInputs } from '../types' import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { Tool } from '@/app/components/tools/types' import type { ToolWithProvider, ValueSelector, Var } from '@/app/components/workflow/types' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { produce } from 'immer' import * as React from 'react' import { useCallback, useState } from 'react' diff --git a/web/app/components/workflow/nodes/variable-assigner/components/var-list/index.tsx b/web/app/components/workflow/nodes/variable-assigner/components/var-list/index.tsx index c2f289b7d1..1f45a8a0e1 100644 --- a/web/app/components/workflow/nodes/variable-assigner/components/var-list/index.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/components/var-list/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' import type { ValueSelector, Var } from '@/app/components/workflow/types' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { produce } from 'immer' import * as React from 'react' import { useCallback } from 'react' diff --git a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx index 20027f37a1..b66827da45 100644 --- a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx +++ b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx @@ -11,7 +11,7 @@ import { RiLinkUnlinkM, } from '@remixicon/react' import { useClickAway } from 'ahooks' -import { escape } from 'es-toolkit/compat' +import { escape } from 'es-toolkit/string' import { memo, useEffect, diff --git a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/hooks.ts b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/hooks.ts index 5248680d89..3a084ef0af 100644 --- a/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/hooks.ts +++ b/web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/hooks.ts @@ -5,7 +5,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext import { mergeRegister, } from '@lexical/utils' -import { escape } from 'es-toolkit/compat' +import { escape } from 'es-toolkit/string' import { CLICK_COMMAND, COMMAND_PRIORITY_LOW, diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-item.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/variable-item.tsx index 9fec4f7d01..58548e3084 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/variable-item.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-item.tsx @@ -1,6 +1,6 @@ import type { ConversationVariable } from '@/app/components/workflow/types' import { RiDeleteBinLine, RiEditLine } from '@remixicon/react' -import { capitalize } from 'es-toolkit/compat' +import { capitalize } from 'es-toolkit/string' import { memo, useState } from 'react' import { BubbleX } from '@/app/components/base/icons/src/vender/line/others' import { cn } from '@/utils/classnames' diff --git a/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx b/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx index 4e9b72f6e5..0f6a4c081d 100644 --- a/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx @@ -5,7 +5,8 @@ import type { import { RiCloseLine } from '@remixicon/react' import { useMount } from 'ahooks' import copy from 'copy-to-clipboard' -import { capitalize, noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' +import { capitalize } from 'es-toolkit/string' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/workflow/panel/debug-and-preview/index.tsx b/web/app/components/workflow/panel/debug-and-preview/index.tsx index b42d4205fa..bc5116bc65 100644 --- a/web/app/components/workflow/panel/debug-and-preview/index.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/index.tsx @@ -1,7 +1,8 @@ import type { StartNodeType } from '../../nodes/start/types' import { RiCloseLine, RiEqualizer2Line } from '@remixicon/react' -import { debounce, noop } from 'es-toolkit/compat' +import { debounce } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { memo, useCallback, diff --git a/web/app/components/workflow/panel/env-panel/env-item.tsx b/web/app/components/workflow/panel/env-panel/env-item.tsx index 582539b85b..26696daad0 100644 --- a/web/app/components/workflow/panel/env-panel/env-item.tsx +++ b/web/app/components/workflow/panel/env-panel/env-item.tsx @@ -1,6 +1,6 @@ import type { EnvironmentVariable } from '@/app/components/workflow/types' import { RiDeleteBinLine, RiEditLine, RiLock2Line } from '@remixicon/react' -import { capitalize } from 'es-toolkit/compat' +import { capitalize } from 'es-toolkit/string' import { memo, useState } from 'react' import { Env } from '@/app/components/base/icons/src/vender/line/others' import { useStore } from '@/app/components/workflow/store' diff --git a/web/app/components/workflow/panel/global-variable-panel/item.tsx b/web/app/components/workflow/panel/global-variable-panel/item.tsx index 458dd27692..83c0df7758 100644 --- a/web/app/components/workflow/panel/global-variable-panel/item.tsx +++ b/web/app/components/workflow/panel/global-variable-panel/item.tsx @@ -1,5 +1,5 @@ import type { GlobalVariable } from '@/app/components/workflow/types' -import { capitalize } from 'es-toolkit/compat' +import { capitalize } from 'es-toolkit/string' import { memo } from 'react' import { GlobalVariable as GlobalVariableIcon } from '@/app/components/base/icons/src/vender/line/others' diff --git a/web/app/components/workflow/run/utils/format-log/agent/index.ts b/web/app/components/workflow/run/utils/format-log/agent/index.ts index f86e4b33bb..19acd8c120 100644 --- a/web/app/components/workflow/run/utils/format-log/agent/index.ts +++ b/web/app/components/workflow/run/utils/format-log/agent/index.ts @@ -1,5 +1,5 @@ import type { AgentLogItem, AgentLogItemWithChildren, NodeTracing } from '@/types/workflow' -import { cloneDeep } from 'es-toolkit/compat' +import { cloneDeep } from 'es-toolkit/object' import { BlockEnum } from '@/app/components/workflow/types' const supportedAgentLogNodes = [BlockEnum.Agent, BlockEnum.Tool] diff --git a/web/app/components/workflow/run/utils/format-log/index.ts b/web/app/components/workflow/run/utils/format-log/index.ts index 1dbe8f1682..c152a5156a 100644 --- a/web/app/components/workflow/run/utils/format-log/index.ts +++ b/web/app/components/workflow/run/utils/format-log/index.ts @@ -1,5 +1,5 @@ import type { NodeTracing } from '@/types/workflow' -import { cloneDeep } from 'es-toolkit/compat' +import { cloneDeep } from 'es-toolkit/object' import { BlockEnum } from '../../../types' import formatAgentNode from './agent' import { addChildrenToIterationNode } from './iteration' diff --git a/web/app/components/workflow/run/utils/format-log/iteration/index.spec.ts b/web/app/components/workflow/run/utils/format-log/iteration/index.spec.ts index 8b4416f529..f984dbea76 100644 --- a/web/app/components/workflow/run/utils/format-log/iteration/index.spec.ts +++ b/web/app/components/workflow/run/utils/format-log/iteration/index.spec.ts @@ -1,4 +1,4 @@ -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import format from '.' import graphToLogStruct from '../graph-to-log-struct' diff --git a/web/app/components/workflow/run/utils/format-log/loop/index.spec.ts b/web/app/components/workflow/run/utils/format-log/loop/index.spec.ts index 3d31e43ba3..d2a2fd24bb 100644 --- a/web/app/components/workflow/run/utils/format-log/loop/index.spec.ts +++ b/web/app/components/workflow/run/utils/format-log/loop/index.spec.ts @@ -1,4 +1,4 @@ -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import format from '.' import graphToLogStruct from '../graph-to-log-struct' diff --git a/web/app/components/workflow/utils/elk-layout.ts b/web/app/components/workflow/utils/elk-layout.ts index 1a4bbf2d50..c3b37c8f16 100644 --- a/web/app/components/workflow/utils/elk-layout.ts +++ b/web/app/components/workflow/utils/elk-layout.ts @@ -5,7 +5,7 @@ import type { Node, } from '@/app/components/workflow/types' import ELK from 'elkjs/lib/elk.bundled.js' -import { cloneDeep } from 'es-toolkit/compat' +import { cloneDeep } from 'es-toolkit/object' import { CUSTOM_NODE, NODE_LAYOUT_HORIZONTAL_PADDING, diff --git a/web/app/components/workflow/utils/workflow-init.ts b/web/app/components/workflow/utils/workflow-init.ts index fa211934e4..77a2ccefac 100644 --- a/web/app/components/workflow/utils/workflow-init.ts +++ b/web/app/components/workflow/utils/workflow-init.ts @@ -7,9 +7,7 @@ import type { Edge, Node, } from '../types' -import { - cloneDeep, -} from 'es-toolkit/compat' +import { cloneDeep } from 'es-toolkit/object' import { getConnectedEdges, } from 'reactflow' diff --git a/web/app/components/workflow/workflow-history-store.tsx b/web/app/components/workflow/workflow-history-store.tsx index 6729fe50e3..5d10f81b27 100644 --- a/web/app/components/workflow/workflow-history-store.tsx +++ b/web/app/components/workflow/workflow-history-store.tsx @@ -3,7 +3,7 @@ import type { TemporalState } from 'zundo' import type { StoreApi } from 'zustand' import type { WorkflowHistoryEventT } from './hooks' import type { Edge, Node } from './types' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import isDeepEqual from 'fast-deep-equal' import { createContext, useContext, useMemo, useState } from 'react' import { temporal } from 'zundo' diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx index f9ab2b4646..0be88091dc 100644 --- a/web/app/education-apply/education-apply-page.tsx +++ b/web/app/education-apply/education-apply-page.tsx @@ -1,7 +1,7 @@ 'use client' import { RiExternalLinkLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useRouter, useSearchParams, diff --git a/web/app/reset-password/page.tsx b/web/app/reset-password/page.tsx index 6be429960c..9fdccdfd87 100644 --- a/web/app/reset-password/page.tsx +++ b/web/app/reset-password/page.tsx @@ -1,6 +1,6 @@ 'use client' import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' diff --git a/web/app/signin/components/mail-and-password-auth.tsx b/web/app/signin/components/mail-and-password-auth.tsx index 4a18e884ad..101ddf559a 100644 --- a/web/app/signin/components/mail-and-password-auth.tsx +++ b/web/app/signin/components/mail-and-password-auth.tsx @@ -1,5 +1,5 @@ import type { ResponseError } from '@/service/fetch' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' diff --git a/web/app/signin/invite-settings/page.tsx b/web/app/signin/invite-settings/page.tsx index 360f305cbd..1e38638360 100644 --- a/web/app/signin/invite-settings/page.tsx +++ b/web/app/signin/invite-settings/page.tsx @@ -1,7 +1,7 @@ 'use client' import type { Locale } from '@/i18n-config' import { RiAccountCircleLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useState } from 'react' diff --git a/web/app/signup/components/input-mail.tsx b/web/app/signup/components/input-mail.tsx index a1730b90c9..6342e7909c 100644 --- a/web/app/signup/components/input-mail.tsx +++ b/web/app/signup/components/input-mail.tsx @@ -1,6 +1,6 @@ 'use client' import type { MailSendResponse } from '@/service/use-common' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import Link from 'next/link' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/web/context/app-context.tsx b/web/context/app-context.tsx index cb4cab65b7..335f96fcce 100644 --- a/web/context/app-context.tsx +++ b/web/context/app-context.tsx @@ -3,7 +3,7 @@ import type { FC, ReactNode } from 'react' import type { ICurrentWorkspace, LangGeniusVersionResponse, UserProfileResponse } from '@/models/common' import { useQueryClient } from '@tanstack/react-query' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useCallback, useEffect, useMemo } from 'react' import { createContext, useContext, useContextSelector } from 'use-context-selector' import { setUserId, setUserProperties } from '@/app/components/base/amplitude' diff --git a/web/context/datasets-context.tsx b/web/context/datasets-context.tsx index f35767bc21..d309a8ef3f 100644 --- a/web/context/datasets-context.tsx +++ b/web/context/datasets-context.tsx @@ -1,7 +1,7 @@ 'use client' import type { DataSet } from '@/models/datasets' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { createContext, useContext } from 'use-context-selector' export type DatasetsContextValue = { diff --git a/web/context/debug-configuration.ts b/web/context/debug-configuration.ts index 2518af6260..ba157e1bf7 100644 --- a/web/context/debug-configuration.ts +++ b/web/context/debug-configuration.ts @@ -22,7 +22,7 @@ import type { TextToSpeechConfig, } from '@/models/debug' import type { VisionSettings } from '@/types/app' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { createContext, useContext } from 'use-context-selector' import { ANNOTATION_DEFAULT, DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' import { PromptMode } from '@/models/debug' diff --git a/web/context/explore-context.ts b/web/context/explore-context.ts index 1a7b35a09b..fc446c0453 100644 --- a/web/context/explore-context.ts +++ b/web/context/explore-context.ts @@ -1,5 +1,5 @@ import type { InstalledApp } from '@/models/explore' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { createContext } from 'use-context-selector' type IExplore = { diff --git a/web/context/mitt-context.tsx b/web/context/mitt-context.tsx index 0fc160613a..4317fc5660 100644 --- a/web/context/mitt-context.tsx +++ b/web/context/mitt-context.tsx @@ -1,4 +1,4 @@ -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { createContext, useContext, useContextSelector } from 'use-context-selector' import { useMitt } from '@/hooks/use-mitt' diff --git a/web/context/modal-context.tsx b/web/context/modal-context.tsx index dce7b9f6e1..293970259a 100644 --- a/web/context/modal-context.tsx +++ b/web/context/modal-context.tsx @@ -22,7 +22,7 @@ import type { ExternalDataTool, } from '@/models/common' import type { ModerationConfig, PromptVariable } from '@/models/debug' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import dynamic from 'next/dynamic' import { useCallback, useEffect, useRef, useState } from 'react' import { createContext, useContext, useContextSelector } from 'use-context-selector' diff --git a/web/context/provider-context.tsx b/web/context/provider-context.tsx index e6a2a1c5af..7eca72e322 100644 --- a/web/context/provider-context.tsx +++ b/web/context/provider-context.tsx @@ -5,7 +5,7 @@ import type { Model, ModelProvider } from '@/app/components/header/account-setti import type { RETRIEVE_METHOD } from '@/types/app' import { useQueryClient } from '@tanstack/react-query' import dayjs from 'dayjs' -import { noop } from 'es-toolkit/compat' +import { noop } from 'es-toolkit/function' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { createContext, useContext, useContextSelector } from 'use-context-selector' diff --git a/web/i18n-config/i18next-config.ts b/web/i18n-config/i18next-config.ts index 107954a384..0997485967 100644 --- a/web/i18n-config/i18next-config.ts +++ b/web/i18n-config/i18next-config.ts @@ -1,6 +1,6 @@ 'use client' import type { Locale } from '.' -import { camelCase, kebabCase } from 'es-toolkit/compat' +import { camelCase, kebabCase } from 'es-toolkit/string' import i18n from 'i18next' import { initReactI18next } from 'react-i18next' import appAnnotation from '../i18n/en-US/app-annotation.json' diff --git a/web/package.json b/web/package.json index 300b9b450a..1d6812b820 100644 --- a/web/package.json +++ b/web/package.json @@ -206,7 +206,6 @@ "jsdom-testing-mocks": "^1.16.0", "knip": "^5.66.1", "lint-staged": "^15.5.2", - "lodash": "^4.17.21", "nock": "^14.0.10", "postcss": "^8.5.6", "react-scan": "^0.4.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 3c4f881fdf..d5cb16cd4c 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -529,9 +529,6 @@ importers: lint-staged: specifier: ^15.5.2 version: 15.5.2 - lodash: - specifier: ^4.17.21 - version: 4.17.21 nock: specifier: ^14.0.10 version: 14.0.10 diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 32ea4f35fd..5c10bac5d2 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -33,7 +33,7 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query' -import { cloneDeep } from 'es-toolkit/compat' +import { cloneDeep } from 'es-toolkit/object' import { useCallback, useEffect, useState } from 'react' import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' diff --git a/web/utils/index.ts b/web/utils/index.ts index 5704e82d87..fe6463c18e 100644 --- a/web/utils/index.ts +++ b/web/utils/index.ts @@ -1,4 +1,4 @@ -import { escape } from 'es-toolkit/compat' +import { escape } from 'es-toolkit/string' export const sleep = (ms: number) => { return new Promise(resolve => setTimeout(resolve, ms)) From 0421387672d4fded149d1219d08540903d535710 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:59:39 +0800 Subject: [PATCH 10/25] chore(deps): bump qs from 6.14.0 to 6.14.1 in /web (#30409) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/package.json | 2 +- web/pnpm-lock.yaml | 31 ++++++++++++++++++++----------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/web/package.json b/web/package.json index 1d6812b820..a78575304c 100644 --- a/web/package.json +++ b/web/package.json @@ -112,7 +112,7 @@ "nuqs": "^2.8.6", "pinyin-pro": "^3.27.0", "qrcode.react": "^4.2.0", - "qs": "^6.14.0", + "qs": "^6.14.1", "react": "19.2.3", "react-18-input-autosize": "^3.0.0", "react-dom": "19.2.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index d5cb16cd4c..fe9032d248 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -253,8 +253,8 @@ importers: specifier: ^4.2.0 version: 4.2.0(react@19.2.3) qs: - specifier: ^6.14.0 - version: 6.14.0 + specifier: ^6.14.1 + version: 6.14.1 react: specifier: 19.2.3 version: 19.2.3 @@ -3698,6 +3698,9 @@ packages: '@types/node@20.19.26': resolution: {integrity: sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==} + '@types/node@20.19.27': + resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} + '@types/papaparse@5.5.1': resolution: {integrity: sha512-esEO+VISsLIyE+JZBmb89NzsYYbpwV8lmv2rPo6oX5y9KhBaIP7hhHgjuTut54qjdKVMufTEcrh5fUl9+58huw==} @@ -6372,8 +6375,8 @@ packages: lexical@0.38.2: resolution: {integrity: sha512-JJmfsG3c4gwBHzUGffbV7ifMNkKAWMCnYE3xJl87gty7hjyV5f3xq7eqTjP5HFYvO4XpjJvvWO2/djHp5S10tw==} - lib0@0.2.115: - resolution: {integrity: sha512-noaW4yNp6hCjOgDnWWxW0vGXE3kZQI5Kqiwz+jIWXavI9J9WyfJ9zjsbQlQlgjIbHBrvlA/x3TSIXBUJj+0L6g==} + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} engines: {node: '>=16'} hasBin: true @@ -7348,8 +7351,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - qs@6.14.0: - resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} quansync@0.2.11: @@ -8776,6 +8779,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} @@ -12408,6 +12412,11 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@20.19.27': + dependencies: + undici-types: 6.21.0 + optional: true + '@types/papaparse@5.5.1': dependencies: '@types/node': 18.15.0 @@ -14884,7 +14893,7 @@ snapshots: happy-dom@20.0.11: dependencies: - '@types/node': 20.19.26 + '@types/node': 20.19.27 '@types/whatwg-mimetype': 3.0.2 whatwg-mimetype: 3.0.0 optional: true @@ -15508,7 +15517,7 @@ snapshots: lexical@0.38.2: {} - lib0@0.2.115: + lib0@0.2.117: dependencies: isomorphic.js: 0.2.5 @@ -16814,7 +16823,7 @@ snapshots: dependencies: react: 19.2.3 - qs@6.14.0: + qs@6.14.1: dependencies: side-channel: '@nolyfill/side-channel@1.0.44' @@ -18106,7 +18115,7 @@ snapshots: url@0.11.4: dependencies: punycode: 1.4.1 - qs: 6.14.0 + qs: 6.14.1 use-callback-ref@1.3.3(@types/react@19.2.7)(react@19.2.3): dependencies: @@ -18585,7 +18594,7 @@ snapshots: yjs@13.6.27: dependencies: - lib0: 0.2.115 + lib0: 0.2.117 yocto-queue@0.1.0: {} From 1b8e80a722d731e1142f0e33c42e35a70584637f Mon Sep 17 00:00:00 2001 From: DevByteAI <161969603+devbyteai@users.noreply.github.com> Date: Wed, 31 Dec 2025 07:28:25 +0200 Subject: [PATCH 11/25] fix: Ensure chat history refreshes when switching back to conversations (#30389) --- web/service/use-share.spec.tsx | 45 ++++++++++++++++++++++++++++++++++ web/service/use-share.ts | 4 +++ 2 files changed, 49 insertions(+) diff --git a/web/service/use-share.spec.tsx b/web/service/use-share.spec.tsx index d0202ed140..db20329767 100644 --- a/web/service/use-share.spec.tsx +++ b/web/service/use-share.spec.tsx @@ -160,6 +160,51 @@ describe('useShareChatList', () => { }) expect(mockFetchChatList).not.toHaveBeenCalled() }) + + it('should always consider data stale to ensure fresh data on conversation switch (GitHub #30378)', async () => { + // This test verifies that chat list data is always considered stale (staleTime: 0) + // which ensures fresh data is fetched when switching back to a conversation. + // Without this, users would see outdated messages until double-switching. + const queryClient = createQueryClient() + const wrapper = createWrapper(queryClient) + const params = { + conversationId: 'conversation-1', + isInstalledApp: false, + appId: undefined, + } + const initialResponse = { data: [{ id: '1', content: 'initial' }] } + const updatedResponse = { data: [{ id: '1', content: 'initial' }, { id: '2', content: 'new message' }] } + + // First fetch + mockFetchChatList.mockResolvedValueOnce(initialResponse) + const { result, unmount } = renderHook(() => useShareChatList(params), { wrapper }) + + await waitFor(() => { + expect(result.current.data).toEqual(initialResponse) + }) + expect(mockFetchChatList).toHaveBeenCalledTimes(1) + + // Unmount (simulates switching away from conversation) + unmount() + + // Remount with same params (simulates switching back) + // With staleTime: 0, this should trigger a background refetch + mockFetchChatList.mockResolvedValueOnce(updatedResponse) + const { result: result2 } = renderHook(() => useShareChatList(params), { wrapper }) + + // Should immediately return cached data + expect(result2.current.data).toEqual(initialResponse) + + // Should trigger background refetch due to staleTime: 0 + await waitFor(() => { + expect(mockFetchChatList).toHaveBeenCalledTimes(2) + }) + + // Should update with fresh data + await waitFor(() => { + expect(result2.current.data).toEqual(updatedResponse) + }) + }) }) // Scenario: conversation name queries follow enabled flags and installation constraints. diff --git a/web/service/use-share.ts b/web/service/use-share.ts index 4dd43e06aa..eef61ccc29 100644 --- a/web/service/use-share.ts +++ b/web/service/use-share.ts @@ -122,6 +122,10 @@ export const useShareChatList = (params: ShareChatListParams, options: ShareQuer enabled: isEnabled, refetchOnReconnect, refetchOnWindowFocus, + // Always consider chat list data stale to ensure fresh data when switching + // back to a conversation. This fixes issue where recent messages don't appear + // until switching away and back again (GitHub issue #30378). + staleTime: 0, }) } From 8129b04143ef283afc2d4f54617ec5c418813319 Mon Sep 17 00:00:00 2001 From: QuantumGhost Date: Wed, 31 Dec 2025 13:38:16 +0800 Subject: [PATCH 12/25] fix(web): enable JSON_OBJECT type support in console UI (#30412) Co-authored-by: zhsama --- .../config-var/config-modal/config.ts | 27 ++++---- .../config-var/config-modal/index.tsx | 63 +++++++++++++++---- web/i18n/ar-TN/app-debug.json | 2 + web/i18n/de-DE/app-debug.json | 2 + web/i18n/en-US/app-debug.json | 2 + web/i18n/es-ES/app-debug.json | 2 + web/i18n/fa-IR/app-debug.json | 2 + web/i18n/fr-FR/app-debug.json | 2 + web/i18n/hi-IN/app-debug.json | 2 + web/i18n/id-ID/app-debug.json | 2 + web/i18n/it-IT/app-debug.json | 2 + web/i18n/ja-JP/app-debug.json | 2 + web/i18n/ko-KR/app-debug.json | 2 + web/i18n/pl-PL/app-debug.json | 2 + web/i18n/pt-BR/app-debug.json | 2 + web/i18n/ro-RO/app-debug.json | 2 + web/i18n/ru-RU/app-debug.json | 2 + web/i18n/sl-SI/app-debug.json | 2 + web/i18n/th-TH/app-debug.json | 2 + web/i18n/tr-TR/app-debug.json | 2 + web/i18n/uk-UA/app-debug.json | 2 + web/i18n/vi-VN/app-debug.json | 2 + web/i18n/zh-Hans/app-debug.json | 2 + web/i18n/zh-Hant/app-debug.json | 2 + 24 files changed, 110 insertions(+), 24 deletions(-) diff --git a/web/app/components/app/configuration/config-var/config-modal/config.ts b/web/app/components/app/configuration/config-var/config-modal/config.ts index 94a3cfae58..6586c2fd54 100644 --- a/web/app/components/app/configuration/config-var/config-modal/config.ts +++ b/web/app/components/app/configuration/config-var/config-modal/config.ts @@ -7,19 +7,24 @@ export const jsonObjectWrap = { export const jsonConfigPlaceHolder = JSON.stringify( { - foo: { - type: 'string', - }, - bar: { - type: 'object', - properties: { - sub: { - type: 'number', - }, + type: 'object', + properties: { + foo: { + type: 'string', + }, + bar: { + type: 'object', + properties: { + sub: { + type: 'number', + }, + }, + required: [], + additionalProperties: true, }, - required: [], - additionalProperties: true, }, + required: [], + additionalProperties: true, }, null, 2, diff --git a/web/app/components/app/configuration/config-var/config-modal/index.tsx b/web/app/components/app/configuration/config-var/config-modal/index.tsx index df07231465..782744882e 100644 --- a/web/app/components/app/configuration/config-var/config-modal/index.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/index.tsx @@ -28,7 +28,7 @@ import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInpu import ConfigSelect from '../config-select' import ConfigString from '../config-string' import ModalFoot from '../modal-foot' -import { jsonConfigPlaceHolder, jsonObjectWrap } from './config' +import { jsonConfigPlaceHolder } from './config' import Field from './field' import TypeSelector from './type-select' @@ -78,13 +78,12 @@ const ConfigModal: FC = ({ const modalRef = useRef(null) const appDetail = useAppStore(state => state.appDetail) const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW - const isSupportJSON = false const jsonSchemaStr = useMemo(() => { const isJsonObject = type === InputVarType.jsonObject if (!isJsonObject || !tempPayload.json_schema) return '' try { - return JSON.stringify(JSON.parse(tempPayload.json_schema).properties, null, 2) + return JSON.stringify(JSON.parse(tempPayload.json_schema), null, 2) } catch { return '' @@ -129,13 +128,14 @@ const ConfigModal: FC = ({ }, []) const handleJSONSchemaChange = useCallback((value: string) => { + const isEmpty = value == null || value.trim() === '' + if (isEmpty) { + handlePayloadChange('json_schema')(undefined) + return null + } try { const v = JSON.parse(value) - const res = { - ...jsonObjectWrap, - properties: v, - } - handlePayloadChange('json_schema')(JSON.stringify(res, null, 2)) + handlePayloadChange('json_schema')(JSON.stringify(v, null, 2)) } catch { return null @@ -175,7 +175,7 @@ const ConfigModal: FC = ({ }, ] : []), - ...((!isBasicApp && isSupportJSON) + ...((!isBasicApp) ? [{ name: t('variableConfig.json', { ns: 'appDebug' }), value: InputVarType.jsonObject, @@ -233,7 +233,28 @@ const ConfigModal: FC = ({ const checkboxDefaultSelectValue = useMemo(() => getCheckboxDefaultSelectValue(tempPayload.default), [tempPayload.default]) + const isJsonSchemaEmpty = (value: InputVar['json_schema']) => { + if (value === null || value === undefined) { + return true + } + if (typeof value !== 'string') { + return false + } + const trimmed = value.trim() + return trimmed === '' + } + const handleConfirm = () => { + const jsonSchemaValue = tempPayload.json_schema + const isSchemaEmpty = isJsonSchemaEmpty(jsonSchemaValue) + const normalizedJsonSchema = isSchemaEmpty ? undefined : jsonSchemaValue + + // if the input type is jsonObject and the schema is empty as determined by `isJsonSchemaEmpty`, + // remove the `json_schema` field from the payload by setting its value to `undefined`. + const payloadToSave = tempPayload.type === InputVarType.jsonObject && isSchemaEmpty + ? { ...tempPayload, json_schema: undefined } + : tempPayload + const moreInfo = tempPayload.variable === payload?.variable ? undefined : { @@ -250,7 +271,7 @@ const ConfigModal: FC = ({ return } if (isStringInput || type === InputVarType.number) { - onConfirm(tempPayload, moreInfo) + onConfirm(payloadToSave, moreInfo) } else if (type === InputVarType.select) { if (options?.length === 0) { @@ -270,7 +291,7 @@ const ConfigModal: FC = ({ Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }) }) return } - onConfirm(tempPayload, moreInfo) + onConfirm(payloadToSave, moreInfo) } else if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) { if (tempPayload.allowed_file_types?.length === 0) { @@ -283,10 +304,26 @@ const ConfigModal: FC = ({ Toast.notify({ type: 'error', message: errorMessages }) return } - onConfirm(tempPayload, moreInfo) + onConfirm(payloadToSave, moreInfo) + } + else if (type === InputVarType.jsonObject) { + if (!isSchemaEmpty && typeof normalizedJsonSchema === 'string') { + try { + const schema = JSON.parse(normalizedJsonSchema) + if (schema?.type !== 'object') { + Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.jsonSchemaMustBeObject', { ns: 'appDebug' }) }) + return + } + } + catch { + Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.jsonSchemaInvalid', { ns: 'appDebug' }) }) + return + } + } + onConfirm(payloadToSave, moreInfo) } else { - onConfirm(tempPayload, moreInfo) + onConfirm(payloadToSave, moreInfo) } } diff --git a/web/i18n/ar-TN/app-debug.json b/web/i18n/ar-TN/app-debug.json index 192f1b49cf..c35983397a 100644 --- a/web/i18n/ar-TN/app-debug.json +++ b/web/i18n/ar-TN/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "اسم العرض", "variableConfig.editModalTitle": "تعديل حقل إدخال", "variableConfig.errorMsg.atLeastOneOption": "خيار واحد على الأقل مطلوب", + "variableConfig.errorMsg.jsonSchemaInvalid": "مخطط JSON ليس JSON صالحًا", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "يجب أن يكون نوع مخطط JSON \"object\"", "variableConfig.errorMsg.labelNameRequired": "اسم التسمية مطلوب", "variableConfig.errorMsg.optionRepeat": "يوجد خيارات مكررة", "variableConfig.errorMsg.varNameCanBeRepeat": "اسم المتغير لا يمكن تكراره", diff --git a/web/i18n/de-DE/app-debug.json b/web/i18n/de-DE/app-debug.json index 2ab907ef2d..d038320cf8 100644 --- a/web/i18n/de-DE/app-debug.json +++ b/web/i18n/de-DE/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "Anzeigename", "variableConfig.editModalTitle": "Eingabefeld bearbeiten", "variableConfig.errorMsg.atLeastOneOption": "Mindestens eine Option ist erforderlich", + "variableConfig.errorMsg.jsonSchemaInvalid": "JSON-Schema ist kein gültiges JSON", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON-Schema muss den Typ \"object\" haben", "variableConfig.errorMsg.labelNameRequired": "Labelname ist erforderlich", "variableConfig.errorMsg.optionRepeat": "Hat Wiederholungsoptionen", "variableConfig.errorMsg.varNameCanBeRepeat": "Variablenname kann nicht wiederholt werden", diff --git a/web/i18n/en-US/app-debug.json b/web/i18n/en-US/app-debug.json index 266f0e5483..cc53891912 100644 --- a/web/i18n/en-US/app-debug.json +++ b/web/i18n/en-US/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "Display Name", "variableConfig.editModalTitle": "Edit Input Field", "variableConfig.errorMsg.atLeastOneOption": "At least one option is required", + "variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema is not valid JSON", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema must have type \"object\"", "variableConfig.errorMsg.labelNameRequired": "Label name is required", "variableConfig.errorMsg.optionRepeat": "Has repeat options", "variableConfig.errorMsg.varNameCanBeRepeat": "Variable name can not be repeated", diff --git a/web/i18n/es-ES/app-debug.json b/web/i18n/es-ES/app-debug.json index a1dfd8ad53..8245f2b325 100644 --- a/web/i18n/es-ES/app-debug.json +++ b/web/i18n/es-ES/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "Nombre para mostrar", "variableConfig.editModalTitle": "Editar Campo de Entrada", "variableConfig.errorMsg.atLeastOneOption": "Se requiere al menos una opción", + "variableConfig.errorMsg.jsonSchemaInvalid": "El esquema JSON no es un JSON válido", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "El esquema JSON debe tener el tipo \"object\"", "variableConfig.errorMsg.labelNameRequired": "Nombre de la etiqueta es requerido", "variableConfig.errorMsg.optionRepeat": "Hay opciones repetidas", "variableConfig.errorMsg.varNameCanBeRepeat": "El nombre de la variable no puede repetirse", diff --git a/web/i18n/fa-IR/app-debug.json b/web/i18n/fa-IR/app-debug.json index c6e1b588e5..5427ebb72a 100644 --- a/web/i18n/fa-IR/app-debug.json +++ b/web/i18n/fa-IR/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "نام نمایشی", "variableConfig.editModalTitle": "ویرایش فیلد ورودی", "variableConfig.errorMsg.atLeastOneOption": "حداقل یک گزینه مورد نیاز است", + "variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema یک JSON معتبر نیست", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "نوع JSON Schema باید \"object\" باشد", "variableConfig.errorMsg.labelNameRequired": "نام برچسب الزامی است", "variableConfig.errorMsg.optionRepeat": "دارای گزینه های تکرار", "variableConfig.errorMsg.varNameCanBeRepeat": "نام متغیر را نمی توان تکرار کرد", diff --git a/web/i18n/fr-FR/app-debug.json b/web/i18n/fr-FR/app-debug.json index 2add116cd3..88f75f5136 100644 --- a/web/i18n/fr-FR/app-debug.json +++ b/web/i18n/fr-FR/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "Nom d’affichage", "variableConfig.editModalTitle": "Edit Input Field", "variableConfig.errorMsg.atLeastOneOption": "At least one option is required", + "variableConfig.errorMsg.jsonSchemaInvalid": "Le schéma JSON n’est pas un JSON valide", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "Le schéma JSON doit avoir le type \"object\"", "variableConfig.errorMsg.labelNameRequired": "Label name is required", "variableConfig.errorMsg.optionRepeat": "Has repeat options", "variableConfig.errorMsg.varNameCanBeRepeat": "Variable name can not be repeated", diff --git a/web/i18n/hi-IN/app-debug.json b/web/i18n/hi-IN/app-debug.json index df891f60ec..97e733e167 100644 --- a/web/i18n/hi-IN/app-debug.json +++ b/web/i18n/hi-IN/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "प्रदर्शन नाम", "variableConfig.editModalTitle": "इनपुट फ़ील्ड संपादित करें", "variableConfig.errorMsg.atLeastOneOption": "कम से कम एक विकल्प आवश्यक है", + "variableConfig.errorMsg.jsonSchemaInvalid": "JSON स्कीमा मान्य JSON नहीं है", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON स्कीमा का प्रकार \"object\" होना चाहिए", "variableConfig.errorMsg.labelNameRequired": "लेबल नाम आवश्यक है", "variableConfig.errorMsg.optionRepeat": "विकल्प दोहराए गए हैं", "variableConfig.errorMsg.varNameCanBeRepeat": "वेरिएबल नाम दोहराया नहीं जा सकता", diff --git a/web/i18n/id-ID/app-debug.json b/web/i18n/id-ID/app-debug.json index dfce0159a5..21651349ba 100644 --- a/web/i18n/id-ID/app-debug.json +++ b/web/i18n/id-ID/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "Nama Tampilan", "variableConfig.editModalTitle": "Edit Bidang Input", "variableConfig.errorMsg.atLeastOneOption": "Setidaknya satu opsi diperlukan", + "variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema bukan JSON yang valid", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema harus bertipe \"object\"", "variableConfig.errorMsg.labelNameRequired": "Nama label diperlukan", "variableConfig.errorMsg.optionRepeat": "Memiliki opsi pengulangan", "variableConfig.errorMsg.varNameCanBeRepeat": "Nama variabel tidak dapat diulang", diff --git a/web/i18n/it-IT/app-debug.json b/web/i18n/it-IT/app-debug.json index fce5620eef..94e4c00894 100644 --- a/web/i18n/it-IT/app-debug.json +++ b/web/i18n/it-IT/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "Nome visualizzato", "variableConfig.editModalTitle": "Modifica Campo Input", "variableConfig.errorMsg.atLeastOneOption": "È richiesta almeno un'opzione", + "variableConfig.errorMsg.jsonSchemaInvalid": "Lo schema JSON non è un JSON valido", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "Lo schema JSON deve avere tipo \"object\"", "variableConfig.errorMsg.labelNameRequired": "Il nome dell'etichetta è richiesto", "variableConfig.errorMsg.optionRepeat": "Ci sono opzioni ripetute", "variableConfig.errorMsg.varNameCanBeRepeat": "Il nome della variabile non può essere ripetuto", diff --git a/web/i18n/ja-JP/app-debug.json b/web/i18n/ja-JP/app-debug.json index cf44ccff94..4b18745e5f 100644 --- a/web/i18n/ja-JP/app-debug.json +++ b/web/i18n/ja-JP/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "表示名", "variableConfig.editModalTitle": "入力フィールドを編集", "variableConfig.errorMsg.atLeastOneOption": "少なくとも 1 つのオプションが必要です", + "variableConfig.errorMsg.jsonSchemaInvalid": "JSONスキーマが有効なJSONではありません", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "JSONスキーマのtypeは\"object\"である必要があります", "variableConfig.errorMsg.labelNameRequired": "ラベル名は必須です", "variableConfig.errorMsg.optionRepeat": "繰り返しオプションがあります", "variableConfig.errorMsg.varNameCanBeRepeat": "変数名は繰り返すことができません", diff --git a/web/i18n/ko-KR/app-debug.json b/web/i18n/ko-KR/app-debug.json index 600062464d..3cb295a3f7 100644 --- a/web/i18n/ko-KR/app-debug.json +++ b/web/i18n/ko-KR/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "표시 이름", "variableConfig.editModalTitle": "입력 필드 편집", "variableConfig.errorMsg.atLeastOneOption": "적어도 하나의 옵션이 필요합니다", + "variableConfig.errorMsg.jsonSchemaInvalid": "JSON 스키마가 올바른 JSON이 아닙니다", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON 스키마의 type은 \"object\"이어야 합니다", "variableConfig.errorMsg.labelNameRequired": "레이블명은 필수입니다", "variableConfig.errorMsg.optionRepeat": "옵션이 중복되어 있습니다", "variableConfig.errorMsg.varNameCanBeRepeat": "변수명은 중복될 수 없습니다", diff --git a/web/i18n/pl-PL/app-debug.json b/web/i18n/pl-PL/app-debug.json index ece00eb9ad..0a9da0fcce 100644 --- a/web/i18n/pl-PL/app-debug.json +++ b/web/i18n/pl-PL/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "Nazwa wyświetlana", "variableConfig.editModalTitle": "Edytuj Pole Wejściowe", "variableConfig.errorMsg.atLeastOneOption": "Wymagana jest co najmniej jedna opcja", + "variableConfig.errorMsg.jsonSchemaInvalid": "Schemat JSON nie jest prawidłowym JSON-em", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "Schemat JSON musi mieć typ \"object\"", "variableConfig.errorMsg.labelNameRequired": "Wymagana nazwa etykiety", "variableConfig.errorMsg.optionRepeat": "Powtarzają się opcje", "variableConfig.errorMsg.varNameCanBeRepeat": "Nazwa zmiennej nie może się powtarzać", diff --git a/web/i18n/pt-BR/app-debug.json b/web/i18n/pt-BR/app-debug.json index 98ad27f69a..6059787894 100644 --- a/web/i18n/pt-BR/app-debug.json +++ b/web/i18n/pt-BR/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "Nome de exibição", "variableConfig.editModalTitle": "Editar Campo de Entrada", "variableConfig.errorMsg.atLeastOneOption": "Pelo menos uma opção é obrigatória", + "variableConfig.errorMsg.jsonSchemaInvalid": "O JSON Schema não é um JSON válido", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "O JSON Schema deve ter o tipo \"object\"", "variableConfig.errorMsg.labelNameRequired": "O nome do rótulo é obrigatório", "variableConfig.errorMsg.optionRepeat": "Tem opções repetidas", "variableConfig.errorMsg.varNameCanBeRepeat": "O nome da variável não pode ser repetido", diff --git a/web/i18n/ro-RO/app-debug.json b/web/i18n/ro-RO/app-debug.json index 83990992cc..6245ca680d 100644 --- a/web/i18n/ro-RO/app-debug.json +++ b/web/i18n/ro-RO/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "Nume afișat", "variableConfig.editModalTitle": "Editați câmpul de intrare", "variableConfig.errorMsg.atLeastOneOption": "Este necesară cel puțin o opțiune", + "variableConfig.errorMsg.jsonSchemaInvalid": "Schema JSON nu este un JSON valid", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "Schema JSON trebuie să aibă tipul \"object\"", "variableConfig.errorMsg.labelNameRequired": "Numele etichetei este obligatoriu", "variableConfig.errorMsg.optionRepeat": "Există opțiuni repetate", "variableConfig.errorMsg.varNameCanBeRepeat": "Numele variabilei nu poate fi repetat", diff --git a/web/i18n/ru-RU/app-debug.json b/web/i18n/ru-RU/app-debug.json index c3075a5b01..fdc797da2f 100644 --- a/web/i18n/ru-RU/app-debug.json +++ b/web/i18n/ru-RU/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "Отображаемое имя", "variableConfig.editModalTitle": "Редактировать поле ввода", "variableConfig.errorMsg.atLeastOneOption": "Требуется хотя бы один вариант", + "variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema не является корректным JSON", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema должна иметь тип \"object\"", "variableConfig.errorMsg.labelNameRequired": "Имя метки обязательно", "variableConfig.errorMsg.optionRepeat": "Есть повторяющиеся варианты", "variableConfig.errorMsg.varNameCanBeRepeat": "Имя переменной не может повторяться", diff --git a/web/i18n/sl-SI/app-debug.json b/web/i18n/sl-SI/app-debug.json index 28094186bf..948e3dbc67 100644 --- a/web/i18n/sl-SI/app-debug.json +++ b/web/i18n/sl-SI/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "Prikazno ime", "variableConfig.editModalTitle": "Uredi vnosno polje", "variableConfig.errorMsg.atLeastOneOption": "Potrebna je vsaj ena možnost", + "variableConfig.errorMsg.jsonSchemaInvalid": "Shema JSON ni veljaven JSON", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "Shema JSON mora imeti tip \"object\"", "variableConfig.errorMsg.labelNameRequired": "Ime nalepke je obvezno", "variableConfig.errorMsg.optionRepeat": "Ima možnosti ponavljanja", "variableConfig.errorMsg.varNameCanBeRepeat": "Imena spremenljivke ni mogoče ponoviti", diff --git a/web/i18n/th-TH/app-debug.json b/web/i18n/th-TH/app-debug.json index c0dd099f08..53dc629a7f 100644 --- a/web/i18n/th-TH/app-debug.json +++ b/web/i18n/th-TH/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "ชื่อที่แสดง", "variableConfig.editModalTitle": "แก้ไขฟิลด์อินพุต", "variableConfig.errorMsg.atLeastOneOption": "จําเป็นต้องมีอย่างน้อยหนึ่งตัวเลือก", + "variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema ไม่ใช่ JSON ที่ถูกต้อง", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema ต้องมีชนิดเป็น \"object\"", "variableConfig.errorMsg.labelNameRequired": "ต้องมีชื่อฉลาก", "variableConfig.errorMsg.optionRepeat": "มีตัวเลือกการทําซ้ํา", "variableConfig.errorMsg.varNameCanBeRepeat": "ไม่สามารถทําซ้ําชื่อตัวแปรได้", diff --git a/web/i18n/tr-TR/app-debug.json b/web/i18n/tr-TR/app-debug.json index f04a8fd64e..1ae01dca6c 100644 --- a/web/i18n/tr-TR/app-debug.json +++ b/web/i18n/tr-TR/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "Görünen Ad", "variableConfig.editModalTitle": "Giriş Alanı Düzenle", "variableConfig.errorMsg.atLeastOneOption": "En az bir seçenek gereklidir", + "variableConfig.errorMsg.jsonSchemaInvalid": "JSON Şeması geçerli bir JSON değil", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Şeması’nın türü \"object\" olmalı", "variableConfig.errorMsg.labelNameRequired": "Etiket adı gereklidir", "variableConfig.errorMsg.optionRepeat": "Yinelenen seçenekler var", "variableConfig.errorMsg.varNameCanBeRepeat": "Değişken adı tekrar edemez", diff --git a/web/i18n/uk-UA/app-debug.json b/web/i18n/uk-UA/app-debug.json index 6d7ecd92a7..74dcc72efd 100644 --- a/web/i18n/uk-UA/app-debug.json +++ b/web/i18n/uk-UA/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "Відображуване ім'я", "variableConfig.editModalTitle": "Редагувати Поле Введення", "variableConfig.errorMsg.atLeastOneOption": "Потрібно щонайменше одну опцію", + "variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema не є коректним JSON", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema має мати тип \"object\"", "variableConfig.errorMsg.labelNameRequired": "Потрібно вказати назву мітки", "variableConfig.errorMsg.optionRepeat": "Є повторні опції", "variableConfig.errorMsg.varNameCanBeRepeat": "Назва змінної не може повторюватися", diff --git a/web/i18n/vi-VN/app-debug.json b/web/i18n/vi-VN/app-debug.json index e94e442626..f13df6c43d 100644 --- a/web/i18n/vi-VN/app-debug.json +++ b/web/i18n/vi-VN/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "Tên hiển thị", "variableConfig.editModalTitle": "Chỉnh sửa trường nhập", "variableConfig.errorMsg.atLeastOneOption": "Cần ít nhất một tùy chọn", + "variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema không phải là JSON hợp lệ", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema phải có kiểu \"object\"", "variableConfig.errorMsg.labelNameRequired": "Tên nhãn là bắt buộc", "variableConfig.errorMsg.optionRepeat": "Có các tùy chọn trùng lặp", "variableConfig.errorMsg.varNameCanBeRepeat": "Tên biến không được trùng lặp", diff --git a/web/i18n/zh-Hans/app-debug.json b/web/i18n/zh-Hans/app-debug.json index 7965400dd7..7559bf2b56 100644 --- a/web/i18n/zh-Hans/app-debug.json +++ b/web/i18n/zh-Hans/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "显示名称", "variableConfig.editModalTitle": "编辑变量", "variableConfig.errorMsg.atLeastOneOption": "至少需要一个选项", + "variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema 不是合法的 JSON", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema 的 type 必须为 \"object\"", "variableConfig.errorMsg.labelNameRequired": "显示名称必填", "variableConfig.errorMsg.optionRepeat": "选项不能重复", "variableConfig.errorMsg.varNameCanBeRepeat": "变量名称不能重复", diff --git a/web/i18n/zh-Hant/app-debug.json b/web/i18n/zh-Hant/app-debug.json index eabcebd8e0..ab7286691f 100644 --- a/web/i18n/zh-Hant/app-debug.json +++ b/web/i18n/zh-Hant/app-debug.json @@ -306,6 +306,8 @@ "variableConfig.displayName": "顯示名稱", "variableConfig.editModalTitle": "編輯變數", "variableConfig.errorMsg.atLeastOneOption": "至少需要一個選項", + "variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema 不是合法的 JSON", + "variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema 的 type 必須為「object」", "variableConfig.errorMsg.labelNameRequired": "顯示名稱必填", "variableConfig.errorMsg.optionRepeat": "選項不能重複", "variableConfig.errorMsg.varNameCanBeRepeat": "變數名稱不能重複", From f28a08a69642e43dc556d3c33b3ba75dc9eb528a Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 31 Dec 2025 13:51:05 +0800 Subject: [PATCH 13/25] fix: correct useEducationStatus query cache configuration (#30416) --- web/context/provider-context.tsx | 8 ++++---- web/service/use-education.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/context/provider-context.tsx b/web/context/provider-context.tsx index 7eca72e322..d350d08b4a 100644 --- a/web/context/provider-context.tsx +++ b/web/context/provider-context.tsx @@ -134,7 +134,7 @@ export const ProviderContextProvider = ({ const [enableEducationPlan, setEnableEducationPlan] = useState(false) const [isEducationWorkspace, setIsEducationWorkspace] = useState(false) - const { data: educationAccountInfo, isLoading: isLoadingEducationAccountInfo, isFetching: isFetchingEducationAccountInfo } = useEducationStatus(!enableEducationPlan) + const { data: educationAccountInfo, isLoading: isLoadingEducationAccountInfo, isFetching: isFetchingEducationAccountInfo, isFetchedAfterMount: isEducationDataFetchedAfterMount } = useEducationStatus(!enableEducationPlan) const [isAllowTransferWorkspace, setIsAllowTransferWorkspace] = useState(false) const [isAllowPublishAsCustomKnowledgePipelineTemplate, setIsAllowPublishAsCustomKnowledgePipelineTemplate] = useState(false) @@ -240,9 +240,9 @@ export const ProviderContextProvider = ({ datasetOperatorEnabled, enableEducationPlan, isEducationWorkspace, - isEducationAccount: educationAccountInfo?.is_student || false, - allowRefreshEducationVerify: educationAccountInfo?.allow_refresh || false, - educationAccountExpireAt: educationAccountInfo?.expire_at || null, + isEducationAccount: isEducationDataFetchedAfterMount ? (educationAccountInfo?.is_student ?? false) : false, + allowRefreshEducationVerify: isEducationDataFetchedAfterMount ? (educationAccountInfo?.allow_refresh ?? false) : false, + educationAccountExpireAt: isEducationDataFetchedAfterMount ? (educationAccountInfo?.expire_at ?? null) : null, isLoadingEducationAccountInfo, isFetchingEducationAccountInfo, webappCopyrightEnabled, diff --git a/web/service/use-education.ts b/web/service/use-education.ts index 34b08e59c6..a75ec6f3c7 100644 --- a/web/service/use-education.ts +++ b/web/service/use-education.ts @@ -59,7 +59,7 @@ export const useEducationStatus = (disable?: boolean) => { return get<{ is_student: boolean, allow_refresh: boolean, expire_at: number | null }>('/account/education') }, retry: false, - gcTime: 0, // No cache. Prevent switch account caused stale data + staleTime: 0, // Data expires immediately, ensuring fresh data on refetch }) } From fa69cce1e79f9703c6c16b3b318295d6cbe6efe8 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Wed, 31 Dec 2025 14:57:39 +0800 Subject: [PATCH 14/25] fix: fix create app xss issue (#30305) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/controllers/console/app/app.py | 58 ++++ .../console/app/test_xss_prevention.py | 254 ++++++++++++++++++ 2 files changed, 312 insertions(+) create mode 100644 api/tests/unit_tests/controllers/console/app/test_xss_prevention.py diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 62e997dae2..44cf89d6a9 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -1,3 +1,4 @@ +import re import uuid from typing import Literal @@ -73,6 +74,48 @@ class AppListQuery(BaseModel): raise ValueError("Invalid UUID format in tag_ids.") from exc +# XSS prevention: patterns that could lead to XSS attacks +# Includes: script tags, iframe tags, javascript: protocol, SVG with onload, etc. +_XSS_PATTERNS = [ + r"]*>.*?", # Script tags + r"]*?(?:/>|>.*?)", # Iframe tags (including self-closing) + r"javascript:", # JavaScript protocol + r"]*?\s+onload\s*=[^>]*>", # SVG with onload handler (attribute-aware, flexible whitespace) + r"<.*?on\s*\w+\s*=", # Event handlers like onclick, onerror, etc. + r"]*(?:\s*/>|>.*?)", # Object tags (opening tag) + r"]*>", # Embed tags (self-closing) + r"]*>", # Link tags with javascript +] + + +def _validate_xss_safe(value: str | None, field_name: str = "Field") -> str | None: + """ + Validate that a string value doesn't contain potential XSS payloads. + + Args: + value: The string value to validate + field_name: Name of the field for error messages + + Returns: + The original value if safe + + Raises: + ValueError: If the value contains XSS patterns + """ + if value is None: + return None + + value_lower = value.lower() + for pattern in _XSS_PATTERNS: + if re.search(pattern, value_lower, re.DOTALL | re.IGNORECASE): + raise ValueError( + f"{field_name} contains invalid characters or patterns. " + "HTML tags, JavaScript, and other potentially dangerous content are not allowed." + ) + + return value + + class CreateAppPayload(BaseModel): name: str = Field(..., min_length=1, description="App name") description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400) @@ -81,6 +124,11 @@ class CreateAppPayload(BaseModel): icon: str | None = Field(default=None, description="Icon") icon_background: str | None = Field(default=None, description="Icon background color") + @field_validator("name", "description", mode="before") + @classmethod + def validate_xss_safe(cls, value: str | None, info) -> str | None: + return _validate_xss_safe(value, info.field_name) + class UpdateAppPayload(BaseModel): name: str = Field(..., min_length=1, description="App name") @@ -91,6 +139,11 @@ class UpdateAppPayload(BaseModel): use_icon_as_answer_icon: bool | None = Field(default=None, description="Use icon as answer icon") max_active_requests: int | None = Field(default=None, description="Maximum active requests") + @field_validator("name", "description", mode="before") + @classmethod + def validate_xss_safe(cls, value: str | None, info) -> str | None: + return _validate_xss_safe(value, info.field_name) + class CopyAppPayload(BaseModel): name: str | None = Field(default=None, description="Name for the copied app") @@ -99,6 +152,11 @@ class CopyAppPayload(BaseModel): icon: str | None = Field(default=None, description="Icon") icon_background: str | None = Field(default=None, description="Icon background color") + @field_validator("name", "description", mode="before") + @classmethod + def validate_xss_safe(cls, value: str | None, info) -> str | None: + return _validate_xss_safe(value, info.field_name) + class AppExportQuery(BaseModel): include_secret: bool = Field(default=False, description="Include secrets in export") diff --git a/api/tests/unit_tests/controllers/console/app/test_xss_prevention.py b/api/tests/unit_tests/controllers/console/app/test_xss_prevention.py new file mode 100644 index 0000000000..313818547b --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_xss_prevention.py @@ -0,0 +1,254 @@ +""" +Unit tests for XSS prevention in App payloads. + +This test module validates that HTML tags, JavaScript, and other potentially +dangerous content are rejected in App names and descriptions. +""" + +import pytest + +from controllers.console.app.app import CopyAppPayload, CreateAppPayload, UpdateAppPayload + + +class TestXSSPreventionUnit: + """Unit tests for XSS prevention in App payloads.""" + + def test_create_app_valid_names(self): + """Test CreateAppPayload with valid app names.""" + # Normal app names should be valid + valid_names = [ + "My App", + "Test App 123", + "App with - dash", + "App with _ underscore", + "App with + plus", + "App with () parentheses", + "App with [] brackets", + "App with {} braces", + "App with ! exclamation", + "App with @ at", + "App with # hash", + "App with $ dollar", + "App with % percent", + "App with ^ caret", + "App with & ampersand", + "App with * asterisk", + "Unicode: 测试应用", + "Emoji: 🤖", + "Mixed: Test 测试 123", + ] + + for name in valid_names: + payload = CreateAppPayload( + name=name, + mode="chat", + ) + assert payload.name == name + + def test_create_app_xss_script_tags(self): + """Test CreateAppPayload rejects script tags.""" + xss_payloads = [ + "", + "", + "", + "", + "", + "", + ] + + for name in xss_payloads: + with pytest.raises(ValueError) as exc_info: + CreateAppPayload(name=name, mode="chat") + assert "invalid characters or patterns" in str(exc_info.value).lower() + + def test_create_app_xss_iframe_tags(self): + """Test CreateAppPayload rejects iframe tags.""" + xss_payloads = [ + "", + "", + "", + ] + + for name in xss_payloads: + with pytest.raises(ValueError) as exc_info: + CreateAppPayload(name=name, mode="chat") + assert "invalid characters or patterns" in str(exc_info.value).lower() + + def test_create_app_xss_javascript_protocol(self): + """Test CreateAppPayload rejects javascript: protocol.""" + xss_payloads = [ + "javascript:alert(1)", + "JAVASCRIPT:alert(1)", + "JavaScript:alert(document.cookie)", + "javascript:void(0)", + "javascript://comment%0Aalert(1)", + ] + + for name in xss_payloads: + with pytest.raises(ValueError) as exc_info: + CreateAppPayload(name=name, mode="chat") + assert "invalid characters or patterns" in str(exc_info.value).lower() + + def test_create_app_xss_svg_onload(self): + """Test CreateAppPayload rejects SVG with onload.""" + xss_payloads = [ + "", + "", + "", + ] + + for name in xss_payloads: + with pytest.raises(ValueError) as exc_info: + CreateAppPayload(name=name, mode="chat") + assert "invalid characters or patterns" in str(exc_info.value).lower() + + def test_create_app_xss_event_handlers(self): + """Test CreateAppPayload rejects HTML event handlers.""" + xss_payloads = [ + "
", + "", + "", + "", + "", + "
", + "", + ] + + for name in xss_payloads: + with pytest.raises(ValueError) as exc_info: + CreateAppPayload(name=name, mode="chat") + assert "invalid characters or patterns" in str(exc_info.value).lower() + + def test_create_app_xss_object_embed(self): + """Test CreateAppPayload rejects object and embed tags.""" + xss_payloads = [ + "", + "", + "", + ] + + for name in xss_payloads: + with pytest.raises(ValueError) as exc_info: + CreateAppPayload(name=name, mode="chat") + assert "invalid characters or patterns" in str(exc_info.value).lower() + + def test_create_app_xss_link_javascript(self): + """Test CreateAppPayload rejects link tags with javascript.""" + xss_payloads = [ + "", + "", + ] + + for name in xss_payloads: + with pytest.raises(ValueError) as exc_info: + CreateAppPayload(name=name, mode="chat") + assert "invalid characters or patterns" in str(exc_info.value).lower() + + def test_create_app_xss_in_description(self): + """Test CreateAppPayload rejects XSS in description.""" + xss_descriptions = [ + "", + "javascript:alert(1)", + "", + ] + + for description in xss_descriptions: + with pytest.raises(ValueError) as exc_info: + CreateAppPayload( + name="Valid Name", + mode="chat", + description=description, + ) + assert "invalid characters or patterns" in str(exc_info.value).lower() + + def test_create_app_valid_descriptions(self): + """Test CreateAppPayload with valid descriptions.""" + valid_descriptions = [ + "A simple description", + "Description with < and > symbols", + "Description with & ampersand", + "Description with 'quotes' and \"double quotes\"", + "Description with / slashes", + "Description with \\ backslashes", + "Description with ; semicolons", + "Unicode: 这是一个描述", + "Emoji: 🎉🚀", + ] + + for description in valid_descriptions: + payload = CreateAppPayload( + name="Valid App Name", + mode="chat", + description=description, + ) + assert payload.description == description + + def test_create_app_none_description(self): + """Test CreateAppPayload with None description.""" + payload = CreateAppPayload( + name="Valid App Name", + mode="chat", + description=None, + ) + assert payload.description is None + + def test_update_app_xss_prevention(self): + """Test UpdateAppPayload also prevents XSS.""" + xss_names = [ + "", + "javascript:alert(1)", + "", + ] + + for name in xss_names: + with pytest.raises(ValueError) as exc_info: + UpdateAppPayload(name=name) + assert "invalid characters or patterns" in str(exc_info.value).lower() + + def test_update_app_valid_names(self): + """Test UpdateAppPayload with valid names.""" + payload = UpdateAppPayload(name="Valid Updated Name") + assert payload.name == "Valid Updated Name" + + def test_copy_app_xss_prevention(self): + """Test CopyAppPayload also prevents XSS.""" + xss_names = [ + "", + "javascript:alert(1)", + "", + ] + + for name in xss_names: + with pytest.raises(ValueError) as exc_info: + CopyAppPayload(name=name) + assert "invalid characters or patterns" in str(exc_info.value).lower() + + def test_copy_app_valid_names(self): + """Test CopyAppPayload with valid names.""" + payload = CopyAppPayload(name="Valid Copy Name") + assert payload.name == "Valid Copy Name" + + def test_copy_app_none_name(self): + """Test CopyAppPayload with None name (should be allowed).""" + payload = CopyAppPayload(name=None) + assert payload.name is None + + def test_edge_case_angle_brackets_content(self): + """Test that angle brackets with actual content are rejected.""" + # Angle brackets without valid HTML-like patterns should be checked + # The regex pattern <.*?on\w+\s*= should catch event handlers + # But let's verify other patterns too + + # Valid: angle brackets used as symbols (not matched by our patterns) + # Our patterns specifically look for dangerous constructs + + # Invalid: actual HTML tags with event handlers + invalid_names = [ + "
", + "", + ] + + for name in invalid_names: + with pytest.raises(ValueError) as exc_info: + CreateAppPayload(name=name, mode="chat") + assert "invalid characters or patterns" in str(exc_info.value).lower() From 27be89c9848d39b2f53c2e5bebc1e4e870db8489 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 31 Dec 2025 15:31:11 +0800 Subject: [PATCH 15/25] chore: lint for react compiler (#30417) --- web/eslint.config.mjs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 89f6d292cd..574dbb091e 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -13,6 +13,24 @@ export default antfu( 'react/no-forward-ref': 'off', 'react/no-use-context': 'off', 'react/prefer-namespace-import': 'error', + + // React Compiler rules + // Set to warn for gradual adoption + 'react-hooks/config': 'warn', + 'react-hooks/error-boundaries': 'warn', + 'react-hooks/component-hook-factories': 'warn', + 'react-hooks/gating': 'warn', + 'react-hooks/globals': 'warn', + 'react-hooks/immutability': 'warn', + 'react-hooks/preserve-manual-memoization': 'warn', + 'react-hooks/purity': 'warn', + 'react-hooks/refs': 'warn', + 'react-hooks/set-state-in-effect': 'warn', + 'react-hooks/set-state-in-render': 'warn', + 'react-hooks/static-components': 'warn', + 'react-hooks/unsupported-syntax': 'warn', + 'react-hooks/use-memo': 'warn', + 'react-hooks/incompatible-library': 'warn', }, }, nextjs: true, From e856287b65fc479a250ef2e2a9afd06b155f90e0 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 31 Dec 2025 15:38:07 +0800 Subject: [PATCH 16/25] chore: update knip config and include in CI (#30410) --- .github/workflows/style.yml | 5 + web/eslint-rules/rules/no-as-any-in-t.js | 5 - .../rules/no-legacy-namespace-prefix.js | 25 -- web/eslint-rules/rules/require-ns-option.js | 5 - web/knip.config.ts | 266 +----------------- web/package.json | 6 +- web/pnpm-lock.yaml | 29 +- .../script.mjs => scripts/gen-icons.mjs} | 13 +- 8 files changed, 30 insertions(+), 324 deletions(-) rename web/{app/components/base/icons/script.mjs => scripts/gen-icons.mjs} (91%) diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index d463349686..39b559f4ca 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -110,6 +110,11 @@ jobs: working-directory: ./web run: pnpm run type-check:tsgo + - name: Web dead code check + if: steps.changed-files.outputs.any_changed == 'true' + working-directory: ./web + run: pnpm run knip + superlinter: name: SuperLinter runs-on: ubuntu-latest diff --git a/web/eslint-rules/rules/no-as-any-in-t.js b/web/eslint-rules/rules/no-as-any-in-t.js index 0eb134a3cf..5e4ffc8c1c 100644 --- a/web/eslint-rules/rules/no-as-any-in-t.js +++ b/web/eslint-rules/rules/no-as-any-in-t.js @@ -29,11 +29,6 @@ export default { const options = context.options[0] || {} const mode = options.mode || 'any' - /** - * Check if this is a t() function call - * @param {import('estree').CallExpression} node - * @returns {boolean} - */ function isTCall(node) { // Direct t() call if (node.callee.type === 'Identifier' && node.callee.name === 't') diff --git a/web/eslint-rules/rules/no-legacy-namespace-prefix.js b/web/eslint-rules/rules/no-legacy-namespace-prefix.js index dc268c9b65..023e6b73d3 100644 --- a/web/eslint-rules/rules/no-legacy-namespace-prefix.js +++ b/web/eslint-rules/rules/no-legacy-namespace-prefix.js @@ -19,26 +19,11 @@ export default { create(context) { const sourceCode = context.sourceCode - // Track all t() calls to fix - /** @type {Array<{ node: import('estree').CallExpression }>} */ const tCallsToFix = [] - - // Track variables with namespace prefix - /** @type {Map} */ const variablesToFix = new Map() - - // Track all namespaces used in the file (from legacy prefix detection) - /** @type {Set} */ const namespacesUsed = new Set() - - // Track variable values for template literal analysis - /** @type {Map} */ const variableValues = new Map() - /** - * Analyze a template literal and extract namespace info - * @param {import('estree').TemplateLiteral} node - */ function analyzeTemplateLiteral(node) { const quasis = node.quasis const expressions = node.expressions @@ -78,11 +63,6 @@ export default { return { ns: null, canFix: false, fixedQuasis: null, variableToUpdate: null } } - /** - * Build a fixed template literal string - * @param {string[]} quasis - * @param {import('estree').Expression[]} expressions - */ function buildTemplateLiteral(quasis, expressions) { let result = '`' for (let i = 0; i < quasis.length; i++) { @@ -95,11 +75,6 @@ export default { return result } - /** - * Check if a t() call already has ns in its second argument - * @param {import('estree').CallExpression} node - * @returns {boolean} - */ function hasNsArgument(node) { if (node.arguments.length < 2) return false diff --git a/web/eslint-rules/rules/require-ns-option.js b/web/eslint-rules/rules/require-ns-option.js index df8f7ec2e8..74621596fd 100644 --- a/web/eslint-rules/rules/require-ns-option.js +++ b/web/eslint-rules/rules/require-ns-option.js @@ -12,11 +12,6 @@ export default { }, }, create(context) { - /** - * Check if a t() call has ns in its second argument - * @param {import('estree').CallExpression} node - * @returns {boolean} - */ function hasNsOption(node) { if (node.arguments.length < 2) return false diff --git a/web/knip.config.ts b/web/knip.config.ts index 975a85b997..414b00fb7f 100644 --- a/web/knip.config.ts +++ b/web/knip.config.ts @@ -1,277 +1,33 @@ import type { KnipConfig } from 'knip' /** - * Knip Configuration for Dead Code Detection - * - * This configuration helps identify unused files, exports, and dependencies - * in the Dify web application (Next.js 15 + TypeScript + React 19). - * - * ⚠️ SAFETY FIRST: This configuration is designed to be conservative and - * avoid false positives that could lead to deleting actively used code. - * * @see https://knip.dev/reference/configuration */ const config: KnipConfig = { - // ============================================================================ - // Next.js Framework Configuration - // ============================================================================ - // Configure entry points specific to Next.js application structure. - // These files are automatically treated as entry points by the framework. - next: { - entry: [ - // Next.js App Router pages (must exist for routing) - 'app/**/page.tsx', - 'app/**/layout.tsx', - 'app/**/loading.tsx', - 'app/**/error.tsx', - 'app/**/not-found.tsx', - 'app/**/template.tsx', - 'app/**/default.tsx', - - // Middleware (runs before every route) - 'middleware.ts', - - // Configuration files - 'next.config.js', - 'tailwind.config.js', - 'tailwind-common-config.ts', - 'postcss.config.js', - - // Linting configuration - 'eslint.config.mjs', - ], - }, - - // ============================================================================ - // Global Entry Points - // ============================================================================ - // Files that serve as entry points for the application. - // The '!' suffix means these patterns take precedence and are always included. entry: [ - // Next.js App Router patterns (high priority) - 'app/**/page.tsx!', - 'app/**/layout.tsx!', - 'app/**/loading.tsx!', - 'app/**/error.tsx!', - 'app/**/not-found.tsx!', - 'app/**/template.tsx!', - 'app/**/default.tsx!', - - // Core configuration files - 'middleware.ts!', - 'next.config.js!', - 'tailwind.config.js!', - 'tailwind-common-config.ts!', - 'postcss.config.js!', - - // Linting setup - 'eslint.config.mjs!', - - // ======================================================================== - // 🔒 CRITICAL: Global Initializers and Providers - // ======================================================================== - // These files are imported by root layout.tsx and provide global functionality. - // Even if not directly imported elsewhere, they are essential for app initialization. - - // Browser initialization (runs on client startup) - 'app/components/browser-initializer.tsx!', - 'app/components/sentry-initializer.tsx!', - 'app/components/app-initializer.tsx!', - - // i18n initialization (server and client) - 'app/components/i18n.tsx!', - 'app/components/i18n-server.tsx!', - - // Route prefix handling (used in root layout) - 'app/routePrefixHandle.tsx!', - - // ======================================================================== - // 🔒 CRITICAL: Context Providers - // ======================================================================== - // Context providers might be used via React Context API and imported dynamically. - // Protecting all context files to prevent breaking the provider chain. - 'context/**/*.ts?(x)!', - - // Component-level contexts (also used via React.useContext) - 'app/components/**/*.context.ts?(x)!', - - // ======================================================================== - // 🔒 CRITICAL: State Management Stores - // ======================================================================== - // Zustand stores might be imported dynamically or via hooks. - // These are often imported at module level, so they should be protected. - 'app/components/**/*.store.ts?(x)!', - 'context/**/*.store.ts?(x)!', - - // ======================================================================== - // 🔒 CRITICAL: Provider Components - // ======================================================================== - // Provider components wrap the app and provide global state/functionality - 'app/components/**/*.provider.ts?(x)!', - 'context/**/*.provider.ts?(x)!', - - // ======================================================================== - // Development tools - // ======================================================================== - // Storybook configuration - '.storybook/**/*', + 'scripts/**/*.{js,ts,mjs}', + 'bin/**/*.{js,ts,mjs}', ], - - // ============================================================================ - // Project Files to Analyze - // ============================================================================ - // Glob patterns for files that should be analyzed for unused code. - // Excludes test files to avoid false positives. - project: [ - '**/*.{js,jsx,ts,tsx,mjs,cjs}', - ], - - // ============================================================================ - // Ignored Files and Directories - // ============================================================================ - // Files and directories that should be completely excluded from analysis. - // These typically contain: - // - Test files - // - Internationalization files (loaded dynamically) - // - Static assets - // - Build outputs - // - External libraries ignore: [ - // Test files and directories - '**/__tests__/**', - '**/*.spec.{ts,tsx}', - '**/*.test.{ts,tsx}', - - // ======================================================================== - // 🔒 CRITICAL: i18n Files (Dynamically Loaded) - // ======================================================================== - // Internationalization files are loaded dynamically at runtime via i18next. - // Pattern: import(`@/i18n/${locale}/messages`) - // These will NEVER show up in static analysis but are essential! 'i18n/**', - - // ======================================================================== - // 🔒 CRITICAL: Static Assets - // ======================================================================== - // Static assets are referenced by URL in the browser, not via imports. - // Examples: /logo.png, /icons/*, /embed.js 'public/**', - - // Build outputs and caches - 'node_modules/**', - '.next/**', - 'coverage/**', - - // Development tools - '**/*.stories.{ts,tsx}', - - // ======================================================================== - // 🔒 Utility scripts (not part of application runtime) - // ======================================================================== - // These scripts are run manually (e.g., pnpm gen-icons, pnpm i18n:check) - // and are not imported by the application code. - 'scripts/**', - 'bin/**', - 'i18n-config/**', - - // Icon generation script (generates components, not used in runtime) - 'app/components/base/icons/script.mjs', ], - - // ============================================================================ - // Ignored Dependencies - // ============================================================================ - // Dependencies that are used but not directly imported in code. - // These are typically: - // - Build tools - // - Plugins loaded by configuration files - // - CLI tools - ignoreDependencies: [ - // ======================================================================== - // Next.js plugins (loaded by next.config.js) - // ======================================================================== - 'next-pwa', - '@next/bundle-analyzer', - '@next/mdx', - - // ======================================================================== - // Build tools (used by webpack/next.js build process) - // ======================================================================== - 'code-inspector-plugin', - - // ======================================================================== - // Development and translation tools (used by scripts) - // ======================================================================== - 'bing-translate-api', - 'uglify-js', - ], - - // ============================================================================ - // Export Analysis Configuration - // ============================================================================ - // Configure how exports are analyzed - - // Ignore exports that are only used within the same file - // (e.g., helper functions used internally in the same module) - ignoreExportsUsedInFile: true, - - // ⚠️ SAFETY: Include exports from entry files in the analysis - // This helps find unused public APIs, but be careful with: - // - Context exports (useContext hooks) - // - Store exports (useStore hooks) - // - Type exports (might be used in other files) - includeEntryExports: true, - - // ============================================================================ - // Ignored Binaries - // ============================================================================ - // Binary executables that are used but not listed in package.json ignoreBinaries: [ - 'only-allow', // Used in preinstall script to enforce pnpm usage + 'only-allow', ], - - // ============================================================================ - // Reporting Rules - // ============================================================================ - // Configure what types of issues to report and at what severity level rules: { - // ======================================================================== - // Unused files are ERRORS - // ======================================================================== - // These should definitely be removed or used. - // However, always manually verify before deleting! - files: 'error', - - // ======================================================================== - // Unused dependencies are WARNINGS - // ======================================================================== - // Dependencies might be: - // - Used in production builds but not in dev - // - Peer dependencies - // - Used by other tools + files: 'warn', dependencies: 'warn', devDependencies: 'warn', - - // ======================================================================== - // Unlisted imports are ERRORS - // ======================================================================== - // Missing from package.json - will break in production! - unlisted: 'error', - - // ======================================================================== - // Unused exports are WARNINGS (not errors!) - // ======================================================================== - // Exports might be: - // - Part of public API for future use - // - Used by external tools - // - Exported for type inference - // ⚠️ ALWAYS manually verify before removing exports! + optionalPeerDependencies: 'warn', + unlisted: 'warn', + unresolved: 'warn', exports: 'warn', - - // Unused types are warnings (might be part of type definitions) + nsExports: 'warn', + classMembers: 'warn', types: 'warn', - - // Duplicate exports are warnings (could cause confusion but not breaking) + nsTypes: 'warn', + enumMembers: 'warn', duplicates: 'warn', }, } diff --git a/web/package.json b/web/package.json index a78575304c..0c6821ce86 100644 --- a/web/package.json +++ b/web/package.json @@ -31,7 +31,7 @@ "type-check": "tsc --noEmit", "type-check:tsgo": "tsgo --noEmit", "prepare": "cd ../ && node -e \"if (process.env.NODE_ENV !== 'production'){process.exit(1)} \" || husky ./web/.husky", - "gen-icons": "node ./app/components/base/icons/script.mjs", + "gen-icons": "node ./scripts/gen-icons.mjs && eslint --fix app/components/base/icons/src/", "uglify-embed": "node ./bin/uglify-embed", "i18n:check": "tsx ./scripts/check-i18n.js", "i18n:gen": "tsx ./scripts/auto-gen-i18n.js", @@ -190,7 +190,6 @@ "@vitejs/plugin-react": "^5.1.2", "@vitest/coverage-v8": "4.0.16", "autoprefixer": "^10.4.21", - "babel-loader": "^10.0.0", "bing-translate-api": "^4.1.0", "code-inspector-plugin": "1.2.9", "cross-env": "^10.1.0", @@ -201,10 +200,9 @@ "eslint-plugin-storybook": "^10.1.10", "eslint-plugin-tailwindcss": "^3.18.2", "husky": "^9.1.7", - "istanbul-lib-coverage": "^3.2.2", "jsdom": "^27.3.0", "jsdom-testing-mocks": "^1.16.0", - "knip": "^5.66.1", + "knip": "^5.78.0", "lint-staged": "^15.5.2", "nock": "^14.0.10", "postcss": "^8.5.6", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index fe9032d248..373e2e4020 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -481,9 +481,6 @@ importers: autoprefixer: specifier: ^10.4.21 version: 10.4.22(postcss@8.5.6) - babel-loader: - specifier: ^10.0.0 - version: 10.0.0(@babel/core@7.28.5)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)) bing-translate-api: specifier: ^4.1.0 version: 4.2.0 @@ -514,9 +511,6 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 - istanbul-lib-coverage: - specifier: ^3.2.2 - version: 3.2.2 jsdom: specifier: ^27.3.0 version: 27.3.0(canvas@3.2.0) @@ -524,8 +518,8 @@ importers: specifier: ^1.16.0 version: 1.16.0 knip: - specifier: ^5.66.1 - version: 5.72.0(@types/node@18.15.0)(typescript@5.9.3) + specifier: ^5.78.0 + version: 5.78.0(@types/node@18.15.0)(typescript@5.9.3) lint-staged: specifier: ^15.5.2 version: 15.5.2 @@ -4271,13 +4265,6 @@ packages: peerDependencies: postcss: ^8.1.0 - babel-loader@10.0.0: - resolution: {integrity: sha512-z8jt+EdS61AMw22nSfoNJAZ0vrtmhPRVi6ghL3rCeRZI8cdNYFiV5xeV3HbE7rlZZNmGH8BVccwWt8/ED0QOHA==} - engines: {node: ^18.20.0 || ^20.10.0 || >=22.0.0} - peerDependencies: - '@babel/core': ^7.12.0 - webpack: '>=5.61.0' - babel-loader@8.4.1: resolution: {integrity: sha512-nXzRChX+Z1GoE6yWavBQg6jDslyFF3SDjl2paADuoQtQW10JqShJt62R6eJQ5m/pjJFDT8xgKIWSP85OY8eXeA==} engines: {node: '>= 8.9'} @@ -6336,8 +6323,8 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - knip@5.72.0: - resolution: {integrity: sha512-rlyoXI8FcggNtM/QXd/GW0sbsYvNuA/zPXt7bsuVi6kVQogY2PDCr81bPpzNnl0CP8AkFm2Z2plVeL5QQSis2w==} + knip@5.78.0: + resolution: {integrity: sha512-nB7i/fgiJl7WVxdv5lX4ZPfDt9/zrw/lOgZtyioy988xtFhKuFJCRdHWT1Zg9Avc0yaojvnmEuAXU8SeMblKww==} engines: {node: '>=18.18.0'} hasBin: true peerDependencies: @@ -13093,12 +13080,6 @@ snapshots: postcss: 8.5.6 postcss-value-parser: 4.2.0 - babel-loader@10.0.0(@babel/core@7.28.5)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)): - dependencies: - '@babel/core': 7.28.5 - find-up: 5.0.0 - webpack: 5.103.0(esbuild@0.25.0)(uglify-js@3.19.3) - babel-loader@8.4.1(@babel/core@7.28.5)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)): dependencies: '@babel/core': 7.28.5 @@ -15468,7 +15449,7 @@ snapshots: kleur@4.1.5: {} - knip@5.72.0(@types/node@18.15.0)(typescript@5.9.3): + knip@5.78.0(@types/node@18.15.0)(typescript@5.9.3): dependencies: '@nodelib/fs.walk': 1.2.8 '@types/node': 18.15.0 diff --git a/web/app/components/base/icons/script.mjs b/web/scripts/gen-icons.mjs similarity index 91% rename from web/app/components/base/icons/script.mjs rename to web/scripts/gen-icons.mjs index 81566cc4cf..f681d65759 100644 --- a/web/app/components/base/icons/script.mjs +++ b/web/scripts/gen-icons.mjs @@ -5,6 +5,7 @@ import { parseXml } from '@rgrove/parse-xml' import { camelCase, template } from 'es-toolkit/compat' const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const iconsDir = path.resolve(__dirname, '../app/components/base/icons') const generateDir = async (currentPath) => { try { @@ -32,7 +33,7 @@ const processSvgStructure = (svgStructure, replaceFillOrStrokeColor) => { } } const generateSvgComponent = async (fileHandle, entry, pathList, replaceFillOrStrokeColor) => { - const currentPath = path.resolve(__dirname, 'src', ...pathList.slice(2)) + const currentPath = path.resolve(iconsDir, 'src', ...pathList.slice(2)) try { await access(currentPath) @@ -86,7 +87,7 @@ export { default as <%= svgName %> } from './<%= svgName %>' } const generateImageComponent = async (entry, pathList) => { - const currentPath = path.resolve(__dirname, 'src', ...pathList.slice(2)) + const currentPath = path.resolve(iconsDir, 'src', ...pathList.slice(2)) try { await access(currentPath) @@ -167,8 +168,8 @@ const walk = async (entry, pathList, replaceFillOrStrokeColor) => { } (async () => { - await rm(path.resolve(__dirname, 'src'), { recursive: true, force: true }) - await walk('public', [__dirname, 'assets']) - await walk('vender', [__dirname, 'assets'], true) - await walk('image', [__dirname, 'assets']) + await rm(path.resolve(iconsDir, 'src'), { recursive: true, force: true }) + await walk('public', [iconsDir, 'assets']) + await walk('vender', [iconsDir, 'assets'], true) + await walk('image', [iconsDir, 'assets']) })() From cad7101534c251f5d0c12218d6d000bab39365d1 Mon Sep 17 00:00:00 2001 From: Zhiqiang Yang Date: Wed, 31 Dec 2025 15:49:06 +0800 Subject: [PATCH 17/25] feat: support image extraction in PDF RAG extractor (#30399) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/rag/extractor/extract_processor.py | 4 +- api/core/rag/extractor/pdf_extractor.py | 122 +++++++++++- .../core/rag/extractor/test_pdf_extractor.py | 186 ++++++++++++++++++ 3 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 api/tests/unit_tests/core/rag/extractor/test_pdf_extractor.py diff --git a/api/core/rag/extractor/extract_processor.py b/api/core/rag/extractor/extract_processor.py index 013c287248..6d28ce25bc 100644 --- a/api/core/rag/extractor/extract_processor.py +++ b/api/core/rag/extractor/extract_processor.py @@ -112,7 +112,7 @@ class ExtractProcessor: if file_extension in {".xlsx", ".xls"}: extractor = ExcelExtractor(file_path) elif file_extension == ".pdf": - extractor = PdfExtractor(file_path) + extractor = PdfExtractor(file_path, upload_file.tenant_id, upload_file.created_by) elif file_extension in {".md", ".markdown", ".mdx"}: extractor = ( UnstructuredMarkdownExtractor(file_path, unstructured_api_url, unstructured_api_key) @@ -148,7 +148,7 @@ class ExtractProcessor: if file_extension in {".xlsx", ".xls"}: extractor = ExcelExtractor(file_path) elif file_extension == ".pdf": - extractor = PdfExtractor(file_path) + extractor = PdfExtractor(file_path, upload_file.tenant_id, upload_file.created_by) elif file_extension in {".md", ".markdown", ".mdx"}: extractor = MarkdownExtractor(file_path, autodetect_encoding=True) elif file_extension in {".htm", ".html"}: diff --git a/api/core/rag/extractor/pdf_extractor.py b/api/core/rag/extractor/pdf_extractor.py index 80530d99a6..6aabcac704 100644 --- a/api/core/rag/extractor/pdf_extractor.py +++ b/api/core/rag/extractor/pdf_extractor.py @@ -1,25 +1,57 @@ """Abstract interface for document loader implementations.""" import contextlib +import io +import logging +import uuid from collections.abc import Iterator +import pypdfium2 +import pypdfium2.raw as pdfium_c + +from configs import dify_config from core.rag.extractor.blob.blob import Blob from core.rag.extractor.extractor_base import BaseExtractor from core.rag.models.document import Document +from extensions.ext_database import db from extensions.ext_storage import storage +from libs.datetime_utils import naive_utc_now +from models.enums import CreatorUserRole +from models.model import UploadFile + +logger = logging.getLogger(__name__) class PdfExtractor(BaseExtractor): - """Load pdf files. - + """ + PdfExtractor is used to extract text and images from PDF files. Args: - file_path: Path to the file to load. + file_path: Path to the PDF file. + tenant_id: Workspace ID. + user_id: ID of the user performing the extraction. + file_cache_key: Optional cache key for the extracted text. """ - def __init__(self, file_path: str, file_cache_key: str | None = None): - """Initialize with file path.""" + # Magic bytes for image format detection: (magic_bytes, extension, mime_type) + IMAGE_FORMATS = [ + (b"\xff\xd8\xff", "jpg", "image/jpeg"), + (b"\x89PNG\r\n\x1a\n", "png", "image/png"), + (b"\x00\x00\x00\x0c\x6a\x50\x20\x20\x0d\x0a\x87\x0a", "jp2", "image/jp2"), + (b"GIF8", "gif", "image/gif"), + (b"BM", "bmp", "image/bmp"), + (b"II*\x00", "tiff", "image/tiff"), + (b"MM\x00*", "tiff", "image/tiff"), + (b"II+\x00", "tiff", "image/tiff"), + (b"MM\x00+", "tiff", "image/tiff"), + ] + MAX_MAGIC_LEN = max(len(m) for m, _, _ in IMAGE_FORMATS) + + def __init__(self, file_path: str, tenant_id: str, user_id: str, file_cache_key: str | None = None): + """Initialize PdfExtractor.""" self._file_path = file_path + self._tenant_id = tenant_id + self._user_id = user_id self._file_cache_key = file_cache_key def extract(self) -> list[Document]: @@ -50,7 +82,6 @@ class PdfExtractor(BaseExtractor): def parse(self, blob: Blob) -> Iterator[Document]: """Lazily parse the blob.""" - import pypdfium2 # type: ignore with blob.as_bytes_io() as file_path: pdf_reader = pypdfium2.PdfDocument(file_path, autoclose=True) @@ -59,8 +90,87 @@ class PdfExtractor(BaseExtractor): text_page = page.get_textpage() content = text_page.get_text_range() text_page.close() + + image_content = self._extract_images(page) + if image_content: + content += "\n" + image_content + page.close() metadata = {"source": blob.source, "page": page_number} yield Document(page_content=content, metadata=metadata) finally: pdf_reader.close() + + def _extract_images(self, page) -> str: + """ + Extract images from a PDF page, save them to storage and database, + and return markdown image links. + + Args: + page: pypdfium2 page object. + + Returns: + Markdown string containing links to the extracted images. + """ + image_content = [] + upload_files = [] + base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL + + try: + image_objects = page.get_objects(filter=(pdfium_c.FPDF_PAGEOBJ_IMAGE,)) + for obj in image_objects: + try: + # Extract image bytes + img_byte_arr = io.BytesIO() + # Extract DCTDecode (JPEG) and JPXDecode (JPEG 2000) images directly + # Fallback to png for other formats + obj.extract(img_byte_arr, fb_format="png") + img_bytes = img_byte_arr.getvalue() + + if not img_bytes: + continue + + header = img_bytes[: self.MAX_MAGIC_LEN] + image_ext = None + mime_type = None + for magic, ext, mime in self.IMAGE_FORMATS: + if header.startswith(magic): + image_ext = ext + mime_type = mime + break + + if not image_ext or not mime_type: + continue + + file_uuid = str(uuid.uuid4()) + file_key = "image_files/" + self._tenant_id + "/" + file_uuid + "." + image_ext + + storage.save(file_key, img_bytes) + + # save file to db + upload_file = UploadFile( + tenant_id=self._tenant_id, + storage_type=dify_config.STORAGE_TYPE, + key=file_key, + name=file_key, + size=len(img_bytes), + extension=image_ext, + mime_type=mime_type, + created_by=self._user_id, + created_by_role=CreatorUserRole.ACCOUNT, + created_at=naive_utc_now(), + used=True, + used_by=self._user_id, + used_at=naive_utc_now(), + ) + upload_files.append(upload_file) + image_content.append(f"![image]({base_url}/files/{upload_file.id}/file-preview)") + except Exception as e: + logger.warning("Failed to extract image from PDF: %s", e) + continue + except Exception as e: + logger.warning("Failed to get objects from PDF page: %s", e) + if upload_files: + db.session.add_all(upload_files) + db.session.commit() + return "\n".join(image_content) diff --git a/api/tests/unit_tests/core/rag/extractor/test_pdf_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_pdf_extractor.py new file mode 100644 index 0000000000..3167a9a301 --- /dev/null +++ b/api/tests/unit_tests/core/rag/extractor/test_pdf_extractor.py @@ -0,0 +1,186 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +import core.rag.extractor.pdf_extractor as pe + + +@pytest.fixture +def mock_dependencies(monkeypatch): + # Mock storage + saves = [] + + def save(key, data): + saves.append((key, data)) + + monkeypatch.setattr(pe, "storage", SimpleNamespace(save=save)) + + # Mock db + class DummySession: + def __init__(self): + self.added = [] + self.committed = False + + def add(self, obj): + self.added.append(obj) + + def add_all(self, objs): + self.added.extend(objs) + + def commit(self): + self.committed = True + + db_stub = SimpleNamespace(session=DummySession()) + monkeypatch.setattr(pe, "db", db_stub) + + # Mock UploadFile + class FakeUploadFile: + DEFAULT_ID = "test_file_id" + + def __init__(self, **kwargs): + # Assign id from DEFAULT_ID, allow override via kwargs if needed + self.id = self.DEFAULT_ID + for k, v in kwargs.items(): + setattr(self, k, v) + + monkeypatch.setattr(pe, "UploadFile", FakeUploadFile) + + # Mock config + monkeypatch.setattr(pe.dify_config, "FILES_URL", "http://files.local") + monkeypatch.setattr(pe.dify_config, "INTERNAL_FILES_URL", None) + monkeypatch.setattr(pe.dify_config, "STORAGE_TYPE", "local") + + return SimpleNamespace(saves=saves, db=db_stub, UploadFile=FakeUploadFile) + + +@pytest.mark.parametrize( + ("image_bytes", "expected_mime", "expected_ext", "file_id"), + [ + (b"\xff\xd8\xff some jpeg", "image/jpeg", "jpg", "test_file_id_jpeg"), + (b"\x89PNG\r\n\x1a\n some png", "image/png", "png", "test_file_id_png"), + ], +) +def test_extract_images_formats(mock_dependencies, monkeypatch, image_bytes, expected_mime, expected_ext, file_id): + saves = mock_dependencies.saves + db_stub = mock_dependencies.db + + # Customize FakeUploadFile id for this test case. + # Using monkeypatch ensures the class attribute is reset between parameter sets. + monkeypatch.setattr(mock_dependencies.UploadFile, "DEFAULT_ID", file_id) + + # Mock page and image objects + mock_page = MagicMock() + mock_image_obj = MagicMock() + + def mock_extract(buf, fb_format=None): + buf.write(image_bytes) + + mock_image_obj.extract.side_effect = mock_extract + + mock_page.get_objects.return_value = [mock_image_obj] + + extractor = pe.PdfExtractor(file_path="test.pdf", tenant_id="t1", user_id="u1") + + # We need to handle the import inside _extract_images + with patch("pypdfium2.raw") as mock_raw: + mock_raw.FPDF_PAGEOBJ_IMAGE = 1 + result = extractor._extract_images(mock_page) + + assert f"![image](http://files.local/files/{file_id}/file-preview)" in result + assert len(saves) == 1 + assert saves[0][1] == image_bytes + assert len(db_stub.session.added) == 1 + assert db_stub.session.added[0].tenant_id == "t1" + assert db_stub.session.added[0].size == len(image_bytes) + assert db_stub.session.added[0].mime_type == expected_mime + assert db_stub.session.added[0].extension == expected_ext + assert db_stub.session.committed is True + + +@pytest.mark.parametrize( + ("get_objects_side_effect", "get_objects_return_value"), + [ + (None, []), # Empty list + (None, None), # None returned + (Exception("Failed to get objects"), None), # Exception raised + ], +) +def test_extract_images_get_objects_scenarios(mock_dependencies, get_objects_side_effect, get_objects_return_value): + mock_page = MagicMock() + if get_objects_side_effect: + mock_page.get_objects.side_effect = get_objects_side_effect + else: + mock_page.get_objects.return_value = get_objects_return_value + + extractor = pe.PdfExtractor(file_path="test.pdf", tenant_id="t1", user_id="u1") + + with patch("pypdfium2.raw") as mock_raw: + mock_raw.FPDF_PAGEOBJ_IMAGE = 1 + result = extractor._extract_images(mock_page) + + assert result == "" + + +def test_extract_calls_extract_images(mock_dependencies, monkeypatch): + # Mock pypdfium2 + mock_pdf_doc = MagicMock() + mock_page = MagicMock() + mock_pdf_doc.__iter__.return_value = [mock_page] + + # Mock text extraction + mock_text_page = MagicMock() + mock_text_page.get_text_range.return_value = "Page text content" + mock_page.get_textpage.return_value = mock_text_page + + with patch("pypdfium2.PdfDocument", return_value=mock_pdf_doc): + # Mock Blob + mock_blob = MagicMock() + mock_blob.source = "test.pdf" + with patch("core.rag.extractor.pdf_extractor.Blob.from_path", return_value=mock_blob): + extractor = pe.PdfExtractor(file_path="test.pdf", tenant_id="t1", user_id="u1") + + # Mock _extract_images to return a known string + monkeypatch.setattr(extractor, "_extract_images", lambda p: "![image](img_url)") + + documents = list(extractor.extract()) + + assert len(documents) == 1 + assert "Page text content" in documents[0].page_content + assert "![image](img_url)" in documents[0].page_content + assert documents[0].metadata["page"] == 0 + + +def test_extract_images_failures(mock_dependencies): + saves = mock_dependencies.saves + db_stub = mock_dependencies.db + + # Mock page and image objects + mock_page = MagicMock() + mock_image_obj_fail = MagicMock() + mock_image_obj_ok = MagicMock() + + # First image raises exception + mock_image_obj_fail.extract.side_effect = Exception("Extraction failure") + + # Second image is OK (JPEG) + jpeg_bytes = b"\xff\xd8\xff some image data" + + def mock_extract(buf, fb_format=None): + buf.write(jpeg_bytes) + + mock_image_obj_ok.extract.side_effect = mock_extract + + mock_page.get_objects.return_value = [mock_image_obj_fail, mock_image_obj_ok] + + extractor = pe.PdfExtractor(file_path="test.pdf", tenant_id="t1", user_id="u1") + + with patch("pypdfium2.raw") as mock_raw: + mock_raw.FPDF_PAGEOBJ_IMAGE = 1 + result = extractor._extract_images(mock_page) + + # Should have one success + assert "![image](http://files.local/files/test_file_id/file-preview)" in result + assert len(saves) == 1 + assert saves[0][1] == jpeg_bytes + assert db_stub.session.committed is True From 2bb1e24fb4593220f7278605ab0154aa43f4e28e Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 31 Dec 2025 15:53:33 +0800 Subject: [PATCH 18/25] test: unify i18next mocks into centralized helpers (#30376) Co-authored-by: Claude Opus 4.5 Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh --- .../assets/component-test.template.tsx | 17 ++-- .../frontend-testing/references/mocking.md | 28 ++++--- .../config/agent-setting-button.spec.tsx | 9 --- .../config/config-audio.spec.tsx | 9 --- .../base/inline-delete-confirm/index.spec.tsx | 26 ++---- .../base/input-with-copy/index.spec.tsx | 23 ++---- web/app/components/base/input/index.spec.tsx | 19 ++--- .../billing/pricing/footer.spec.tsx | 18 ----- .../components/datasets/create/index.spec.tsx | 10 --- .../processing/index.spec.tsx | 10 --- .../components/plugins/card/index.spec.tsx | 27 ------- .../steps/install.spec.tsx | 34 ++++---- .../steps/uploading.spec.tsx | 15 ---- .../plugins/marketplace/index.spec.tsx | 46 +++++------ .../create/common-modal.spec.tsx | 11 --- .../create/oauth-client.spec.tsx | 11 --- .../subscription-list/edit/index.spec.tsx | 10 --- .../plugin-mutation-model/index.spec.tsx | 22 ------ web/test/i18n-mock.ts | 79 +++++++++++++++++++ web/testing/testing.md | 27 ++++--- web/vitest.setup.ts | 20 +---- 21 files changed, 178 insertions(+), 293 deletions(-) create mode 100644 web/test/i18n-mock.ts diff --git a/.claude/skills/frontend-testing/assets/component-test.template.tsx b/.claude/skills/frontend-testing/assets/component-test.template.tsx index c39baff916..6b7803bd4b 100644 --- a/.claude/skills/frontend-testing/assets/component-test.template.tsx +++ b/.claude/skills/frontend-testing/assets/component-test.template.tsx @@ -28,17 +28,14 @@ import userEvent from '@testing-library/user-event' // i18n (automatically mocked) // WHY: Global mock in web/vitest.setup.ts is auto-loaded by Vitest setup -// No explicit mock needed - it returns translation keys as-is +// The global mock provides: useTranslation, Trans, useMixedTranslation, useGetLanguage +// No explicit mock needed for most tests +// // Override only if custom translations are required: -// vi.mock('react-i18next', () => ({ -// useTranslation: () => ({ -// t: (key: string) => { -// const customTranslations: Record = { -// 'my.custom.key': 'Custom Translation', -// } -// return customTranslations[key] || key -// }, -// }), +// import { createReactI18nextMock } from '@/test/i18n-mock' +// vi.mock('react-i18next', () => createReactI18nextMock({ +// 'my.custom.key': 'Custom Translation', +// 'button.save': 'Save', // })) // Router (if component uses useRouter, usePathname, useSearchParams) diff --git a/.claude/skills/frontend-testing/references/mocking.md b/.claude/skills/frontend-testing/references/mocking.md index 23889c8d3d..c70bcf0ae5 100644 --- a/.claude/skills/frontend-testing/references/mocking.md +++ b/.claude/skills/frontend-testing/references/mocking.md @@ -52,23 +52,29 @@ Modules are not mocked automatically. Use `vi.mock` in test files, or add global ### 1. i18n (Auto-loaded via Global Mock) A global mock is defined in `web/vitest.setup.ts` and is auto-loaded by Vitest setup. -**No explicit mock needed** for most tests - it returns translation keys as-is. -For tests requiring custom translations, override the mock: +The global mock provides: + +- `useTranslation` - returns translation keys with namespace prefix +- `Trans` component - renders i18nKey and components +- `useMixedTranslation` (from `@/app/components/plugins/marketplace/hooks`) +- `useGetLanguage` (from `@/context/i18n`) - returns `'en-US'` + +**Default behavior**: Most tests should use the global mock (no local override needed). + +**For custom translations**: Use the helper function from `@/test/i18n-mock`: ```typescript -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - 'my.custom.key': 'Custom translation', - } - return translations[key] || key - }, - }), +import { createReactI18nextMock } from '@/test/i18n-mock' + +vi.mock('react-i18next', () => createReactI18nextMock({ + 'my.custom.key': 'Custom translation', + 'button.save': 'Save', })) ``` +**Avoid**: Manually defining `useTranslation` mocks that just return the key - the global mock already does this. + ### 2. Next.js Router ```typescript diff --git a/web/app/components/app/configuration/config/agent-setting-button.spec.tsx b/web/app/components/app/configuration/config/agent-setting-button.spec.tsx index 1874a3cccf..492b3b104c 100644 --- a/web/app/components/app/configuration/config/agent-setting-button.spec.tsx +++ b/web/app/components/app/configuration/config/agent-setting-button.spec.tsx @@ -5,15 +5,6 @@ import * as React from 'react' import { AgentStrategy } from '@/types/app' import AgentSettingButton from './agent-setting-button' -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - const prefix = options?.ns ? `${options.ns}.` : '' - return `${prefix}${key}` - }, - }), -})) - let latestAgentSettingProps: any vi.mock('./agent/agent-setting', () => ({ default: (props: any) => { diff --git a/web/app/components/app/configuration/config/config-audio.spec.tsx b/web/app/components/app/configuration/config/config-audio.spec.tsx index 41219fd1fa..a3e5c7c149 100644 --- a/web/app/components/app/configuration/config/config-audio.spec.tsx +++ b/web/app/components/app/configuration/config/config-audio.spec.tsx @@ -15,15 +15,6 @@ vi.mock('use-context-selector', async (importOriginal) => { } }) -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - const prefix = options?.ns ? `${options.ns}.` : '' - return `${prefix}${key}` - }, - }), -})) - const mockUseFeatures = vi.fn() const mockUseFeaturesStore = vi.fn() vi.mock('@/app/components/base/features/hooks', () => ({ diff --git a/web/app/components/base/inline-delete-confirm/index.spec.tsx b/web/app/components/base/inline-delete-confirm/index.spec.tsx index 0de8c0844f..b770fccc88 100644 --- a/web/app/components/base/inline-delete-confirm/index.spec.tsx +++ b/web/app/components/base/inline-delete-confirm/index.spec.tsx @@ -1,26 +1,14 @@ import { cleanup, fireEvent, render } from '@testing-library/react' import * as React from 'react' +import { createReactI18nextMock } from '@/test/i18n-mock' import InlineDeleteConfirm from './index' -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, defaultValueOrOptions?: string | { ns?: string }) => { - const translations: Record = { - 'operation.deleteConfirmTitle': 'Delete?', - 'operation.yes': 'Yes', - 'operation.no': 'No', - 'operation.confirmAction': 'Please confirm your action.', - } - if (translations[key]) - return translations[key] - // Handle case where second arg is default value string - if (typeof defaultValueOrOptions === 'string') - return defaultValueOrOptions - const prefix = defaultValueOrOptions?.ns ? `${defaultValueOrOptions.ns}.` : '' - return `${prefix}${key}` - }, - }), +// Mock react-i18next with custom translations for test assertions +vi.mock('react-i18next', () => createReactI18nextMock({ + 'operation.deleteConfirmTitle': 'Delete?', + 'operation.yes': 'Yes', + 'operation.no': 'No', + 'operation.confirmAction': 'Please confirm your action.', })) afterEach(cleanup) diff --git a/web/app/components/base/input-with-copy/index.spec.tsx b/web/app/components/base/input-with-copy/index.spec.tsx index 5a4ca7c97e..438e72d142 100644 --- a/web/app/components/base/input-with-copy/index.spec.tsx +++ b/web/app/components/base/input-with-copy/index.spec.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' +import { createReactI18nextMock } from '@/test/i18n-mock' import InputWithCopy from './index' // Create a mock function that we can track using vi.hoisted @@ -10,22 +11,12 @@ vi.mock('copy-to-clipboard', () => ({ default: mockCopyToClipboard, })) -// Mock the i18n hook -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - const translations: Record = { - 'operation.copy': 'Copy', - 'operation.copied': 'Copied', - 'overview.appInfo.embedded.copy': 'Copy', - 'overview.appInfo.embedded.copied': 'Copied', - } - if (translations[key]) - return translations[key] - const prefix = options?.ns ? `${options.ns}.` : '' - return `${prefix}${key}` - }, - }), +// Mock the i18n hook with custom translations for test assertions +vi.mock('react-i18next', () => createReactI18nextMock({ + 'operation.copy': 'Copy', + 'operation.copied': 'Copied', + 'overview.appInfo.embedded.copy': 'Copy', + 'overview.appInfo.embedded.copied': 'Copied', })) // Mock es-toolkit/compat debounce diff --git a/web/app/components/base/input/index.spec.tsx b/web/app/components/base/input/index.spec.tsx index a0de3c4ca4..65589ddcdf 100644 --- a/web/app/components/base/input/index.spec.tsx +++ b/web/app/components/base/input/index.spec.tsx @@ -1,21 +1,12 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' +import { createReactI18nextMock } from '@/test/i18n-mock' import Input, { inputVariants } from './index' -// Mock the i18n hook -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - const translations: Record = { - 'operation.search': 'Search', - 'placeholder.input': 'Please input', - } - if (translations[key]) - return translations[key] - const prefix = options?.ns ? `${options.ns}.` : '' - return `${prefix}${key}` - }, - }), +// Mock the i18n hook with custom translations for test assertions +vi.mock('react-i18next', () => createReactI18nextMock({ + 'operation.search': 'Search', + 'placeholder.input': 'Please input', })) describe('Input component', () => { diff --git a/web/app/components/billing/pricing/footer.spec.tsx b/web/app/components/billing/pricing/footer.spec.tsx index 0bbc38224e..85bd72c247 100644 --- a/web/app/components/billing/pricing/footer.spec.tsx +++ b/web/app/components/billing/pricing/footer.spec.tsx @@ -3,8 +3,6 @@ import * as React from 'react' import { CategoryEnum } from '.' import Footer from './footer' -let mockTranslations: Record = {} - vi.mock('next/link', () => ({ default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => ( @@ -13,25 +11,9 @@ vi.mock('next/link', () => ({ ), })) -vi.mock('react-i18next', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - if (mockTranslations[key]) - return mockTranslations[key] - const prefix = options?.ns ? `${options.ns}.` : '' - return `${prefix}${key}` - }, - }), - } -}) - describe('Footer', () => { beforeEach(() => { vi.clearAllMocks() - mockTranslations = {} }) // Rendering behavior diff --git a/web/app/components/datasets/create/index.spec.tsx b/web/app/components/datasets/create/index.spec.tsx index 1cf24e6f21..7e20e4bc1c 100644 --- a/web/app/components/datasets/create/index.spec.tsx +++ b/web/app/components/datasets/create/index.spec.tsx @@ -18,16 +18,6 @@ const IndexingTypeValues = { // Mock External Dependencies // ========================================== -// Mock react-i18next (handled by global mock in web/vitest.setup.ts but we override for custom messages) -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - const prefix = options?.ns ? `${options.ns}.` : '' - return `${prefix}${key}` - }, - }), -})) - // Mock next/link vi.mock('next/link', () => { return function MockLink({ children, href }: { children: React.ReactNode, href: string }) { diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/index.spec.tsx index 875adb2779..d9fea93446 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/index.spec.tsx @@ -9,16 +9,6 @@ import Processing from './index' // Mock External Dependencies // ========================================== -// Mock react-i18next (handled by global mock in web/vitest.setup.ts but we override for custom messages) -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - const prefix = options?.ns ? `${options.ns}.` : '' - return `${prefix}${key}` - }, - }), -})) - // Mock useDocLink - returns a function that generates doc URLs // Strips leading slash from path to match actual implementation behavior vi.mock('@/context/i18n', () => ({ diff --git a/web/app/components/plugins/card/index.spec.tsx b/web/app/components/plugins/card/index.spec.tsx index d32aafff57..4a3e5a587b 100644 --- a/web/app/components/plugins/card/index.spec.tsx +++ b/web/app/components/plugins/card/index.spec.tsx @@ -21,33 +21,6 @@ import Card from './index' // Mock External Dependencies Only // ================================ -// Mock react-i18next (translation hook) -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - -// Mock useMixedTranslation hook -vi.mock('../marketplace/hooks', () => ({ - useMixedTranslation: (_locale?: string) => ({ - t: (key: string, options?: { ns?: string }) => { - const fullKey = options?.ns ? `${options.ns}.${key}` : key - const translations: Record = { - 'plugin.marketplace.partnerTip': 'Partner plugin', - 'plugin.marketplace.verifiedTip': 'Verified plugin', - 'plugin.installModal.installWarning': 'Install warning message', - } - return translations[fullKey] || key - }, - }), -})) - -// Mock useGetLanguage context -vi.mock('@/context/i18n', () => ({ - useGetLanguage: () => 'en-US', -})) - // Mock useTheme hook vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light' }), diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.spec.tsx index 4e3a3307df..7f95eb0b35 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.spec.tsx @@ -64,26 +64,20 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string } & Record) => { - // Build full key with namespace prefix if provided - const fullKey = options?.ns ? `${options.ns}.${key}` : key - // Handle interpolation params (excluding ns) - const { ns: _ns, ...params } = options || {} - if (Object.keys(params).length > 0) { - return `${fullKey}:${JSON.stringify(params)}` - } - return fullKey - }, - }), - Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record }) => ( - - {i18nKey} - {components?.trustSource} - - ), -})) +vi.mock('react-i18next', async (importOriginal) => { + const actual = await importOriginal() + const { createReactI18nextMock } = await import('@/test/i18n-mock') + return { + ...actual, + ...createReactI18nextMock(), + Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record }) => ( + + {i18nKey} + {components?.trustSource} + + ), + } +}) vi.mock('../../../card', () => ({ default: ({ payload, titleLeft }: { diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.spec.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.spec.tsx index c1d7e8cefe..35256b6633 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.spec.tsx @@ -48,21 +48,6 @@ vi.mock('@/service/plugins', () => ({ uploadFile: (...args: unknown[]) => mockUploadFile(...args), })) -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string } & Record) => { - // Build full key with namespace prefix if provided - const fullKey = options?.ns ? `${options.ns}.${key}` : key - // Handle interpolation params (excluding ns) - const { ns: _ns, ...params } = options || {} - if (Object.keys(params).length > 0) { - return `${fullKey}:${JSON.stringify(params)}` - } - return fullKey - }, - }), -})) - vi.mock('../../../card', () => ({ default: ({ payload, isLoading, loadingFileName }: { payload: { name: string } diff --git a/web/app/components/plugins/marketplace/index.spec.tsx b/web/app/components/plugins/marketplace/index.spec.tsx index 6047afe950..3073897ba1 100644 --- a/web/app/components/plugins/marketplace/index.spec.tsx +++ b/web/app/components/plugins/marketplace/index.spec.tsx @@ -27,17 +27,17 @@ import { // Mock External Dependencies Only // ================================ -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - // Mock i18next-config vi.mock('@/i18n-config/i18next-config', () => ({ default: { - getFixedT: (_locale: string) => (key: string) => key, + getFixedT: (_locale: string) => (key: string, options?: Record) => { + if (options && options.ns) { + return `${options.ns}.${key}` + } + else { + return key + } + }, }, })) @@ -617,8 +617,8 @@ describe('hooks', () => { it('should return translation key when no translation found', () => { const { result } = renderHook(() => useMixedTranslation()) - // The mock returns key as-is - expect(result.current.t('category.all', { ns: 'plugin' })).toBe('category.all') + // The global mock returns key with namespace prefix + expect(result.current.t('category.all', { ns: 'plugin' })).toBe('plugin.category.all') }) it('should use locale from outer when provided', () => { @@ -638,8 +638,8 @@ describe('hooks', () => { it('should use getFixedT when localeFromOuter is provided', () => { const { result } = renderHook(() => useMixedTranslation('fr-FR')) - // Should still return a function - expect(result.current.t('search', { ns: 'plugin' })).toBe('search') + // The global mock returns key with namespace prefix + expect(result.current.t('search', { ns: 'plugin' })).toBe('plugin.search') }) }) }) @@ -2756,15 +2756,15 @@ describe('PluginTypeSwitch Component', () => { , ) - // Note: The mock returns the key without namespace prefix - expect(screen.getByText('category.all')).toBeInTheDocument() - expect(screen.getByText('category.models')).toBeInTheDocument() - expect(screen.getByText('category.tools')).toBeInTheDocument() - expect(screen.getByText('category.datasources')).toBeInTheDocument() - expect(screen.getByText('category.triggers')).toBeInTheDocument() - expect(screen.getByText('category.agents')).toBeInTheDocument() - expect(screen.getByText('category.extensions')).toBeInTheDocument() - expect(screen.getByText('category.bundles')).toBeInTheDocument() + // Note: The global mock returns the key with namespace prefix (plugin.) + expect(screen.getByText('plugin.category.all')).toBeInTheDocument() + expect(screen.getByText('plugin.category.models')).toBeInTheDocument() + expect(screen.getByText('plugin.category.tools')).toBeInTheDocument() + expect(screen.getByText('plugin.category.datasources')).toBeInTheDocument() + expect(screen.getByText('plugin.category.triggers')).toBeInTheDocument() + expect(screen.getByText('plugin.category.agents')).toBeInTheDocument() + expect(screen.getByText('plugin.category.extensions')).toBeInTheDocument() + expect(screen.getByText('plugin.category.bundles')).toBeInTheDocument() }) it('should apply className prop', () => { @@ -2794,7 +2794,7 @@ describe('PluginTypeSwitch Component', () => { , ) - fireEvent.click(screen.getByText('category.tools')) + fireEvent.click(screen.getByText('plugin.category.tools')) expect(screen.getByTestId('active-type-display')).toHaveTextContent('tool') }) @@ -2816,7 +2816,7 @@ describe('PluginTypeSwitch Component', () => { ) fireEvent.click(screen.getByTestId('set-model')) - const modelOption = screen.getByText('category.models').closest('div') + const modelOption = screen.getByText('plugin.category.models').closest('div') expect(modelOption).toHaveClass('shadow-xs') }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx index 33cb93013d..c87fc1e4da 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx @@ -78,17 +78,6 @@ function createMockLogData(logs: TriggerLogEntity[] = []): { logs: TriggerLogEnt // Mock Setup // ============================================================================ -const mockTranslate = vi.fn((key: string, options?: { ns?: string }) => { - // Build full key with namespace prefix if provided - const fullKey = options?.ns ? `${options.ns}.${key}` : key - return fullKey -}) -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: mockTranslate, - }), -})) - // Mock plugin store const mockPluginDetail = createMockPluginDetail() const mockUsePluginStore = vi.fn(() => mockPluginDetail) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx index 74599a13c5..f1cb7a65ae 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx @@ -68,17 +68,6 @@ function createMockSubscriptionBuilder(overrides: Partial { - // Build full key with namespace prefix if provided - const fullKey = options?.ns ? `${options.ns}.${key}` : key - return fullKey -}) -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: mockTranslate, - }), -})) - // Mock plugin store const mockPluginDetail = createMockPluginDetail() const mockUsePluginStore = vi.fn(() => mockPluginDetail) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.spec.tsx index 4ce1841b05..b7988c916b 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.spec.tsx @@ -12,16 +12,6 @@ import { OAuthEditModal } from './oauth-edit-modal' // ==================== Mock Setup ==================== -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - // Build full key with namespace prefix if provided - const fullKey = options?.ns ? `${options.ns}.${key}` : key - return fullKey - }, - }), -})) - const mockToastNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ default: { notify: (params: unknown) => mockToastNotify(params) }, diff --git a/web/app/components/plugins/plugin-mutation-model/index.spec.tsx b/web/app/components/plugins/plugin-mutation-model/index.spec.tsx index f007c32ef1..95c9db3c97 100644 --- a/web/app/components/plugins/plugin-mutation-model/index.spec.tsx +++ b/web/app/components/plugins/plugin-mutation-model/index.spec.tsx @@ -9,28 +9,6 @@ import PluginMutationModal from './index' // Mock External Dependencies Only // ================================ -// Mock react-i18next (translation hook) -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - -// Mock useMixedTranslation hook -vi.mock('../marketplace/hooks', () => ({ - useMixedTranslation: (_locale?: string) => ({ - t: (key: string, options?: { ns?: string }) => { - const fullKey = options?.ns ? `${options.ns}.${key}` : key - return fullKey - }, - }), -})) - -// Mock useGetLanguage context -vi.mock('@/context/i18n', () => ({ - useGetLanguage: () => 'en-US', -})) - // Mock useTheme hook vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light' }), diff --git a/web/test/i18n-mock.ts b/web/test/i18n-mock.ts new file mode 100644 index 0000000000..20e7a22eef --- /dev/null +++ b/web/test/i18n-mock.ts @@ -0,0 +1,79 @@ +import * as React from 'react' +import { vi } from 'vitest' + +type TranslationMap = Record + +/** + * Create a t function with optional custom translations + * Checks translations[key] first, then translations[ns.key], then returns ns.key as fallback + */ +export function createTFunction(translations: TranslationMap, defaultNs?: string) { + return (key: string, options?: Record) => { + // Check custom translations first (without namespace) + if (translations[key] !== undefined) + return translations[key] + + const ns = (options?.ns as string | undefined) ?? defaultNs + const fullKey = ns ? `${ns}.${key}` : key + + // Check custom translations with namespace + if (translations[fullKey] !== undefined) + return translations[fullKey] + + // Serialize params (excluding ns) for test assertions + const params = { ...options } + delete params.ns + const suffix = Object.keys(params).length > 0 ? `:${JSON.stringify(params)}` : '' + return `${fullKey}${suffix}` + } +} + +/** + * Create useTranslation mock with optional custom translations + * + * @example + * vi.mock('react-i18next', () => createUseTranslationMock({ + * 'operation.confirm': 'Confirm', + * })) + */ +export function createUseTranslationMock(translations: TranslationMap = {}) { + return { + useTranslation: (defaultNs?: string) => ({ + t: createTFunction(translations, defaultNs), + i18n: { + language: 'en', + changeLanguage: vi.fn(), + }, + }), + } +} + +/** + * Create Trans component mock with optional custom translations + */ +export function createTransMock(translations: TranslationMap = {}) { + return { + Trans: ({ i18nKey, children }: { + i18nKey: string + children?: React.ReactNode + }) => { + const text = translations[i18nKey] ?? i18nKey + return React.createElement('span', { 'data-i18n-key': i18nKey }, children ?? text) + }, + } +} + +/** + * Create complete react-i18next mock (useTranslation + Trans) + * + * @example + * vi.mock('react-i18next', () => createReactI18nextMock({ + * 'modal.title': 'My Modal', + * })) + */ +export function createReactI18nextMock(translations: TranslationMap = {}) { + return { + ...createUseTranslationMock(translations), + ...createTransMock(translations), + } +} diff --git a/web/testing/testing.md b/web/testing/testing.md index 1d578ae634..47341e445e 100644 --- a/web/testing/testing.md +++ b/web/testing/testing.md @@ -329,21 +329,28 @@ describe('ComponentName', () => { 1. **i18n**: Uses global mock in `web/vitest.setup.ts` (auto-loaded by Vitest setup) - The global mock returns translation keys as-is. For custom translations, override: + The global mock provides: + + - `useTranslation` - returns translation keys with namespace prefix + - `Trans` component - renders i18nKey and components + - `useMixedTranslation` (from `@/app/components/plugins/marketplace/hooks`) + - `useGetLanguage` (from `@/context/i18n`) - returns `'en-US'` + + **Default behavior**: Most tests should use the global mock (no local override needed). + + **For custom translations**: Use the helper function from `@/test/i18n-mock`: ```typescript - vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - 'my.custom.key': 'Custom translation', - } - return translations[key] || key - }, - }), + import { createReactI18nextMock } from '@/test/i18n-mock' + + vi.mock('react-i18next', () => createReactI18nextMock({ + 'my.custom.key': 'Custom translation', + 'button.save': 'Save', })) ``` + **Avoid**: Manually defining `useTranslation` mocks that just return the key - the global mock already does this. + 1. **Forms**: Test validation logic thoroughly 1. **Example - Correct mock with conditional rendering**: diff --git a/web/vitest.setup.ts b/web/vitest.setup.ts index 551a22475b..26dc25bbcf 100644 --- a/web/vitest.setup.ts +++ b/web/vitest.setup.ts @@ -88,26 +88,10 @@ vi.mock('next/image') // mock react-i18next vi.mock('react-i18next', async () => { const actual = await vi.importActual('react-i18next') + const { createReactI18nextMock } = await import('./test/i18n-mock') return { ...actual, - useTranslation: (defaultNs?: string) => ({ - t: (key: string, options?: Record) => { - if (options?.returnObjects) - return [`${key}-feature-1`, `${key}-feature-2`] - const ns = options?.ns ?? defaultNs - if (options || ns) { - const { ns: _ns, ...rest } = options ?? {} - const prefix = ns ? `${ns}.` : '' - const suffix = Object.keys(rest).length > 0 ? `:${JSON.stringify(rest)}` : '' - return `${prefix}${key}${suffix}` - } - return key - }, - i18n: { - language: 'en', - changeLanguage: vi.fn(), - }, - }), + ...createReactI18nextMock(), } }) From 3015e9be73e1781d860699cedf6fe2210fb3c8ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Wed, 31 Dec 2025 16:14:46 +0800 Subject: [PATCH 19/25] feat: add archive storage client and env config (#30422) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/.env.example | 9 + api/configs/extra/__init__.py | 2 + api/configs/extra/archive_config.py | 43 +++ api/libs/archive_storage.py | 347 ++++++++++++++++++ .../unit_tests/libs/test_archive_storage.py | 272 ++++++++++++++ docker/.env.example | 9 + docker/docker-compose.yaml | 7 + 7 files changed, 689 insertions(+) create mode 100644 api/configs/extra/archive_config.py create mode 100644 api/libs/archive_storage.py create mode 100644 api/tests/unit_tests/libs/test_archive_storage.py diff --git a/api/.env.example b/api/.env.example index 99cd2ba558..5f8d369ec4 100644 --- a/api/.env.example +++ b/api/.env.example @@ -101,6 +101,15 @@ S3_ACCESS_KEY=your-access-key S3_SECRET_KEY=your-secret-key S3_REGION=your-region +# Workflow run and Conversation archive storage (S3-compatible) +ARCHIVE_STORAGE_ENABLED=false +ARCHIVE_STORAGE_ENDPOINT= +ARCHIVE_STORAGE_ARCHIVE_BUCKET= +ARCHIVE_STORAGE_EXPORT_BUCKET= +ARCHIVE_STORAGE_ACCESS_KEY= +ARCHIVE_STORAGE_SECRET_KEY= +ARCHIVE_STORAGE_REGION=auto + # Azure Blob Storage configuration AZURE_BLOB_ACCOUNT_NAME=your-account-name AZURE_BLOB_ACCOUNT_KEY=your-account-key diff --git a/api/configs/extra/__init__.py b/api/configs/extra/__init__.py index 4543b5389d..de97adfc0e 100644 --- a/api/configs/extra/__init__.py +++ b/api/configs/extra/__init__.py @@ -1,9 +1,11 @@ +from configs.extra.archive_config import ArchiveStorageConfig from configs.extra.notion_config import NotionConfig from configs.extra.sentry_config import SentryConfig class ExtraServiceConfig( # place the configs in alphabet order + ArchiveStorageConfig, NotionConfig, SentryConfig, ): diff --git a/api/configs/extra/archive_config.py b/api/configs/extra/archive_config.py new file mode 100644 index 0000000000..a85628fa61 --- /dev/null +++ b/api/configs/extra/archive_config.py @@ -0,0 +1,43 @@ +from pydantic import Field +from pydantic_settings import BaseSettings + + +class ArchiveStorageConfig(BaseSettings): + """ + Configuration settings for workflow run logs archiving storage. + """ + + ARCHIVE_STORAGE_ENABLED: bool = Field( + description="Enable workflow run logs archiving to S3-compatible storage", + default=False, + ) + + ARCHIVE_STORAGE_ENDPOINT: str | None = Field( + description="URL of the S3-compatible storage endpoint (e.g., 'https://storage.example.com')", + default=None, + ) + + ARCHIVE_STORAGE_ARCHIVE_BUCKET: str | None = Field( + description="Name of the bucket to store archived workflow logs", + default=None, + ) + + ARCHIVE_STORAGE_EXPORT_BUCKET: str | None = Field( + description="Name of the bucket to store exported workflow runs", + default=None, + ) + + ARCHIVE_STORAGE_ACCESS_KEY: str | None = Field( + description="Access key ID for authenticating with storage", + default=None, + ) + + ARCHIVE_STORAGE_SECRET_KEY: str | None = Field( + description="Secret access key for authenticating with storage", + default=None, + ) + + ARCHIVE_STORAGE_REGION: str = Field( + description="Region for storage (use 'auto' if the provider supports it)", + default="auto", + ) diff --git a/api/libs/archive_storage.py b/api/libs/archive_storage.py new file mode 100644 index 0000000000..f84d226447 --- /dev/null +++ b/api/libs/archive_storage.py @@ -0,0 +1,347 @@ +""" +Archive Storage Client for S3-compatible storage. + +This module provides a dedicated storage client for archiving or exporting logs +to S3-compatible object storage. +""" + +import base64 +import datetime +import gzip +import hashlib +import logging +from collections.abc import Generator +from typing import Any, cast + +import boto3 +import orjson +from botocore.client import Config +from botocore.exceptions import ClientError + +from configs import dify_config + +logger = logging.getLogger(__name__) + + +class ArchiveStorageError(Exception): + """Base exception for archive storage operations.""" + + pass + + +class ArchiveStorageNotConfiguredError(ArchiveStorageError): + """Raised when archive storage is not properly configured.""" + + pass + + +class ArchiveStorage: + """ + S3-compatible storage client for archiving or exporting. + + This client provides methods for storing and retrieving archived data in JSONL+gzip format. + """ + + def __init__(self, bucket: str): + if not dify_config.ARCHIVE_STORAGE_ENABLED: + raise ArchiveStorageNotConfiguredError("Archive storage is not enabled") + + if not bucket: + raise ArchiveStorageNotConfiguredError("Archive storage bucket is not configured") + if not all( + [ + dify_config.ARCHIVE_STORAGE_ENDPOINT, + bucket, + dify_config.ARCHIVE_STORAGE_ACCESS_KEY, + dify_config.ARCHIVE_STORAGE_SECRET_KEY, + ] + ): + raise ArchiveStorageNotConfiguredError( + "Archive storage configuration is incomplete. " + "Required: ARCHIVE_STORAGE_ENDPOINT, ARCHIVE_STORAGE_ACCESS_KEY, " + "ARCHIVE_STORAGE_SECRET_KEY, and a bucket name" + ) + + self.bucket = bucket + self.client = boto3.client( + "s3", + endpoint_url=dify_config.ARCHIVE_STORAGE_ENDPOINT, + aws_access_key_id=dify_config.ARCHIVE_STORAGE_ACCESS_KEY, + aws_secret_access_key=dify_config.ARCHIVE_STORAGE_SECRET_KEY, + region_name=dify_config.ARCHIVE_STORAGE_REGION, + config=Config(s3={"addressing_style": "path"}), + ) + + # Verify bucket accessibility + try: + self.client.head_bucket(Bucket=self.bucket) + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code") + if error_code == "404": + raise ArchiveStorageNotConfiguredError(f"Archive bucket '{self.bucket}' does not exist") + elif error_code == "403": + raise ArchiveStorageNotConfiguredError(f"Access denied to archive bucket '{self.bucket}'") + else: + raise ArchiveStorageError(f"Failed to access archive bucket: {e}") + + def put_object(self, key: str, data: bytes) -> str: + """ + Upload an object to the archive storage. + + Args: + key: Object key (path) within the bucket + data: Binary data to upload + + Returns: + MD5 checksum of the uploaded data + + Raises: + ArchiveStorageError: If upload fails + """ + checksum = hashlib.md5(data).hexdigest() + try: + self.client.put_object( + Bucket=self.bucket, + Key=key, + Body=data, + ContentMD5=self._content_md5(data), + ) + logger.debug("Uploaded object: %s (size=%d, checksum=%s)", key, len(data), checksum) + return checksum + except ClientError as e: + raise ArchiveStorageError(f"Failed to upload object '{key}': {e}") + + def get_object(self, key: str) -> bytes: + """ + Download an object from the archive storage. + + Args: + key: Object key (path) within the bucket + + Returns: + Binary data of the object + + Raises: + ArchiveStorageError: If download fails + FileNotFoundError: If object does not exist + """ + try: + response = self.client.get_object(Bucket=self.bucket, Key=key) + return response["Body"].read() + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code") + if error_code == "NoSuchKey": + raise FileNotFoundError(f"Archive object not found: {key}") + raise ArchiveStorageError(f"Failed to download object '{key}': {e}") + + def get_object_stream(self, key: str) -> Generator[bytes, None, None]: + """ + Stream an object from the archive storage. + + Args: + key: Object key (path) within the bucket + + Yields: + Chunks of binary data + + Raises: + ArchiveStorageError: If download fails + FileNotFoundError: If object does not exist + """ + try: + response = self.client.get_object(Bucket=self.bucket, Key=key) + yield from response["Body"].iter_chunks() + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code") + if error_code == "NoSuchKey": + raise FileNotFoundError(f"Archive object not found: {key}") + raise ArchiveStorageError(f"Failed to stream object '{key}': {e}") + + def object_exists(self, key: str) -> bool: + """ + Check if an object exists in the archive storage. + + Args: + key: Object key (path) within the bucket + + Returns: + True if object exists, False otherwise + """ + try: + self.client.head_object(Bucket=self.bucket, Key=key) + return True + except ClientError: + return False + + def delete_object(self, key: str) -> None: + """ + Delete an object from the archive storage. + + Args: + key: Object key (path) within the bucket + + Raises: + ArchiveStorageError: If deletion fails + """ + try: + self.client.delete_object(Bucket=self.bucket, Key=key) + logger.debug("Deleted object: %s", key) + except ClientError as e: + raise ArchiveStorageError(f"Failed to delete object '{key}': {e}") + + def generate_presigned_url(self, key: str, expires_in: int = 3600) -> str: + """ + Generate a pre-signed URL for downloading an object. + + Args: + key: Object key (path) within the bucket + expires_in: URL validity duration in seconds (default: 1 hour) + + Returns: + Pre-signed URL string. + + Raises: + ArchiveStorageError: If generation fails + """ + try: + return self.client.generate_presigned_url( + ClientMethod="get_object", + Params={"Bucket": self.bucket, "Key": key}, + ExpiresIn=expires_in, + ) + except ClientError as e: + raise ArchiveStorageError(f"Failed to generate pre-signed URL for '{key}': {e}") + + def list_objects(self, prefix: str) -> list[str]: + """ + List objects under a given prefix. + + Args: + prefix: Object key prefix to filter by + + Returns: + List of object keys matching the prefix + """ + keys = [] + paginator = self.client.get_paginator("list_objects_v2") + + try: + for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix): + for obj in page.get("Contents", []): + keys.append(obj["Key"]) + except ClientError as e: + raise ArchiveStorageError(f"Failed to list objects with prefix '{prefix}': {e}") + + return keys + + @staticmethod + def _content_md5(data: bytes) -> str: + """Calculate base64-encoded MD5 for Content-MD5 header.""" + return base64.b64encode(hashlib.md5(data).digest()).decode() + + @staticmethod + def serialize_to_jsonl_gz(records: list[dict[str, Any]]) -> bytes: + """ + Serialize records to gzipped JSONL format. + + Args: + records: List of dictionaries to serialize + + Returns: + Gzipped JSONL bytes + """ + lines = [] + for record in records: + # Convert datetime objects to ISO format strings + serialized = ArchiveStorage._serialize_record(record) + lines.append(orjson.dumps(serialized)) + + jsonl_content = b"\n".join(lines) + if jsonl_content: + jsonl_content += b"\n" + + return gzip.compress(jsonl_content) + + @staticmethod + def deserialize_from_jsonl_gz(data: bytes) -> list[dict[str, Any]]: + """ + Deserialize gzipped JSONL data to records. + + Args: + data: Gzipped JSONL bytes + + Returns: + List of dictionaries + """ + jsonl_content = gzip.decompress(data) + records = [] + + for line in jsonl_content.splitlines(): + if line: + records.append(orjson.loads(line)) + + return records + + @staticmethod + def _serialize_record(record: dict[str, Any]) -> dict[str, Any]: + """Serialize a single record, converting special types.""" + + def _serialize(item: Any) -> Any: + if isinstance(item, datetime.datetime): + return item.isoformat() + if isinstance(item, dict): + return {key: _serialize(value) for key, value in item.items()} + if isinstance(item, list): + return [_serialize(value) for value in item] + return item + + return cast(dict[str, Any], _serialize(record)) + + @staticmethod + def compute_checksum(data: bytes) -> str: + """Compute MD5 checksum of data.""" + return hashlib.md5(data).hexdigest() + + +# Singleton instance (lazy initialization) +_archive_storage: ArchiveStorage | None = None +_export_storage: ArchiveStorage | None = None + + +def get_archive_storage() -> ArchiveStorage: + """ + Get the archive storage singleton instance. + + Returns: + ArchiveStorage instance + + Raises: + ArchiveStorageNotConfiguredError: If archive storage is not configured + """ + global _archive_storage + if _archive_storage is None: + archive_bucket = dify_config.ARCHIVE_STORAGE_ARCHIVE_BUCKET + if not archive_bucket: + raise ArchiveStorageNotConfiguredError( + "Archive storage bucket is not configured. Required: ARCHIVE_STORAGE_ARCHIVE_BUCKET" + ) + _archive_storage = ArchiveStorage(bucket=archive_bucket) + return _archive_storage + + +def get_export_storage() -> ArchiveStorage: + """ + Get the export storage singleton instance. + + Returns: + ArchiveStorage instance + """ + global _export_storage + if _export_storage is None: + export_bucket = dify_config.ARCHIVE_STORAGE_EXPORT_BUCKET + if not export_bucket: + raise ArchiveStorageNotConfiguredError( + "Archive export bucket is not configured. Required: ARCHIVE_STORAGE_EXPORT_BUCKET" + ) + _export_storage = ArchiveStorage(bucket=export_bucket) + return _export_storage diff --git a/api/tests/unit_tests/libs/test_archive_storage.py b/api/tests/unit_tests/libs/test_archive_storage.py new file mode 100644 index 0000000000..697760e33a --- /dev/null +++ b/api/tests/unit_tests/libs/test_archive_storage.py @@ -0,0 +1,272 @@ +import base64 +import hashlib +from datetime import datetime +from unittest.mock import ANY, MagicMock + +import pytest +from botocore.exceptions import ClientError + +from libs import archive_storage as storage_module +from libs.archive_storage import ( + ArchiveStorage, + ArchiveStorageError, + ArchiveStorageNotConfiguredError, +) + +BUCKET_NAME = "archive-bucket" + + +def _configure_storage(monkeypatch, **overrides): + defaults = { + "ARCHIVE_STORAGE_ENABLED": True, + "ARCHIVE_STORAGE_ENDPOINT": "https://storage.example.com", + "ARCHIVE_STORAGE_ARCHIVE_BUCKET": BUCKET_NAME, + "ARCHIVE_STORAGE_ACCESS_KEY": "access", + "ARCHIVE_STORAGE_SECRET_KEY": "secret", + "ARCHIVE_STORAGE_REGION": "auto", + } + defaults.update(overrides) + for key, value in defaults.items(): + monkeypatch.setattr(storage_module.dify_config, key, value, raising=False) + + +def _client_error(code: str) -> ClientError: + return ClientError({"Error": {"Code": code}}, "Operation") + + +def _mock_client(monkeypatch): + client = MagicMock() + client.head_bucket.return_value = None + boto_client = MagicMock(return_value=client) + monkeypatch.setattr(storage_module.boto3, "client", boto_client) + return client, boto_client + + +def test_init_disabled(monkeypatch): + _configure_storage(monkeypatch, ARCHIVE_STORAGE_ENABLED=False) + with pytest.raises(ArchiveStorageNotConfiguredError, match="not enabled"): + ArchiveStorage(bucket=BUCKET_NAME) + + +def test_init_missing_config(monkeypatch): + _configure_storage(monkeypatch, ARCHIVE_STORAGE_ENDPOINT=None) + with pytest.raises(ArchiveStorageNotConfiguredError, match="incomplete"): + ArchiveStorage(bucket=BUCKET_NAME) + + +def test_init_bucket_not_found(monkeypatch): + _configure_storage(monkeypatch) + client, _ = _mock_client(monkeypatch) + client.head_bucket.side_effect = _client_error("404") + + with pytest.raises(ArchiveStorageNotConfiguredError, match="does not exist"): + ArchiveStorage(bucket=BUCKET_NAME) + + +def test_init_bucket_access_denied(monkeypatch): + _configure_storage(monkeypatch) + client, _ = _mock_client(monkeypatch) + client.head_bucket.side_effect = _client_error("403") + + with pytest.raises(ArchiveStorageNotConfiguredError, match="Access denied"): + ArchiveStorage(bucket=BUCKET_NAME) + + +def test_init_bucket_other_error(monkeypatch): + _configure_storage(monkeypatch) + client, _ = _mock_client(monkeypatch) + client.head_bucket.side_effect = _client_error("500") + + with pytest.raises(ArchiveStorageError, match="Failed to access archive bucket"): + ArchiveStorage(bucket=BUCKET_NAME) + + +def test_init_sets_client(monkeypatch): + _configure_storage(monkeypatch) + client, boto_client = _mock_client(monkeypatch) + + storage = ArchiveStorage(bucket=BUCKET_NAME) + + boto_client.assert_called_once_with( + "s3", + endpoint_url="https://storage.example.com", + aws_access_key_id="access", + aws_secret_access_key="secret", + region_name="auto", + config=ANY, + ) + assert storage.client is client + assert storage.bucket == BUCKET_NAME + + +def test_put_object_returns_checksum(monkeypatch): + _configure_storage(monkeypatch) + client, _ = _mock_client(monkeypatch) + storage = ArchiveStorage(bucket=BUCKET_NAME) + + data = b"hello" + checksum = storage.put_object("key", data) + + expected_md5 = hashlib.md5(data).hexdigest() + expected_content_md5 = base64.b64encode(hashlib.md5(data).digest()).decode() + client.put_object.assert_called_once_with( + Bucket="archive-bucket", + Key="key", + Body=data, + ContentMD5=expected_content_md5, + ) + assert checksum == expected_md5 + + +def test_put_object_raises_on_error(monkeypatch): + _configure_storage(monkeypatch) + client, _ = _mock_client(monkeypatch) + storage = ArchiveStorage(bucket=BUCKET_NAME) + client.put_object.side_effect = _client_error("500") + + with pytest.raises(ArchiveStorageError, match="Failed to upload object"): + storage.put_object("key", b"data") + + +def test_get_object_returns_bytes(monkeypatch): + _configure_storage(monkeypatch) + client, _ = _mock_client(monkeypatch) + body = MagicMock() + body.read.return_value = b"payload" + client.get_object.return_value = {"Body": body} + storage = ArchiveStorage(bucket=BUCKET_NAME) + + assert storage.get_object("key") == b"payload" + + +def test_get_object_missing(monkeypatch): + _configure_storage(monkeypatch) + client, _ = _mock_client(monkeypatch) + client.get_object.side_effect = _client_error("NoSuchKey") + storage = ArchiveStorage(bucket=BUCKET_NAME) + + with pytest.raises(FileNotFoundError, match="Archive object not found"): + storage.get_object("missing") + + +def test_get_object_stream(monkeypatch): + _configure_storage(monkeypatch) + client, _ = _mock_client(monkeypatch) + body = MagicMock() + body.iter_chunks.return_value = [b"a", b"b"] + client.get_object.return_value = {"Body": body} + storage = ArchiveStorage(bucket=BUCKET_NAME) + + assert list(storage.get_object_stream("key")) == [b"a", b"b"] + + +def test_get_object_stream_missing(monkeypatch): + _configure_storage(monkeypatch) + client, _ = _mock_client(monkeypatch) + client.get_object.side_effect = _client_error("NoSuchKey") + storage = ArchiveStorage(bucket=BUCKET_NAME) + + with pytest.raises(FileNotFoundError, match="Archive object not found"): + list(storage.get_object_stream("missing")) + + +def test_object_exists(monkeypatch): + _configure_storage(monkeypatch) + client, _ = _mock_client(monkeypatch) + storage = ArchiveStorage(bucket=BUCKET_NAME) + + assert storage.object_exists("key") is True + client.head_object.side_effect = _client_error("404") + assert storage.object_exists("missing") is False + + +def test_delete_object_error(monkeypatch): + _configure_storage(monkeypatch) + client, _ = _mock_client(monkeypatch) + client.delete_object.side_effect = _client_error("500") + storage = ArchiveStorage(bucket=BUCKET_NAME) + + with pytest.raises(ArchiveStorageError, match="Failed to delete object"): + storage.delete_object("key") + + +def test_list_objects(monkeypatch): + _configure_storage(monkeypatch) + client, _ = _mock_client(monkeypatch) + paginator = MagicMock() + paginator.paginate.return_value = [ + {"Contents": [{"Key": "a"}, {"Key": "b"}]}, + {"Contents": [{"Key": "c"}]}, + ] + client.get_paginator.return_value = paginator + storage = ArchiveStorage(bucket=BUCKET_NAME) + + assert storage.list_objects("prefix") == ["a", "b", "c"] + paginator.paginate.assert_called_once_with(Bucket="archive-bucket", Prefix="prefix") + + +def test_list_objects_error(monkeypatch): + _configure_storage(monkeypatch) + client, _ = _mock_client(monkeypatch) + paginator = MagicMock() + paginator.paginate.side_effect = _client_error("500") + client.get_paginator.return_value = paginator + storage = ArchiveStorage(bucket=BUCKET_NAME) + + with pytest.raises(ArchiveStorageError, match="Failed to list objects"): + storage.list_objects("prefix") + + +def test_generate_presigned_url(monkeypatch): + _configure_storage(monkeypatch) + client, _ = _mock_client(monkeypatch) + client.generate_presigned_url.return_value = "http://signed-url" + storage = ArchiveStorage(bucket=BUCKET_NAME) + + url = storage.generate_presigned_url("key", expires_in=123) + + client.generate_presigned_url.assert_called_once_with( + ClientMethod="get_object", + Params={"Bucket": "archive-bucket", "Key": "key"}, + ExpiresIn=123, + ) + assert url == "http://signed-url" + + +def test_generate_presigned_url_error(monkeypatch): + _configure_storage(monkeypatch) + client, _ = _mock_client(monkeypatch) + client.generate_presigned_url.side_effect = _client_error("500") + storage = ArchiveStorage(bucket=BUCKET_NAME) + + with pytest.raises(ArchiveStorageError, match="Failed to generate pre-signed URL"): + storage.generate_presigned_url("key") + + +def test_serialization_roundtrip(): + records = [ + { + "id": "1", + "created_at": datetime(2024, 1, 1, 12, 0, 0), + "payload": {"nested": "value"}, + "items": [{"name": "a"}], + }, + {"id": "2", "value": 123}, + ] + + data = ArchiveStorage.serialize_to_jsonl_gz(records) + decoded = ArchiveStorage.deserialize_from_jsonl_gz(data) + + assert decoded[0]["id"] == "1" + assert decoded[0]["payload"]["nested"] == "value" + assert decoded[0]["items"][0]["name"] == "a" + assert "2024-01-01T12:00:00" in decoded[0]["created_at"] + assert decoded[1]["value"] == 123 + + +def test_content_md5_matches_checksum(): + data = b"checksum" + expected = base64.b64encode(hashlib.md5(data).digest()).decode() + + assert ArchiveStorage._content_md5(data) == expected + assert ArchiveStorage.compute_checksum(data) == hashlib.md5(data).hexdigest() diff --git a/docker/.env.example b/docker/.env.example index 0e09d6869d..5c1089408c 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -447,6 +447,15 @@ S3_SECRET_KEY= # If set to false, the access key and secret key must be provided. S3_USE_AWS_MANAGED_IAM=false +# Workflow run and Conversation archive storage (S3-compatible) +ARCHIVE_STORAGE_ENABLED=false +ARCHIVE_STORAGE_ENDPOINT= +ARCHIVE_STORAGE_ARCHIVE_BUCKET= +ARCHIVE_STORAGE_EXPORT_BUCKET= +ARCHIVE_STORAGE_ACCESS_KEY= +ARCHIVE_STORAGE_SECRET_KEY= +ARCHIVE_STORAGE_REGION=auto + # Azure Blob Configuration # AZURE_BLOB_ACCOUNT_NAME=difyai diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 1c8d8d03e3..9910c95a84 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -122,6 +122,13 @@ x-shared-env: &shared-api-worker-env S3_ACCESS_KEY: ${S3_ACCESS_KEY:-} S3_SECRET_KEY: ${S3_SECRET_KEY:-} S3_USE_AWS_MANAGED_IAM: ${S3_USE_AWS_MANAGED_IAM:-false} + ARCHIVE_STORAGE_ENABLED: ${ARCHIVE_STORAGE_ENABLED:-false} + ARCHIVE_STORAGE_ENDPOINT: ${ARCHIVE_STORAGE_ENDPOINT:-} + ARCHIVE_STORAGE_ARCHIVE_BUCKET: ${ARCHIVE_STORAGE_ARCHIVE_BUCKET:-} + ARCHIVE_STORAGE_EXPORT_BUCKET: ${ARCHIVE_STORAGE_EXPORT_BUCKET:-} + ARCHIVE_STORAGE_ACCESS_KEY: ${ARCHIVE_STORAGE_ACCESS_KEY:-} + ARCHIVE_STORAGE_SECRET_KEY: ${ARCHIVE_STORAGE_SECRET_KEY:-} + ARCHIVE_STORAGE_REGION: ${ARCHIVE_STORAGE_REGION:-auto} AZURE_BLOB_ACCOUNT_NAME: ${AZURE_BLOB_ACCOUNT_NAME:-difyai} AZURE_BLOB_ACCOUNT_KEY: ${AZURE_BLOB_ACCOUNT_KEY:-difyai} AZURE_BLOB_CONTAINER_NAME: ${AZURE_BLOB_CONTAINER_NAME:-difyai-container} From 184077c37c7ddbaff4c16f0d9bb417ea1493d5c1 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:41:43 +0800 Subject: [PATCH 20/25] build: bring back babel-loader, add build check (#30427) --- .github/workflows/style.yml | 5 +++++ web/knip.config.ts | 4 ++++ web/package.json | 1 + web/pnpm-lock.yaml | 16 ++++++++++++++++ 4 files changed, 26 insertions(+) diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 39b559f4ca..462ece303e 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -115,6 +115,11 @@ jobs: working-directory: ./web run: pnpm run knip + - name: Web build check + if: steps.changed-files.outputs.any_changed == 'true' + working-directory: ./web + run: pnpm run build + superlinter: name: SuperLinter runs-on: ubuntu-latest diff --git a/web/knip.config.ts b/web/knip.config.ts index 414b00fb7f..6ffda0316a 100644 --- a/web/knip.config.ts +++ b/web/knip.config.ts @@ -15,6 +15,10 @@ const config: KnipConfig = { ignoreBinaries: [ 'only-allow', ], + ignoreDependencies: [ + // required by next-pwa + 'babel-loader', + ], rules: { files: 'warn', dependencies: 'warn', diff --git a/web/package.json b/web/package.json index 0c6821ce86..7ee2325dbc 100644 --- a/web/package.json +++ b/web/package.json @@ -190,6 +190,7 @@ "@vitejs/plugin-react": "^5.1.2", "@vitest/coverage-v8": "4.0.16", "autoprefixer": "^10.4.21", + "babel-loader": "^10.0.0", "bing-translate-api": "^4.1.0", "code-inspector-plugin": "1.2.9", "cross-env": "^10.1.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 373e2e4020..cdd194da37 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -481,6 +481,9 @@ importers: autoprefixer: specifier: ^10.4.21 version: 10.4.22(postcss@8.5.6) + babel-loader: + specifier: ^10.0.0 + version: 10.0.0(@babel/core@7.28.5)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)) bing-translate-api: specifier: ^4.1.0 version: 4.2.0 @@ -4265,6 +4268,13 @@ packages: peerDependencies: postcss: ^8.1.0 + babel-loader@10.0.0: + resolution: {integrity: sha512-z8jt+EdS61AMw22nSfoNJAZ0vrtmhPRVi6ghL3rCeRZI8cdNYFiV5xeV3HbE7rlZZNmGH8BVccwWt8/ED0QOHA==} + engines: {node: ^18.20.0 || ^20.10.0 || >=22.0.0} + peerDependencies: + '@babel/core': ^7.12.0 + webpack: '>=5.61.0' + babel-loader@8.4.1: resolution: {integrity: sha512-nXzRChX+Z1GoE6yWavBQg6jDslyFF3SDjl2paADuoQtQW10JqShJt62R6eJQ5m/pjJFDT8xgKIWSP85OY8eXeA==} engines: {node: '>= 8.9'} @@ -13080,6 +13090,12 @@ snapshots: postcss: 8.5.6 postcss-value-parser: 4.2.0 + babel-loader@10.0.0(@babel/core@7.28.5)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)): + dependencies: + '@babel/core': 7.28.5 + find-up: 5.0.0 + webpack: 5.103.0(esbuild@0.25.0)(uglify-js@3.19.3) + babel-loader@8.4.1(@babel/core@7.28.5)(webpack@5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)): dependencies: '@babel/core': 7.28.5 From ee1d0df927b8b64baf835b9df1e7aaba62c2cb2c Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 31 Dec 2025 17:55:25 +0800 Subject: [PATCH 21/25] chore: add jotai store (#30432) Signed-off-by: yyh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: yyh --- web/app/layout.tsx | 45 ++++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/web/app/layout.tsx b/web/app/layout.tsx index fa1f7d48b5..8fc5f8abcc 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,4 +1,5 @@ import type { Viewport } from 'next' +import { Provider as JotaiProvider } from 'jotai' import { ThemeProvider } from 'next-themes' import { Instrument_Serif } from 'next/font/google' import { NuqsAdapter } from 'nuqs/adapters/next/app' @@ -91,27 +92,29 @@ const LocaleLayout = async ({ {...datasetMap} > - - - - - - - - {children} - - - - - - - + + + + + + + + + {children} + + + + + + + + From e3ef33366db303add95c46a863635485864fa644 Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Thu, 1 Jan 2026 00:36:18 +0800 Subject: [PATCH 22/25] fix(web): stop thinking timer when user clicks stop button (#30442) --- .../base/markdown-blocks/think-block.spec.tsx | 248 ++++++++++++++++++ .../base/markdown-blocks/think-block.tsx | 6 +- 2 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 web/app/components/base/markdown-blocks/think-block.spec.tsx diff --git a/web/app/components/base/markdown-blocks/think-block.spec.tsx b/web/app/components/base/markdown-blocks/think-block.spec.tsx new file mode 100644 index 0000000000..a155b240b9 --- /dev/null +++ b/web/app/components/base/markdown-blocks/think-block.spec.tsx @@ -0,0 +1,248 @@ +import { act, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ChatContextProvider } from '@/app/components/base/chat/chat/context' +import ThinkBlock from './think-block' + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'chat.thinking': 'Thinking...', + 'chat.thought': 'Thought', + } + return translations[key] || key + }, + }), +})) + +// Helper to wrap component with ChatContextProvider +const renderWithContext = ( + children: React.ReactNode, + isResponding: boolean = true, +) => { + return render( + + {children} + , + ) +} + +describe('ThinkBlock', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('Rendering', () => { + it('should render regular details element when data-think is false', () => { + render( + +

Regular content

+
, + ) + + expect(screen.getByText('Regular content')).toBeInTheDocument() + }) + + it('should render think block with thinking state when data-think is true', () => { + renderWithContext( + +

Thinking content

+
, + true, + ) + + expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument() + expect(screen.getByText('Thinking content')).toBeInTheDocument() + }) + + it('should render thought state when content has ENDTHINKFLAG', () => { + renderWithContext( + +

Completed thinking[ENDTHINKFLAG]

+
, + true, + ) + + expect(screen.getByText(/Thought/)).toBeInTheDocument() + }) + }) + + describe('Timer behavior', () => { + it('should update elapsed time while thinking', () => { + renderWithContext( + +

Thinking...

+
, + true, + ) + + // Initial state should show 0.0s + expect(screen.getByText(/\(0\.0s\)/)).toBeInTheDocument() + + // Advance timer by 500ms and run pending timers + act(() => { + vi.advanceTimersByTime(500) + }) + + // Should show approximately 0.5s + expect(screen.getByText(/\(0\.5s\)/)).toBeInTheDocument() + }) + + it('should stop timer when isResponding becomes false', () => { + const { rerender } = render( + + +

Thinking content

+
+
, + ) + + // Verify initial thinking state + expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument() + + // Advance timer + act(() => { + vi.advanceTimersByTime(1000) + }) + + // Simulate user clicking stop (isResponding becomes false) + rerender( + + +

Thinking content

+
+
, + ) + + // Should now show "Thought" instead of "Thinking..." + expect(screen.getByText(/Thought/)).toBeInTheDocument() + }) + + it('should NOT stop timer when isResponding is undefined (outside ChatContextProvider)', () => { + // Render without ChatContextProvider + render( + +

Content without ENDTHINKFLAG

+
, + ) + + // Initial state should show "Thinking..." + expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument() + + // Advance timer + act(() => { + vi.advanceTimersByTime(2000) + }) + + // Timer should still be running (showing "Thinking..." not "Thought") + expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument() + expect(screen.getByText(/\(2\.0s\)/)).toBeInTheDocument() + }) + }) + + describe('ENDTHINKFLAG handling', () => { + it('should remove ENDTHINKFLAG from displayed content', () => { + renderWithContext( + +

Content[ENDTHINKFLAG]

+
, + true, + ) + + expect(screen.getByText('Content')).toBeInTheDocument() + expect(screen.queryByText('[ENDTHINKFLAG]')).not.toBeInTheDocument() + }) + + it('should detect ENDTHINKFLAG in nested children', () => { + renderWithContext( + +
+ Nested content[ENDTHINKFLAG] +
+
, + true, + ) + + // Should show "Thought" since ENDTHINKFLAG is present + expect(screen.getByText(/Thought/)).toBeInTheDocument() + }) + + it('should detect ENDTHINKFLAG in array children', () => { + renderWithContext( + + {['Part 1', 'Part 2[ENDTHINKFLAG]']} + , + true, + ) + + expect(screen.getByText(/Thought/)).toBeInTheDocument() + }) + }) + + describe('Edge cases', () => { + it('should handle empty children', () => { + renderWithContext( + , + true, + ) + + expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument() + }) + + it('should handle null children gracefully', () => { + renderWithContext( + + {null} + , + true, + ) + + expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/markdown-blocks/think-block.tsx b/web/app/components/base/markdown-blocks/think-block.tsx index 697fd096cc..f920218152 100644 --- a/web/app/components/base/markdown-blocks/think-block.tsx +++ b/web/app/components/base/markdown-blocks/think-block.tsx @@ -59,7 +59,11 @@ const useThinkTimer = (children: any) => { }, [startTime, isComplete]) useEffect(() => { - if (hasEndThink(children) || !isResponding) + // Stop timer when: + // 1. Content has [ENDTHINKFLAG] marker (normal completion) + // 2. isResponding is explicitly false (user clicked stop button) + // Note: Don't stop when isResponding is undefined (component used outside ChatContextProvider) + if (hasEndThink(children) || isResponding === false) setIsComplete(true) }, [children, isResponding]) From 5b02e5dcb6f6770e30a733bfa17724939193442f Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Thu, 1 Jan 2026 01:38:12 +0900 Subject: [PATCH 23/25] refactor: migrate some ns.model to BaseModel (#30388) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- api/controllers/common/fields.py | 101 +++++++++--------- api/controllers/console/explore/parameter.py | 6 +- api/controllers/service_api/app/annotation.py | 4 +- api/controllers/service_api/app/app.py | 6 +- api/controllers/service_api/app/site.py | 5 +- api/controllers/service_api/app/workflow.py | 4 +- api/controllers/web/app.py | 6 +- api/fields/annotation_fields.py | 4 +- api/fields/conversation_fields.py | 10 +- api/fields/conversation_variable_fields.py | 6 +- api/fields/end_user_fields.py | 4 +- api/fields/file_fields.py | 10 +- api/fields/member_fields.py | 4 +- api/fields/message_fields.py | 6 +- api/fields/tag_fields.py | 4 +- api/fields/workflow_app_log_fields.py | 6 +- api/fields/workflow_run_fields.py | 4 +- api/models/account.py | 8 +- .../controllers/common/test_fields.py | 69 ++++++++++++ 19 files changed, 168 insertions(+), 99 deletions(-) create mode 100644 api/tests/unit_tests/controllers/common/test_fields.py diff --git a/api/controllers/common/fields.py b/api/controllers/common/fields.py index df9de825de..c16a23fac8 100644 --- a/api/controllers/common/fields.py +++ b/api/controllers/common/fields.py @@ -1,62 +1,59 @@ -from flask_restx import Api, Namespace, fields +from __future__ import annotations -from libs.helper import AppIconUrlField +from typing import Any, TypeAlias -parameters__system_parameters = { - "image_file_size_limit": fields.Integer, - "video_file_size_limit": fields.Integer, - "audio_file_size_limit": fields.Integer, - "file_size_limit": fields.Integer, - "workflow_file_upload_limit": fields.Integer, -} +from pydantic import BaseModel, ConfigDict, computed_field + +from core.file import helpers as file_helpers +from models.model import IconType + +JSONValue: TypeAlias = str | int | float | bool | None | dict[str, Any] | list[Any] +JSONObject: TypeAlias = dict[str, Any] -def build_system_parameters_model(api_or_ns: Api | Namespace): - """Build the system parameters model for the API or Namespace.""" - return api_or_ns.model("SystemParameters", parameters__system_parameters) +class SystemParameters(BaseModel): + image_file_size_limit: int + video_file_size_limit: int + audio_file_size_limit: int + file_size_limit: int + workflow_file_upload_limit: int -parameters_fields = { - "opening_statement": fields.String, - "suggested_questions": fields.Raw, - "suggested_questions_after_answer": fields.Raw, - "speech_to_text": fields.Raw, - "text_to_speech": fields.Raw, - "retriever_resource": fields.Raw, - "annotation_reply": fields.Raw, - "more_like_this": fields.Raw, - "user_input_form": fields.Raw, - "sensitive_word_avoidance": fields.Raw, - "file_upload": fields.Raw, - "system_parameters": fields.Nested(parameters__system_parameters), -} +class Parameters(BaseModel): + opening_statement: str | None = None + suggested_questions: list[str] + suggested_questions_after_answer: JSONObject + speech_to_text: JSONObject + text_to_speech: JSONObject + retriever_resource: JSONObject + annotation_reply: JSONObject + more_like_this: JSONObject + user_input_form: list[JSONObject] + sensitive_word_avoidance: JSONObject + file_upload: JSONObject + system_parameters: SystemParameters -def build_parameters_model(api_or_ns: Api | Namespace): - """Build the parameters model for the API or Namespace.""" - copied_fields = parameters_fields.copy() - copied_fields["system_parameters"] = fields.Nested(build_system_parameters_model(api_or_ns)) - return api_or_ns.model("Parameters", copied_fields) +class Site(BaseModel): + model_config = ConfigDict(from_attributes=True) + title: str + chat_color_theme: str | None = None + chat_color_theme_inverted: bool + icon_type: str | None = None + icon: str | None = None + icon_background: str | None = None + description: str | None = None + copyright: str | None = None + privacy_policy: str | None = None + custom_disclaimer: str | None = None + default_language: str + show_workflow_steps: bool + use_icon_as_answer_icon: bool -site_fields = { - "title": fields.String, - "chat_color_theme": fields.String, - "chat_color_theme_inverted": fields.Boolean, - "icon_type": fields.String, - "icon": fields.String, - "icon_background": fields.String, - "icon_url": AppIconUrlField, - "description": fields.String, - "copyright": fields.String, - "privacy_policy": fields.String, - "custom_disclaimer": fields.String, - "default_language": fields.String, - "show_workflow_steps": fields.Boolean, - "use_icon_as_answer_icon": fields.Boolean, -} - - -def build_site_model(api_or_ns: Api | Namespace): - """Build the site model for the API or Namespace.""" - return api_or_ns.model("Site", site_fields) + @computed_field(return_type=str | None) # type: ignore + @property + def icon_url(self) -> str | None: + if self.icon and self.icon_type == IconType.IMAGE: + return file_helpers.get_signed_file_url(self.icon) + return None diff --git a/api/controllers/console/explore/parameter.py b/api/controllers/console/explore/parameter.py index 9c6b2aedfb..660a4d5aea 100644 --- a/api/controllers/console/explore/parameter.py +++ b/api/controllers/console/explore/parameter.py @@ -1,5 +1,3 @@ -from flask_restx import marshal_with - from controllers.common import fields from controllers.console import console_ns from controllers.console.app.error import AppUnavailableError @@ -13,7 +11,6 @@ from services.app_service import AppService class AppParameterApi(InstalledAppResource): """Resource for app variables.""" - @marshal_with(fields.parameters_fields) def get(self, installed_app: InstalledApp): """Retrieve app parameters.""" app_model = installed_app.app @@ -37,7 +34,8 @@ class AppParameterApi(InstalledAppResource): user_input_form = features_dict.get("user_input_form", []) - return get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form) + parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form) + return fields.Parameters.model_validate(parameters).model_dump(mode="json") @console_ns.route("/installed-apps//meta", endpoint="installed_app_meta") diff --git a/api/controllers/service_api/app/annotation.py b/api/controllers/service_api/app/annotation.py index 63c373b50f..85ac9336d6 100644 --- a/api/controllers/service_api/app/annotation.py +++ b/api/controllers/service_api/app/annotation.py @@ -1,7 +1,7 @@ from typing import Literal from flask import request -from flask_restx import Api, Namespace, Resource, fields +from flask_restx import Namespace, Resource, fields from flask_restx.api import HTTPStatus from pydantic import BaseModel, Field @@ -92,7 +92,7 @@ annotation_list_fields = { } -def build_annotation_list_model(api_or_ns: Api | Namespace): +def build_annotation_list_model(api_or_ns: Namespace): """Build the annotation list model for the API or Namespace.""" copied_annotation_list_fields = annotation_list_fields.copy() copied_annotation_list_fields["data"] = fields.List(fields.Nested(build_annotation_model(api_or_ns))) diff --git a/api/controllers/service_api/app/app.py b/api/controllers/service_api/app/app.py index 25d7ccccec..562f5e33cc 100644 --- a/api/controllers/service_api/app/app.py +++ b/api/controllers/service_api/app/app.py @@ -1,6 +1,6 @@ from flask_restx import Resource -from controllers.common.fields import build_parameters_model +from controllers.common.fields import Parameters from controllers.service_api import service_api_ns from controllers.service_api.app.error import AppUnavailableError from controllers.service_api.wraps import validate_app_token @@ -23,7 +23,6 @@ class AppParameterApi(Resource): } ) @validate_app_token - @service_api_ns.marshal_with(build_parameters_model(service_api_ns)) def get(self, app_model: App): """Retrieve app parameters. @@ -45,7 +44,8 @@ class AppParameterApi(Resource): user_input_form = features_dict.get("user_input_form", []) - return get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form) + parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form) + return Parameters.model_validate(parameters).model_dump(mode="json") @service_api_ns.route("/meta") diff --git a/api/controllers/service_api/app/site.py b/api/controllers/service_api/app/site.py index 9f8324a84e..8b47a887bb 100644 --- a/api/controllers/service_api/app/site.py +++ b/api/controllers/service_api/app/site.py @@ -1,7 +1,7 @@ from flask_restx import Resource from werkzeug.exceptions import Forbidden -from controllers.common.fields import build_site_model +from controllers.common.fields import Site as SiteResponse from controllers.service_api import service_api_ns from controllers.service_api.wraps import validate_app_token from extensions.ext_database import db @@ -23,7 +23,6 @@ class AppSiteApi(Resource): } ) @validate_app_token - @service_api_ns.marshal_with(build_site_model(service_api_ns)) def get(self, app_model: App): """Retrieve app site info. @@ -38,4 +37,4 @@ class AppSiteApi(Resource): if app_model.tenant.status == TenantStatus.ARCHIVE: raise Forbidden() - return site + return SiteResponse.model_validate(site).model_dump(mode="json") diff --git a/api/controllers/service_api/app/workflow.py b/api/controllers/service_api/app/workflow.py index 4964888fd6..6a549fc926 100644 --- a/api/controllers/service_api/app/workflow.py +++ b/api/controllers/service_api/app/workflow.py @@ -3,7 +3,7 @@ from typing import Any, Literal from dateutil.parser import isoparse from flask import request -from flask_restx import Api, Namespace, Resource, fields +from flask_restx import Namespace, Resource, fields from pydantic import BaseModel, Field from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import BadRequest, InternalServerError, NotFound @@ -78,7 +78,7 @@ workflow_run_fields = { } -def build_workflow_run_model(api_or_ns: Api | Namespace): +def build_workflow_run_model(api_or_ns: Namespace): """Build the workflow run model for the API or Namespace.""" return api_or_ns.model("WorkflowRun", workflow_run_fields) diff --git a/api/controllers/web/app.py b/api/controllers/web/app.py index db3b93a4dc..62ea532eac 100644 --- a/api/controllers/web/app.py +++ b/api/controllers/web/app.py @@ -1,7 +1,7 @@ import logging from flask import request -from flask_restx import Resource, marshal_with +from flask_restx import Resource from pydantic import BaseModel, ConfigDict, Field from werkzeug.exceptions import Unauthorized @@ -50,7 +50,6 @@ class AppParameterApi(WebApiResource): 500: "Internal Server Error", } ) - @marshal_with(fields.parameters_fields) def get(self, app_model: App, end_user): """Retrieve app parameters.""" if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: @@ -69,7 +68,8 @@ class AppParameterApi(WebApiResource): user_input_form = features_dict.get("user_input_form", []) - return get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form) + parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form) + return fields.Parameters.model_validate(parameters).model_dump(mode="json") @web_ns.route("/meta") diff --git a/api/fields/annotation_fields.py b/api/fields/annotation_fields.py index 38835d5ac7..e69306dcb2 100644 --- a/api/fields/annotation_fields.py +++ b/api/fields/annotation_fields.py @@ -1,4 +1,4 @@ -from flask_restx import Api, Namespace, fields +from flask_restx import Namespace, fields from libs.helper import TimestampField @@ -12,7 +12,7 @@ annotation_fields = { } -def build_annotation_model(api_or_ns: Api | Namespace): +def build_annotation_model(api_or_ns: Namespace): """Build the annotation model for the API or Namespace.""" return api_or_ns.model("Annotation", annotation_fields) diff --git a/api/fields/conversation_fields.py b/api/fields/conversation_fields.py index ecc267cf38..e4ca2e7a42 100644 --- a/api/fields/conversation_fields.py +++ b/api/fields/conversation_fields.py @@ -1,4 +1,4 @@ -from flask_restx import Api, Namespace, fields +from flask_restx import Namespace, fields from fields.member_fields import simple_account_fields from libs.helper import TimestampField @@ -46,7 +46,7 @@ message_file_fields = { } -def build_message_file_model(api_or_ns: Api | Namespace): +def build_message_file_model(api_or_ns: Namespace): """Build the message file fields for the API or Namespace.""" return api_or_ns.model("MessageFile", message_file_fields) @@ -217,7 +217,7 @@ conversation_infinite_scroll_pagination_fields = { } -def build_conversation_infinite_scroll_pagination_model(api_or_ns: Api | Namespace): +def build_conversation_infinite_scroll_pagination_model(api_or_ns: Namespace): """Build the conversation infinite scroll pagination model for the API or Namespace.""" simple_conversation_model = build_simple_conversation_model(api_or_ns) @@ -226,11 +226,11 @@ def build_conversation_infinite_scroll_pagination_model(api_or_ns: Api | Namespa return api_or_ns.model("ConversationInfiniteScrollPagination", copied_fields) -def build_conversation_delete_model(api_or_ns: Api | Namespace): +def build_conversation_delete_model(api_or_ns: Namespace): """Build the conversation delete model for the API or Namespace.""" return api_or_ns.model("ConversationDelete", conversation_delete_fields) -def build_simple_conversation_model(api_or_ns: Api | Namespace): +def build_simple_conversation_model(api_or_ns: Namespace): """Build the simple conversation model for the API or Namespace.""" return api_or_ns.model("SimpleConversation", simple_conversation_fields) diff --git a/api/fields/conversation_variable_fields.py b/api/fields/conversation_variable_fields.py index 7d5e311591..c55014a368 100644 --- a/api/fields/conversation_variable_fields.py +++ b/api/fields/conversation_variable_fields.py @@ -1,4 +1,4 @@ -from flask_restx import Api, Namespace, fields +from flask_restx import Namespace, fields from libs.helper import TimestampField @@ -29,12 +29,12 @@ conversation_variable_infinite_scroll_pagination_fields = { } -def build_conversation_variable_model(api_or_ns: Api | Namespace): +def build_conversation_variable_model(api_or_ns: Namespace): """Build the conversation variable model for the API or Namespace.""" return api_or_ns.model("ConversationVariable", conversation_variable_fields) -def build_conversation_variable_infinite_scroll_pagination_model(api_or_ns: Api | Namespace): +def build_conversation_variable_infinite_scroll_pagination_model(api_or_ns: Namespace): """Build the conversation variable infinite scroll pagination model for the API or Namespace.""" # Build the nested variable model first conversation_variable_model = build_conversation_variable_model(api_or_ns) diff --git a/api/fields/end_user_fields.py b/api/fields/end_user_fields.py index ea43e3b5fd..5389b0213a 100644 --- a/api/fields/end_user_fields.py +++ b/api/fields/end_user_fields.py @@ -1,4 +1,4 @@ -from flask_restx import Api, Namespace, fields +from flask_restx import Namespace, fields simple_end_user_fields = { "id": fields.String, @@ -8,5 +8,5 @@ simple_end_user_fields = { } -def build_simple_end_user_model(api_or_ns: Api | Namespace): +def build_simple_end_user_model(api_or_ns: Namespace): return api_or_ns.model("SimpleEndUser", simple_end_user_fields) diff --git a/api/fields/file_fields.py b/api/fields/file_fields.py index a707500445..70138404c7 100644 --- a/api/fields/file_fields.py +++ b/api/fields/file_fields.py @@ -1,4 +1,4 @@ -from flask_restx import Api, Namespace, fields +from flask_restx import Namespace, fields from libs.helper import TimestampField @@ -14,7 +14,7 @@ upload_config_fields = { } -def build_upload_config_model(api_or_ns: Api | Namespace): +def build_upload_config_model(api_or_ns: Namespace): """Build the upload config model for the API or Namespace. Args: @@ -39,7 +39,7 @@ file_fields = { } -def build_file_model(api_or_ns: Api | Namespace): +def build_file_model(api_or_ns: Namespace): """Build the file model for the API or Namespace. Args: @@ -57,7 +57,7 @@ remote_file_info_fields = { } -def build_remote_file_info_model(api_or_ns: Api | Namespace): +def build_remote_file_info_model(api_or_ns: Namespace): """Build the remote file info model for the API or Namespace. Args: @@ -81,7 +81,7 @@ file_fields_with_signed_url = { } -def build_file_with_signed_url_model(api_or_ns: Api | Namespace): +def build_file_with_signed_url_model(api_or_ns: Namespace): """Build the file with signed URL model for the API or Namespace. Args: diff --git a/api/fields/member_fields.py b/api/fields/member_fields.py index 08e38a6931..25160927e6 100644 --- a/api/fields/member_fields.py +++ b/api/fields/member_fields.py @@ -1,4 +1,4 @@ -from flask_restx import Api, Namespace, fields +from flask_restx import Namespace, fields from libs.helper import AvatarUrlField, TimestampField @@ -9,7 +9,7 @@ simple_account_fields = { } -def build_simple_account_model(api_or_ns: Api | Namespace): +def build_simple_account_model(api_or_ns: Namespace): return api_or_ns.model("SimpleAccount", simple_account_fields) diff --git a/api/fields/message_fields.py b/api/fields/message_fields.py index a419da2e18..151ff6f826 100644 --- a/api/fields/message_fields.py +++ b/api/fields/message_fields.py @@ -1,4 +1,4 @@ -from flask_restx import Api, Namespace, fields +from flask_restx import Namespace, fields from fields.conversation_fields import message_file_fields from libs.helper import TimestampField @@ -10,7 +10,7 @@ feedback_fields = { } -def build_feedback_model(api_or_ns: Api | Namespace): +def build_feedback_model(api_or_ns: Namespace): """Build the feedback model for the API or Namespace.""" return api_or_ns.model("Feedback", feedback_fields) @@ -30,7 +30,7 @@ agent_thought_fields = { } -def build_agent_thought_model(api_or_ns: Api | Namespace): +def build_agent_thought_model(api_or_ns: Namespace): """Build the agent thought model for the API or Namespace.""" return api_or_ns.model("AgentThought", agent_thought_fields) diff --git a/api/fields/tag_fields.py b/api/fields/tag_fields.py index d5b7c86a04..e359a4408c 100644 --- a/api/fields/tag_fields.py +++ b/api/fields/tag_fields.py @@ -1,4 +1,4 @@ -from flask_restx import Api, Namespace, fields +from flask_restx import Namespace, fields dataset_tag_fields = { "id": fields.String, @@ -8,5 +8,5 @@ dataset_tag_fields = { } -def build_dataset_tag_fields(api_or_ns: Api | Namespace): +def build_dataset_tag_fields(api_or_ns: Namespace): return api_or_ns.model("DataSetTag", dataset_tag_fields) diff --git a/api/fields/workflow_app_log_fields.py b/api/fields/workflow_app_log_fields.py index 4cbdf6f0ca..0ebc03a98c 100644 --- a/api/fields/workflow_app_log_fields.py +++ b/api/fields/workflow_app_log_fields.py @@ -1,4 +1,4 @@ -from flask_restx import Api, Namespace, fields +from flask_restx import Namespace, fields from fields.end_user_fields import build_simple_end_user_model, simple_end_user_fields from fields.member_fields import build_simple_account_model, simple_account_fields @@ -17,7 +17,7 @@ workflow_app_log_partial_fields = { } -def build_workflow_app_log_partial_model(api_or_ns: Api | Namespace): +def build_workflow_app_log_partial_model(api_or_ns: Namespace): """Build the workflow app log partial model for the API or Namespace.""" workflow_run_model = build_workflow_run_for_log_model(api_or_ns) simple_account_model = build_simple_account_model(api_or_ns) @@ -43,7 +43,7 @@ workflow_app_log_pagination_fields = { } -def build_workflow_app_log_pagination_model(api_or_ns: Api | Namespace): +def build_workflow_app_log_pagination_model(api_or_ns: Namespace): """Build the workflow app log pagination model for the API or Namespace.""" # Build the nested partial model first workflow_app_log_partial_model = build_workflow_app_log_partial_model(api_or_ns) diff --git a/api/fields/workflow_run_fields.py b/api/fields/workflow_run_fields.py index 821ce62ecc..476025064f 100644 --- a/api/fields/workflow_run_fields.py +++ b/api/fields/workflow_run_fields.py @@ -1,4 +1,4 @@ -from flask_restx import Api, Namespace, fields +from flask_restx import Namespace, fields from fields.end_user_fields import simple_end_user_fields from fields.member_fields import simple_account_fields @@ -19,7 +19,7 @@ workflow_run_for_log_fields = { } -def build_workflow_run_for_log_model(api_or_ns: Api | Namespace): +def build_workflow_run_for_log_model(api_or_ns: Namespace): return api_or_ns.model("WorkflowRunForLog", workflow_run_for_log_fields) diff --git a/api/models/account.py b/api/models/account.py index 420e6adc6c..f7a9c20026 100644 --- a/api/models/account.py +++ b/api/models/account.py @@ -8,7 +8,7 @@ from uuid import uuid4 import sqlalchemy as sa from flask_login import UserMixin from sqlalchemy import DateTime, String, func, select -from sqlalchemy.orm import Mapped, Session, mapped_column +from sqlalchemy.orm import Mapped, Session, mapped_column, validates from typing_extensions import deprecated from .base import TypeBase @@ -116,6 +116,12 @@ class Account(UserMixin, TypeBase): role: TenantAccountRole | None = field(default=None, init=False) _current_tenant: "Tenant | None" = field(default=None, init=False) + @validates("status") + def _normalize_status(self, _key: str, value: str | AccountStatus) -> str: + if isinstance(value, AccountStatus): + return value.value + return value + @property def is_password_set(self): return self.password is not None diff --git a/api/tests/unit_tests/controllers/common/test_fields.py b/api/tests/unit_tests/controllers/common/test_fields.py new file mode 100644 index 0000000000..d4dc13127d --- /dev/null +++ b/api/tests/unit_tests/controllers/common/test_fields.py @@ -0,0 +1,69 @@ +import builtins +from types import SimpleNamespace +from unittest.mock import patch + +from flask.views import MethodView as FlaskMethodView + +_NEEDS_METHOD_VIEW_CLEANUP = False +if not hasattr(builtins, "MethodView"): + builtins.MethodView = FlaskMethodView + _NEEDS_METHOD_VIEW_CLEANUP = True +from controllers.common.fields import Parameters, Site +from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict +from models.model import IconType + + +def test_parameters_model_round_trip(): + parameters = get_parameters_from_feature_dict(features_dict={}, user_input_form=[]) + + model = Parameters.model_validate(parameters) + + assert model.model_dump(mode="json") == parameters + + +def test_site_icon_url_uses_signed_url_for_image_icon(): + site = SimpleNamespace( + title="Example", + chat_color_theme=None, + chat_color_theme_inverted=False, + icon_type=IconType.IMAGE, + icon="file-id", + icon_background=None, + description=None, + copyright=None, + privacy_policy=None, + custom_disclaimer=None, + default_language="en-US", + show_workflow_steps=True, + use_icon_as_answer_icon=False, + ) + + with patch("controllers.common.fields.file_helpers.get_signed_file_url", return_value="signed") as mock_helper: + model = Site.model_validate(site) + + assert model.icon_url == "signed" + mock_helper.assert_called_once_with("file-id") + + +def test_site_icon_url_is_none_for_non_image_icon(): + site = SimpleNamespace( + title="Example", + chat_color_theme=None, + chat_color_theme_inverted=False, + icon_type=IconType.EMOJI, + icon="file-id", + icon_background=None, + description=None, + copyright=None, + privacy_policy=None, + custom_disclaimer=None, + default_language="en-US", + show_workflow_steps=True, + use_icon_as_answer_icon=False, + ) + + with patch("controllers.common.fields.file_helpers.get_signed_file_url") as mock_helper: + model = Site.model_validate(site) + + assert model.icon_url is None + mock_helper.assert_not_called() From ae43ad5cb6ca51ed630d3f1a8f2e827566af8bab Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Thu, 1 Jan 2026 00:40:21 +0800 Subject: [PATCH 24/25] fix: fix when vision is disabled delete the configs (#30420) --- web/app/components/workflow/hooks/use-config-vision.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/app/components/workflow/hooks/use-config-vision.ts b/web/app/components/workflow/hooks/use-config-vision.ts index 12a2ad38ad..f9c68243c0 100644 --- a/web/app/components/workflow/hooks/use-config-vision.ts +++ b/web/app/components/workflow/hooks/use-config-vision.ts @@ -49,6 +49,9 @@ const useConfigVision = (model: ModelConfig, { variable_selector: ['sys', 'files'], } } + else if (!enabled) { + delete draft.configs + } }) onChange(newPayload) }, [isChatMode, onChange, payload]) From 9b6b2f31950c50ec54a06985dadaf6577e295c83 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Thu, 1 Jan 2026 00:40:54 +0800 Subject: [PATCH 25/25] feat: add AgentMaxIterationError exc (#30423) --- api/core/agent/cot_agent_runner.py | 6 ++++++ api/core/agent/fc_agent_runner.py | 5 +++++ api/core/workflow/nodes/agent/exc.py | 11 +++++++++++ 3 files changed, 22 insertions(+) diff --git a/api/core/agent/cot_agent_runner.py b/api/core/agent/cot_agent_runner.py index b32e35d0ca..a55f2d0f5f 100644 --- a/api/core/agent/cot_agent_runner.py +++ b/api/core/agent/cot_agent_runner.py @@ -22,6 +22,7 @@ from core.prompt.agent_history_prompt_transform import AgentHistoryPromptTransfo from core.tools.__base.tool import Tool from core.tools.entities.tool_entities import ToolInvokeMeta from core.tools.tool_engine import ToolEngine +from core.workflow.nodes.agent.exc import AgentMaxIterationError from models.model import Message logger = logging.getLogger(__name__) @@ -165,6 +166,11 @@ class CotAgentRunner(BaseAgentRunner, ABC): scratchpad.thought = scratchpad.thought.strip() or "I am thinking about how to help you" self._agent_scratchpad.append(scratchpad) + # Check if max iteration is reached and model still wants to call tools + if iteration_step == max_iteration_steps and scratchpad.action: + if scratchpad.action.action_name.lower() != "final answer": + raise AgentMaxIterationError(app_config.agent.max_iteration) + # get llm usage if "usage" in usage_dict: if usage_dict["usage"] is not None: diff --git a/api/core/agent/fc_agent_runner.py b/api/core/agent/fc_agent_runner.py index dcc1326b33..68d14ad027 100644 --- a/api/core/agent/fc_agent_runner.py +++ b/api/core/agent/fc_agent_runner.py @@ -25,6 +25,7 @@ from core.model_runtime.entities.message_entities import ImagePromptMessageConte from core.prompt.agent_history_prompt_transform import AgentHistoryPromptTransform from core.tools.entities.tool_entities import ToolInvokeMeta from core.tools.tool_engine import ToolEngine +from core.workflow.nodes.agent.exc import AgentMaxIterationError from models.model import Message logger = logging.getLogger(__name__) @@ -222,6 +223,10 @@ class FunctionCallAgentRunner(BaseAgentRunner): final_answer += response + "\n" + # Check if max iteration is reached and model still wants to call tools + if iteration_step == max_iteration_steps and tool_calls: + raise AgentMaxIterationError(app_config.agent.max_iteration) + # call tools tool_responses = [] for tool_call_id, tool_call_name, tool_call_args in tool_calls: diff --git a/api/core/workflow/nodes/agent/exc.py b/api/core/workflow/nodes/agent/exc.py index 944f5f0b20..ba2c83d8a6 100644 --- a/api/core/workflow/nodes/agent/exc.py +++ b/api/core/workflow/nodes/agent/exc.py @@ -119,3 +119,14 @@ class AgentVariableTypeError(AgentNodeError): self.expected_type = expected_type self.actual_type = actual_type super().__init__(message) + + +class AgentMaxIterationError(AgentNodeError): + """Exception raised when the agent exceeds the maximum iteration limit.""" + + def __init__(self, max_iteration: int): + self.max_iteration = max_iteration + super().__init__( + f"Agent exceeded the maximum iteration limit of {max_iteration}. " + f"The agent was unable to complete the task within the allowed number of iterations." + )