diff --git a/web/app/components/share/text-generation/result/__tests__/index.spec.tsx b/web/app/components/share/text-generation/result/__tests__/index.spec.tsx index b52410176c0..5e6e8d6f3a6 100644 --- a/web/app/components/share/text-generation/result/__tests__/index.spec.tsx +++ b/web/app/components/share/text-generation/result/__tests__/index.spec.tsx @@ -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() - const { createI18nextMock } = await import('@/test/i18n-mock') return { ...actual, - ...createI18nextMock(), + t: () => '', + } +}) + +vi.mock('react-i18next', async (importOriginal) => { + const actual = await importOriginal() + 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('@/utils') return { ...actual, - sleep: () => new Promise(() => {}), + sleep: (...args: Parameters) => sleepMock(...args), } }) @@ -120,6 +132,7 @@ const baseProps = { describe('Result', () => { beforeEach(() => { vi.clearAllMocks() + sleepMock.mockImplementation(() => new Promise(() => {})) 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((resolve) => { + resolveTimeout = resolve + }), + ) + sendWorkflowMessageMock.mockImplementation(async (_data, handlers) => { + workflowHandlers = handlers + }) + + const onCompleted = vi.fn() + const { rerender } = render() + + rerender() + + 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() diff --git a/web/app/components/share/text-generation/result/__tests__/workflow-stream-handlers.spec.ts b/web/app/components/share/text-generation/result/__tests__/workflow-stream-handlers.spec.ts index 14001d7ca75..f8ad4bed78c 100644 --- a/web/app/components/share/text-generation/result/__tests__/workflow-stream-handlers.spec.ts +++ b/web/app/components/share/text-generation/result/__tests__/workflow-stream-handlers.spec.ts @@ -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 diff --git a/web/app/components/share/text-generation/result/index.tsx b/web/app/components/share/text-generation/result/index.tsx index 89ee19c6080..d5b578ed0a1 100644 --- a/web/app/components/share/text-generation/result/index.tsx +++ b/web/app/components/share/text-generation/result/index.tsx @@ -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 = ({ onRunControlChange, hideInlineStopButton = false, }) => { + const { t } = useTranslation() + const translateResultKey = useCallback( + (selector, options) => t(selector, options), + [t], + ) const notify = useCallback( ({ type, message }: { type: 'error' | 'info' | 'success' | 'warning'; message: string }) => { toast(message, { type }) diff --git a/web/app/components/share/text-generation/result/workflow-stream-handlers.ts b/web/app/components/share/text-generation/result/workflow-stream-handlers.ts index 335acdf69c9..64ff5fc7dc8 100644 --- a/web/app/components/share/text-generation/result/workflow-stream-handlers.ts +++ b/web/app/components/share/text-generation/result/workflow-stream-handlers.ts @@ -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(