mirror of
https://github.com/langgenius/dify.git
synced 2026-02-09 23:20:12 -05:00
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.
This commit is contained in:
@@ -24,7 +24,7 @@ const Header: FC<IResultHeaderProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between ">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="text-2xl font-normal leading-4 text-gray-800">{t('generation.resultTitle', { ns: 'share' })}</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
@@ -50,7 +50,7 @@ const Header: FC<IResultHeaderProps> = ({
|
||||
rating: null,
|
||||
})
|
||||
}}
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-primary-200 bg-primary-100 !text-primary-600 hover:border-primary-300 hover:bg-primary-200"
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-primary-200 bg-primary-100 !text-primary-600 hover:border-primary-300 hover:bg-primary-200"
|
||||
>
|
||||
<HandThumbUpIcon width={16} height={16} />
|
||||
</div>
|
||||
@@ -67,7 +67,7 @@ const Header: FC<IResultHeaderProps> = ({
|
||||
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"
|
||||
>
|
||||
<HandThumbDownIcon width={16} height={16} />
|
||||
</div>
|
||||
@@ -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<Record<string, unknown>>()
|
||||
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> = {}): 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, InputValueTypes>
|
||||
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> | 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<string, string | number | boolean | object>,
|
||||
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<WorkflowProcess>()
|
||||
const workflowProcessDataRef = useRef<WorkflowProcess | undefined>(undefined)
|
||||
const setWorkflowProcessData = (data: WorkflowProcess) => {
|
||||
workflowProcessDataRef.current = data
|
||||
doSetWorkflowProcessData(data)
|
||||
}
|
||||
const getWorkflowProcessData = () => workflowProcessDataRef.current
|
||||
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null)
|
||||
const [isStopping, setIsStopping] = useState(false)
|
||||
const abortControllerRef = useRef<AbortController | null>(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<string | null>(null)
|
||||
const [feedback, setFeedback] = useState<FeedbackType>({ 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<string, string | number | boolean | object>
|
||||
const processedInputs = { ...formatBooleanInputs(promptConfig?.prompt_variables, definedInputs) }
|
||||
processFileInputs(processedInputs, promptConfig?.prompt_variables ?? [])
|
||||
|
||||
const data: { inputs: Record<string, string | number | boolean | object>, 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,
|
||||
}
|
||||
}
|
||||
@@ -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> = {}): 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> = {}): WorkflowProcess => ({
|
||||
status: WorkflowRunningStatus.Running,
|
||||
tracing: [],
|
||||
expand: false,
|
||||
resultText: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Factory for mock dependencies
|
||||
function createMockDeps(overrides: Partial<WorkflowCallbackDeps> = {}): 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
|
||||
expect(produced.resultText).toBe('new')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, string | number | boolean | object>, 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
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
245
web/app/components/share/text-generation/result/index.spec.tsx
Normal file
245
web/app/components/share/text-generation/result/index.spec.tsx
Normal file
@@ -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
|
||||
}) => (
|
||||
<div
|
||||
data-testid="text-generation-res"
|
||||
data-content={content}
|
||||
data-workflow={String(isWorkflow)}
|
||||
data-task-id={taskId ?? ''}
|
||||
data-loading={String(isLoading)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/share/text-generation/no-data', () => ({
|
||||
default: () => <div data-testid="no-data" />,
|
||||
}))
|
||||
|
||||
// Factory for default props
|
||||
const createProps = (overrides: Partial<IResultProps> = {}): 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(<Result {...createProps()} />)
|
||||
|
||||
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(<Result {...createProps({ isWorkflow: true })} />)
|
||||
|
||||
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(<Result {...createProps()} />)
|
||||
|
||||
// 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(<Result {...createProps({ isCallBatchAPI: true })} />)
|
||||
|
||||
// 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(<Result {...createProps()} />)
|
||||
|
||||
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(<Result {...createProps({ isWorkflow: true })} />)
|
||||
|
||||
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(<Result {...createProps({ isCallBatchAPI: true, taskId: 3 })} />)
|
||||
|
||||
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(<Result {...createProps({ isCallBatchAPI: true, taskId: 12 })} />)
|
||||
|
||||
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(<Result {...createProps({ isCallBatchAPI: true })} />)
|
||||
|
||||
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(<Result {...createProps()} />)
|
||||
|
||||
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(<Result {...createProps({ hideInlineStopButton: true })} />)
|
||||
|
||||
expect(screen.queryByText('operation.stopResponding')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide stop button when not responding', () => {
|
||||
hookReturnValue.completionRes = 'data'
|
||||
hookReturnValue.currentTaskId = 'task-1'
|
||||
|
||||
render(<Result {...createProps()} />)
|
||||
|
||||
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(<Result {...createProps()} />)
|
||||
|
||||
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(<Result {...createProps({ isPC: true })} />)
|
||||
expect(container.querySelector('.justify-end')).toBeInTheDocument()
|
||||
|
||||
rerender(<Result {...createProps({ isPC: false })} />)
|
||||
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'))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, any>
|
||||
inputs: Record<string, InputValueTypes>
|
||||
controlSend?: number
|
||||
controlRetry?: number
|
||||
controlStopResponding?: number
|
||||
@@ -57,492 +42,61 @@ export type IResultProps = {
|
||||
hideInlineStopButton?: boolean
|
||||
}
|
||||
|
||||
const Result: FC<IResultProps> = ({
|
||||
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<string>('')
|
||||
const completionResRef = useRef<string>('')
|
||||
const setCompletionRes = (res: string) => {
|
||||
completionResRef.current = res
|
||||
doSetCompletionRes(res)
|
||||
}
|
||||
const getCompletionRes = () => completionResRef.current
|
||||
const [workflowProcessData, doSetWorkflowProcessData] = useState<WorkflowProcess>()
|
||||
const workflowProcessDataRef = useRef<WorkflowProcess | undefined>(undefined)
|
||||
const setWorkflowProcessData = (data: WorkflowProcess) => {
|
||||
workflowProcessDataRef.current = data
|
||||
doSetWorkflowProcessData(data)
|
||||
}
|
||||
const getWorkflowProcessData = () => workflowProcessDataRef.current
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null)
|
||||
const [isStopping, setIsStopping] = useState(false)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const resetRunState = useCallback(() => {
|
||||
setCurrentTaskId(null)
|
||||
setIsStopping(false)
|
||||
abortControllerRef.current = null
|
||||
onRunControlChange?.(null)
|
||||
}, [onRunControlChange])
|
||||
const Result: FC<IResultProps> = (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<string | null>(null)
|
||||
const [feedback, setFeedback] = useState<FeedbackType>({
|
||||
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 (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loading type="area" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const logError = (message: string) => {
|
||||
notify({ type: 'error', message })
|
||||
}
|
||||
if (isEmptyState)
|
||||
return <NoData />
|
||||
|
||||
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<string, any> = {
|
||||
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 && (
|
||||
<div className={`mb-3 flex ${isPC ? 'justify-end' : 'justify-center'}`}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isStopping}
|
||||
onClick={handleStop}
|
||||
>
|
||||
{
|
||||
isStopping
|
||||
? <RiLoader2Line className="mr-[5px] h-3.5 w-3.5 animate-spin" />
|
||||
: <StopCircle className="mr-[5px] h-3.5 w-3.5" />
|
||||
}
|
||||
<Button variant="secondary" disabled={isStopping} onClick={handleStop}>
|
||||
{isStopping
|
||||
? <RiLoader2Line className="mr-[5px] h-3.5 w-3.5 animate-spin" />
|
||||
: <StopCircle className="mr-[5px] h-3.5 w-3.5" />}
|
||||
<span className="text-xs font-normal">{t('operation.stopResponding', { ns: 'appDebug' })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -571,37 +125,6 @@ const Result: FC<IResultProps> = ({
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isCallBatchAPI && !isWorkflow && (
|
||||
(isResponding && !completionRes)
|
||||
? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loading type="area" />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{(isNoData)
|
||||
? <NoData />
|
||||
: renderTextGenerationRes()}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{!isCallBatchAPI && isWorkflow && (
|
||||
(isResponding && !workflowProcessData)
|
||||
? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loading type="area" />
|
||||
</div>
|
||||
)
|
||||
: !workflowProcessData
|
||||
? <NoData />
|
||||
: renderTextGenerationRes()
|
||||
)}
|
||||
{isCallBatchAPI && renderTextGenerationRes()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Result)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user