fix: text generation web app keeps loading after timeout (#39268)

This commit is contained in:
Joel 2026-07-20 14:27:32 +08:00 committed by GitHub
parent 605c0fe02b
commit bbc31a3403
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 120 additions and 14 deletions

View File

@ -11,37 +11,49 @@ const {
notifyMock,
sendCompletionMessageMock,
sendWorkflowMessageMock,
sleepMock,
stopChatMessageRespondingMock,
textGenerationResPropsSpy,
} = vi.hoisted(() => ({
notifyMock: vi.fn(),
sendCompletionMessageMock: vi.fn(),
sendWorkflowMessageMock: vi.fn(),
sleepMock: vi.fn(),
stopChatMessageRespondingMock: vi.fn(),
textGenerationResPropsSpy: vi.fn(),
}))
vi.mock('i18next', async (importOriginal) => {
const actual = await importOriginal<typeof import('i18next')>()
const { createI18nextMock } = await import('@/test/i18n-mock')
return {
...actual,
...createI18nextMock(),
t: () => '',
}
})
vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-i18next')>()
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return {
...actual,
...createReactI18nextMock({
'operation.stopResponding': 'operation.stopResponding',
'warningMessage.timeoutExceeded': 'Translated timeout warning',
}),
}
})
vi.mock('@langgenius/dify-ui/toast', () => ({
default: {
notify: notifyMock,
},
toast: (...args: unknown[]) => notifyMock(...args),
}))
vi.mock('@/utils', async () => {
const actual = await vi.importActual<typeof import('@/utils')>('@/utils')
return {
...actual,
sleep: () => new Promise<void>(() => {}),
sleep: (...args: Parameters<typeof actual.sleep>) => sleepMock(...args),
}
})
@ -120,6 +132,7 @@ const baseProps = {
describe('Result', () => {
beforeEach(() => {
vi.clearAllMocks()
sleepMock.mockImplementation(() => new Promise<void>(() => {}))
stopChatMessageRespondingMock.mockResolvedValue(undefined)
})
@ -271,6 +284,77 @@ describe('Result', () => {
expect(onCompleted).toHaveBeenCalledWith('{"answer":"Hello"}', undefined, true)
})
it('should finish a timed-out workflow and show a translated warning', async () => {
let resolveTimeout!: () => void
let workflowHandlers: IOtherOptions | null = null
sleepMock.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveTimeout = resolve
}),
)
sendWorkflowMessageMock.mockImplementation(async (_data, handlers) => {
workflowHandlers = handlers
})
const onCompleted = vi.fn()
const { rerender } = render(<Result {...baseProps} isWorkflow onCompleted={onCompleted} />)
rerender(<Result {...baseProps} isWorkflow controlSend={1} onCompleted={onCompleted} />)
await act(async () => {
workflowHandlers?.onWorkflowStarted?.({
workflow_run_id: 'run-timeout',
task_id: 'task-timeout',
event: 'workflow_started',
data: {
id: 'run-timeout',
workflow_id: 'wf-1',
created_at: 0,
},
})
resolveTimeout()
await Promise.resolve()
})
await act(async () => {
workflowHandlers?.onWorkflowFinished?.({
task_id: 'task-timeout',
workflow_run_id: 'run-timeout',
event: 'workflow_finished',
data: {
id: 'run-timeout',
workflow_id: 'wf-1',
status: 'succeeded',
outputs: { answer: 'Late result' },
error: '',
elapsed_time: 61,
total_tokens: 0,
total_steps: 0,
created_at: 0,
created_by: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
},
finished_at: 61,
},
})
})
expect(textGenerationResPropsSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
workflowProcessData: expect.objectContaining({
resultText: '',
status: 'succeeded',
}),
}),
)
expect(notifyMock).toHaveBeenCalledWith('Translated timeout warning', { type: 'warning' })
expect(onCompleted).toHaveBeenCalledWith('', undefined, false)
expect(onCompleted).toHaveBeenCalledTimes(1)
})
it('should render batch task ids for both short and long indexes', () => {
const { rerender } = render(<Result {...baseProps} isCallBatchAPI taskId={3} />)

View File

@ -637,7 +637,7 @@ describe('createWorkflowStreamHandlers', () => {
expect(setup.resetRunState).toHaveBeenCalled()
})
it('should handle timeout and workflow failures', () => {
it('should finish timed-out workflow state and warn without applying late outputs', () => {
const timeoutSetup = setupHandlers({
isTimedOut: () => true,
})
@ -654,7 +654,7 @@ describe('createWorkflowStreamHandlers', () => {
id: 'run-1',
workflow_id: 'wf-1',
status: WorkflowRunningStatus.Succeeded,
outputs: null,
outputs: { answer: 'Late result' },
error: '',
elapsed_time: 0,
total_tokens: 0,
@ -674,7 +674,18 @@ describe('createWorkflowStreamHandlers', () => {
type: 'warning',
message: 'warningMessage.timeoutExceeded',
})
expect(timeoutSetup.workflowProcessData()).toEqual(
expect.objectContaining({
resultText: '',
status: WorkflowRunningStatus.Succeeded,
}),
)
expect(timeoutSetup.onCompleted).not.toHaveBeenCalled()
expect(timeoutSetup.setRespondingFalse).not.toHaveBeenCalled()
expect(timeoutSetup.resetRunState).not.toHaveBeenCalled()
})
it('should handle workflow failures', () => {
const failureSetup = setupHandlers()
const failureHandlers = failureSetup.handlers as Required<
Pick<IOtherOptions, 'onWorkflowStarted' | 'onWorkflowFinished'>

View File

@ -7,19 +7,15 @@ import type { AppSourceType } from '@/service/share'
import type { VisionFile, VisionSettings } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { t } from 'i18next'
import { useCallback } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import TextGenerationRes from '@/app/components/app/text-generate/item'
import Loading from '@/app/components/base/loading'
import NoData from '@/app/components/share/text-generation/no-data'
import { useResultRunState } from './hooks/use-result-run-state'
import { useResultSender } from './hooks/use-result-sender'
const translateResultKey: TextGenerationTranslate = (selector, options) => {
return t(selector, options)
}
type IResultProps = {
isWorkflow: boolean
isCallBatchAPI: boolean
@ -77,6 +73,11 @@ const Result: FC<IResultProps> = ({
onRunControlChange,
hideInlineStopButton = false,
}) => {
const { t } = useTranslation()
const translateResultKey = useCallback<TextGenerationTranslate>(
(selector, options) => t(selector, options),
[t],
)
const notify = useCallback(
({ type, message }: { type: 'error' | 'info' | 'success' | 'warning'; message: string }) => {
toast(message, { type })

View File

@ -341,15 +341,25 @@ export const createWorkflowStreamHandlers = ({
setWorkflowProcessData(finishWorkflowNode(getWorkflowProcessData(), data))
},
onWorkflowFinished: ({ data }) => {
const workflowStatus = data.status as WorkflowRunningStatus | undefined
if (isTimedOut()) {
const finishedStatus =
workflowStatus === WorkflowRunningStatus.Stopped
? WorkflowRunningStatus.Stopped
: workflowStatus === WorkflowRunningStatus.Failed || data.error
? WorkflowRunningStatus.Failed
: WorkflowRunningStatus.Succeeded
setWorkflowProcessData(
applyWorkflowFinishedState(getWorkflowProcessData(), finishedStatus, data.error),
)
notify({
type: 'warning',
message: t(($) => $['warningMessage.timeoutExceeded'], { ns: 'appDebug' }),
})
markEnded()
return
}
const workflowStatus = data.status as WorkflowRunningStatus | undefined
if (workflowStatus === WorkflowRunningStatus.Stopped) {
setWorkflowProcessData(
applyWorkflowFinishedState(