From d51900974f5a6ae6566638caa8a9c1d10c856afc Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 6 Aug 2026 12:14:12 +0800 Subject: [PATCH] fix: offset legacy app error toasts below model controls (#40040) Co-authored-by: yyh Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- .../src/toast/__tests__/index.spec.tsx | 42 +++++++- packages/dify-ui/src/toast/index.tsx | 93 ++++++++++-------- .../configuration/__tests__/index.spec.tsx | 48 ++++++++++ .../__tests__/advanced-prompt-input.spec.tsx | 2 +- .../__tests__/simple-prompt-input.spec.tsx | 2 +- .../config-prompt/advanced-prompt-input.tsx | 2 +- .../config-prompt/simple-prompt-input.tsx | 2 +- .../config-var/__tests__/index.spec.tsx | 2 +- .../__tests__/index-logic.spec.tsx | 2 +- .../config-modal/__tests__/index.spec.tsx | 2 +- .../config-var/config-modal/index.tsx | 2 +- .../app/configuration/config-var/index.tsx | 2 +- .../__tests__/get-automatic-res.spec.tsx | 2 +- .../automatic/__tests__/result.spec.tsx | 4 +- .../config/automatic/get-automatic-res.tsx | 2 +- .../configuration/config/automatic/result.tsx | 2 +- .../__tests__/get-code-generator-res.spec.tsx | 2 +- .../code-generator/get-code-generator-res.tsx | 2 +- .../__tests__/config-content.spec.tsx | 2 +- .../params-config/__tests__/index.spec.tsx | 2 +- .../params-config/config-content.tsx | 2 +- .../dataset-config/params-config/index.tsx | 2 +- .../settings-modal/__tests__/index.spec.tsx | 2 +- .../dataset-config/settings-modal/index.tsx | 2 +- .../debug/__tests__/index.spec.tsx | 14 ++- .../__tests__/chat-item.spec.tsx | 19 +++- .../__tests__/text-generation-item.spec.tsx | 24 +++++ .../debug-with-multiple-model/chat-item.tsx | 2 + .../text-generation-item.tsx | 5 +- .../__tests__/index.spec.tsx | 20 +++- .../debug/debug-with-single-model/index.tsx | 2 + .../app/configuration/debug/index.tsx | 3 +- .../__tests__/use-configuration-utils.spec.ts | 2 +- .../hooks/use-configuration-utils.ts | 2 +- .../components/app/configuration/index.tsx | 9 +- web/app/components/app/configuration/toast.ts | 8 ++ .../external-data-tool-modal.spec.tsx | 2 +- .../tools/external-data-tool-modal.tsx | 2 +- .../base/chat/chat/__tests__/hooks.spec.tsx | 14 ++- web/app/components/base/chat/chat/hooks.ts | 12 ++- .../text-generation/__tests__/hooks.spec.ts | 8 +- .../components/base/text-generation/hooks.ts | 7 +- .../__tests__/selector-entry.spec.tsx | 3 +- .../__tests__/selector-view.spec.tsx | 3 +- .../__tests__/test-email-sender.spec.tsx | 3 +- .../panel/__tests__/inputs-panel.spec.tsx | 3 +- .../__tests__/variable-modal.spec.tsx | 3 +- .../workflow/run/__tests__/index.spec.tsx | 3 +- web/service/base.spec.ts | 96 +++++++++++++++++++ web/service/base.ts | 67 ++++++++++--- web/service/debug.ts | 10 +- 51 files changed, 463 insertions(+), 110 deletions(-) create mode 100644 web/app/components/app/configuration/__tests__/index.spec.tsx create mode 100644 web/app/components/app/configuration/toast.ts 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, ) }