mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
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:
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} />)
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -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),
|
||||
},
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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'
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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'
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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', () => {
|
||||
|
||||
+24
@@ -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
-1
@@ -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()
|
||||
|
||||
+19
-1
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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 }
|
||||
+1
-1
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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(),
|
||||
|
||||
+2
-1
@@ -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(),
|
||||
|
||||
+2
-1
@@ -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 }),
|
||||
|
||||
+2
-1
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user