Merge branch 'main' into feat/grouping-branching

This commit is contained in:
zhsama
2026-01-02 01:34:02 +08:00
278 changed files with 3019 additions and 1015 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<IConfigModalProps> = ({
const modalRef = useRef<HTMLDivElement>(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<IConfigModalProps> = ({
}, [])
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<IConfigModalProps> = ({
},
]
: []),
...((!isBasicApp && isSupportJSON)
...((!isBasicApp)
? [{
name: t('variableConfig.json', { ns: 'appDebug' }),
value: InputVarType.jsonObject,
@ -233,7 +233,28 @@ const ConfigModal: FC<IConfigModalProps> = ({
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<IConfigModalProps> = ({
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<IConfigModalProps> = ({
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<IConfigModalProps> = ({
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)
}
}

View File

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

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import type { ExternalDataTool } from '@/models/common'
import copy from 'copy-to-clipboard'
import { noop } from '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'

View File

@ -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', () => ({

View File

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

View File

@ -3,7 +3,7 @@ import type { Member } from '@/models/common'
import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { RiCloseLine } from '@remixicon/react'
import { isEqual } from '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'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<typeof import('use-context-selector')>('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: [] },

View File

@ -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 = ({
<AppCard
key={app.app_id}
app={app}
canCreate={hasEditPermission}
canCreate={isCurrentWorkspaceEditor}
onCreate={() => {
setCurrApp(app)
setIsShowCreateModal(true)

View File

@ -3,7 +3,7 @@
import type { MouseEventHandler } from 'react'
import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks'
import { noop } from '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'

View File

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

View File

@ -3,7 +3,7 @@ import type { FC } from 'react'
import type { App } from '@/types/app'
import { useDebounce } from 'ahooks'
import dayjs from 'dayjs'
import { omit } from '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'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import type { Area } from 'react-easy-crop'
import type { OnImageInput } from './ImageInput'
import type { AppIconType, ImageFile } from '@/types/app'
import { RiImageCircleAiLine } from '@remixicon/react'
import { noop } from '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'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<P extends object>(
// Simple error fallback component
export const ErrorFallback: React.FC<{
error: Error
resetErrorBoundary: () => void
}> = ({ error, resetErrorBoundary }) => {
resetErrorBoundaryAction: () => void
}> = ({ error, resetErrorBoundaryAction }) => {
return (
<div className="flex min-h-[200px] flex-col items-center justify-center rounded-lg border border-red-200 bg-red-50 p-8">
<h2 className="mb-2 text-lg font-semibold text-red-800">Oops! Something went wrong</h2>
<p className="mb-4 text-center text-red-600">{error.message}</p>
<Button onClick={resetErrorBoundary} size="small">
<Button onClick={resetErrorBoundaryAction} size="small">
Try again
</Button>
</div>

View File

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

View File

@ -2,7 +2,7 @@ import type { ChangeEvent, FC } from 'react'
import type { CodeBasedExtensionItem } from '@/models/common'
import type { ModerationConfig, ModerationContentConfig } from '@/models/debug'
import { RiCloseLine } from '@remixicon/react'
import { noop } from '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'

View File

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

View File

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

View File

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

View File

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

View File

@ -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<IGAProps> = ({
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)

View File

@ -1,174 +0,0 @@
import { access, appendFile, mkdir, open, readdir, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseXml } from '@rgrove/parse-xml'
import { camelCase, template } from 'es-toolkit/compat'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const generateDir = async (currentPath) => {
try {
await mkdir(currentPath, { recursive: true })
}
catch (err) {
console.error(err.message)
}
}
const processSvgStructure = (svgStructure, replaceFillOrStrokeColor) => {
if (svgStructure?.children.length) {
svgStructure.children = svgStructure.children.filter(c => c.type !== 'text')
svgStructure.children.forEach((child) => {
if (child?.name === 'path' && replaceFillOrStrokeColor) {
if (child?.attributes?.stroke)
child.attributes.stroke = 'currentColor'
if (child?.attributes.fill)
child.attributes.fill = 'currentColor'
}
if (child?.children.length)
processSvgStructure(child, replaceFillOrStrokeColor)
})
}
}
const generateSvgComponent = async (fileHandle, entry, pathList, replaceFillOrStrokeColor) => {
const currentPath = path.resolve(__dirname, 'src', ...pathList.slice(2))
try {
await access(currentPath)
}
catch {
await generateDir(currentPath)
}
const svgString = await fileHandle.readFile({ encoding: 'utf8' })
const svgJson = parseXml(svgString).toJSON()
const svgStructure = svgJson.children[0]
processSvgStructure(svgStructure, replaceFillOrStrokeColor)
const prefixFileName = camelCase(entry.split('.')[0])
const fileName = prefixFileName.charAt(0).toUpperCase() + prefixFileName.slice(1)
const svgData = {
icon: svgStructure,
name: fileName,
}
const componentRender = template(`
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './<%= svgName %>.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = '<%= svgName %>'
export default Icon
`.trim())
await writeFile(path.resolve(currentPath, `${fileName}.json`), `${JSON.stringify(svgData, '', '\t')}\n`)
await writeFile(path.resolve(currentPath, `${fileName}.tsx`), `${componentRender({ svgName: fileName })}\n`)
const indexingRender = template(`
export { default as <%= svgName %> } from './<%= svgName %>'
`.trim())
await appendFile(path.resolve(currentPath, 'index.ts'), `${indexingRender({ svgName: fileName })}\n`)
}
const generateImageComponent = async (entry, pathList) => {
const currentPath = path.resolve(__dirname, 'src', ...pathList.slice(2))
try {
await access(currentPath)
}
catch {
await generateDir(currentPath)
}
const prefixFileName = camelCase(entry.split('.')[0])
const fileName = prefixFileName.charAt(0).toUpperCase() + prefixFileName.slice(1)
const componentCSSRender = template(`
.wrapper {
display: inline-flex;
background: url(<%= assetPath %>) center center no-repeat;
background-size: contain;
}
`.trim())
await writeFile(path.resolve(currentPath, `${fileName}.module.css`), `${componentCSSRender({ assetPath: path.posix.join('~@/app/components/base/icons/assets', ...pathList.slice(2), entry) })}\n`)
const componentRender = template(`
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import { cn } from '@/utils/classnames'
import s from './<%= fileName %>.module.css'
const Icon = (
{
ref,
className,
...restProps
}: React.DetailedHTMLProps<React.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement> & {
ref?: React.RefObject<HTMLSpanElement>;
},
) => <span className={cn(s.wrapper, className)} {...restProps} ref={ref} />
Icon.displayName = '<%= fileName %>'
export default Icon
`.trim())
await writeFile(path.resolve(currentPath, `${fileName}.tsx`), `${componentRender({ fileName })}\n`)
const indexingRender = template(`
export { default as <%= fileName %> } from './<%= fileName %>'
`.trim())
await appendFile(path.resolve(currentPath, 'index.ts'), `${indexingRender({ fileName })}\n`)
}
const walk = async (entry, pathList, replaceFillOrStrokeColor) => {
const currentPath = path.resolve(...pathList, entry)
let fileHandle
try {
fileHandle = await open(currentPath)
const stat = await fileHandle.stat()
if (stat.isDirectory()) {
const files = await readdir(currentPath)
for (const file of files)
await walk(file, [...pathList, entry], replaceFillOrStrokeColor)
}
if (stat.isFile() && /.+\.svg$/.test(entry))
await generateSvgComponent(fileHandle, entry, pathList, replaceFillOrStrokeColor)
if (stat.isFile() && /.+\.png$/.test(entry))
await generateImageComponent(entry, pathList)
}
finally {
fileHandle?.close()
}
}
(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'])
})()

View File

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

View File

@ -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<string, string> = {
'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)

View File

@ -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<string, string> = {
'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

View File

@ -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<string, string> = {
'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', () => {

View File

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

View File

@ -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<string, string> = {
'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(
<ChatContextProvider
config={undefined}
isResponding={isResponding}
chatList={[]}
showPromptLog={false}
questionIcon={undefined}
answerIcon={undefined}
onSend={undefined}
onRegenerate={undefined}
onAnnotationEdited={undefined}
onAnnotationAdded={undefined}
onAnnotationRemoved={undefined}
onFeedback={undefined}
>
{children}
</ChatContextProvider>,
)
}
describe('ThinkBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('Rendering', () => {
it('should render regular details element when data-think is false', () => {
render(
<ThinkBlock data-think={false}>
<p>Regular content</p>
</ThinkBlock>,
)
expect(screen.getByText('Regular content')).toBeInTheDocument()
})
it('should render think block with thinking state when data-think is true', () => {
renderWithContext(
<ThinkBlock data-think={true}>
<p>Thinking content</p>
</ThinkBlock>,
true,
)
expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument()
expect(screen.getByText('Thinking content')).toBeInTheDocument()
})
it('should render thought state when content has ENDTHINKFLAG', () => {
renderWithContext(
<ThinkBlock data-think={true}>
<p>Completed thinking[ENDTHINKFLAG]</p>
</ThinkBlock>,
true,
)
expect(screen.getByText(/Thought/)).toBeInTheDocument()
})
})
describe('Timer behavior', () => {
it('should update elapsed time while thinking', () => {
renderWithContext(
<ThinkBlock data-think={true}>
<p>Thinking...</p>
</ThinkBlock>,
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(
<ChatContextProvider
config={undefined}
isResponding={true}
chatList={[]}
showPromptLog={false}
questionIcon={undefined}
answerIcon={undefined}
onSend={undefined}
onRegenerate={undefined}
onAnnotationEdited={undefined}
onAnnotationAdded={undefined}
onAnnotationRemoved={undefined}
onFeedback={undefined}
>
<ThinkBlock data-think={true}>
<p>Thinking content</p>
</ThinkBlock>
</ChatContextProvider>,
)
// Verify initial thinking state
expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument()
// Advance timer
act(() => {
vi.advanceTimersByTime(1000)
})
// Simulate user clicking stop (isResponding becomes false)
rerender(
<ChatContextProvider
config={undefined}
isResponding={false}
chatList={[]}
showPromptLog={false}
questionIcon={undefined}
answerIcon={undefined}
onSend={undefined}
onRegenerate={undefined}
onAnnotationEdited={undefined}
onAnnotationAdded={undefined}
onAnnotationRemoved={undefined}
onFeedback={undefined}
>
<ThinkBlock data-think={true}>
<p>Thinking content</p>
</ThinkBlock>
</ChatContextProvider>,
)
// 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(
<ThinkBlock data-think={true}>
<p>Content without ENDTHINKFLAG</p>
</ThinkBlock>,
)
// 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(
<ThinkBlock data-think={true}>
<p>Content[ENDTHINKFLAG]</p>
</ThinkBlock>,
true,
)
expect(screen.getByText('Content')).toBeInTheDocument()
expect(screen.queryByText('[ENDTHINKFLAG]')).not.toBeInTheDocument()
})
it('should detect ENDTHINKFLAG in nested children', () => {
renderWithContext(
<ThinkBlock data-think={true}>
<div>
<span>Nested content[ENDTHINKFLAG]</span>
</div>
</ThinkBlock>,
true,
)
// Should show "Thought" since ENDTHINKFLAG is present
expect(screen.getByText(/Thought/)).toBeInTheDocument()
})
it('should detect ENDTHINKFLAG in array children', () => {
renderWithContext(
<ThinkBlock data-think={true}>
{['Part 1', 'Part 2[ENDTHINKFLAG]']}
</ThinkBlock>,
true,
)
expect(screen.getByText(/Thought/)).toBeInTheDocument()
})
})
describe('Edge cases', () => {
it('should handle empty children', () => {
renderWithContext(
<ThinkBlock data-think={true}></ThinkBlock>,
true,
)
expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument()
})
it('should handle null children gracefully', () => {
renderWithContext(
<ThinkBlock data-think={true}>
{null}
</ThinkBlock>,
true,
)
expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument()
})
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
import type { Tag } from '@/app/components/base/tag-management/constant'
import { RiCloseLine } from '@remixicon/react'
import { noop } from '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'

View File

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

View File

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

View File

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

View File

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

View File

@ -3,8 +3,6 @@ import * as React from 'react'
import { CategoryEnum } from '.'
import Footer from './footer'
let mockTranslations: Record<string, string> = {}
vi.mock('next/link', () => ({
default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => (
<a href={href} className={className} target={target} data-testid="pricing-link">
@ -13,25 +11,9 @@ vi.mock('next/link', () => ({
),
}))
vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-i18next')>()
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

View File

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

View File

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

View File

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

View File

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

View File

@ -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', () => ({

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import type { Item } from '@/app/components/base/select'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { ChildChunkDetail, SegmentDetailModel, SegmentUpdater } from '@/models/datasets'
import { useDebounceFn } from 'ahooks'
import { noop } from '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'

View File

@ -1,7 +1,7 @@
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem, CustomFile, FileIndexingEstimateResponse } from '@/models/datasets'
import type { OnlineDriveFile, PublishedPipelineRunPreviewResponse } from '@/models/pipeline'
import { noop } from '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'

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import type { MouseEventHandler } from 'react'
import type { AppIconSelection } from '../../base/app-icon-picker'
import type { DataSet } from '@/models/datasets'
import { RiCloseLine } from '@remixicon/react'
import { noop } from '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'

View File

@ -2,7 +2,7 @@
import type { AppIconType } from '@/types/app'
import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -1,6 +1,6 @@
import type { FC } from 'react'
import type { ApiBasedExtension } from '@/models/common'
import { noop } from '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'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<GithubRepo>({
queryKey: ['github-star'],
queryFn: getStar,
enabled: process.env.NODE_ENV !== 'development',
enabled: !IS_DEV,
retry: false,
placeholderData: defaultData,
})

View File

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

View File

@ -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<string, string> = {
'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' }),

View File

@ -64,26 +64,20 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string } & Record<string, unknown>) => {
// 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<string, React.ReactNode> }) => (
<span data-testid="trans">
{i18nKey}
{components?.trustSource}
</span>
),
}))
vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-i18next')>()
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return {
...actual,
...createReactI18nextMock(),
Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record<string, React.ReactNode> }) => (
<span data-testid="trans">
{i18nKey}
{components?.trustSource}
</span>
),
}
})
vi.mock('../../../card', () => ({
default: ({ payload, titleLeft }: {

View File

@ -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<string, unknown>) => {
// 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 }

View File

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

View File

@ -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<string, unknown>) => {
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', () => {
</MarketplaceContextProvider>,
)
// 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', () => {
</MarketplaceContextProvider>,
)
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')
})
})

View File

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

View File

@ -68,17 +68,6 @@ function createMockSubscriptionBuilder(overrides: Partial<TriggerSubscriptionBui
// 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)

View File

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

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