diff --git a/.agents/skills/frontend-query-mutation/references/runtime-rules.md b/.agents/skills/frontend-query-mutation/references/runtime-rules.md index 02e8b9c2b62..73d6fbddedb 100644 --- a/.agents/skills/frontend-query-mutation/references/runtime-rules.md +++ b/.agents/skills/frontend-query-mutation/references/runtime-rules.md @@ -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') } ``` diff --git a/web/.storybook/preview.tsx b/web/.storybook/preview.tsx index 072244c33f9..a9144e71280 100644 --- a/web/.storybook/preview.tsx +++ b/web/.storybook/preview.tsx @@ -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 ( - + <> + - + ) diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index c5766878a15..765c7045e56 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -23,8 +23,25 @@ let mockSystemFeatures = { webapp_auth: { enabled: false }, } +const toastMocks = vi.hoisted(() => ({ + mockNotify: vi.fn(), + dismiss: vi.fn(), + update: vi.fn(), + promise: vi.fn(), +})) const mockRouterPush = vi.fn() -const mockNotify = vi.fn() + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + success: (message: string, options?: Record) => toastMocks.mockNotify({ type: 'success', message, ...options }), + error: (message: string, options?: Record) => toastMocks.mockNotify({ type: 'error', message, ...options }), + warning: (message: string, options?: Record) => toastMocks.mockNotify({ type: 'warning', message, ...options }), + info: (message: string, options?: Record) => toastMocks.mockNotify({ type: 'info', message, ...options }), + dismiss: toastMocks.dismiss, + update: toastMocks.update, + promise: toastMocks.promise, + }, +})) const mockOnPlanInfoChanged = vi.fn() const mockDeleteAppMutation = vi.fn().mockResolvedValue(undefined) let mockDeleteMutationPending = false @@ -94,27 +111,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('use-context-selector') - return { - ...actual, - useContext: () => ({ notify: mockNotify }), - } -}) - -vi.mock('@/app/components/base/tag-management/store', () => ({ - useStore: (selector: (state: Record) => unknown) => { - const state = { - tagList: [], - showTagManagementModal: false, - setTagList: vi.fn(), - setShowTagManagementModal: vi.fn(), - } - return selector(state) - }, -})) - vi.mock('@/service/tag', () => ({ fetchTagList: vi.fn().mockResolvedValue([]), })) diff --git a/web/__tests__/datasets/create-dataset-flow.test.tsx b/web/__tests__/datasets/create-dataset-flow.test.tsx index e3a59edde64..34d64d8c439 100644 --- a/web/__tests__/datasets/create-dataset-flow.test.tsx +++ b/web/__tests__/datasets/create-dataset-flow.test.tsx @@ -33,8 +33,14 @@ 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() }, + toast: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }, })) vi.mock('@/app/components/base/amplitude', () => ({ diff --git a/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts b/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts index dc5ab3fc86b..cdf7aba4f6a 100644 --- a/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts +++ b/web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts @@ -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) => mockNotify({ type: 'success', message, ...options }), + error: (message: string, options?: Record) => mockNotify({ type: 'error', message, ...options }), + warning: (message: string, options?: Record) => mockNotify({ type: 'warning', message, ...options }), + info: (message: string, options?: Record) => 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', })) diff --git a/web/__tests__/tools/tool-provider-detail-flow.test.tsx b/web/__tests__/tools/tool-provider-detail-flow.test.tsx index 0101f83f22e..3d66467695a 100644 --- a/web/__tests__/tools/tool-provider-detail-flow.test.tsx +++ b/web/__tests__/tools/tool-provider-detail-flow.test.tsx @@ -153,8 +153,14 @@ vi.mock('@/app/components/base/confirm', () => ({ ), })) -vi.mock('@/app/components/base/toast', () => ({ +vi.mock('@/app/components/base/ui/toast', () => ({ default: { notify: vi.fn() }, + toast: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }, })) vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({ diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx index 8c1df8d63df..26373bd42ae 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx @@ -7,12 +7,11 @@ import type { App } from '@/types/app' import type { I18nKeysByPrefix } from '@/types/i18n' import { useCallback, 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 { isTriggerNode } from '@/app/components/workflow/types' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' @@ -34,7 +33,6 @@ export type ICardViewProps = { const CardView: FC = ({ appId, isInPanel, className }) => { const { t } = useTranslation() - const { notify } = useContext(ToastContext) const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(state => state.setAppDetail) @@ -90,10 +88,7 @@ const CardView: FC = ({ appId, isInPanel, className }) => { if (type === 'success') updateAppDetail() - notify({ - type, - message: t(`actionMsg.${message}`, { ns: 'common' }) as string, - }) + toast(t(`actionMsg.${message}`, { ns: 'common' }) as string, { type }) } const onChangeSiteStatus = async (value: boolean) => { diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx index 4201d114909..239427159c1 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx @@ -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' }) } } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx index ff78712c3c2..cc2143faac5 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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) diff --git a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx index 3fc677d8d80..25e529a2210 100644 --- a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx +++ b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx @@ -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() 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) => { - setIsShowAvatarPicker(false)} - > - - + !open && setIsShowAvatarPicker(false)}> + + + -
- +
+ - -
- + +
+
+
- setIsShowDeleteConfirm(false)} - > -
{t('avatar.deleteTitle', { ns: 'common' })}
-

{t('avatar.deleteDescription', { ns: 'common' })}

+ !open && setIsShowDeleteConfirm(false)}> + +
{t('avatar.deleteTitle', { ns: 'common' })}
+

{t('avatar.deleteDescription', { ns: 'common' })}

