Compare commits

...

1 Commits

Author SHA1 Message Date
Joel
116e8bf6c9 fix: offset legacy app error toasts below model controls 2026-08-05 18:35:25 +08:00
26 changed files with 346 additions and 48 deletions

View File

@@ -54,6 +54,20 @@ describe('@langgenius/dify-ui/toast', () => {
await expect.element(screen.getByText('Neutral toast')).toBeInTheDocument()
})
it('should place an individual toast at its configured top position', async () => {
const screen = await render(<ToastHost />)
toast.error('Positioned toast', {
position: { top: 80 },
})
const toastDialog = screen.getByRole('dialog', { name: 'Positioned toast' })
await expect.element(toastDialog).toBeInTheDocument()
await vi.waitFor(() => {
expect(toastDialog.element().getBoundingClientRect().top).toBeCloseTo(80, 0)
})
})
it('should mark overflow toasts as limited when the stack exceeds the configured limit', async () => {
const screen = await render(<ToastHost limit={1} />)

View File

@@ -9,7 +9,11 @@ import type * as React from 'react'
import { Toast as BaseToast } from '@base-ui/react/toast'
import { cn } from '../cn'
type ToastData = Record<string, never>
type ToastData = {
position?: {
top: number
}
}
type ToastToneStyle = {
gradientClassName: string
iconClassName: string
@@ -53,6 +57,7 @@ type ToastAddOptions = Omit<
ToastManagerAddOptions<ToastData>,
'data' | 'positionerProps' | 'type'
> & {
position?: ToastData['position']
type?: ToastType
}
@@ -110,8 +115,11 @@ function getToastRenderType(type?: string): ToastRenderType | undefined {
return type && isToastRenderType(type) ? type : undefined
}
function addToast(options: ToastAddOptions) {
return toastManager.add(options)
function addToast({ position, ...options }: ToastAddOptions) {
return toastManager.add({
...options,
data: { position },
})
}
const showToast: ToastCall = (title, options) =>
@@ -168,6 +176,11 @@ function ToastCard({ toast: toastItem }: { toast: ToastObject<ToastData> }) {
return (
<BaseToast.Root
toast={toastItem}
style={
toastItem.data?.position
? { top: `calc(${toastItem.data.position.top}px - 1rem)` }
: undefined
}
className={cn(
'pointer-events-auto absolute top-0 right-0 w-full origin-top cursor-default rounded-xl select-none focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
'[--toast-current-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:8px] [--toast-peek:5px] [--toast-scale:calc(1-(var(--toast-index)*0.0225))] [--toast-shrink:calc(1-var(--toast-scale))]',

View File

@@ -13,6 +13,7 @@ import { AppModeEnum, ModelModeType } from '@/types/app'
import ConfigurationView from '../configuration-view'
const mockIsAgentV2Enabled = vi.hoisted(() => vi.fn(() => false))
let capturedModelParameterModalProps: Record<string, unknown> | null = null
vi.mock('@/features/agent-v2/feature-flag', () => ({
isAgentV2Enabled: () => mockIsAgentV2Enabled(),
@@ -37,7 +38,10 @@ vi.mock('@/app/components/app/configuration/config/agent-setting-button', () =>
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
default: () => <div data-testid="model-parameter-modal" />,
default: (props: Record<string, unknown>) => {
capturedModelParameterModalProps = props
return <div data-testid="model-parameter-modal" />
},
}),
)
@@ -314,8 +318,25 @@ describe('ConfigurationView', () => {
vi.clearAllMocks()
mockIsAgentV2Enabled.mockReturnValue(false)
pluginDependencyOnInstallComplete = undefined
capturedModelParameterModalProps = null
})
it.each([AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION])(
'should position model parameter rule errors below the header for %s apps',
(mode) => {
const contextValue = createContextValue()
contextValue.mode = mode
render(<ConfigurationView {...createViewModel({ contextValue })} />)
expect(capturedModelParameterModalProps).toEqual(
expect.objectContaining({
errorToastOptions: { position: { top: 60 } },
}),
)
},
)
it('should render a loading state before configuration data is ready', () => {
render(<ConfigurationView {...createViewModel({ showLoading: true })} />)

View File

@@ -32,6 +32,7 @@ import EditHistoryModal from '@/app/components/app/configuration/config-prompt/c
import AgentSettingButton from '@/app/components/app/configuration/config/agent-setting-button'
import SelectDataSet from '@/app/components/app/configuration/dataset-config/select-dataset'
import Debug from '@/app/components/app/configuration/debug'
import { getDebugErrorToastOptions } from '@/app/components/app/configuration/debug/types'
import Divider from '@/app/components/base/divider'
import { FeaturesProvider } from '@/app/components/base/features'
import NewFeaturePanel from '@/app/components/base/features/new-feature-panel'
@@ -184,6 +185,7 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
onCompletionParamsChange={onCompletionParamsChange}
debugWithMultipleModel={debugWithMultipleModel}
onDebugWithMultipleModelChange={onEnableMultipleModelDebug}
errorToastOptions={getDebugErrorToastOptions(contextValue.mode)}
/>
<Divider type="vertical" className="mx-2 h-3.5" />
</>

View File

@@ -678,6 +678,7 @@ describe('Debug', () => {
expect(notify).toHaveBeenCalledWith({
type: 'error',
message: 'appDebug.otherError.historyNoBeEmpty',
position: { top: 60 },
})
})
@@ -695,6 +696,7 @@ describe('Debug', () => {
expect(notify).toHaveBeenCalledWith({
type: 'error',
message: 'appDebug.otherError.queryNoBeEmpty',
position: { top: 60 },
})
})
})
@@ -737,6 +739,7 @@ describe('Debug', () => {
expect(notify).toHaveBeenCalledWith({
type: 'error',
message: 'appDebug.errorMessage.valueOfVarRequired:{"key":"Question"}',
position: { top: 60 },
})
expect(mockState.mockSendCompletionMessage).not.toHaveBeenCalled()
})
@@ -846,10 +849,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, Record<string, unknown>]
expect(requestData).toMatchObject({
inputs: { question: 'hello' },
model_config: {
@@ -863,6 +864,11 @@ 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')
expect(handlers).toEqual(
expect.objectContaining({
errorToastOptions: { position: { top: 60 } },
}),
)
})
it('should notify when sending again while a response is in progress', async () => {

View File

@@ -4,6 +4,7 @@ import { screen } from '@testing-library/react'
import { TransferMethod } from '@/app/components/base/chat/types'
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { renderWithAccountProfile as render } from '@/test/console/account-profile'
import { AppModeEnum } from '@/types/app'
import { APP_CHAT_WITH_MULTIPLE_MODEL, APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } from '../../types'
import ChatItem from '../chat-item'
@@ -126,6 +127,7 @@ const createDefaultMocks = () => {
inputs: { key: 'value' },
collectionList: [],
canTestAndRun: true,
mode: AppModeEnum.CHAT,
})
mockUseProviderContext.mockReturnValue({
@@ -338,7 +340,9 @@ describe('ChatItem', () => {
query: 'Hello',
inputs: { key: 'value' },
}),
expect.any(Object),
expect.objectContaining({
errorToastOptions: { position: { top: 60 } },
}),
)
})
@@ -361,6 +365,43 @@ describe('ChatItem', () => {
expect(handleRestart).toHaveBeenCalled()
})
it('should position error toasts below the header for agent-chat apps', () => {
const handleSend = vi.fn()
mockUseDebugConfigurationContext.mockReturnValue({
modelConfig: {
configs: { prompt_variables: [] },
agentConfig: { tools: [] },
},
appId: 'app-123',
inputs: { key: 'value' },
collectionList: [],
canTestAndRun: true,
mode: AppModeEnum.AGENT_CHAT,
})
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
renderComponent()
eventSubscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'Hello', files: [] },
})
expect(handleSend).toHaveBeenCalledWith(
'apps/app-123/chat-messages',
expect.any(Object),
expect.objectContaining({
errorToastOptions: { position: { top: 60 } },
}),
)
})
it('should ignore unrelated events', () => {
const handleSend = vi.fn()
const handleRestart = vi.fn()

View File

@@ -15,6 +15,7 @@ import {
ModelTypeEnum,
PreferredProviderTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { AppModeEnum } from '@/types/app'
import ModelParameterTrigger from '../model-parameter-trigger'
const mockUseDebugConfigurationContext = vi.fn()
@@ -38,6 +39,7 @@ let capturedModalProps: {
debugWithMultipleModel: boolean
onDebugWithMultipleModelChange: () => void
renderTrigger: (props: RenderTriggerProps) => ReactNode
errorToastOptions?: { position: { top: number } }
} | null = null
vi.mock('@/context/debug-configuration', () => ({
@@ -149,6 +151,7 @@ describe('ModelParameterTrigger', () => {
mockUseDebugConfigurationContext.mockReturnValue({
isAdvancedMode: false,
mode: AppModeEnum.CHAT,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
@@ -193,6 +196,7 @@ describe('ModelParameterTrigger', () => {
expect(capturedModalProps?.modelId).toBe('claude-3')
expect(capturedModalProps?.completionParams).toEqual({ max_tokens: 1000 })
expect(capturedModalProps?.debugWithMultipleModel).toBe(true)
expect(capturedModalProps?.errorToastOptions).toEqual({ position: { top: 60 } })
})
it('should pass isAdvancedMode from context', () => {

View File

@@ -1,6 +1,7 @@
import type { ModelAndParameter } from '../../types'
import { render, screen } from '@testing-library/react'
import { TransferMethod } from '@/app/components/base/chat/types'
import { AppModeEnum } from '@/types/app'
import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../../types'
import TextGenerationItem from '../text-generation-item'
@@ -36,7 +37,7 @@ vi.mock('@/app/components/base/features/hooks', () => ({
}))
vi.mock('@/app/components/base/text-generation/hooks', () => ({
useTextGeneration: () => mockUseTextGeneration(),
useTextGeneration: (...args: unknown[]) => mockUseTextGeneration(...args),
}))
vi.mock('@/context/event-emitter', () => ({
@@ -94,6 +95,7 @@ const createDefaultMocks = () => {
completionPromptConfig: {},
dataSets: [{ id: 'ds-1' }],
datasetConfigs: { retrieval_model: 'single' },
mode: AppModeEnum.COMPLETION,
})
mockUseProviderContext.mockReturnValue({
@@ -165,6 +167,9 @@ describe('TextGenerationItem', () => {
renderComponent()
expect(screen.getByTestId('text-generation')).toBeInTheDocument()
expect(mockUseTextGeneration).toHaveBeenCalledWith({
errorToastOptions: { position: { top: 60 } },
})
})
it('should pass completion content to TextGeneration', () => {

View File

@@ -21,7 +21,11 @@ import {
} from '@/service/debug'
import { canFindTool } from '@/utils'
import { useConfigFromDebugContext, useFormattingChangedSubscription } from '../hooks'
import { APP_CHAT_WITH_MULTIPLE_MODEL, APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } from '../types'
import {
APP_CHAT_WITH_MULTIPLE_MODEL,
APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
getDebugErrorToastOptions,
} from '../types'
type ChatItemProps = {
modelAndParameter: ModelAndParameter
@@ -34,7 +38,9 @@ const ChatItem: FC<ChatItemProps> = ({ modelAndParameter }) => {
inputs,
collectionList,
canTestAndRun = false,
mode,
} = useDebugConfigurationContext()
const errorToastOptions = getDebugErrorToastOptions(mode)
const { textGenerationModelList } = useProviderContext()
const features = useFeatures((s) => s.features)
const configTemplate = useConfigFromDebugContext()
@@ -106,6 +112,7 @@ const ChatItem: FC<ChatItemProps> = ({ modelAndParameter }) => {
fetchConversationMessages(appId, conversationId, getAbortController),
onGetSuggestedQuestions: (responseItemId, getAbortController) =>
fetchSuggestedQuestions(appId, responseItemId, getAbortController),
...(errorToastOptions && { errorToastOptions }),
})
},
[
@@ -113,6 +120,7 @@ const ChatItem: FC<ChatItemProps> = ({ modelAndParameter }) => {
canTestAndRun,
chatList,
config,
errorToastOptions,
handleSend,
inputs,
modelAndParameter.model,

View File

@@ -15,6 +15,7 @@ import ModelParameterModal from '@/app/components/header/account-setting/model-p
import { useCredentialPanelState } from '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state'
import { useDebugConfigurationContext } from '@/context/debug-configuration'
import { useProviderContext } from '@/context/provider-context'
import { getDebugErrorToastOptions } from '../types'
import { useDebugWithMultipleModelContext } from './context'
type ModelParameterTriggerProps = {
@@ -22,7 +23,7 @@ type ModelParameterTriggerProps = {
}
const ModelParameterTrigger: FC<ModelParameterTriggerProps> = ({ modelAndParameter }) => {
const { t } = useTranslation()
const { isAdvancedMode } = useDebugConfigurationContext()
const { isAdvancedMode, mode } = useDebugConfigurationContext()
const { multipleModelConfigs, onMultipleModelConfigsChange, onDebugWithMultipleModelChange } =
useDebugWithMultipleModelContext()
const { modelProviders } = useProviderContext()
@@ -60,6 +61,7 @@ const ModelParameterTrigger: FC<ModelParameterTriggerProps> = ({ modelAndParamet
setModel={handleSelectModel}
debugWithMultipleModel
onDebugWithMultipleModelChange={() => onDebugWithMultipleModelChange(modelAndParameter)}
errorToastOptions={getDebugErrorToastOptions(mode)}
renderTrigger={({ open, currentProvider, currentModel }) => {
const status = deriveModelStatus(
modelAndParameter.model,

View File

@@ -14,7 +14,7 @@ import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useProviderContext } from '@/context/provider-context'
import { AppSourceType } from '@/service/share'
import { promptVariablesToUserInputsForm } from '@/utils/model-config'
import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types'
import { APP_CHAT_WITH_MULTIPLE_MODEL, getDebugErrorToastOptions } from '../types'
type TextGenerationItemProps = {
modelAndParameter: ModelAndParameter
@@ -35,6 +35,7 @@ const TextGenerationItem: FC<TextGenerationItemProps> = ({ modelAndParameter })
completionPromptConfig,
dataSets,
datasetConfigs,
mode,
} = useDebugConfigurationContext()
const { textGenerationModelList } = useProviderContext()
const features = useFeatures((s) => s.features)
@@ -76,7 +77,9 @@ const TextGenerationItem: FC<TextGenerationItemProps> = ({ modelAndParameter })
},
system_parameters: modelConfig.system_parameters,
}
const { completion, handleSend, isResponding, messageId } = useTextGeneration()
const { completion, handleSend, isResponding, messageId } = useTextGeneration({
errorToastOptions: getDebugErrorToastOptions(mode),
})
const doSend: OnSend = (message, files) => {
const currentProvider = textGenerationModelList.find(

View File

@@ -673,6 +673,11 @@ describe('DebugWithSingleModel', () => {
})
expect(mockSsePost.mock.calls[0]![0]).toBe('apps/test-app-id/chat-messages')
expect(mockSsePost.mock.calls[0]![2]).toEqual(
expect.objectContaining({
errorToastOptions: { position: { top: 60 } },
}),
)
})
it('should prevent send when checkCanSend returns false', async () => {
@@ -694,6 +699,26 @@ describe('DebugWithSingleModel', () => {
})
expect(mockSsePost).not.toHaveBeenCalled()
})
it('should position error toasts below the header for agent-chat apps', async () => {
mockUseDebugConfigurationContext.mockReturnValue({
...mockDebugConfigContext,
mode: AppModeEnum.AGENT_CHAT,
})
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
fireEvent.click(screen.getByTestId('send-button'))
await waitFor(() => {
expect(mockSsePost).toHaveBeenCalled()
})
expect(mockSsePost.mock.calls[0]![2]).toEqual(
expect.objectContaining({
errorToastOptions: { position: { top: 60 } },
}),
)
})
})
// User Interactions

View File

@@ -11,7 +11,6 @@ import { getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/cha
import { useFeatures } from '@/app/components/base/features/hooks'
import { userProfileAtom } from '@/context/account-state'
import { useDebugConfigurationContext } from '@/context/debug-configuration'
import { useProviderContext } from '@/context/provider-context'
import {
fetchConversationMessages,
fetchSuggestedQuestions,
@@ -19,6 +18,7 @@ import {
} from '@/service/debug'
import { canFindTool } from '@/utils'
import { useConfigFromDebugContext, useFormattingChangedSubscription } from '../hooks'
import { getDebugErrorToastOptions } from '../types'
type DebugWithSingleModelProps = {
checkCanSend?: () => boolean
@@ -41,11 +41,12 @@ const DebugWithSingleModel = ({
inputs,
collectionList,
completionParams,
mode,
// isShowVisionConfig,
} = useDebugConfigurationContext()
const errorToastOptions = getDebugErrorToastOptions(mode)
const debugInputReadonly = !canTestAndRun
const canManageAnnotation = !readonly && canTestAndRun
const { textGenerationModelList } = useProviderContext()
const features = useFeatures((s) => s.features)
const configTemplate = useConfigFromDebugContext()
const config = useMemo(() => {
@@ -121,6 +122,7 @@ const DebugWithSingleModel = ({
fetchConversationMessages(appId, conversationId, getAbortController),
onGetSuggestedQuestions: (responseItemId, getAbortController) =>
fetchSuggestedQuestions(appId, responseItemId, getAbortController),
...(errorToastOptions && { errorToastOptions }),
})
},
[
@@ -130,12 +132,12 @@ const DebugWithSingleModel = ({
checkCanSend,
completionParams,
config,
errorToastOptions,
handleSend,
inputs,
modelConfig.mode,
modelConfig.model_id,
modelConfig.provider,
textGenerationModelList,
],
)

View File

@@ -46,7 +46,11 @@ import FormattingChanged from '../base/warning-mask/formatting-changed'
import HasNotSetAPIKEY from '../base/warning-mask/has-not-set-api'
import DebugWithMultipleModel from './debug-with-multiple-model'
import DebugWithSingleModel from './debug-with-single-model'
import { APP_CHAT_WITH_MULTIPLE_MODEL, APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } from './types'
import {
APP_CHAT_WITH_MULTIPLE_MODEL,
APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
getDebugErrorToastOptions,
} from './types'
type IDebug = {
isAPIKeySet: boolean
@@ -135,20 +139,25 @@ const Debug: FC<IDebug> = ({
setIsShowFormattingChangeConfirm(false)
setFormattingChanged(false)
}
const logError = useCallback((message: string) => {
toast.error(message)
}, [])
const errorToastOptions = getDebugErrorToastOptions(mode)
const logError = useCallback(
(message: string) => {
if (errorToastOptions) toast.error(message, errorToastOptions)
else toast.error(message)
},
[errorToastOptions],
)
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
const checkCanSend = useCallback(() => {
if (isAdvancedMode && mode !== AppModeEnum.COMPLETION) {
if (modelModeType === ModelModeType.completion) {
if (!hasSetBlockStatus.history) {
toast.error(t(($) => $['otherError.historyNoBeEmpty'], { ns: 'appDebug' }))
logError(t(($) => $['otherError.historyNoBeEmpty'], { ns: 'appDebug' }))
return false
}
if (!hasSetBlockStatus.query) {
toast.error(t(($) => $['otherError.queryNoBeEmpty'], { ns: 'appDebug' }))
logError(t(($) => $['otherError.queryNoBeEmpty'], { ns: 'appDebug' }))
return false
}
}
@@ -305,6 +314,7 @@ const Debug: FC<IDebug> = ({
onError() {
setRespondingFalse()
},
...(errorToastOptions && { errorToastOptions }),
})
}

View File

@@ -1,3 +1,5 @@
import { AppModeEnum } from '@/types/app'
export type ModelAndParameter = {
id: string
model: string
@@ -16,3 +18,15 @@ export type DebugWithSingleOrMultipleModelConfigs = {
export const APP_CHAT_WITH_MULTIPLE_MODEL = 'APP_CHAT_WITH_MULTIPLE_MODEL'
export const APP_CHAT_WITH_MULTIPLE_MODEL_RESTART = 'APP_CHAT_WITH_MULTIPLE_MODEL_RESTART'
export const ORCHESTRATE_CHANGED = 'ORCHESTRATE_CHANGED'
const DEBUG_ERROR_TOAST_OPTIONS = {
position: { top: 60 },
} as const
export const getDebugErrorToastOptions = (mode: AppModeEnum) => {
if (
mode === AppModeEnum.CHAT ||
mode === AppModeEnum.AGENT_CHAT ||
mode === AppModeEnum.COMPLETION
)
return DEBUG_ERROR_TOAST_OPTIONS
}

View File

@@ -69,6 +69,7 @@ type SendCallback = {
onUnhandledEvent?: IOtherOptions['onUnhandledEvent']
onSendSettled?: (hasError?: boolean) => void
isPublicAPI?: boolean
errorToastOptions?: IOtherOptions['errorToastOptions']
}
type UseChatOptions = {
@@ -578,7 +579,13 @@ export const useChat = (
async (
messageId: string,
workflowRunId: string,
{ onGetSuggestedQuestions, onConversationComplete, onSendSettled, isPublicAPI }: SendCallback,
{
onGetSuggestedQuestions,
onConversationComplete,
onSendSettled,
isPublicAPI,
errorToastOptions,
}: SendCallback,
) => {
const hasActiveSubscription =
workflowEventsSubscriptionActiveRef.current &&
@@ -600,6 +607,7 @@ export const useChat = (
}
const otherOptions: IOtherOptions = {
isPublicAPI,
...(errorToastOptions && { errorToastOptions }),
getAbortController: (abortController) => {
workflowEventsAbortControllerRef.current = abortController
},
@@ -1086,6 +1094,7 @@ export const useChat = (
onUnhandledEvent,
onSendSettled,
isPublicAPI,
errorToastOptions,
}: SendCallback,
) => {
setSuggestedQuestions([])
@@ -1186,6 +1195,7 @@ export const useChat = (
const otherOptions: IOtherOptions = {
isPublicAPI,
...(errorToastOptions && { errorToastOptions }),
onUnhandledEvent,
getAbortController: (abortController) => {
workflowEventsAbortControllerRef.current = abortController

View File

@@ -81,6 +81,17 @@ describe('useTextGeneration', () => {
)
})
it('should forward configured error toast options to the stream request', async () => {
const errorToastOptions = { position: { top: 60 } } as const
const { result } = renderHook(() => useTextGeneration({ errorToastOptions }))
await act(async () => {
await result.current.handleSend('/console/api', { query: 'hello' })
})
expect(getLatestStreamOptions().errorToastOptions).toEqual(errorToastOptions)
})
it('should append chunks and update messageId when onData is triggered', async () => {
const { result } = renderHook(() => useTextGeneration())

View File

@@ -3,7 +3,11 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ssePost } from '@/service/base'
export const useTextGeneration = () => {
type UseTextGenerationOptions = {
errorToastOptions?: Parameters<typeof toast.error>[1]
}
export const useTextGeneration = ({ errorToastOptions }: UseTextGenerationOptions = {}) => {
const { t } = useTranslation()
const [isResponding, setIsResponding] = useState(false)
const [completion, setCompletion] = useState('')
@@ -41,6 +45,7 @@ export const useTextGeneration = () => {
onError() {
setIsResponding(false)
},
...(errorToastOptions && { errorToastOptions }),
},
)
return true

View File

@@ -14,6 +14,7 @@ let parameterRules: Array<Record<string, unknown>> | undefined = [
]
let isRulesLoading = false
let isRulesPending = false
let modelParameterRulesArgs: unknown[] = []
let currentProvider: Record<string, unknown> | undefined = {
provider: 'openai',
label: { en_US: 'OpenAI' },
@@ -48,13 +49,16 @@ vi.mock('@/context/provider-context', () => ({
}))
vi.mock('@/service/use-common', () => ({
useModelParameterRules: () => ({
data: {
data: parameterRules,
},
isLoading: isRulesLoading,
isPending: isRulesPending,
}),
useModelParameterRules: (...args: unknown[]) => {
modelParameterRulesArgs = args
return {
data: {
data: parameterRules,
},
isLoading: isRulesLoading,
isPending: isRulesPending,
}
},
}))
vi.mock('../../hooks', () => ({
@@ -197,6 +201,19 @@ describe('ModelParameterModal', () => {
],
},
]
modelParameterRulesArgs = []
})
it('should pass error toast options to the parameter-rules query', () => {
const errorToastOptions = { position: { top: 60 } } as const
render(<ModelParameterModal {...defaultProps} errorToastOptions={errorToastOptions} />)
expect(modelParameterRulesArgs).toEqual([
defaultProps.provider,
defaultProps.modelId,
{ errorToastOptions },
])
})
it('should render trigger and open modal content when trigger is clicked', () => {

View File

@@ -3,6 +3,7 @@ import type { DefaultModel, FormValue, Model, ModelParameterRule } from '../decl
import type { ParameterValue } from './parameter-item'
import type { TriggerProps } from './types'
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
import type { ModelParameterRulesQueryOptions } from '@/service/use-common'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useMemo, useState } from 'react'
@@ -41,6 +42,7 @@ export type ModelParameterModalProps = {
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
modelList?: Model[]
errorToastOptions?: ModelParameterRulesQueryOptions['errorToastOptions']
}
const ModelParameterModal: FC<ModelParameterModalProps> = ({
@@ -61,10 +63,13 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
nodesOutputVars,
availableNodes,
modelList,
errorToastOptions,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const { data: parameterRulesData, isLoading } = useModelParameterRules(provider, modelId)
const { data: parameterRulesData, isLoading } = useModelParameterRules(provider, modelId, {
errorToastOptions,
})
const isRulesLoading = !!provider && !!modelId && isLoading
const { currentProvider, currentModel, activeTextGenerationModelList } =
useTextGenerationCurrentProviderAndModelAndModelList({ provider, model: modelId })

View File

@@ -383,6 +383,27 @@ describe('ssePost and sseGet', () => {
expect(toast.error).toHaveBeenCalledWith('TypeError: Network failed')
})
it('should apply custom positioning to a stream error toast', async () => {
const errorToastOptions = { position: { top: 80 } } as const
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new TypeError('Network failed'))
await ssePost(
'/chat-messages',
{
body: {
query: 'hello',
},
},
{
errorToastOptions,
},
)
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('TypeError: Network failed', errorToastOptions)
})
})
it('should report token refresh failures through onError', async () => {
const onError = vi.fn()
refreshAccessTokenOrReLoginMock.mockRejectedValueOnce(new Error('refresh failed'))

View File

@@ -59,6 +59,11 @@ const isAbortError = (error: unknown) => {
const shouldNotifyStreamError = (error: unknown) =>
!isAbortError(error) && !String(error).includes('TypeError: Cannot assign to read only property')
const showErrorToast = (message: string, options?: Parameters<typeof toast.error>[1]) => {
if (options) toast.error(message, options)
else toast.error(message)
}
export type IOnDataMoreInfo = {
event?: string
conversationId?: string
@@ -120,6 +125,7 @@ export type IOtherOptions = {
needAllResponseContent?: boolean
deleteContentType?: boolean
silent?: boolean
errorToastOptions?: Parameters<typeof toast.error>[1]
/** If true, behaves like standard fetch: no URL prefix, returns raw Response */
fetchCompat?: boolean
@@ -556,6 +562,7 @@ export const ssePost = async (
onDataSourceNodeCompleted,
onDataSourceNodeError,
onUnhandledEvent,
errorToastOptions,
} = otherOptions
const abortController = new AbortController()
@@ -617,7 +624,7 @@ export const ssePost = async (
}
} else {
res.json().then((data) => {
toast.error(data.message || 'Server Error')
showErrorToast(data.message || 'Server Error', errorToastOptions)
})
onError?.('Server Error')
}
@@ -629,7 +636,8 @@ 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))
showErrorToast(moreInfo.errorMessage, errorToastOptions)
return
}
onData?.(str, isFirstMessage, moreInfo)
@@ -670,7 +678,7 @@ export const ssePost = async (
})
.catch((e) => {
const errorMessage = String(e)
if (shouldNotifyStreamError(e)) toast.error(errorMessage)
if (shouldNotifyStreamError(e)) showErrorToast(errorMessage, errorToastOptions)
onError?.(errorMessage)
})
}
@@ -717,6 +725,7 @@ export const sseGet = async (
onDataSourceNodeCompleted,
onDataSourceNodeError,
onUnhandledEvent,
errorToastOptions,
} = otherOptions
const abortController = new AbortController()
@@ -772,7 +781,7 @@ export const sseGet = async (
}
} else {
res.json().then((data) => {
toast.error(data.message || 'Server Error')
showErrorToast(data.message || 'Server Error', errorToastOptions)
})
onError?.('Server Error')
}
@@ -784,7 +793,8 @@ 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))
showErrorToast(moreInfo.errorMessage, errorToastOptions)
return
}
onData?.(str, isFirstMessage, moreInfo)
@@ -825,7 +835,7 @@ export const sseGet = async (
})
.catch((e) => {
const errorMessage = String(e)
if (shouldNotifyStreamError(e)) toast.error(errorMessage)
if (shouldNotifyStreamError(e)) showErrorToast(errorMessage, errorToastOptions)
onError?.(errorMessage)
})
}
@@ -975,7 +985,7 @@ export const request = async <T>(url: string, options = {}, otherOptions?: IOthe
return Promise.reject(err)
}
if (code === 'init_validate_failed' && !silent) {
toast.error(message, { timeout: 4000 })
toast.error(message, { ...otherOptionsForBaseFetch.errorToastOptions, timeout: 4000 })
return Promise.reject(err)
}
if (code === 'not_init_validated') {
@@ -999,7 +1009,7 @@ export const request = async <T>(url: string, options = {}, otherOptions?: IOthe
return Promise.reject(err)
}
if (!silent) {
toast.error(message)
showErrorToast(message, otherOptionsForBaseFetch.errorToastOptions)
return Promise.reject(err)
}
jumpTo(buildSigninUrlWithRedirect())

View File

@@ -1,4 +1,4 @@
import type { IOnCompleted, IOnData, IOnError, IOnMessageReplace } from './base'
import type { IOnCompleted, IOnData, IOnError, IOnMessageReplace, IOtherOptions } from './base'
import type { ChatPromptConfig, CompletionPromptConfig } from '@/models/debug'
import type { AppModeEnum, ModelModeType } from '@/types/app'
import { get, post, ssePost } from './base'
@@ -30,11 +30,13 @@ export const sendCompletionMessage = async (
onCompleted,
onError,
onMessageReplace,
errorToastOptions,
}: {
onData: IOnData
onCompleted: IOnCompleted
onError: IOnError
onMessageReplace: IOnMessageReplace
errorToastOptions?: IOtherOptions['errorToastOptions']
},
) => {
return ssePost(
@@ -45,7 +47,13 @@ export const sendCompletionMessage = async (
response_mode: 'streaming',
},
},
{ onData, onCompleted, onError, onMessageReplace },
{
onData,
onCompleted,
onError,
onMessageReplace,
...(errorToastOptions && { errorToastOptions }),
},
)
}

View File

@@ -73,6 +73,31 @@ describe('base', () => {
expect(toast.error).toHaveBeenCalledWith('Invalid DSL kind')
})
it('should apply custom positioning to an error toast', async () => {
const errorToastOptions = { position: { top: 60 } } as const
const errorResponse = new Response(
JSON.stringify({
code: 'invalid_param',
message: 'Invalid model',
status: 400,
}),
{
status: 400,
headers: {
'Content-Type': 'application/json',
},
},
)
vi.spyOn(globalThis, 'fetch').mockResolvedValue(errorResponse)
await expect(
base('/model-parameter-rules', {}, { errorToastOptions }),
).rejects.toBeInstanceOf(Response)
expect(toast.error).toHaveBeenCalledWith('Invalid model', errorToastOptions)
})
it('should not display an empty error toast when message and error are absent', async () => {
const errorResponse = new Response(
JSON.stringify({

View File

@@ -77,7 +77,11 @@ const afterResponseErrorCode = (otherOptions: IOtherOptions): AfterResponseHook
const shouldNotifyError = response.status !== 401 && errorData && !otherOptions.silent
const errorMessage = errorData?.message || errorData?.error
if (shouldNotifyError && errorMessage) toast.error(errorMessage)
if (shouldNotifyError && errorMessage) {
if (otherOptions.errorToastOptions)
toast.error(errorMessage, otherOptions.errorToastOptions)
else toast.error(errorMessage)
}
if (response.status === 403 && errorData?.code === 'already_setup')
globalThis.location.href = `${globalThis.location.origin}/signin`

View File

@@ -1,3 +1,5 @@
// oxlint-disable-next-line no-restricted-imports
import type { IOtherOptions } from './base'
import type { FileTypesRes } from './datasets'
import type {
Model,
@@ -248,14 +250,24 @@ export const useNotionBinding = (code?: string | null, enabled?: boolean) => {
})
}
export const useModelParameterRules = (provider?: string, model?: string, enabled?: boolean) => {
export type ModelParameterRulesQueryOptions = {
enabled?: boolean
errorToastOptions?: IOtherOptions['errorToastOptions']
}
export const useModelParameterRules = (
provider?: string,
model?: string,
options: ModelParameterRulesQueryOptions = {},
) => {
return useQuery<{ data: ModelParameterRule[] }>({
queryKey: commonQueryKeys.modelParameterRules(provider, model),
queryKey: [...commonQueryKeys.modelParameterRules(provider, model), options.errorToastOptions],
queryFn: () =>
get<{ data: ModelParameterRule[] }>(
`/workspaces/current/model-providers/${provider}/models/parameter-rules`,
{ params: { model }, silent: true },
{ params: { model } },
options.errorToastOptions ? { errorToastOptions: options.errorToastOptions } : undefined,
),
enabled: !!provider && !!model && (enabled ?? true),
enabled: !!provider && !!model && (options.enabled ?? true),
})
}