From 15d25f88769737191eef1c5f8c83115eaad28a1f Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Fri, 6 Feb 2026 17:31:42 +0800 Subject: [PATCH] feat: enhance text generation result components and add tests - Removed unused ESLint suppressions from the configuration. - Introduced new Result and Header components for better organization of text generation results. - Added comprehensive tests for the new components and hooks to ensure functionality and reliability. - Updated the useTextGeneration hook to streamline state management and improve performance. --- .../result/{ => components}/content.spec.tsx | 0 .../result/{ => components}/content.tsx | 0 .../result/{ => components}/header.spec.tsx | 0 .../result/{ => components}/header.tsx | 6 +- .../result/hooks/use-text-generation.spec.ts | 283 +++++++++ .../result/hooks/use-text-generation.ts | 357 +++++++++++ .../result/hooks/workflow-callbacks.spec.ts | 295 +++++++++ .../result/hooks/workflow-callbacks.ts | 237 +++++++ .../text-generation/result/index.spec.tsx | 245 ++++++++ .../share/text-generation/result/index.tsx | 577 ++---------------- web/eslint-suppressions.json | 13 - 11 files changed, 1470 insertions(+), 543 deletions(-) rename web/app/components/share/text-generation/result/{ => components}/content.spec.tsx (100%) rename web/app/components/share/text-generation/result/{ => components}/content.tsx (100%) rename web/app/components/share/text-generation/result/{ => components}/header.spec.tsx (100%) rename web/app/components/share/text-generation/result/{ => components}/header.tsx (91%) create mode 100644 web/app/components/share/text-generation/result/hooks/use-text-generation.spec.ts create mode 100644 web/app/components/share/text-generation/result/hooks/use-text-generation.ts create mode 100644 web/app/components/share/text-generation/result/hooks/workflow-callbacks.spec.ts create mode 100644 web/app/components/share/text-generation/result/hooks/workflow-callbacks.ts create mode 100644 web/app/components/share/text-generation/result/index.spec.tsx diff --git a/web/app/components/share/text-generation/result/content.spec.tsx b/web/app/components/share/text-generation/result/components/content.spec.tsx similarity index 100% rename from web/app/components/share/text-generation/result/content.spec.tsx rename to web/app/components/share/text-generation/result/components/content.spec.tsx diff --git a/web/app/components/share/text-generation/result/content.tsx b/web/app/components/share/text-generation/result/components/content.tsx similarity index 100% rename from web/app/components/share/text-generation/result/content.tsx rename to web/app/components/share/text-generation/result/components/content.tsx diff --git a/web/app/components/share/text-generation/result/header.spec.tsx b/web/app/components/share/text-generation/result/components/header.spec.tsx similarity index 100% rename from web/app/components/share/text-generation/result/header.spec.tsx rename to web/app/components/share/text-generation/result/components/header.spec.tsx diff --git a/web/app/components/share/text-generation/result/header.tsx b/web/app/components/share/text-generation/result/components/header.tsx similarity index 91% rename from web/app/components/share/text-generation/result/header.tsx rename to web/app/components/share/text-generation/result/components/header.tsx index 250a46f088..4e494e0b5a 100644 --- a/web/app/components/share/text-generation/result/header.tsx +++ b/web/app/components/share/text-generation/result/components/header.tsx @@ -24,7 +24,7 @@ const Header: FC = ({ }) => { const { t } = useTranslation() return ( -
+
{t('generation.resultTitle', { ns: 'share' })}
@@ -67,7 +67,7 @@ const Header: FC = ({ rating: null, }) }} - className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-red-200 bg-red-100 !text-red-600 hover:border-red-300 hover:bg-red-200" + className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-red-200 bg-red-100 !text-red-600 hover:border-red-300 hover:bg-red-200" >
diff --git a/web/app/components/share/text-generation/result/hooks/use-text-generation.spec.ts b/web/app/components/share/text-generation/result/hooks/use-text-generation.spec.ts new file mode 100644 index 0000000000..51a62e9984 --- /dev/null +++ b/web/app/components/share/text-generation/result/hooks/use-text-generation.spec.ts @@ -0,0 +1,283 @@ +import type { UseTextGenerationProps } from './use-text-generation' +import { act, renderHook } from '@testing-library/react' +import { AppSourceType } from '@/service/share' +import { useTextGeneration } from './use-text-generation' + +// Mock external services +vi.mock('@/service/share', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + sendCompletionMessage: vi.fn(), + sendWorkflowMessage: vi.fn(() => Promise.resolve()), + stopChatMessageResponding: vi.fn(() => Promise.resolve()), + stopWorkflowMessage: vi.fn(() => Promise.resolve()), + updateFeedback: vi.fn(() => Promise.resolve()), + } +}) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: vi.fn() }, +})) + +vi.mock('i18next', () => ({ + t: (key: string) => key, +})) + +vi.mock('@/utils', () => ({ + sleep: vi.fn(() => Promise.resolve()), +})) + +vi.mock('@/app/components/base/file-uploader/utils', () => ({ + getProcessedFiles: vi.fn((files: unknown[]) => files), + getFilesInLogs: vi.fn(() => []), +})) + +vi.mock('@/utils/model-config', () => ({ + formatBooleanInputs: vi.fn((_vars: unknown, inputs: unknown) => inputs), +})) + +// Factory for default hook props +function createProps(overrides: Partial = {}): UseTextGenerationProps { + return { + isWorkflow: false, + isCallBatchAPI: false, + isPC: true, + appSourceType: AppSourceType.webApp, + appId: 'app-1', + promptConfig: { prompt_template: '', prompt_variables: [] }, + inputs: {}, + onShowRes: vi.fn(), + onCompleted: vi.fn(), + visionConfig: { enabled: false } as UseTextGenerationProps['visionConfig'], + completionFiles: [], + onRunStart: vi.fn(), + ...overrides, + } +} + +describe('useTextGeneration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Initial state + describe('initial state', () => { + it('should return correct default values', () => { + const { result } = renderHook(() => useTextGeneration(createProps())) + + expect(result.current.isResponding).toBe(false) + expect(result.current.completionRes).toBe('') + expect(result.current.workflowProcessData).toBeUndefined() + expect(result.current.messageId).toBeNull() + expect(result.current.feedback).toEqual({ rating: null }) + expect(result.current.isStopping).toBe(false) + expect(result.current.currentTaskId).toBeNull() + expect(result.current.controlClearMoreLikeThis).toBe(0) + }) + + it('should expose handler functions', () => { + const { result } = renderHook(() => useTextGeneration(createProps())) + + expect(typeof result.current.handleSend).toBe('function') + expect(typeof result.current.handleStop).toBe('function') + expect(typeof result.current.handleFeedback).toBe('function') + }) + }) + + // Feedback + describe('handleFeedback', () => { + it('should call updateFeedback API and update state', async () => { + const { updateFeedback } = await import('@/service/share') + const { result } = renderHook(() => useTextGeneration(createProps())) + + await act(async () => { + await result.current.handleFeedback({ rating: 'like' }) + }) + + expect(updateFeedback).toHaveBeenCalledWith( + expect.objectContaining({ body: { rating: 'like', content: undefined } }), + AppSourceType.webApp, + 'app-1', + ) + expect(result.current.feedback).toEqual({ rating: 'like' }) + }) + }) + + // Stop + describe('handleStop', () => { + it('should do nothing when no currentTaskId', async () => { + const { stopChatMessageResponding } = await import('@/service/share') + const { result } = renderHook(() => useTextGeneration(createProps())) + + await act(async () => { + await result.current.handleStop() + }) + + expect(stopChatMessageResponding).not.toHaveBeenCalled() + }) + + it('should call stopWorkflowMessage for workflow mode', async () => { + const { stopWorkflowMessage, sendWorkflowMessage } = await import('@/service/share') + const props = createProps({ isWorkflow: true }) + const { result } = renderHook(() => useTextGeneration(props)) + + // Trigger a send to set currentTaskId (mock will set it via callbacks) + // Instead, we test that handleStop guards against empty taskId + await act(async () => { + await result.current.handleStop() + }) + + // No task to stop + expect(stopWorkflowMessage).not.toHaveBeenCalled() + expect(sendWorkflowMessage).toBeDefined() + }) + }) + + // Send - validation + describe('handleSend - validation', () => { + it('should show toast when called while responding', async () => { + const { sendCompletionMessage } = await import('@/service/share') + const { result } = renderHook(() => useTextGeneration(createProps({ controlSend: 1 }))) + + // First send sets isResponding true + // Second send should show warning + await act(async () => { + await result.current.handleSend() + }) + + expect(sendCompletionMessage).toHaveBeenCalled() + }) + + it('should validate required prompt variables', async () => { + const Toast = (await import('@/app/components/base/toast')).default + const props = createProps({ + promptConfig: { + prompt_template: '', + prompt_variables: [ + { key: 'name', name: 'Name', type: 'string', required: true }, + ] as UseTextGenerationProps['promptConfig'] extends infer T ? T extends { prompt_variables: infer V } ? V : never : never, + }, + inputs: {}, // missing required 'name' + }) + const { result } = renderHook(() => useTextGeneration(props)) + + await act(async () => { + await result.current.handleSend() + }) + + expect(Toast.notify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('should pass validation for batch API mode', async () => { + const { sendCompletionMessage } = await import('@/service/share') + const props = createProps({ isCallBatchAPI: true }) + const { result } = renderHook(() => useTextGeneration(props)) + + await act(async () => { + await result.current.handleSend() + }) + + // Batch mode skips validation - should call send + expect(sendCompletionMessage).toHaveBeenCalled() + }) + }) + + // Send - API calls + describe('handleSend - API', () => { + it('should call sendCompletionMessage for non-workflow mode', async () => { + const { sendCompletionMessage } = await import('@/service/share') + const props = createProps({ isWorkflow: false }) + const { result } = renderHook(() => useTextGeneration(props)) + + await act(async () => { + await result.current.handleSend() + }) + + expect(sendCompletionMessage).toHaveBeenCalledWith( + expect.objectContaining({ inputs: {} }), + expect.objectContaining({ + onData: expect.any(Function), + onCompleted: expect.any(Function), + onError: expect.any(Function), + }), + AppSourceType.webApp, + 'app-1', + ) + }) + + it('should call sendWorkflowMessage for workflow mode', async () => { + const { sendWorkflowMessage } = await import('@/service/share') + const props = createProps({ isWorkflow: true }) + const { result } = renderHook(() => useTextGeneration(props)) + + await act(async () => { + await result.current.handleSend() + }) + + expect(sendWorkflowMessage).toHaveBeenCalledWith( + expect.objectContaining({ inputs: {} }), + expect.objectContaining({ + onWorkflowStarted: expect.any(Function), + onNodeStarted: expect.any(Function), + onWorkflowFinished: expect.any(Function), + }), + AppSourceType.webApp, + 'app-1', + ) + }) + + it('should call onShowRes and onRunStart on mobile', async () => { + const onShowRes = vi.fn() + const onRunStart = vi.fn() + const props = createProps({ isPC: false, onShowRes, onRunStart }) + const { result } = renderHook(() => useTextGeneration(props)) + + await act(async () => { + await result.current.handleSend() + }) + + expect(onShowRes).toHaveBeenCalled() + expect(onRunStart).toHaveBeenCalled() + }) + }) + + // Effects + describe('effects', () => { + it('should trigger send when controlSend changes', async () => { + const { sendCompletionMessage } = await import('@/service/share') + const { result, rerender } = renderHook( + (props: UseTextGenerationProps) => useTextGeneration(props), + { initialProps: createProps({ controlSend: 0 }) }, + ) + + // Change controlSend to trigger the effect + await act(async () => { + rerender(createProps({ controlSend: Date.now() })) + }) + + expect(sendCompletionMessage).toHaveBeenCalled() + expect(result.current.controlClearMoreLikeThis).toBeGreaterThan(0) + }) + + it('should trigger send when controlRetry changes', async () => { + const { sendCompletionMessage } = await import('@/service/share') + + await act(async () => { + renderHook(() => useTextGeneration(createProps({ controlRetry: Date.now() }))) + }) + + expect(sendCompletionMessage).toHaveBeenCalled() + }) + + it('should sync run control with parent via onRunControlChange', () => { + const onRunControlChange = vi.fn() + renderHook(() => useTextGeneration(createProps({ onRunControlChange }))) + + // Initially not responding, so should pass null + expect(onRunControlChange).toHaveBeenCalledWith(null) + }) + }) +}) diff --git a/web/app/components/share/text-generation/result/hooks/use-text-generation.ts b/web/app/components/share/text-generation/result/hooks/use-text-generation.ts new file mode 100644 index 0000000000..c3a01f8437 --- /dev/null +++ b/web/app/components/share/text-generation/result/hooks/use-text-generation.ts @@ -0,0 +1,357 @@ +import type { InputValueTypes } from '../../types' +import type { FeedbackType } from '@/app/components/base/chat/chat/type' +import type { WorkflowProcess } from '@/app/components/base/chat/types' +import type { FileEntity } from '@/app/components/base/file-uploader/types' +import type { PromptConfig } from '@/models/debug' +import type { AppSourceType } from '@/service/share' +import type { VisionFile, VisionSettings } from '@/types/app' +import { useBoolean } from 'ahooks' +import { t } from 'i18next' +import { useCallback, useEffect, useRef, useState } from 'react' +import { + getProcessedFiles, +} from '@/app/components/base/file-uploader/utils' +import Toast from '@/app/components/base/toast' +import { TEXT_GENERATION_TIMEOUT_MS } from '@/config' +import { + sendCompletionMessage, + sendWorkflowMessage, + stopChatMessageResponding, + stopWorkflowMessage, + updateFeedback, +} from '@/service/share' +import { TransferMethod } from '@/types/app' +import { sleep } from '@/utils' +import { formatBooleanInputs } from '@/utils/model-config' +import { createWorkflowCallbacks } from './workflow-callbacks' + +export type UseTextGenerationProps = { + isWorkflow: boolean + isCallBatchAPI: boolean + isPC: boolean + appSourceType: AppSourceType + appId?: string + promptConfig: PromptConfig | null + inputs: Record + controlSend?: number + controlRetry?: number + controlStopResponding?: number + onShowRes: () => void + taskId?: number + onCompleted: (completionRes: string, taskId?: number, success?: boolean) => void + visionConfig: VisionSettings + completionFiles: VisionFile[] + onRunStart: () => void + onRunControlChange?: (control: { onStop: () => Promise | void, isStopping: boolean } | null) => void +} + +function hasUploadingFiles(files: VisionFile[]): boolean { + return files.some(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id) +} + +function processFileInputs( + processedInputs: Record, + promptVariables: PromptConfig['prompt_variables'], +) { + promptVariables.forEach((variable) => { + const value = processedInputs[variable.key] + if (variable.type === 'file' && value && typeof value === 'object' && !Array.isArray(value)) + processedInputs[variable.key] = getProcessedFiles([value as FileEntity])[0] + else if (variable.type === 'file-list' && Array.isArray(value) && value.length > 0) + processedInputs[variable.key] = getProcessedFiles(value as FileEntity[]) + }) +} + +function prepareVisionFiles(files: VisionFile[]): VisionFile[] { + return files.map(item => + item.transfer_method === TransferMethod.local_file ? { ...item, url: '' } : item, + ) +} + +export function useTextGeneration(props: UseTextGenerationProps) { + const { + isWorkflow, + isCallBatchAPI, + isPC, + appSourceType, + appId, + promptConfig, + inputs, + controlSend, + controlRetry, + controlStopResponding, + onShowRes, + taskId, + onCompleted, + visionConfig, + completionFiles, + onRunStart, + onRunControlChange, + } = props + + const { notify } = Toast + + const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false) + + const [completionRes, doSetCompletionRes] = useState('') + const completionResRef = useRef('') + const setCompletionRes = (res: string) => { + completionResRef.current = res + doSetCompletionRes(res) + } + const getCompletionRes = () => completionResRef.current + + const [workflowProcessData, doSetWorkflowProcessData] = useState() + const workflowProcessDataRef = useRef(undefined) + const setWorkflowProcessData = (data: WorkflowProcess) => { + workflowProcessDataRef.current = data + doSetWorkflowProcessData(data) + } + const getWorkflowProcessData = () => workflowProcessDataRef.current + + const [currentTaskId, setCurrentTaskId] = useState(null) + const [isStopping, setIsStopping] = useState(false) + const abortControllerRef = useRef(null) + const isEndRef = useRef(false) + const isTimeoutRef = useRef(false) + const tempMessageIdRef = useRef('') + + const resetRunState = useCallback(() => { + setCurrentTaskId(null) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect + setIsStopping(false) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect + abortControllerRef.current = null + onRunControlChange?.(null) + }, [onRunControlChange]) + + const [messageId, setMessageId] = useState(null) + const [feedback, setFeedback] = useState({ rating: null }) + const [controlClearMoreLikeThis, setControlClearMoreLikeThis] = useState(0) + + const handleFeedback = async (fb: FeedbackType) => { + await updateFeedback( + { url: `/messages/${messageId}/feedbacks`, body: { rating: fb.rating, content: fb.content } }, + appSourceType, + appId, + ) + setFeedback(fb) + } + + const handleStop = useCallback(async () => { + if (!currentTaskId || isStopping) + return + setIsStopping(true) + try { + if (isWorkflow) + await stopWorkflowMessage(appId!, currentTaskId, appSourceType, appId || '') + else + await stopChatMessageResponding(appId!, currentTaskId, appSourceType, appId || '') + abortControllerRef.current?.abort() + } + catch (error) { + notify({ type: 'error', message: error instanceof Error ? error.message : String(error) }) + } + finally { + setIsStopping(false) + } + }, [appId, currentTaskId, appSourceType, isStopping, isWorkflow, notify]) + + const checkCanSend = (): boolean => { + if (isCallBatchAPI) + return true + + const promptVariables = promptConfig?.prompt_variables + if (!promptVariables?.length) { + if (hasUploadingFiles(completionFiles)) { + notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) }) + return false + } + return true + } + + let hasEmptyInput = '' + const requiredVars = promptVariables?.filter(({ key, name, required, type }) => { + if (type === 'boolean' || type === 'checkbox') + return false + return (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null) + }) || [] + + requiredVars.forEach(({ key, name }) => { + if (hasEmptyInput) + return + if (!inputs[key]) + hasEmptyInput = name + }) + + if (hasEmptyInput) { + notify({ type: 'error', message: t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }) }) + return false + } + + if (hasUploadingFiles(completionFiles)) { + notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) }) + return false + } + + return !hasEmptyInput + } + + const handleSend = async () => { + if (isResponding) { + notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) }) + return + } + + if (!checkCanSend()) + return + + const definedInputs = Object.fromEntries( + Object.entries(inputs).filter(([, v]) => v !== undefined), + ) as Record + const processedInputs = { ...formatBooleanInputs(promptConfig?.prompt_variables, definedInputs) } + processFileInputs(processedInputs, promptConfig?.prompt_variables ?? []) + + const data: { inputs: Record, files?: VisionFile[] } = { inputs: processedInputs } + if (visionConfig.enabled && completionFiles?.length > 0) + data.files = prepareVisionFiles(completionFiles) + setMessageId(null) + setFeedback({ rating: null }) + setCompletionRes('') + resetRunState() + isEndRef.current = false + isTimeoutRef.current = false + tempMessageIdRef.current = '' + + if (!isPC) { + onShowRes() + onRunStart() + } + + setRespondingTrue() + + ;(async () => { + await sleep(TEXT_GENERATION_TIMEOUT_MS) + if (!isEndRef.current) { + setRespondingFalse() + onCompleted(getCompletionRes(), taskId, false) + resetRunState() + isTimeoutRef.current = true + } + })() + + if (isWorkflow) { + const callbacks = createWorkflowCallbacks({ + getProcessData: getWorkflowProcessData, + setProcessData: setWorkflowProcessData, + setCurrentTaskId, + setIsStopping, + getCompletionRes, + setCompletionRes, + setRespondingFalse, + resetRunState, + setMessageId, + isTimeoutRef, + isEndRef, + tempMessageIdRef, + taskId, + onCompleted, + notify, + t, + requestData: data, + }) + sendWorkflowMessage(data, callbacks, appSourceType, appId).catch((error) => { + setRespondingFalse() + resetRunState() + notify({ type: 'error', message: error instanceof Error ? error.message : String(error) }) + }) + } + else { + let res: string[] = [] + sendCompletionMessage(data, { + onData: (chunk: string, _isFirstMessage: boolean, { messageId: msgId, taskId: tId }) => { + tempMessageIdRef.current = msgId + if (tId && typeof tId === 'string' && tId.trim() !== '') + setCurrentTaskId(prev => prev ?? tId) + res.push(chunk) + setCompletionRes(res.join('')) + }, + onCompleted: () => { + if (isTimeoutRef.current) { + notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) }) + return + } + setRespondingFalse() + resetRunState() + setMessageId(tempMessageIdRef.current) + onCompleted(getCompletionRes(), taskId, true) + isEndRef.current = true + }, + onMessageReplace: (messageReplace) => { + res = [messageReplace.answer] + setCompletionRes(res.join('')) + }, + onError() { + if (isTimeoutRef.current) { + notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) }) + return + } + setRespondingFalse() + resetRunState() + onCompleted(getCompletionRes(), taskId, false) + isEndRef.current = true + }, + getAbortController: (abortController) => { + abortControllerRef.current = abortController + }, + }, appSourceType, appId) + } + } + + useEffect(() => { + const abortCurrentRequest = () => { + abortControllerRef.current?.abort() + } + if (controlStopResponding) { + abortCurrentRequest() + setRespondingFalse() + resetRunState() + } + return abortCurrentRequest + }, [controlStopResponding, resetRunState, setRespondingFalse]) + + useEffect(() => { + if (!onRunControlChange) + return + if (isResponding && currentTaskId) + onRunControlChange({ onStop: handleStop, isStopping }) + else + onRunControlChange(null) + }, [currentTaskId, handleStop, isResponding, isStopping, onRunControlChange]) + + useEffect(() => { + if (controlSend) { + handleSend() + setControlClearMoreLikeThis(Date.now()) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controlSend]) + + useEffect(() => { + if (controlRetry) + handleSend() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controlRetry]) + + return { + isResponding, + completionRes, + workflowProcessData, + messageId, + feedback, + isStopping, + currentTaskId, + controlClearMoreLikeThis, + handleSend, + handleStop, + handleFeedback, + } +} diff --git a/web/app/components/share/text-generation/result/hooks/workflow-callbacks.spec.ts b/web/app/components/share/text-generation/result/hooks/workflow-callbacks.spec.ts new file mode 100644 index 0000000000..a54389a26f --- /dev/null +++ b/web/app/components/share/text-generation/result/hooks/workflow-callbacks.spec.ts @@ -0,0 +1,295 @@ +import type { WorkflowCallbackDeps } from './workflow-callbacks' +import type { WorkflowProcess } from '@/app/components/base/chat/types' +import type { NodeTracing } from '@/types/workflow' +import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types' +import { createWorkflowCallbacks } from './workflow-callbacks' + +vi.mock('@/app/components/base/file-uploader/utils', () => ({ + getFilesInLogs: vi.fn(() => [{ name: 'file.png' }]), +})) + +// Factory for a minimal NodeTracing-like object +const createTrace = (overrides: Partial = {}): NodeTracing => ({ + id: 'trace-1', + index: 0, + predecessor_node_id: '', + node_id: 'node-1', + node_type: 'start', + title: 'Node', + status: NodeRunningStatus.Running, + ...overrides, +} as NodeTracing) + +// Factory for a base WorkflowProcess +const createProcess = (overrides: Partial = {}): WorkflowProcess => ({ + status: WorkflowRunningStatus.Running, + tracing: [], + expand: false, + resultText: '', + ...overrides, +}) + +// Factory for mock dependencies +function createMockDeps(overrides: Partial = {}): WorkflowCallbackDeps { + const process = createProcess() + return { + getProcessData: vi.fn(() => process), + setProcessData: vi.fn(), + setCurrentTaskId: vi.fn(), + setIsStopping: vi.fn(), + getCompletionRes: vi.fn(() => ''), + setCompletionRes: vi.fn(), + setRespondingFalse: vi.fn(), + resetRunState: vi.fn(), + setMessageId: vi.fn(), + isTimeoutRef: { current: false }, + isEndRef: { current: false }, + tempMessageIdRef: { current: '' }, + onCompleted: vi.fn(), + notify: vi.fn(), + t: vi.fn((key: string) => key) as unknown as WorkflowCallbackDeps['t'], + requestData: { inputs: {} }, + ...overrides, + } +} + +describe('createWorkflowCallbacks', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Workflow lifecycle start + describe('onWorkflowStarted', () => { + it('should initialize process data and set task id', () => { + const deps = createMockDeps() + const cb = createWorkflowCallbacks(deps) + + cb.onWorkflowStarted({ workflow_run_id: 'run-1', task_id: 'task-1' } as never) + + expect(deps.tempMessageIdRef.current).toBe('run-1') + expect(deps.setCurrentTaskId).toHaveBeenCalledWith('task-1') + expect(deps.setIsStopping).toHaveBeenCalledWith(false) + expect(deps.setProcessData).toHaveBeenCalledWith( + expect.objectContaining({ status: WorkflowRunningStatus.Running, tracing: [] }), + ) + }) + + it('should default task_id to null when not provided', () => { + const deps = createMockDeps() + const cb = createWorkflowCallbacks(deps) + + cb.onWorkflowStarted({ workflow_run_id: 'run-2' } as never) + + expect(deps.setCurrentTaskId).toHaveBeenCalledWith(null) + }) + }) + + // Shared group handlers (iteration & loop use the same logic) + describe('group handlers (iteration/loop)', () => { + it('onIterationStart should push a running trace', () => { + const deps = createMockDeps() + const cb = createWorkflowCallbacks(deps) + const trace = createTrace({ node_id: 'iter-node' }) + + cb.onIterationStart({ data: trace } as never) + + const produced = (deps.setProcessData as ReturnType).mock.calls[0][0] as WorkflowProcess + expect(produced.expand).toBe(true) + expect(produced.tracing).toHaveLength(1) + expect(produced.tracing[0].node_id).toBe('iter-node') + expect(produced.tracing[0].status).toBe(NodeRunningStatus.Running) + }) + + it('onLoopStart should behave identically to onIterationStart', () => { + const deps = createMockDeps() + const cb = createWorkflowCallbacks(deps) + + cb.onLoopStart({ data: createTrace({ node_id: 'loop-node' }) } as never) + + const produced = (deps.setProcessData as ReturnType).mock.calls[0][0] as WorkflowProcess + expect(produced.tracing[0].node_id).toBe('loop-node') + }) + + it('onIterationFinish should replace trace entry', () => { + const existing = createTrace({ node_id: 'n1', execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'] }) + const deps = createMockDeps({ + getProcessData: vi.fn(() => createProcess({ tracing: [existing] })), + }) + const cb = createWorkflowCallbacks(deps) + const updated = createTrace({ node_id: 'n1', execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'], error: 'fail' } as NodeTracing) + + cb.onIterationFinish({ data: updated } as never) + + const produced = (deps.setProcessData as ReturnType).mock.calls[0][0] as WorkflowProcess + expect(produced.tracing[0].expand).toBe(true) // error -> expand + }) + }) + + // Node lifecycle + describe('onNodeStarted', () => { + it('should add a running trace for top-level nodes', () => { + const deps = createMockDeps() + const cb = createWorkflowCallbacks(deps) + + cb.onNodeStarted({ data: createTrace({ node_id: 'top-node' }) } as never) + + const produced = (deps.setProcessData as ReturnType).mock.calls[0][0] as WorkflowProcess + expect(produced.tracing).toHaveLength(1) + }) + + it('should skip nodes inside an iteration', () => { + const deps = createMockDeps() + const cb = createWorkflowCallbacks(deps) + + cb.onNodeStarted({ data: createTrace({ iteration_id: 'iter-1' }) } as never) + + expect(deps.setProcessData).not.toHaveBeenCalled() + }) + + it('should skip nodes inside a loop', () => { + const deps = createMockDeps() + const cb = createWorkflowCallbacks(deps) + + cb.onNodeStarted({ data: createTrace({ loop_id: 'loop-1' }) } as never) + + expect(deps.setProcessData).not.toHaveBeenCalled() + }) + }) + + describe('onNodeFinished', () => { + it('should update existing trace entry', () => { + const trace = createTrace({ node_id: 'n1', execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'] }) + const deps = createMockDeps({ + getProcessData: vi.fn(() => createProcess({ tracing: [trace] })), + }) + const cb = createWorkflowCallbacks(deps) + const finished = createTrace({ + node_id: 'n1', + execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'], + status: NodeRunningStatus.Succeeded as NodeTracing['status'], + }) + + cb.onNodeFinished({ data: finished } as never) + + const produced = (deps.setProcessData as ReturnType).mock.calls[0][0] as WorkflowProcess + expect(produced.tracing[0].status).toBe(NodeRunningStatus.Succeeded) + }) + + it('should skip nodes inside iteration or loop', () => { + const deps = createMockDeps() + const cb = createWorkflowCallbacks(deps) + + cb.onNodeFinished({ data: createTrace({ iteration_id: 'i1' }) } as never) + cb.onNodeFinished({ data: createTrace({ loop_id: 'l1' }) } as never) + + expect(deps.setProcessData).not.toHaveBeenCalled() + }) + }) + + // Workflow completion + describe('onWorkflowFinished', () => { + it('should handle success with outputs', () => { + const deps = createMockDeps({ taskId: 1 }) + const cb = createWorkflowCallbacks(deps) + + cb.onWorkflowFinished({ + data: { status: 'succeeded', outputs: { result: 'hello' } }, + } as never) + + expect(deps.setCompletionRes).toHaveBeenCalledWith({ result: 'hello' }) + expect(deps.setRespondingFalse).toHaveBeenCalled() + expect(deps.resetRunState).toHaveBeenCalled() + expect(deps.onCompleted).toHaveBeenCalledWith('', 1, true) + expect(deps.isEndRef.current).toBe(true) + }) + + it('should handle success with single string output and set resultText', () => { + const deps = createMockDeps() + const cb = createWorkflowCallbacks(deps) + + cb.onWorkflowFinished({ + data: { status: 'succeeded', outputs: { text: 'response' } }, + } as never) + + // setProcessData called multiple times: succeeded status, then resultText + expect(deps.setProcessData).toHaveBeenCalledTimes(2) + }) + + it('should handle success without outputs', () => { + const deps = createMockDeps() + const cb = createWorkflowCallbacks(deps) + + cb.onWorkflowFinished({ + data: { status: 'succeeded', outputs: null }, + } as never) + + expect(deps.setCompletionRes).toHaveBeenCalledWith('') + }) + + it('should handle stopped status', () => { + const deps = createMockDeps() + const cb = createWorkflowCallbacks(deps) + + cb.onWorkflowFinished({ + data: { status: WorkflowRunningStatus.Stopped }, + } as never) + + expect(deps.onCompleted).toHaveBeenCalledWith('', undefined, false) + expect(deps.isEndRef.current).toBe(true) + }) + + it('should handle error status', () => { + const deps = createMockDeps() + const cb = createWorkflowCallbacks(deps) + + cb.onWorkflowFinished({ + data: { status: 'failed', error: 'Something broke' }, + } as never) + + expect(deps.notify).toHaveBeenCalledWith({ type: 'error', message: 'Something broke' }) + expect(deps.onCompleted).toHaveBeenCalledWith('', undefined, false) + }) + + it('should skip processing when timeout has already occurred', () => { + const deps = createMockDeps() + deps.isTimeoutRef.current = true + const cb = createWorkflowCallbacks(deps) + + cb.onWorkflowFinished({ + data: { status: 'succeeded', outputs: { text: 'late' } }, + } as never) + + expect(deps.notify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'warning' }), + ) + expect(deps.onCompleted).not.toHaveBeenCalled() + }) + }) + + // Streaming text handlers + describe('text handlers', () => { + it('onTextChunk should append text to resultText', () => { + const deps = createMockDeps({ + getProcessData: vi.fn(() => createProcess({ resultText: 'hello' })), + }) + const cb = createWorkflowCallbacks(deps) + + cb.onTextChunk({ data: { text: ' world' } } as never) + + const produced = (deps.setProcessData as ReturnType).mock.calls[0][0] as WorkflowProcess + expect(produced.resultText).toBe('hello world') + }) + + it('onTextReplace should replace resultText entirely', () => { + const deps = createMockDeps({ + getProcessData: vi.fn(() => createProcess({ resultText: 'old' })), + }) + const cb = createWorkflowCallbacks(deps) + + cb.onTextReplace({ data: { text: 'new' } } as never) + + const produced = (deps.setProcessData as ReturnType).mock.calls[0][0] as WorkflowProcess + expect(produced.resultText).toBe('new') + }) + }) +}) diff --git a/web/app/components/share/text-generation/result/hooks/workflow-callbacks.ts b/web/app/components/share/text-generation/result/hooks/workflow-callbacks.ts new file mode 100644 index 0000000000..43fc11d6fc --- /dev/null +++ b/web/app/components/share/text-generation/result/hooks/workflow-callbacks.ts @@ -0,0 +1,237 @@ +import type { TFunction } from 'i18next' +import type { WorkflowProcess } from '@/app/components/base/chat/types' +import type { VisionFile } from '@/types/app' +import type { NodeTracing, WorkflowFinishedResponse } from '@/types/workflow' +import { produce } from 'immer' +import { + getFilesInLogs, +} from '@/app/components/base/file-uploader/utils' +import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types' + +type WorkflowFinishedData = WorkflowFinishedResponse['data'] + +type TraceItem = WorkflowProcess['tracing'][number] + +function findTraceIndex( + tracing: WorkflowProcess['tracing'], + nodeId: string, + parallelId?: string, +): number { + return tracing.findIndex(item => + item.node_id === nodeId + && (item.execution_metadata?.parallel_id === parallelId || item.parallel_id === parallelId), + ) +} + +function findTrace( + tracing: WorkflowProcess['tracing'], + nodeId: string, + parallelId?: string, +): TraceItem | undefined { + return tracing.find(item => + item.node_id === nodeId + && (item.execution_metadata?.parallel_id === parallelId || item.parallel_id === parallelId), + ) +} + +function markNodesStopped(traces?: WorkflowProcess['tracing']) { + if (!traces) + return + const mark = (trace: TraceItem) => { + if ([NodeRunningStatus.Running, NodeRunningStatus.Waiting].includes(trace.status as NodeRunningStatus)) + trace.status = NodeRunningStatus.Stopped + trace.details?.forEach(group => group.forEach(mark)) + trace.retryDetail?.forEach(mark) + trace.parallelDetail?.children?.forEach(mark) + } + traces.forEach(mark) +} + +export type WorkflowCallbackDeps = { + getProcessData: () => WorkflowProcess | undefined + setProcessData: (data: WorkflowProcess) => void + setCurrentTaskId: (id: string | null) => void + setIsStopping: (v: boolean) => void + getCompletionRes: () => string + setCompletionRes: (res: string) => void + setRespondingFalse: () => void + resetRunState: () => void + setMessageId: (id: string | null) => void + isTimeoutRef: { current: boolean } + isEndRef: { current: boolean } + tempMessageIdRef: { current: string } + taskId?: number + onCompleted: (completionRes: string, taskId?: number, success?: boolean) => void + notify: (options: { type: 'error' | 'info' | 'success' | 'warning', message: string }) => void + t: TFunction + // The outer request data object passed to sendWorkflowMessage. + // Used by group next handlers to match traces (mirrors original closure behavior). + requestData: { inputs: Record, files?: VisionFile[], node_id?: string, execution_metadata?: { parallel_id?: string } } +} + +export function createWorkflowCallbacks(deps: WorkflowCallbackDeps) { + const { + getProcessData, + setProcessData, + setCurrentTaskId, + setIsStopping, + getCompletionRes, + setCompletionRes, + setRespondingFalse, + resetRunState, + setMessageId, + isTimeoutRef, + isEndRef, + tempMessageIdRef, + taskId, + onCompleted, + notify, + t, + requestData, + } = deps + + const updateProcessData = (updater: (draft: WorkflowProcess) => void) => { + setProcessData(produce(getProcessData()!, updater)) + } + + const handleGroupStart = ({ data }: { data: NodeTracing }) => { + updateProcessData((draft) => { + draft.expand = true + draft.tracing!.push({ ...data, status: NodeRunningStatus.Running, expand: true }) + }) + } + + const handleGroupNext = () => { + if (!requestData.node_id) + return + updateProcessData((draft) => { + draft.expand = true + const group = findTrace( + draft.tracing, + requestData.node_id!, + requestData.execution_metadata?.parallel_id, + ) + group?.details!.push([]) + }) + } + + const handleGroupFinish = ({ data }: { data: NodeTracing }) => { + updateProcessData((draft) => { + draft.expand = true + const idx = findTraceIndex(draft.tracing, data.node_id, data.execution_metadata?.parallel_id) + draft.tracing[idx] = { ...data, expand: !!data.error } + }) + } + + const handleWorkflowEnd = (status: WorkflowRunningStatus, error?: string) => { + if (error) + notify({ type: 'error', message: error }) + updateProcessData((draft) => { + draft.status = status + markNodesStopped(draft.tracing) + }) + setRespondingFalse() + resetRunState() + onCompleted(getCompletionRes(), taskId, false) + isEndRef.current = true + } + + return { + onWorkflowStarted: ({ workflow_run_id, task_id }: { workflow_run_id: string, task_id?: string }) => { + tempMessageIdRef.current = workflow_run_id + setCurrentTaskId(task_id || null) + setIsStopping(false) + setProcessData({ + status: WorkflowRunningStatus.Running, + tracing: [], + expand: false, + resultText: '', + }) + }, + + onIterationStart: handleGroupStart, + onIterationNext: handleGroupNext, + onIterationFinish: handleGroupFinish, + onLoopStart: handleGroupStart, + onLoopNext: handleGroupNext, + onLoopFinish: handleGroupFinish, + + onNodeStarted: ({ data }: { data: NodeTracing }) => { + if (data.iteration_id || data.loop_id) + return + updateProcessData((draft) => { + draft.expand = true + draft.tracing!.push({ ...data, status: NodeRunningStatus.Running, expand: true }) + }) + }, + + onNodeFinished: ({ data }: { data: NodeTracing }) => { + if (data.iteration_id || data.loop_id) + return + updateProcessData((draft) => { + const idx = findTraceIndex(draft.tracing!, data.node_id, data.execution_metadata?.parallel_id) + if (idx > -1 && draft.tracing) { + draft.tracing[idx] = { + ...(draft.tracing[idx].extras ? { extras: draft.tracing[idx].extras } : {}), + ...data, + expand: !!data.error, + } + } + }) + }, + + onWorkflowFinished: ({ data }: { data: WorkflowFinishedData }) => { + if (isTimeoutRef.current) { + notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) }) + return + } + + if (data.status === WorkflowRunningStatus.Stopped) { + handleWorkflowEnd(WorkflowRunningStatus.Stopped) + return + } + + if (data.error) { + handleWorkflowEnd(WorkflowRunningStatus.Failed, data.error) + return + } + + updateProcessData((draft) => { + draft.status = WorkflowRunningStatus.Succeeded + // eslint-disable-next-line ts/no-explicit-any + draft.files = getFilesInLogs(data.outputs || []) as any[] + }) + + if (data.outputs) { + setCompletionRes(data.outputs) + const keys = Object.keys(data.outputs) + if (keys.length === 1 && typeof data.outputs[keys[0]] === 'string') { + updateProcessData((draft) => { + draft.resultText = data.outputs[keys[0]] + }) + } + } + else { + setCompletionRes('') + } + + setRespondingFalse() + resetRunState() + setMessageId(tempMessageIdRef.current) + onCompleted(getCompletionRes(), taskId, true) + isEndRef.current = true + }, + + onTextChunk: (params: { data: { text: string } }) => { + updateProcessData((draft) => { + draft.resultText += params.data.text + }) + }, + + onTextReplace: (params: { data: { text: string } }) => { + updateProcessData((draft) => { + draft.resultText = params.data.text + }) + }, + } +} diff --git a/web/app/components/share/text-generation/result/index.spec.tsx b/web/app/components/share/text-generation/result/index.spec.tsx new file mode 100644 index 0000000000..ae9967eadf --- /dev/null +++ b/web/app/components/share/text-generation/result/index.spec.tsx @@ -0,0 +1,245 @@ +import type { IResultProps } from './index' +import { render, screen } from '@testing-library/react' +import { AppSourceType } from '@/service/share' +import Result from './index' + +// Mock the custom hook to control state +const mockHandleSend = vi.fn() +const mockHandleStop = vi.fn() +const mockHandleFeedback = vi.fn() + +let hookReturnValue = { + isResponding: false, + completionRes: '', + workflowProcessData: undefined as IResultProps['isWorkflow'] extends true ? object : undefined, + messageId: null as string | null, + feedback: { rating: null as string | null }, + isStopping: false, + currentTaskId: null as string | null, + controlClearMoreLikeThis: 0, + handleSend: mockHandleSend, + handleStop: mockHandleStop, + handleFeedback: mockHandleFeedback, +} + +vi.mock('./hooks/use-text-generation', () => ({ + useTextGeneration: () => hookReturnValue, +})) + +vi.mock('i18next', () => ({ + t: (key: string) => key, +})) + +// Mock complex external component to keep tests focused +vi.mock('@/app/components/app/text-generate/item', () => ({ + default: ({ content, isWorkflow, taskId, isLoading }: { + content: string + isWorkflow: boolean + taskId?: string + isLoading: boolean + }) => ( +
+ ), +})) + +vi.mock('@/app/components/share/text-generation/no-data', () => ({ + default: () =>
, +})) + +// Factory for default props +const createProps = (overrides: Partial = {}): IResultProps => ({ + isWorkflow: false, + isCallBatchAPI: false, + isPC: true, + isMobile: false, + appSourceType: AppSourceType.webApp, + appId: 'app-1', + isError: false, + isShowTextToSpeech: false, + promptConfig: { prompt_template: '', prompt_variables: [] }, + moreLikeThisEnabled: false, + inputs: {}, + onShowRes: vi.fn(), + handleSaveMessage: vi.fn(), + onCompleted: vi.fn(), + visionConfig: { enabled: false } as IResultProps['visionConfig'], + completionFiles: [], + siteInfo: null, + onRunStart: vi.fn(), + ...overrides, +}) + +describe('Result', () => { + beforeEach(() => { + vi.clearAllMocks() + hookReturnValue = { + isResponding: false, + completionRes: '', + workflowProcessData: undefined, + messageId: null, + feedback: { rating: null }, + isStopping: false, + currentTaskId: null, + controlClearMoreLikeThis: 0, + handleSend: mockHandleSend, + handleStop: mockHandleStop, + handleFeedback: mockHandleFeedback, + } + }) + + // Empty state rendering + describe('empty state', () => { + it('should show NoData when not batch and no completion data', () => { + render() + + expect(screen.getByTestId('no-data')).toBeInTheDocument() + expect(screen.queryByTestId('text-generation-res')).not.toBeInTheDocument() + }) + + it('should show NoData when workflow mode has no process data', () => { + render() + + expect(screen.getByTestId('no-data')).toBeInTheDocument() + }) + }) + + // Loading state rendering + describe('loading state', () => { + it('should show loading spinner when responding but no data yet', () => { + hookReturnValue.isResponding = true + hookReturnValue.completionRes = '' + + const { container } = render() + + // Loading area renders a spinner + expect(container.querySelector('.items-center.justify-center')).toBeInTheDocument() + expect(screen.queryByTestId('no-data')).not.toBeInTheDocument() + expect(screen.queryByTestId('text-generation-res')).not.toBeInTheDocument() + }) + + it('should not show loading in batch mode even when responding', () => { + hookReturnValue.isResponding = true + hookReturnValue.completionRes = '' + + render() + + // Batch mode skips loading state and goes to TextGenerationRes + expect(screen.getByTestId('text-generation-res')).toBeInTheDocument() + }) + }) + + // Result rendering + describe('result rendering', () => { + it('should render TextGenerationRes when completion data exists', () => { + hookReturnValue.completionRes = 'Generated output' + + render() + + const res = screen.getByTestId('text-generation-res') + expect(res).toBeInTheDocument() + expect(res.dataset.content).toBe('Generated output') + }) + + it('should render TextGenerationRes for workflow with process data', () => { + hookReturnValue.workflowProcessData = { status: 'running', tracing: [] } as never + + render() + + const res = screen.getByTestId('text-generation-res') + expect(res.dataset.workflow).toBe('true') + }) + + it('should format batch taskId with leading zero for single digit', () => { + hookReturnValue.completionRes = 'batch result' + + render() + + expect(screen.getByTestId('text-generation-res').dataset.taskId).toBe('03') + }) + + it('should format batch taskId without leading zero for double digit', () => { + hookReturnValue.completionRes = 'batch result' + + render() + + expect(screen.getByTestId('text-generation-res').dataset.taskId).toBe('12') + }) + + it('should show loading in TextGenerationRes for batch mode while responding', () => { + hookReturnValue.isResponding = true + hookReturnValue.completionRes = '' + + render() + + expect(screen.getByTestId('text-generation-res').dataset.loading).toBe('true') + }) + }) + + // Stop button + describe('stop button', () => { + it('should show stop button when responding with active task', () => { + hookReturnValue.isResponding = true + hookReturnValue.completionRes = 'data' + hookReturnValue.currentTaskId = 'task-1' + + render() + + expect(screen.getByText('operation.stopResponding')).toBeInTheDocument() + }) + + it('should hide stop button when hideInlineStopButton is true', () => { + hookReturnValue.isResponding = true + hookReturnValue.completionRes = 'data' + hookReturnValue.currentTaskId = 'task-1' + + render() + + expect(screen.queryByText('operation.stopResponding')).not.toBeInTheDocument() + }) + + it('should hide stop button when not responding', () => { + hookReturnValue.completionRes = 'data' + hookReturnValue.currentTaskId = 'task-1' + + render() + + expect(screen.queryByText('operation.stopResponding')).not.toBeInTheDocument() + }) + + it('should show spinner icon when stopping', () => { + hookReturnValue.isResponding = true + hookReturnValue.completionRes = 'data' + hookReturnValue.currentTaskId = 'task-1' + hookReturnValue.isStopping = true + + const { container } = render() + + expect(container.querySelector('.animate-spin')).toBeInTheDocument() + }) + + it('should align stop button to end on PC, center on mobile', () => { + hookReturnValue.isResponding = true + hookReturnValue.completionRes = 'data' + hookReturnValue.currentTaskId = 'task-1' + + const { container, rerender } = render() + expect(container.querySelector('.justify-end')).toBeInTheDocument() + + rerender() + expect(container.querySelector('.justify-center')).toBeInTheDocument() + }) + }) + + // Memo + describe('memoization', () => { + it('should be wrapped with React.memo', () => { + expect((Result as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) + }) + }) +}) diff --git a/web/app/components/share/text-generation/result/index.tsx b/web/app/components/share/text-generation/result/index.tsx index fe518c6d25..91ce5264b1 100644 --- a/web/app/components/share/text-generation/result/index.tsx +++ b/web/app/components/share/text-generation/result/index.tsx @@ -1,34 +1,19 @@ 'use client' import type { FC } from 'react' -import type { FeedbackType } from '@/app/components/base/chat/chat/type' -import type { WorkflowProcess } from '@/app/components/base/chat/types' -import type { FileEntity } from '@/app/components/base/file-uploader/types' +import type { InputValueTypes } from '../types' import type { PromptConfig } from '@/models/debug' import type { SiteInfo } from '@/models/share' import type { AppSourceType } from '@/service/share' import type { VisionFile, VisionSettings } from '@/types/app' import { RiLoader2Line } from '@remixicon/react' -import { useBoolean } from 'ahooks' import { t } from 'i18next' -import { produce } from 'immer' import * as React from 'react' -import { useCallback, useEffect, useRef, useState } from 'react' import TextGenerationRes from '@/app/components/app/text-generate/item' import Button from '@/app/components/base/button' -import { - getFilesInLogs, - getProcessedFiles, -} from '@/app/components/base/file-uploader/utils' import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' import Loading from '@/app/components/base/loading' -import Toast from '@/app/components/base/toast' import NoData from '@/app/components/share/text-generation/no-data' -import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types' -import { TEXT_GENERATION_TIMEOUT_MS } from '@/config' -import { sendCompletionMessage, sendWorkflowMessage, stopChatMessageResponding, stopWorkflowMessage, updateFeedback } from '@/service/share' -import { TransferMethod } from '@/types/app' -import { sleep } from '@/utils' -import { formatBooleanInputs } from '@/utils/model-config' +import { useTextGeneration } from './hooks/use-text-generation' export type IResultProps = { isWorkflow: boolean @@ -41,7 +26,7 @@ export type IResultProps = { isShowTextToSpeech: boolean promptConfig: PromptConfig | null moreLikeThisEnabled: boolean - inputs: Record + inputs: Record controlSend?: number controlRetry?: number controlStopResponding?: number @@ -57,492 +42,61 @@ export type IResultProps = { hideInlineStopButton?: boolean } -const Result: FC = ({ - isWorkflow, - isCallBatchAPI, - isPC, - isMobile, - appSourceType, - appId, - isError, - isShowTextToSpeech, - promptConfig, - moreLikeThisEnabled, - inputs, - controlSend, - controlRetry, - controlStopResponding, - onShowRes, - handleSaveMessage, - taskId, - onCompleted, - visionConfig, - completionFiles, - siteInfo, - onRunStart, - onRunControlChange, - hideInlineStopButton = false, -}) => { - const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false) - const [completionRes, doSetCompletionRes] = useState('') - const completionResRef = useRef('') - const setCompletionRes = (res: string) => { - completionResRef.current = res - doSetCompletionRes(res) - } - const getCompletionRes = () => completionResRef.current - const [workflowProcessData, doSetWorkflowProcessData] = useState() - const workflowProcessDataRef = useRef(undefined) - const setWorkflowProcessData = (data: WorkflowProcess) => { - workflowProcessDataRef.current = data - doSetWorkflowProcessData(data) - } - const getWorkflowProcessData = () => workflowProcessDataRef.current - const [currentTaskId, setCurrentTaskId] = useState(null) - const [isStopping, setIsStopping] = useState(false) - const abortControllerRef = useRef(null) - const resetRunState = useCallback(() => { - setCurrentTaskId(null) - setIsStopping(false) - abortControllerRef.current = null - onRunControlChange?.(null) - }, [onRunControlChange]) +const Result: FC = (props) => { + const { + isWorkflow, + isCallBatchAPI, + isPC, + isMobile, + appSourceType, + appId, + isError, + isShowTextToSpeech, + moreLikeThisEnabled, + handleSaveMessage, + taskId, + siteInfo, + hideInlineStopButton = false, + } = props - useEffect(() => { - const abortCurrentRequest = () => { - abortControllerRef.current?.abort() - } + const { + isResponding, + completionRes, + workflowProcessData, + messageId, + feedback, + isStopping, + currentTaskId, + controlClearMoreLikeThis, + handleSend, + handleStop, + handleFeedback, + } = useTextGeneration(props) - if (controlStopResponding) { - abortCurrentRequest() - setRespondingFalse() - resetRunState() - } + // Determine content state using a unified check + const hasData = isWorkflow ? !!workflowProcessData : !!completionRes + const isLoadingState = !isCallBatchAPI && isResponding && !hasData + const isEmptyState = !isCallBatchAPI && !hasData - return abortCurrentRequest - }, [controlStopResponding, resetRunState, setRespondingFalse]) - - const { notify } = Toast - const isNoData = !completionRes - - const [messageId, setMessageId] = useState(null) - const [feedback, setFeedback] = useState({ - rating: null, - }) - - const handleFeedback = async (feedback: FeedbackType) => { - await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, appSourceType, appId) - setFeedback(feedback) + if (isLoadingState) { + return ( +
+ +
+ ) } - const logError = (message: string) => { - notify({ type: 'error', message }) - } + if (isEmptyState) + return - const handleStop = useCallback(async () => { - if (!currentTaskId || isStopping) - return - setIsStopping(true) - try { - if (isWorkflow) - await stopWorkflowMessage(appId!, currentTaskId, appSourceType, appId || '') - else - await stopChatMessageResponding(appId!, currentTaskId, appSourceType, appId || '') - abortControllerRef.current?.abort() - } - catch (error) { - const message = error instanceof Error ? error.message : String(error) - notify({ type: 'error', message }) - } - finally { - setIsStopping(false) - } - }, [appId, currentTaskId, appSourceType, appId, isStopping, isWorkflow, notify]) - - useEffect(() => { - if (!onRunControlChange) - return - if (isResponding && currentTaskId) { - onRunControlChange({ - onStop: handleStop, - isStopping, - }) - } - else { - onRunControlChange(null) - } - }, [currentTaskId, handleStop, isResponding, isStopping, onRunControlChange]) - - const checkCanSend = () => { - // batch will check outer - if (isCallBatchAPI) - return true - - const prompt_variables = promptConfig?.prompt_variables - if (!prompt_variables || prompt_variables?.length === 0) { - if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) { - notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) }) - return false - } - return true - } - - let hasEmptyInput = '' - const requiredVars = prompt_variables?.filter(({ key, name, required, type }) => { - if (type === 'boolean' || type === 'checkbox') - return false // boolean/checkbox input is not required - const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null) - return res - }) || [] // compatible with old version - requiredVars.forEach(({ key, name }) => { - if (hasEmptyInput) - return - - if (!inputs[key]) - hasEmptyInput = name - }) - - if (hasEmptyInput) { - logError(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput })) - return false - } - - if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) { - notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) }) - return false - } - return !hasEmptyInput - } - - const handleSend = async () => { - if (isResponding) { - notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) }) - return false - } - - if (!checkCanSend()) - return - - // Process inputs: convert file entities to API format - const processedInputs = { ...formatBooleanInputs(promptConfig?.prompt_variables, inputs) } - promptConfig?.prompt_variables.forEach((variable) => { - const value = processedInputs[variable.key] - if (variable.type === 'file' && value && typeof value === 'object' && !Array.isArray(value)) { - // Convert single file entity to API format - processedInputs[variable.key] = getProcessedFiles([value as FileEntity])[0] - } - else if (variable.type === 'file-list' && Array.isArray(value) && value.length > 0) { - // Convert file entity array to API format - processedInputs[variable.key] = getProcessedFiles(value as FileEntity[]) - } - }) - - const data: Record = { - inputs: processedInputs, - } - if (visionConfig.enabled && completionFiles && completionFiles?.length > 0) { - data.files = completionFiles.map((item) => { - if (item.transfer_method === TransferMethod.local_file) { - return { - ...item, - url: '', - } - } - return item - }) - } - - setMessageId(null) - setFeedback({ - rating: null, - }) - setCompletionRes('') - resetRunState() - - let res: string[] = [] - let tempMessageId = '' - - if (!isPC) { - onShowRes() - onRunStart() - } - - setRespondingTrue() - let isEnd = false - let isTimeout = false; - (async () => { - await sleep(TEXT_GENERATION_TIMEOUT_MS) - if (!isEnd) { - setRespondingFalse() - onCompleted(getCompletionRes(), taskId, false) - resetRunState() - isTimeout = true - } - })() - - if (isWorkflow) { - sendWorkflowMessage( - data, - { - onWorkflowStarted: ({ workflow_run_id, task_id }) => { - tempMessageId = workflow_run_id - setCurrentTaskId(task_id || null) - setIsStopping(false) - setWorkflowProcessData({ - status: WorkflowRunningStatus.Running, - tracing: [], - expand: false, - resultText: '', - }) - }, - onIterationStart: ({ data }) => { - setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { - draft.expand = true - draft.tracing!.push({ - ...data, - status: NodeRunningStatus.Running, - expand: true, - }) - })) - }, - onIterationNext: () => { - setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { - draft.expand = true - const iterations = draft.tracing.find(item => item.node_id === data.node_id - && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))! - iterations?.details!.push([]) - })) - }, - onIterationFinish: ({ data }) => { - setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { - draft.expand = true - const iterationsIndex = draft.tracing.findIndex(item => item.node_id === data.node_id - && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))! - draft.tracing[iterationsIndex] = { - ...data, - expand: !!data.error, - } - })) - }, - onLoopStart: ({ data }) => { - setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { - draft.expand = true - draft.tracing!.push({ - ...data, - status: NodeRunningStatus.Running, - expand: true, - }) - })) - }, - onLoopNext: () => { - setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { - draft.expand = true - const loops = draft.tracing.find(item => item.node_id === data.node_id - && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))! - loops?.details!.push([]) - })) - }, - onLoopFinish: ({ data }) => { - setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { - draft.expand = true - const loopsIndex = draft.tracing.findIndex(item => item.node_id === data.node_id - && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))! - draft.tracing[loopsIndex] = { - ...data, - expand: !!data.error, - } - })) - }, - onNodeStarted: ({ data }) => { - if (data.iteration_id) - return - - if (data.loop_id) - return - - setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { - draft.expand = true - draft.tracing!.push({ - ...data, - status: NodeRunningStatus.Running, - expand: true, - }) - })) - }, - onNodeFinished: ({ data }) => { - if (data.iteration_id) - return - - if (data.loop_id) - return - - setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { - const currentIndex = draft.tracing!.findIndex(trace => trace.node_id === data.node_id - && (trace.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || trace.parallel_id === data.execution_metadata?.parallel_id)) - if (currentIndex > -1 && draft.tracing) { - draft.tracing[currentIndex] = { - ...(draft.tracing[currentIndex].extras - ? { extras: draft.tracing[currentIndex].extras } - : {}), - ...data, - expand: !!data.error, - } - } - })) - }, - onWorkflowFinished: ({ data }) => { - if (isTimeout) { - notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) }) - return - } - const workflowStatus = data.status as WorkflowRunningStatus | undefined - const markNodesStopped = (traces?: WorkflowProcess['tracing']) => { - if (!traces) - return - const markTrace = (trace: WorkflowProcess['tracing'][number]) => { - if ([NodeRunningStatus.Running, NodeRunningStatus.Waiting].includes(trace.status as NodeRunningStatus)) - trace.status = NodeRunningStatus.Stopped - trace.details?.forEach(detailGroup => detailGroup.forEach(markTrace)) - trace.retryDetail?.forEach(markTrace) - trace.parallelDetail?.children?.forEach(markTrace) - } - traces.forEach(markTrace) - } - if (workflowStatus === WorkflowRunningStatus.Stopped) { - setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { - draft.status = WorkflowRunningStatus.Stopped - markNodesStopped(draft.tracing) - })) - setRespondingFalse() - resetRunState() - onCompleted(getCompletionRes(), taskId, false) - isEnd = true - return - } - if (data.error) { - notify({ type: 'error', message: data.error }) - setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { - draft.status = WorkflowRunningStatus.Failed - markNodesStopped(draft.tracing) - })) - setRespondingFalse() - resetRunState() - onCompleted(getCompletionRes(), taskId, false) - isEnd = true - return - } - setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { - draft.status = WorkflowRunningStatus.Succeeded - draft.files = getFilesInLogs(data.outputs || []) as any[] - })) - if (!data.outputs) { - setCompletionRes('') - } - else { - setCompletionRes(data.outputs) - const isStringOutput = Object.keys(data.outputs).length === 1 && typeof data.outputs[Object.keys(data.outputs)[0]] === 'string' - if (isStringOutput) { - setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { - draft.resultText = data.outputs[Object.keys(data.outputs)[0]] - })) - } - } - setRespondingFalse() - resetRunState() - setMessageId(tempMessageId) - onCompleted(getCompletionRes(), taskId, true) - isEnd = true - }, - onTextChunk: (params) => { - const { data: { text } } = params - setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { - draft.resultText += text - })) - }, - onTextReplace: (params) => { - const { data: { text } } = params - setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { - draft.resultText = text - })) - }, - }, - appSourceType, - appId, - ).catch((error) => { - setRespondingFalse() - resetRunState() - const message = error instanceof Error ? error.message : String(error) - notify({ type: 'error', message }) - }) - } - else { - sendCompletionMessage(data, { - onData: (data: string, _isFirstMessage: boolean, { messageId, taskId }) => { - tempMessageId = messageId - if (taskId && typeof taskId === 'string' && taskId.trim() !== '') - setCurrentTaskId(prev => prev ?? taskId) - res.push(data) - setCompletionRes(res.join('')) - }, - onCompleted: () => { - if (isTimeout) { - notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) }) - return - } - setRespondingFalse() - resetRunState() - setMessageId(tempMessageId) - onCompleted(getCompletionRes(), taskId, true) - isEnd = true - }, - onMessageReplace: (messageReplace) => { - res = [messageReplace.answer] - setCompletionRes(res.join('')) - }, - onError() { - if (isTimeout) { - notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) }) - return - } - setRespondingFalse() - resetRunState() - onCompleted(getCompletionRes(), taskId, false) - isEnd = true - }, - getAbortController: (abortController) => { - abortControllerRef.current = abortController - }, - }, appSourceType, appId) - } - } - - const [controlClearMoreLikeThis, setControlClearMoreLikeThis] = useState(0) - useEffect(() => { - if (controlSend) { - handleSend() - setControlClearMoreLikeThis(Date.now()) - } - }, [controlSend]) - - useEffect(() => { - if (controlRetry) - handleSend() - }, [controlRetry]) - - const renderTextGenerationRes = () => ( + return ( <> {!hideInlineStopButton && isResponding && currentTaskId && (
-
@@ -571,37 +125,6 @@ const Result: FC = ({ /> ) - - return ( - <> - {!isCallBatchAPI && !isWorkflow && ( - (isResponding && !completionRes) - ? ( -
- -
- ) - : ( - <> - {(isNoData) - ? - : renderTextGenerationRes()} - - ) - )} - {!isCallBatchAPI && isWorkflow && ( - (isResponding && !workflowProcessData) - ? ( -
- -
- ) - : !workflowProcessData - ? - : renderTextGenerationRes() - )} - {isCallBatchAPI && renderTextGenerationRes()} - - ) } + export default React.memo(Result) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 574173194d..976972d1c1 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -3151,19 +3151,6 @@ "count": 1 } }, - "app/components/share/text-generation/result/header.tsx": { - "tailwindcss/no-unnecessary-whitespace": { - "count": 3 - } - }, - "app/components/share/text-generation/result/index.tsx": { - "react-hooks-extra/no-direct-set-state-in-use-effect": { - "count": 3 - }, - "ts/no-explicit-any": { - "count": 3 - } - }, "app/components/share/text-generation/run-batch/csv-reader/index.spec.tsx": { "ts/no-explicit-any": { "count": 2