-
- +
+ - -
- + +
+
+
) } diff --git a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx index f0dfd4f12fd..2e2d61f2f93 100644 --- a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx +++ b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx @@ -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.start) const [code, setCode] = useState('') @@ -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 ( - -
- -
- {step === STEP.start && ( - <> -
{t('account.changeEmail.title', { ns: 'common' })}
-
-
{t('account.changeEmail.authTip', { ns: 'common' })}
-
- }} - values={{ email }} + !open && onClose()}> + +
+ +
+ {step === STEP.start && ( + <> +
{t('account.changeEmail.title', { ns: 'common' })}
+
+
{t('account.changeEmail.authTip', { ns: 'common' })}
+
+ }} + values={{ email }} + /> +
+
+
+
+ + +
+ + )} + {step === STEP.verifyOrigin && ( + <> +
{t('account.changeEmail.verifyEmail', { ns: 'common' })}
+
+
+ }} + values={{ email }} + /> +
+
+
+
{t('account.changeEmail.codeLabel', { ns: 'common' })}
+ setCode(e.target.value)} + maxLength={6} />
-
-
-
- - -
- - )} - {step === STEP.verifyOrigin && ( - <> -
{t('account.changeEmail.verifyEmail', { ns: 'common' })}
-
-
- }} - values={{ email }} +
+ + +
+
+ {t('account.changeEmail.resendTip', { ns: 'common' })} + {time > 0 && ( + {t('account.changeEmail.resendCount', { ns: 'common', count: time })} + )} + {!time && ( + {t('account.changeEmail.resend', { ns: 'common' })} + )} +
+ + )} + {step === STEP.newEmail && ( + <> +
{t('account.changeEmail.newEmail', { ns: 'common' })}
+
+
{t('account.changeEmail.content3', { ns: 'common' })}
+
+
+
{t('account.changeEmail.emailLabel', { ns: 'common' })}
+ handleNewEmailValueChange(e.target.value)} + destructive={newEmailExited || unAvailableEmail} + /> + {newEmailExited && ( +
{t('account.changeEmail.existingEmail', { ns: 'common' })}
+ )} + {unAvailableEmail && ( +
{t('account.changeEmail.unAvailableEmail', { ns: 'common' })}
+ )} +
+
+ + +
+ + )} + {step === STEP.verifyNew && ( + <> +
{t('account.changeEmail.verifyNew', { ns: 'common' })}
+
+
+ }} + values={{ email: mail }} + /> +
+
+
+
{t('account.changeEmail.codeLabel', { ns: 'common' })}
+ setCode(e.target.value)} + maxLength={6} />
-
-
-
{t('account.changeEmail.codeLabel', { ns: 'common' })}
- setCode(e.target.value)} - maxLength={6} - /> -
-
- - -
-
- {t('account.changeEmail.resendTip', { ns: 'common' })} - {time > 0 && ( - {t('account.changeEmail.resendCount', { ns: 'common', count: time })} - )} - {!time && ( - {t('account.changeEmail.resend', { ns: 'common' })} - )} -
- - )} - {step === STEP.newEmail && ( - <> -
{t('account.changeEmail.newEmail', { ns: 'common' })}
-
-
{t('account.changeEmail.content3', { ns: 'common' })}
-
-
-
{t('account.changeEmail.emailLabel', { ns: 'common' })}
- handleNewEmailValueChange(e.target.value)} - destructive={newEmailExited || unAvailableEmail} - /> - {newEmailExited && ( -
{t('account.changeEmail.existingEmail', { ns: 'common' })}
- )} - {unAvailableEmail && ( -
{t('account.changeEmail.unAvailableEmail', { ns: 'common' })}
- )} -
-
- - -
- - )} - {step === STEP.verifyNew && ( - <> -
{t('account.changeEmail.verifyNew', { ns: 'common' })}
-
-
- }} - values={{ email: mail }} - /> +
+ +
-
-
-
{t('account.changeEmail.codeLabel', { ns: 'common' })}
- setCode(e.target.value)} - maxLength={6} - /> -
-
- - -
-
- {t('account.changeEmail.resendTip', { ns: 'common' })} - {time > 0 && ( - {t('account.changeEmail.resendCount', { ns: 'common', count: time })} - )} - {!time && ( - {t('account.changeEmail.resend', { ns: 'common' })} - )} -
- - )} - +
+ {t('account.changeEmail.resendTip', { ns: 'common' })} + {time > 0 && ( + {t('account.changeEmail.resendCount', { ns: 'common', count: time })} + )} + {!time && ( + {t('account.changeEmail.resend', { ns: 'common' })} + )} +
+ + )} + + ) } diff --git a/web/app/account/(commonLayout)/account-page/index.tsx b/web/app/account/(commonLayout)/account-page/index.tsx index 9a104619da7..7b4a1485300 100644 --- a/web/app/account/(commonLayout)/account-page/index.tsx +++ b/web/app/account/(commonLayout)/account-page/index.tsx @@ -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() {
{ editNameModalVisible && ( - setEditNameModalVisible(false)} - className="!w-[420px] !p-6" - > -
{t('account.editName', { ns: 'common' })}
-
{t('account.name', { ns: 'common' })}
- setEditName(e.target.value)} - /> -
- - -
-
+ !open && setEditNameModalVisible(false)}> + +
{t('account.editName', { ns: 'common' })}
+
{t('account.name', { ns: 'common' })}
+ setEditName(e.target.value)} + /> +
+ + +
+
+
) } { editPasswordModalVisible && ( - { - setEditPasswordModalVisible(false) - resetPasswordForm() - }} - className="!w-[420px] !p-6" - > -
{userProfile.is_password_set ? t('account.resetPassword', { ns: 'common' }) : t('account.setPassword', { ns: 'common' })}
- {userProfile.is_password_set && ( - <> -
{t('account.currentPassword', { ns: 'common' })}
-
- setCurrentPassword(e.target.value)} - /> + !open && (setEditPasswordModalVisible(false), resetPasswordForm())}> + +
{userProfile.is_password_set ? t('account.resetPassword', { ns: 'common' }) : t('account.setPassword', { ns: 'common' })}
+ {userProfile.is_password_set && ( + <> +
{t('account.currentPassword', { ns: 'common' })}
+
+ setCurrentPassword(e.target.value)} + /> -
- +
+ +
+ + )} +
+ {userProfile.is_password_set ? t('account.newPassword', { ns: 'common' }) : t('account.password', { ns: 'common' })} +
+
+ setPassword(e.target.value)} + /> +
+
- - )} -
- {userProfile.is_password_set ? t('account.newPassword', { ns: 'common' }) : t('account.password', { ns: 'common' })} -
-
- setPassword(e.target.value)} - /> -
+
+
{t('account.confirmPassword', { ns: 'common' })}
+
+ setConfirmPassword(e.target.value)} + /> +
+ +
+
+
+
-
-
{t('account.confirmPassword', { ns: 'common' })}
-
- setConfirmPassword(e.target.value)} - /> -
- -
-
-
- - -
- + +
) } { diff --git a/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx b/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx index ae73d778f8e..60bd7e5c0da 100644 --- a/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx +++ b/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx @@ -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]) diff --git a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts index deea28ce3ea..d5eaa4bfe4b 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts +++ b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts @@ -2,7 +2,16 @@ import { act, renderHook } from '@testing-library/react' import { AppModeEnum } from '@/types/app' import { useAppInfoActions } from '../use-app-info-actions' -const mockNotify = vi.fn() +const toastMocks = vi.hoisted(() => { + const call = vi.fn() + return { + call, + api: vi.fn((message: unknown, options?: Record) => call({ message, ...options })), + dismiss: vi.fn(), + update: vi.fn(), + promise: vi.fn(), + } +}) const mockReplace = vi.fn() const mockOnPlanInfoChanged = vi.fn() const mockInvalidateAppList = vi.fn() @@ -27,10 +36,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 +47,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(toastMocks.api, { + success: vi.fn((message, options) => toastMocks.call({ type: 'success', message, ...options })), + error: vi.fn((message, options) => toastMocks.call({ type: 'error', message, ...options })), + warning: vi.fn((message, options) => toastMocks.call({ type: 'warning', message, ...options })), + info: vi.fn((message, options) => toastMocks.call({ type: 'info', message, ...options })), + dismiss: toastMocks.dismiss, + update: toastMocks.update, + promise: toastMocks.promise, + }), })) vi.mock('@/service/use-apps', () => ({ @@ -175,7 +188,7 @@ describe('useAppInfoActions', () => { expect(mockUpdateAppInfo).toHaveBeenCalled() expect(mockSetAppDetail).toHaveBeenCalledWith(updatedApp) - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.editDone' }) + expect(toastMocks.call).toHaveBeenCalledWith({ type: 'success', message: 'app.editDone' }) }) it('should notify error on edit failure', async () => { @@ -194,7 +207,7 @@ describe('useAppInfoActions', () => { }) }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.editFailed' }) + expect(toastMocks.call).toHaveBeenCalledWith({ type: 'error', message: 'app.editFailed' }) }) it('should not call updateAppInfo when appDetail is undefined', async () => { @@ -234,7 +247,7 @@ describe('useAppInfoActions', () => { }) expect(mockCopyApp).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' }) + expect(toastMocks.call).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' }) expect(mockOnPlanInfoChanged).toHaveBeenCalled() }) @@ -252,7 +265,7 @@ describe('useAppInfoActions', () => { }) }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.newApp.appCreateFailed' }) + expect(toastMocks.call).toHaveBeenCalledWith({ type: 'error', message: 'app.newApp.appCreateFailed' }) }) }) @@ -298,7 +311,7 @@ describe('useAppInfoActions', () => { await result.current.onExport() }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' }) + expect(toastMocks.call).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' }) }) }) @@ -410,7 +423,7 @@ describe('useAppInfoActions', () => { await result.current.handleConfirmExport() }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' }) + expect(toastMocks.call).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' }) }) }) @@ -456,7 +469,7 @@ describe('useAppInfoActions', () => { }) expect(mockDeleteApp).toHaveBeenCalledWith('app-1') - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.appDeleted' }) + expect(toastMocks.call).toHaveBeenCalledWith({ type: 'success', message: 'app.appDeleted' }) expect(mockInvalidateAppList).toHaveBeenCalled() expect(mockReplace).toHaveBeenCalledWith('/apps') expect(mockSetAppDetail).toHaveBeenCalledWith() @@ -483,7 +496,7 @@ describe('useAppInfoActions', () => { await result.current.onConfirmDelete() }) - expect(mockNotify).toHaveBeenCalledWith({ + expect(toastMocks.call).toHaveBeenCalledWith({ type: 'error', message: expect.stringContaining('app.appDeleteFailed'), }) diff --git a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts index 55ec13e506f..8b559f7bbaa 100644 --- a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts +++ b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts @@ -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, diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index 528bac831fb..1d1208e7d3c 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -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') } diff --git a/web/app/components/app/annotation/add-annotation-modal/index.spec.tsx b/web/app/components/app/annotation/add-annotation-modal/index.spec.tsx index bad3ceefdf9..14f94d910bc 100644 --- a/web/app/components/app/annotation/add-annotation-modal/index.spec.tsx +++ b/web/app/components/app/annotation/add-annotation-modal/index.spec.tsx @@ -9,10 +9,16 @@ 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)), }, + toast: { + success: (message: string) => mockToastNotify({ type: 'success', message }), + error: (message: string) => mockToastNotify({ type: 'error', message }), + warning: (message: string) => mockToastNotify({ type: 'warning', message }), + info: (message: string) => mockToastNotify({ type: 'info', message }), + }, })) vi.mock('@/app/components/billing/annotation-full', () => ({ diff --git a/web/app/components/app/annotation/add-annotation-modal/index.tsx b/web/app/components/app/annotation/add-annotation-modal/index.tsx index a3100d51313..d4cc943a574 100644 --- a/web/app/components/app/annotation/add-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/add-annotation-modal/index.tsx @@ -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 = ({ answer, } if (isValid(payload) !== true) { - Toast.notify({ - type: 'error', - message: isValid(payload) as string, - }) + toast.error(isValid(payload) as string) return } diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.spec.tsx index 55f5ee0564d..847db746195 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.spec.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.spec.tsx @@ -1,11 +1,28 @@ 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' +const toastMocks = vi.hoisted(() => ({ + notify: vi.fn(), + dismiss: vi.fn(), + update: vi.fn(), + promise: vi.fn(), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + success: (message: string, options?: Record) => toastMocks.notify({ type: 'success', message, ...options }), + error: (message: string, options?: Record) => toastMocks.notify({ type: 'error', message, ...options }), + warning: (message: string, options?: Record) => toastMocks.notify({ type: 'warning', message, ...options }), + info: (message: string, options?: Record) => toastMocks.notify({ type: 'info', message, ...options }), + dismiss: toastMocks.dismiss, + update: toastMocks.update, + promise: toastMocks.promise, + }, +})) + describe('CSVUploader', () => { - const notify = vi.fn() const updateFile = vi.fn() const getDropElements = () => { @@ -24,9 +41,8 @@ describe('CSVUploader', () => { ...props, } return render( - - - , + , + ) } @@ -76,7 +92,7 @@ describe('CSVUploader', () => { fireEvent.drop(dropContainer, { dataTransfer: { files: [fileA, fileB] } }) - await waitFor(() => expect(notify).toHaveBeenCalledWith({ + await waitFor(() => expect(toastMocks.notify).toHaveBeenCalledWith({ type: 'error', message: 'datasetCreation.stepOne.uploader.validation.count', })) diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx index a969b3d491c..0fbd3974aa2 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx @@ -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 = ({ updateFile, }) => { const { t } = useTranslation() - const { notify } = useContext(ToastContext) const [dragging, setDragging] = useState(false) const dropRef = useRef(null) const dragRef = useRef(null) @@ -50,7 +48,7 @@ const CSVUploader: FC = ({ 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]) diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/index.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/index.spec.tsx index 7fdb99fbab1..8929cc292f9 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/index.spec.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/index.spec.tsx @@ -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,18 @@ vi.mock('@/app/components/billing/annotation-full', () => ({ default: () =>
, })) -const mockNotify = Toast.notify as Mock +const mockNotify = vi.fn() +vi.mock('@/app/components/base/ui/toast', () => ({ + default: { + notify: (args: unknown) => mockNotify(args), + }, + toast: { + success: (message: string) => mockNotify({ type: 'success', message }), + error: (message: string) => mockNotify({ type: 'error', message }), + warning: (message: string) => mockNotify({ type: 'warning', message }), + info: (message: string) => mockNotify({ type: 'info', message }), + }, +})) const useProviderContextMock = useProviderContext as Mock const annotationBatchImportMock = annotationBatchImport as Mock const checkAnnotationBatchImportProgressMock = checkAnnotationBatchImportProgress as Mock diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx index be1518b7085..f6d9512d3da 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx @@ -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 = ({ }, [isShow]) const [importStatus, setImportStatus] = useState() - const notify = Toast.notify const checkProcess = async (jobID: string) => { try { const res = await checkAnnotationBatchImportProgress({ jobID, appId }) @@ -54,15 +53,15 @@ const BatchModal: FC = ({ 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 = ({ 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}` : ''}`) } } diff --git a/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx b/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx index 0bbd1ab67d4..8f6dec42cfe 100644 --- a/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx +++ b/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx @@ -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: () =>
, })) -type ToastNotifyProps = Pick -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') }) }) }) diff --git a/web/app/components/app/annotation/edit-annotation-modal/index.tsx b/web/app/components/app/annotation/edit-annotation-modal/index.tsx index 2595ec38b2e..c0e60b65dcd 100644 --- a/web/app/components/app/annotation/edit-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/edit-annotation-modal/index.tsx @@ -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 = ({ 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 } diff --git a/web/app/components/app/annotation/index.spec.tsx b/web/app/components/app/annotation/index.spec.tsx index d62b60d33dd..5f5e9f74c07 100644 --- a/web/app/components/app/annotation/index.spec.tsx +++ b/web/app/components/app/annotation/index.spec.tsx @@ -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 ?
: null })) vi.mock('@/app/components/billing/annotation-full/modal', () => ({ default: (props: any) => props.show ?
: 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 diff --git a/web/app/components/app/annotation/index.tsx b/web/app/components/app/annotation/index.tsx index ee276603cc6..0ea25744ff3 100644 --- a/web/app/components/app/annotation/index.tsx +++ b/web/app/components/app/annotation/index.tsx @@ -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) => { 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) => { 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) => { 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) => { 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) => { 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!} diff --git a/web/app/components/app/app-access-control/access-control.spec.tsx b/web/app/components/app/app-access-control/access-control.spec.tsx index 3a5f2272edd..7411676586e 100644 --- a/web/app/components/app/app-access-control/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/access-control.spec.tsx @@ -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() }) }) diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx index 8d46e41a119..0c1c64eadc2 100644 --- a/web/app/components/app/app-access-control/index.tsx +++ b/web/app/components/app/app-access-control/index.tsx @@ -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 ( diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 74d6a19cc1e..649b225b231 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -35,8 +35,8 @@ 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 { toast } from '../../base/ui/toast' import ShortcutsName from '../../workflow/shortcuts-name' import { getKeyboardKeyCodeBySystem } from '../../workflow/utils' import AccessControl from '../app-access-control' @@ -219,7 +219,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]) diff --git a/web/app/components/app/app-publisher/version-info-modal.tsx b/web/app/components/app/app-publisher/version-info-modal.tsx index ee896cf5837..a1d6edcf04d 100644 --- a/web/app/components/app/app-publisher/version-info-modal.tsx +++ b/web/app/components/app/app-publisher/version-info-modal.tsx @@ -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 = ({ 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 = ({ 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 { diff --git a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx index 9625204d814..482f61bb82c 100644 --- a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx @@ -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 = ({ showSelectDataSet, externalDataToolsConfig, } = useContext(ConfigContext) - const { notify } = useToastContext() const { setShowExternalDataToolModal } = useModalContext() const handleOpenExternalDataToolModal = () => { setShowExternalDataToolModal({ @@ -94,7 +97,7 @@ const AdvancedPromptInput: FC = ({ 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 = ({
{t('pageTitle.line1', { ns: 'appDebug' })}
- + + )} + /> +
{t('promptTip', { ns: 'appDebug' })}
- )} - /> +
+
)}
diff --git a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx index 39a16990636..bc54e0f16dd 100644 --- a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx @@ -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 = ({ showSelectDataSet, externalDataToolsConfig, } = useContext(ConfigContext) - const { notify } = useToastContext() const { setShowExternalDataToolModal } = useModalContext() const handleOpenExternalDataToolModal = () => { setShowExternalDataToolModal({ @@ -92,7 +95,7 @@ const Prompt: FC = ({ 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 = ({
{mode !== AppModeEnum.COMPLETION ? t('chatSubTitle', { ns: 'appDebug' }) : t('completionSubTitle', { ns: 'appDebug' })}
{!readonly && ( - + + )} + /> +
{t('promptTip', { ns: 'appDebug' })}
- )} - /> +
+
)}
diff --git a/web/app/components/app/configuration/config-var/config-modal/index.tsx b/web/app/components/app/configuration/config-var/config-modal/index.tsx index 7ea784baa32..b864206b26d 100644 --- a/web/app/components/app/configuration/config-var/config-modal/index.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/index.tsx @@ -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 = ({ 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 @@ -219,10 +216,7 @@ const ConfigModal: FC = ({ 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) @@ -264,7 +258,7 @@ const ConfigModal: FC = ({ 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) { @@ -272,7 +266,7 @@ const ConfigModal: FC = ({ } 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 = {} @@ -285,7 +279,7 @@ const ConfigModal: FC = ({ 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) @@ -293,12 +287,12 @@ const ConfigModal: FC = ({ else if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) { 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) @@ -308,12 +302,12 @@ const ConfigModal: FC = ({ 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 } } diff --git a/web/app/components/app/configuration/config-var/index.spec.tsx b/web/app/components/app/configuration/config-var/index.spec.tsx index 096358c8058..a48d3233f5b 100644 --- a/web/app/components/app/configuration/config-var/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/index.spec.tsx @@ -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', () => { diff --git a/web/app/components/app/configuration/config-var/index.tsx b/web/app/components/app/configuration/config-var/index.tsx index 4d9a4e480fc..17f5e2efe53 100644 --- a/web/app/components/app/configuration/config-var/index.tsx +++ b/web/app/components/app/configuration/config-var/index.tsx @@ -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 = ({ 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 = ({ 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 } } diff --git a/web/app/components/app/configuration/config/agent/prompt-editor.tsx b/web/app/components/app/configuration/config/agent/prompt-editor.tsx index f719d872613..e807c21518e 100644 --- a/web/app/components/app/configuration/config/agent/prompt-editor.tsx +++ b/web/app/components/app/configuration/config/agent/prompt-editor.tsx @@ -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 = ({ }) => { const { t } = useTranslation() - const { notify } = useToastContext() - const [isCopied, setIsCopied] = React.useState(false) const { modelConfig, @@ -59,14 +57,14 @@ const Editor: FC = ({ 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 } } diff --git a/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx b/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx index 8ad284bcfb8..6c135fdee35 100644 --- a/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx +++ b/web/app/components/app/configuration/config/automatic/get-automatic-res.tsx @@ -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' @@ -159,13 +159,10 @@ const GetAutomaticRes: FC = ({ 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 = ({ } as GenRes if (error) { hasError = true - Toast.notify({ - type: 'error', - message: error, - }) + toast.error(error) } } else { @@ -260,10 +254,7 @@ const GetAutomaticRes: FC = ({ apiRes = res if (error) { hasError = true - Toast.notify({ - type: 'error', - message: error, - }) + toast.error(error) } } if (!hasError) diff --git a/web/app/components/app/configuration/config/automatic/result.tsx b/web/app/components/app/configuration/config/automatic/result.tsx index ef82007e515..776d774bd8e 100644 --- a/web/app/components/app/configuration/config/automatic/result.tsx +++ b/web/app/components/app/configuration/config/automatic/result.tsx @@ -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 = ({ className="px-2" onClick={() => { copy(current.modified) - Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) }) + toast.success(t('actionMsg.copySuccessfully', { ns: 'common' })) }} > diff --git a/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx b/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx index a7bc7ab97b7..6bdb59fa173 100644 --- a/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx +++ b/web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx @@ -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' @@ -97,13 +97,10 @@ export const GetCodeGeneratorResModal: FC = ( 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 = ( res.modified = (res as any).code if (error) { - Toast.notify({ - type: 'error', - message: error, - }) + toast.error(error) } else { addVersion(res) diff --git a/web/app/components/app/configuration/dataset-config/params-config/config-content.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/config-content.spec.tsx index 2cd8418c656..8a53e9a8b0f 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/config-content.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/config-content.spec.tsx @@ -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 const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as MockedFunction -let toastNotifySpy: MockInstance +let toastErrorSpy: MockInstance const baseRetrievalConfig: RetrievalConfig = { search_method: RETRIEVE_METHOD.semantic, @@ -172,7 +172,7 @@ const createDatasetConfigs = (overrides: Partial = {}): 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, diff --git a/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx b/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx index 6dd03d217e0..be0d1d9394c 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx @@ -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 = ({ 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 = ({ 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, diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.spec.tsx index ea70725ea81..7fdf9d0a23d 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.spec.tsx @@ -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' @@ -13,7 +12,24 @@ import { useMembers } from '@/service/use-common' import { RETRIEVE_METHOD } from '@/types/app' import SettingsModal from './index' -const mockNotify = vi.fn() +const toastMocks = vi.hoisted(() => ({ + call: vi.fn(), + dismiss: vi.fn(), + update: vi.fn(), + promise: vi.fn(), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: Object.assign(toastMocks.call, { + success: vi.fn((message: string, options?: Record) => toastMocks.call({ type: 'success', message, ...options })), + error: vi.fn((message: string, options?: Record) => toastMocks.call({ type: 'error', message, ...options })), + warning: vi.fn((message: string, options?: Record) => toastMocks.call({ type: 'warning', message, ...options })), + info: vi.fn((message: string, options?: Record) => toastMocks.call({ type: 'info', message, ...options })), + dismiss: toastMocks.dismiss, + update: toastMocks.update, + promise: toastMocks.promise, + }), +})) const mockOnCancel = vi.fn() const mockOnSave = vi.fn() const mockSetShowAccountSettingModal = vi.fn() @@ -183,13 +199,12 @@ const createDataset = (overrides: Partial = {}, retrievalOverrides: Par const renderWithProviders = (dataset: DataSet) => { return render( - - - , + , + ) } @@ -378,7 +393,7 @@ describe('SettingsModal', () => { await user.click(screen.getByRole('button', { name: 'common.operation.save' })) // Assert - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', message: 'datasetSettings.form.nameError', })) @@ -402,7 +417,7 @@ describe('SettingsModal', () => { await user.click(screen.getByRole('button', { name: 'common.operation.save' })) // Assert - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', message: 'appDebug.datasetConfig.rerankModelRequired', })) @@ -444,7 +459,7 @@ describe('SettingsModal', () => { permission: DatasetPermission.allTeamMembers, }), })) - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully', })) @@ -528,7 +543,7 @@ describe('SettingsModal', () => { // Assert await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) }) }) diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx index bc534599dec..8b2c4270cdc 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ }) } 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 = ({ }) } catch { - notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) }) + toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' })) } finally { setLoading(false) diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx index a75516a43ff..910a8fd2b58 100644 --- a/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx @@ -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(() => ({ diff --git a/web/app/components/app/configuration/debug/index.spec.tsx b/web/app/components/app/configuration/debug/index.spec.tsx index e94695f1ef1..61fe6730795 100644 --- a/web/app/components/app/configuration/debug/index.spec.tsx +++ b/web/app/components/app/configuration/debug/index.spec.tsx @@ -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' @@ -16,6 +15,10 @@ const mockState = vi.hoisted(() => ({ mockHandleRestart: vi.fn(), mockSetFeatures: vi.fn(), mockEventEmitterEmit: vi.fn(), + mockToastCall: vi.fn(), + mockToastDismiss: vi.fn(), + mockToastUpdate: vi.fn(), + mockToastPromise: vi.fn(), mockText2speechDefaultModel: null as unknown, mockStoreState: { currentLogItem: null as unknown, @@ -43,6 +46,22 @@ const mockState = vi.hoisted(() => ({ }, })) +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: Object.assign(mockState.mockToastCall, { + success: vi.fn((message: string, options?: Record) => + mockState.mockToastCall({ type: 'success', message, ...options })), + error: vi.fn((message: string, options?: Record) => + mockState.mockToastCall({ type: 'error', message, ...options })), + warning: vi.fn((message: string, options?: Record) => + mockState.mockToastCall({ type: 'warning', message, ...options })), + info: vi.fn((message: string, options?: Record) => + mockState.mockToastCall({ type: 'info', message, ...options })), + dismiss: mockState.mockToastDismiss, + update: mockState.mockToastUpdate, + promise: mockState.mockToastPromise, + }), +})) + vi.mock('@/app/components/app/configuration/debug/chat-user-input', () => ({ default: () =>
ChatUserInput
, })) @@ -215,19 +234,27 @@ vi.mock('./debug-with-multiple-model', () => ({ ), })) -vi.mock('./debug-with-single-model', () => ({ - default: React.forwardRef((props: { checkCanSend: () => boolean }, ref) => { +vi.mock('./debug-with-single-model', () => { + function DebugWithSingleModelMock({ + checkCanSend, + ref, + }: { + checkCanSend: () => boolean + ref?: React.Ref<{ handleRestart: () => void }> + }) { React.useImperativeHandle(ref, () => ({ handleRestart: mockState.mockHandleRestart, })) return (
- +
) - }), -})) + } + + return { default: DebugWithSingleModelMock } +}) const createContextValue = (overrides: Partial = {}): DebugContextValue => ({ readonly: false, @@ -376,7 +403,6 @@ const renderDebug = (options: { props?: Partial } = {}) => { const onSetting = vi.fn() - const notify = vi.fn() const props: ComponentProps = { isAPIKeySet: true, onSetting, @@ -392,14 +418,16 @@ const renderDebug = (options: { } render( - - - - - , + React.createElement( + ConfigContext.Provider, + { + value: createContextValue(options.contextValue), + children: , + }, + ), ) - return { onSetting, notify, props } + return { onSetting, notify: mockState.mockToastCall, props } } describe('Debug', () => { diff --git a/web/app/components/app/configuration/debug/index.tsx b/web/app/components/app/configuration/debug/index.tsx index cd07885f0cb..36cd4c34454 100644 --- a/web/app/components/app/configuration/debug/index.tsx +++ b/web/app/components/app/configuration/debug/index.tsx @@ -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 = ({ 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([]) 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 = ({ } 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 = ({ modelConfig.configs.prompt_variables, t, logError, - notify, modelModeType, ]) @@ -205,7 +206,7 @@ const Debug: FC = ({ 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 = ({ <> { !readonly && ( - - - - - - + + } /> + + {t('operation.refresh', { ns: 'common' })} + + ) } { varList.length > 0 && (
- - !readonly && setExpanded(!expanded)}> - - - + + !readonly && setExpanded(!expanded)}>} /> + + {t('panel.userInputField', { ns: 'workflow' })} + + {expanded &&
}
) diff --git a/web/app/components/app/configuration/index.tsx b/web/app/components/app/configuration/index.tsx index aa1bbe0a163..08df556d8e5 100644 --- a/web/app/components/app/configuration/index.tsx +++ b/web/app/components/app/configuration/index.tsx @@ -26,7 +26,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' @@ -48,8 +47,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 { @@ -93,7 +91,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 => ({ @@ -492,11 +489,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({}) } } @@ -767,23 +764,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 }) => ({ @@ -849,7 +846,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 diff --git a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx index dd7a0c6a6cc..1c9adca1d1f 100644 --- a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx +++ b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx @@ -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 = ({ }) => { 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 = ({ 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 = ({ const action = data.type ? t('operation.edit', { ns: 'common' }) : t('operation.add', { ns: 'common' }) return ( - -
- {`${action} ${t('variableConfig.apiBasedVar', { ns: 'appDebug' })}`} -
-
-
- {t('apiBasedExtension.type', { ns: 'common' })} + +
+ {`${action} ${t('variableConfig.apiBasedVar', { ns: 'appDebug' })}`}
- { - return { - value: option.key, - name: option.name, - } - })} - onSelect={item => handleDataTypeChange(item.value as string)} - /> -
-
-
- {t('feature.tools.modal.name.title', { ns: 'appDebug' })} +
+
+ {t('apiBasedExtension.type', { ns: 'common' })} +
+
-
- 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' }) || ''} - /> - { 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} - /> -
-
-
-
- {t('feature.tools.modal.variableName.title', { ns: 'appDebug' })} -
- 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' }) || ''} - /> -
- { - localeData.type === 'api' && ( -
-
- {t('apiBasedExtension.selector.title', { ns: 'common' })} - - - {t('apiBasedExtension.link', { ns: 'common' })} - -
- +
+ {t('feature.tools.modal.name.title', { ns: 'appDebug' })} +
+
+ 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' }) || ''} + /> + { 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} />
- ) - } - { - systemTypes.findIndex(t => t === localeData.type) < 0 - && currentProvider?.form_schema - && ( - +
+
+ {t('feature.tools.modal.variableName.title', { ns: 'appDebug' })} +
+ 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' }) || ''} /> - ) - } -
- - -
- { - showEmojiPicker && ( - { - handleValueChange({ icon, icon_background }) - setShowEmojiPicker(false) - }} - onClose={() => { - handleValueChange({ icon: '', icon_background: '' }) - setShowEmojiPicker(false) - }} - /> - ) - } - +
+ { + localeData.type === 'api' && ( +
+
+ {t('apiBasedExtension.selector.title', { ns: 'common' })} + + + {t('apiBasedExtension.link', { ns: 'common' })} + +
+ +
+ ) + } + { + systemTypes.findIndex(t => t === localeData.type) < 0 + && currentProvider?.form_schema + && ( + + ) + } +
+ + +
+ { + showEmojiPicker && ( + { + handleValueChange({ icon, icon_background }) + setShowEmojiPicker(false) + }} + onClose={() => { + handleValueChange({ icon: '', icon_background: '' }) + setShowEmojiPicker(false) + }} + /> + ) + } + + ) } diff --git a/web/app/components/app/configuration/tools/index.tsx b/web/app/components/app/configuration/tools/index.tsx index 51a9e87a973..8ab71c73cf5 100644 --- a/web/app/components/app/configuration/tools/index.tsx +++ b/web/app/components/app/configuration/tools/index.tsx @@ -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 = () => {
{t('feature.tools.title', { ns: 'appDebug' })}
- + } /> +
{t('feature.tools.tips', { ns: 'appDebug' })}
- )} - /> +
+
{ !expanded && !!externalDataToolsConfig.length && ( @@ -151,18 +154,23 @@ const Tools = () => { background={item.icon_background} />
{item.label}
- -
{ - copy(item.variable || '') - setCopied(true) - }} - > - {item.variable} -
+ + { + copy(item.variable || '') + setCopied(true) + }} + > + {item.variable} +
+ )} + /> + + {copied ? t('copied', { ns: 'appApi' }) : `${item.variable}, ${t('copy', { ns: 'appApi' })}`} +
({ vi.mock('@/service/apps', () => ({ createApp: vi.fn(), })) +const toastMocks = vi.hoisted(() => ({ + mockToastSuccess: vi.fn(), + mockToastError: vi.fn(), +})) +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + success: toastMocks.mockToastSuccess, + error: toastMocks.mockToastError, + }, +})) vi.mock('@/utils/app-redirection', () => ({ getRedirection: vi.fn(), })) @@ -48,7 +57,6 @@ vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light' }), })) -const mockNotify = vi.fn() const mockUseRouter = vi.mocked(useRouter) const mockPush = vi.fn() const mockCreateApp = vi.mocked(createApp) @@ -56,6 +64,7 @@ const mockTrackEvent = vi.mocked(trackEvent) const mockGetRedirection = vi.mocked(getRedirection) const mockUseProviderContext = vi.mocked(useProviderContext) const mockUseAppContext = vi.mocked(useAppContext) +const { mockToastSuccess, mockToastError } = toastMocks const defaultPlanUsage = { buildApps: 0, @@ -70,11 +79,7 @@ const defaultPlanUsage = { const renderModal = () => { const onClose = vi.fn() const onSuccess = vi.fn() - render( - - - , - ) + render() return { onClose, onSuccess } } @@ -140,7 +145,7 @@ describe('CreateAppModal', () => { app_mode: AppModeEnum.ADVANCED_CHAT, description: '', }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' }) + expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated') expect(onSuccess).toHaveBeenCalled() expect(onClose).toHaveBeenCalled() await waitFor(() => expect(mockSetItem).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY, '1')) @@ -156,7 +161,7 @@ describe('CreateAppModal', () => { fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Create/ })) await waitFor(() => expect(mockCreateApp).toHaveBeenCalled()) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'boom' }) + expect(mockToastError).toHaveBeenCalledWith('boom') expect(onClose).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index 556773c3411..8750b732b14 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -6,7 +6,6 @@ import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon import { useDebounceFn, useKeyPress } from 'ahooks' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import { trackEvent } from '@/app/components/base/amplitude' import AppIcon from '@/app/components/base/app-icon' import Button from '@/app/components/base/button' @@ -15,7 +14,7 @@ import FullScreenModal from '@/app/components/base/fullscreen-modal' import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication' import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' -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' @@ -40,7 +39,6 @@ type CreateAppProps = { function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: CreateAppProps) { const { t } = useTranslation() const { push } = useRouter() - const { notify } = useContext(ToastContext) const [appMode, setAppMode] = useState(defaultAppMode || AppModeEnum.ADVANCED_CHAT) const [appIcon, setAppIcon] = useState({ type: 'emoji', icon: '🤖', background: '#FFEAD5' }) @@ -62,11 +60,11 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: const onCreate = useCallback(async () => { if (!appMode) { - notify({ type: 'error', message: t('newApp.appTypeRequired', { ns: 'app' }) }) + toast.error(t('newApp.appTypeRequired', { ns: 'app' })) return } if (!name.trim()) { - notify({ type: 'error', message: t('newApp.nameNotEmpty', { ns: 'app' }) }) + toast.error(t('newApp.nameNotEmpty', { ns: 'app' })) return } if (isCreatingRef.current) @@ -88,20 +86,17 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: description, }) - notify({ type: 'success', message: t('newApp.appCreated', { ns: 'app' }) }) + toast.success(t('newApp.appCreated', { ns: 'app' })) onSuccess() onClose() localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') getRedirection(isCurrentWorkspaceEditor, app, push) } catch (e: any) { - notify({ - type: 'error', - message: e.message || t('newApp.appCreateFailed', { ns: 'app' }), - }) + toast.error(e.message || t('newApp.appCreateFailed', { ns: 'app' })) } isCreatingRef.current = false - }, [name, notify, t, appMode, appIcon, description, onSuccess, onClose, push, isCurrentWorkspaceEditor]) + }, [name, t, appMode, appIcon, description, onSuccess, onClose, push, isCurrentWorkspaceEditor]) const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 }) useKeyPress(['meta.enter', 'ctrl.enter'], () => { diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index eaaee509733..dd17655e3cb 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -6,12 +6,11 @@ import { useDebounceFn, 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 { 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' @@ -48,7 +47,6 @@ export enum CreateFromDSLModalTab { const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDSLModalTab.FROM_FILE, dslUrl = '', droppedFile }: CreateFromDSLModalProps) => { const { push } = useRouter() const { t } = useTranslation() - const { notify } = useContext(ToastContext) const [currentFile, setDSLFile] = useState(droppedFile) const [fileContent, setFileContent] = useState() const [currentTab, setCurrentTab] = useState(activeTab) @@ -126,10 +124,11 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS if (onClose) onClose() - notify({ + toast(t(status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution', { ns: 'app' }), { 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' }), + description: status === DSLImportStatus.COMPLETED_WITH_WARNINGS + ? t('newApp.appCreateDSLWarning', { ns: 'app' }) + : undefined, }) localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') if (app_id) @@ -147,12 +146,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' })) } isCreatingRef.current = false } @@ -185,22 +184,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' })) } } diff --git a/web/app/components/app/create-from-dsl-modal/uploader.tsx b/web/app/components/app/create-from-dsl-modal/uploader.tsx index 74c8e5f48ec..3dcab1c6d6f 100644 --- a/web/app/components/app/create-from-dsl-modal/uploader.tsx +++ b/web/app/components/app/create-from-dsl-modal/uploader.tsx @@ -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 = ({ displayName = 'YAML', }) => { const { t } = useTranslation() - const { notify } = useContext(ToastContext) const [dragging, setDragging] = useState(false) const dropRef = useRef(null) const dragRef = useRef(null) @@ -60,7 +58,7 @@ const Uploader: FC = ({ 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]) diff --git a/web/app/components/app/duplicate-modal/index.spec.tsx b/web/app/components/app/duplicate-modal/index.spec.tsx index ef126465715..e70329a1052 100644 --- a/web/app/components/app/duplicate-modal/index.spec.tsx +++ b/web/app/components/app/duplicate-modal/index.spec.tsx @@ -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() }) diff --git a/web/app/components/app/duplicate-modal/index.tsx b/web/app/components/app/duplicate-modal/index.tsx index 7d5b122f699..b2ba7f1d0f7 100644 --- a/web/app/components/app/duplicate-modal/index.tsx +++ b/web/app/components/app/duplicate-modal/index.tsx @@ -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({ diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 453c7c9d4c8..4a22a0c85fb 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -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 => { @@ -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 => { @@ -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 => { 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 } } diff --git a/web/app/components/app/overview/settings/index.spec.tsx b/web/app/components/app/overview/settings/index.spec.tsx index e933855ca8d..d6f9612f751 100644 --- a/web/app/components/app/overview/settings/index.spec.tsx +++ b/web/app/components/app/overview/settings/index.spec.tsx @@ -29,7 +29,24 @@ vi.mock('react-i18next', async () => { } }) -const mockNotify = vi.fn() +const toastMocks = vi.hoisted(() => ({ + call: vi.fn(), + dismiss: vi.fn(), + update: vi.fn(), + promise: vi.fn(), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: Object.assign(toastMocks.call, { + success: vi.fn((message: string, options?: Record) => toastMocks.call({ type: 'success', message, ...options })), + error: vi.fn((message: string, options?: Record) => toastMocks.call({ type: 'error', message, ...options })), + warning: vi.fn((message: string, options?: Record) => toastMocks.call({ type: 'warning', message, ...options })), + info: vi.fn((message: string, options?: Record) => toastMocks.call({ type: 'info', message, ...options })), + dismiss: toastMocks.dismiss, + update: toastMocks.update, + promise: toastMocks.promise, + }), +})) const mockOnClose = vi.fn() const mockOnSave = vi.fn() const mockSetShowPricingModal = vi.fn() @@ -56,13 +73,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('@/context/i18n') return { @@ -112,7 +122,7 @@ const renderSettingsModal = () => render( describe('SettingsModal', () => { beforeEach(() => { - mockNotify.mockClear() + toastMocks.call.mockClear() mockOnClose.mockClear() mockOnSave.mockClear() mockSetShowPricingModal.mockClear() @@ -152,7 +162,7 @@ describe('SettingsModal', () => { fireEvent.click(screen.getByText('common.operation.save')) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ message: 'app.newApp.nameNotEmpty' })) + expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ message: 'app.newApp.nameNotEmpty' })) }) expect(mockOnSave).not.toHaveBeenCalled() }) @@ -164,7 +174,7 @@ describe('SettingsModal', () => { fireEvent.click(screen.getByText('common.operation.save')) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ message: 'appOverview.overview.appInfo.settings.invalidHexMessage', })) }) @@ -180,7 +190,7 @@ describe('SettingsModal', () => { fireEvent.click(screen.getByText('common.operation.save')) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + expect(toastMocks.call).toHaveBeenCalledWith(expect.objectContaining({ message: 'appOverview.overview.appInfo.settings.invalidPrivacyPolicy', })) }) diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx index 13dacde4245..0d77d32ec41 100644 --- a/web/app/components/app/overview/settings/index.tsx +++ b/web/app/components/app/overview/settings/index.tsx @@ -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 = ({ onClose, onSave, }) => { - const { notify } = useToastContext() const [isShowMore, setIsShowMore] = useState(false) const { title, @@ -159,7 +158,7 @@ const SettingsModal: FC = ({ 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 = ({ 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 } } diff --git a/web/app/components/app/switch-app-modal/index.spec.tsx b/web/app/components/app/switch-app-modal/index.spec.tsx index 53007b986b2..147edeb5edd 100644 --- a/web/app/components/app/switch-app-modal/index.spec.tsx +++ b/web/app/components/app/switch-app-modal/index.spec.tsx @@ -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' @@ -108,27 +107,44 @@ const createMockApp = (overrides: Partial = {}): App => ({ ...overrides, }) +const toastMocks = vi.hoisted(() => ({ + notify: vi.fn(), + dismiss: vi.fn(), + update: vi.fn(), + promise: vi.fn(), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + success: (message: string, options?: Record) => toastMocks.notify({ type: 'success', message, ...options }), + error: (message: string, options?: Record) => toastMocks.notify({ type: 'error', message, ...options }), + warning: (message: string, options?: Record) => toastMocks.notify({ type: 'warning', message, ...options }), + info: (message: string, options?: Record) => toastMocks.notify({ type: 'info', message, ...options }), + dismiss: toastMocks.dismiss, + update: toastMocks.update, + promise: toastMocks.promise, + }, +})) + const renderComponent = (overrides: Partial> = {}) => { - const notify = vi.fn() const onClose = vi.fn() const onSuccess = vi.fn() const appDetail = createMockApp() const utils = render( - - - , + , + ) return { ...utils, - notify, + notify: toastMocks.notify, onClose, onSuccess, appDetail, diff --git a/web/app/components/app/switch-app-modal/index.tsx b/web/app/components/app/switch-app-modal/index.tsx index 7c3269d52cf..ffa5dc6ef46 100644 --- a/web/app/components/app/switch-app-modal/index.tsx +++ b/web/app/components/app/switch-app-modal/index.tsx @@ -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' })) } } diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx index d22375a2926..ab96077f679 100644 --- a/web/app/components/app/text-generate/item/index.tsx +++ b/web/app/components/app/text-generate/item/index.tsx @@ -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 = ({ 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() @@ -366,7 +366,7 @@ const GenerationItem: FC = ({ copy(copyContent) else copy(JSON.stringify(copyContent)) - Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) }) + toast.success(t('actionMsg.copySuccessfully', { ns: 'common' })) }} > diff --git a/web/app/components/app/text-generate/saved-items/index.spec.tsx b/web/app/components/app/text-generate/saved-items/index.spec.tsx index b45a1cca6c6..dff0950f897 100644 --- a/web/app/components/app/text-generate/saved-items/index.spec.tsx +++ b/web/app/components/app/text-generate/saved-items/index.spec.tsx @@ -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') diff --git a/web/app/components/app/text-generate/saved-items/index.tsx b/web/app/components/app/text-generate/saved-items/index.tsx index 36006402c4f..cd43f354f32 100644 --- a/web/app/components/app/text-generate/saved-items/index.tsx +++ b/web/app/components/app/text-generate/saved-items/index.tsx @@ -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 = ({ {isShowTextToSpeech && } { copy(answer) - Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) }) + toast.success(t('actionMsg.copySuccessfully', { ns: 'common' })) }} > diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index 86c87e0c5bc..d1e89b7a850 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -17,16 +17,36 @@ vi.mock('@/next/navigation', () => ({ }), })) -// Mock use-context-selector with stable mockNotify reference for tracking calls +const toastMocks = vi.hoisted(() => { + const record = vi.fn() + const api = vi.fn((message: unknown, options?: Record) => record({ message, ...options })) + return { + record, + api: Object.assign(api, { + success: vi.fn((message: unknown, options?: Record) => record({ type: 'success', message, ...options })), + error: vi.fn((message: unknown, options?: Record) => record({ type: 'error', message, ...options })), + warning: vi.fn((message: unknown, options?: Record) => record({ type: 'warning', message, ...options })), + info: vi.fn((message: unknown, options?: Record) => record({ type: 'info', message, ...options })), + dismiss: vi.fn(), + update: vi.fn(), + promise: vi.fn(), + }), + } +}) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: toastMocks.api, +})) + +// Mock use-context-selector with stable toast reference for tracking calls // Include createContext for components that use it (like Toast) -const mockNotify = vi.fn() vi.mock('use-context-selector', () => ({ createContext: (defaultValue: T) => React.createContext(defaultValue), useContext: () => ({ - notify: mockNotify, + notify: toastMocks.api, }), useContextSelector: (_context: unknown, selector: (state: Record) => unknown) => selector({ - notify: mockNotify, + notify: toastMocks.api, }), })) @@ -591,7 +611,7 @@ describe('AppCard', () => { await waitFor(() => { expect(mockDeleteAppMutation).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: expect.stringContaining('Delete failed') }) + expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: expect.stringContaining('Delete failed') }) }) }) @@ -670,7 +690,7 @@ describe('AppCard', () => { await waitFor(() => { expect(appsService.copyApp).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.newApp.appCreateFailed' }) + expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.newApp.appCreateFailed' }) }) }) @@ -699,7 +719,7 @@ describe('AppCard', () => { await waitFor(() => { expect(appsService.exportAppConfig).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' }) + expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' }) }) }) }) @@ -945,7 +965,7 @@ describe('AppCard', () => { await waitFor(() => { expect(appsService.updateAppInfo).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: expect.stringContaining('Edit failed') }) + expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: expect.stringContaining('Edit failed') }) }) }) @@ -998,7 +1018,7 @@ describe('AppCard', () => { await waitFor(() => { expect(workflowService.fetchWorkflowDraft).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' }) + expect(toastMocks.record).toHaveBeenCalledWith({ type: 'error', message: 'app.exportFailed' }) }) }) }) diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index 9a8abf64439..c1131ad2d44 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -10,14 +10,11 @@ import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLi import * as React from 'react' import { useCallback, useEffect, useMemo, useState } 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, @@ -28,6 +25,7 @@ import { AlertDialogDescription, AlertDialogTitle, } from '@/app/components/base/ui/alert-dialog' +import { toast } from '@/app/components/base/ui/toast' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' @@ -71,7 +69,6 @@ export type AppCardProps = { const AppCard = ({ app, onRefresh }: AppCardProps) => { const { t } = useTranslation() - const { notify } = useContext(ToastContext) const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { isCurrentWorkspaceEditor } = useAppContext() const { onPlanInfoChanged } = useProviderContext() @@ -90,20 +87,17 @@ const AppCard = ({ app, onRefresh }: 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: any) { - notify({ - type: 'error', - message: `${t('appDeleteFailed', { ns: 'app' })}${'message' in e ? `: ${e.message}` : ''}`, - }) + toast.error(`${t('appDeleteFailed', { ns: 'app' })}${'message' in e ? `: ${e.message}` : ''}`) } finally { setShowConfirmDelete(false) setConfirmDeleteInput('') } - }, [app.id, mutateDeleteApp, notify, onPlanInfoChanged, t]) + }, [app.id, mutateDeleteApp, onPlanInfoChanged, t]) const onDeleteDialogOpenChange = useCallback((open: boolean) => { if (isDeleting) @@ -135,20 +129,14 @@ const AppCard = ({ app, onRefresh }: 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: any) { - notify({ - type: 'error', - message: e.message || t('editFailed', { ns: 'app' }), - }) + toast.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 { @@ -161,10 +149,7 @@ const AppCard = ({ app, onRefresh }: 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() @@ -172,7 +157,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { getRedirection(isCurrentWorkspaceEditor, newApp, push) } catch { - notify({ type: 'error', message: t('newApp.appCreateFailed', { ns: 'app' }) }) + toast.error(t('newApp.appCreateFailed', { ns: 'app' })) } } @@ -186,7 +171,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { downloadBlob({ data: file, fileName: `${app.name}.yml` }) } catch { - notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) }) + toast.error(t('exportFailed', { ns: 'app' })) } } @@ -205,7 +190,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { setSecretEnvList(list) } catch { - notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) }) + toast.error(t('exportFailed', { ns: 'app' })) } } @@ -274,13 +259,13 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { throw new Error('No app found in Explore') }, { onError: (err) => { - Toast.notify({ type: 'error', message: `${err.message || err}` }) + toast.error(`${err.message || err}`) }, }) } catch (e: unknown) { const message = e instanceof Error ? e.message : `${e}` - Toast.notify({ type: 'error', message }) + toast.error(message) } } return ( diff --git a/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx index 8b796435e09..6ce1e54a47c 100644 --- a/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx @@ -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 }) => (
{title} {typeof value === 'string' ? value : JSON.stringify(value)} @@ -76,19 +92,13 @@ const createMockResponse = (overrides: Partial = {}): Ag }) describe('AgentLogDetail', () => { - const notify = vi.fn() - const renderComponent = (props: Partial> = {}) => { const defaultProps: ComponentProps = { conversationID: 'conv-id', messageID: 'msg-id', log: createMockLog(), } - return render( - ['value']}> - - , - ) + return render() } const renderAndWaitForData = async (props: Partial> = {}) => { @@ -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') }) }) diff --git a/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx index b2db5244535..d1581c40b52 100644 --- a/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx @@ -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( - ['value']}> - - , - ) + render() 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( - ['value']}> - - , - ) + render() const closeBtn = screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i }).nextElementSibling! fireEvent.click(closeBtn) @@ -130,11 +138,7 @@ describe('AgentLogModal', () => { clickAwayHandler = callback }) - render( - ['value']}> - - , - ) + render() clickAwayHandler(new Event('click')) expect(mockProps.onCancel).toHaveBeenCalledTimes(1) @@ -150,11 +154,7 @@ describe('AgentLogModal', () => { } }) - render( - ['value']}> - - , - ) + render() expect(mockProps.onCancel).not.toHaveBeenCalled() }) diff --git a/web/app/components/base/agent-log-modal/detail.tsx b/web/app/components/base/agent-log-modal/detail.tsx index 21ed0be7e8c..6550b305f87 100644 --- a/web/app/components/base/agent-log-modal/detail.tsx +++ b/web/app/components/base/agent-log-modal/detail.tsx @@ -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 = ({ - activeTab = 'DETAIL', - conversationID, - messageID, - log, -}) => { +const AgentLogDetail: FC = ({ activeTab = 'DETAIL', conversationID, messageID, log }) => { const { t } = useTranslation() - const { notify } = useContext(ToastContext) const [currentTab, setCurrentTab] = useState(activeTab) const appDetail = useAppStore(s => s.appDetail) const [loading, setLoading] = useState(true) const [runDetail, setRunDetail] = useState() const [list, setList] = useState([]) - 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 = ({ 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 (
{/* tab */}
-
switchTab('DETAIL')} - > +
switchTab('DETAIL')}> {t('detail', { ns: 'runLog' })}
-
switchTab('TRACING')} - > +
switchTab('TRACING')}> {t('tracing', { ns: 'runLog' })}
@@ -112,29 +81,10 @@ const AgentLogDetail: FC = ({
)} - {!loading && currentTab === 'DETAIL' && runDetail && ( - - )} - {!loading && currentTab === 'TRACING' && ( - - )} + {!loading && currentTab === 'DETAIL' && runDetail && ()} + {!loading && currentTab === 'TRACING' && ()}
) } - export default AgentLogDetail diff --git a/web/app/components/base/agent-log-modal/index.stories.tsx b/web/app/components/base/agent-log-modal/index.stories.tsx index 87318848b4f..e8b49600a57 100644 --- a/web/app/components/base/agent-log-modal/index.stories.tsx +++ b/web/app/components/base/agent-log-modal/index.stories.tsx @@ -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 ( - + <> +
-
+ ) } diff --git a/web/app/components/base/audio-btn/__tests__/audio.spec.ts b/web/app/components/base/audio-btn/__tests__/audio.spec.ts index 00ffea2dfb0..4399cb40fd0 100644 --- a/web/app/components/base/audio-btn/__tests__/audio.spec.ts +++ b/web/app/components/base/audio-btn/__tests__/audio.spec.ts @@ -6,9 +6,9 @@ import AudioPlayer from '../audio' const mockToastNotify = vi.hoisted(() => vi.fn()) const mockTextToAudioStream = vi.hoisted(() => vi.fn()) -vi.mock('@/app/components/base/toast', () => ({ - default: { - notify: (...args: unknown[]) => mockToastNotify(...args), +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: (message: string) => mockToastNotify({ type: 'error', message }), }, })) diff --git a/web/app/components/base/audio-btn/audio.ts b/web/app/components/base/audio-btn/audio.ts index abfcad7c2f7..5afe2bb656f 100644 --- a/web/app/components/base/audio-btn/audio.ts +++ b/web/app/components/base/audio-btn/audio.ts @@ -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) } diff --git a/web/app/components/base/audio-gallery/AudioPlayer.tsx b/web/app/components/base/audio-gallery/AudioPlayer.tsx index cbf50ddc13f..5a0a753ecf5 100644 --- a/web/app/components/base/audio-gallery/AudioPlayer.tsx +++ b/web/app/components/base/audio-gallery/AudioPlayer.tsx @@ -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 = ({ src, srcs }) => { const [isPlaying, setIsPlaying] = useState(false) const [currentTime, setCurrentTime] = useState(0) @@ -23,43 +22,34 @@ const AudioPlayer: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ src, srcs }) => { await audioContext.close() } } - const togglePlay = useCallback(() => { const audio = audioRef.current if (audio && isAudioAvailable) { @@ -160,99 +134,75 @@ const AudioPlayer: React.FC = ({ 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 = ({ src, srcs }) => { } }) }, [currentTime, duration, hoverTime, theme, waveformData]) - useEffect(() => { drawWaveform() }, [drawWaveform, bufferedTime, hasStartedPlaying]) - const handleMouseMove = useCallback((e: React.MouseEvent | React.TouchEvent) => { 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 = ({ src, srcs }) => { } } }, [duration]) - return (
- ) } - const placeholder = '' const editAreaClassName = 'focus:outline-none bg-transparent text-sm' - const textAreaContent = (
!readonly && setIsEditing(true)}> {isEditing @@ -134,10 +105,10 @@ const BlockInput: FC = ({ 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) }} />
@@ -145,7 +116,6 @@ const BlockInput: FC = ({ : }
) - return (
{textAreaContent} @@ -159,5 +129,4 @@ const BlockInput: FC = ({
) } - export default React.memo(BlockInput) diff --git a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx index b004a1bee67..f4c8ef0c458 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx @@ -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 }) => ( - {children} + + {children} ) } diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index 23936111ce0..e6f5657ff5b 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -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' @@ -93,14 +67,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, @@ -108,7 +80,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! @@ -129,18 +100,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() useEffect(() => { getProcessedSystemVariablesFromUrlParams().then(({ user_id }) => { setUserId(user_id) }) }, []) - useEffect(() => { const setLocaleFromProps = async () => { if (appData?.site.default_language) @@ -148,7 +116,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { } setLocaleFromProps() }, [appData]) - const [sidebarCollapseState, setSidebarCollapseState] = useState(() => { if (typeof window !== 'undefined') { try { @@ -192,15 +159,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, @@ -211,10 +175,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, @@ -224,10 +185,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, @@ -237,18 +195,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]) @@ -267,7 +219,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, @@ -282,7 +233,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { type: 'number', } } - if (item.checkbox) { const preset = initInputs[item.checkbox.variable] === true return { @@ -291,7 +241,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { type: 'checkbox', } } - if (item.select) { const isInputInOptions = item.select.options.includes(initInputs[item.select.variable]) return { @@ -300,32 +249,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, @@ -333,11 +277,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 () => { @@ -347,16 +289,13 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { setInitUserVariables(userVariables) })() }, []) - useEffect(() => { const conversationInputs: Record = {} - inputsForms.forEach((item: any) => { conversationInputs[item.variable] = item.default || null }) handleNewConversationInputsChange(conversationInputs) }, [handleNewConversationInputsChange, inputsForms]) - const { data: newConversation } = useShareConversationName({ conversationId: newConversationId, appSourceType, @@ -372,7 +311,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { }, [appConversationData, appConversationDataLoading]) const conversationList = useMemo(() => { const data = originConversationList.slice() - if (showNewConversationItemInList && data[0]?.id !== '') { data.unshift({ id: '', @@ -383,12 +321,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 @@ -396,16 +332,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 || {} @@ -416,12 +348,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) @@ -429,13 +358,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)) @@ -445,26 +371,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('') @@ -486,76 +411,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, @@ -566,20 +463,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, diff --git a/web/app/components/base/chat/chat/__tests__/check-input-forms-hooks.spec.tsx b/web/app/components/base/chat/chat/__tests__/check-input-forms-hooks.spec.tsx index 6afbc26582a..1e96c1f798f 100644 --- a/web/app/components/base/chat/chat/__tests__/check-input-forms-hooks.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/check-input-forms-hooks.spec.tsx @@ -5,8 +5,16 @@ 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', () => ({ + default: { + notify: (args: unknown) => mockNotify(args), + }, + toast: { + success: (message: string) => mockNotify({ type: 'success', message }), + error: (message: string) => mockNotify({ type: 'error', message }), + warning: (message: string) => mockNotify({ type: 'warning', message }), + info: (message: string) => mockNotify({ type: 'info', message }), + }, })) describe('useCheckInputsForms', () => { diff --git a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx index 92fa9ea42ee..89327341de8 100644 --- a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx @@ -20,8 +20,14 @@ 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', () => ({ + default: { notify: vi.fn() }, + toast: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }, })) vi.mock('@/hooks/use-timestamp', () => ({ diff --git a/web/app/components/base/chat/chat/__tests__/question.spec.tsx b/web/app/components/base/chat/chat/__tests__/question.spec.tsx index e9392adb8a5..9d49be3a156 100644 --- a/web/app/components/base/chat/chat/__tests__/question.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/question.spec.tsx @@ -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()) diff --git a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx index 836397a5862..588b261323e 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx @@ -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() }, })) diff --git a/web/app/components/base/chat/chat/answer/operation.tsx b/web/app/components/base/chat/chat/answer/operation.tsx index f0d077975c0..26a4b6bd99e 100644 --- a/web/app/components/base/chat/chat/answer/operation.tsx +++ b/web/app/components/base/chat/chat/answer/operation.tsx @@ -17,8 +17,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' @@ -302,7 +302,7 @@ const Operation: FC = ({ { copy(content) - Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) }) + toast.success(t('actionMsg.copySuccessfully', { ns: 'common' })) }} data-testid="copy-btn" > diff --git a/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx index f628b7de827..1a8dd55f616 100644 --- a/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx @@ -175,8 +175,16 @@ 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', () => ({ + default: { + notify: (args: unknown) => mockNotify(args), + }, + toast: { + success: (message: string) => mockNotify({ type: 'success', message }), + error: (message: string) => mockNotify({ type: 'error', message }), + warning: (message: string) => mockNotify({ type: 'warning', message }), + info: (message: string) => mockNotify({ type: 'info', message }), + }, })) // --------------------------------------------------------------------------- diff --git a/web/app/components/base/chat/chat/chat-input-area/index.tsx b/web/app/components/base/chat/chat/chat-input-area/index.tsx index 8b5ca185850..0ea928d6d6c 100644 --- a/web/app/components/base/chat/chat/chat-input-area/index.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/index.tsx @@ -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 = ( - - ) - + }, [t]) + const operation = () return ( <> -
+
-
+
-
+
{query}
-