use base ui toast

This commit is contained in:
yyh
2026-03-25 20:38:44 +08:00
parent a7178b4d5c
commit 20dea1faa2
274 changed files with 3597 additions and 8129 deletions

View File

@ -64,7 +64,7 @@ export const useUpdateAccessMode = () => {
// Component only adds UI behavior.
updateAccessMode({ appId, mode }, {
onSuccess: () => Toast.notify({ type: 'success', message: '...' }),
onSuccess: () => toast.success('...'),
})
// Avoid putting invalidation knowledge in the component.
@ -114,10 +114,7 @@ try {
router.push(`/orders/${order.id}`)
}
catch (error) {
Toast.notify({
type: 'error',
message: error instanceof Error ? error.message : 'Unknown error',
})
toast.error(error instanceof Error ? error.message : 'Unknown error')
}
```

View File

@ -2,7 +2,7 @@ import type { Preview } from '@storybook/react'
import type { Resource } from 'i18next'
import { withThemeByDataAttribute } from '@storybook/addon-themes'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ToastProvider } from '../app/components/base/toast'
import { ToastHost } from '../app/components/base/ui/toast'
import { I18nClientProvider as I18N } from '../app/components/provider/i18n'
import commonEnUS from '../i18n/en-US/common.json'
@ -39,9 +39,10 @@ export const decorators = [
return (
<QueryClientProvider client={queryClient}>
<I18N locale="en-US" resource={storyResources}>
<ToastProvider>
<>
<ToastHost />
<Story />
</ToastProvider>
</>
</I18N>
</QueryClientProvider>
)

View File

@ -25,6 +25,19 @@ let mockSystemFeatures = {
const mockRouterPush = vi.fn()
const mockNotify = vi.fn()
const mockToast = {
success: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'success', message, ...options }),
error: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'error', message, ...options }),
warning: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'warning', message, ...options }),
info: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'info', message, ...options }),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
}
vi.mock('@/app/components/base/ui/toast', () => ({
toast: mockToast,
}))
const mockOnPlanInfoChanged = vi.fn()
const mockDeleteAppMutation = vi.fn().mockResolvedValue(undefined)
let mockDeleteMutationPending = false
@ -94,27 +107,6 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
// Mock the ToastContext used via useContext from use-context-selector
vi.mock('use-context-selector', async () => {
const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector')
return {
...actual,
useContext: () => ({ notify: mockNotify }),
}
})
vi.mock('@/app/components/base/tag-management/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state = {
tagList: [],
showTagManagementModal: false,
setTagList: vi.fn(),
setShowTagManagementModal: vi.fn(),
}
return selector(state)
},
}))
vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([]),
}))

View File

@ -33,7 +33,7 @@ vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
vi.mock('@/app/components/base/ui/toast', () => ({
default: { notify: vi.fn() },
}))

View File

@ -10,6 +10,19 @@ import { describe, expect, it, vi } from 'vitest'
const mockDoSyncWorkflowDraft = vi.fn().mockResolvedValue(undefined)
const mockExportPipelineConfig = vi.fn().mockResolvedValue({ data: 'yaml-content' })
const mockNotify = vi.fn()
const mockToast = {
success: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'success', message, ...options }),
error: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'error', message, ...options }),
warning: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'warning', message, ...options }),
info: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'info', message, ...options }),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
}
vi.mock('@/app/components/base/ui/toast', () => ({
toast: mockToast,
}))
const mockEventEmitter = { emit: vi.fn() }
const mockDownloadBlob = vi.fn()
@ -19,10 +32,6 @@ vi.mock('react-i18next', () => ({
}),
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/app/components/workflow/constants', () => ({
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
}))

View File

@ -153,7 +153,7 @@ vi.mock('@/app/components/base/confirm', () => ({
),
}))
vi.mock('@/app/components/base/toast', () => ({
vi.mock('@/app/components/base/ui/toast', () => ({
default: { notify: vi.fn() },
}))

View File

@ -8,12 +8,11 @@ import type { I18nKeysByPrefix } from '@/types/i18n'
import * as React from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AppCard from '@/app/components/app/overview/app-card'
import TriggerCard from '@/app/components/app/overview/trigger-card'
import { useStore as useAppStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import { ToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
@ -37,7 +36,6 @@ export type ICardViewProps = {
const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
@ -106,10 +104,7 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
}
}
notify({
type,
message: t(`actionMsg.${message}`, { ns: 'common' }) as string,
})
toast(t(`actionMsg.${message}`, { ns: 'common' }) as string, { type })
}
// Listen for collaborative app state updates from other clients

View File

@ -13,7 +13,7 @@ import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { AliyunIcon, ArizeIcon, DatabricksIcon, LangfuseIcon, LangsmithIcon, MlflowIcon, OpikIcon, PhoenixIcon, TencentIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import Indicator from '@/app/components/header/indicator'
import { useAppContext } from '@/context/app-context'
import { usePathname } from '@/next/navigation'
@ -43,10 +43,7 @@ const Panel: FC = () => {
await updateTracingStatus({ appId, body: tracingStatus })
setTracingStatus(tracingStatus)
if (!noToast) {
Toast.notify({
type: 'success',
message: t('api.success', { ns: 'common' }),
})
toast(t('api.success', { ns: 'common' }), { type: 'success' })
}
}

View File

@ -14,7 +14,7 @@ import {
PortalToFollowElem,
PortalToFollowElemContent,
} from '@/app/components/base/portal-to-follow-elem'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { addTracingConfig, removeTracingConfig, updateTracingConfig } from '@/service/apps'
import { docURL } from './config'
import Field from './field'
@ -155,10 +155,7 @@ const ProviderConfigModal: FC<Props> = ({
appId,
provider: type,
})
Toast.notify({
type: 'success',
message: t('api.remove', { ns: 'common' }),
})
toast(t('api.remove', { ns: 'common' }), { type: 'success' })
onRemoved()
hideRemoveConfirm()
}, [hideRemoveConfirm, appId, type, t, onRemoved])
@ -264,10 +261,7 @@ const ProviderConfigModal: FC<Props> = ({
return
const errorMessage = checkValid()
if (errorMessage) {
Toast.notify({
type: 'error',
message: errorMessage,
})
toast(errorMessage, { type: 'error' })
return
}
const action = isEdit ? updateTracingConfig : addTracingConfig
@ -279,10 +273,7 @@ const ProviderConfigModal: FC<Props> = ({
tracing_config: config,
},
})
Toast.notify({
type: 'success',
message: t('api.success', { ns: 'common' }),
})
toast(t('api.success', { ns: 'common' }), { type: 'success' })
onSaved(config)
if (isAdd)
onChosen(type)

View File

@ -8,15 +8,14 @@ import { RiDeleteBin5Line, RiPencilLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import ImageInput from '@/app/components/base/app-icon-picker/ImageInput'
import getCroppedImg from '@/app/components/base/app-icon-picker/utils'
import { Avatar } from '@/app/components/base/avatar'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast/context'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import { toast } from '@/app/components/base/ui/toast'
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
import { updateUserProfile } from '@/service/common'
@ -25,7 +24,6 @@ type AvatarWithEditProps = AvatarProps & { onSave?: () => void }
const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [inputImageInfo, setInputImageInfo] = useState<InputImageInfo>()
const [isShowAvatarPicker, setIsShowAvatarPicker] = useState(false)
@ -48,24 +46,24 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
await updateUserProfile({ url: 'account/avatar', body: { avatar: uploadedFileId } })
setIsShowAvatarPicker(false)
onSave?.()
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
}
catch (e) {
notify({ type: 'error', message: (e as Error).message })
toast.error((e as Error).message)
}
}, [notify, onSave, t])
}, [onSave, t])
const handleDeleteAvatar = useCallback(async () => {
try {
await updateUserProfile({ url: 'account/avatar', body: { avatar: '' } })
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
setIsShowDeleteConfirm(false)
onSave?.()
}
catch (e) {
notify({ type: 'error', message: (e as Error).message })
toast.error((e as Error).message)
}
}, [notify, onSave, t])
}, [onSave, t])
const { handleLocalFileUpload } = useLocalFileUploader({
limit: 3,
@ -134,45 +132,39 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
</div>
</div>
<Modal
closable
className="!w-[362px] !p-0"
isShow={isShowAvatarPicker}
onClose={() => setIsShowAvatarPicker(false)}
>
<ImageInput onImageInput={handleImageInput} cropShape="round" />
<Divider className="m-0" />
<Dialog open={isShowAvatarPicker} onOpenChange={open => !open && setIsShowAvatarPicker(false)}>
<DialogContent className="!w-[362px] !p-0">
<ImageInput onImageInput={handleImageInput} cropShape="round" />
<Divider className="m-0" />
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button className="w-full" onClick={() => setIsShowAvatarPicker(false)}>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button className="w-full" onClick={() => setIsShowAvatarPicker(false)}>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<Button variant="primary" className="w-full" disabled={uploading || !inputImageInfo} loading={uploading} onClick={handleSelect}>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</Modal>
<Button variant="primary" className="w-full" disabled={uploading || !inputImageInfo} loading={uploading} onClick={handleSelect}>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</DialogContent>
</Dialog>
<Modal
closable
className="!w-[362px] !p-6"
isShow={isShowDeleteConfirm}
onClose={() => setIsShowDeleteConfirm(false)}
>
<div className="mb-3 text-text-primary title-2xl-semi-bold">{t('avatar.deleteTitle', { ns: 'common' })}</div>
<p className="mb-8 text-text-secondary">{t('avatar.deleteDescription', { ns: 'common' })}</p>
<Dialog open={isShowDeleteConfirm} onOpenChange={open => !open && setIsShowDeleteConfirm(false)}>
<DialogContent className="!w-[362px] !p-6">
<div className="mb-3 text-text-primary title-2xl-semi-bold">{t('avatar.deleteTitle', { ns: 'common' })}</div>
<p className="mb-8 text-text-secondary">{t('avatar.deleteDescription', { ns: 'common' })}</p>
<div className="flex w-full items-center justify-center gap-2">
<Button className="w-full" onClick={() => setIsShowDeleteConfirm(false)}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<div className="flex w-full items-center justify-center gap-2">
<Button className="w-full" onClick={() => setIsShowDeleteConfirm(false)}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button variant="warning" className="w-full" onClick={handleDeleteAvatar}>
{t('operation.delete', { ns: 'common' })}
</Button>
</div>
</Modal>
<Button variant="warning" className="w-full" onClick={handleDeleteAvatar}>
{t('operation.delete', { ns: 'common' })}
</Button>
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -1,14 +1,12 @@
import type { ResponseError } from '@/service/fetch'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast/context'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import { toast } from '@/app/components/base/ui/toast'
import { useRouter } from '@/next/navigation'
import {
checkEmailExisted,
@ -34,7 +32,6 @@ enum STEP {
const EmailChangeModal = ({ onClose, email, show }: Props) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const router = useRouter()
const [step, setStep] = useState<STEP>(STEP.start)
const [code, setCode] = useState<string>('')
@ -70,10 +67,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
setStepToken(res.data)
}
catch (error) {
notify({
type: 'error',
message: `Error sending verification code: ${error ? (error as any).message : ''}`,
})
toast.error(`Error sending verification code: ${error ? (error as any).message : ''}`)
}
}
@ -89,17 +83,11 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
callback?.(res.token)
}
else {
notify({
type: 'error',
message: 'Verifying email failed',
})
toast.error('Verifying email failed')
}
}
catch (error) {
notify({
type: 'error',
message: `Error verifying email: ${error ? (error as any).message : ''}`,
})
toast.error(`Error verifying email: ${error ? (error as any).message : ''}`)
}
}
@ -154,10 +142,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
const sendCodeToNewEmail = async () => {
if (!isValidEmail(mail)) {
notify({
type: 'error',
message: 'Invalid email format',
})
toast.error('Invalid email format')
return
}
await sendEmail(
@ -187,10 +172,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
handleLogout()
}
catch (error) {
notify({
type: 'error',
message: `Error changing email: ${error ? (error as any).message : ''}`,
})
toast.error(`Error changing email: ${error ? (error as any).message : ''}`)
}
}
@ -199,187 +181,185 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
}
return (
<Modal
isShow={show}
onClose={noop}
className="!w-[420px] !p-6"
>
<div className="absolute right-5 top-5 cursor-pointer p-1.5" onClick={onClose}>
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
</div>
{step === STEP.start && (
<>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.title', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="text-text-warning body-md-medium">{t('account.changeEmail.authTip', { ns: 'common' })}</div>
<div className="text-text-secondary body-md-regular">
<Trans
i18nKey="account.changeEmail.content1"
ns="common"
components={{ email: <span className="text-text-primary body-md-medium"></span> }}
values={{ email }}
<Dialog open={show} onOpenChange={open => !open && onClose()}>
<DialogContent className="!w-[420px] !p-6">
<div className="absolute right-5 top-5 cursor-pointer p-1.5" onClick={onClose}>
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
</div>
{step === STEP.start && (
<>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.title', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="text-text-warning body-md-medium">{t('account.changeEmail.authTip', { ns: 'common' })}</div>
<div className="text-text-secondary body-md-regular">
<Trans
i18nKey="account.changeEmail.content1"
ns="common"
components={{ email: <span className="text-text-primary body-md-medium"></span> }}
values={{ email }}
/>
</div>
</div>
<div className="pt-3"></div>
<div className="space-y-2">
<Button
className="!w-full"
variant="primary"
onClick={sendCodeToOriginEmail}
>
{t('account.changeEmail.sendVerifyCode', { ns: 'common' })}
</Button>
<Button
className="!w-full"
onClick={onClose}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
</>
)}
{step === STEP.verifyOrigin && (
<>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.verifyEmail', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="text-text-secondary body-md-regular">
<Trans
i18nKey="account.changeEmail.content2"
ns="common"
components={{ email: <span className="text-text-primary body-md-medium"></span> }}
values={{ email }}
/>
</div>
</div>
<div className="pt-3">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
<Input
className="!w-full"
placeholder={t('account.changeEmail.codePlaceholder', { ns: 'common' })}
value={code}
onChange={e => setCode(e.target.value)}
maxLength={6}
/>
</div>
</div>
<div className="pt-3"></div>
<div className="space-y-2">
<Button
className="!w-full"
variant="primary"
onClick={sendCodeToOriginEmail}
>
{t('account.changeEmail.sendVerifyCode', { ns: 'common' })}
</Button>
<Button
className="!w-full"
onClick={onClose}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
</>
)}
{step === STEP.verifyOrigin && (
<>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.verifyEmail', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="text-text-secondary body-md-regular">
<Trans
i18nKey="account.changeEmail.content2"
ns="common"
components={{ email: <span className="text-text-primary body-md-medium"></span> }}
values={{ email }}
<div className="mt-3 space-y-2">
<Button
disabled={code.length !== 6}
className="!w-full"
variant="primary"
onClick={handleVerifyOriginEmail}
>
{t('account.changeEmail.continue', { ns: 'common' })}
</Button>
<Button
className="!w-full"
onClick={onClose}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
<div className="mt-3 flex items-center gap-1 text-text-tertiary system-xs-regular">
<span>{t('account.changeEmail.resendTip', { ns: 'common' })}</span>
{time > 0 && (
<span>{t('account.changeEmail.resendCount', { ns: 'common', count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToOriginEmail} className="cursor-pointer text-text-accent-secondary system-xs-medium">{t('account.changeEmail.resend', { ns: 'common' })}</span>
)}
</div>
</>
)}
{step === STEP.newEmail && (
<>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.newEmail', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="text-text-secondary body-md-regular">{t('account.changeEmail.content3', { ns: 'common' })}</div>
</div>
<div className="pt-3">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">{t('account.changeEmail.emailLabel', { ns: 'common' })}</div>
<Input
className="!w-full"
placeholder={t('account.changeEmail.emailPlaceholder', { ns: 'common' })}
value={mail}
onChange={e => handleNewEmailValueChange(e.target.value)}
destructive={newEmailExited || unAvailableEmail}
/>
{newEmailExited && (
<div className="mt-1 py-0.5 text-text-destructive body-xs-regular">{t('account.changeEmail.existingEmail', { ns: 'common' })}</div>
)}
{unAvailableEmail && (
<div className="mt-1 py-0.5 text-text-destructive body-xs-regular">{t('account.changeEmail.unAvailableEmail', { ns: 'common' })}</div>
)}
</div>
<div className="mt-3 space-y-2">
<Button
disabled={!mail || newEmailExited || unAvailableEmail || isCheckingEmail || !isValidEmail(mail)}
className="!w-full"
variant="primary"
onClick={sendCodeToNewEmail}
>
{t('account.changeEmail.sendVerifyCode', { ns: 'common' })}
</Button>
<Button
className="!w-full"
onClick={onClose}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
</>
)}
{step === STEP.verifyNew && (
<>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.verifyNew', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="text-text-secondary body-md-regular">
<Trans
i18nKey="account.changeEmail.content4"
ns="common"
components={{ email: <span className="text-text-primary body-md-medium"></span> }}
values={{ email: mail }}
/>
</div>
</div>
<div className="pt-3">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
<Input
className="!w-full"
placeholder={t('account.changeEmail.codePlaceholder', { ns: 'common' })}
value={code}
onChange={e => setCode(e.target.value)}
maxLength={6}
/>
</div>
</div>
<div className="pt-3">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
<Input
className="!w-full"
placeholder={t('account.changeEmail.codePlaceholder', { ns: 'common' })}
value={code}
onChange={e => setCode(e.target.value)}
maxLength={6}
/>
</div>
<div className="mt-3 space-y-2">
<Button
disabled={code.length !== 6}
className="!w-full"
variant="primary"
onClick={handleVerifyOriginEmail}
>
{t('account.changeEmail.continue', { ns: 'common' })}
</Button>
<Button
className="!w-full"
onClick={onClose}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
<div className="mt-3 flex items-center gap-1 text-text-tertiary system-xs-regular">
<span>{t('account.changeEmail.resendTip', { ns: 'common' })}</span>
{time > 0 && (
<span>{t('account.changeEmail.resendCount', { ns: 'common', count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToOriginEmail} className="cursor-pointer text-text-accent-secondary system-xs-medium">{t('account.changeEmail.resend', { ns: 'common' })}</span>
)}
</div>
</>
)}
{step === STEP.newEmail && (
<>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.newEmail', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="text-text-secondary body-md-regular">{t('account.changeEmail.content3', { ns: 'common' })}</div>
</div>
<div className="pt-3">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">{t('account.changeEmail.emailLabel', { ns: 'common' })}</div>
<Input
className="!w-full"
placeholder={t('account.changeEmail.emailPlaceholder', { ns: 'common' })}
value={mail}
onChange={e => handleNewEmailValueChange(e.target.value)}
destructive={newEmailExited || unAvailableEmail}
/>
{newEmailExited && (
<div className="mt-1 py-0.5 text-text-destructive body-xs-regular">{t('account.changeEmail.existingEmail', { ns: 'common' })}</div>
)}
{unAvailableEmail && (
<div className="mt-1 py-0.5 text-text-destructive body-xs-regular">{t('account.changeEmail.unAvailableEmail', { ns: 'common' })}</div>
)}
</div>
<div className="mt-3 space-y-2">
<Button
disabled={!mail || newEmailExited || unAvailableEmail || isCheckingEmail || !isValidEmail(mail)}
className="!w-full"
variant="primary"
onClick={sendCodeToNewEmail}
>
{t('account.changeEmail.sendVerifyCode', { ns: 'common' })}
</Button>
<Button
className="!w-full"
onClick={onClose}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
</>
)}
{step === STEP.verifyNew && (
<>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.verifyNew', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="text-text-secondary body-md-regular">
<Trans
i18nKey="account.changeEmail.content4"
ns="common"
components={{ email: <span className="text-text-primary body-md-medium"></span> }}
values={{ email: mail }}
/>
<div className="mt-3 space-y-2">
<Button
disabled={code.length !== 6}
className="!w-full"
variant="primary"
onClick={submitNewEmail}
>
{t('account.changeEmail.changeTo', { ns: 'common', email: mail })}
</Button>
<Button
className="!w-full"
onClick={onClose}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
</div>
<div className="pt-3">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
<Input
className="!w-full"
placeholder={t('account.changeEmail.codePlaceholder', { ns: 'common' })}
value={code}
onChange={e => setCode(e.target.value)}
maxLength={6}
/>
</div>
<div className="mt-3 space-y-2">
<Button
disabled={code.length !== 6}
className="!w-full"
variant="primary"
onClick={submitNewEmail}
>
{t('account.changeEmail.changeTo', { ns: 'common', email: mail })}
</Button>
<Button
className="!w-full"
onClick={onClose}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
<div className="mt-3 flex items-center gap-1 text-text-tertiary system-xs-regular">
<span>{t('account.changeEmail.resendTip', { ns: 'common' })}</span>
{time > 0 && (
<span>{t('account.changeEmail.resendCount', { ns: 'common', count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToNewEmail} className="cursor-pointer text-text-accent-secondary system-xs-medium">{t('account.changeEmail.resend', { ns: 'common' })}</span>
)}
</div>
</>
)}
</Modal>
<div className="mt-3 flex items-center gap-1 text-text-tertiary system-xs-regular">
<span>{t('account.changeEmail.resendTip', { ns: 'common' })}</span>
{time > 0 && (
<span>{t('account.changeEmail.resendCount', { ns: 'common', count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToNewEmail} className="cursor-pointer text-text-accent-secondary system-xs-medium">{t('account.changeEmail.resend', { ns: 'common' })}</span>
)}
</div>
</>
)}
</DialogContent>
</Dialog>
)
}

View File

@ -7,13 +7,12 @@ import {
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import PremiumBadge from '@/app/components/base/premium-badge'
import { ToastContext } from '@/app/components/base/toast/context'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import { toast } from '@/app/components/base/ui/toast'
import Collapse from '@/app/components/header/account-setting/collapse'
import { IS_CE_EDITION, validPassword } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
@ -43,7 +42,6 @@ export default function AccountPage() {
const userProfile = userProfileResp?.profile
const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: commonQueryKeys.userProfile })
const { isEducationAccount } = useProviderContext()
const { notify } = useContext(ToastContext)
const [editNameModalVisible, setEditNameModalVisible] = useState(false)
const [editName, setEditName] = useState('')
const [editing, setEditing] = useState(false)
@ -68,22 +66,19 @@ export default function AccountPage() {
try {
setEditing(true)
await updateUserProfile({ url: 'account/name', body: { name: editName } })
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
mutateUserProfile()
setEditNameModalVisible(false)
setEditing(false)
}
catch (e) {
notify({ type: 'error', message: (e as Error).message })
toast.error((e as Error).message)
setEditing(false)
}
}
const showErrorMessage = (message: string) => {
notify({
type: 'error',
message,
})
toast.error(message)
}
const valid = () => {
if (!password.trim()) {
@ -119,14 +114,14 @@ export default function AccountPage() {
repeat_new_password: confirmPassword,
},
})
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
mutateUserProfile()
setEditPasswordModalVisible(false)
resetPasswordForm()
setEditing(false)
}
catch (e) {
notify({ type: 'error', message: (e as Error).message })
toast.error((e as Error).message)
setEditPasswordModalVisible(false)
setEditing(false)
}
@ -221,119 +216,112 @@ export default function AccountPage() {
</div>
{
editNameModalVisible && (
<Modal
isShow
onClose={() => setEditNameModalVisible(false)}
className="!w-[420px] !p-6"
>
<div className="mb-6 text-text-primary title-2xl-semi-bold">{t('account.editName', { ns: 'common' })}</div>
<div className={titleClassName}>{t('account.name', { ns: 'common' })}</div>
<Input
className="mt-2"
value={editName}
onChange={e => setEditName(e.target.value)}
/>
<div className="mt-10 flex justify-end">
<Button className="mr-2" onClick={() => setEditNameModalVisible(false)}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button
disabled={editing || !editName}
variant="primary"
onClick={handleSaveName}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</Modal>
<Dialog open={editNameModalVisible} onOpenChange={open => !open && setEditNameModalVisible(false)}>
<DialogContent className="!w-[420px] !p-6">
<div className="mb-6 text-text-primary title-2xl-semi-bold">{t('account.editName', { ns: 'common' })}</div>
<div className={titleClassName}>{t('account.name', { ns: 'common' })}</div>
<Input
className="mt-2"
value={editName}
onChange={e => setEditName(e.target.value)}
/>
<div className="mt-10 flex justify-end">
<Button className="mr-2" onClick={() => setEditNameModalVisible(false)}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button
disabled={editing || !editName}
variant="primary"
onClick={handleSaveName}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
{
editPasswordModalVisible && (
<Modal
isShow
onClose={() => {
setEditPasswordModalVisible(false)
resetPasswordForm()
}}
className="!w-[420px] !p-6"
>
<div className="mb-6 text-text-primary title-2xl-semi-bold">{userProfile.is_password_set ? t('account.resetPassword', { ns: 'common' }) : t('account.setPassword', { ns: 'common' })}</div>
{userProfile.is_password_set && (
<>
<div className={titleClassName}>{t('account.currentPassword', { ns: 'common' })}</div>
<div className="relative mt-2">
<Input
type={showCurrentPassword ? 'text' : 'password'}
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
/>
<Dialog open={editPasswordModalVisible} onOpenChange={open => !open && (setEditPasswordModalVisible(false), resetPasswordForm())}>
<DialogContent className="!w-[420px] !p-6">
<div className="mb-6 text-text-primary title-2xl-semi-bold">{userProfile.is_password_set ? t('account.resetPassword', { ns: 'common' }) : t('account.setPassword', { ns: 'common' })}</div>
{userProfile.is_password_set && (
<>
<div className={titleClassName}>{t('account.currentPassword', { ns: 'common' })}</div>
<div className="relative mt-2">
<Input
type={showCurrentPassword ? 'text' : 'password'}
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant="ghost"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
>
{showCurrentPassword ? '👀' : '😝'}
</Button>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant="ghost"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
>
{showCurrentPassword ? '👀' : '😝'}
</Button>
</div>
</div>
</>
)}
<div className="mt-8 text-text-secondary system-sm-semibold">
{userProfile.is_password_set ? t('account.newPassword', { ns: 'common' }) : t('account.password', { ns: 'common' })}
</div>
<div className="relative mt-2">
<Input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant="ghost"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? '👀' : '😝'}
</Button>
</div>
</>
)}
<div className="mt-8 text-text-secondary system-sm-semibold">
{userProfile.is_password_set ? t('account.newPassword', { ns: 'common' }) : t('account.password', { ns: 'common' })}
</div>
<div className="relative mt-2">
<Input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
</div>
<div className="mt-8 text-text-secondary system-sm-semibold">{t('account.confirmPassword', { ns: 'common' })}</div>
<div className="relative mt-2">
<Input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant="ghost"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? '👀' : '😝'}
</Button>
</div>
</div>
<div className="mt-10 flex justify-end">
<Button
type="button"
variant="ghost"
onClick={() => setShowPassword(!showPassword)}
className="mr-2"
onClick={() => {
setEditPasswordModalVisible(false)
resetPasswordForm()
}}
>
{showPassword ? '👀' : '😝'}
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
disabled={editing}
variant="primary"
onClick={handleSavePassword}
>
{userProfile.is_password_set ? t('operation.reset', { ns: 'common' }) : t('operation.save', { ns: 'common' })}
</Button>
</div>
</div>
<div className="mt-8 text-text-secondary system-sm-semibold">{t('account.confirmPassword', { ns: 'common' })}</div>
<div className="relative mt-2">
<Input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant="ghost"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? '👀' : '😝'}
</Button>
</div>
</div>
<div className="mt-10 flex justify-end">
<Button
className="mr-2"
onClick={() => {
setEditPasswordModalVisible(false)
resetPasswordForm()
}}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
disabled={editing}
variant="primary"
onClick={handleSavePassword}
>
{userProfile.is_password_set ? t('operation.reset', { ns: 'common' }) : t('operation.save', { ns: 'common' })}
</Button>
</div>
</Modal>
</DialogContent>
</Dialog>
)
}
{

View File

@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import CustomDialog from '@/app/components/base/dialog'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { useAppContext } from '@/context/app-context'
import { useRouter } from '@/next/navigation'
import { useLogout } from '@/service/use-common'
@ -28,7 +28,7 @@ export default function FeedBack(props: DeleteAccountProps) {
await logout()
// Tokens are now stored in cookies and cleared by backend
router.push('/signin')
Toast.notify({ type: 'info', message: t('account.deleteSuccessTip', { ns: 'common' }) })
toast.info(t('account.deleteSuccessTip', { ns: 'common' }))
}
catch (error) { console.error(error) }
}, [router, t])

View File

@ -16,8 +16,8 @@ import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import ContentDialog from '@/app/components/base/content-dialog'
import { toast } from '@/app/components/base/ui/toast'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'

View File

@ -27,10 +27,6 @@ vi.mock('@/next/navigation', () => ({
useRouter: () => ({ replace: mockReplace }),
}))
vi.mock('use-context-selector', () => ({
useContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({ onPlanInfoChanged: mockOnPlanInfoChanged }),
}))
@ -42,8 +38,16 @@ vi.mock('@/app/components/app/store', () => ({
}),
}))
vi.mock('@/app/components/base/toast/context', () => ({
ToastContext: {},
vi.mock('@/app/components/base/ui/toast', () => ({
toast: Object.assign(mockNotify, {
success: vi.fn((message, options) => mockNotify({ type: 'success', message, ...options })),
error: vi.fn((message, options) => mockNotify({ type: 'error', message, ...options })),
warning: vi.fn((message, options) => mockNotify({ type: 'warning', message, ...options })),
info: vi.fn((message, options) => mockNotify({ type: 'info', message, ...options })),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
}),
}))
vi.mock('@/service/use-apps', () => ({

View File

@ -3,9 +3,8 @@ import type { CreateAppModalProps } from '@/app/components/explore/create-app-mo
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useProviderContext } from '@/context/provider-context'
import { useRouter } from '@/next/navigation'
@ -24,7 +23,6 @@ type UseAppInfoActionsParams = {
export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const { replace } = useRouter()
const { onPlanInfoChanged } = useProviderContext()
const appDetail = useAppStore(state => state.appDetail)
@ -72,13 +70,13 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) {
max_active_requests,
})
closeModal()
notify({ type: 'success', message: t('editDone', { ns: 'app' }) })
toast(t('editDone', { ns: 'app' }), { type: 'success' })
setAppDetail(app)
}
catch {
notify({ type: 'error', message: t('editFailed', { ns: 'app' }) })
toast(t('editFailed', { ns: 'app' }), { type: 'error' })
}
}, [appDetail, closeModal, notify, setAppDetail, t])
}, [appDetail, closeModal, setAppDetail, t])
const onCopy: DuplicateAppModalProps['onConfirm'] = useCallback(async ({
name,
@ -98,15 +96,15 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) {
mode: appDetail.mode,
})
closeModal()
notify({ type: 'success', message: t('newApp.appCreated', { ns: 'app' }) })
toast(t('newApp.appCreated', { ns: 'app' }), { type: 'success' })
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
onPlanInfoChanged()
getRedirection(true, newApp, replace)
}
catch {
notify({ type: 'error', message: t('newApp.appCreateFailed', { ns: 'app' }) })
toast(t('newApp.appCreateFailed', { ns: 'app' }), { type: 'error' })
}
}, [appDetail, closeModal, notify, onPlanInfoChanged, replace, t])
}, [appDetail, closeModal, onPlanInfoChanged, replace, t])
const onExport = useCallback(async (include = false) => {
if (!appDetail)
@ -117,9 +115,9 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) {
downloadBlob({ data: file, fileName: `${appDetail.name}.yml` })
}
catch {
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
toast(t('exportFailed', { ns: 'app' }), { type: 'error' })
}
}, [appDetail, notify, t])
}, [appDetail, t])
const exportCheck = useCallback(async () => {
if (!appDetail)
@ -145,29 +143,26 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) {
setSecretEnvList(list)
}
catch {
notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
toast(t('exportFailed', { ns: 'app' }), { type: 'error' })
}
}, [appDetail, closeModal, notify, onExport, t])
}, [appDetail, closeModal, onExport, t])
const onConfirmDelete = useCallback(async () => {
if (!appDetail)
return
try {
await deleteApp(appDetail.id)
notify({ type: 'success', message: t('appDeleted', { ns: 'app' }) })
toast(t('appDeleted', { ns: 'app' }), { type: 'success' })
invalidateAppList()
onPlanInfoChanged()
setAppDetail()
replace('/apps')
}
catch (e: unknown) {
notify({
type: 'error',
message: `${t('appDeleteFailed', { ns: 'app' })}${e instanceof Error && e.message ? `: ${e.message}` : ''}`,
})
toast(`${t('appDeleteFailed', { ns: 'app' })}${e instanceof Error && e.message ? `: ${e.message}` : ''}`, { type: 'error' })
}
closeModal()
}, [appDetail, closeModal, invalidateAppList, notify, onPlanInfoChanged, replace, setAppDetail, t])
}, [appDetail, closeModal, invalidateAppList, onPlanInfoChanged, replace, setAppDetail, t])
return {
appDetail,

View File

@ -3,6 +3,7 @@ import { RiMoreFill } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/components/base/ui/toast'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useRouter } from '@/next/navigation'
@ -15,7 +16,6 @@ import { downloadBlob } from '@/utils/download'
import ActionButton from '../../base/action-button'
import Confirm from '../../base/confirm'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
import Toast from '../../base/toast'
import RenameDatasetModal from '../../datasets/rename-modal'
import Menu from './menu'
@ -69,7 +69,7 @@ const DropDown = ({
downloadBlob({ data: file, fileName: `${name}.pipeline` })
}
catch {
Toast.notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
toast(t('exportFailed', { ns: 'app' }), { type: 'error' })
}
}, [dataset, exportPipelineConfig, handleTrigger, t])
@ -81,7 +81,7 @@ const DropDown = ({
}
catch (e: any) {
const res = await e.json()
Toast.notify({ type: 'error', message: res?.message || 'Unknown error' })
toast(res?.message || 'Unknown error', { type: 'error' })
}
finally {
handleTrigger()
@ -91,7 +91,7 @@ const DropDown = ({
const onConfirmDelete = useCallback(async () => {
try {
await deleteDataset(dataset.id)
Toast.notify({ type: 'success', message: t('datasetDeleted', { ns: 'dataset' }) })
toast(t('datasetDeleted', { ns: 'dataset' }), { type: 'success' })
invalidDatasetList()
replace('/datasets')
}

View File

@ -9,7 +9,7 @@ vi.mock('@/context/provider-context', () => ({
}))
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
vi.mock('@/app/components/base/ui/toast', () => ({
default: {
notify: vi.fn(args => mockToastNotify(args)),
},

View File

@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Checkbox from '@/app/components/base/checkbox'
import Drawer from '@/app/components/base/drawer-plus'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import AnnotationFull from '@/app/components/billing/annotation-full'
import { useProviderContext } from '@/context/provider-context'
import EditItem, { EditItemType } from './edit-item'
@ -47,10 +47,7 @@ const AddAnnotationModal: FC<Props> = ({
answer,
}
if (isValid(payload) !== true) {
Toast.notify({
type: 'error',
message: isValid(payload) as string,
})
toast.error(isValid(payload) as string)
return
}

View File

@ -1,11 +1,23 @@
import type { Props } from './csv-uploader'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { ToastContext } from '@/app/components/base/toast/context'
import CSVUploader from './csv-uploader'
describe('CSVUploader', () => {
const notify = vi.fn()
const mockToast = {
success: (message: string, options?: Record<string, unknown>) => notify({ type: 'success', message, ...options }),
error: (message: string, options?: Record<string, unknown>) => notify({ type: 'error', message, ...options }),
warning: (message: string, options?: Record<string, unknown>) => notify({ type: 'warning', message, ...options }),
info: (message: string, options?: Record<string, unknown>) => notify({ type: 'info', message, ...options }),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
}
vi.mock('@/app/components/base/ui/toast', () => ({
toast: mockToast,
}))
const updateFile = vi.fn()
const getDropElements = () => {
@ -24,9 +36,8 @@ describe('CSVUploader', () => {
...props,
}
return render(
<ToastContext.Provider value={{ notify, close: vi.fn() }}>
<CSVUploader {...mergedProps} />
</ToastContext.Provider>,
<CSVUploader {...mergedProps} />,
)
}

View File

@ -4,10 +4,9 @@ import { RiDeleteBinLine } from '@remixicon/react'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import { Csv as CSVIcon } from '@/app/components/base/icons/src/public/files'
import { ToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import { cn } from '@/utils/classnames'
export type Props = {
@ -20,7 +19,6 @@ const CSVUploader: FC<Props> = ({
updateFile,
}) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [dragging, setDragging] = useState(false)
const dropRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<HTMLDivElement>(null)
@ -50,7 +48,7 @@ const CSVUploader: FC<Props> = ({
return
const files = Array.from(e.dataTransfer.files)
if (files.length > 1) {
notify({ type: 'error', message: t('stepOne.uploader.validation.count', { ns: 'datasetCreation' }) })
toast.error(t('stepOne.uploader.validation.count', { ns: 'datasetCreation' }))
return
}
updateFile(files[0])

View File

@ -2,17 +2,10 @@ import type { Mock } from 'vitest'
import type { IBatchModalProps } from './index'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import Toast from '@/app/components/base/toast'
import { useProviderContext } from '@/context/provider-context'
import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation'
import BatchModal, { ProcessStatus } from './index'
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
vi.mock('@/service/annotation', () => ({
annotationBatchImport: vi.fn(),
checkAnnotationBatchImportProgress: vi.fn(),
@ -49,7 +42,7 @@ vi.mock('@/app/components/billing/annotation-full', () => ({
default: () => <div data-testid="annotation-full" />,
}))
const mockNotify = Toast.notify as Mock
const mockNotify = vi.fn()
const useProviderContextMock = useProviderContext as Mock
const annotationBatchImportMock = annotationBatchImport as Mock
const checkAnnotationBatchImportProgressMock = checkAnnotationBatchImportProgress as Mock

View File

@ -7,7 +7,7 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import AnnotationFull from '@/app/components/billing/annotation-full'
import { useProviderContext } from '@/context/provider-context'
import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation'
@ -46,7 +46,6 @@ const BatchModal: FC<IBatchModalProps> = ({
}, [isShow])
const [importStatus, setImportStatus] = useState<ProcessStatus | string>()
const notify = Toast.notify
const checkProcess = async (jobID: string) => {
try {
const res = await checkAnnotationBatchImportProgress({ jobID, appId })
@ -54,15 +53,15 @@ const BatchModal: FC<IBatchModalProps> = ({
if (res.job_status === ProcessStatus.WAITING || res.job_status === ProcessStatus.PROCESSING)
setTimeout(() => checkProcess(res.job_id), 2500)
if (res.job_status === ProcessStatus.ERROR)
notify({ type: 'error', message: `${t('batchModal.runError', { ns: 'appAnnotation' })}` })
toast.error(`${t('batchModal.runError', { ns: 'appAnnotation' })}`)
if (res.job_status === ProcessStatus.COMPLETED) {
notify({ type: 'success', message: `${t('batchModal.completed', { ns: 'appAnnotation' })}` })
toast.success(`${t('batchModal.completed', { ns: 'appAnnotation' })}`)
onAdded()
onCancel()
}
}
catch (e: any) {
notify({ type: 'error', message: `${t('batchModal.runError', { ns: 'appAnnotation' })}${'message' in e ? `: ${e.message}` : ''}` })
toast.error(`${t('batchModal.runError', { ns: 'appAnnotation' })}${'message' in e ? `: ${e.message}` : ''}`)
}
}
@ -78,7 +77,7 @@ const BatchModal: FC<IBatchModalProps> = ({
checkProcess(res.job_id)
}
catch (e: any) {
notify({ type: 'error', message: `${t('batchModal.runError', { ns: 'appAnnotation' })}${'message' in e ? `: ${e.message}` : ''}` })
toast.error(`${t('batchModal.runError', { ns: 'appAnnotation' })}${'message' in e ? `: ${e.message}` : ''}`)
}
}

View File

@ -1,7 +1,6 @@
import type { IToastProps, ToastHandle } from '@/app/components/base/toast'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import EditAnnotationModal from './index'
const { mockAddAnnotation, mockEditAnnotation } = vi.hoisted(() => ({
@ -37,10 +36,8 @@ vi.mock('@/app/components/billing/annotation-full', () => ({
default: () => <div data-testid="annotation-full" />,
}))
type ToastNotifyProps = Pick<IToastProps, 'type' | 'size' | 'message' | 'duration' | 'className' | 'customComponent' | 'onClose'>
type ToastWithNotify = typeof Toast & { notify: (props: ToastNotifyProps) => ToastHandle }
const toastWithNotify = Toast as unknown as ToastWithNotify
const toastNotifySpy = vi.spyOn(toastWithNotify, 'notify').mockReturnValue({ clear: vi.fn() })
const toastSuccessSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success')
const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
describe('EditAnnotationModal', () => {
const defaultProps = {
@ -55,7 +52,8 @@ describe('EditAnnotationModal', () => {
}
afterAll(() => {
toastNotifySpy.mockRestore()
toastSuccessSpy.mockRestore()
toastErrorSpy.mockRestore()
})
beforeEach(() => {
@ -437,10 +435,7 @@ describe('EditAnnotationModal', () => {
// Assert
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
message: 'API Error',
type: 'error',
})
expect(toastErrorSpy).toHaveBeenCalledWith('API Error')
})
expect(mockOnAdded).not.toHaveBeenCalled()
@ -475,10 +470,7 @@ describe('EditAnnotationModal', () => {
// Assert
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
message: 'common.api.actionFailed',
type: 'error',
})
expect(toastErrorSpy).toHaveBeenCalledWith('common.api.actionFailed')
})
expect(mockOnAdded).not.toHaveBeenCalled()
@ -517,10 +509,7 @@ describe('EditAnnotationModal', () => {
// Assert
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
message: 'API Error',
type: 'error',
})
expect(toastErrorSpy).toHaveBeenCalledWith('API Error')
})
expect(mockOnEdited).not.toHaveBeenCalled()
@ -557,10 +546,7 @@ describe('EditAnnotationModal', () => {
// Assert
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
message: 'common.api.actionFailed',
type: 'error',
})
expect(toastErrorSpy).toHaveBeenCalledWith('common.api.actionFailed')
})
expect(mockOnEdited).not.toHaveBeenCalled()
@ -641,10 +627,7 @@ describe('EditAnnotationModal', () => {
// Assert
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
message: 'common.api.actionSuccess',
type: 'success',
})
expect(toastSuccessSpy).toHaveBeenCalledWith('common.api.actionSuccess')
})
})
})

View File

@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
import Drawer from '@/app/components/base/drawer-plus'
import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import AnnotationFull from '@/app/components/billing/annotation-full'
import { useProviderContext } from '@/context/provider-context'
import useTimestamp from '@/hooks/use-timestamp'
@ -72,18 +72,12 @@ const EditAnnotationModal: FC<Props> = ({
onAdded(res.id, res.account?.name ?? '', postQuery, postAnswer)
}
Toast.notify({
message: t('api.actionSuccess', { ns: 'common' }) as string,
type: 'success',
})
toast.success(t('api.actionSuccess', { ns: 'common' }) as string)
}
catch (error) {
const fallbackMessage = t('api.actionFailed', { ns: 'common' }) as string
const message = error instanceof Error && error.message ? error.message : fallbackMessage
Toast.notify({
message,
type: 'error',
})
toast.error(message)
// Re-throw to preserve edit mode behavior for UI components
throw error
}

View File

@ -3,7 +3,7 @@ import type { AnnotationItem } from './type'
import type { App } from '@/types/app'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { useProviderContext } from '@/context/provider-context'
import {
addAnnotation,
@ -17,10 +17,6 @@ import { AppModeEnum } from '@/types/app'
import Annotation from './index'
import { JobStatus } from './type'
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
vi.mock('ahooks', () => ({
useDebounce: (value: any) => value,
}))
@ -95,7 +91,23 @@ vi.mock('./view-annotation-modal', () => ({
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', () => ({ default: (props: any) => props.isShow ? <div data-testid="config-modal" /> : null }))
vi.mock('@/app/components/billing/annotation-full/modal', () => ({ default: (props: any) => props.show ? <div data-testid="annotation-full-modal" /> : null }))
const mockNotify = Toast.notify as Mock
const mockNotify = vi.fn()
vi.spyOn(toast, 'success').mockImplementation((message, options) => {
mockNotify({ type: 'success', message, ...options })
return 'toast-success-id'
})
vi.spyOn(toast, 'error').mockImplementation((message, options) => {
mockNotify({ type: 'error', message, ...options })
return 'toast-error-id'
})
vi.spyOn(toast, 'warning').mockImplementation((message, options) => {
mockNotify({ type: 'warning', message, ...options })
return 'toast-warning-id'
})
vi.spyOn(toast, 'info').mockImplementation((message, options) => {
mockNotify({ type: 'info', message, ...options })
return 'toast-info-id'
})
const addAnnotationMock = addAnnotation as Mock
const delAnnotationMock = delAnnotation as Mock
const delAnnotationsMock = delAnnotations as Mock

View File

@ -15,6 +15,7 @@ import { MessageFast } from '@/app/components/base/icons/src/vender/solid/commun
import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination'
import Switch from '@/app/components/base/switch'
import { toast } from '@/app/components/base/ui/toast'
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
import { APP_PAGE_LIMIT } from '@/config'
import { useProviderContext } from '@/context/provider-context'
@ -22,7 +23,6 @@ import { addAnnotation, delAnnotation, delAnnotations, fetchAnnotationConfig as
import { AppModeEnum } from '@/types/app'
import { sleep } from '@/utils'
import { cn } from '@/utils/classnames'
import Toast from '../../base/toast'
import EmptyElement from './empty-element'
import Filter from './filter'
import HeaderOpts from './header-opts'
@ -98,14 +98,14 @@ const Annotation: FC<Props> = (props) => {
const handleAdd = async (payload: AnnotationItemBasic) => {
await addAnnotation(appDetail.id, payload)
Toast.notify({ message: t('api.actionSuccess', { ns: 'common' }), type: 'success' })
toast.success(t('api.actionSuccess', { ns: 'common' }))
fetchList()
setControlUpdateList(Date.now())
}
const handleRemove = async (id: string) => {
await delAnnotation(appDetail.id, id)
Toast.notify({ message: t('api.actionSuccess', { ns: 'common' }), type: 'success' })
toast.success(t('api.actionSuccess', { ns: 'common' }))
fetchList()
setControlUpdateList(Date.now())
}
@ -113,13 +113,13 @@ const Annotation: FC<Props> = (props) => {
const handleBatchDelete = async () => {
try {
await delAnnotations(appDetail.id, selectedIds)
Toast.notify({ message: t('api.actionSuccess', { ns: 'common' }), type: 'success' })
toast.success(t('api.actionSuccess', { ns: 'common' }))
fetchList()
setControlUpdateList(Date.now())
setSelectedIds([])
}
catch (e: any) {
Toast.notify({ type: 'error', message: e.message || t('api.actionFailed', { ns: 'common' }) })
toast.error(e.message || t('api.actionFailed', { ns: 'common' }))
}
}
@ -132,7 +132,7 @@ const Annotation: FC<Props> = (props) => {
if (!currItem)
return
await editAnnotation(appDetail.id, currItem.id, { question, answer })
Toast.notify({ message: t('api.actionSuccess', { ns: 'common' }), type: 'success' })
toast.success(t('api.actionSuccess', { ns: 'common' }))
fetchList()
setControlUpdateList(Date.now())
}
@ -170,10 +170,7 @@ const Annotation: FC<Props> = (props) => {
const { job_id: jobId }: any = await updateAnnotationStatus(appDetail.id, AnnotationEnableStatus.disable, annotationConfig?.embedding_model, annotationConfig?.score_threshold)
await ensureJobCompleted(jobId, AnnotationEnableStatus.disable)
await fetchAnnotationConfig()
Toast.notify({
message: t('api.actionSuccess', { ns: 'common' }),
type: 'success',
})
toast.success(t('api.actionSuccess', { ns: 'common' }))
}
}}
>
@ -263,10 +260,7 @@ const Annotation: FC<Props> = (props) => {
await updateAnnotationScore(appDetail.id, annotationId, score)
await fetchAnnotationConfig()
Toast.notify({
message: t('api.actionSuccess', { ns: 'common' }),
type: 'success',
})
toast.success(t('api.actionSuccess', { ns: 'common' }))
setIsShowEdit(false)
}}
annotationConfig={annotationConfig!}

View File

@ -2,9 +2,9 @@ import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models
import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { toast } from '@/app/components/base/ui/toast'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode, SubjectType } from '@/models/access-control'
import Toast from '../../base/toast'
import AccessControlDialog from './access-control-dialog'
import AccessControlItem from './access-control-item'
import AddMemberOrGroupDialog from './add-member-or-group-pop'
@ -303,7 +303,7 @@ describe('AccessControl', () => {
it('should initialize menu from app and call update on confirm', async () => {
const onClose = vi.fn()
const onConfirm = vi.fn()
const toastSpy = vi.spyOn(Toast, 'notify').mockReturnValue({})
const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success')
useAccessControlStore.setState({
specificGroups: [baseGroup],
specificMembers: [baseMember],
@ -336,7 +336,7 @@ describe('AccessControl', () => {
{ subjectId: baseMember.id, subjectType: SubjectType.ACCOUNT },
],
})
expect(toastSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess')
expect(onConfirm).toHaveBeenCalled()
})
})

View File

@ -5,12 +5,12 @@ import { Description as DialogDescription, DialogTitle } from '@headlessui/react
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/components/base/ui/toast'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { AccessMode, SubjectType } from '@/models/access-control'
import { useUpdateAccessMode } from '@/service/access-control'
import useAccessControlStore from '../../../../context/access-control-store'
import Button from '../../base/button'
import Toast from '../../base/toast'
import AccessControlDialog from './access-control-dialog'
import AccessControlItem from './access-control-item'
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
@ -61,7 +61,7 @@ export default function AccessControl(props: AccessControlProps) {
submitData.subjects = subjects
}
await updateAccessMode(submitData)
Toast.notify({ type: 'success', message: t('accessControlDialog.updateSuccess', { ns: 'app' }) })
toast.success(t('accessControlDialog.updateSuccess', { ns: 'app' }))
onConfirm?.()
}, [updateAccessMode, app, specificGroups, specificMembers, t, onConfirm, currentMenu])
return (

View File

@ -28,6 +28,7 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { toast } from '@/app/components/base/ui/toast'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
@ -47,7 +48,6 @@ import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import Divider from '../../base/divider'
import Loading from '../../base/loading'
import Toast from '../../base/toast'
import Tooltip from '../../base/tooltip'
import ShortcutsName from '../../workflow/shortcuts-name'
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
@ -264,7 +264,7 @@ const AppPublisher = ({
throw new Error('No app found in Explore')
}, {
onError: (err) => {
Toast.notify({ type: 'error', message: `${err.message || err}` })
toast.error(`${err.message || err}`)
},
})
}, [appDetail?.id, openAsyncWindow])
@ -290,7 +290,7 @@ const AppPublisher = ({
window.open(result.redirect_url, '_blank')
}
catch (error: any) {
Toast.notify({ type: 'error', message: error.message || t('common.publishToMarketplaceFailed', { ns: 'workflow' }) })
toast.error(error.message || t('common.publishToMarketplaceFailed', { ns: 'workflow' }))
}
finally {
setPublishingToMarketplace(false)

View File

@ -5,7 +5,7 @@ import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import Button from '../../base/button'
import Input from '../../base/input'
import Textarea from '../../base/textarea'
@ -35,10 +35,7 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
const handlePublish = () => {
if (title.length > TITLE_MAX_LENGTH) {
setTitleError(true)
Toast.notify({
type: 'error',
message: t('versionHistory.editField.titleLengthLimit', { ns: 'workflow', limit: TITLE_MAX_LENGTH }),
})
toast.error(t('versionHistory.editField.titleLengthLimit', { ns: 'workflow', limit: TITLE_MAX_LENGTH }))
return
}
else {
@ -48,10 +45,7 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
if (releaseNotes.length > RELEASE_NOTES_MAX_LENGTH) {
setReleaseNotesError(true)
Toast.notify({
type: 'error',
message: t('versionHistory.editField.releaseNotesLengthLimit', { ns: 'workflow', limit: RELEASE_NOTES_MAX_LENGTH }),
})
toast.error(t('versionHistory.editField.releaseNotesLengthLimit', { ns: 'workflow', limit: RELEASE_NOTES_MAX_LENGTH }))
return
}
else {

View File

@ -20,8 +20,12 @@ import {
} from '@/app/components/base/icons/src/vender/line/files'
import PromptEditor from '@/app/components/base/prompt-editor'
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block'
import { useToastContext } from '@/app/components/base/toast/context'
import Tooltip from '@/app/components/base/tooltip'
import { toast } from '@/app/components/base/ui/toast'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/app/components/base/ui/tooltip'
import ConfigContext from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useModalContext } from '@/context/modal-context'
@ -74,7 +78,6 @@ const AdvancedPromptInput: FC<Props> = ({
showSelectDataSet,
externalDataToolsConfig,
} = useContext(ConfigContext)
const { notify } = useToastContext()
const { setShowExternalDataToolModal } = useModalContext()
const handleOpenExternalDataToolModal = () => {
setShowExternalDataToolModal({
@ -94,7 +97,7 @@ const AdvancedPromptInput: FC<Props> = ({
onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => {
for (let i = 0; i < promptVariables.length; i++) {
if (promptVariables[i].key === newExternalDataTool.variable) {
notify({ type: 'error', message: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: promptVariables[i].key }) })
toast.error(t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: promptVariables[i].key }))
return false
}
}
@ -180,13 +183,18 @@ const AdvancedPromptInput: FC<Props> = ({
<div className="text-sm font-semibold uppercase text-indigo-800">
{t('pageTitle.line1', { ns: 'appDebug' })}
</div>
<Tooltip
popupContent={(
<Tooltip>
<TooltipTrigger
render={(
<span className="i-ri-question-line ml-1 h-4 w-4 shrink-0 text-text-quaternary" />
)}
/>
<TooltipContent>
<div className="w-[180px]">
{t('promptTip', { ns: 'appDebug' })}
</div>
)}
/>
</TooltipContent>
</Tooltip>
</div>
)}
<div className={cn(s.optionWrap, 'items-center space-x-1')}>

View File

@ -17,8 +17,12 @@ import { useFeaturesStore } from '@/app/components/base/features/hooks'
import PromptEditor from '@/app/components/base/prompt-editor'
import { PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER } from '@/app/components/base/prompt-editor/plugins/update-block'
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block'
import { useToastContext } from '@/app/components/base/toast/context'
import Tooltip from '@/app/components/base/tooltip'
import { toast } from '@/app/components/base/ui/toast'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/app/components/base/ui/tooltip'
import ConfigContext from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useModalContext } from '@/context/modal-context'
@ -72,7 +76,6 @@ const Prompt: FC<ISimplePromptInput> = ({
showSelectDataSet,
externalDataToolsConfig,
} = useContext(ConfigContext)
const { notify } = useToastContext()
const { setShowExternalDataToolModal } = useModalContext()
const handleOpenExternalDataToolModal = () => {
setShowExternalDataToolModal({
@ -92,7 +95,7 @@ const Prompt: FC<ISimplePromptInput> = ({
onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => {
for (let i = 0; i < promptVariables.length; i++) {
if (promptVariables[i].key === newExternalDataTool.variable) {
notify({ type: 'error', message: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: promptVariables[i].key }) })
toast.error(t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: promptVariables[i].key }))
return false
}
}
@ -180,13 +183,18 @@ const Prompt: FC<ISimplePromptInput> = ({
<div className="flex items-center space-x-1">
<div className="h2 text-text-secondary system-sm-semibold-uppercase">{mode !== AppModeEnum.COMPLETION ? t('chatSubTitle', { ns: 'appDebug' }) : t('completionSubTitle', { ns: 'appDebug' })}</div>
{!readonly && (
<Tooltip
popupContent={(
<Tooltip>
<TooltipTrigger
render={(
<span className="i-ri-question-line ml-1 h-4 w-4 shrink-0 text-text-quaternary" />
)}
/>
<TooltipContent>
<div className="w-[180px]">
{t('promptTip', { ns: 'appDebug' })}
</div>
)}
/>
</TooltipContent>
</Tooltip>
)}
</div>
<div className="flex items-center">

View File

@ -15,7 +15,7 @@ import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { SimpleSelect } from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
@ -98,10 +98,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
const checkVariableName = useCallback((value: string, canBeEmpty?: boolean) => {
const { isValid, errorMessageKey } = checkKeys([value], canBeEmpty)
if (!isValid) {
Toast.notify({
type: 'error',
message: t(`varKeyError.${errorMessageKey}`, { ns: 'appDebug', key: t('variableConfig.varName', { ns: 'appDebug' }) }),
})
toast.error(t(`varKeyError.${errorMessageKey}`, { ns: 'appDebug', key: t('variableConfig.varName', { ns: 'appDebug' }) }))
return false
}
return true
@ -221,10 +218,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
const value = e.target.value
const { isValid, errorKey, errorMessageKey } = checkKeys([value], true)
if (!isValid) {
Toast.notify({
type: 'error',
message: t(`varKeyError.${errorMessageKey}`, { ns: 'appDebug', key: errorKey }),
})
toast.error(t(`varKeyError.${errorMessageKey}`, { ns: 'appDebug', key: errorKey }))
return
}
handlePayloadChange('variable')(e.target.value)
@ -266,7 +260,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
return
if (!tempPayload.label) {
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }) })
toast.error(t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }))
return
}
if (isStringInput || type === InputVarType.number) {
@ -274,7 +268,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
}
else if (type === InputVarType.select) {
if (options?.length === 0) {
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }) })
toast.error(t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }))
return
}
const obj: Record<string, boolean> = {}
@ -287,7 +281,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
obj[o] = true
})
if (hasRepeatedItem) {
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }) })
toast.error(t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }))
return
}
onConfirm(payloadToSave, moreInfo)
@ -297,12 +291,12 @@ const ConfigModal: FC<IConfigModalProps> = ({
)) {
if (tempPayload.allowed_file_types?.length === 0) {
const errorMessages = t('errorMsg.fieldRequired', { ns: 'workflow', field: t('variableConfig.file.supportFileTypes', { ns: 'appDebug' }) })
Toast.notify({ type: 'error', message: errorMessages })
toast.error(errorMessages)
return
}
if (tempPayload.allowed_file_types?.includes(SupportUploadFileTypes.custom) && !tempPayload.allowed_file_extensions?.length) {
const errorMessages = t('errorMsg.fieldRequired', { ns: 'workflow', field: t('variableConfig.file.custom.name', { ns: 'appDebug' }) })
Toast.notify({ type: 'error', message: errorMessages })
toast.error(errorMessages)
return
}
onConfirm(payloadToSave, moreInfo)
@ -312,12 +306,12 @@ const ConfigModal: FC<IConfigModalProps> = ({
try {
const schema = JSON.parse(normalizedJsonSchema)
if (schema?.type !== 'object') {
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.jsonSchemaMustBeObject', { ns: 'appDebug' }) })
toast.error(t('variableConfig.errorMsg.jsonSchemaMustBeObject', { ns: 'appDebug' }))
return
}
}
catch {
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.jsonSchemaInvalid', { ns: 'appDebug' }) })
toast.error(t('variableConfig.errorMsg.jsonSchemaInvalid', { ns: 'appDebug' }))
return
}
}

View File

@ -5,13 +5,13 @@ import type { PromptVariable } from '@/models/debug'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import * as React from 'react'
import { vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import DebugConfigurationContext from '@/context/debug-configuration'
import { AppModeEnum } from '@/types/app'
import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from './index'
const notifySpy = vi.spyOn(Toast, 'notify').mockImplementation(vi.fn())
const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
const setShowExternalDataToolModal = vi.fn()
@ -112,7 +112,7 @@ describe('ConfigVar', () => {
latestSortableProps = null
subscriptionCallback = null
variableIndex = 0
notifySpy.mockClear()
toastErrorSpy.mockClear()
})
it('should show empty state when no variables exist', () => {
@ -152,7 +152,7 @@ describe('ConfigVar', () => {
latestSortableProps = null
subscriptionCallback = null
variableIndex = 0
notifySpy.mockClear()
toastErrorSpy.mockClear()
})
it('should add a text variable when selecting the string option', async () => {
@ -218,7 +218,7 @@ describe('ConfigVar', () => {
latestSortableProps = null
subscriptionCallback = null
variableIndex = 0
notifySpy.mockClear()
toastErrorSpy.mockClear()
})
it('should save updates when editing a basic variable', async () => {
@ -268,7 +268,7 @@ describe('ConfigVar', () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(Toast.notify).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
expect(onPromptVariablesChange).not.toHaveBeenCalled()
})
@ -294,7 +294,7 @@ describe('ConfigVar', () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(Toast.notify).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
expect(onPromptVariablesChange).not.toHaveBeenCalled()
})
})
@ -306,7 +306,7 @@ describe('ConfigVar', () => {
latestSortableProps = null
subscriptionCallback = null
variableIndex = 0
notifySpy.mockClear()
toastErrorSpy.mockClear()
})
it('should remove variable directly when context confirmation is not required', () => {
@ -359,7 +359,7 @@ describe('ConfigVar', () => {
latestSortableProps = null
subscriptionCallback = null
variableIndex = 0
notifySpy.mockClear()
toastErrorSpy.mockClear()
})
it('should append external data tool variables from event emitter', () => {

View File

@ -12,8 +12,8 @@ import { useTranslation } from 'react-i18next'
import { ReactSortable } from 'react-sortablejs'
import { useContext } from 'use-context-selector'
import Confirm from '@/app/components/base/confirm'
import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import { toast } from '@/app/components/base/ui/toast'
import { InputVarType } from '@/app/components/workflow/types'
import ConfigContext from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
@ -108,10 +108,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
})
const duplicateError = getDuplicateError(newPromptVariables)
if (duplicateError) {
Toast.notify({
type: 'error',
message: t(duplicateError.errorMsgKey as I18nKeysByPrefix<'appDebug', 'duplicateError.'>, { ns: 'appDebug', key: t(duplicateError.typeName as I18nKeysByPrefix<'appDebug', 'duplicateError.'>, { ns: 'appDebug' }) }) as string,
})
toast.error(t(duplicateError.errorMsgKey as I18nKeysByPrefix<'appDebug', 'duplicateError.'>, { ns: 'appDebug', key: t(duplicateError.typeName as I18nKeysByPrefix<'appDebug', 'duplicateError.'>, { ns: 'appDebug' }) }) as string)
return false
}
@ -161,7 +158,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => {
for (let i = 0; i < promptVariables.length; i++) {
if (promptVariables[i].key === newExternalDataTool.variable && i !== index) {
Toast.notify({ type: 'error', message: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: promptVariables[i].key }) })
toast.error(t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: promptVariables[i].key }))
return false
}
}

View File

@ -12,7 +12,7 @@ import {
CopyCheck,
} from '@/app/components/base/icons/src/vender/line/files'
import PromptEditor from '@/app/components/base/prompt-editor'
import { useToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import ConfigContext from '@/context/debug-configuration'
import { useModalContext } from '@/context/modal-context'
import { cn } from '@/utils/classnames'
@ -32,8 +32,6 @@ const Editor: FC<Props> = ({
}) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const [isCopied, setIsCopied] = React.useState(false)
const {
modelConfig,
@ -59,14 +57,14 @@ const Editor: FC<Props> = ({
onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => {
for (let i = 0; i < promptVariables.length; i++) {
if (promptVariables[i].key === newExternalDataTool.variable) {
notify({ type: 'error', message: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: promptVariables[i].key }) })
toast.error(t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: promptVariables[i].key }))
return false
}
}
for (let i = 0; i < externalDataToolsConfig.length; i++) {
if (externalDataToolsConfig[i].variable === newExternalDataTool.variable) {
notify({ type: 'error', message: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: externalDataToolsConfig[i].variable }) })
toast.error(t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: externalDataToolsConfig[i].variable }))
return false
}
}

View File

@ -23,9 +23,9 @@ import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm'
import { Generator } from '@/app/components/base/icons/src/vender/other'
import Loading from '@/app/components/base/loading'
import Modal from '@/app/components/base/modal'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
@ -161,13 +161,10 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
const isValid = () => {
if (instruction.trim() === '') {
Toast.notify({
type: 'error',
message: t('errorMsg.fieldRequired', {
ns: 'common',
field: t('generate.instruction', { ns: 'appDebug' }),
}),
})
toast.error(t('errorMsg.fieldRequired', {
ns: 'common',
field: t('generate.instruction', { ns: 'appDebug' }),
}))
return false
}
return true
@ -242,10 +239,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
} as GenRes
if (error) {
hasError = true
Toast.notify({
type: 'error',
message: error,
})
toast.error(error)
}
}
else {
@ -260,10 +254,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
apiRes = res
if (error) {
hasError = true
Toast.notify({
type: 'error',
message: error,
})
toast.error(error)
}
}
if (!hasError)

View File

@ -6,7 +6,7 @@ import copy from 'copy-to-clipboard'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import CodeEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor'
import PromptRes from './prompt-res'
import PromptResInWorkflow from './prompt-res-in-workflow'
@ -54,7 +54,7 @@ const Result: FC<Props> = ({
className="px-2"
onClick={() => {
copy(current.modified)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
}}
>
<RiClipboardLine className="h-4 w-4 text-text-secondary" />

View File

@ -15,7 +15,7 @@ import Confirm from '@/app/components/base/confirm'
import { Generator } from '@/app/components/base/icons/src/vender/other'
import Loading from '@/app/components/base/loading'
import Modal from '@/app/components/base/modal'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
@ -90,13 +90,10 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
const isValid = () => {
if (instruction.trim() === '') {
Toast.notify({
type: 'error',
message: t('errorMsg.fieldRequired', {
ns: 'common',
field: t('code.instruction', { ns: 'appDebug' }),
}),
})
toast.error(t('errorMsg.fieldRequired', {
ns: 'common',
field: t('code.instruction', { ns: 'appDebug' }),
}))
return false
}
return true
@ -149,10 +146,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
res.modified = (res as any).code
if (error) {
Toast.notify({
type: 'error',
message: error,
})
toast.error(error)
}
else {
addVersion(res)

View File

@ -5,7 +5,7 @@ import type { DatasetConfigs } from '@/models/debug'
import type { RetrievalConfig } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import {
useCurrentProviderAndModel,
useModelListAndDefaultModelAndCurrentProviderAndModel,
@ -46,7 +46,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = useModelListAndDefaultModelAndCurrentProviderAndModel as MockedFunction<typeof useModelListAndDefaultModelAndCurrentProviderAndModel>
const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as MockedFunction<typeof useCurrentProviderAndModel>
let toastNotifySpy: MockInstance
let toastErrorSpy: MockInstance
const baseRetrievalConfig: RetrievalConfig = {
search_method: RETRIEVE_METHOD.semantic,
@ -172,7 +172,7 @@ const createDatasetConfigs = (overrides: Partial<DatasetConfigs> = {}): DatasetC
describe('ConfigContent', () => {
beforeEach(() => {
vi.clearAllMocks()
toastNotifySpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({}))
toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
modelList: [],
defaultModel: undefined,
@ -186,7 +186,7 @@ describe('ConfigContent', () => {
})
afterEach(() => {
toastNotifySpy.mockRestore()
toastErrorSpy.mockRestore()
})
// State management
@ -331,10 +331,7 @@ describe('ConfigContent', () => {
await user.click(screen.getByText('common.modelProvider.rerankModel.key'))
// Assert
expect(toastNotifySpy).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.errorMsg.rerankModelRequired',
})
expect(toastErrorSpy).toHaveBeenCalledWith('workflow.errorMsg.rerankModelRequired')
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
reranking_mode: RerankingModeEnum.RerankingModel,
@ -373,10 +370,7 @@ describe('ConfigContent', () => {
await user.click(screen.getByRole('switch'))
// Assert
expect(toastNotifySpy).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.errorMsg.rerankModelRequired',
})
expect(toastErrorSpy).toHaveBeenCalledWith('workflow.errorMsg.rerankModelRequired')
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
reranking_enable: true,

View File

@ -15,8 +15,8 @@ import Divider from '@/app/components/base/divider'
import ScoreThresholdItem from '@/app/components/base/param-item/score-threshold-item'
import TopKItem from '@/app/components/base/param-item/top-k-item'
import Switch from '@/app/components/base/switch'
import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import { toast } from '@/app/components/base/ui/toast'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
@ -136,7 +136,7 @@ const ConfigContent: FC<Props> = ({
return
if (mode === RerankingModeEnum.RerankingModel && !currentRerankModel)
Toast.notify({ type: 'error', message: t('errorMsg.rerankModelRequired', { ns: 'workflow' }) })
toast.error(t('errorMsg.rerankModelRequired', { ns: 'workflow' }))
onChange({
...datasetConfigs,
@ -179,7 +179,7 @@ const ConfigContent: FC<Props> = ({
const handleManuallyToggleRerank = useCallback((enable: boolean) => {
if (!currentRerankModel && enable)
Toast.notify({ type: 'error', message: t('errorMsg.rerankModelRequired', { ns: 'workflow' }) })
toast.error(t('errorMsg.rerankModelRequired', { ns: 'workflow' }))
onChange({
...datasetConfigs,
reranking_enable: enable,

View File

@ -3,7 +3,6 @@ import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ToastContext } from '@/app/components/base/toast/context'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
@ -14,6 +13,19 @@ import { RETRIEVE_METHOD } from '@/types/app'
import SettingsModal from './index'
const mockNotify = vi.fn()
const mockToast = {
success: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'success', message, ...options }),
error: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'error', message, ...options }),
warning: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'warning', message, ...options }),
info: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'info', message, ...options }),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
}
vi.mock('@/app/components/base/ui/toast', () => ({
toast: mockToast,
}))
const mockOnCancel = vi.fn()
const mockOnSave = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
@ -183,13 +195,12 @@ const createDataset = (overrides: Partial<DataSet> = {}, retrievalOverrides: Par
const renderWithProviders = (dataset: DataSet) => {
return render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<SettingsModal
currentDataset={dataset}
onCancel={mockOnCancel}
onSave={mockOnSave}
/>
</ToastContext.Provider>,
<SettingsModal
currentDataset={dataset}
onCancel={mockOnCancel}
onSave={mockOnSave}
/>,
)
}

View File

@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { useToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import IndexMethod from '@/app/components/datasets/settings/index-method'
@ -51,7 +51,6 @@ const SettingsModal: FC<SettingsModalProps> = ({
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
const { t } = useTranslation()
const docLink = useDocLink()
const { notify } = useToastContext()
const ref = useRef(null)
const isExternal = currentDataset.provider === 'external'
const { setShowAccountSettingModal } = useModalContext()
@ -96,7 +95,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
if (loading)
return
if (!localeCurrentDataset.name?.trim()) {
notify({ type: 'error', message: t('form.nameError', { ns: 'datasetSettings' }) })
toast.error(t('form.nameError', { ns: 'datasetSettings' }))
return
}
if (
@ -106,7 +105,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
indexMethod,
})
) {
notify({ type: 'error', message: t('datasetConfig.rerankModelRequired', { ns: 'appDebug' }) })
toast.error(t('datasetConfig.rerankModelRequired', { ns: 'appDebug' }))
return
}
try {
@ -146,7 +145,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
})
}
await updateDatasetSetting(requestParams)
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
onSave({
...localeCurrentDataset,
indexing_technique: indexMethod,
@ -154,7 +153,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
})
}
catch {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
}
finally {
setLoading(false)

View File

@ -386,13 +386,6 @@ vi.mock('@/context/event-emitter', () => ({
})),
}))
// Mock toast context
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: vi.fn(() => ({
notify: vi.fn(),
})),
}))
// Mock hooks/use-timestamp
vi.mock('@/hooks/use-timestamp', () => ({
default: vi.fn(() => ({

View File

@ -1,7 +1,6 @@
import type { ComponentProps } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { ToastContext } from '@/app/components/base/toast/context'
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import ConfigContext from '@/context/debug-configuration'
import { AppModeEnum, ModelModeType, TransferMethod } from '@/types/app'
@ -377,6 +376,19 @@ const renderDebug = (options: {
} = {}) => {
const onSetting = vi.fn()
const notify = vi.fn()
const mockToast = {
success: (message: string, options?: Record<string, unknown>) => notify({ type: 'success', message, ...options }),
error: (message: string, options?: Record<string, unknown>) => notify({ type: 'error', message, ...options }),
warning: (message: string, options?: Record<string, unknown>) => notify({ type: 'warning', message, ...options }),
info: (message: string, options?: Record<string, unknown>) => notify({ type: 'info', message, ...options }),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
}
vi.mock('@/app/components/base/ui/toast', () => ({
toast: mockToast,
}))
const props: ComponentProps<typeof Debug> = {
isAPIKeySet: true,
onSetting,
@ -392,11 +404,10 @@ const renderDebug = (options: {
}
render(
<ToastContext.Provider value={{ notify, close: vi.fn() }}>
<ConfigContext.Provider value={createContextValue(options.contextValue)}>
<Debug {...props} />
</ConfigContext.Provider>
</ToastContext.Provider>,
<ConfigContext.Provider value={createContextValue(options.contextValue)}>
<Debug {...props} />
</ConfigContext.Provider>,
)
return { onSetting, notify, props }

View File

@ -29,8 +29,12 @@ import Button from '@/app/components/base/button'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
import PromptLogModal from '@/app/components/base/prompt-log-modal'
import { ToastContext } from '@/app/components/base/toast/context'
import TooltipPlus from '@/app/components/base/tooltip'
import { toast } from '@/app/components/base/ui/toast'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/app/components/base/ui/tooltip'
import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
@ -139,22 +143,20 @@ const Debug: FC<IDebug> = ({
setIsShowFormattingChangeConfirm(false)
setFormattingChanged(false)
}
const { notify } = useContext(ToastContext)
const logError = useCallback((message: string) => {
notify({ type: 'error', message })
}, [notify])
toast.error(message)
}, [])
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
const checkCanSend = useCallback(() => {
if (isAdvancedMode && mode !== AppModeEnum.COMPLETION) {
if (modelModeType === ModelModeType.completion) {
if (!hasSetBlockStatus.history) {
notify({ type: 'error', message: t('otherError.historyNoBeEmpty', { ns: 'appDebug' }) })
toast.error(t('otherError.historyNoBeEmpty', { ns: 'appDebug' }))
return false
}
if (!hasSetBlockStatus.query) {
notify({ type: 'error', message: t('otherError.queryNoBeEmpty', { ns: 'appDebug' }) })
toast.error(t('otherError.queryNoBeEmpty', { ns: 'appDebug' }))
return false
}
}
@ -180,7 +182,7 @@ const Debug: FC<IDebug> = ({
}
if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
toast.info(t('errorMessage.waitForFileUpload', { ns: 'appDebug' }))
return false
}
return !hasEmptyInput
@ -194,7 +196,6 @@ const Debug: FC<IDebug> = ({
modelConfig.configs.prompt_variables,
t,
logError,
notify,
modelModeType,
])
@ -205,7 +206,7 @@ const Debug: FC<IDebug> = ({
const sendTextCompletion = async () => {
if (isResponding) {
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
toast.info(t('errorMessage.waitForResponse', { ns: 'appDebug' }))
return false
}
@ -420,27 +421,24 @@ const Debug: FC<IDebug> = ({
<>
{
!readonly && (
<TooltipPlus
popupContent={t('operation.refresh', { ns: 'common' })}
>
<ActionButton onClick={clearConversation}>
<RefreshCcw01 className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
<Tooltip>
<TooltipTrigger render={<ActionButton onClick={clearConversation}><RefreshCcw01 className="h-4 w-4" /></ActionButton>} />
<TooltipContent>
{t('operation.refresh', { ns: 'common' })}
</TooltipContent>
</Tooltip>
)
}
{
varList.length > 0 && (
<div className="relative ml-1 mr-2">
<TooltipPlus
popupContent={t('panel.userInputField', { ns: 'workflow' })}
>
<ActionButton state={expanded ? ActionButtonState.Active : undefined} onClick={() => !readonly && setExpanded(!expanded)}>
<RiEqualizer2Line className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
<Tooltip>
<TooltipTrigger render={<ActionButton state={expanded ? ActionButtonState.Active : undefined} onClick={() => !readonly && setExpanded(!expanded)}><RiEqualizer2Line className="h-4 w-4" /></ActionButton>} />
<TooltipContent>
{t('panel.userInputField', { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
{expanded && <div className="absolute bottom-[-14px] right-[5px] z-10 h-3 w-3 rotate-45 border-l-[0.5px] border-t-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" />}
</div>
)

View File

@ -27,7 +27,6 @@ import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useShallow } from 'zustand/react/shallow'
import AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
import Config from '@/app/components/app/configuration/config'
@ -49,8 +48,7 @@ import { FeaturesProvider } from '@/app/components/base/features'
import NewFeaturePanel from '@/app/components/base/features/new-feature-panel'
import Loading from '@/app/components/base/loading'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import Toast from '@/app/components/base/toast'
import { ToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
@ -94,7 +92,6 @@ type PublishConfig = {
const Configuration: FC = () => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const { isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
const { appDetail, showAppConfigureFeaturesModal, setAppSidebarExpand, setShowAppConfigureFeaturesModal } = useAppStore(useShallow(state => ({
@ -493,11 +490,11 @@ const Configuration: FC = () => {
isAdvancedMode,
)
if (Object.keys(removedDetails).length)
Toast.notify({ type: 'warning', message: `${t('modelProvider.parametersInvalidRemoved', { ns: 'common' })}: ${Object.entries(removedDetails).map(([k, reason]) => `${k} (${reason})`).join(', ')}` })
toast.warning(`${t('modelProvider.parametersInvalidRemoved', { ns: 'common' })}: ${Object.entries(removedDetails).map(([k, reason]) => `${k} (${reason})`).join(', ')}`)
setCompletionParams(filtered)
}
catch {
Toast.notify({ type: 'error', message: t('error', { ns: 'common' }) })
toast.error(t('error', { ns: 'common' }))
setCompletionParams({})
}
}
@ -769,23 +766,23 @@ const Configuration: FC = () => {
const promptVariables = modelConfig.configs.prompt_variables
if (promptEmpty) {
notify({ type: 'error', message: t('otherError.promptNoBeEmpty', { ns: 'appDebug' }) })
toast.error(t('otherError.promptNoBeEmpty', { ns: 'appDebug' }))
return
}
if (isAdvancedMode && mode !== AppModeEnum.COMPLETION) {
if (modelModeType === ModelModeType.completion) {
if (!hasSetBlockStatus.history) {
notify({ type: 'error', message: t('otherError.historyNoBeEmpty', { ns: 'appDebug' }) })
toast.error(t('otherError.historyNoBeEmpty', { ns: 'appDebug' }))
return
}
if (!hasSetBlockStatus.query) {
notify({ type: 'error', message: t('otherError.queryNoBeEmpty', { ns: 'appDebug' }) })
toast.error(t('otherError.queryNoBeEmpty', { ns: 'appDebug' }))
return
}
}
}
if (contextVarEmpty) {
notify({ type: 'error', message: t('feature.dataSet.queryVariable.contextVarNotEmpty', { ns: 'appDebug' }) })
toast.error(t('feature.dataSet.queryVariable.contextVarNotEmpty', { ns: 'appDebug' }))
return
}
const postDatasets = dataSets.map(({ id }) => ({
@ -851,7 +848,7 @@ const Configuration: FC = () => {
modelConfig: newModelConfig,
completionParams,
})
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
toast.success(t('api.success', { ns: 'common' }))
setCanReturnToSimpleMode(false)
return true

View File

@ -11,9 +11,9 @@ import Button from '@/app/components/base/button'
import EmojiPicker from '@/app/components/base/emoji-picker'
import FormGeneration from '@/app/components/base/features/new-feature-panel/moderation/form-generation'
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
import Modal from '@/app/components/base/modal'
import { SimpleSelect } from '@/app/components/base/select'
import { useToastContext } from '@/app/components/base/toast/context'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { toast } from '@/app/components/base/ui/toast'
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
import { useDocLink, useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
@ -39,7 +39,6 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
}) => {
const { t } = useTranslation()
const docLink = useDocLink()
const { notify } = useToastContext()
const locale = useLocale()
const [localeData, setLocaleData] = useState(data.type ? data : { ...data, type: 'api' })
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
@ -133,37 +132,34 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
const handleSave = () => {
if (!localeData.type) {
notify({ type: 'error', message: t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: t('feature.tools.modal.toolType.title', { ns: 'appDebug' }) }) })
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: t('feature.tools.modal.toolType.title', { ns: 'appDebug' }) }))
return
}
if (!localeData.label) {
notify({ type: 'error', message: t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: t('feature.tools.modal.name.title', { ns: 'appDebug' }) }) })
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: t('feature.tools.modal.name.title', { ns: 'appDebug' }) }))
return
}
if (!localeData.variable) {
notify({ type: 'error', message: t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: t('feature.tools.modal.variableName.title', { ns: 'appDebug' }) }) })
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: t('feature.tools.modal.variableName.title', { ns: 'appDebug' }) }))
return
}
if (localeData.variable && !/^[a-z_]\w{0,29}$/i.test(localeData.variable)) {
notify({ type: 'error', message: t('varKeyError.notValid', { ns: 'appDebug', key: t('feature.tools.modal.variableName.title', { ns: 'appDebug' }) }) })
toast.error(t('varKeyError.notValid', { ns: 'appDebug', key: t('feature.tools.modal.variableName.title', { ns: 'appDebug' }) }))
return
}
if (localeData.type === 'api' && !localeData.config?.api_based_extension_id) {
notify({ type: 'error', message: t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: locale !== LanguagesSupported[1] ? 'API Extension' : 'API 扩展' }) })
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: locale !== LanguagesSupported[1] ? 'API Extension' : 'API 扩展' }))
return
}
if (systemTypes.findIndex(t => t === localeData.type) < 0 && currentProvider?.form_schema) {
for (let i = 0; i < currentProvider.form_schema.length; i++) {
if (!localeData.config?.[currentProvider.form_schema[i].variable] && currentProvider.form_schema[i].required) {
notify({
type: 'error',
message: t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: locale !== LanguagesSupported[1] ? currentProvider.form_schema[i].label['en-US'] : currentProvider.form_schema[i].label['zh-Hans'] }),
})
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: locale !== LanguagesSupported[1] ? currentProvider.form_schema[i].label['en-US'] : currentProvider.form_schema[i].label['zh-Hans'] }))
return
}
}
@ -180,122 +176,128 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
const action = data.type ? t('operation.edit', { ns: 'common' }) : t('operation.add', { ns: 'common' })
return (
<Modal
isShow
onClose={noop}
className="!w-[640px] !max-w-none !p-8 !pb-6"
<Dialog
open
onOpenChange={noop}
>
<div className="mb-2 text-xl font-semibold text-text-primary">
{`${action} ${t('variableConfig.apiBasedVar', { ns: 'appDebug' })}`}
</div>
<div className="py-2">
<div className="text-sm font-medium leading-9 text-text-primary">
{t('apiBasedExtension.type', { ns: 'common' })}
<DialogContent className="!w-[640px] !max-w-none !p-8 !pb-6">
<div className="mb-2 text-xl font-semibold text-text-primary">
{`${action} ${t('variableConfig.apiBasedVar', { ns: 'appDebug' })}`}
</div>
<SimpleSelect
defaultValue={localeData.type}
items={providers.map((option) => {
return {
value: option.key,
name: option.name,
}
})}
onSelect={item => handleDataTypeChange(item.value as string)}
/>
</div>
<div className="py-2">
<div className="text-sm font-medium leading-9 text-text-primary">
{t('feature.tools.modal.name.title', { ns: 'appDebug' })}
<div className="py-2">
<div className="text-sm font-medium leading-9 text-text-primary">
{t('apiBasedExtension.type', { ns: 'common' })}
</div>
<Select
defaultValue={localeData.type}
onValueChange={value => value && handleDataTypeChange(value)}
>
<SelectTrigger className="w-full" aria-label={t('apiBasedExtension.type', { ns: 'common' })}>
<SelectValue />
</SelectTrigger>
<SelectContent popupClassName="w-[354px]">
{providers.map(option => (
<SelectItem key={option.key} value={option.key}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center">
<input
value={localeData.label || ''}
onChange={e => handleValueChange({ label: e.target.value })}
className="mr-2 block h-9 grow appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-components-input-text-filled outline-none"
placeholder={t('feature.tools.modal.name.placeholder', { ns: 'appDebug' }) || ''}
/>
<AppIcon
size="large"
onClick={() => { setShowEmojiPicker(true) }}
className="!h-9 !w-9 cursor-pointer rounded-lg border-[0.5px] border-components-panel-border"
icon={localeData.icon}
background={localeData.icon_background}
/>
</div>
</div>
<div className="py-2">
<div className="text-sm font-medium leading-9 text-text-primary">
{t('feature.tools.modal.variableName.title', { ns: 'appDebug' })}
</div>
<input
value={localeData.variable || ''}
onChange={e => handleValueChange({ variable: e.target.value })}
className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-components-input-text-filled outline-none"
placeholder={t('feature.tools.modal.variableName.placeholder', { ns: 'appDebug' }) || ''}
/>
</div>
{
localeData.type === 'api' && (
<div className="py-2">
<div className="flex h-9 items-center justify-between text-sm font-medium text-text-primary">
{t('apiBasedExtension.selector.title', { ns: 'common' })}
<a
href={docLink('/use-dify/workspace/api-extension/api-extension')}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center text-xs font-normal text-text-tertiary hover:text-text-accent"
>
<BookOpen01 className="mr-1 h-3 w-3 text-text-tertiary group-hover:text-text-accent" />
{t('apiBasedExtension.link', { ns: 'common' })}
</a>
</div>
<ApiBasedExtensionSelector
value={localeData.config?.api_based_extension_id || ''}
onChange={handleDataApiBasedChange}
<div className="py-2">
<div className="text-sm font-medium leading-9 text-text-primary">
{t('feature.tools.modal.name.title', { ns: 'appDebug' })}
</div>
<div className="flex items-center">
<input
value={localeData.label || ''}
onChange={e => handleValueChange({ label: e.target.value })}
className="mr-2 block h-9 grow appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-components-input-text-filled outline-none"
placeholder={t('feature.tools.modal.name.placeholder', { ns: 'appDebug' }) || ''}
/>
<AppIcon
size="large"
onClick={() => { setShowEmojiPicker(true) }}
className="!h-9 !w-9 cursor-pointer rounded-lg border-[0.5px] border-components-panel-border"
icon={localeData.icon}
background={localeData.icon_background}
/>
</div>
)
}
{
systemTypes.findIndex(t => t === localeData.type) < 0
&& currentProvider?.form_schema
&& (
<FormGeneration
forms={currentProvider?.form_schema}
value={localeData.config}
onChange={handleDataExtraChange}
</div>
<div className="py-2">
<div className="text-sm font-medium leading-9 text-text-primary">
{t('feature.tools.modal.variableName.title', { ns: 'appDebug' })}
</div>
<input
value={localeData.variable || ''}
onChange={e => handleValueChange({ variable: e.target.value })}
className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-components-input-text-filled outline-none"
placeholder={t('feature.tools.modal.variableName.placeholder', { ns: 'appDebug' }) || ''}
/>
)
}
<div className="mt-6 flex items-center justify-end">
<Button
onClick={onCancel}
className="mr-2"
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
onClick={handleSave}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
{
showEmojiPicker && (
<EmojiPicker
onSelect={(icon, icon_background) => {
handleValueChange({ icon, icon_background })
setShowEmojiPicker(false)
}}
onClose={() => {
handleValueChange({ icon: '', icon_background: '' })
setShowEmojiPicker(false)
}}
/>
)
}
</Modal>
</div>
{
localeData.type === 'api' && (
<div className="py-2">
<div className="flex h-9 items-center justify-between text-sm font-medium text-text-primary">
{t('apiBasedExtension.selector.title', { ns: 'common' })}
<a
href={docLink('/use-dify/workspace/api-extension/api-extension')}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center text-xs font-normal text-text-tertiary hover:text-text-accent"
>
<BookOpen01 className="mr-1 h-3 w-3 text-text-tertiary group-hover:text-text-accent" />
{t('apiBasedExtension.link', { ns: 'common' })}
</a>
</div>
<ApiBasedExtensionSelector
value={localeData.config?.api_based_extension_id || ''}
onChange={handleDataApiBasedChange}
/>
</div>
)
}
{
systemTypes.findIndex(t => t === localeData.type) < 0
&& currentProvider?.form_schema
&& (
<FormGeneration
forms={currentProvider?.form_schema}
value={localeData.config}
onChange={handleDataExtraChange}
/>
)
}
<div className="mt-6 flex items-center justify-end">
<Button
onClick={onCancel}
className="mr-2"
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
onClick={handleSave}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
{
showEmojiPicker && (
<EmojiPicker
onSelect={(icon, icon_background) => {
handleValueChange({ icon, icon_background })
setShowEmojiPicker(false)
}}
onClose={() => {
handleValueChange({ icon: '', icon_background: '' })
setShowEmojiPicker(false)
}}
/>
)
}
</DialogContent>
</Dialog>
)
}

View File

@ -5,7 +5,6 @@ import {
RiDeleteBinLine,
} from '@remixicon/react'
import copy from 'copy-to-clipboard'
// abandoned
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
@ -15,14 +14,17 @@ import {
} from '@/app/components/base/icons/src/vender/line/general'
import { Tool03 } from '@/app/components/base/icons/src/vender/solid/general'
import Switch from '@/app/components/base/switch'
import { useToastContext } from '@/app/components/base/toast/context'
import Tooltip from '@/app/components/base/tooltip'
import { toast } from '@/app/components/base/ui/toast'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/app/components/base/ui/tooltip'
import ConfigContext from '@/context/debug-configuration'
import { useModalContext } from '@/context/modal-context'
const Tools = () => {
const { t } = useTranslation()
const { notify } = useToastContext()
const { setShowExternalDataToolModal } = useModalContext()
const {
externalDataToolsConfig,
@ -48,7 +50,7 @@ const Tools = () => {
const promptVariables = modelConfig?.configs?.prompt_variables || []
for (let i = 0; i < promptVariables.length; i++) {
if (promptVariables[i].key === newExternalDataTool.variable) {
notify({ type: 'error', message: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: promptVariables[i].key }) })
toast.error(t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: promptVariables[i].key }))
return false
}
}
@ -66,7 +68,7 @@ const Tools = () => {
for (let i = 0; i < existedExternalDataTools.length; i++) {
if (existedExternalDataTools[i].variable === newExternalDataTool.variable) {
notify({ type: 'error', message: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: existedExternalDataTools[i].variable }) })
toast.error(t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: existedExternalDataTools[i].variable }))
return false
}
}
@ -110,13 +112,14 @@ const Tools = () => {
<div className="mr-1 text-sm font-semibold text-gray-800">
{t('feature.tools.title', { ns: 'appDebug' })}
</div>
<Tooltip
popupContent={(
<Tooltip>
<TooltipTrigger render={<span className="i-ri-question-line ml-1 h-4 w-4 shrink-0 text-text-quaternary" />} />
<TooltipContent>
<div className="max-w-[160px]">
{t('feature.tools.tips', { ns: 'appDebug' })}
</div>
)}
/>
</TooltipContent>
</Tooltip>
</div>
{
!expanded && !!externalDataToolsConfig.length && (
@ -151,18 +154,23 @@ const Tools = () => {
background={item.icon_background}
/>
<div className="mr-2 text-[13px] font-medium text-gray-800">{item.label}</div>
<Tooltip
popupContent={copied ? t('copied', { ns: 'appApi' }) : `${item.variable}, ${t('copy', { ns: 'appApi' })}`}
>
<div
className="text-xs text-gray-500"
onClick={() => {
copy(item.variable || '')
setCopied(true)
}}
>
{item.variable}
</div>
<Tooltip>
<TooltipTrigger
render={(
<div
className="text-xs text-gray-500"
onClick={() => {
copy(item.variable || '')
setCopied(true)
}}
>
{item.variable}
</div>
)}
/>
<TooltipContent>
{copied ? t('copied', { ns: 'appApi' }) : `${item.variable}, ${t('copy', { ns: 'appApi' })}`}
</TooltipContent>
</Tooltip>
</div>
<div

View File

@ -2,7 +2,6 @@ import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { trackEvent } from '@/app/components/base/amplitude'
import { ToastContext } from '@/app/components/base/toast/context'
import { MARKETPLACE_URL_PREFIX, NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
@ -57,6 +56,19 @@ vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
}))
const mockToast = {
success: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'success', message, ...options }),
error: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'error', message, ...options }),
warning: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'warning', message, ...options }),
info: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'info', message, ...options }),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
}
vi.mock('@/app/components/base/ui/toast', () => ({
toast: mockToast,
}))
const mockUseRouter = vi.mocked(useRouter)
const mockPush = vi.fn()
const mockCreateApp = vi.mocked(createApp)
@ -79,9 +91,8 @@ const renderModal = () => {
const onClose = vi.fn()
const onSuccess = vi.fn()
render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<CreateAppModal show onClose={onClose} onSuccess={onSuccess} defaultAppMode={AppModeEnum.ADVANCED_CHAT} />
</ToastContext.Provider>,
<CreateAppModal show onClose={onClose} onSuccess={onSuccess} defaultAppMode={AppModeEnum.ADVANCED_CHAT} />,
)
return { onClose, onSuccess }
}

View File

@ -6,8 +6,8 @@ import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon
import { useDebounceFn, useKeyPress } from 'ahooks'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import AppIcon from '@/app/components/base/app-icon'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'

View File

@ -2,15 +2,13 @@
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { useKeyPress } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { trackEvent } from '@/app/components/base/amplitude'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast/context'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import { toast } from '@/app/components/base/ui/toast'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
@ -58,7 +56,6 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
const { push } = useRouter()
const { t } = useTranslation()
const docLink = useDocLink()
const { notify } = useContext(ToastContext)
const [currentFile, setDSLFile] = useState<File | undefined>(droppedFile)
const [fileContent, setFileContent] = useState<string>()
const [currentTab, setCurrentTab] = useState(activeTab)
@ -152,11 +149,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
if (onClose)
onClose()
notify({
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
message: t(status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution', { ns: 'app' }),
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }),
})
toast(t(status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution', { ns: 'app' }), { type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', description: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }) })
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
if (app_id)
await handleCheckPluginDependencies(app_id)
@ -173,12 +166,12 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
setImportId(id)
}
else {
notify({ type: 'error', message: t('newApp.appCreateFailed', { ns: 'app' }) })
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
notify({ type: 'error', message: t('newApp.appCreateFailed', { ns: 'app' }) })
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
finally {
isCreatingRef.current = false
@ -213,22 +206,19 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
if (onClose)
onClose()
notify({
type: 'success',
message: t('newApp.appCreated', { ns: 'app' }),
})
toast.success(t('newApp.appCreated', { ns: 'app' }))
if (app_id)
await handleCheckPluginDependencies(app_id)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
}
else if (status === DSLImportStatus.FAILED) {
notify({ type: 'error', message: t('newApp.appCreateFailed', { ns: 'app' }) })
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
notify({ type: 'error', message: t('newApp.appCreateFailed', { ns: 'app' }) })
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
}
@ -265,94 +255,98 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
return (
<>
<Modal
className="w-[520px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl"
isShow={show}
onClose={noop}
<Dialog
open={show}
onOpenChange={(open) => {
if (!open)
onClose()
}}
>
<div className="flex items-center justify-between pb-3 pl-6 pr-5 pt-6 text-text-primary title-2xl-semi-bold">
{t('importApp', { ns: 'app' })}
<div
className="flex h-8 w-8 cursor-pointer items-center justify-center"
onClick={() => onClose()}
>
<span className="i-ri-close-line h-[18px] w-[18px] text-text-tertiary" aria-hidden="true" />
<DialogContent className="w-[520px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl">
<div className="flex items-center justify-between pb-3 pl-6 pr-5 pt-6 text-text-primary title-2xl-semi-bold">
{t('importApp', { ns: 'app' })}
<div
className="flex h-8 w-8 cursor-pointer items-center justify-center"
onClick={() => onClose()}
>
<span className="i-ri-close-line h-[18px] w-[18px] text-text-tertiary" aria-hidden="true" />
</div>
</div>
</div>
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 text-text-tertiary system-md-semibold">
{
tabs.map(tab => (
<div
key={tab.key}
className={cn(
'relative flex h-full cursor-pointer items-center',
currentTab === tab.key && 'text-text-primary',
)}
onClick={() => setCurrentTab(tab.key)}
>
{tab.label}
{currentTab === tab.key && (
<div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600"></div>
)}
</div>
))
}
</div>
<div className="px-6 py-4">
{currentTab === CreateFromDSLModalTab.FROM_FILE && (
<Uploader
className="mt-0"
file={currentFile}
updateFile={handleFile}
accept=".yaml,.yml,.zip"
displayName={isZipFile(currentFile) ? 'ZIP' : 'YAML'}
/>
)}
{currentTab === CreateFromDSLModalTab.FROM_URL && (
<div>
<div className="mb-1 text-text-secondary system-md-semibold">
{t('importFromDSLUrl', { ns: 'app' })}
</div>
<Input
placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''}
value={dslUrlValue}
onChange={e => setDslUrlValue(e.target.value)}
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 text-text-tertiary system-md-semibold">
{
tabs.map(tab => (
<div
key={tab.key}
className={cn(
'relative flex h-full cursor-pointer items-center',
currentTab === tab.key && 'text-text-primary',
)}
onClick={() => setCurrentTab(tab.key)}
>
{tab.label}
{currentTab === tab.key && (
<div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600"></div>
)}
</div>
))
}
</div>
<div className="px-6 py-4">
{currentTab === CreateFromDSLModalTab.FROM_FILE && (
<Uploader
className="mt-0"
file={currentFile}
updateFile={handleFile}
accept=".yaml,.yml,.zip"
displayName={isZipFile(currentFile) ? 'ZIP' : 'YAML'}
/>
)}
{currentTab === CreateFromDSLModalTab.FROM_URL && (
<div>
<div className="mb-1 text-text-secondary system-md-semibold">
{t('importFromDSLUrl', { ns: 'app' })}
</div>
<Input
placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''}
value={dslUrlValue}
onChange={e => setDslUrlValue(e.target.value)}
/>
</div>
)}
</div>
{isAppsFull && (
<div className="px-6">
<AppsFull className="mt-0" loc="app-create-dsl" />
</div>
)}
</div>
{isAppsFull && (
<div className="px-6">
<AppsFull className="mt-0" loc="app-create-dsl" />
</div>
)}
<div className="flex items-center justify-between px-6 pb-6 pt-5">
<a
className="flex items-center gap-1 text-text-accent system-xs-regular"
href={docLink('/use-dify/workspace/app-management#app-export-and-import', appManagementLocalizedPathMap)}
target="_blank"
rel="noopener noreferrer"
>
{learnMoreLabel}
<span className="i-ri-external-link-line h-[12px] w-[12px]" aria-hidden="true" />
</a>
<div className="flex items-center gap-3">
<Button variant="secondary" onClick={onClose}>
{t('newApp.Cancel', { ns: 'app' })}
</Button>
<Button
disabled={buttonDisabled || isCreating}
variant="primary"
onClick={onCreate}
className="gap-1"
loading={isCreating}
<div className="flex items-center justify-between px-6 pb-6 pt-5">
<a
className="flex items-center gap-1 text-text-accent system-xs-regular"
href={docLink('/use-dify/workspace/app-management#app-export-and-import', appManagementLocalizedPathMap)}
target="_blank"
rel="noopener noreferrer"
>
<span>{t('newApp.import', { ns: 'app' })}</span>
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
</Button>
{learnMoreLabel}
<span className="i-ri-external-link-line h-[12px] w-[12px]" aria-hidden="true" />
</a>
<div className="flex items-center gap-3">
<Button variant="secondary" onClick={onClose}>
{t('newApp.Cancel', { ns: 'app' })}
</Button>
<Button
disabled={buttonDisabled || isCreating}
variant="primary"
onClick={onCreate}
className="gap-1"
loading={isCreating}
>
<span>{t('newApp.import', { ns: 'app' })}</span>
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
</Button>
</div>
</div>
</div>
</Modal>
</DialogContent>
</Dialog>
{showErrorModal && (
<DSLConfirmModal
file={currentFile}

View File

@ -7,10 +7,9 @@ import {
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import ActionButton from '@/app/components/base/action-button'
import { Yaml as YamlIcon } from '@/app/components/base/icons/src/public/files'
import { ToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import { cn } from '@/utils/classnames'
import { formatFileSize } from '@/utils/format'
@ -30,7 +29,6 @@ const Uploader: FC<Props> = ({
displayName = 'YAML',
}) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [dragging, setDragging] = useState(false)
const dropRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<HTMLDivElement>(null)
@ -60,7 +58,7 @@ const Uploader: FC<Props> = ({
return
const files = Array.from(e.dataTransfer.files)
if (files.length > 1) {
notify({ type: 'error', message: t('stepOne.uploader.validation.count', { ns: 'datasetCreation' }) })
toast.error(t('stepOne.uploader.validation.count', { ns: 'datasetCreation' }))
return
}
updateFile(files[0])

View File

@ -2,7 +2,7 @@ import type { ProviderContextState } from '@/context/provider-context'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { Plan } from '@/app/components/billing/type'
import { baseProviderContextValue } from '@/context/provider-context'
import DuplicateAppModal from './index'
@ -129,7 +129,7 @@ describe('DuplicateAppModal', () => {
it('should show error toast when name is empty', async () => {
const user = userEvent.setup()
const toastSpy = vi.spyOn(Toast, 'notify')
const toastSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
// Arrange
const { onConfirm, onHide } = renderComponent()
@ -138,7 +138,7 @@ describe('DuplicateAppModal', () => {
await user.click(screen.getByRole('button', { name: 'app.duplicate' }))
// Assert
expect(toastSpy).toHaveBeenCalledWith({ type: 'error', message: 'explore.appCustomize.nameRequired' })
expect(toastSpy).toHaveBeenCalledWith('explore.appCustomize.nameRequired')
expect(onConfirm).not.toHaveBeenCalled()
expect(onHide).not.toHaveBeenCalled()
})

View File

@ -9,7 +9,7 @@ import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
@ -57,7 +57,7 @@ const DuplicateAppModal = ({
const submit = () => {
if (!name.trim()) {
Toast.notify({ type: 'error', message: t('appCustomize.nameRequired', { ns: 'explore' }) })
toast.error(t('appCustomize.nameRequired', { ns: 'explore' }))
return
}
onConfirm({

View File

@ -30,8 +30,8 @@ import Drawer from '@/app/components/base/drawer'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import Loading from '@/app/components/base/loading'
import MessageLogModal from '@/app/components/base/message-log-modal'
import { ToastContext } from '@/app/components/base/toast/context'
import Tooltip from '@/app/components/base/tooltip'
import { toast } from '@/app/components/base/ui/toast'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import { useAppContext } from '@/context/app-context'
@ -223,7 +223,6 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
const { userProfile: { timezone } } = useAppContext()
const { formatTime } = useTimestamp()
const { onClose, appDetail } = useContext(DrawerContext)
const { notify } = useContext(ToastContext)
const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, showPromptLogModal, setShowPromptLogModal, currentLogModalActiveTab } = useAppStore(useShallow((state: AppStoreState) => ({
currentLogItem: state.currentLogItem,
setCurrentLogItem: state.setCurrentLogItem,
@ -413,14 +412,14 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
return item
}))
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
return true
}
catch {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
return false
}
}, [allChatItems, appDetail?.id, notify, t])
}, [allChatItems, appDetail?.id, t])
const fetchInitiated = useRef(false)
@ -734,7 +733,6 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => {
// Text Generator App Session Details Including Message List
const { data: conversationDetail, refetch: conversationDetailMutate } = useCompletionConversationDetail(appId, conversationId)
const { notify } = useContext(ToastContext)
const { t } = useTranslation()
const handleFeedback = async (mid: string, { rating, content }: FeedbackType): Promise<boolean> => {
@ -744,11 +742,11 @@ const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: st
body: { message_id: mid, rating, content: content ?? undefined },
})
conversationDetailMutate()
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
return true
}
catch {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
return false
}
}
@ -757,11 +755,11 @@ const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: st
try {
await updateLogMessageAnnotations({ url: `/apps/${appId}/annotations`, body: { message_id: mid, content: value } })
conversationDetailMutate()
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
return true
}
catch {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
return false
}
}
@ -783,7 +781,6 @@ const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: st
*/
const ChatConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => {
const { data: conversationDetail } = useChatConversationDetail(appId, conversationId)
const { notify } = useContext(ToastContext)
const { t } = useTranslation()
const handleFeedback = async (mid: string, { rating, content }: FeedbackType): Promise<boolean> => {
@ -792,11 +789,11 @@ const ChatConversationDetailComp: FC<{ appId?: string, conversationId?: string }
url: `/apps/${appId}/feedbacks`,
body: { message_id: mid, rating, content: content ?? undefined },
})
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
return true
}
catch {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
return false
}
}
@ -804,11 +801,11 @@ const ChatConversationDetailComp: FC<{ appId?: string, conversationId?: string }
const handleAnnotation = async (mid: string, value: string): Promise<boolean> => {
try {
await updateLogMessageAnnotations({ url: `/apps/${appId}/annotations`, body: { message_id: mid, content: value } })
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
return true
}
catch {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
return false
}
}

View File

@ -33,6 +33,19 @@ vi.mock('react-i18next', async () => {
})
const mockNotify = vi.fn()
const mockToast = {
success: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'success', message, ...options }),
error: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'error', message, ...options }),
warning: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'warning', message, ...options }),
info: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'info', message, ...options }),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
}
vi.mock('@/app/components/base/ui/toast', () => ({
toast: mockToast,
}))
const mockOnClose = vi.fn()
const mockOnSave = vi.fn()
const mockSetShowPricingModal = vi.fn()
@ -59,13 +72,6 @@ vi.mock('@/context/modal-context', () => ({
useModalContext: () => buildModalContext(),
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
close: vi.fn(),
}),
}))
vi.mock('@/context/i18n', async () => {
const actual = await vi.importActual<typeof import('@/context/i18n')>('@/context/i18n')
return {

View File

@ -19,8 +19,8 @@ import PremiumBadge from '@/app/components/base/premium-badge'
import { SimpleSelect } from '@/app/components/base/select'
import Switch from '@/app/components/base/switch'
import Textarea from '@/app/components/base/textarea'
import { useToastContext } from '@/app/components/base/toast/context'
import Tooltip from '@/app/components/base/tooltip'
import { toast } from '@/app/components/base/ui/toast'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
@ -65,7 +65,6 @@ const SettingsModal: FC<ISettingsModalProps> = ({
onClose,
onSave,
}) => {
const { notify } = useToastContext()
const [isShowMore, setIsShowMore] = useState(false)
const {
title,
@ -159,7 +158,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
const onClickSave = async () => {
if (!inputInfo.title) {
notify({ type: 'error', message: t('newApp.nameNotEmpty', { ns: 'app' }) })
toast.error(t('newApp.nameNotEmpty', { ns: 'app' }))
return
}
@ -181,11 +180,11 @@ const SettingsModal: FC<ISettingsModalProps> = ({
if (inputInfo !== null) {
if (!validateColorHex(inputInfo.chatColorTheme)) {
notify({ type: 'error', message: t(`${prefixSettings}.invalidHexMessage`, { ns: 'appOverview' }) })
toast.error(t(`${prefixSettings}.invalidHexMessage`, { ns: 'appOverview' }))
return
}
if (!validatePrivacyPolicy(inputInfo.privacyPolicy)) {
notify({ type: 'error', message: t(`${prefixSettings}.invalidPrivacyPolicy`, { ns: 'appOverview' }) })
toast.error(t(`${prefixSettings}.invalidPrivacyPolicy`, { ns: 'appOverview' }))
return
}
}

View File

@ -3,7 +3,6 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast/context'
import { Plan } from '@/app/components/billing/type'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { AppModeEnum } from '@/types/app'
@ -111,20 +110,32 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({
const renderComponent = (overrides: Partial<React.ComponentProps<typeof SwitchAppModal>> = {}) => {
const notify = vi.fn()
const mockToast = {
success: (message: string, options?: Record<string, unknown>) => notify({ type: 'success', message, ...options }),
error: (message: string, options?: Record<string, unknown>) => notify({ type: 'error', message, ...options }),
warning: (message: string, options?: Record<string, unknown>) => notify({ type: 'warning', message, ...options }),
info: (message: string, options?: Record<string, unknown>) => notify({ type: 'info', message, ...options }),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
}
vi.mock('@/app/components/base/ui/toast', () => ({
toast: mockToast,
}))
const onClose = vi.fn()
const onSuccess = vi.fn()
const appDetail = createMockApp()
const utils = render(
<ToastContext.Provider value={{ notify, close: vi.fn() }}>
<SwitchAppModal
show
appDetail={appDetail}
onClose={onClose}
onSuccess={onSuccess}
{...overrides}
/>
</ToastContext.Provider>,
<SwitchAppModal
show
appDetail={appDetail}
onClose={onClose}
onSuccess={onSuccess}
{...overrides}
/>,
)
return {

View File

@ -5,7 +5,6 @@ import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useStore as useAppStore } from '@/app/components/app/store'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
@ -14,7 +13,7 @@ import Confirm from '@/app/components/base/confirm'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
@ -37,7 +36,6 @@ type SwitchAppModalProps = {
const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClose }: SwitchAppModalProps) => {
const { push, replace } = useRouter()
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const setAppDetail = useAppStore(s => s.setAppDetail)
const { isCurrentWorkspaceEditor } = useAppContext()
@ -68,7 +66,7 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
onSuccess()
if (onClose)
onClose()
notify({ type: 'success', message: t('newApp.appCreated', { ns: 'app' }) })
toast.success(t('newApp.appCreated', { ns: 'app' }))
if (inAppDetail)
setAppDetail()
if (removeOriginal)
@ -84,7 +82,7 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
)
}
catch {
notify({ type: 'error', message: t('newApp.appCreateFailed', { ns: 'app' }) })
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
}

View File

@ -28,7 +28,7 @@ import { useChatContext } from '@/app/components/base/chat/chat/context'
import Loading from '@/app/components/base/loading'
import { Markdown } from '@/app/components/base/markdown'
import NewAudioButton from '@/app/components/base/new-audio-button'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { useParams } from '@/next/navigation'
import { fetchTextGenerationMessage } from '@/service/debug'
import { AppSourceType, fetchMoreLikeThis, submitHumanInputForm, updateFeedback } from '@/service/share'
@ -145,7 +145,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
const handleMoreLikeThis = async () => {
if (isQuerying || !messageId) {
Toast.notify({ type: 'warning', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
toast.warning(t('errorMessage.waitForResponse', { ns: 'appDebug' }))
return
}
startQuerying()
@ -368,7 +368,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
copy(copyContent)
else
copy(JSON.stringify(copyContent))
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
}}
>
<RiClipboardLine className="h-4 w-4" />

View File

@ -4,7 +4,7 @@ import copy from 'copy-to-clipboard'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import SavedItems from './index'
vi.mock('copy-to-clipboard', () => ({
@ -16,7 +16,7 @@ vi.mock('@/next/navigation', () => ({
}))
const mockCopy = vi.mocked(copy)
const toastNotifySpy = vi.spyOn(Toast, 'notify')
const toastSuccessSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success')
const baseProps: ISavedItemsProps = {
list: [
@ -30,7 +30,7 @@ const baseProps: ISavedItemsProps = {
describe('SavedItems', () => {
beforeEach(() => {
vi.clearAllMocks()
toastNotifySpy.mockClear()
toastSuccessSpy.mockClear()
})
it('renders saved answers with metadata and controls', () => {
@ -58,7 +58,7 @@ describe('SavedItems', () => {
fireEvent.click(copyButton)
expect(mockCopy).toHaveBeenCalledWith('hello world')
expect(toastNotifySpy).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.copySuccessfully' })
expect(toastSuccessSpy).toHaveBeenCalledWith('common.actionMsg.copySuccessfully')
fireEvent.click(deleteButton)
expect(handleRemove).toHaveBeenCalledWith('1')

View File

@ -11,7 +11,7 @@ import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { Markdown } from '@/app/components/base/markdown'
import NewAudioButton from '@/app/components/base/new-audio-button'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { cn } from '@/utils/classnames'
import NoData from './no-data'
@ -60,7 +60,7 @@ const SavedItems: FC<ISavedItemsProps> = ({
{isShowTextToSpeech && <NewAudioButton value={answer} />}
<ActionButton onClick={() => {
copy(answer)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
}}
>
<RiClipboardLine className="h-4 w-4" />

View File

@ -1,24 +1,18 @@
'use client'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { HtmlContentProps } from '@/app/components/base/popover'
import type { Tag } from '@/app/components/base/tag-management/constant'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import type { WorkflowOnlineUser } from '@/models/app'
import type { App } from '@/types/app'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState, useTransition } from 'react'
import { useCallback, useMemo, useState, useTransition } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { AppTypeIcon } from '@/app/components/app/type-selector'
import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider'
import CustomPopover from '@/app/components/base/popover'
import TagSelector from '@/app/components/base/tag-management/selector'
import Toast from '@/app/components/base/toast'
import { ToastContext } from '@/app/components/base/toast/context'
import Tooltip from '@/app/components/base/tooltip'
import {
AlertDialog,
AlertDialogActions,
@ -28,6 +22,17 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { toast } from '@/app/components/base/ui/toast'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/app/components/base/ui/tooltip'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
@ -65,6 +70,166 @@ const AccessControl = dynamic(() => import('@/app/components/app/app-access-cont
ssr: false,
})
type AppCardOperationsProps = {
app: App
webappAuthEnabled: boolean
isCurrentWorkspaceEditor: boolean
exporting: boolean
secretEnvListLength: number
isUpgradingRuntime: boolean
popupClassName: string
onEdit: () => void
onDuplicate: () => void
onExport: () => void
onSwitch: () => void
onDelete: () => void
onAccessControl: () => void
onInstalledApp: () => void
onUpgradeRuntime: () => void
}
const AppCardOperations = ({
app,
webappAuthEnabled,
isCurrentWorkspaceEditor,
exporting,
secretEnvListLength,
isUpgradingRuntime,
popupClassName,
onEdit,
onDuplicate,
onExport,
onSwitch,
onDelete,
onAccessControl,
onInstalledApp,
onUpgradeRuntime,
}: AppCardOperationsProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({
appId: app.id,
enabled: !!open && webappAuthEnabled,
})
const onMouseLeave = () => {
setOpen(false)
}
const onClickInstalledApp = async () => {
onInstalledApp()
onMouseLeave()
}
const onClickUpgradeRuntime = async () => {
onUpgradeRuntime()
onMouseLeave()
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<button
type="button"
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md',
open && '!bg-state-base-hover !shadow-none',
)}
>
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</button>
)}
/>
<PopoverContent
placement="bottom-end"
sideOffset={4}
popupClassName={cn(
'w-fit min-w-[130px] overflow-hidden rounded-lg bg-components-panel-bg shadow-lg ring-1 ring-black/5',
popupClassName,
)}
>
<div className="flex w-full flex-col py-1" onMouseLeave={onMouseLeave}>
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onEdit}>
<span className="text-text-secondary system-sm-regular">{t('editApp', { ns: 'app' })}</span>
</button>
<Divider className="my-1" />
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onDuplicate}>
<span className="text-text-secondary system-sm-regular">{t('duplicate', { ns: 'app' })}</span>
</button>
<button type="button" disabled={exporting || secretEnvListLength > 0} className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50" onClick={onExport}>
<span className="text-text-secondary system-sm-regular">{t('export', { ns: 'app' })}</span>
</button>
{(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) && (
<>
<Divider className="my-1" />
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover" onClick={onSwitch}>
<span className="text-sm leading-5 text-text-secondary">{t('switch', { ns: 'app' })}</span>
</button>
</>
)}
{
!app.has_draft_trigger && (
(!webappAuthEnabled)
? (
<>
<Divider className="my-1" />
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickInstalledApp}>
<span className="text-text-secondary system-sm-regular">{t('openInExplore', { ns: 'app' })}</span>
</button>
</>
)
: !(isGettingUserCanAccessApp || !userCanAccessApp?.result) && (
<>
<Divider className="my-1" />
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickInstalledApp}>
<span className="text-text-secondary system-sm-regular">{t('openInExplore', { ns: 'app' })}</span>
</button>
</>
)
)
}
<Divider className="my-1" />
{
webappAuthEnabled && isCurrentWorkspaceEditor && (
<>
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover" onClick={onAccessControl}>
<span className="text-sm leading-5 text-text-secondary">{t('accessControl', { ns: 'app' })}</span>
</button>
<Divider className="my-1" />
</>
)
}
{app.runtime_type !== 'sandboxed'
&& (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT)
&& (
<button
type="button"
disabled={isUpgradingRuntime}
className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50"
onClick={onClickUpgradeRuntime}
>
<span className="text-text-accent system-sm-regular">
{t('upgradeRuntime', { ns: 'app' })}
</span>
</button>
)}
<button
type="button"
className="group mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-destructive-hover"
onClick={onDelete}
>
<span className="text-text-secondary system-sm-regular group-hover:text-text-destructive">
{t('operation.delete', { ns: 'common' })}
</span>
</button>
</div>
</PopoverContent>
</Popover>
)
}
export type AppCardProps = {
app: App
onRefresh?: () => void
@ -73,7 +238,6 @@ export type AppCardProps = {
const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { isCurrentWorkspaceEditor } = useAppContext()
const { onPlanInfoChanged } = useProviderContext()
@ -93,20 +257,17 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
const onConfirmDelete = useCallback(async () => {
try {
await mutateDeleteApp(app.id)
notify({ type: 'success', message: t('appDeleted', { ns: 'app' }) })
toast.success(t('appDeleted', { ns: 'app' }))
onPlanInfoChanged()
}
catch (e: unknown) {
notify({
type: 'error',
message: `${t('appDeleteFailed', { ns: 'app' })}${e instanceof Error ? `: ${e.message}` : ''}`,
})
toast.error(`${t('appDeleteFailed', { ns: 'app' })}${e instanceof Error ? `: ${e.message}` : ''}`)
}
finally {
setShowConfirmDelete(false)
setConfirmDeleteInput('')
}
}, [app.id, mutateDeleteApp, notify, onPlanInfoChanged, t])
}, [app.id, mutateDeleteApp, onPlanInfoChanged, t])
const onDeleteDialogOpenChange = useCallback((open: boolean) => {
if (isDeleting)
@ -138,20 +299,14 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
max_active_requests,
})
setShowEditModal(false)
notify({
type: 'success',
message: t('editDone', { ns: 'app' }),
})
toast.success(t('editDone', { ns: 'app' }))
if (onRefresh)
onRefresh()
}
catch (e: unknown) {
notify({
type: 'error',
message: (e instanceof Error ? e.message : '') || t('editFailed', { ns: 'app' }),
})
toast.error((e instanceof Error ? e.message : '') || t('editFailed', { ns: 'app' }))
}
}, [app.id, notify, onRefresh, t])
}, [app.id, onRefresh, t])
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
try {
@ -164,10 +319,7 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
mode: app.mode,
})
setShowDuplicateModal(false)
notify({
type: 'success',
message: t('newApp.appCreated', { ns: 'app' }),
})
toast.success(t('newApp.appCreated', { ns: 'app' }))
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
if (onRefresh)
onRefresh()
@ -175,7 +327,7 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
getRedirection(isCurrentWorkspaceEditor, newApp, push)
}
catch {
notify({ type: 'error', message: t('newApp.appCreateFailed', { ns: 'app' }) })
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
}
@ -197,10 +349,7 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
downloadBlob({ data: file, fileName: `${app.name}.yml` })
}
catch {
notify({
type: 'error',
message: t('exportFailed', { ns: 'app' }),
})
toast.error(t('exportFailed', { ns: 'app' }))
}
}
@ -219,196 +368,71 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
setSecretEnvList(list)
}
catch {
notify({
type: 'error',
message: t('exportFailed', { ns: 'app' }),
})
toast.error(t('exportFailed', { ns: 'app' }))
}
}
const [isUpgradingRuntime, startUpgradeRuntime] = useTransition()
const onSwitch = () => {
if (onRefresh)
onRefresh()
setShowSwitchModal(false)
}
const [isUpgradingRuntime, startUpgradeRuntime] = useTransition()
const isClassicWorkflowApp = app.runtime_type !== 'sandboxed'
&& (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT)
const onUpdateAccessControl = useCallback(() => {
if (onRefresh)
onRefresh()
setShowAccessControl(false)
}, [onRefresh, setShowAccessControl])
const Operations = (props: HtmlContentProps) => {
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({ appId: app?.id, enabled: (!!props?.open && systemFeatures.webapp_auth.enabled) })
const onMouseLeave = async () => {
props.onClose?.()
}
const onClickSettings = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
setShowEditModal(true)
}
const onClickDuplicate = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
setShowDuplicateModal(true)
}
const onClickExport = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
startExport(async () => {
await exportCheck()
const handleOpenEditModal = useCallback(() => setShowEditModal(true), [])
const handleOpenDuplicateModal = useCallback(() => setShowDuplicateModal(true), [])
const handleOpenSwitchModal = useCallback(() => setShowSwitchModal(true), [])
const handleOpenDeleteModal = useCallback(() => setShowConfirmDelete(true), [])
const handleOpenAccessControl = useCallback(() => setShowAccessControl(true), [])
const handleExport = useCallback(() => {
startExport(async () => {
await exportCheck()
})
}, [exportCheck, startExport])
const handleInstalledApp = useCallback(async () => {
try {
await openAsyncWindow(async () => {
const { installed_apps } = (await fetchInstalledAppList(app.id) || {}) as { installed_apps?: { id: string }[] }
if (installed_apps && installed_apps.length > 0)
return `${basePath}/explore/installed/${installed_apps[0].id}`
throw new Error('No app found in Explore')
}, {
onError: (err) => {
toast.error(`${err.message || err}`)
},
})
}
const onClickSwitch = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
setShowSwitchModal(true)
catch (e: unknown) {
toast.error(e instanceof Error ? e.message : String(e))
}
const onClickDelete = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
setShowConfirmDelete(true)
}
const onClickAccessControl = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
setShowAccessControl(true)
}
const onClickInstalledApp = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
}, [app.id, openAsyncWindow])
const handleUpgradeRuntime = useCallback(() => {
startUpgradeRuntime(async () => {
try {
await openAsyncWindow(async () => {
const { installed_apps } = (await fetchInstalledAppList(app.id) || {}) as { installed_apps?: { id: string }[] }
if (installed_apps && installed_apps.length > 0)
return `${basePath}/explore/installed/${installed_apps[0].id}`
throw new Error('No app found in Explore')
}, {
onError: (err) => {
Toast.notify({ type: 'error', message: `${err.message || err}` })
},
})
const res = await upgradeAppRuntime(app.id)
if (res.result === 'success' && res.new_app_id) {
toast.success(t('sandboxMigrationModal.upgrade', { ns: 'workflow' }))
const params = new URLSearchParams({
upgraded_from: app.id,
upgraded_from_name: app.name,
})
push(`/app/${res.new_app_id}/workflow?${params.toString()}`)
}
}
catch (e: unknown) {
Toast.notify({ type: 'error', message: e instanceof Error ? e.message : String(e) })
toast.error((e instanceof Error ? e.message : '') || 'Upgrade failed')
}
}
const onClickUpgradeRuntime = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
props.onClick?.()
e.preventDefault()
startUpgradeRuntime(async () => {
try {
const res = await upgradeAppRuntime(app.id)
if (res.result === 'success' && res.new_app_id) {
notify({ type: 'success', message: t('sandboxMigrationModal.upgrade', { ns: 'workflow' }) })
const params = new URLSearchParams({
upgraded_from: app.id,
upgraded_from_name: app.name,
})
push(`/app/${res.new_app_id}/workflow?${params.toString()}`)
}
}
catch (e: unknown) {
notify({ type: 'error', message: (e instanceof Error ? e.message : '') || 'Upgrade failed' })
}
})
}
return (
<div className="relative flex w-full flex-col py-1" onMouseLeave={onMouseLeave}>
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickSettings}>
<span className="text-text-secondary system-sm-regular">{t('editApp', { ns: 'app' })}</span>
</button>
<Divider className="my-1" />
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickDuplicate}>
<span className="text-text-secondary system-sm-regular">{t('duplicate', { ns: 'app' })}</span>
</button>
<button type="button" disabled={exporting || secretEnvList.length > 0} className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50" onClick={onClickExport}>
<span className="text-text-secondary system-sm-regular">{t('export', { ns: 'app' })}</span>
</button>
{(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) && (
<>
<Divider className="my-1" />
<button
type="button"
className="mx-1 flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
onClick={onClickSwitch}
>
<span className="text-sm leading-5 text-text-secondary">{t('switch', { ns: 'app' })}</span>
</button>
</>
)}
{
!app.has_draft_trigger && (
(!systemFeatures.webapp_auth.enabled)
? (
<>
<Divider className="my-1" />
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickInstalledApp}>
<span className="text-text-secondary system-sm-regular">{t('openInExplore', { ns: 'app' })}</span>
</button>
</>
)
: !(isGettingUserCanAccessApp || !userCanAccessApp?.result) && (
<>
<Divider className="my-1" />
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickInstalledApp}>
<span className="text-text-secondary system-sm-regular">{t('openInExplore', { ns: 'app' })}</span>
</button>
</>
)
)
}
<Divider className="my-1" />
{
systemFeatures.webapp_auth.enabled && isCurrentWorkspaceEditor && (
<>
<button type="button" className="mx-1 flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover" onClick={onClickAccessControl}>
<span className="text-sm leading-5 text-text-secondary">{t('accessControl', { ns: 'app' })}</span>
</button>
<Divider className="my-1" />
</>
)
}
{isClassicWorkflowApp && (
<button
type="button"
disabled={isUpgradingRuntime}
className="mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50"
onClick={onClickUpgradeRuntime}
>
<span className="text-text-accent system-sm-regular">{t('upgradeRuntime', { ns: 'app' })}</span>
</button>
)}
<button
type="button"
className="group mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-destructive-hover"
onClick={onClickDelete}
>
<span className="text-text-secondary system-sm-regular group-hover:text-text-destructive">
{t('operation.delete', { ns: 'common' })}
</span>
</button>
</div>
)
}
})
}, [app.id, app.name, push, startUpgradeRuntime, t])
const [tags, setTags] = useState<Tag[]>(app.tags)
useEffect(() => {
setTags(app.tags)
}, [app.tags])
const [tags, setTags] = useState<Tag[]>(() => app.tags)
const EditTimeText = useMemo(() => {
const timeText = formatTime({
@ -463,23 +487,27 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
</div>
<div className="flex h-5 w-5 shrink-0 items-center justify-center">
{app.access_mode === AccessMode.PUBLIC && (
<Tooltip asChild={false} popupContent={t('accessItemsDescription.anyone', { ns: 'app' })}>
<span className="i-ri-global-line h-4 w-4 text-text-quaternary" />
<Tooltip>
<TooltipTrigger render={<span className="i-ri-global-line h-4 w-4 text-text-quaternary" />} />
<TooltipContent>{t('accessItemsDescription.anyone', { ns: 'app' })}</TooltipContent>
</Tooltip>
)}
{app.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && (
<Tooltip asChild={false} popupContent={t('accessItemsDescription.specific', { ns: 'app' })}>
<span className="i-ri-lock-line h-4 w-4 text-text-quaternary" />
<Tooltip>
<TooltipTrigger render={<span className="i-ri-lock-line h-4 w-4 text-text-quaternary" />} />
<TooltipContent>{t('accessItemsDescription.specific', { ns: 'app' })}</TooltipContent>
</Tooltip>
)}
{app.access_mode === AccessMode.ORGANIZATION && (
<Tooltip asChild={false} popupContent={t('accessItemsDescription.organization', { ns: 'app' })}>
<span className="i-ri-building-line h-4 w-4 text-text-quaternary" />
<Tooltip>
<TooltipTrigger render={<span className="i-ri-building-line h-4 w-4 text-text-quaternary" />} />
<TooltipContent>{t('accessItemsDescription.organization', { ns: 'app' })}</TooltipContent>
</Tooltip>
)}
{app.access_mode === AccessMode.EXTERNAL_MEMBERS && (
<Tooltip asChild={false} popupContent={t('accessItemsDescription.external', { ns: 'app' })}>
<span className="i-ri-verified-badge-line h-4 w-4 text-text-quaternary" />
<Tooltip>
<TooltipTrigger render={<span className="i-ri-verified-badge-line h-4 w-4 text-text-quaternary" />} />
<TooltipContent>{t('accessItemsDescription.external', { ns: 'app' })}</TooltipContent>
</Tooltip>
)}
</div>
@ -521,29 +549,26 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
</div>
<div className="mx-1 !hidden h-[14px] w-[1px] shrink-0 bg-divider-regular group-hover:!flex" />
<div className="!hidden shrink-0 group-hover:!flex">
<CustomPopover
htmlContent={<Operations />}
position="br"
trigger="click"
btnElement={(
<div
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md"
>
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</div>
)}
btnClassName={open =>
cn(
open ? '!bg-state-base-hover !shadow-none' : '!bg-transparent',
'h-8 w-8 rounded-md border-none !p-2 hover:!bg-state-base-hover',
)}
<AppCardOperations
app={app}
webappAuthEnabled={systemFeatures.webapp_auth.enabled}
isCurrentWorkspaceEditor={isCurrentWorkspaceEditor}
exporting={exporting}
secretEnvListLength={secretEnvList.length}
isUpgradingRuntime={isUpgradingRuntime}
popupClassName={
(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT)
? '!w-[256px] translate-x-[-224px]'
: '!w-[216px] translate-x-[-128px]'
? 'w-[256px]'
: 'w-[216px]'
}
className="!z-20 h-fit"
onEdit={handleOpenEditModal}
onDuplicate={handleOpenDuplicateModal}
onExport={handleExport}
onSwitch={handleOpenSwitchModal}
onDelete={handleOpenDeleteModal}
onAccessControl={handleOpenAccessControl}
onInstalledApp={handleInstalledApp}
onUpgradeRuntime={handleUpgradeRuntime}
/>
</div>
</>

View File

@ -1,16 +1,32 @@
import type { ComponentProps } from 'react'
import type { ComponentProps, ReactNode } from 'react'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { AgentLogDetailResponse } from '@/models/log'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast/context'
import { fetchAgentLogDetail } from '@/service/log'
import AgentLogDetail from '../detail'
const { mockToast } = vi.hoisted(() => {
const mockToast = Object.assign(vi.fn(), {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
})
return { mockToast }
})
vi.mock('@/service/log', () => ({
fetchAgentLogDetail: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: mockToast,
}))
vi.mock('@/app/components/app/store', () => ({
useStore: vi.fn(selector => selector({ appDetail: { id: 'app-id' } })),
}))
@ -22,7 +38,7 @@ vi.mock('@/app/components/workflow/run/status', () => ({
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
default: ({ title, value }: { title: React.ReactNode, value: string | object }) => (
default: ({ title, value }: { title: ReactNode, value: string | object }) => (
<div data-testid="code-editor">
{title}
{typeof value === 'string' ? value : JSON.stringify(value)}
@ -76,19 +92,13 @@ const createMockResponse = (overrides: Partial<AgentLogDetailResponse> = {}): Ag
})
describe('AgentLogDetail', () => {
const notify = vi.fn()
const renderComponent = (props: Partial<ComponentProps<typeof AgentLogDetail>> = {}) => {
const defaultProps: ComponentProps<typeof AgentLogDetail> = {
conversationID: 'conv-id',
messageID: 'msg-id',
log: createMockLog(),
}
return render(
<ToastContext.Provider value={{ notify, close: vi.fn() } as ComponentProps<typeof ToastContext.Provider>['value']}>
<AgentLogDetail {...defaultProps} {...props} />
</ToastContext.Provider>,
)
return render(<AgentLogDetail {...defaultProps} {...props} />)
}
const renderAndWaitForData = async (props: Partial<ComponentProps<typeof AgentLogDetail>> = {}) => {
@ -212,10 +222,7 @@ describe('AgentLogDetail', () => {
renderComponent()
await waitFor(() => {
expect(notify).toHaveBeenCalledWith({
type: 'error',
message: 'Error: API Error',
})
expect(mockToast.error).toHaveBeenCalledWith('Error: API Error')
})
})

View File

@ -1,14 +1,30 @@
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useClickAway } from 'ahooks'
import { ToastContext } from '@/app/components/base/toast/context'
import { fetchAgentLogDetail } from '@/service/log'
import AgentLogModal from '../index'
const { mockToast } = vi.hoisted(() => {
const mockToast = Object.assign(vi.fn(), {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
})
return { mockToast }
})
vi.mock('@/service/log', () => ({
fetchAgentLogDetail: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: mockToast,
}))
vi.mock('@/app/components/app/store', () => ({
useStore: vi.fn(selector => selector({ appDetail: { id: 'app-id' } })),
}))
@ -94,11 +110,7 @@ describe('AgentLogModal', () => {
})
it('should render correctly when log item is provided', async () => {
render(
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() } as React.ComponentProps<typeof ToastContext.Provider>['value']}>
<AgentLogModal {...mockProps} />
</ToastContext.Provider>,
)
render(<AgentLogModal {...mockProps} />)
expect(screen.getByText('appLog.runDetail.workflowTitle')).toBeInTheDocument()
@ -110,11 +122,7 @@ describe('AgentLogModal', () => {
it('should call onCancel when close button is clicked', () => {
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
render(
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() } as React.ComponentProps<typeof ToastContext.Provider>['value']}>
<AgentLogModal {...mockProps} />
</ToastContext.Provider>,
)
render(<AgentLogModal {...mockProps} />)
const closeBtn = screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i }).nextElementSibling!
fireEvent.click(closeBtn)
@ -130,11 +138,7 @@ describe('AgentLogModal', () => {
clickAwayHandler = callback
})
render(
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() } as React.ComponentProps<typeof ToastContext.Provider>['value']}>
<AgentLogModal {...mockProps} />
</ToastContext.Provider>,
)
render(<AgentLogModal {...mockProps} />)
clickAwayHandler(new Event('click'))
expect(mockProps.onCancel).toHaveBeenCalledTimes(1)
@ -150,11 +154,7 @@ describe('AgentLogModal', () => {
}
})
render(
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() } as React.ComponentProps<typeof ToastContext.Provider>['value']}>
<AgentLogModal {...mockProps} />
</ToastContext.Provider>,
)
render(<AgentLogModal {...mockProps} />)
expect(mockProps.onCancel).not.toHaveBeenCalled()
})

View File

@ -7,10 +7,9 @@ import { flatten } from 'es-toolkit/compat'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useStore as useAppStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import { ToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import { fetchAgentLogDetail } from '@/service/log'
import { cn } from '@/utils/classnames'
import ResultPanel from './result'
@ -22,28 +21,19 @@ export type AgentLogDetailProps = {
log: IChatItem
messageID: string
}
const AgentLogDetail: FC<AgentLogDetailProps> = ({
activeTab = 'DETAIL',
conversationID,
messageID,
log,
}) => {
const AgentLogDetail: FC<AgentLogDetailProps> = ({ activeTab = 'DETAIL', conversationID, messageID, log }) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [currentTab, setCurrentTab] = useState<string>(activeTab)
const appDetail = useAppStore(s => s.appDetail)
const [loading, setLoading] = useState<boolean>(true)
const [runDetail, setRunDetail] = useState<AgentLogDetailResponse>()
const [list, setList] = useState<AgentIteration[]>([])
const tools = useMemo(() => {
const res = uniq(flatten(runDetail?.iterations.map((iteration) => {
return iteration.tool_calls.map((tool: any) => tool.tool_name).filter(Boolean)
})).filter(Boolean))
return res
}, [runDetail])
const getLogDetail = useCallback(async (appID: string, conversationID: string, messageID: string) => {
try {
const res = await fetchAgentLogDetail({
@ -57,51 +47,30 @@ const AgentLogDetail: FC<AgentLogDetailProps> = ({
setList(res.iterations)
}
catch (err) {
notify({
type: 'error',
message: `${err}`,
})
toast.error(`${err}`)
}
}, [notify])
}, [])
const getData = async (appID: string, conversationID: string, messageID: string) => {
setLoading(true)
await getLogDetail(appID, conversationID, messageID)
setLoading(false)
}
const switchTab = async (tab: string) => {
setCurrentTab(tab)
}
useEffect(() => {
// fetch data
if (appDetail)
getData(appDetail.id, conversationID, messageID)
}, [appDetail, conversationID, messageID])
return (
<div className="relative flex grow flex-col">
{/* tab */}
<div className="flex shrink-0 items-center border-b-[0.5px] border-divider-regular px-4">
<div
className={cn(
'mr-6 cursor-pointer border-b-2 border-transparent py-3 text-[13px] font-semibold leading-[18px] text-text-tertiary',
currentTab === 'DETAIL' && '!border-[rgb(21,94,239)] text-text-secondary',
)}
data-active={currentTab === 'DETAIL'}
onClick={() => switchTab('DETAIL')}
>
<div className={cn('mr-6 cursor-pointer border-b-2 border-transparent py-3 text-[13px] font-semibold leading-[18px] text-text-tertiary', currentTab === 'DETAIL' && '!border-[rgb(21,94,239)] text-text-secondary')} data-active={currentTab === 'DETAIL'} onClick={() => switchTab('DETAIL')}>
{t('detail', { ns: 'runLog' })}
</div>
<div
className={cn(
'mr-6 cursor-pointer border-b-2 border-transparent py-3 text-[13px] font-semibold leading-[18px] text-text-tertiary',
currentTab === 'TRACING' && '!border-[rgb(21,94,239)] text-text-secondary',
)}
data-active={currentTab === 'TRACING'}
onClick={() => switchTab('TRACING')}
>
<div className={cn('mr-6 cursor-pointer border-b-2 border-transparent py-3 text-[13px] font-semibold leading-[18px] text-text-tertiary', currentTab === 'TRACING' && '!border-[rgb(21,94,239)] text-text-secondary')} data-active={currentTab === 'TRACING'} onClick={() => switchTab('TRACING')}>
{t('tracing', { ns: 'runLog' })}
</div>
</div>
@ -112,29 +81,10 @@ const AgentLogDetail: FC<AgentLogDetailProps> = ({
<Loading />
</div>
)}
{!loading && currentTab === 'DETAIL' && runDetail && (
<ResultPanel
inputs={log.input}
outputs={log.content}
status={runDetail.meta.status}
error={runDetail.meta.error}
elapsed_time={runDetail.meta.elapsed_time}
total_tokens={runDetail.meta.total_tokens}
created_at={runDetail.meta.start_time}
created_by={runDetail.meta.executor}
agentMode={runDetail.meta.agent_mode}
tools={tools}
iterations={runDetail.iterations.length}
/>
)}
{!loading && currentTab === 'TRACING' && (
<TracingPanel
list={list}
/>
)}
{!loading && currentTab === 'DETAIL' && runDetail && (<ResultPanel inputs={log.input} outputs={log.content} status={runDetail.meta.status} error={runDetail.meta.error} elapsed_time={runDetail.meta.elapsed_time} total_tokens={runDetail.meta.total_tokens} created_at={runDetail.meta.start_time} created_by={runDetail.meta.executor} agentMode={runDetail.meta.agent_mode} tools={tools} iterations={runDetail.iterations.length} />)}
{!loading && currentTab === 'TRACING' && (<TracingPanel list={list} />)}
</div>
</div>
)
}
export default AgentLogDetail

View File

@ -3,7 +3,7 @@ import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { AgentLogDetailResponse } from '@/models/log'
import { useEffect, useRef } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastProvider } from '@/app/components/base/toast'
import { ToastHost } from '@/app/components/base/ui/toast'
import AgentLogModal from '.'
const MOCK_RESPONSE: AgentLogDetailResponse = {
@ -109,7 +109,8 @@ const AgentLogModalDemo = ({
}, [setAppDetail])
return (
<ToastProvider>
<>
<ToastHost />
<div className="relative min-h-[540px] w-full bg-background-default-subtle p-6">
<AgentLogModal
currentLogItem={MOCK_CHAT_ITEM}
@ -119,7 +120,7 @@ const AgentLogModalDemo = ({
}}
/>
</div>
</ToastProvider>
</>
)
}

View File

@ -6,7 +6,7 @@ import AudioPlayer from '../audio'
const mockToastNotify = vi.hoisted(() => vi.fn())
const mockTextToAudioStream = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/toast', () => ({
vi.mock('@/app/components/base/ui/toast', () => ({
default: {
notify: (...args: unknown[]) => mockToastNotify(...args),
},

View File

@ -1,4 +1,4 @@
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { AppSourceType, textToAudioStream } from '@/service/share'
declare global {
@ -7,7 +7,6 @@ declare global {
ManagedMediaSource: any
}
}
export default class AudioPlayer {
mediaSource: MediaSource | null
audio: HTMLAudioElement
@ -22,7 +21,6 @@ export default class AudioPlayer {
url: string
isPublic: boolean
callback: ((event: string) => void) | null
constructor(streamUrl: string, isPublic: boolean, msgId: string | undefined, msgContent: string | null | undefined, voice: string | undefined, callback: ((event: string) => void) | null) {
this.audioContext = new AudioContext()
this.msgId = msgId
@ -31,14 +29,10 @@ export default class AudioPlayer {
this.isPublic = isPublic
this.voice = voice
this.callback = callback
// Compatible with iphone ios17 ManagedMediaSource
const MediaSource = window.ManagedMediaSource || window.MediaSource
if (!MediaSource) {
Toast.notify({
message: 'Your browser does not support audio streaming, if you are using an iPhone, please update to iOS 17.1 or later.',
type: 'error',
})
toast.error('Your browser does not support audio streaming, if you are using an iPhone, please update to iOS 17.1 or later.')
}
this.mediaSource = MediaSource ? new MediaSource() : null
this.audio = new Audio()
@ -49,7 +43,6 @@ export default class AudioPlayer {
}
this.audio.src = this.mediaSource ? URL.createObjectURL(this.mediaSource) : ''
this.audio.autoplay = true
const source = this.audioContext.createMediaElementSource(this.audio)
source.connect(this.audioContext.destination)
this.listenMediaSource('audio/mpeg')
@ -63,7 +56,6 @@ export default class AudioPlayer {
this.mediaSource?.addEventListener('sourceopen', () => {
if (this.sourceBuffer)
return
this.sourceBuffer = this.mediaSource?.addSourceBuffer(contentType)
})
}
@ -106,22 +98,18 @@ export default class AudioPlayer {
voice: this.voice,
text: this.msgContent,
})
if (audioResponse.status !== 200) {
this.isLoadData = false
if (this.callback)
this.callback('error')
}
const reader = audioResponse.body.getReader()
while (true) {
const { value, done } = await reader.read()
if (done) {
this.receiveAudioData(value)
break
}
this.receiveAudioData(value)
}
}
@ -167,7 +155,6 @@ export default class AudioPlayer {
this.theEndOfStream()
clearInterval(timer)
}
if (this.cacheBuffers.length && !this.sourceBuffer?.updating) {
const arrayBuffer = this.cacheBuffers.shift()!
this.sourceBuffer?.appendBuffer(arrayBuffer)
@ -180,7 +167,6 @@ export default class AudioPlayer {
this.finishStream()
return
}
const audioContent = Buffer.from(audio, 'base64')
this.receiveAudioData(new Uint8Array(audioContent))
if (play) {
@ -196,7 +182,6 @@ export default class AudioPlayer {
this.callback?.('play')
}
else if (this.audio.played) { /* empty */ }
else {
this.audio.play()
this.callback?.('play')
@ -221,7 +206,6 @@ export default class AudioPlayer {
this.finishStream()
return
}
if (this.sourceBuffer?.updating) {
this.cacheBuffers.push(audioData)
}

View File

@ -1,7 +1,7 @@
import { t } from 'i18next'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { cn } from '@/utils/classnames'
@ -10,7 +10,6 @@ type AudioPlayerProps = {
src?: string // Keep backward compatibility
srcs?: string[] // Support multiple sources
}
const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
@ -23,43 +22,34 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
const [hoverTime, setHoverTime] = useState(0)
const [isAudioAvailable, setIsAudioAvailable] = useState(true)
const { theme } = useTheme()
useEffect(() => {
const audio = audioRef.current
/* v8 ignore next 2 - @preserve */
if (!audio)
return
const handleError = () => {
setIsAudioAvailable(false)
}
const setAudioData = () => {
setDuration(audio.duration)
}
const setAudioTime = () => {
setCurrentTime(audio.currentTime)
}
const handleProgress = () => {
if (audio.buffered.length > 0)
setBufferedTime(audio.buffered.end(audio.buffered.length - 1))
}
const handleEnded = () => {
setIsPlaying(false)
}
audio.addEventListener('loadedmetadata', setAudioData)
audio.addEventListener('timeupdate', setAudioTime)
audio.addEventListener('progress', handleProgress)
audio.addEventListener('ended', handleEnded)
audio.addEventListener('error', handleError)
// Preload audio metadata
audio.load()
// Use the first source or src to generate waveform
const primarySrc = srcs?.[0] || src
if (primarySrc) {
@ -76,17 +66,12 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
}
}
}, [src, srcs])
const generateWaveformData = async (audioSrc: string) => {
if (!window.AudioContext && !(window as any).webkitAudioContext) {
setIsAudioAvailable(false)
Toast.notify({
type: 'error',
message: 'Web Audio API is not supported in this browser',
})
toast.error('Web Audio API is not supported in this browser')
return null
}
const primarySrc = srcs?.[0] || src
const url = primarySrc ? new URL(primarySrc) : null
const isHttp = url ? (url.protocol === 'http:' || url.protocol === 'https:') : false
@ -94,53 +79,43 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
setIsAudioAvailable(false)
return null
}
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
const samples = 70
try {
const response = await fetch(audioSrc, { mode: 'cors' })
if (!response || !response.ok) {
setIsAudioAvailable(false)
return null
}
const arrayBuffer = await response.arrayBuffer()
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
const channelData = audioBuffer.getChannelData(0)
const blockSize = Math.floor(channelData.length / samples)
const waveformData: number[] = []
for (let i = 0; i < samples; i++) {
let sum = 0
for (let j = 0; j < blockSize; j++)
sum += Math.abs(channelData[i * blockSize + j])
// Apply nonlinear scaling to enhance small amplitudes
waveformData.push((sum / blockSize) * 5)
}
// Normalized waveform data
const maxAmplitude = Math.max(...waveformData)
const normalizedWaveform = waveformData.map(amp => amp / maxAmplitude)
setWaveformData(normalizedWaveform)
setIsAudioAvailable(true)
}
catch {
const waveform: number[] = []
let prevValue = Math.random()
for (let i = 0; i < samples; i++) {
const targetValue = Math.random()
const interpolatedValue = prevValue + (targetValue - prevValue) * 0.3
waveform.push(interpolatedValue)
prevValue = interpolatedValue
}
const maxAmplitude = Math.max(...waveform)
const randomWaveform = waveform.map(amp => amp / maxAmplitude)
setWaveformData(randomWaveform)
setIsAudioAvailable(true)
}
@ -148,7 +123,6 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
await audioContext.close()
}
}
const togglePlay = useCallback(() => {
const audio = audioRef.current
if (audio && isAudioAvailable) {
@ -160,99 +134,75 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
setHasStartedPlaying(true)
audio.play().catch(error => console.error('Error playing audio:', error))
}
setIsPlaying(!isPlaying)
}
else {
Toast.notify({
type: 'error',
message: 'Audio element not found',
})
toast.error('Audio element not found')
setIsAudioAvailable(false)
}
}, [isAudioAvailable, isPlaying])
const handleCanvasInteraction = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault()
const getClientX = (event: React.MouseEvent | React.TouchEvent): number => {
if ('touches' in event)
return event.touches[0].clientX
return event.clientX
}
const updateProgress = (clientX: number) => {
const canvas = canvasRef.current
const audio = audioRef.current
if (!canvas || !audio)
return
const rect = canvas.getBoundingClientRect()
const percent = Math.min(Math.max(0, clientX - rect.left), rect.width) / rect.width
const newTime = percent * duration
// Removes the buffer check, allowing drag to any location
audio.currentTime = newTime
setCurrentTime(newTime)
if (!isPlaying) {
setIsPlaying(true)
audio.play().catch((error) => {
Toast.notify({
type: 'error',
message: `Error playing audio: ${error}`,
})
toast.error(`Error playing audio: ${error}`)
setIsPlaying(false)
})
}
}
updateProgress(getClientX(e))
}, [duration, isPlaying])
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
const drawWaveform = useCallback(() => {
const canvas = canvasRef.current
/* v8 ignore next 2 - @preserve */
if (!canvas)
return
const ctx = canvas.getContext('2d')
if (!ctx)
return
const width = canvas.width
const height = canvas.height
const data = waveformData
ctx.clearRect(0, 0, width, height)
const barWidth = width / data.length
const playedWidth = (currentTime / duration) * width
const cornerRadius = 2
// Draw waveform bars
data.forEach((value, index) => {
let color
if (index * barWidth <= playedWidth)
color = theme === Theme.light ? '#296DFF' : '#84ABFF'
else if ((index * barWidth / width) * duration <= hoverTime)
color = theme === Theme.light ? 'rgba(21,90,239,.40)' : 'rgba(200, 206, 218, 0.28)'
else
color = theme === Theme.light ? 'rgba(21,90,239,.20)' : 'rgba(200, 206, 218, 0.14)'
const barHeight = value * height
const rectX = index * barWidth
const rectY = (height - barHeight) / 2
const rectWidth = barWidth * 0.5
const rectHeight = barHeight
ctx.lineWidth = 1
ctx.fillStyle = color
if (ctx.roundRect) {
@ -265,27 +215,22 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
}
})
}, [currentTime, duration, hoverTime, theme, waveformData])
useEffect(() => {
drawWaveform()
}, [drawWaveform, bufferedTime, hasStartedPlaying])
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current
const audio = audioRef.current
if (!canvas || !audio)
return
const clientX = 'touches' in e
? e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX
: e.clientX
if (clientX === undefined)
return
const rect = canvas.getBoundingClientRect()
const percent = Math.min(Math.max(0, clientX - rect.left), rect.width) / rect.width
const time = percent * duration
// Check if the hovered position is within a buffered range before updating hoverTime
for (let i = 0; i < audio.buffered.length; i++) {
if (time >= audio.buffered.start(i) && time <= audio.buffered.end(i)) {
@ -294,38 +239,20 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
}
}
}, [duration])
return (
<div className="flex h-9 min-w-[240px] max-w-[420px] items-center gap-2 rounded-[10px] border border-components-panel-border-subtle bg-components-chat-input-audio-bg-alt p-2 shadow-xs backdrop-blur-sm">
<audio ref={audioRef} src={src} preload="auto" data-testid="audio-player">
{/* If srcs array is provided, render multiple source elements */}
{srcs && srcs.map((srcUrl, index) => (
<source key={index} src={srcUrl} />
))}
{srcs && srcs.map((srcUrl, index) => (<source key={index} src={srcUrl} />))}
</audio>
<button
type="button"
data-testid="play-pause-btn"
className="inline-flex shrink-0 cursor-pointer items-center justify-center border-none text-text-accent transition-all hover:text-text-accent-secondary disabled:text-components-button-primary-bg-disabled"
onClick={togglePlay}
disabled={!isAudioAvailable}
>
<button type="button" data-testid="play-pause-btn" className="inline-flex shrink-0 cursor-pointer items-center justify-center border-none text-text-accent transition-all hover:text-text-accent-secondary disabled:text-components-button-primary-bg-disabled" onClick={togglePlay} disabled={!isAudioAvailable}>
{isPlaying
? (<div className="i-ri-pause-circle-fill h-5 w-5" />)
: (<div className="i-ri-play-large-fill h-5 w-5" />)}
</button>
<div className={cn(isAudioAvailable && 'grow')} hidden={!isAudioAvailable}>
<div className="flex h-8 items-center justify-center">
<canvas
ref={canvasRef}
data-testid="waveform-canvas"
className="relative flex h-6 w-full grow cursor-pointer items-center justify-center"
onClick={handleCanvasInteraction}
onMouseMove={handleMouseMove}
onMouseDown={handleCanvasInteraction}
onTouchMove={handleMouseMove}
onTouchStart={handleCanvasInteraction}
/>
<canvas ref={canvasRef} data-testid="waveform-canvas" className="relative flex h-6 w-full grow cursor-pointer items-center justify-center" onClick={handleCanvasInteraction} onMouseMove={handleMouseMove} onMouseDown={handleCanvasInteraction} onTouchMove={handleMouseMove} onTouchStart={handleCanvasInteraction} />
<div className="inline-flex min-w-[50px] items-center justify-center text-text-accent-secondary system-xs-medium">
<span className="rounded-[10px] px-0.5 py-1">{formatTime(duration)}</span>
</div>
@ -335,5 +262,4 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
</div>
)
}
export default AudioPlayer

View File

@ -1,6 +1,5 @@
import type { ToastHandle } from '@/app/components/base/toast'
import { act, fireEvent, render, screen } from '@testing-library/react'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import useThemeMock from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import AudioPlayer from '../AudioPlayer'
@ -263,10 +262,12 @@ describe('AudioPlayer — waveform generation', () => {
it('should show Toast when AudioContext is not available', async () => {
vi.stubGlobal('AudioContext', undefined)
const toastSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
render(<AudioPlayer src="https://example.com/audio.mp3" />)
await advanceWaveformTimer()
expect(toastSpy).toHaveBeenCalledWith('Web Audio API is not supported in this browser')
const toastFound = Array.from(document.body.querySelectorAll('div')).some(
d => d.textContent?.includes('Web Audio API is not supported in this browser'),
)
@ -529,7 +530,7 @@ describe('AudioPlayer — missing coverage', () => {
it('should keep play button disabled when source is unavailable', async () => {
vi.stubGlobal('AudioContext', buildAudioContext(300))
const toastSpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({} as unknown as ToastHandle))
const toastSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
render(<AudioPlayer src="blob:https://example.com" />)
await advanceWaveformTimer() // sets isAudioAvailable to false (invalid protocol)
@ -545,7 +546,7 @@ describe('AudioPlayer — missing coverage', () => {
})
it('should notify when toggle is invoked while audio is unavailable', async () => {
const toastSpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({} as unknown as ToastHandle))
const toastSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
render(<AudioPlayer src="https://example.com/a.mp3" />)
const audio = document.querySelector('audio') as HTMLAudioElement
await act(async () => {
@ -559,10 +560,7 @@ describe('AudioPlayer — missing coverage', () => {
props.onClick?.()
})
expect(toastSpy).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'Audio element not found',
}))
expect(toastSpy).toHaveBeenCalledWith('Audio element not found')
toastSpy.mockRestore()
})
})
@ -626,7 +624,7 @@ describe('AudioPlayer — additional branch coverage', () => {
})
it('should ignore toggle click after audio error marks source unavailable', async () => {
const toastSpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({} as unknown as ToastHandle))
const toastSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
render(<AudioPlayer src="https://example.com/a.mp3" />)
const audio = document.querySelector('audio') as HTMLAudioElement
await act(async () => {

View File

@ -1,6 +1,6 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import BlockInput, { getInputKeys } from '../index'
vi.mock('@/utils/var', () => ({
@ -14,7 +14,7 @@ vi.mock('@/utils/var', () => ({
describe('BlockInput', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(Toast, 'notify')
vi.spyOn(toast, 'error').mockReturnValue('toast-error')
cleanup()
})
@ -138,7 +138,7 @@ describe('BlockInput', () => {
fireEvent.change(textarea, { target: { value: '{{invalid}}' } })
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalled()
expect(toast.error).toHaveBeenCalled()
})
expect(onConfirm).not.toHaveBeenCalled()
})

View File

@ -1,17 +1,14 @@
'use client'
import type { ChangeEvent, FC } from 'react'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/components/base/ui/toast'
import { cn } from '@/utils/classnames'
import { checkKeys } from '@/utils/var'
import VarHighlight from '../../app/configuration/base/var-highlight'
import Toast from '../toast'
// regex to match the {{}} and replace it with a span
const regex = /\{\{([^}]+)\}\}/g
export const getInputKeys = (value: string) => {
const keys = value.match(regex)?.map((item) => {
return item.replace('{{', '').replace('}}', '')
@ -22,13 +19,11 @@ export const getInputKeys = (value: string) => {
keys.forEach((key) => {
if (keyObj[key])
return
keyObj[key] = true
res.push(key)
})
return res
}
export type IBlockInputProps = {
value: string
className?: string // wrapper class
@ -36,20 +31,13 @@ export type IBlockInputProps = {
readonly?: boolean
onConfirm?: (value: string, keys: string[]) => void
}
const BlockInput: FC<IBlockInputProps> = ({
value = '',
className,
readonly = false,
onConfirm,
}) => {
const BlockInput: FC<IBlockInputProps> = ({ value = '', className, readonly = false, onConfirm }) => {
const { t } = useTranslation()
// current is used to store the current value of the contentEditable element
const [currentValue, setCurrentValue] = useState<string>(value)
useEffect(() => {
setCurrentValue(value)
}, [value])
const contentEditableRef = useRef<HTMLTextAreaElement>(null)
const [isEditing, setIsEditing] = useState<boolean>(false)
useEffect(() => {
@ -57,57 +45,42 @@ const BlockInput: FC<IBlockInputProps> = ({
// TODO: Focus at the click position
if (currentValue)
contentEditableRef.current.setSelectionRange(currentValue.length, currentValue.length)
contentEditableRef.current.focus()
}
}, [isEditing])
const style = cn({
'block h-full w-full break-all border-0 px-4 py-2 text-sm text-gray-900 outline-0': true,
'block-input--editing': isEditing,
})
const renderSafeContent = (value: string) => {
const parts = value.split(/(\{\{[^}]+\}\}|\n)/g)
return parts.map((part, index) => {
const variableMatch = /^\{\{([^}]+)\}\}$/.exec(part)
if (variableMatch) {
return (
<VarHighlight
key={`var-${index}`}
name={variableMatch[1]}
/>
)
return (<VarHighlight key={`var-${index}`} name={variableMatch[1]} />)
}
if (part === '\n')
return <br key={`br-${index}`} />
return <span key={`text-${index}`}>{part}</span>
})
}
// Not use useCallback. That will cause out callback get old data.
const handleSubmit = (value: string) => {
if (onConfirm) {
const keys = getInputKeys(value)
const result = checkKeys(keys)
if (!result.isValid) {
Toast.notify({
type: 'error',
message: t(`varKeyError.${result.errorMessageKey}`, { ns: 'appDebug', key: result.errorKey }),
})
toast.error(t(`varKeyError.${result.errorMessageKey}`, { ns: 'appDebug', key: result.errorKey }))
return
}
onConfirm(value, keys)
}
}
const onValueChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
setCurrentValue(value)
handleSubmit(value)
}, [])
// Prevent rerendering caused cursor to jump to the start of the contentEditable element
const TextAreaContentView = () => {
return (
@ -116,10 +89,8 @@ const BlockInput: FC<IBlockInputProps> = ({
</div>
)
}
const placeholder = ''
const editAreaClassName = 'focus:outline-none bg-transparent text-sm'
const textAreaContent = (
<div className={cn(readonly ? 'max-h-[180px] pb-5' : 'h-[180px]', 'overflow-y-auto')} onClick={() => !readonly && setIsEditing(true)}>
{isEditing
@ -134,10 +105,10 @@ const BlockInput: FC<IBlockInputProps> = ({
onBlur={() => {
blur()
setIsEditing(false)
// click confirm also make blur. Then outer value is change. So below code has problem.
// setTimeout(() => {
// handleCancel()
// }, 1000)
// click confirm also make blur. Then outer value is change. So below code has problem.
// setTimeout(() => {
// handleCancel()
// }, 1000)
}}
/>
</div>
@ -145,7 +116,6 @@ const BlockInput: FC<IBlockInputProps> = ({
: <TextAreaContentView />}
</div>
)
return (
<div className={cn('block-input w-full overflow-y-auto rounded-xl border-none bg-white')} data-testid="block-input">
{textAreaContent}
@ -159,5 +129,4 @@ const BlockInput: FC<IBlockInputProps> = ({
</div>
)
}
export default React.memo(BlockInput)

View File

@ -4,7 +4,7 @@ import type { InstalledApp } from '@/models/explore'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { ToastProvider } from '@/app/components/base/toast'
import { ToastHost } from '@/app/components/base/ui/toast'
import {
AppSourceType,
delConversation,
@ -95,7 +95,8 @@ const createQueryClient = () => new QueryClient({
const createWrapper = (queryClient: QueryClient) => {
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<ToastProvider>{children}</ToastProvider>
<ToastHost />
{children}
</QueryClientProvider>
)
}

View File

@ -1,47 +1,21 @@
import type { ExtraContent } from '../chat/type'
import type {
Callback,
ChatConfig,
ChatItem,
Feedback,
} from '../types'
import type { Callback, ChatConfig, ChatItem, Feedback } from '../types'
import type { InstalledApp } from '@/models/explore'
import type {
AppData,
ConversationItem,
} from '@/models/share'
import type { AppData, ConversationItem } from '@/models/share'
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
import { useLocalStorageState } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import { useToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import { InputVarType } from '@/app/components/workflow/types'
import { useWebAppStore } from '@/context/web-app-context'
import { useAppFavicon } from '@/hooks/use-app-favicon'
import { changeLanguage } from '@/i18n-config/client'
import {
AppSourceType,
delConversation,
pinConversation,
renameConversation,
unpinConversation,
updateFeedback,
} from '@/service/share'
import {
useInvalidateShareConversations,
useShareChatList,
useShareConversationName,
useShareConversations,
} from '@/service/use-share'
import { AppSourceType, delConversation, pinConversation, renameConversation, unpinConversation, updateFeedback } from '@/service/share'
import { useInvalidateShareConversations, useShareChatList, useShareConversationName, useShareConversations } from '@/service/use-share'
import { TransferMethod } from '@/types/app'
import { addFileInfos, sortAgentSorts } from '../../../tools/utils'
import { CONVERSATION_ID_INFO } from '../constants'
@ -94,14 +68,12 @@ function getFormattedChatList(messages: any[]) {
})
return newChatList
}
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
const appInfo = useWebAppStore(s => s.appInfo)
const appParams = useWebAppStore(s => s.appParams)
const appMeta = useWebAppStore(s => s.appMeta)
useAppFavicon({
enable: !installedAppInfo,
icon_type: appInfo?.site.icon_type,
@ -109,7 +81,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
icon_background: appInfo?.site.icon_background,
icon_url: appInfo?.site.icon_url,
})
const appData = useMemo(() => {
if (isInstalledApp) {
const { id, app } = installedAppInfo!
@ -130,18 +101,15 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
custom_config: null,
} as AppData
}
return appInfo
}, [isInstalledApp, installedAppInfo, appInfo])
const appId = useMemo(() => appData?.app_id, [appData])
const [userId, setUserId] = useState<string>()
useEffect(() => {
getProcessedSystemVariablesFromUrlParams().then(({ user_id }) => {
setUserId(user_id)
})
}, [])
useEffect(() => {
const setLocaleFromProps = async () => {
if (appData?.site.default_language)
@ -149,7 +117,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}
setLocaleFromProps()
}, [appData])
const [sidebarCollapseState, setSidebarCollapseState] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
try {
@ -193,15 +160,12 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
})
}
}, [appId, conversationIdInfo, setConversationIdInfo, userId])
const [newConversationId, setNewConversationId] = useState('')
const chatShouldReloadKey = useMemo(() => {
if (currentConversationId === newConversationId)
return ''
return currentConversationId
}, [currentConversationId, newConversationId])
const { data: appPinnedConversationData } = useShareConversations({
appSourceType,
appId,
@ -212,10 +176,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const {
data: appConversationData,
isLoading: appConversationDataLoading,
} = useShareConversations({
const { data: appConversationData, isLoading: appConversationDataLoading } = useShareConversations({
appSourceType,
appId,
pinned: false,
@ -225,10 +186,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const {
data: appChatListData,
isLoading: appChatListDataLoading,
} = useShareChatList({
const { data: appChatListData, isLoading: appChatListDataLoading } = useShareChatList({
conversationId: chatShouldReloadKey,
appSourceType,
appId,
@ -238,18 +196,12 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
refetchOnReconnect: false,
})
const invalidateShareConversations = useInvalidateShareConversations()
const [clearChatList, setClearChatList] = useState(false)
const [isResponding, setIsResponding] = useState(false)
const appPrevChatTree = useMemo(
() => (currentConversationId && appChatListData?.data.length)
? buildChatItemTree(getFormattedChatList(appChatListData.data))
: [],
[appChatListData, currentConversationId],
)
const appPrevChatTree = useMemo(() => (currentConversationId && appChatListData?.data.length)
? buildChatItemTree(getFormattedChatList(appChatListData.data))
: [], [appChatListData, currentConversationId])
const [showNewConversationItemInList, setShowNewConversationItemInList] = useState(false)
const pinnedConversationList = useMemo(() => {
return appPinnedConversationData?.data || []
}, [appPinnedConversationData])
@ -268,7 +220,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
let value = initInputs[item.paragraph.variable]
if (value && item.paragraph.max_length && value.length > item.paragraph.max_length)
value = value.slice(0, item.paragraph.max_length)
return {
...item.paragraph,
default: value || item.default || item.paragraph.default,
@ -283,7 +234,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
type: 'number',
}
}
if (item.checkbox) {
const preset = initInputs[item.checkbox.variable] === true
return {
@ -292,7 +242,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
type: 'checkbox',
}
}
if (item.select) {
const isInputInOptions = item.select.options.includes(initInputs[item.select.variable])
return {
@ -301,32 +250,27 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
type: 'select',
}
}
if (item['file-list']) {
return {
...item['file-list'],
type: 'file-list',
}
}
if (item.file) {
return {
...item.file,
type: 'file',
}
}
if (item.json_object) {
return {
...item.json_object,
type: 'json_object',
}
}
let value = initInputs[item['text-input'].variable]
if (value && item['text-input'].max_length && value.length > item['text-input'].max_length)
value = value.slice(0, item['text-input'].max_length)
return {
...item['text-input'],
default: value || item.default || item['text-input'].default,
@ -334,11 +278,9 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}
})
}, [initInputs, appParams])
const allInputsHidden = useMemo(() => {
return inputsForms.length > 0 && inputsForms.every(item => item.hide === true)
}, [inputsForms])
useEffect(() => {
// init inputs from url params
(async () => {
@ -348,16 +290,13 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
setInitUserVariables(userVariables)
})()
}, [])
useEffect(() => {
const conversationInputs: Record<string, any> = {}
inputsForms.forEach((item: any) => {
conversationInputs[item.variable] = item.default || null
})
handleNewConversationInputsChange(conversationInputs)
}, [handleNewConversationInputsChange, inputsForms])
const { data: newConversation } = useShareConversationName({
conversationId: newConversationId,
appSourceType,
@ -373,7 +312,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}, [appConversationData, appConversationDataLoading])
const conversationList = useMemo(() => {
const data = originConversationList.slice()
if (showNewConversationItemInList && data[0]?.id !== '') {
data.unshift({
id: '',
@ -384,12 +322,10 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}
return data
}, [originConversationList, showNewConversationItemInList, t])
useEffect(() => {
if (newConversation) {
setOriginConversationList(produce((draft) => {
const index = draft.findIndex(item => item.id === newConversation.id)
if (index > -1)
draft[index] = newConversation
else
@ -397,16 +333,12 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}))
}
}, [newConversation])
const currentConversationItem = useMemo(() => {
let conversationItem = conversationList.find(item => item.id === currentConversationId)
if (!conversationItem && pinnedConversationList.length)
conversationItem = pinnedConversationList.find(item => item.id === currentConversationId)
return conversationItem
}, [conversationList, currentConversationId, pinnedConversationList])
const currentConversationLatestInputs = useMemo(() => {
if (!currentConversationId || !appChatListData?.data.length)
return newConversationInputsRef.current || {}
@ -417,12 +349,9 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
if (currentConversationItem)
setCurrentConversationInputs(currentConversationLatestInputs || {})
}, [currentConversationItem, currentConversationLatestInputs])
const { notify } = useToastContext()
const checkInputsRequired = useCallback((silent?: boolean) => {
if (allInputsHidden)
return true
let hasEmptyInput = ''
let fileIsUploading = false
const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox)
@ -430,13 +359,10 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
requiredVars.forEach(({ variable, label, type }) => {
if (hasEmptyInput)
return
if (fileIsUploading)
return
if (!newConversationInputsRef.current[variable] && !silent)
hasEmptyInput = label as string
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && newConversationInputsRef.current[variable] && !silent) {
const files = newConversationInputsRef.current[variable]
if (Array.isArray(files))
@ -446,26 +372,25 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}
})
}
if (hasEmptyInput) {
notify({ type: 'error', message: t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }) })
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }))
return false
}
if (fileIsUploading) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
toast.info(t('errorMessage.waitForFileUpload', { ns: 'appDebug' }))
return
}
return true
}, [inputsForms, notify, t, allInputsHidden])
}, [inputsForms, t, allInputsHidden])
const handleStartChat = useCallback((callback: any) => {
if (checkInputsRequired()) {
setShowNewConversationItemInList(true)
callback?.()
}
}, [setShowNewConversationItemInList, checkInputsRequired])
const currentChatInstanceRef = useRef<{ handleStop: () => void }>({ handleStop: noop })
const currentChatInstanceRef = useRef<{
handleStop: () => void
}>({ handleStop: noop })
const handleChangeConversation = useCallback((conversationId: string) => {
currentChatInstanceRef.current.handleStop()
setNewConversationId('')
@ -487,76 +412,48 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const handleUpdateConversationList = useCallback(() => {
invalidateShareConversations()
}, [invalidateShareConversations])
const handlePinConversation = useCallback(async (conversationId: string) => {
await pinConversation(appSourceType, appId, conversationId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
toast.success(t('api.success', { ns: 'common' }))
handleUpdateConversationList()
}, [appSourceType, appId, notify, t, handleUpdateConversationList])
}, [appSourceType, appId, t, handleUpdateConversationList])
const handleUnpinConversation = useCallback(async (conversationId: string) => {
await unpinConversation(appSourceType, appId, conversationId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
toast.success(t('api.success', { ns: 'common' }))
handleUpdateConversationList()
}, [appSourceType, appId, notify, t, handleUpdateConversationList])
}, [appSourceType, appId, t, handleUpdateConversationList])
const [conversationDeleting, setConversationDeleting] = useState(false)
const handleDeleteConversation = useCallback(async (
conversationId: string,
{
onSuccess,
}: Callback,
) => {
const handleDeleteConversation = useCallback(async (conversationId: string, { onSuccess }: Callback) => {
if (conversationDeleting)
return
try {
setConversationDeleting(true)
await delConversation(appSourceType, appId, conversationId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
toast.success(t('api.success', { ns: 'common' }))
onSuccess()
}
finally {
setConversationDeleting(false)
}
if (conversationId === currentConversationId)
handleNewConversation()
handleUpdateConversationList()
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList, handleNewConversation, currentConversationId, conversationDeleting])
}, [isInstalledApp, appId, t, handleUpdateConversationList, handleNewConversation, currentConversationId, conversationDeleting])
const [conversationRenaming, setConversationRenaming] = useState(false)
const handleRenameConversation = useCallback(async (
conversationId: string,
newName: string,
{
onSuccess,
}: Callback,
) => {
const handleRenameConversation = useCallback(async (conversationId: string, newName: string, { onSuccess }: Callback) => {
if (conversationRenaming)
return
if (!newName.trim()) {
notify({
type: 'error',
message: t('chat.conversationNameCanNotEmpty', { ns: 'common' }),
})
toast.error(t('chat.conversationNameCanNotEmpty', { ns: 'common' }))
return
}
setConversationRenaming(true)
try {
await renameConversation(appSourceType, appId, conversationId, newName)
notify({
type: 'success',
message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }),
})
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
setOriginConversationList(produce((draft) => {
const index = originConversationList.findIndex(item => item.id === conversationId)
const item = draft[index]
draft[index] = {
...item,
name: newName,
@ -567,20 +464,17 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
finally {
setConversationRenaming(false)
}
}, [isInstalledApp, appId, notify, t, conversationRenaming, originConversationList])
}, [isInstalledApp, appId, t, conversationRenaming, originConversationList])
const handleNewConversationCompleted = useCallback((newConversationId: string) => {
setNewConversationId(newConversationId)
handleConversationIdInfoChange(newConversationId)
setShowNewConversationItemInList(false)
invalidateShareConversations()
}, [handleConversationIdInfoChange, invalidateShareConversations])
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, appSourceType, appId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
}, [appSourceType, appId, t, notify])
toast.success(t('api.success', { ns: 'common' }))
}, [appSourceType, appId, t])
return {
isInstalledApp,
appId,

View File

@ -5,8 +5,8 @@ import { TransferMethod } from '@/types/app'
import { useCheckInputsForms } from '../check-input-forms-hooks'
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: mockNotify }),
vi.mock('@/app/components/base/ui/toast', () => ({
}))
describe('useCheckInputsForms', () => {

View File

@ -20,8 +20,8 @@ vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
},
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: vi.fn() }),
vi.mock('@/app/components/base/ui/toast', () => ({
}))
vi.mock('@/hooks/use-timestamp', () => ({

View File

@ -5,7 +5,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
import * as React from 'react'
import Toast from '../../../toast'
import { toast } from '@/app/components/base/ui/toast'
import { ThemeBuilder } from '../../embedded-chatbot/theme/theme-context'
import { ChatContextProvider } from '../context-provider'
import Question from '../question'
@ -179,7 +179,7 @@ describe('Question component', () => {
it('should call copy-to-clipboard and show a toast when copy action is clicked', async () => {
const user = userEvent.setup()
const toastSpy = vi.spyOn(Toast, 'notify')
const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success')
renderWithProvider(makeItem())

View File

@ -29,7 +29,7 @@ const {
vi.mock('copy-to-clipboard', () => ({ default: vi.fn() }))
vi.mock('@/app/components/base/toast', () => ({
vi.mock('@/app/components/base/ui/toast', () => ({
default: { notify: vi.fn() },
}))

View File

@ -1,14 +1,7 @@
import type { FC } from 'react'
import type {
ChatItem,
Feedback,
} from '../../types'
import type { ChatItem, Feedback } from '../../types'
import copy from 'copy-to-clipboard'
import {
memo,
useMemo,
useState,
} from 'react'
import { memo, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import EditReplyModal from '@/app/components/app/annotation/edit-annotation-modal'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
@ -17,8 +10,8 @@ import AnnotationCtrlButton from '@/app/components/base/features/new-feature-pan
import Modal from '@/app/components/base/modal/modal'
import NewAudioButton from '@/app/components/base/new-audio-button'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import { toast } from '@/app/components/base/ui/toast'
import { cn } from '@/utils/classnames'
import { useChatContext } from '../context'
@ -32,14 +25,11 @@ type OperationProps = {
hasWorkflowProcess: boolean
noChatInput?: boolean
}
const stringifyCopyValue = (value: unknown) => {
if (typeof value === 'string')
return value
if (value === null || typeof value === 'undefined')
return ''
try {
return JSON.stringify(value, null, 2)
}
@ -47,196 +37,132 @@ const stringifyCopyValue = (value: unknown) => {
return String(value)
}
}
const buildCopyContentFromLLMGenerationItems = (llmGenerationItems?: ChatItem['llmGenerationItems']) => {
if (!llmGenerationItems?.length)
return ''
const hasStructuredItems = llmGenerationItems.some(item => item.type !== 'text')
if (!hasStructuredItems)
return ''
return llmGenerationItems
.map((item) => {
if (item.type === 'text')
return item.text || ''
if (item.type === 'thought')
return item.thoughtOutput ? `[THOUGHT]\n${item.thoughtOutput}` : ''
if (item.type === 'tool') {
const sections = [
`[TOOL] ${item.toolName || ''}`.trim(),
]
if (item.toolArguments)
sections.push(`INPUT:\n${stringifyCopyValue(item.toolArguments)}`)
if (typeof item.toolOutput !== 'undefined')
sections.push(`OUTPUT:\n${stringifyCopyValue(item.toolOutput)}`)
if (item.toolError)
sections.push(`ERROR:\n${item.toolError}`)
return sections.join('\n')
}
if (item.type === 'model') {
const sections = [
`[MODEL] ${item.modelName || ''}`.trim(),
]
if (typeof item.modelOutput !== 'undefined')
sections.push(`OUTPUT:\n${stringifyCopyValue(item.modelOutput)}`)
return sections.join('\n')
}
return ''
})
.filter(Boolean)
.join('\n\n')
}
const buildCopyContentFromAgentThoughts = (agentThoughts?: ChatItem['agent_thoughts']) => {
if (!agentThoughts?.length)
return ''
return agentThoughts
.map((thought) => {
const sections = [
`[AGENT] ${thought.tool || ''}`.trim(),
]
if (thought.thought)
sections.push(`THOUGHT:\n${thought.thought}`)
if (thought.tool_input)
sections.push(`INPUT:\n${thought.tool_input}`)
if (thought.observation)
sections.push(`OUTPUT:\n${thought.observation}`)
return sections.join('\n')
})
.join('\n\n')
}
const Operation: FC<OperationProps> = ({
item,
question,
index,
showPromptLog,
maxSize,
contentWidth,
hasWorkflowProcess,
noChatInput,
}) => {
const Operation: FC<OperationProps> = ({ item, question, index, showPromptLog, maxSize, contentWidth, hasWorkflowProcess, noChatInput }) => {
const { t } = useTranslation()
const {
config,
onAnnotationAdded,
onAnnotationEdited,
onAnnotationRemoved,
onFeedback,
onRegenerate,
} = useChatContext()
const { config, onAnnotationAdded, onAnnotationEdited, onAnnotationRemoved, onFeedback, onRegenerate } = useChatContext()
const [isShowReplyModal, setIsShowReplyModal] = useState(false)
const [isShowFeedbackModal, setIsShowFeedbackModal] = useState(false)
const [feedbackContent, setFeedbackContent] = useState('')
const {
id,
isOpeningStatement,
content: messageContent,
annotation,
feedback,
adminFeedback,
agent_thoughts,
humanInputFormDataList,
} = item
const { id, isOpeningStatement, content: messageContent, annotation, feedback, adminFeedback, agent_thoughts, humanInputFormDataList } = item
const [userLocalFeedback, setUserLocalFeedback] = useState(feedback)
const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback)
const [feedbackTarget, setFeedbackTarget] = useState<'user' | 'admin'>('user')
// Separate feedback types for display
const userFeedback = feedback
const content = useMemo(() => {
if (agent_thoughts?.length)
return agent_thoughts.reduce((acc, cur) => acc + cur.thought, '')
return messageContent
}, [agent_thoughts, messageContent])
const copyContent = useMemo(() => {
const llmGenerationCopyContent = buildCopyContentFromLLMGenerationItems(item.llmGenerationItems)
if (llmGenerationCopyContent)
return llmGenerationCopyContent
const agentThoughtCopyContent = buildCopyContentFromAgentThoughts(agent_thoughts)
if (agentThoughtCopyContent)
return agentThoughtCopyContent
return messageContent
}, [item.llmGenerationItems, agent_thoughts, messageContent])
const displayUserFeedback = userLocalFeedback ?? userFeedback
const hasUserFeedback = !!displayUserFeedback?.rating
const hasAdminFeedback = !!adminLocalFeedback?.rating
const shouldShowUserFeedbackBar = !isOpeningStatement && config?.supportFeedback && !!onFeedback && !config?.supportAnnotation
const shouldShowAdminFeedbackBar = !isOpeningStatement && config?.supportFeedback && !!onFeedback && !!config?.supportAnnotation
const userFeedbackLabel = t('table.header.userRate', { ns: 'appLog' }) || 'User feedback'
const adminFeedbackLabel = t('table.header.adminRate', { ns: 'appLog' }) || 'Admin feedback'
const feedbackTooltipClassName = 'max-w-[260px]'
const buildFeedbackTooltip = (feedbackData?: Feedback | null, label = userFeedbackLabel) => {
if (!feedbackData?.rating)
return label
const ratingLabel = feedbackData.rating === 'like'
? (t('detail.operation.like', { ns: 'appLog' }) || 'like')
: (t('detail.operation.dislike', { ns: 'appLog' }) || 'dislike')
const feedbackText = feedbackData.content?.trim()
if (feedbackText)
return `${label}: ${ratingLabel} - ${feedbackText}`
return `${label}: ${ratingLabel}`
}
const handleFeedback = async (rating: 'like' | 'dislike' | null, content?: string, target: 'user' | 'admin' = 'user') => {
if (!config?.supportFeedback || !onFeedback)
return
await onFeedback?.(id, { rating, content })
const nextFeedback = rating === null ? { rating: null } : { rating, content }
if (target === 'admin')
setAdminLocalFeedback(nextFeedback)
else
setUserLocalFeedback(nextFeedback)
}
const handleLikeClick = (target: 'user' | 'admin') => {
handleFeedback('like', undefined, target)
}
const handleDislikeClick = (target: 'user' | 'admin') => {
setFeedbackTarget(target)
setIsShowFeedbackModal(true)
}
const handleFeedbackSubmit = async () => {
await handleFeedback('dislike', feedbackContent, feedbackTarget)
setFeedbackContent('')
setIsShowFeedbackModal(false)
}
const handleFeedbackCancel = () => {
setFeedbackContent('')
setIsShowFeedbackModal(false)
}
const operationWidth = useMemo(() => {
let width = 0
if (!isOpeningStatement)
@ -251,40 +177,18 @@ const Operation: FC<OperationProps> = ({
width += hasUserFeedback ? 28 + 8 : 60 + 8
if (shouldShowAdminFeedbackBar)
width += (hasAdminFeedback ? 28 : 60) + 8 + (hasUserFeedback ? 28 : 0)
return width
}, [config?.annotation_reply?.enabled, config?.supportAnnotation, config?.text_to_speech?.enabled, hasAdminFeedback, hasUserFeedback, isOpeningStatement, shouldShowAdminFeedbackBar, shouldShowUserFeedbackBar, showPromptLog])
const positionRight = useMemo(() => operationWidth < maxSize, [operationWidth, maxSize])
return (
<>
<div
className={cn(
'absolute flex justify-end gap-1',
hasWorkflowProcess && '-bottom-4 right-2',
!positionRight && '-bottom-4 right-2',
!hasWorkflowProcess && positionRight && '!top-[9px]',
)}
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
data-testid="operation-bar"
>
<div className={cn('absolute flex justify-end gap-1', hasWorkflowProcess && '-bottom-4 right-2', !positionRight && '-bottom-4 right-2', !hasWorkflowProcess && positionRight && '!top-[9px]')} style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}} data-testid="operation-bar">
{shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
<div className={cn(
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
hasUserFeedback ? 'flex' : 'hidden group-hover:flex',
)}
>
<div className={cn('ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm', hasUserFeedback ? 'flex' : 'hidden group-hover:flex')}>
{hasUserFeedback
? (
<Tooltip
popupContent={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
>
<ActionButton
state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Destructive}
onClick={() => handleFeedback(null, undefined, 'user')}
>
<Tooltip popupContent={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)} popupClassName={feedbackTooltipClassName}>
<ActionButton state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Destructive} onClick={() => handleFeedback(null, undefined, 'user')}>
{displayUserFeedback?.rating === 'like'
? <div className="i-ri-thumb-up-line h-4 w-4" />
: <div className="i-ri-thumb-down-line h-4 w-4" />}
@ -293,16 +197,10 @@ const Operation: FC<OperationProps> = ({
)
: (
<>
<ActionButton
state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
onClick={() => handleLikeClick('user')}
>
<ActionButton state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default} onClick={() => handleLikeClick('user')}>
<div className="i-ri-thumb-up-line h-4 w-4" />
</ActionButton>
<ActionButton
state={displayUserFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
onClick={() => handleDislikeClick('user')}
>
<ActionButton state={displayUserFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default} onClick={() => handleDislikeClick('user')}>
<div className="i-ri-thumb-down-line h-4 w-4" />
</ActionButton>
</>
@ -310,17 +208,10 @@ const Operation: FC<OperationProps> = ({
</div>
)}
{shouldShowAdminFeedbackBar && !humanInputFormDataList?.length && (
<div className={cn(
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
(hasAdminFeedback || hasUserFeedback) ? 'flex' : 'hidden group-hover:flex',
)}
>
<div className={cn('ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm', (hasAdminFeedback || hasUserFeedback) ? 'flex' : 'hidden group-hover:flex')}>
{/* User Feedback Display */}
{displayUserFeedback?.rating && (
<Tooltip
popupContent={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
>
<Tooltip popupContent={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)} popupClassName={feedbackTooltipClassName}>
{displayUserFeedback.rating === 'like'
? (
<ActionButton state={ActionButtonState.Active}>
@ -339,14 +230,8 @@ const Operation: FC<OperationProps> = ({
{displayUserFeedback?.rating && <div className="mx-1 h-3 w-[0.5px] bg-components-actionbar-border" />}
{hasAdminFeedback
? (
<Tooltip
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
>
<ActionButton
state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Destructive}
onClick={() => handleFeedback(null, undefined, 'admin')}
>
<Tooltip popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)} popupClassName={feedbackTooltipClassName}>
<ActionButton state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Destructive} onClick={() => handleFeedback(null, undefined, 'admin')}>
{adminLocalFeedback?.rating === 'like'
? <div className="i-ri-thumb-up-line h-4 w-4" />
: <div className="i-ri-thumb-down-line h-4 w-4" />}
@ -355,25 +240,13 @@ const Operation: FC<OperationProps> = ({
)
: (
<>
<Tooltip
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
>
<ActionButton
state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
onClick={() => handleLikeClick('admin')}
>
<Tooltip popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)} popupClassName={feedbackTooltipClassName}>
<ActionButton state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default} onClick={() => handleLikeClick('admin')}>
<div className="i-ri-thumb-up-line h-4 w-4" />
</ActionButton>
</Tooltip>
<Tooltip
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
>
<ActionButton
state={adminLocalFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
onClick={() => handleDislikeClick('admin')}
>
<Tooltip popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)} popupClassName={feedbackTooltipClassName}>
<ActionButton state={adminLocalFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default} onClick={() => handleDislikeClick('admin')}>
<div className="i-ri-thumb-down-line h-4 w-4" />
</ActionButton>
</Tooltip>
@ -388,18 +261,12 @@ const Operation: FC<OperationProps> = ({
)}
{!isOpeningStatement && (
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex" data-testid="operation-actions">
{(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && (
<NewAudioButton
id={id}
value={content}
voice={config?.text_to_speech?.voice}
/>
)}
{(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && (<NewAudioButton id={id} value={content} voice={config?.text_to_speech?.voice} />)}
{!humanInputFormDataList?.length && (
<ActionButton
onClick={() => {
copy(copyContent)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
}}
data-testid="copy-btn"
>
@ -411,55 +278,19 @@ const Operation: FC<OperationProps> = ({
<div className="i-ri-reset-left-line h-4 w-4" />
</ActionButton>
)}
{config?.supportAnnotation && config.annotation_reply?.enabled && !humanInputFormDataList?.length && (
<AnnotationCtrlButton
appId={config?.appId || ''}
messageId={id}
cached={!!annotation?.id}
query={question}
answer={content}
onAdded={(id, authorName) => onAnnotationAdded?.(id, authorName, question, content, index)}
onEdit={() => setIsShowReplyModal(true)}
/>
)}
{config?.supportAnnotation && config.annotation_reply?.enabled && !humanInputFormDataList?.length && (<AnnotationCtrlButton appId={config?.appId || ''} messageId={id} cached={!!annotation?.id} query={question} answer={content} onAdded={(id, authorName) => onAnnotationAdded?.(id, authorName, question, content, index)} onEdit={() => setIsShowReplyModal(true)} />)}
</div>
)}
</div>
<EditReplyModal
isShow={isShowReplyModal}
onHide={() => setIsShowReplyModal(false)}
query={question}
answer={content}
onEdited={(editedQuery, editedAnswer) => onAnnotationEdited?.(editedQuery, editedAnswer, index)}
onAdded={(annotationId, authorName, editedQuery, editedAnswer) => onAnnotationAdded?.(annotationId, authorName, editedQuery, editedAnswer, index)}
appId={config?.appId || ''}
messageId={id}
annotationId={annotation?.id || ''}
createdAt={annotation?.created_at}
onRemove={() => onAnnotationRemoved?.(index)}
/>
<EditReplyModal isShow={isShowReplyModal} onHide={() => setIsShowReplyModal(false)} query={question} answer={content} onEdited={(editedQuery, editedAnswer) => onAnnotationEdited?.(editedQuery, editedAnswer, index)} onAdded={(annotationId, authorName, editedQuery, editedAnswer) => onAnnotationAdded?.(annotationId, authorName, editedQuery, editedAnswer, index)} appId={config?.appId || ''} messageId={id} annotationId={annotation?.id || ''} createdAt={annotation?.created_at} onRemove={() => onAnnotationRemoved?.(index)} />
{isShowFeedbackModal && (
<Modal
title={t('feedback.title', { ns: 'common' }) || 'Provide Feedback'}
subTitle={t('feedback.subtitle', { ns: 'common' }) || 'Please tell us what went wrong with this response'}
onClose={handleFeedbackCancel}
onConfirm={handleFeedbackSubmit}
onCancel={handleFeedbackCancel}
confirmButtonText={t('operation.submit', { ns: 'common' }) || 'Submit'}
cancelButtonText={t('operation.cancel', { ns: 'common' }) || 'Cancel'}
>
<Modal title={t('feedback.title', { ns: 'common' }) || 'Provide Feedback'} subTitle={t('feedback.subtitle', { ns: 'common' }) || 'Please tell us what went wrong with this response'} onClose={handleFeedbackCancel} onConfirm={handleFeedbackSubmit} onCancel={handleFeedbackCancel} confirmButtonText={t('operation.submit', { ns: 'common' }) || 'Submit'} cancelButtonText={t('operation.cancel', { ns: 'common' }) || 'Cancel'}>
<div className="space-y-3">
<div>
<label className="mb-2 block text-text-secondary system-sm-semibold">
{t('feedback.content', { ns: 'common' }) || 'Feedback Content'}
</label>
<Textarea
value={feedbackContent}
onChange={e => setFeedbackContent(e.target.value)}
placeholder={t('feedback.placeholder', { ns: 'common' }) || 'Please describe what went wrong or how we can improve...'}
rows={4}
className="w-full"
/>
<Textarea value={feedbackContent} onChange={e => setFeedbackContent(e.target.value)} placeholder={t('feedback.placeholder', { ns: 'common' }) || 'Please describe what went wrong or how we can improve...'} rows={4} className="w-full" />
</div>
</div>
</Modal>
@ -467,5 +298,4 @@ const Operation: FC<OperationProps> = ({
</>
)
}
export default memo(Operation)

View File

@ -175,8 +175,8 @@ vi.mock('@/app/components/base/features/hooks', () => ({
// ---------------------------------------------------------------------------
// Toast context
// ---------------------------------------------------------------------------
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: mockNotify, close: vi.fn() }),
vi.mock('@/app/components/base/ui/toast', () => ({
}))
// ---------------------------------------------------------------------------

View File

@ -1,28 +1,18 @@
import type { Theme } from '../../embedded-chatbot/theme/theme-context'
import type {
EnableType,
OnSend,
} from '../../types'
import type { EnableType, OnSend } from '../../types'
import type { InputForm } from '../type'
import type { FileUpload } from '@/app/components/base/features/types'
import { noop } from 'es-toolkit/function'
import { decode } from 'html-entities'
import Recorder from 'js-audio-recorder'
import {
useCallback,
useRef,
useState,
} from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from 'react-textarea-autosize'
import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar'
import { FileListInChatInput } from '@/app/components/base/file-uploader'
import { useFile } from '@/app/components/base/file-uploader/hooks'
import {
FileContextProvider,
useFileStore,
} from '@/app/components/base/file-uploader/store'
import { useToastContext } from '@/app/components/base/toast/context'
import { FileContextProvider, useFileStore } from '@/app/components/base/file-uploader/store'
import { toast } from '@/app/components/base/ui/toast'
import VoiceInput from '@/app/components/base/voice-input'
import { TransferMethod } from '@/types/app'
import { cn } from '@/utils/classnames'
@ -53,71 +43,34 @@ type ChatInputAreaProps = {
*/
sendOnEnter?: boolean
}
const ChatInputArea = ({
readonly,
botName,
showFeatureBar,
showFileUpload,
featureBarDisabled,
onFeatureBarClick,
visionConfig,
speechToTextConfig = { enabled: true },
onSend,
inputs = {},
inputsForm = [],
theme,
isResponding,
disabled,
sendOnEnter = true,
}: ChatInputAreaProps) => {
const ChatInputArea = ({ readonly, botName, showFeatureBar, showFileUpload, featureBarDisabled, onFeatureBarClick, visionConfig, speechToTextConfig = { enabled: true }, onSend, inputs = {}, inputsForm = [], theme, isResponding, disabled, sendOnEnter = true }: ChatInputAreaProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const {
wrapperRef,
textareaRef,
textValueRef,
holdSpaceRef,
handleTextareaResize,
isMultipleLine,
} = useTextAreaHeight()
const { wrapperRef, textareaRef, textValueRef, holdSpaceRef, handleTextareaResize, isMultipleLine } = useTextAreaHeight()
const [query, setQuery] = useState('')
const [showVoiceInput, setShowVoiceInput] = useState(false)
const filesStore = useFileStore()
const {
handleDragFileEnter,
handleDragFileLeave,
handleDragFileOver,
handleDropFile,
handleClipboardPasteFile,
isDragActive,
} = useFile(visionConfig!, false)
const { handleDragFileEnter, handleDragFileLeave, handleDragFileOver, handleDropFile, handleClipboardPasteFile, isDragActive } = useFile(visionConfig!, false)
const { checkInputsForm } = useCheckInputsForms()
const historyRef = useRef([''])
const [currentIndex, setCurrentIndex] = useState(-1)
const isComposingRef = useRef(false)
const handleQueryChange = useCallback(
(value: string) => {
setQuery(value)
setTimeout(handleTextareaResize, 0)
},
[handleTextareaResize],
)
const handleQueryChange = useCallback((value: string) => {
setQuery(value)
setTimeout(handleTextareaResize, 0)
}, [handleTextareaResize])
const handleSend = () => {
if (isResponding) {
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
toast.info(t('errorMessage.waitForResponse', { ns: 'appDebug' }))
return
}
if (onSend) {
const { files, setFiles } = filesStore.getState()
if (files.some(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
toast.info(t('errorMessage.waitForFileUpload', { ns: 'appDebug' }))
return
}
if (!query || !query.trim()) {
notify({ type: 'info', message: t('errorMessage.queryRequired', { ns: 'appAnnotation' }) })
toast.info(t('errorMessage.queryRequired', { ns: 'appAnnotation' }))
return
}
if (checkInputsForm(inputs, inputsForm)) {
@ -145,7 +98,6 @@ const ChatInputArea = ({
const isSendCombo = sendOnEnter
? (e.key === 'Enter' && !e.shiftKey)
: (e.key === 'Enter' && e.shiftKey)
if (isSendCombo && !e.nativeEvent.isComposing) {
// if isComposing, exit
if (isComposingRef.current)
@ -176,101 +128,36 @@ const ChatInputArea = ({
}
}
}
const handleShowVoiceInput = useCallback(() => {
(Recorder as any).getPermission().then(() => {
setShowVoiceInput(true)
}, () => {
notify({ type: 'error', message: t('voiceInput.notAllow', { ns: 'common' }) })
toast.error(t('voiceInput.notAllow', { ns: 'common' }))
})
}, [t, notify])
const operation = (
<Operation
ref={holdSpaceRef}
readonly={readonly}
fileConfig={visionConfig}
speechToTextConfig={speechToTextConfig}
onShowVoiceInput={handleShowVoiceInput}
onSend={handleSend}
theme={theme}
/>
)
}, [t])
const operation = (<Operation ref={holdSpaceRef} readonly={readonly} fileConfig={visionConfig} speechToTextConfig={speechToTextConfig} onShowVoiceInput={handleShowVoiceInput} onSend={handleSend} theme={theme} />)
return (
<>
<div
className={cn(
'relative z-10 overflow-hidden rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-[9px] shadow-md',
isDragActive && 'border border-dashed border-components-option-card-option-selected-border',
disabled && 'pointer-events-none border-components-panel-border opacity-50 shadow-none',
)}
>
<div className={cn('relative z-10 overflow-hidden rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-[9px] shadow-md', isDragActive && 'border border-dashed border-components-option-card-option-selected-border', disabled && 'pointer-events-none border-components-panel-border opacity-50 shadow-none')}>
<div className="relative max-h-[158px] overflow-y-auto overflow-x-hidden px-[9px] pt-[9px]">
<FileListInChatInput fileConfig={visionConfig!} />
<div
ref={wrapperRef}
className="flex items-center justify-between"
>
<div ref={wrapperRef} className="flex items-center justify-between">
<div className="relative flex w-full grow items-center">
<div
ref={textValueRef}
className="pointer-events-none invisible absolute h-auto w-auto whitespace-pre p-1 leading-6 body-lg-regular"
>
<div ref={textValueRef} className="pointer-events-none invisible absolute h-auto w-auto whitespace-pre p-1 leading-6 body-lg-regular">
{query}
</div>
<Textarea
ref={ref => textareaRef.current = ref as any}
className={cn(
'w-full resize-none bg-transparent p-1 leading-6 text-text-primary outline-none body-lg-regular',
)}
placeholder={decode(t(readonly ? 'chat.inputDisabledPlaceholder' : 'chat.inputPlaceholder', { ns: 'common', botName }) || '')}
autoFocus
minRows={1}
value={query}
onChange={e => handleQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onPaste={handleClipboardPasteFile}
onDragEnter={handleDragFileEnter}
onDragLeave={handleDragFileLeave}
onDragOver={handleDragFileOver}
onDrop={handleDropFile}
readOnly={readonly}
/>
<Textarea ref={ref => textareaRef.current = ref as any} className={cn('w-full resize-none bg-transparent p-1 leading-6 text-text-primary outline-none body-lg-regular')} placeholder={decode(t(readonly ? 'chat.inputDisabledPlaceholder' : 'chat.inputPlaceholder', { ns: 'common', botName }) || '')} autoFocus minRows={1} value={query} onChange={e => handleQueryChange(e.target.value)} onKeyDown={handleKeyDown} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} onPaste={handleClipboardPasteFile} onDragEnter={handleDragFileEnter} onDragLeave={handleDragFileLeave} onDragOver={handleDragFileOver} onDrop={handleDropFile} readOnly={readonly} />
</div>
{
!isMultipleLine && operation
}
{!isMultipleLine && operation}
</div>
{
showVoiceInput && (
<VoiceInput
onCancel={() => setShowVoiceInput(false)}
onConverted={text => handleQueryChange(text)}
/>
)
}
{showVoiceInput && (<VoiceInput onCancel={() => setShowVoiceInput(false)} onConverted={text => handleQueryChange(text)} />)}
</div>
{
isMultipleLine && (
<div className="px-[9px]">{operation}</div>
)
}
{isMultipleLine && (<div className="px-[9px]">{operation}</div>)}
</div>
{showFeatureBar && (
<FeatureBar
showFileUpload={showFileUpload}
disabled={featureBarDisabled}
onFeatureBarClick={readonly ? noop : onFeatureBarClick}
hideEditEntrance={readonly}
/>
)}
{showFeatureBar && (<FeatureBar showFileUpload={showFileUpload} disabled={featureBarDisabled} onFeatureBarClick={readonly ? noop : onFeatureBarClick} hideEditEntrance={readonly} />)}
</>
)
}
const ChatInputAreaWrapper = (props: ChatInputAreaProps) => {
return (
<FileContextProvider>
@ -278,5 +165,4 @@ const ChatInputAreaWrapper = (props: ChatInputAreaProps) => {
</FileContextProvider>
)
}
export default ChatInputAreaWrapper

View File

@ -1,30 +1,24 @@
import type { InputForm } from './type'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import { InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
export const useCheckInputsForms = () => {
const { t } = useTranslation()
const { notify } = useToastContext()
const checkInputsForm = useCallback((inputs: Record<string, any>, inputsForm: InputForm[]) => {
let hasEmptyInput = ''
let fileIsUploading = false
const requiredVars = inputsForm.filter(({ required, type }) => required && type !== InputVarType.checkbox) // boolean can be not checked
if (requiredVars?.length) {
requiredVars.forEach(({ variable, label, type }) => {
if (hasEmptyInput)
return
if (fileIsUploading)
return
if (!inputs[variable])
hasEmptyInput = label as string
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && inputs[variable]) {
const files = inputs[variable]
if (Array.isArray(files))
@ -34,20 +28,16 @@ export const useCheckInputsForms = () => {
}
})
}
if (hasEmptyInput) {
notify({ type: 'error', message: t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }) })
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }))
return false
}
if (fileIsUploading) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
toast.info(t('errorMessage.waitForFileUpload', { ns: 'appDebug' }))
return
}
return true
}, [notify, t])
}, [t])
return {
checkInputsForm,
}

View File

@ -24,10 +24,8 @@ vi.mock('@/hooks/use-timestamp', () => ({
}),
}))
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: vi.fn(),
}),
vi.mock('@/app/components/base/ui/toast', () => ({
}))
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({

View File

@ -29,7 +29,7 @@ import {
getProcessedFiles,
getProcessedFilesFromResponse,
} from '@/app/components/base/file-uploader/utils'
import { useToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
import useTimestamp from '@/hooks/use-timestamp'
import { useParams, usePathname } from '@/next/navigation'
@ -65,7 +65,6 @@ export const useChat = (
) => {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const { notify } = useToastContext()
const conversationIdRef = useRef('')
const hasStopRespondedRef = useRef(false)
const [isResponding, setIsResponding] = useState(false)
@ -637,7 +636,7 @@ export const useChat = (
setSuggestedQuestions([])
if (isRespondingRef.current) {
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
toast.info(t('errorMessage.waitForResponse', { ns: 'appDebug' }))
return false
}
@ -1269,7 +1268,6 @@ export const useChat = (
config?.suggested_questions_after_answer,
updateCurrentQAOnTree,
updateChatTreeNode,
notify,
handleResponding,
formatTime,
createAudioPlayerManager,

View File

@ -1,25 +1,16 @@
import type {
FC,
ReactNode,
} from 'react'
import type { FC, ReactNode } from 'react'
import type { Theme } from '../embedded-chatbot/theme/theme-context'
import type { ChatItem } from '../types'
import copy from 'copy-to-clipboard'
import {
memo,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from 'react-textarea-autosize'
import { FileList } from '@/app/components/base/file-uploader'
import { Markdown } from '@/app/components/base/markdown'
import { toast } from '@/app/components/base/ui/toast'
import { cn } from '@/utils/classnames'
import ActionButton from '../../action-button'
import Button from '../../button'
import Toast from '../../toast'
import { CssTransform } from '../embedded-chatbot/theme/utils'
import ContentSwitch from './content-switch'
import { useChatContext } from './context'
@ -32,38 +23,20 @@ type QuestionProps = {
switchSibling?: (siblingMessageId: string) => void
hideAvatar?: boolean
}
const Question: FC<QuestionProps> = ({
item,
questionIcon,
theme,
enableEdit = true,
switchSibling,
hideAvatar,
}) => {
const Question: FC<QuestionProps> = ({ item, questionIcon, theme, enableEdit = true, switchSibling, hideAvatar }) => {
const { t } = useTranslation()
const {
content,
message_files,
} = item
const {
onRegenerate,
} = useChatContext()
const { content, message_files } = item
const { onRegenerate } = useChatContext()
const [isEditing, setIsEditing] = useState(false)
const [editedContent, setEditedContent] = useState(content)
const [contentWidth, setContentWidth] = useState(0)
const contentRef = useRef<HTMLDivElement>(null)
const isComposingRef = useRef(false)
const compositionEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleEdit = useCallback(() => {
setIsEditing(true)
setEditedContent(content)
}, [content])
const handleResend = useCallback(() => {
if (compositionEndTimerRef.current) {
clearTimeout(compositionEndTimerRef.current)
@ -73,7 +46,6 @@ const Question: FC<QuestionProps> = ({
setIsEditing(false)
onRegenerate?.(item, { message: editedContent, files: message_files })
}, [editedContent, message_files, item, onRegenerate])
const handleCancelEditing = useCallback(() => {
if (compositionEndTimerRef.current) {
clearTimeout(compositionEndTimerRef.current)
@ -83,36 +55,28 @@ const Question: FC<QuestionProps> = ({
setIsEditing(false)
setEditedContent(content)
}, [content])
const handleEditInputKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== 'Enter' || e.shiftKey)
return
if (e.nativeEvent.isComposing)
return
if (isComposingRef.current) {
e.preventDefault()
return
}
e.preventDefault()
handleResend()
}, [handleResend])
const clearCompositionEndTimer = useCallback(() => {
if (!compositionEndTimerRef.current)
return
clearTimeout(compositionEndTimerRef.current)
compositionEndTimerRef.current = null
}, [])
const handleCompositionStart = useCallback(() => {
clearCompositionEndTimer()
isComposingRef.current = true
}, [clearCompositionEndTimer])
const handleCompositionEnd = useCallback(() => {
clearCompositionEndTimer()
compositionEndTimerRef.current = setTimeout(() => {
@ -120,7 +84,6 @@ const Question: FC<QuestionProps> = ({
compositionEndTimerRef.current = null
}, 50)
}, [clearCompositionEndTimer])
const handleSwitchSibling = useCallback((direction: 'prev' | 'next') => {
if (direction === 'prev') {
if (item.prevSibling)
@ -131,13 +94,11 @@ const Question: FC<QuestionProps> = ({
switchSibling?.(item.nextSibling)
}
}, [switchSibling, item.prevSibling, item.nextSibling])
const getContentWidth = () => {
/* v8 ignore next 2 -- @preserve */
if (contentRef.current)
setContentWidth(contentRef.current?.clientWidth)
}
useEffect(() => {
/* v8 ignore next 2 -- @preserve */
if (!contentRef.current)
@ -150,27 +111,21 @@ const Question: FC<QuestionProps> = ({
resizeObserver.disconnect()
}
}, [])
useEffect(() => {
return () => {
clearCompositionEndTimer()
}
}, [clearCompositionEndTimer])
return (
<div className="mb-2 flex justify-end last:mb-0">
<div className={cn('group relative mr-4 flex max-w-full items-start overflow-x-hidden pl-14', isEditing && 'flex-1')}>
<div className={cn('mr-2 gap-1', isEditing ? 'hidden' : 'flex')}>
<div
data-testid="action-container"
className="absolute hidden gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex"
style={{ right: contentWidth + 8 }}
>
<div data-testid="action-container" className="absolute hidden gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex" style={{ right: contentWidth + 8 }}>
<ActionButton
data-testid="copy-btn"
onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
}}
>
<div className="i-ri-clipboard-line h-4 w-4" />
@ -182,43 +137,14 @@ const Question: FC<QuestionProps> = ({
)}
</div>
</div>
<div
ref={contentRef}
data-testid="question-content"
className={cn(
'w-full px-4 py-3 text-sm',
!isEditing && 'rounded-2xl bg-background-gradient-bg-fill-chat-bubble-bg-3 text-text-primary',
isEditing && 'rounded-[24px] border-[3px] border-components-option-card-option-selected-border bg-components-panel-bg-blur shadow-lg',
)}
style={(!isEditing && theme?.chatBubbleColorStyle) ? CssTransform(theme.chatBubbleColorStyle) : {}}
>
{
!!message_files?.length && (
<FileList
className={cn(isEditing ? 'mb-3' : 'mb-2')}
files={message_files}
showDeleteAction={false}
showDownloadAction={true}
/>
)
}
<div ref={contentRef} data-testid="question-content" className={cn('w-full px-4 py-3 text-sm', !isEditing && 'rounded-2xl bg-background-gradient-bg-fill-chat-bubble-bg-3 text-text-primary', isEditing && 'rounded-[24px] border-[3px] border-components-option-card-option-selected-border bg-components-panel-bg-blur shadow-lg')} style={(!isEditing && theme?.chatBubbleColorStyle) ? CssTransform(theme.chatBubbleColorStyle) : {}}>
{!!message_files?.length && (<FileList className={cn(isEditing ? 'mb-3' : 'mb-2')} files={message_files} showDeleteAction={false} showDownloadAction={true} />)}
{!isEditing
? <Markdown content={content} />
: (
<div className="flex flex-col gap-4">
<div className="max-h-[158px] overflow-y-auto overflow-x-hidden pr-1">
<Textarea
className={cn(
'w-full resize-none bg-transparent p-0 leading-7 text-text-primary outline-none body-lg-regular',
)}
autoFocus
minRows={1}
value={editedContent}
onChange={e => setEditedContent(e.target.value)}
onKeyDown={handleEditInputKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
<Textarea className={cn('w-full resize-none bg-transparent p-0 leading-7 text-text-primary outline-none body-lg-regular')} autoFocus minRows={1} value={editedContent} onChange={e => setEditedContent(e.target.value)} onKeyDown={handleEditInputKeyDown} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} />
</div>
<div className="flex items-center justify-end gap-2">
<Button className="min-w-24" onClick={handleCancelEditing} data-testid="cancel-edit-btn">{t('operation.cancel', { ns: 'common' })}</Button>
@ -226,31 +152,20 @@ const Question: FC<QuestionProps> = ({
</div>
</div>
)}
{!isEditing && (
<ContentSwitch
count={item.siblingCount}
currentIndex={item.siblingIndex}
prevDisabled={!item.prevSibling}
nextDisabled={!item.nextSibling}
switchSibling={handleSwitchSibling}
/>
)}
{!isEditing && (<ContentSwitch count={item.siblingCount} currentIndex={item.siblingIndex} prevDisabled={!item.prevSibling} nextDisabled={!item.nextSibling} switchSibling={handleSwitchSibling} />)}
</div>
<div className="mt-1 h-[18px]" />
</div>
{!hideAvatar && (
<div className="h-10 w-10 shrink-0">
{
questionIcon || (
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
<div className="i-custom-public-avatar-user h-full w-full" />
</div>
)
}
{questionIcon || (
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
<div className="i-custom-public-avatar-user h-full w-full" />
</div>
)}
</div>
)}
</div>
)
}
export default memo(Question)

View File

@ -3,7 +3,7 @@ import type { ChatConfig } from '../../types'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { ToastProvider } from '@/app/components/base/toast'
import { ToastHost } from '@/app/components/base/ui/toast'
import { InputVarType } from '@/app/components/workflow/types'
import {
AppSourceType,
@ -109,7 +109,8 @@ const createQueryClient = () => new QueryClient({
const createWrapper = (queryClient: QueryClient) => {
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<ToastProvider>{children}</ToastProvider>
<ToastHost />
{children}
</QueryClientProvider>
)
}

View File

@ -1,38 +1,20 @@
import type { ChatConfig, ChatItem, Feedback } from '../types'
/* eslint-disable ts/no-explicit-any */
import type {
ChatConfig,
ChatItem,
Feedback,
} from '../types'
import type { InputValueTypes } from '@/app/components/share/text-generation/types'
import type { Locale } from '@/i18n-config'
import type {
AppData,
ConversationItem,
} from '@/models/share'
import type { AppData, ConversationItem } from '@/models/share'
import { useLocalStorageState } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import { InputVarType } from '@/app/components/workflow/types'
import { useWebAppStore } from '@/context/web-app-context'
import { changeLanguage } from '@/i18n-config/client'
import { AppSourceType, updateFeedback } from '@/service/share'
import {
useInvalidateShareConversations,
useShareChatList,
useShareConversationName,
useShareConversations,
} from '@/service/use-share'
import { useInvalidateShareConversations, useShareChatList, useShareConversationName, useShareConversations } from '@/service/use-share'
import { useGetTryAppInfo, useGetTryAppParams } from '@/service/use-try-app'
import { TransferMethod } from '@/types/app'
import { getProcessedFilesFromResponse } from '../../file-uploader/utils'
@ -64,7 +46,6 @@ function getFormattedChatList(messages: any[]) {
})
return newChatList
}
export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: string) => {
const isInstalledApp = false // just can be webapp and try app
const isTryApp = appSourceType === AppSourceType.tryApp
@ -75,17 +56,13 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
const { data: tryAppParams } = useGetTryAppParams(isTryApp ? tryAppId! : '')
const webAppParams = useWebAppStore(s => s.appParams)
const appParams = isTryApp ? tryAppParams : webAppParams
const appId = useMemo(() => {
return isTryApp ? tryAppId : (appInfo as any)?.app_id
}, [appInfo, isTryApp, tryAppId])
const embeddedConversationId = useWebAppStore(s => s.embeddedConversationId)
const embeddedUserId = useWebAppStore(s => s.embeddedUserId)
const [userId, setUserId] = useState<string>()
const [conversationId, setConversationId] = useState<string>()
useEffect(() => {
if (isTryApp)
return
@ -94,15 +71,12 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
setConversationId(conversation_id)
})
}, [])
useEffect(() => {
setUserId(embeddedUserId || undefined)
}, [embeddedUserId])
useEffect(() => {
setConversationId(embeddedConversationId || undefined)
}, [embeddedConversationId])
useEffect(() => {
if (isTryApp)
return
@ -110,11 +84,9 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
// Check URL parameters for language override
const urlParams = new URLSearchParams(window.location.search)
const localeParam = urlParams.get('locale')
// Check for encoded system variables
const systemVariables = await getProcessedSystemVariablesFromUrlParams()
const localeFromSysVar = systemVariables.locale
if (localeParam) {
// If locale parameter exists in URL, use it instead of default
await changeLanguage(localeParam as Locale)
@ -128,10 +100,8 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
await changeLanguage((appInfo as unknown as AppData).site?.default_language)
}
}
setLanguageFromParams()
}, [appInfo])
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {
defaultValue: {},
})
@ -158,51 +128,36 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
})
}
}, [appId, conversationIdInfo, setConversationIdInfo, userId])
const [newConversationId, setNewConversationId] = useState('')
const chatShouldReloadKey = useMemo(() => {
if (currentConversationId === newConversationId)
return ''
return currentConversationId
}, [currentConversationId, newConversationId])
const { data: appPinnedConversationData } = useShareConversations({
appSourceType,
appId,
pinned: true,
limit: 100,
})
const {
data: appConversationData,
isLoading: appConversationDataLoading,
} = useShareConversations({
const { data: appConversationData, isLoading: appConversationDataLoading } = useShareConversations({
appSourceType,
appId,
pinned: false,
limit: 100,
})
const {
data: appChatListData,
isLoading: appChatListDataLoading,
} = useShareChatList({
const { data: appChatListData, isLoading: appChatListDataLoading } = useShareChatList({
conversationId: chatShouldReloadKey,
appSourceType,
appId,
})
const invalidateShareConversations = useInvalidateShareConversations()
const [clearChatList, setClearChatList] = useState(false)
const [isResponding, setIsResponding] = useState(false)
const appPrevChatList = useMemo(
() => (currentConversationId && appChatListData?.data.length)
? buildChatItemTree(getFormattedChatList(appChatListData.data))
: [],
[appChatListData, currentConversationId],
)
const appPrevChatList = useMemo(() => (currentConversationId && appChatListData?.data.length)
? buildChatItemTree(getFormattedChatList(appChatListData.data))
: [], [appChatListData, currentConversationId])
const [showNewConversationItemInList, setShowNewConversationItemInList] = useState(false)
const pinnedConversationList = useMemo(() => {
return appPinnedConversationData?.data || []
}, [appPinnedConversationData])
@ -222,7 +177,6 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
let value = initInputs[item.paragraph.variable]
if (value && item.paragraph.max_length && value.length > item.paragraph.max_length)
value = value.slice(0, item.paragraph.max_length)
return {
...item.paragraph,
default: value || item.default || item.paragraph.default,
@ -237,7 +191,6 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
type: 'number',
}
}
if (item.checkbox) {
const preset = initInputs[item.checkbox.variable] === true
return {
@ -246,7 +199,6 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
type: 'checkbox',
}
}
if (item.select) {
const isInputInOptions = item.select.options.includes(initInputs[item.select.variable])
return {
@ -255,32 +207,27 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
type: 'select',
}
}
if (item['file-list']) {
return {
...item['file-list'],
type: 'file-list',
}
}
if (item.file) {
return {
...item.file,
type: 'file',
}
}
if (item.json_object) {
return {
...item.json_object,
type: 'json_object',
}
}
let value = initInputs[item['text-input'].variable]
if (value && item['text-input'].max_length && value.length > item['text-input'].max_length)
value = value.slice(0, item['text-input'].max_length)
return {
...item['text-input'],
default: value || item.default || item['text-input'].default,
@ -288,11 +235,9 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
}
})
}, [initInputs, appParams])
const allInputsHidden = useMemo(() => {
return inputsForms.length > 0 && inputsForms.every(item => item.hide === true)
}, [inputsForms])
useEffect(() => {
// init inputs from url params
(async () => {
@ -306,13 +251,11 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
}, [])
useEffect(() => {
const conversationInputs: Record<string, InputValueTypes> = {}
inputsForms.forEach((item) => {
conversationInputs[item.variable] = item.default || null
})
handleNewConversationInputsChange(conversationInputs)
}, [handleNewConversationInputsChange, inputsForms])
const { data: newConversation } = useShareConversationName({
conversationId: newConversationId,
appSourceType,
@ -324,12 +267,11 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
const [originConversationList, setOriginConversationList] = useState<ConversationItem[]>([])
useEffect(() => {
if (appConversationData?.data && !appConversationDataLoading)
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setOriginConversationList(appConversationData?.data)
}, [appConversationData, appConversationDataLoading])
const conversationList = useMemo(() => {
const data = originConversationList.slice()
if (showNewConversationItemInList && data[0]?.id !== '') {
data.unshift({
id: '',
@ -340,12 +282,10 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
}
return data
}, [originConversationList, showNewConversationItemInList, t])
useEffect(() => {
if (newConversation) {
setOriginConversationList(produce((draft) => {
const index = draft.findIndex(item => item.id === newConversation.id)
if (index > -1)
draft[index] = newConversation
else
@ -353,16 +293,12 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
}))
}
}, [newConversation])
const currentConversationItem = useMemo(() => {
let conversationItem = conversationList.find(item => item.id === currentConversationId)
if (!conversationItem && pinnedConversationList.length)
conversationItem = pinnedConversationList.find(item => item.id === currentConversationId)
return conversationItem
}, [conversationList, currentConversationId, pinnedConversationList])
const currentConversationLatestInputs = useMemo(() => {
if (!currentConversationId || !appChatListData?.data.length)
return newConversationInputsRef.current || {}
@ -371,15 +307,12 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
const [currentConversationInputs, setCurrentConversationInputs] = useState<Record<string, any>>(currentConversationLatestInputs || {})
useEffect(() => {
if (currentConversationItem && !isTryApp)
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setCurrentConversationInputs(currentConversationLatestInputs || {})
}, [currentConversationItem, currentConversationLatestInputs])
const { notify } = useToastContext()
const checkInputsRequired = useCallback((silent?: boolean) => {
if (allInputsHidden)
return true
let hasEmptyInput = ''
let fileIsUploading = false
const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox)
@ -387,13 +320,10 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
requiredVars.forEach(({ variable, label, type }) => {
if (hasEmptyInput)
return
if (fileIsUploading)
return
if (!newConversationInputsRef.current[variable] && !silent)
hasEmptyInput = label as string
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && newConversationInputsRef.current[variable] && !silent) {
const files = newConversationInputsRef.current[variable]
if (Array.isArray(files))
@ -403,26 +333,25 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
}
})
}
if (hasEmptyInput) {
notify({ type: 'error', message: t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }) })
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }))
return false
}
if (fileIsUploading) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
toast.info(t('errorMessage.waitForFileUpload', { ns: 'appDebug' }))
return
}
return true
}, [inputsForms, notify, t, allInputsHidden])
}, [inputsForms, t, allInputsHidden])
const handleStartChat = useCallback((callback?: () => void) => {
if (checkInputsRequired()) {
setShowNewConversationItemInList(true)
callback?.()
}
}, [setShowNewConversationItemInList, checkInputsRequired])
const currentChatInstanceRef = useRef<{ handleStop: () => void }>({ handleStop: noop })
const currentChatInstanceRef = useRef<{
handleStop: () => void
}>({ handleStop: noop })
const handleChangeConversation = useCallback((conversationId: string) => {
currentChatInstanceRef.current.handleStop()
setNewConversationId('')
@ -435,26 +364,22 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
setClearChatList(true)
return
}
currentChatInstanceRef.current.handleStop()
setShowNewConversationItemInList(true)
handleChangeConversation('')
handleNewConversationInputsChange(await getProcessedInputsFromUrlParams())
setClearChatList(true)
}, [isTryApp, setShowNewConversationItemInList, handleNewConversationInputsChange, setClearChatList])
const handleNewConversationCompleted = useCallback((newConversationId: string) => {
setNewConversationId(newConversationId)
handleConversationIdInfoChange(newConversationId)
setShowNewConversationItemInList(false)
invalidateShareConversations()
}, [handleConversationIdInfoChange, invalidateShareConversations])
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, appSourceType, appId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
}, [appSourceType, appId, t, notify])
toast.success(t('api.success', { ns: 'common' }))
}, [appSourceType, appId, t])
return {
appSourceType,
isInstalledApp,

View File

@ -16,8 +16,8 @@ vi.mock('@/next/navigation', () => ({
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: vi.fn() }),
vi.mock('@/app/components/base/ui/toast', () => ({
}))
// Mock CodeEditor to trigger onChange easily

View File

@ -28,7 +28,7 @@ vi.mock('@/service/annotation', () => ({
addAnnotation: (...args: unknown[]) => mockAddAnnotation(...args),
}))
vi.mock('@/app/components/base/toast', () => ({
vi.mock('@/app/components/base/ui/toast', () => ({
default: { notify: vi.fn() },
}))

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import ConfigParamModal from '../config-param-modal'
let mockHooksReturn: {
@ -31,10 +31,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec
),
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
}))
vi.mock('@/config', () => ({
ANNOTATION_DEFAULT: { score_threshold: 0.9 },
}))
@ -63,8 +59,11 @@ const defaultAnnotationConfig = {
}
describe('ConfigParamModal', () => {
const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
beforeEach(() => {
vi.clearAllMocks()
toastErrorSpy.mockClear()
mockHooksReturn = {
modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }],
defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
@ -241,7 +240,7 @@ describe('ConfigParamModal', () => {
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
fireEvent.click(saveBtn!)
expect(Toast.notify).toHaveBeenCalledWith(
expect(toastErrorSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})

View File

@ -1,14 +1,11 @@
'use client'
import type { FC } from 'react'
import {
RiEditLine,
RiFileEditLine,
} from '@remixicon/react'
import { RiEditLine, RiFileEditLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import { toast } from '@/app/components/base/ui/toast'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { addAnnotation } from '@/service/annotation'
@ -22,16 +19,7 @@ type Props = {
onAdded: (annotationId: string, authorName: string) => void
onEdit: () => void
}
const AnnotationCtrlButton: FC<Props> = ({
cached,
query,
answer,
appId,
messageId,
onAdded,
onEdit,
}) => {
const AnnotationCtrlButton: FC<Props> = ({ cached, query, answer, appId, messageId, onAdded, onEdit }) => {
const { t } = useTranslation()
const { plan, enableBilling } = useProviderContext()
const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse)
@ -46,28 +34,20 @@ const AnnotationCtrlButton: FC<Props> = ({
question: query,
answer,
})
Toast.notify({
message: t('api.actionSuccess', { ns: 'common' }) as string,
type: 'success',
})
toast.success(t('api.actionSuccess', { ns: 'common' }) as string)
onAdded(res.id, res.account?.name ?? '')
}
return (
<>
{cached && (
<Tooltip
popupContent={t('feature.annotation.edit', { ns: 'appDebug' })}
>
<Tooltip popupContent={t('feature.annotation.edit', { ns: 'appDebug' })}>
<ActionButton onClick={onEdit}>
<RiEditLine className="h-4 w-4" />
</ActionButton>
</Tooltip>
)}
{!cached && answer && (
<Tooltip
popupContent={t('feature.annotation.add', { ns: 'appDebug' })}
>
<Tooltip popupContent={t('feature.annotation.add', { ns: 'appDebug' })}>
<ActionButton onClick={handleAdd}>
<RiFileEditLine className="h-4 w-4" />
</ActionButton>

View File

@ -6,7 +6,7 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
@ -25,22 +25,10 @@ type Props = {
isInit?: boolean
annotationConfig: AnnotationReplyConfig
}
const ConfigParamModal: FC<Props> = ({
isShow,
onHide: doHide,
onSave,
isInit,
annotationConfig: oldAnnotationConfig,
}) => {
const ConfigParamModal: FC<Props> = ({ isShow, onHide: doHide, onSave, isInit, annotationConfig: oldAnnotationConfig }) => {
const { t } = useTranslation()
const {
modelList: embeddingsModelList,
defaultModel: embeddingsDefaultModel,
currentModel: isEmbeddingsDefaultModelValid,
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textEmbedding)
const { modelList: embeddingsModelList, defaultModel: embeddingsDefaultModel, currentModel: isEmbeddingsDefaultModelValid } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textEmbedding)
const [annotationConfig, setAnnotationConfig] = useState(oldAnnotationConfig)
const [isLoading, setLoading] = useState(false)
const [embeddingModel, setEmbeddingModel] = useState(oldAnnotationConfig.embedding_model
? {
@ -57,13 +45,9 @@ const ConfigParamModal: FC<Props> = ({
if (!isLoading)
doHide()
}
const handleSave = async () => {
if (!embeddingModel || !embeddingModel.modelName || (embeddingModel.modelName === embeddingsDefaultModel?.model && !isEmbeddingsDefaultModelValid)) {
Toast.notify({
message: t('modelProvider.embeddingModel.required', { ns: 'common' }),
type: 'error',
})
toast.error(t('modelProvider.embeddingModel.required', { ns: 'common' }))
return
}
setLoading(true)
@ -73,22 +57,14 @@ const ConfigParamModal: FC<Props> = ({
}, annotationConfig.score_threshold)
setLoading(false)
}
return (
<Modal
isShow={isShow}
onClose={onHide}
className="!mt-14 !w-[640px] !max-w-none !p-6"
>
<Modal isShow={isShow} onClose={onHide} className="!mt-14 !w-[640px] !max-w-none !p-6">
<div className="mb-2 text-text-primary title-2xl-semi-bold">
{t(`initSetup.${isInit ? 'title' : 'configTitle'}`, { ns: 'appAnnotation' })}
</div>
<div className="mt-6 space-y-3">
<Item
title={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })}
tooltip={t('feature.annotation.scoreThreshold.description', { ns: 'appDebug' })}
>
<Item title={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })} tooltip={t('feature.annotation.scoreThreshold.description', { ns: 'appDebug' })}>
<ScoreSlider
className="mt-1"
value={(annotationConfig.score_threshold || ANNOTATION_DEFAULT.score_threshold) * 100}
@ -101,10 +77,7 @@ const ConfigParamModal: FC<Props> = ({
/>
</Item>
<Item
title={t('modelProvider.embeddingModel.key', { ns: 'common' })}
tooltip={t('embeddingModelSwitchTip', { ns: 'appAnnotation' })}
>
<Item title={t('modelProvider.embeddingModel.key', { ns: 'common' })} tooltip={t('embeddingModelSwitchTip', { ns: 'appAnnotation' })}>
<div className="pt-1">
<ModelSelector
defaultModel={embeddingModel && {
@ -125,11 +98,7 @@ const ConfigParamModal: FC<Props> = ({
<div className="mt-6 flex justify-end gap-2">
<Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button
variant="primary"
onClick={handleSave}
loading={isLoading}
>
<Button variant="primary" onClick={handleSave} loading={isLoading}>
<div></div>
<div>{t(`initSetup.${isInit ? 'confirmBtn' : 'configConfirmBtn'}`, { ns: 'appAnnotation' })}</div>
</Button>

View File

@ -4,8 +4,8 @@ import * as i18n from 'react-i18next'
import ModerationSettingModal from '../moderation-setting-modal'
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: mockNotify }),
vi.mock('@/app/components/base/ui/toast', () => ({
}))
const mockSetShowAccountSettingModal = vi.fn()

View File

@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import Modal from '@/app/components/base/modal'
import { useToastContext } from '@/app/components/base/toast/context'
import { toast } from '@/app/components/base/ui/toast'
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { CustomConfigurationStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
@ -20,27 +20,19 @@ import FormGeneration from './form-generation'
import ModerationContent from './moderation-content'
const systemTypes = ['openai_moderation', 'keywords', 'api']
type Provider = {
key: string
name: string
form_schema?: CodeBasedExtensionItem['form_schema']
}
type ModerationSettingModalProps = {
data: ModerationConfig
onCancel: () => void
onSave: (moderationConfig: ModerationConfig) => void
}
const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
data,
onCancel,
onSave,
}) => {
const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ data, onCancel, onSave }) => {
const { t } = useTranslation()
const docLink = useDocLink()
const { notify } = useToastContext()
const locale = useLocale()
const { data: modelProviders, isPending: isLoading, refetch: refetchModelProviders } = useModelProviders()
const [localeData, setLocaleData] = useState<ModerationConfig>(data)
@ -73,25 +65,20 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
key: 'api',
name: t('apiBasedExtension.selector.title', { ns: 'common' }),
},
...(
codeBasedExtensionList
? codeBasedExtensionList.data.map((item) => {
return {
key: item.name,
name: locale === 'zh-Hans' ? item.label['zh-Hans'] : item.label['en-US'],
form_schema: item.form_schema,
}
})
: []
),
...(codeBasedExtensionList
? codeBasedExtensionList.data.map((item) => {
return {
key: item.name,
name: locale === 'zh-Hans' ? item.label['zh-Hans'] : item.label['en-US'],
form_schema: item.form_schema,
}
})
: []),
]
const currentProvider = providers.find(provider => provider.key === localeData.type)
const handleDataTypeChange = (type: string) => {
let config: undefined | Record<string, any>
const currProvider = providers.find(provider => provider.key === type)
if (systemTypes.findIndex(t => t === type) < 0 && currProvider?.form_schema) {
config = currProvider?.form_schema.reduce((prev, next) => {
prev[next.variable] = next.default
@ -104,19 +91,15 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
config,
})
}
const handleDataKeywordsChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
const arr = value.split('\n').reduce((prev: string[], next: string) => {
if (next !== '')
prev.push(next.slice(0, 100))
if (next === '' && prev[prev.length - 1] !== '')
prev.push(next)
return prev
}, [])
setLocaleData({
...localeData,
config: {
@ -125,7 +108,6 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
},
})
}
const handleDataContentChange = (contentType: string, contentConfig: ModerationContentConfig) => {
setLocaleData({
...localeData,
@ -135,7 +117,6 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
},
})
}
const handleDataApiBasedChange = (apiBasedExtensionId: string) => {
setLocaleData({
...localeData,
@ -145,7 +126,6 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
},
})
}
const handleDataExtraChange = (extraValue: Record<string, string>) => {
setLocaleData({
...localeData,
@ -155,24 +135,19 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
},
})
}
const formatData = (originData: ModerationConfig) => {
const { enabled, type, config } = originData
const { inputs_config, outputs_config } = config!
const params: Record<string, string | undefined> = {}
if (type === 'keywords')
params.keywords = config?.keywords
if (type === 'api')
params.api_based_extension_id = config?.api_based_extension_id
if (systemTypes.findIndex(t => t === type) < 0 && currentProvider?.form_schema) {
currentProvider.form_schema.forEach((form) => {
params[form.variable] = config?.[form.variable]
})
}
return {
type,
enabled,
@ -183,58 +158,42 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
},
}
}
const handleSave = () => {
/* v8 ignore next -- UI-invariant guard: same condition is used in Save button disabled logic, so when true handleSave has no user-triggerable invocation path. @preserve */
if (localeData.type === 'openai_moderation' && !isOpenAIProviderConfigured)
return
if (!localeData.config?.inputs_config?.enabled && !localeData.config?.outputs_config?.enabled) {
notify({ type: 'error', message: t('feature.moderation.modal.content.condition', { ns: 'appDebug' }) })
toast.error(t('feature.moderation.modal.content.condition', { ns: 'appDebug' }))
return
}
if (localeData.type === 'keywords' && !localeData.config.keywords) {
notify({ type: 'error', message: t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: locale !== LanguagesSupported[1] ? 'keywords' : '关键词' }) })
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: locale !== LanguagesSupported[1] ? 'keywords' : '关键词' }))
return
}
if (localeData.type === 'api' && !localeData.config.api_based_extension_id) {
notify({ type: 'error', message: t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: locale !== LanguagesSupported[1] ? 'API Extension' : 'API 扩展' }) })
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: locale !== LanguagesSupported[1] ? 'API Extension' : 'API 扩展' }))
return
}
if (systemTypes.findIndex(t => t === localeData.type) < 0 && currentProvider?.form_schema) {
for (let i = 0; i < currentProvider.form_schema.length; i++) {
if (!localeData.config?.[currentProvider.form_schema[i].variable] && currentProvider.form_schema[i].required) {
notify({
type: 'error',
message: t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: locale !== LanguagesSupported[1] ? currentProvider.form_schema[i].label['en-US'] : currentProvider.form_schema[i].label['zh-Hans'] }),
})
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: locale !== LanguagesSupported[1] ? currentProvider.form_schema[i].label['en-US'] : currentProvider.form_schema[i].label['zh-Hans'] }))
return
}
}
}
if (localeData.config.inputs_config?.enabled && !localeData.config.inputs_config.preset_response && localeData.type !== 'api') {
notify({ type: 'error', message: t('feature.moderation.modal.content.errorMessage', { ns: 'appDebug' }) })
toast.error(t('feature.moderation.modal.content.errorMessage', { ns: 'appDebug' }))
return
}
if (localeData.config.outputs_config?.enabled && !localeData.config.outputs_config.preset_response && localeData.type !== 'api') {
notify({ type: 'error', message: t('feature.moderation.modal.content.errorMessage', { ns: 'appDebug' }) })
toast.error(t('feature.moderation.modal.content.errorMessage', { ns: 'appDebug' }))
return
}
onSave(formatData(localeData))
}
return (
<Modal
isShow
onClose={noop}
className="!mt-14 !w-[600px] !max-w-none !p-6"
>
<Modal isShow onClose={noop} className="!mt-14 !w-[600px] !max-w-none !p-6">
<div className="flex items-center justify-between">
<div className="text-text-primary title-2xl-semi-bold">{t('feature.moderation.modal.title', { ns: 'appDebug' })}</div>
<div
@ -257,139 +216,74 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
{t('feature.moderation.modal.provider.title', { ns: 'appDebug' })}
</div>
<div className="grid grid-cols-3 gap-2.5">
{
providers.map(provider => (
<div
key={provider.key}
className={cn(
'flex h-8 cursor-default items-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 text-text-secondary system-sm-regular',
localeData.type !== provider.key && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
localeData.type === provider.key && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs system-sm-medium',
localeData.type === 'openai_moderation' && provider.key === 'openai_moderation' && !isOpenAIProviderConfigured && 'text-text-disabled',
)}
onClick={() => handleDataTypeChange(provider.key)}
>
<div className={cn(
'mr-2 h-4 w-4 rounded-full border border-components-radio-border bg-components-radio-bg shadow-xs',
localeData.type === provider.key && 'border-[5px] border-components-radio-border-checked',
)}
>
</div>
{provider.name}
{providers.map(provider => (
<div key={provider.key} className={cn('flex h-8 cursor-default items-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 text-text-secondary system-sm-regular', localeData.type !== provider.key && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs', localeData.type === provider.key && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs system-sm-medium', localeData.type === 'openai_moderation' && provider.key === 'openai_moderation' && !isOpenAIProviderConfigured && 'text-text-disabled')} onClick={() => handleDataTypeChange(provider.key)}>
<div className={cn('mr-2 h-4 w-4 rounded-full border border-components-radio-border bg-components-radio-bg shadow-xs', localeData.type === provider.key && 'border-[5px] border-components-radio-border-checked')}>
</div>
))
}
{provider.name}
</div>
))}
</div>
{
!isLoading && !isOpenAIProviderConfigured && localeData.type === 'openai_moderation' && (
<div className="mt-2 flex items-center rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 py-2">
<span className="i-custom-vender-line-general-info-circle mr-1 h-4 w-4 text-[#F79009]" />
<div className="flex items-center text-xs font-medium text-gray-700">
{t('feature.moderation.modal.openaiNotConfig.before', { ns: 'appDebug' })}
<span
className="cursor-pointer text-primary-600"
onClick={handleOpenSettingsModal}
>
{!isLoading && !isOpenAIProviderConfigured && localeData.type === 'openai_moderation' && (
<div className="mt-2 flex items-center rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 py-2">
<span className="i-custom-vender-line-general-info-circle mr-1 h-4 w-4 text-[#F79009]" />
<div className="flex items-center text-xs font-medium text-gray-700">
{t('feature.moderation.modal.openaiNotConfig.before', { ns: 'appDebug' })}
<span className="cursor-pointer text-primary-600" onClick={handleOpenSettingsModal}>
&nbsp;
{t('settings.provider', { ns: 'common' })}
{t('settings.provider', { ns: 'common' })}
&nbsp;
</span>
{t('feature.moderation.modal.openaiNotConfig.after', { ns: 'appDebug' })}
</div>
</span>
{t('feature.moderation.modal.openaiNotConfig.after', { ns: 'appDebug' })}
</div>
)
}
</div>
)}
</div>
{
localeData.type === 'keywords' && (
<div className="py-2">
<div className="mb-1 text-sm font-medium text-text-primary">{t('feature.moderation.modal.provider.keywords', { ns: 'appDebug' })}</div>
<div className="mb-2 text-xs text-text-tertiary">{t('feature.moderation.modal.keywords.tip', { ns: 'appDebug' })}</div>
<div className="relative h-[88px] rounded-lg bg-components-input-bg-normal px-3 py-2">
<textarea
value={localeData.config?.keywords || ''}
onChange={handleDataKeywordsChange}
className="block h-full w-full resize-none appearance-none bg-transparent text-sm text-text-secondary outline-none"
placeholder={t('feature.moderation.modal.keywords.placeholder', { ns: 'appDebug' }) || ''}
/>
<div className="absolute bottom-2 right-2 flex h-5 items-center rounded-md bg-background-section px-1 text-xs font-medium text-text-quaternary">
<span>{(localeData.config?.keywords || '').split('\n').filter(Boolean).length}</span>
/
<span className="text-text-tertiary">
100
{t('feature.moderation.modal.keywords.line', { ns: 'appDebug' })}
</span>
</div>
{localeData.type === 'keywords' && (
<div className="py-2">
<div className="mb-1 text-sm font-medium text-text-primary">{t('feature.moderation.modal.provider.keywords', { ns: 'appDebug' })}</div>
<div className="mb-2 text-xs text-text-tertiary">{t('feature.moderation.modal.keywords.tip', { ns: 'appDebug' })}</div>
<div className="relative h-[88px] rounded-lg bg-components-input-bg-normal px-3 py-2">
<textarea value={localeData.config?.keywords || ''} onChange={handleDataKeywordsChange} className="block h-full w-full resize-none appearance-none bg-transparent text-sm text-text-secondary outline-none" placeholder={t('feature.moderation.modal.keywords.placeholder', { ns: 'appDebug' }) || ''} />
<div className="absolute bottom-2 right-2 flex h-5 items-center rounded-md bg-background-section px-1 text-xs font-medium text-text-quaternary">
<span>{(localeData.config?.keywords || '').split('\n').filter(Boolean).length}</span>
/
<span className="text-text-tertiary">
100
{t('feature.moderation.modal.keywords.line', { ns: 'appDebug' })}
</span>
</div>
</div>
)
}
{
localeData.type === 'api' && (
<div className="py-2">
<div className="flex h-9 items-center justify-between">
<div className="text-sm font-medium text-text-primary">{t('apiBasedExtension.selector.title', { ns: 'common' })}</div>
<a
href={docLink('/use-dify/workspace/api-extension/api-extension')}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center text-xs text-text-tertiary hover:text-primary-600"
>
<span className="i-custom-vender-line-education-book-open-01 mr-1 h-3 w-3 text-text-tertiary group-hover:text-primary-600" />
{t('apiBasedExtension.link', { ns: 'common' })}
</a>
</div>
<ApiBasedExtensionSelector
value={localeData.config?.api_based_extension_id || ''}
onChange={handleDataApiBasedChange}
/>
</div>
)}
{localeData.type === 'api' && (
<div className="py-2">
<div className="flex h-9 items-center justify-between">
<div className="text-sm font-medium text-text-primary">{t('apiBasedExtension.selector.title', { ns: 'common' })}</div>
<a href={docLink('/use-dify/workspace/api-extension/api-extension')} target="_blank" rel="noopener noreferrer" className="group flex items-center text-xs text-text-tertiary hover:text-primary-600">
<span className="i-custom-vender-line-education-book-open-01 mr-1 h-3 w-3 text-text-tertiary group-hover:text-primary-600" />
{t('apiBasedExtension.link', { ns: 'common' })}
</a>
</div>
)
}
{
systemTypes.findIndex(t => t === localeData.type) < 0
<ApiBasedExtensionSelector value={localeData.config?.api_based_extension_id || ''} onChange={handleDataApiBasedChange} />
</div>
)}
{systemTypes.findIndex(t => t === localeData.type) < 0
&& currentProvider?.form_schema
&& (
<FormGeneration
forms={currentProvider?.form_schema}
value={localeData.config}
onChange={handleDataExtraChange}
/>
)
}
&& (<FormGeneration forms={currentProvider?.form_schema} value={localeData.config} onChange={handleDataExtraChange} />)}
<Divider bgStyle="gradient" className="my-3 h-px" />
<ModerationContent
title={t('feature.moderation.modal.content.input', { ns: 'appDebug' }) || ''}
config={localeData.config?.inputs_config || { enabled: false, preset_response: '' }}
onConfigChange={config => handleDataContentChange('inputs_config', config)}
info={(localeData.type === 'api' && t('feature.moderation.modal.content.fromApi', { ns: 'appDebug' })) || ''}
showPreset={localeData.type !== 'api'}
/>
<ModerationContent
title={t('feature.moderation.modal.content.output', { ns: 'appDebug' }) || ''}
config={localeData.config?.outputs_config || { enabled: false, preset_response: '' }}
onConfigChange={config => handleDataContentChange('outputs_config', config)}
info={(localeData.type === 'api' && t('feature.moderation.modal.content.fromApi', { ns: 'appDebug' })) || ''}
showPreset={localeData.type !== 'api'}
/>
<ModerationContent title={t('feature.moderation.modal.content.input', { ns: 'appDebug' }) || ''} config={localeData.config?.inputs_config || { enabled: false, preset_response: '' }} onConfigChange={config => handleDataContentChange('inputs_config', config)} info={(localeData.type === 'api' && t('feature.moderation.modal.content.fromApi', { ns: 'appDebug' })) || ''} showPreset={localeData.type !== 'api'} />
<ModerationContent title={t('feature.moderation.modal.content.output', { ns: 'appDebug' }) || ''} config={localeData.config?.outputs_config || { enabled: false, preset_response: '' }} onConfigChange={config => handleDataContentChange('outputs_config', config)} info={(localeData.type === 'api' && t('feature.moderation.modal.content.fromApi', { ns: 'appDebug' })) || ''} showPreset={localeData.type !== 'api'} />
<div className="mb-8 mt-1 text-xs font-medium text-text-tertiary">{t('feature.moderation.modal.content.condition', { ns: 'appDebug' })}</div>
<div className="flex items-center justify-end">
<Button
onClick={onCancel}
className="mr-2"
>
<Button onClick={onCancel} className="mr-2">
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={localeData.type === 'openai_moderation' && !isOpenAIProviderConfigured}
>
<Button variant="primary" onClick={handleSave} disabled={localeData.type === 'openai_moderation' && !isOpenAIProviderConfigured}>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</Modal>
)
}
export default ModerationSettingModal

View File

@ -10,11 +10,8 @@ vi.mock('@/next/navigation', () => ({
useParams: () => ({ token: undefined }),
}))
// Exception: hook requires toast context that isn't available without a provider wrapper
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
vi.mock('@/app/components/base/ui/toast', () => ({
}))
const mockSetFiles = vi.fn()

View File

@ -4,7 +4,7 @@ import type { FileUpload } from '@/app/components/base/features/types'
import { useState } from 'react'
import { fn } from 'storybook/test'
import { PreviewMode } from '@/app/components/base/features/types'
import { ToastProvider } from '@/app/components/base/toast'
import { ToastHost } from '@/app/components/base/ui/toast'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import FileUploaderInAttachmentWrapper from './index'
@ -83,7 +83,8 @@ const AttachmentDemo = (props: React.ComponentProps<typeof FileUploaderInAttachm
const [files, setFiles] = useState<FileEntity[]>(mockFiles)
return (
<ToastProvider>
<>
<ToastHost />
<div className="w-[320px] rounded-2xl border border-divider-subtle bg-components-panel-bg p-4 shadow-xs">
<FileUploaderInAttachmentWrapper
{...props}
@ -91,7 +92,7 @@ const AttachmentDemo = (props: React.ComponentProps<typeof FileUploaderInAttachm
onChange={setFiles}
/>
</div>
</ToastProvider>
</>
)
}

View File

@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { FileEntity } from '../types'
import type { FileUpload } from '@/app/components/base/features/types'
import { useState } from 'react'
import { ToastProvider } from '@/app/components/base/toast'
import { ToastHost } from '@/app/components/base/ui/toast'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import FileUploaderInChatInput from '.'
@ -36,7 +36,8 @@ const ChatInputDemo = ({ initialFiles = mockFiles, ...props }: ChatInputDemoProp
const [files, setFiles] = useState<FileEntity[]>(initialFiles)
return (
<ToastProvider>
<>
<ToastHost />
<FileContextProvider value={files} onChange={setFiles}>
<div className="w-[360px] rounded-2xl border border-divider-subtle bg-components-panel-bg p-4">
<div className="mb-3 text-xs text-text-secondary">Simulated chat input</div>
@ -49,7 +50,7 @@ const ChatInputDemo = ({ initialFiles = mockFiles, ...props }: ChatInputDemoProp
</div>
</div>
</FileContextProvider>
</ToastProvider>
</>
)
}

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