fix: offset legacy app error toasts below model controls (#40040)

Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-08-06 04:14:12 +00:00
1 parent f08cc41546
commit d51900974f
51 files changed
+463 -110

No files matched your search

@@ -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(
<>
<ToastHost />
<ToastHost manager={localManager} />
</>,
)
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(<ToastHost manager={localManager} offset={{ top: 80 }} />)
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(<ToastHost limit={1} />)
+52 -41
View File
@@ -1,6 +1,7 @@
'use client'
import type {
ToastManager as BaseToastManager,
ToastManagerAddOptions,
ToastManagerUpdateOptions,
ToastObject,
@@ -80,8 +81,12 @@ type ToastPromiseOptions<Value> = {
type ToastHostProps = {
timeout?: number
limit?: number
manager?: ToastManager
offset?: ToastHostOffset
}
type ToastHostOffset = Pick<React.CSSProperties, 'top' | 'right'>
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<Value>
}
const toastManager = BaseToast.createToastManager<ToastData>()
type ToastManager = BaseToastManager<ToastData>
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 = <Value,>(
promiseValue: Promise<Value>,
options: ToastPromiseOptions<Value>,
) => 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<ToastData>()
}
function promiseToast<Value>(promiseValue: Promise<Value>, options: ToastPromiseOptions<Value>) {
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<ToastData> }) {
)
}
function ToastViewport() {
function ToastViewport({ offset }: { offset?: ToastHostOffset }) {
const { toasts } = BaseToast.useToastManager<ToastData>()
return (
<BaseToast.Viewport
aria-label={toastViewportLabel}
className={cn(
'group/toast-viewport pointer-events-none fixed top-4 right-4 z-60 w-90 max-w-[calc(100vw-2rem)] overflow-visible sm:right-8',
)}
className="group/toast-viewport pointer-events-none fixed top-4 right-4 z-60 w-90 max-w-[calc(100vw-2rem)] overflow-visible sm:right-8"
style={offset}
>
{toasts.map((toastItem) => (
<ToastCard key={toastItem.id} toast={toastItem} />
@@ -251,16 +262,16 @@ function ToastViewport() {
)
}
function ToastHost({ timeout, limit }: ToastHostProps) {
function ToastHost({ timeout, limit, manager = defaultToastManager, offset }: ToastHostProps) {
return (
<BaseToast.Provider toastManager={toastManager} timeout={timeout} limit={limit}>
<BaseToast.Provider toastManager={manager} timeout={timeout} limit={limit}>
<BaseToast.Portal>
<ToastViewport />
<ToastViewport offset={offset} />
</BaseToast.Portal>
</BaseToast.Provider>
)
}
export { toast, ToastHost }
export { createToast, createToastManager, toast, ToastHost }
export type { ToastHostProps }
@@ -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: () => <div>Configuration view</div>,
}))
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(
<>
<ToastHost />
<Configuration />
</>,
)
act(() => {
toast.error('Configuration error')
})
const toastItem = await screen.findByText('Configuration error')
const configurationViewport = toastItem.closest<HTMLElement>('[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')
})
})
@@ -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),
},
@@ -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),
},
@@ -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'
@@ -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'
@@ -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'
@@ -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'
@@ -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'
@@ -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'
@@ -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'
@@ -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),
},
@@ -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(),
},
@@ -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'
@@ -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'
@@ -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),
},
@@ -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'
@@ -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,
@@ -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,
@@ -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'
@@ -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,
@@ -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<string, unknown>) =>
toastMocks.call({ type: 'success', message, ...options }),
@@ -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'
@@ -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<string, unknown>) =>
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 () => {
@@ -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', () => {
@@ -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) }),
)
})
@@ -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<ChatItemProps> = ({ 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) =>
@@ -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<TextGenerationItemProps> = ({ modelAndParameter })
})
}
handleSend(`apps/${appId}/completion-messages`, data)
handleSend(`apps/${appId}/completion-messages`, data, {
onNotifyError: (message) => toast.error(message),
})
}
const { eventEmitter } = useEventEmitterContextContext()
@@ -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<void>>(() => 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 () => {
@@ -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) =>
@@ -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<IDebug> = ({
onError() {
setRespondingFalse()
},
onNotifyError: (message) => toast.error(message),
})
}
@@ -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),
@@ -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,
@@ -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 <ConfigurationView {...viewModel} />
return (
<>
<ToastHost manager={appConfigurationToastManager} offset={{ top: 60 }} />
<ConfigurationView {...viewModel} />
</>
)
}
export default React.memo(Configuration)
@@ -0,0 +1,8 @@
'use client'
import { createToast, createToastManager } from '@langgenius/dify-ui/toast'
const appConfigurationToastManager = createToastManager()
const toast = createToast(appConfigurationToastManager)
export { appConfigurationToastManager, toast }
@@ -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),
},
@@ -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'
@@ -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(() => {
+11 -1
View File
@@ -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
@@ -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,
}),
)
})
@@ -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<string | null>(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
@@ -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(),
@@ -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(),
@@ -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(),
},
@@ -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 }),
@@ -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(),
@@ -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),
},
+96
View File
@@ -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()
})
})
+55 -12
View File
@@ -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)
})
}
+3 -7
View File
@@ -25,16 +25,12 @@ export const stopChatMessageResponding = async (appId: string, taskId: string) =
export const sendCompletionMessage = async (
appId: string,
body: Record<string, any>,
{
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,
)
}