diff --git a/packages/dify-ui/src/toast/__tests__/index.spec.tsx b/packages/dify-ui/src/toast/__tests__/index.spec.tsx
index 45b605a0e9b..5e10696b9d0 100644
--- a/packages/dify-ui/src/toast/__tests__/index.spec.tsx
+++ b/packages/dify-ui/src/toast/__tests__/index.spec.tsx
@@ -1,5 +1,5 @@
import { render } from 'vitest-browser-react'
-import { toast, ToastHost } from '../index'
+import { createToast, createToastManager, toast, ToastHost } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
@@ -54,6 +54,46 @@ describe('@langgenius/dify-ui/toast', () => {
await expect.element(screen.getByText('Neutral toast')).toBeInTheDocument()
})
+ it('should isolate toasts between managers', async () => {
+ const localManager = createToastManager()
+ const localToast = createToast(localManager)
+ const screen = await render(
+ <>
+
+
+ >,
+ )
+
+ localToast.error('Local error')
+ toast.success('Global success')
+
+ await expect.element(screen.getByText('Local error')).toBeInTheDocument()
+ await expect.element(screen.getByText('Global success')).toBeInTheDocument()
+ const globalViewport = screen.getByText('Global success').element().closest('[role="region"]')
+ const localViewport = screen.getByText('Local error').element().closest('[role="region"]')
+ expect(globalViewport).not.toBe(localViewport)
+ expect(globalViewport).not.toHaveTextContent('Local error')
+ expect(localViewport).not.toHaveTextContent('Global success')
+
+ localToast.dismiss()
+ })
+
+ it('should apply custom positioning to the viewport', async () => {
+ const localManager = createToastManager()
+ const localToast = createToast(localManager)
+ const screen = await render()
+
+ localToast('Positioned viewport')
+
+ const viewport = screen.getByRole('region', { name: 'Notifications' })
+ await expect.element(screen.getByText('Positioned viewport')).toBeInTheDocument()
+ await vi.waitFor(() => {
+ expect(viewport.element().getBoundingClientRect().top).toBeCloseTo(80, 0)
+ })
+
+ localToast.dismiss()
+ })
+
it('should mark overflow toasts as limited when the stack exceeds the configured limit', async () => {
const screen = await render()
diff --git a/packages/dify-ui/src/toast/index.tsx b/packages/dify-ui/src/toast/index.tsx
index 6e3637dbcf5..f76d9391575 100644
--- a/packages/dify-ui/src/toast/index.tsx
+++ b/packages/dify-ui/src/toast/index.tsx
@@ -1,6 +1,7 @@
'use client'
import type {
+ ToastManager as BaseToastManager,
ToastManagerAddOptions,
ToastManagerUpdateOptions,
ToastObject,
@@ -80,8 +81,12 @@ type ToastPromiseOptions = {
type ToastHostProps = {
timeout?: number
limit?: number
+ manager?: ToastManager
+ offset?: ToastHostOffset
}
+type ToastHostOffset = Pick
+
type ToastDismiss = (toastId?: string) => void
type ToastCall = (title: React.ReactNode, options?: ToastOptions) => string
type TypedToastCall = (title: React.ReactNode, options?: TypedToastOptions) => string
@@ -100,7 +105,7 @@ type ToastApi = {
) => Promise
}
-const toastManager = BaseToast.createToastManager()
+type ToastManager = BaseToastManager
function isToastRenderType(type: string): type is ToastRenderType {
return Object.prototype.hasOwnProperty.call(TOAST_TONE_STYLES, type)
@@ -110,46 +115,53 @@ function getToastRenderType(type?: string): ToastRenderType | undefined {
return type && isToastRenderType(type) ? type : undefined
}
-function addToast(options: ToastAddOptions) {
- return toastManager.add(options)
-}
-
-const showToast: ToastCall = (title, options) =>
- addToast({
- ...options,
- title,
- })
-
-const dismissToast: ToastDismiss = (toastId) => {
- toastManager.close(toastId)
-}
-
-function createTypedToast(type: ToastType): TypedToastCall {
- return (title, options) =>
+function createToast(manager: ToastManager): ToastApi {
+ const addToast = (options: ToastAddOptions) => manager.add(options)
+ const showToast: ToastCall = (title, options) =>
addToast({
...options,
title,
- type,
})
+
+ const dismissToast: ToastDismiss = (toastId) => {
+ manager.close(toastId)
+ }
+
+ const createTypedToast = (type: ToastType): TypedToastCall => {
+ return (title, options) =>
+ addToast({
+ ...options,
+ title,
+ type,
+ })
+ }
+
+ const updateToast = (toastId: string, options: ToastUpdateOptions) => {
+ manager.update(toastId, options)
+ }
+
+ const promiseToast = (
+ promiseValue: Promise,
+ options: ToastPromiseOptions,
+ ) => manager.promise(promiseValue, options)
+
+ return Object.assign(showToast, {
+ success: createTypedToast('success'),
+ error: createTypedToast('error'),
+ warning: createTypedToast('warning'),
+ info: createTypedToast('info'),
+ dismiss: dismissToast,
+ update: updateToast,
+ promise: promiseToast,
+ })
}
-function updateToast(toastId: string, options: ToastUpdateOptions) {
- toastManager.update(toastId, options)
+function createToastManager(): ToastManager {
+ return BaseToast.createToastManager()
}
-function promiseToast(promiseValue: Promise, options: ToastPromiseOptions) {
- return toastManager.promise(promiseValue, options)
-}
-
-const toast: ToastApi = Object.assign(showToast, {
- success: createTypedToast('success'),
- error: createTypedToast('error'),
- warning: createTypedToast('warning'),
- info: createTypedToast('info'),
- dismiss: dismissToast,
- update: updateToast,
- promise: promiseToast,
-})
+const defaultToastManager = createToastManager()
+const toast = createToast(defaultToastManager)
function ToastIcon({ type }: { type?: ToastRenderType }) {
return type ? (
@@ -234,15 +246,14 @@ function ToastCard({ toast: toastItem }: { toast: ToastObject }) {
)
}
-function ToastViewport() {
+function ToastViewport({ offset }: { offset?: ToastHostOffset }) {
const { toasts } = BaseToast.useToastManager()
return (
{toasts.map((toastItem) => (
@@ -251,16 +262,16 @@ function ToastViewport() {
)
}
-function ToastHost({ timeout, limit }: ToastHostProps) {
+function ToastHost({ timeout, limit, manager = defaultToastManager, offset }: ToastHostProps) {
return (
-
+
-
+
)
}
-export { toast, ToastHost }
+export { createToast, createToastManager, toast, ToastHost }
export type { ToastHostProps }
diff --git a/web/app/components/app/configuration/__tests__/index.spec.tsx b/web/app/components/app/configuration/__tests__/index.spec.tsx
new file mode 100644
index 00000000000..049a70c11de
--- /dev/null
+++ b/web/app/components/app/configuration/__tests__/index.spec.tsx
@@ -0,0 +1,48 @@
+import { ToastHost } from '@langgenius/dify-ui/toast'
+import { act, render, screen } from '@testing-library/react'
+import Configuration from '../index'
+import { toast } from '../toast'
+
+vi.mock('../configuration-view', () => ({
+ default: () => Configuration view
,
+}))
+
+vi.mock('../hooks/use-configuration', () => ({
+ useConfiguration: () => ({}),
+}))
+
+describe('Configuration', () => {
+ beforeEach(() => {
+ toast.dismiss()
+ })
+
+ afterEach(() => {
+ toast.dismiss()
+ })
+
+ it('should render configuration notifications in its offset viewport', async () => {
+ render(
+ <>
+
+
+ >,
+ )
+
+ act(() => {
+ toast.error('Configuration error')
+ })
+
+ const toastItem = await screen.findByText('Configuration error')
+ const configurationViewport = toastItem.closest('[role="region"]')
+ if (!configurationViewport) throw new Error('Configuration toast viewport was not rendered')
+
+ expect(configurationViewport).toHaveStyle({ top: '60px' })
+
+ const globalViewport = screen
+ .getAllByRole('region', { name: 'Notifications' })
+ .find((viewport) => viewport !== configurationViewport)
+ if (!globalViewport) throw new Error('Global toast viewport was not rendered')
+
+ expect(globalViewport).not.toHaveTextContent('Configuration error')
+ })
+})
diff --git a/web/app/components/app/configuration/config-prompt/__tests__/advanced-prompt-input.spec.tsx b/web/app/components/app/configuration/config-prompt/__tests__/advanced-prompt-input.spec.tsx
index 30e2f309e70..0370c6d5cd6 100644
--- a/web/app/components/app/configuration/config-prompt/__tests__/advanced-prompt-input.spec.tsx
+++ b/web/app/components/app/configuration/config-prompt/__tests__/advanced-prompt-input.spec.tsx
@@ -50,7 +50,7 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
-vi.mock('@langgenius/dify-ui/toast', () => ({
+vi.mock('@/app/components/app/configuration/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
},
diff --git a/web/app/components/app/configuration/config-prompt/__tests__/simple-prompt-input.spec.tsx b/web/app/components/app/configuration/config-prompt/__tests__/simple-prompt-input.spec.tsx
index d7a31dd1f5a..3207edb7dae 100644
--- a/web/app/components/app/configuration/config-prompt/__tests__/simple-prompt-input.spec.tsx
+++ b/web/app/components/app/configuration/config-prompt/__tests__/simple-prompt-input.spec.tsx
@@ -50,7 +50,7 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
-vi.mock('@langgenius/dify-ui/toast', () => ({
+vi.mock('@/app/components/app/configuration/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
},
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 28d3574b8ce..fa8a951ecdd 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
@@ -4,7 +4,6 @@ import type { ExternalDataTool } from '@/models/common'
import type { PromptRole, PromptVariable } from '@/models/debug'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
-import { toast } from '@langgenius/dify-ui/toast'
import { RiDeleteBinLine, RiErrorWarningFill } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import copy from 'copy-to-clipboard'
@@ -13,6 +12,7 @@ import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { ADD_EXTERNAL_DATA_TOOL } from '@/app/components/app/configuration/config-var'
+import { toast } from '@/app/components/app/configuration/toast'
import { Copy, CopyCheck } from '@/app/components/base/icons/src/vender/line/files'
import { Infotip } from '@/app/components/base/infotip'
import PromptEditor from '@/app/components/base/prompt-editor'
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 06685462a47..f01b69955e5 100644
--- a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx
+++ b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx
@@ -4,7 +4,6 @@ import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug'
import type { GenRes } from '@/service/debug'
import { cn } from '@langgenius/dify-ui/cn'
-import { toast } from '@langgenius/dify-ui/toast'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
@@ -15,6 +14,7 @@ import { useContext } from 'use-context-selector'
import { ADD_EXTERNAL_DATA_TOOL } from '@/app/components/app/configuration/config-var'
import AutomaticBtn from '@/app/components/app/configuration/config/automatic/automatic-btn'
import GetAutomaticResModal from '@/app/components/app/configuration/config/automatic/get-automatic-res'
+import { toast } from '@/app/components/app/configuration/toast'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import { Infotip } from '@/app/components/base/infotip'
import PromptEditor from '@/app/components/base/prompt-editor'
diff --git a/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx
index 2eeeabcdae2..397a001d53b 100644
--- a/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx
+++ b/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx
@@ -2,10 +2,10 @@ import type { ReactNode } from 'react'
import type { IConfigVarProps } from '../index'
import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug'
-import { toast } from '@langgenius/dify-ui/toast'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import * as React from 'react'
import { vi } from 'vitest'
+import { toast } from '@/app/components/app/configuration/toast'
import DebugConfigurationContext from '@/context/debug-configuration'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx
index f39d726f7c6..603d5991704 100644
--- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx
+++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx
@@ -1,9 +1,9 @@
/* oxlint-disable typescript/no-explicit-any */
import type { InputVar } from '@/app/components/workflow/types'
import type { App, AppSSO } from '@/types/app'
-import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
+import { toast } from '@/app/components/app/configuration/toast'
import { useStore } from '@/app/components/app/store'
import { InputVarType } from '@/app/components/workflow/types'
import DebugConfigurationContext from '@/context/debug-configuration'
diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx
index 0b8f715192f..4bca4e5db92 100644
--- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx
+++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx
@@ -1,8 +1,8 @@
import type { InputVar } from '@/app/components/workflow/types'
import type { App, AppSSO } from '@/types/app'
-import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, screen } from '@testing-library/react'
import * as React from 'react'
+import { toast } from '@/app/components/app/configuration/toast'
import { useStore } from '@/app/components/app/store'
import { InputVarType } from '@/app/components/workflow/types'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
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 b5082af848d..5ab337c7d0a 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
@@ -3,11 +3,11 @@ import type { ChangeEvent, FC } from 'react'
import type { Item as SelectItem } from './type-select'
import type { InputVar, InputVarType, MoreInfo } from '@/app/components/workflow/types'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
-import { toast } from '@langgenius/dify-ui/toast'
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 { toast } from '@/app/components/app/configuration/toast'
import { useStore as useAppStore } from '@/app/components/app/store'
import ConfigContext from '@/context/debug-configuration'
import { AppModeEnum } from '@/types/app'
diff --git a/web/app/components/app/configuration/config-var/index.tsx b/web/app/components/app/configuration/config-var/index.tsx
index 16a709f360e..04b61803806 100644
--- a/web/app/components/app/configuration/config-var/index.tsx
+++ b/web/app/components/app/configuration/config-var/index.tsx
@@ -13,7 +13,6 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
-import { toast } from '@langgenius/dify-ui/toast'
import { useBoolean } from 'ahooks'
import { produce } from 'immer'
import * as React from 'react'
@@ -21,6 +20,7 @@ import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ReactSortable } from 'react-sortablejs'
import { useContext } from 'use-context-selector'
+import { toast } from '@/app/components/app/configuration/toast'
import { Infotip } from '@/app/components/base/infotip'
import { InputVarType } from '@/app/components/workflow/types'
import ConfigContext from '@/context/debug-configuration'
diff --git a/web/app/components/app/configuration/config/automatic/__tests__/get-automatic-res.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/get-automatic-res.spec.tsx
index 1d45b712d2b..6c78a82446d 100644
--- a/web/app/components/app/configuration/config/automatic/__tests__/get-automatic-res.spec.tsx
+++ b/web/app/components/app/configuration/config/automatic/__tests__/get-automatic-res.spec.tsx
@@ -14,7 +14,7 @@ let mockDefaultModel: {
} | null = null
let mockInstructionTemplate: { data: string } | undefined
-vi.mock('@langgenius/dify-ui/toast', () => ({
+vi.mock('@/app/components/app/configuration/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
},
diff --git a/web/app/components/app/configuration/config/automatic/__tests__/result.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/result.spec.tsx
index 69053a6e2dc..b426e47227f 100644
--- a/web/app/components/app/configuration/config/automatic/__tests__/result.spec.tsx
+++ b/web/app/components/app/configuration/config/automatic/__tests__/result.spec.tsx
@@ -1,5 +1,5 @@
-import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, render, screen } from '@testing-library/react'
+import { toast } from '@/app/components/app/configuration/toast'
import Result from '../result'
import { GeneratorType } from '../types'
@@ -12,7 +12,7 @@ vi.mock('copy-to-clipboard', () => ({
default: (...args: unknown[]) => mockCopy(...args),
}))
-vi.mock('@langgenius/dify-ui/toast', () => ({
+vi.mock('@/app/components/app/configuration/toast', () => ({
toast: {
success: vi.fn(),
},
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 0846b1e0746..bc99743eb80 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
@@ -15,7 +15,6 @@ import {
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
-import { toast } from '@langgenius/dify-ui/toast'
import {
RiDatabase2Line,
RiFileExcel2Line,
@@ -31,6 +30,7 @@ import { useBoolean, useSessionStorageState } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
+import { toast } from '@/app/components/app/configuration/toast'
import { Generator } from '@/app/components/base/icons/src/vender/other'
import Loading from '@/app/components/base/loading'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
diff --git a/web/app/components/app/configuration/config/automatic/result.tsx b/web/app/components/app/configuration/config/automatic/result.tsx
index 2abb29b53f7..334d7927056 100644
--- a/web/app/components/app/configuration/config/automatic/result.tsx
+++ b/web/app/components/app/configuration/config/automatic/result.tsx
@@ -2,11 +2,11 @@
import type { FC } from 'react'
import type { GenRes } from '@/service/debug'
import { Button } from '@langgenius/dify-ui/button'
-import { toast } from '@langgenius/dify-ui/toast'
import { RiClipboardLine } from '@remixicon/react'
import copy from 'copy-to-clipboard'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
+import { toast } from '@/app/components/app/configuration/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'
diff --git a/web/app/components/app/configuration/config/code-generator/__tests__/get-code-generator-res.spec.tsx b/web/app/components/app/configuration/config/code-generator/__tests__/get-code-generator-res.spec.tsx
index 292e68ce7e7..75fb1d1e335 100644
--- a/web/app/components/app/configuration/config/code-generator/__tests__/get-code-generator-res.spec.tsx
+++ b/web/app/components/app/configuration/config/code-generator/__tests__/get-code-generator-res.spec.tsx
@@ -14,7 +14,7 @@ let mockDefaultModel: {
} | null = null
let mockInstructionTemplate: { data: string } | undefined
-vi.mock('@langgenius/dify-ui/toast', () => ({
+vi.mock('@/app/components/app/configuration/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
},
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 6b70ffebc90..4f6b9954675 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
@@ -14,11 +14,11 @@ import {
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
-import { toast } from '@langgenius/dify-ui/toast'
import { useBoolean, useSessionStorageState } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
+import { toast } from '@/app/components/app/configuration/toast'
import { Generator } from '@/app/components/base/icons/src/vender/other'
import Loading from '@/app/components/base/loading'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
diff --git a/web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx
index 37bd31de029..2a281c6cf74 100644
--- a/web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx
+++ b/web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx
@@ -3,9 +3,9 @@ import type { IndexingType } from '@/app/components/datasets/create/step-two'
import type { DataSet } from '@/models/datasets'
import type { DatasetConfigs } from '@/models/debug'
import type { RetrievalConfig } from '@/types/app'
-import { toast } from '@langgenius/dify-ui/toast'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
+import { toast } from '@/app/components/app/configuration/toast'
import {
useCurrentProviderAndModel,
useModelListAndDefaultModelAndCurrentProviderAndModel,
diff --git a/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx
index ce4e445a95b..fe88910da73 100644
--- a/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx
+++ b/web/app/components/app/configuration/dataset-config/params-config/__tests__/index.spec.tsx
@@ -1,9 +1,9 @@
import type { MockedFunction, MockInstance } from 'vitest'
import type { DatasetConfigs } from '@/models/debug'
-import { toast } from '@langgenius/dify-ui/toast'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
+import { toast } from '@/app/components/app/configuration/toast'
import {
useCurrentProviderAndModel,
useModelListAndDefaultModelAndCurrentProviderAndModel,
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 ee845f30757..767f2aa6d0e 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
@@ -7,9 +7,9 @@ import type { DataSet } from '@/models/datasets'
import type { DatasetConfigs } from '@/models/debug'
import { cn } from '@langgenius/dify-ui/cn'
import { Switch } from '@langgenius/dify-ui/switch'
-import { toast } from '@langgenius/dify-ui/toast'
import { memo, useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
+import { toast } from '@/app/components/app/configuration/toast'
import Divider from '@/app/components/base/divider'
import { Infotip } from '@/app/components/base/infotip'
import ScoreThresholdItem from '@/app/components/base/param-item/score-threshold-item'
diff --git a/web/app/components/app/configuration/dataset-config/params-config/index.tsx b/web/app/components/app/configuration/dataset-config/params-config/index.tsx
index f935aada53a..a3925882186 100644
--- a/web/app/components/app/configuration/dataset-config/params-config/index.tsx
+++ b/web/app/components/app/configuration/dataset-config/params-config/index.tsx
@@ -4,11 +4,11 @@ import type { DatasetConfigs } from '@/models/debug'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
-import { toast } from '@langgenius/dify-ui/toast'
import { RiEqualizer2Line } from '@remixicon/react'
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
+import { toast } from '@/app/components/app/configuration/toast'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
useCurrentProviderAndModel,
diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx
index 8fe18a162b0..cdefed550c4 100644
--- a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx
+++ b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx
@@ -29,7 +29,7 @@ const toastMocks = vi.hoisted(() => ({
promise: vi.fn(),
}))
-vi.mock('@langgenius/dify-ui/toast', () => ({
+vi.mock('@/app/components/app/configuration/toast', () => ({
toast: Object.assign(toastMocks.call, {
success: vi.fn((message: string, options?: Record) =>
toastMocks.call({ type: 'success', message, ...options }),
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 1d1ab94faac..ef389e99742 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
@@ -6,12 +6,12 @@ import type { RetrievalConfig } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
-import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { isEqual } from 'es-toolkit/predicate'
import { useQueryState } from 'nuqs'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
+import { toast } from '@/app/components/app/configuration/toast'
import Input from '@/app/components/base/input'
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
import { IndexingType } from '@/app/components/datasets/create/step-two'
diff --git a/web/app/components/app/configuration/debug/__tests__/index.spec.tsx b/web/app/components/app/configuration/debug/__tests__/index.spec.tsx
index 7f2812f5aa4..b5f6bae70c5 100644
--- a/web/app/components/app/configuration/debug/__tests__/index.spec.tsx
+++ b/web/app/components/app/configuration/debug/__tests__/index.spec.tsx
@@ -50,7 +50,7 @@ const mockState = vi.hoisted(() => ({
},
}))
-vi.mock('@langgenius/dify-ui/toast', () => ({
+vi.mock('@/app/components/app/configuration/toast', () => ({
toast: Object.assign(mockState.mockToastCall, {
success: vi.fn((message: string, options?: Record) =>
mockState.mockToastCall({ type: 'success', message, ...options }),
@@ -846,10 +846,8 @@ describe('Debug', () => {
fireEvent.click(screen.getByTestId('panel-send'))
await waitFor(() => expect(mockState.mockSendCompletionMessage).toHaveBeenCalledTimes(1))
- const [, requestData] = (mockState.mockSendCompletionMessage.mock.calls[0] ?? []) as [
- unknown,
- any,
- ]
+ const [, requestData, handlers] = (mockState.mockSendCompletionMessage.mock.calls[0] ??
+ []) as [unknown, any, { onNotifyError: (message: string) => void }]
expect(requestData).toMatchObject({
inputs: { question: 'hello' },
model_config: {
@@ -863,6 +861,12 @@ describe('Debug', () => {
expect(screen.getByTestId('text-generation'))!.toHaveTextContent('final answer')
expect(screen.getByTestId('text-generation'))!.toHaveAttribute('data-message-id', 'msg-1')
expect(screen.getByTestId('text-generation'))!.toHaveAttribute('data-tts', 'true')
+
+ handlers.onNotifyError('Base model not found')
+ expect(mockState.mockToastCall).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'Base model not found',
+ })
})
it('should notify when sending again while a response is in progress', async () => {
diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx
index 1779b60ec58..3b5d28b439d 100644
--- a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx
+++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx
@@ -18,6 +18,9 @@ const mockUseEventEmitterContextContext = vi.fn()
const mockFetchConversationMessages = vi.fn()
const mockFetchSuggestedQuestions = vi.fn()
const mockStopChatMessageResponding = vi.fn()
+const { mockToastError } = vi.hoisted(() => ({
+ mockToastError: vi.fn(),
+}))
let capturedChatProps: {
config: ChatConfig
@@ -64,6 +67,12 @@ vi.mock('@/service/debug', () => ({
stopChatMessageResponding: (...args: unknown[]) => mockStopChatMessageResponding(...args),
}))
+vi.mock('@/app/components/app/configuration/toast', () => ({
+ toast: {
+ error: mockToastError,
+ },
+}))
+
vi.mock('@/app/components/base/chat/utils', () => ({
getLastAnswer: (chatList: ChatItemType[]) => chatList.find((item) => item.isAnswer),
}))
@@ -338,8 +347,16 @@ describe('ChatItem', () => {
query: 'Hello',
inputs: { key: 'value' },
}),
- expect.any(Object),
+ expect.objectContaining({
+ onNotifyError: expect.any(Function),
+ }),
)
+
+ const callbacks = handleSend.mock.calls[0]![2] as {
+ onNotifyError: (message: string) => void
+ }
+ callbacks.onNotifyError('Base model not found')
+ expect(mockToastError).toHaveBeenCalledWith('Base model not found')
})
it('should handle APP_CHAT_WITH_MULTIPLE_MODEL_RESTART event', () => {
diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/text-generation-item.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/text-generation-item.spec.tsx
index 6e7e775b24f..96728e360d9 100644
--- a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/text-generation-item.spec.tsx
+++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/text-generation-item.spec.tsx
@@ -10,6 +10,9 @@ const mockUseFeatures = vi.fn()
const mockUseTextGeneration = vi.fn()
const mockUseEventEmitterContextContext = vi.fn()
const mockPromptVariablesToUserInputsForm = vi.fn()
+const { mockToastError } = vi.hoisted(() => ({
+ mockToastError: vi.fn(),
+}))
let capturedTextGenerationProps: {
content: string
@@ -39,6 +42,12 @@ vi.mock('@/app/components/base/text-generation/hooks', () => ({
useTextGeneration: () => mockUseTextGeneration(),
}))
+vi.mock('@/app/components/app/configuration/toast', () => ({
+ toast: {
+ error: mockToastError,
+ },
+}))
+
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => mockUseEventEmitterContextContext(),
}))
@@ -377,7 +386,16 @@ describe('TextGenerationItem', () => {
expect.objectContaining({
inputs: { name: 'World' },
}),
+ expect.objectContaining({
+ onNotifyError: expect.any(Function),
+ }),
)
+
+ const callbacks = handleSend.mock.calls[0]![2] as {
+ onNotifyError: (message: string) => void
+ }
+ callbacks.onNotifyError('Base model not found')
+ expect(mockToastError).toHaveBeenCalledWith('Base model not found')
})
it('should ignore other event types', () => {
@@ -434,6 +452,7 @@ describe('TextGenerationItem', () => {
}),
}),
}),
+ expect.objectContaining({ onNotifyError: expect.any(Function) }),
)
})
@@ -474,6 +493,7 @@ describe('TextGenerationItem', () => {
}),
],
}),
+ expect.objectContaining({ onNotifyError: expect.any(Function) }),
)
})
@@ -512,6 +532,7 @@ describe('TextGenerationItem', () => {
expect.not.objectContaining({
files: expect.anything(),
}),
+ expect.objectContaining({ onNotifyError: expect.any(Function) }),
)
})
@@ -536,6 +557,7 @@ describe('TextGenerationItem', () => {
expect.not.objectContaining({
files: expect.anything(),
}),
+ expect.objectContaining({ onNotifyError: expect.any(Function) }),
)
})
@@ -560,6 +582,7 @@ describe('TextGenerationItem', () => {
expect.not.objectContaining({
files: expect.anything(),
}),
+ expect.objectContaining({ onNotifyError: expect.any(Function) }),
)
})
})
@@ -607,6 +630,7 @@ describe('TextGenerationItem', () => {
}),
}),
}),
+ expect.objectContaining({ onNotifyError: expect.any(Function) }),
)
})
diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx
index 61a4ecc7fba..a5c9073f288 100644
--- a/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx
+++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx
@@ -5,6 +5,7 @@ import type { ChatConfig, OnSend } from '@/app/components/base/chat/types'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { useAtomValue } from 'jotai'
import { memo, useCallback, useMemo } from 'react'
+import { toast } from '@/app/components/app/configuration/toast'
import Chat from '@/app/components/base/chat/chat'
import { useChat } from '@/app/components/base/chat/chat/hooks'
import { getLastAnswer } from '@/app/components/base/chat/utils'
@@ -102,6 +103,7 @@ const ChatItem: FC = ({ modelAndParameter }) => {
if ((config.file_upload as any).enabled && files?.length && supportVision) data.files = files
handleSend(`apps/${appId}/chat-messages`, data, {
+ onNotifyError: (message) => toast.error(message),
onGetConversationMessages: (conversationId, getAbortController) =>
fetchConversationMessages(appId, conversationId, getAbortController),
onGetSuggestedQuestions: (responseItemId, getAbortController) =>
diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx
index 39719028d4a..97bd8c7ec00 100644
--- a/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx
+++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx
@@ -4,6 +4,7 @@ import type { OnSend, TextGenerationConfig } from '@/app/components/base/text-ge
import { noop } from 'es-toolkit/function'
import { cloneDeep } from 'es-toolkit/object'
import { memo } from 'react'
+import { toast } from '@/app/components/app/configuration/toast'
import TextGeneration from '@/app/components/app/text-generate/item'
import { TransferMethod } from '@/app/components/base/chat/types'
import { useFeatures } from '@/app/components/base/features/hooks'
@@ -113,7 +114,9 @@ const TextGenerationItem: FC = ({ modelAndParameter })
})
}
- handleSend(`apps/${appId}/completion-messages`, data)
+ handleSend(`apps/${appId}/completion-messages`, data, {
+ onNotifyError: (message) => toast.error(message),
+ })
}
const { eventEmitter } = useEventEmitterContextContext()
diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/__tests__/index.spec.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/__tests__/index.spec.tsx
index e2b9e16c4af..5be40dee395 100644
--- a/web/app/components/app/configuration/debug/debug-with-single-model/__tests__/index.spec.tsx
+++ b/web/app/components/app/configuration/debug/debug-with-single-model/__tests__/index.spec.tsx
@@ -137,8 +137,15 @@ function createMockProviderContext(
// ============================================================================
// Mock service layer (API calls)
-const { mockSsePost } = vi.hoisted(() => ({
+const { mockSsePost, mockToastError } = vi.hoisted(() => ({
mockSsePost: vi.fn<(...args: any[]) => Promise>(() => Promise.resolve()),
+ mockToastError: vi.fn(),
+}))
+
+vi.mock('@/app/components/app/configuration/toast', () => ({
+ toast: {
+ error: mockToastError,
+ },
}))
vi.mock('@/service/base', () => ({
@@ -673,6 +680,17 @@ describe('DebugWithSingleModel', () => {
})
expect(mockSsePost.mock.calls[0]![0]).toBe('apps/test-app-id/chat-messages')
+ expect(mockSsePost.mock.calls[0]![2]).toEqual(
+ expect.objectContaining({
+ onNotifyError: expect.any(Function),
+ }),
+ )
+
+ const callbacks = mockSsePost.mock.calls[0]![2] as {
+ onNotifyError: (message: string) => void
+ }
+ callbacks.onNotifyError('Base model not found')
+ expect(mockToastError).toHaveBeenCalledWith('Base model not found')
})
it('should prevent send when checkCanSend returns false', async () => {
diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx
index 85062d2748f..9a58252609b 100644
--- a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx
+++ b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx
@@ -4,6 +4,7 @@ import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { useAtomValue } from 'jotai'
import { memo, useCallback, useImperativeHandle, useMemo } from 'react'
+import { toast } from '@/app/components/app/configuration/toast'
import { useStore as useAppStore } from '@/app/components/app/store'
import Chat from '@/app/components/base/chat/chat'
import { useChat } from '@/app/components/base/chat/chat/hooks'
@@ -117,6 +118,7 @@ const DebugWithSingleModel = ({
if ((config.file_upload as any)?.enabled && files?.length) data.files = files
handleSend(`apps/${appId}/chat-messages`, data, {
+ onNotifyError: (message) => toast.error(message),
onGetConversationMessages: (conversationId, getAbortController) =>
fetchConversationMessages(appId, conversationId, getAbortController),
onGetSuggestedQuestions: (responseItemId, getAbortController) =>
diff --git a/web/app/components/app/configuration/debug/index.tsx b/web/app/components/app/configuration/debug/index.tsx
index e71e0c67b6f..1fdc4f2bf9b 100644
--- a/web/app/components/app/configuration/debug/index.tsx
+++ b/web/app/components/app/configuration/debug/index.tsx
@@ -6,7 +6,6 @@ import type { ModelParameterModalProps } from '@/app/components/header/account-s
import type { Inputs } from '@/models/debug'
import type { ModelConfig as BackendModelConfig, VisionFile, VisionSettings } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
-import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiAddLine, RiEqualizer2Line, RiSparklingFill } from '@remixicon/react'
import { useBoolean } from 'ahooks'
@@ -20,6 +19,7 @@ import { useContext } from 'use-context-selector'
import { useShallow } from 'zustand/react/shallow'
import ChatUserInput from '@/app/components/app/configuration/debug/chat-user-input'
import PromptValuePanel from '@/app/components/app/configuration/prompt-value-panel'
+import { toast } from '@/app/components/app/configuration/toast'
import { useStore as useAppStore } from '@/app/components/app/store'
import TextGeneration from '@/app/components/app/text-generate/item'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
@@ -305,6 +305,7 @@ const Debug: FC = ({
onError() {
setRespondingFalse()
},
+ onNotifyError: (message) => toast.error(message),
})
}
diff --git a/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts b/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts
index 23b0403d756..3ee71947e66 100644
--- a/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts
+++ b/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts
@@ -64,7 +64,7 @@ vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/utils', async () =>
}
})
-vi.mock('@langgenius/dify-ui/toast', () => ({
+vi.mock('@/app/components/app/configuration/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
success: (...args: unknown[]) => mockToastSuccess(...args),
diff --git a/web/app/components/app/configuration/hooks/use-configuration-utils.ts b/web/app/components/app/configuration/hooks/use-configuration-utils.ts
index ef6388e0cf6..2bdf2cb85fc 100644
--- a/web/app/components/app/configuration/hooks/use-configuration-utils.ts
+++ b/web/app/components/app/configuration/hooks/use-configuration-utils.ts
@@ -14,9 +14,9 @@ import type {
UserInputFormItem,
VisionSettings,
} from '@/types/app'
-import { toast } from '@langgenius/dify-ui/toast'
import { clone } from 'es-toolkit/object'
import { produce } from 'immer'
+import { toast } from '@/app/components/app/configuration/toast'
import {
getMultipleRetrievalConfig,
getSelectedDatasetsMode,
diff --git a/web/app/components/app/configuration/index.tsx b/web/app/components/app/configuration/index.tsx
index 6653e9d61bc..2dadd0373e3 100644
--- a/web/app/components/app/configuration/index.tsx
+++ b/web/app/components/app/configuration/index.tsx
@@ -1,11 +1,18 @@
'use client'
+import { ToastHost } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import ConfigurationView from './configuration-view'
import { useConfiguration } from './hooks/use-configuration'
+import { appConfigurationToastManager } from './toast'
const Configuration = () => {
const viewModel = useConfiguration()
- return
+ return (
+ <>
+
+
+ >
+ )
}
export default React.memo(Configuration)
diff --git a/web/app/components/app/configuration/toast.ts b/web/app/components/app/configuration/toast.ts
new file mode 100644
index 00000000000..f1e64bbc1f8
--- /dev/null
+++ b/web/app/components/app/configuration/toast.ts
@@ -0,0 +1,8 @@
+'use client'
+
+import { createToast, createToastManager } from '@langgenius/dify-ui/toast'
+
+const appConfigurationToastManager = createToastManager()
+const toast = createToast(appConfigurationToastManager)
+
+export { appConfigurationToastManager, toast }
diff --git a/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx
index d0db735a4d4..4b5ca839428 100644
--- a/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx
+++ b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx
@@ -4,7 +4,7 @@ import ExternalDataToolModal from '../external-data-tool-modal'
const mockToastError = vi.fn()
let mockLocale = 'en-US'
-vi.mock('@langgenius/dify-ui/toast', () => ({
+vi.mock('@/app/components/app/configuration/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
},
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 8c8425dfafe..c4831b80a09 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,10 +11,10 @@ import {
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
-import { toast } from '@langgenius/dify-ui/toast'
import { noop } from 'es-toolkit/function'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
+import { toast } from '@/app/components/app/configuration/toast'
import AppIcon from '@/app/components/base/app-icon'
import EmojiPicker from '@/app/components/base/emoji-picker'
import FormGeneration from '@/app/components/base/features/new-feature-panel/moderation/form-generation'
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 0a1f90dc7f9..d53860456fd 100644
--- a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx
+++ b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx
@@ -437,6 +437,7 @@ describe('useChat', () => {
it('should call ssePost and handle data correctly on success', async () => {
let callbacks: HookCallbacks
+ const onNotifyError = vi.fn()
vi.mocked(ssePost).mockImplementation(async (url, params, options) => {
callbacks = options as HookCallbacks
@@ -445,10 +446,14 @@ describe('useChat', () => {
const { result } = renderHook(() => useChat())
act(() => {
- result.current.handleSend('test-url', { query: 'hello' }, {})
+ result.current.handleSend('test-url', { query: 'hello' }, { onNotifyError })
})
- expect(ssePost).toHaveBeenCalled()
+ expect(ssePost).toHaveBeenCalledWith(
+ 'test-url',
+ expect.any(Object),
+ expect.objectContaining({ onNotifyError }),
+ )
expect(result.current.isResponding).toBe(true)
// Simulate typical SSE lifecycle
@@ -1445,6 +1450,7 @@ describe('useChat', () => {
describe('handleResume', () => {
it('should call sseGet to resume a node and handle complex tracing', async () => {
let callbacks: HookCallbacks
+ const onNotifyError = vi.fn()
vi.mocked(sseGet).mockImplementation(async (url, params, options) => {
callbacks = options as HookCallbacks
@@ -1485,13 +1491,13 @@ describe('useChat', () => {
)
act(() => {
- result.current.handleResume('m-1', 'wr-1', { isPublicAPI: true })
+ result.current.handleResume('m-1', 'wr-1', { isPublicAPI: true, onNotifyError })
})
expect(sseGet).toHaveBeenCalledWith(
'/workflow/wr-1/events?include_state_snapshot=true&continue_on_pause=true',
expect.any(Object),
- expect.any(Object),
+ expect.objectContaining({ onNotifyError }),
)
act(() => {
diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts
index 408cb44ede1..6809b58bbf0 100644
--- a/web/app/components/base/chat/chat/hooks.ts
+++ b/web/app/components/base/chat/chat/hooks.ts
@@ -68,6 +68,7 @@ type SendCallback = {
onConversationComplete?: (conversationId: string, workflowRunId?: string) => void
onUnhandledEvent?: IOtherOptions['onUnhandledEvent']
onSendSettled?: (hasError?: boolean) => void
+ onNotifyError?: IOtherOptions['onNotifyError']
isPublicAPI?: boolean
}
@@ -578,7 +579,13 @@ export const useChat = (
async (
messageId: string,
workflowRunId: string,
- { onGetSuggestedQuestions, onConversationComplete, onSendSettled, isPublicAPI }: SendCallback,
+ {
+ onGetSuggestedQuestions,
+ onConversationComplete,
+ onSendSettled,
+ onNotifyError,
+ isPublicAPI,
+ }: SendCallback,
) => {
const hasActiveSubscription =
workflowEventsSubscriptionActiveRef.current &&
@@ -600,6 +607,7 @@ export const useChat = (
}
const otherOptions: IOtherOptions = {
isPublicAPI,
+ onNotifyError,
getAbortController: (abortController) => {
workflowEventsAbortControllerRef.current = abortController
},
@@ -1085,6 +1093,7 @@ export const useChat = (
onConversationComplete,
onUnhandledEvent,
onSendSettled,
+ onNotifyError,
isPublicAPI,
}: SendCallback,
) => {
@@ -1186,6 +1195,7 @@ export const useChat = (
const otherOptions: IOtherOptions = {
isPublicAPI,
+ onNotifyError,
onUnhandledEvent,
getAbortController: (abortController) => {
workflowEventsAbortControllerRef.current = abortController
diff --git a/web/app/components/base/text-generation/__tests__/hooks.spec.ts b/web/app/components/base/text-generation/__tests__/hooks.spec.ts
index db299870478..74a57b32380 100644
--- a/web/app/components/base/text-generation/__tests__/hooks.spec.ts
+++ b/web/app/components/base/text-generation/__tests__/hooks.spec.ts
@@ -54,10 +54,15 @@ describe('useTextGeneration', () => {
describe('Send Flow', () => {
it('should start streaming request and return true when not responding', async () => {
const { result } = renderHook(() => useTextGeneration())
+ const onNotifyError = vi.fn()
let sendResult: boolean | undefined
await act(async () => {
- sendResult = await result.current.handleSend('/console/api', { query: 'hello' })
+ sendResult = await result.current.handleSend(
+ '/console/api',
+ { query: 'hello' },
+ { onNotifyError },
+ )
})
expect(sendResult).toBe(true)
@@ -77,6 +82,7 @@ describe('useTextGeneration', () => {
onMessageReplace: expect.any(Function),
onCompleted: expect.any(Function),
onError: expect.any(Function),
+ onNotifyError,
}),
)
})
diff --git a/web/app/components/base/text-generation/hooks.ts b/web/app/components/base/text-generation/hooks.ts
index b9fdd799958..08d8c1ea394 100644
--- a/web/app/components/base/text-generation/hooks.ts
+++ b/web/app/components/base/text-generation/hooks.ts
@@ -3,12 +3,16 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ssePost } from '@/service/base'
+type SendOptions = {
+ onNotifyError?: (message: string, code?: string) => void
+}
+
export const useTextGeneration = () => {
const { t } = useTranslation()
const [isResponding, setIsResponding] = useState(false)
const [completion, setCompletion] = useState('')
const [messageId, setMessageId] = useState(null)
- const handleSend = async (url: string, data: any) => {
+ const handleSend = async (url: string, data: any, { onNotifyError }: SendOptions = {}) => {
if (isResponding) {
toast.info(t(($) => $['errorMessage.waitForResponse'], { ns: 'appDebug' }))
return false
@@ -41,6 +45,7 @@ export const useTextGeneration = () => {
onError() {
setIsResponding(false)
},
+ onNotifyError,
},
)
return true
diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx
index 37186813f83..72af1bd77d6 100644
--- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx
@@ -27,7 +27,8 @@ vi.mock('@/service/use-triggers', () => ({
useDeleteTriggerSubscription: () => ({ mutate: vi.fn(), isPending: false }),
}))
-vi.mock('@langgenius/dify-ui/toast', () => ({
+vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => ({
+ ...(await importOriginal()),
toast: Object.assign(vi.fn(), {
success: vi.fn(),
error: vi.fn(),
diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx
index df028cb4280..a506921d12b 100644
--- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-view.spec.tsx
@@ -25,7 +25,8 @@ vi.mock('@/service/use-triggers', () => ({
useDeleteTriggerSubscription: () => ({ mutate: mockDelete, isPending: false }),
}))
-vi.mock('@langgenius/dify-ui/toast', () => ({
+vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => ({
+ ...(await importOriginal()),
toast: Object.assign(vi.fn(), {
success: vi.fn(),
error: vi.fn(),
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx
index e1c4628851c..2c363e14690 100644
--- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx
+++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx
@@ -19,7 +19,8 @@ import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/type
import { render } from '@/test/console/render'
import EmailSenderModal from '../test-email-sender'
-vi.mock('@langgenius/dify-ui/toast', () => ({
+vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => ({
+ ...(await importOriginal()),
toast: {
error: vi.fn(),
},
diff --git a/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx b/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx
index 1b7e65f9aa6..65fdabf5e06 100644
--- a/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx
+++ b/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx
@@ -17,7 +17,8 @@ vi.mock('next/navigation', () => ({
useParams: () => ({}),
}))
-vi.mock('@langgenius/dify-ui/toast', () => ({
+vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => ({
+ ...(await importOriginal()),
toast: {
success: (message: string) => mockNotify({ type: 'success', message }),
error: (message: string) => mockNotify({ type: 'error', message }),
diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.spec.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.spec.tsx
index e54a912dff8..322f07ce1d7 100644
--- a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.spec.tsx
+++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-modal.spec.tsx
@@ -10,7 +10,8 @@ vi.mock('uuid', () => ({
v4: () => 'generated-id',
}))
-vi.mock('@langgenius/dify-ui/toast', () => ({
+vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => ({
+ ...(await importOriginal()),
toast: {
error: vi.fn(),
info: vi.fn(),
diff --git a/web/app/components/workflow/run/__tests__/index.spec.tsx b/web/app/components/workflow/run/__tests__/index.spec.tsx
index 5c3beb7d909..66f2660df58 100644
--- a/web/app/components/workflow/run/__tests__/index.spec.tsx
+++ b/web/app/components/workflow/run/__tests__/index.spec.tsx
@@ -26,7 +26,8 @@ vi.mock('@/context/account-state', async () => {
return createAccountStateModuleMock(() => ({ userProfile: { id: 'account-1' } }))
})
-vi.mock('@langgenius/dify-ui/toast', () => ({
+vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => ({
+ ...(await importOriginal()),
toast: {
error: (...args: unknown[]) => mockToastError(...args),
},
diff --git a/web/service/base.spec.ts b/web/service/base.spec.ts
index 4254720f7f8..f0427322122 100644
--- a/web/service/base.spec.ts
+++ b/web/service/base.spec.ts
@@ -383,6 +383,99 @@ describe('ssePost and sseGet', () => {
expect(toast.error).toHaveBeenCalledWith('TypeError: Network failed')
})
+ it('should route fetch failures through a custom notifier', async () => {
+ const onError = vi.fn()
+ const onNotifyError = vi.fn()
+ vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new TypeError('Network failed'))
+
+ await ssePost('/chat-messages', { body: { query: 'hello' } }, { onError, onNotifyError })
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalledWith('TypeError: Network failed')
+ })
+ expect(onNotifyError).toHaveBeenCalledWith('TypeError: Network failed')
+ expect(toast.error).not.toHaveBeenCalled()
+ })
+
+ it('should route the response error through a custom notifier', async () => {
+ const onError = vi.fn()
+ const onNotifyError = vi.fn()
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+ new Response(JSON.stringify({ message: 'Base model not found' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+
+ await ssePost('/chat-messages', { body: { query: 'hello' } }, { onError, onNotifyError })
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalledWith('Base model not found')
+ })
+ expect(onNotifyError).toHaveBeenCalledWith('Base model not found')
+ expect(toast.error).not.toHaveBeenCalled()
+ })
+
+ it('should route event stream response errors through a custom notifier', async () => {
+ const onError = vi.fn()
+ const onNotifyError = vi.fn()
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+ new Response(JSON.stringify({ message: 'Workflow resume failed' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+
+ await sseGet('/workflow/workflow-run-1/events', {}, { onError, onNotifyError })
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalledWith('Workflow resume failed')
+ })
+ expect(onNotifyError).toHaveBeenCalledWith('Workflow resume failed')
+ expect(toast.error).not.toHaveBeenCalled()
+ })
+
+ it('should preserve the default response error notification', async () => {
+ const onError = vi.fn()
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+ new Response(JSON.stringify({ message: 'Base model not found' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+
+ await ssePost('/chat-messages', { body: { query: 'hello' } }, { onError })
+
+ await waitFor(() => {
+ expect(toast.error).toHaveBeenCalledWith('Base model not found')
+ })
+ expect(onError).toHaveBeenCalledWith('Server Error')
+ })
+
+ it('should route the stream error through a custom notifier', async () => {
+ const onError = vi.fn()
+ const onNotifyError = vi.fn()
+ const body = new ReadableStream({
+ start(controller) {
+ controller.enqueue(
+ new TextEncoder().encode(
+ 'data: {"event":"error","message":"Base model not found","code":"model_not_found"}\n',
+ ),
+ )
+ controller.close()
+ },
+ })
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(body, { status: 200 }))
+
+ await ssePost('/chat-messages', { body: { query: 'hello' } }, { onError, onNotifyError })
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalledWith('Base model not found', 'model_not_found')
+ })
+ expect(onNotifyError).toHaveBeenCalledWith('Base model not found', 'model_not_found')
+ expect(toast.error).not.toHaveBeenCalled()
+ })
+
it('should report token refresh failures through onError', async () => {
const onError = vi.fn()
refreshAccessTokenOrReLoginMock.mockRejectedValueOnce(new Error('refresh failed'))
@@ -461,6 +554,7 @@ describe('ssePost and sseGet', () => {
it('should not notify when the stream reader is aborted', async () => {
const onError = vi.fn()
+ const onNotifyError = vi.fn()
const onCompleted = vi.fn()
const mockReader = {
read: vi
@@ -486,6 +580,7 @@ describe('ssePost and sseGet', () => {
},
{
onError,
+ onNotifyError,
onCompleted,
},
)
@@ -497,6 +592,7 @@ describe('ssePost and sseGet', () => {
)
})
expect(onCompleted).toHaveBeenCalledWith(true, 'AbortError: BodyStreamBuffer was aborted')
+ expect(onNotifyError).not.toHaveBeenCalled()
expect(toast.error).not.toHaveBeenCalled()
})
})
diff --git a/web/service/base.ts b/web/service/base.ts
index f2e456b3dd5..1a36f364288 100644
--- a/web/service/base.ts
+++ b/web/service/base.ts
@@ -75,6 +75,25 @@ type IOnMessageEnd = (messageEnd: MessageEnd) => void
export type IOnMessageReplace = (messageReplace: MessageReplace) => void
export type IOnCompleted = (hasError?: boolean, errorMessage?: string) => void
export type IOnError = (msg: string, code?: string) => void
+
+const reportStreamResponseError = async (
+ response: Response,
+ onError: IOnError | undefined,
+ onNotifyError: IOnError,
+) => {
+ let errorMessage = 'Server Error'
+ try {
+ const data: unknown = await response.json()
+ if (typeof data === 'object' && data !== null && 'message' in data) {
+ const message = data.message
+ if (typeof message === 'string' && message) errorMessage = message
+ }
+ } catch {}
+
+ onError?.(errorMessage)
+ onNotifyError(errorMessage)
+}
+
type UnhandledEventError = {
conversationId?: string
errorCode?: string
@@ -132,6 +151,8 @@ export type IOtherOptions = {
onMessageEnd?: IOnMessageEnd
onMessageReplace?: IOnMessageReplace
onError?: IOnError
+ /** Replaces the default global error notification for this request. */
+ onNotifyError?: IOnError
onUnhandledEvent?: IOnUnhandledEvent
onCompleted?: IOnCompleted // for stream
getAbortController?: (abortController: AbortController) => void
@@ -544,6 +565,7 @@ export const ssePost = async (
onTextReplace,
onAgentLog,
onError,
+ onNotifyError,
getAbortController,
onLoopStart,
onLoopNext,
@@ -616,10 +638,14 @@ export const ssePost = async (
})
}
} else {
- res.json().then((data) => {
- toast.error(data.message || 'Server Error')
- })
- onError?.('Server Error')
+ if (onNotifyError) {
+ void reportStreamResponseError(res, onError, onNotifyError)
+ } else {
+ res.json().then((data) => {
+ toast.error(data.message || 'Server Error')
+ })
+ onError?.('Server Error')
+ }
}
return
}
@@ -629,7 +655,10 @@ export const ssePost = async (
if (moreInfo.errorMessage) {
onError?.(moreInfo.errorMessage, moreInfo.errorCode)
// These errors can happen when a stream is intentionally stopped or its page is left.
- if (shouldNotifyStreamError(moreInfo.errorMessage)) toast.error(moreInfo.errorMessage)
+ if (shouldNotifyStreamError(moreInfo.errorMessage)) {
+ if (onNotifyError) onNotifyError(moreInfo.errorMessage, moreInfo.errorCode)
+ else toast.error(moreInfo.errorMessage)
+ }
return
}
onData?.(str, isFirstMessage, moreInfo)
@@ -670,7 +699,10 @@ export const ssePost = async (
})
.catch((e) => {
const errorMessage = String(e)
- if (shouldNotifyStreamError(e)) toast.error(errorMessage)
+ if (shouldNotifyStreamError(e)) {
+ if (onNotifyError) onNotifyError(errorMessage)
+ else toast.error(errorMessage)
+ }
onError?.(errorMessage)
})
}
@@ -705,6 +737,7 @@ export const sseGet = async (
onTextReplace,
onAgentLog,
onError,
+ onNotifyError,
getAbortController,
onLoopStart,
onLoopNext,
@@ -771,10 +804,14 @@ export const sseGet = async (
})
}
} else {
- res.json().then((data) => {
- toast.error(data.message || 'Server Error')
- })
- onError?.('Server Error')
+ if (onNotifyError) {
+ void reportStreamResponseError(res, onError, onNotifyError)
+ } else {
+ res.json().then((data) => {
+ toast.error(data.message || 'Server Error')
+ })
+ onError?.('Server Error')
+ }
}
return
}
@@ -784,7 +821,10 @@ export const sseGet = async (
if (moreInfo.errorMessage) {
onError?.(moreInfo.errorMessage, moreInfo.errorCode)
// These errors can happen when a stream is intentionally stopped or its page is left.
- if (shouldNotifyStreamError(moreInfo.errorMessage)) toast.error(moreInfo.errorMessage)
+ if (shouldNotifyStreamError(moreInfo.errorMessage)) {
+ if (onNotifyError) onNotifyError(moreInfo.errorMessage, moreInfo.errorCode)
+ else toast.error(moreInfo.errorMessage)
+ }
return
}
onData?.(str, isFirstMessage, moreInfo)
@@ -825,7 +865,10 @@ export const sseGet = async (
})
.catch((e) => {
const errorMessage = String(e)
- if (shouldNotifyStreamError(e)) toast.error(errorMessage)
+ if (shouldNotifyStreamError(e)) {
+ if (onNotifyError) onNotifyError(errorMessage)
+ else toast.error(errorMessage)
+ }
onError?.(errorMessage)
})
}
diff --git a/web/service/debug.ts b/web/service/debug.ts
index 342e06dce2e..566521271f9 100644
--- a/web/service/debug.ts
+++ b/web/service/debug.ts
@@ -25,16 +25,12 @@ export const stopChatMessageResponding = async (appId: string, taskId: string) =
export const sendCompletionMessage = async (
appId: string,
body: Record,
- {
- onData,
- onCompleted,
- onError,
- onMessageReplace,
- }: {
+ handlers: {
onData: IOnData
onCompleted: IOnCompleted
onError: IOnError
onMessageReplace: IOnMessageReplace
+ onNotifyError?: IOnError
},
) => {
return ssePost(
@@ -45,7 +41,7 @@ export const sendCompletionMessage = async (
response_mode: 'streaming',
},
},
- { onData, onCompleted, onError, onMessageReplace },
+ handlers,
)
}