mirror of
https://github.com/langgenius/dify.git
synced 2026-03-16 20:37:42 +08:00
refactor(web): split text-generation result flow and raise coverage (#33499)
Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@ -275,7 +275,7 @@ describe('useTextGenerationBatch', () => {
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleCompleted({ answer: 'failed' } as unknown as string, 1, false)
|
||||
result.current.handleCompleted('{"answer":"failed"}', 1, false)
|
||||
})
|
||||
|
||||
expect(result.current.allFailedTaskList).toEqual([
|
||||
@ -291,7 +291,7 @@ describe('useTextGenerationBatch', () => {
|
||||
{
|
||||
'Name': 'Alice',
|
||||
'Score': '',
|
||||
'generation.completionResult': JSON.stringify({ answer: 'failed' }),
|
||||
'generation.completionResult': '{"answer":"failed"}',
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
@ -241,10 +241,7 @@ export const useTextGenerationBatch = ({
|
||||
result[variable.name] = String(task.params.inputs[variable.key] ?? '')
|
||||
})
|
||||
|
||||
let completionValue = batchCompletionMap[String(task.id)]
|
||||
if (typeof completionValue === 'object')
|
||||
completionValue = JSON.stringify(completionValue)
|
||||
|
||||
const completionValue = batchCompletionMap[String(task.id)] ?? ''
|
||||
result[t('generation.completionResult', { ns: 'share' })] = completionValue
|
||||
return result
|
||||
})
|
||||
|
||||
@ -0,0 +1,334 @@
|
||||
import type { PromptConfig } from '@/models/debug'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import type { IOtherOptions } from '@/service/base'
|
||||
import type { VisionSettings } from '@/types/app'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
import Result from '../index'
|
||||
|
||||
const {
|
||||
notifyMock,
|
||||
sendCompletionMessageMock,
|
||||
sendWorkflowMessageMock,
|
||||
stopChatMessageRespondingMock,
|
||||
textGenerationResPropsSpy,
|
||||
} = vi.hoisted(() => ({
|
||||
notifyMock: vi.fn(),
|
||||
sendCompletionMessageMock: vi.fn(),
|
||||
sendWorkflowMessageMock: vi.fn(),
|
||||
stopChatMessageRespondingMock: vi.fn(),
|
||||
textGenerationResPropsSpy: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('i18next', () => ({
|
||||
t: (key: string) => key,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: notifyMock,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/utils')>('@/utils')
|
||||
return {
|
||||
...actual,
|
||||
sleep: () => new Promise<void>(() => {}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/share', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/service/share')>('@/service/share')
|
||||
return {
|
||||
...actual,
|
||||
sendCompletionMessage: (...args: Parameters<typeof actual.sendCompletionMessage>) => sendCompletionMessageMock(...args),
|
||||
sendWorkflowMessage: (...args: Parameters<typeof actual.sendWorkflowMessage>) => sendWorkflowMessageMock(...args),
|
||||
stopChatMessageResponding: (...args: Parameters<typeof actual.stopChatMessageResponding>) => stopChatMessageRespondingMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/app/text-generate/item', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
textGenerationResPropsSpy(props)
|
||||
return (
|
||||
<div data-testid="text-generation-res">
|
||||
{typeof props.content === 'string' ? props.content : JSON.stringify(props.content ?? null)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/share/text-generation/no-data', () => ({
|
||||
default: () => <div data-testid="no-data">No data</div>,
|
||||
}))
|
||||
|
||||
const promptConfig: PromptConfig = {
|
||||
prompt_template: 'template',
|
||||
prompt_variables: [
|
||||
{ key: 'name', name: 'Name', type: 'string', required: true },
|
||||
],
|
||||
}
|
||||
|
||||
const siteInfo: SiteInfo = {
|
||||
title: 'Share title',
|
||||
description: 'Share description',
|
||||
icon_type: 'emoji',
|
||||
icon: 'robot',
|
||||
}
|
||||
|
||||
const visionConfig: VisionSettings = {
|
||||
enabled: false,
|
||||
number_limits: 2,
|
||||
detail: Resolution.low,
|
||||
transfer_methods: [TransferMethod.local_file],
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
appId: 'app-1',
|
||||
appSourceType: AppSourceType.webApp,
|
||||
completionFiles: [],
|
||||
controlRetry: 0,
|
||||
controlSend: 0,
|
||||
controlStopResponding: 0,
|
||||
handleSaveMessage: vi.fn(),
|
||||
inputs: { name: 'Alice' },
|
||||
isCallBatchAPI: false,
|
||||
isError: false,
|
||||
isMobile: false,
|
||||
isPC: true,
|
||||
isShowTextToSpeech: true,
|
||||
isWorkflow: false,
|
||||
moreLikeThisEnabled: true,
|
||||
onCompleted: vi.fn(),
|
||||
onRunControlChange: vi.fn(),
|
||||
onRunStart: vi.fn(),
|
||||
onShowRes: vi.fn(),
|
||||
promptConfig,
|
||||
siteInfo,
|
||||
visionConfig,
|
||||
}
|
||||
|
||||
describe('Result', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
stopChatMessageRespondingMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('should render no data before the first execution', () => {
|
||||
render(<Result {...baseProps} />)
|
||||
|
||||
expect(screen.getByTestId('no-data')).toBeTruthy()
|
||||
expect(screen.queryByTestId('text-generation-res')).toBeNull()
|
||||
})
|
||||
|
||||
it('should stream completion results and stop the current task', async () => {
|
||||
let completionHandlers: {
|
||||
onCompleted: () => void
|
||||
onData: (chunk: string, isFirstMessage: boolean, info: { messageId: string, taskId?: string }) => void
|
||||
onError: () => void
|
||||
onMessageReplace: (messageReplace: { answer: string }) => void
|
||||
} | null = null
|
||||
|
||||
sendCompletionMessageMock.mockImplementation(async (_data, handlers) => {
|
||||
completionHandlers = handlers
|
||||
})
|
||||
|
||||
const onCompleted = vi.fn()
|
||||
const onRunControlChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<Result
|
||||
{...baseProps}
|
||||
onCompleted={onCompleted}
|
||||
onRunControlChange={onRunControlChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<Result
|
||||
{...baseProps}
|
||||
controlSend={1}
|
||||
onCompleted={onCompleted}
|
||||
onRunControlChange={onRunControlChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(sendCompletionMessageMock).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeTruthy()
|
||||
|
||||
await act(async () => {
|
||||
completionHandlers?.onData('Hello', false, {
|
||||
messageId: 'message-1',
|
||||
taskId: 'task-1',
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('text-generation-res').textContent).toContain('Hello')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRunControlChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
isStopping: false,
|
||||
}))
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'operation.stopResponding' }))
|
||||
await waitFor(() => {
|
||||
expect(stopChatMessageRespondingMock).toHaveBeenCalledWith('app-1', 'task-1', AppSourceType.webApp, 'app-1')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
completionHandlers?.onCompleted()
|
||||
})
|
||||
|
||||
expect(onCompleted).toHaveBeenCalledWith('Hello', undefined, true)
|
||||
expect(textGenerationResPropsSpy).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
messageId: 'message-1',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render workflow results after workflow completion', async () => {
|
||||
let workflowHandlers: IOtherOptions | null = null
|
||||
sendWorkflowMessageMock.mockImplementation(async (_data, handlers) => {
|
||||
workflowHandlers = handlers
|
||||
})
|
||||
|
||||
const onCompleted = vi.fn()
|
||||
const { rerender } = render(
|
||||
<Result
|
||||
{...baseProps}
|
||||
isWorkflow
|
||||
onCompleted={onCompleted}
|
||||
/>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<Result
|
||||
{...baseProps}
|
||||
isWorkflow
|
||||
controlSend={1}
|
||||
onCompleted={onCompleted}
|
||||
/>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
workflowHandlers?.onWorkflowStarted?.({
|
||||
workflow_run_id: 'run-1',
|
||||
task_id: 'task-1',
|
||||
event: 'workflow_started',
|
||||
data: {
|
||||
id: 'run-1',
|
||||
workflow_id: 'wf-1',
|
||||
created_at: 0,
|
||||
},
|
||||
})
|
||||
workflowHandlers?.onTextChunk?.({
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
event: 'text_chunk',
|
||||
data: {
|
||||
text: 'Hello',
|
||||
},
|
||||
})
|
||||
workflowHandlers?.onWorkflowFinished?.({
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
event: 'workflow_finished',
|
||||
data: {
|
||||
id: 'run-1',
|
||||
workflow_id: 'wf-1',
|
||||
status: 'succeeded',
|
||||
outputs: {
|
||||
answer: 'Hello',
|
||||
},
|
||||
error: '',
|
||||
elapsed_time: 0,
|
||||
total_tokens: 0,
|
||||
total_steps: 0,
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
finished_at: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('text-generation-res').textContent).toContain('{"answer":"Hello"}')
|
||||
expect(textGenerationResPropsSpy).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
workflowProcessData: expect.objectContaining({
|
||||
resultText: 'Hello',
|
||||
status: 'succeeded',
|
||||
}),
|
||||
}))
|
||||
expect(onCompleted).toHaveBeenCalledWith('{"answer":"Hello"}', undefined, true)
|
||||
})
|
||||
|
||||
it('should render batch task ids for both short and long indexes', () => {
|
||||
const { rerender } = render(
|
||||
<Result
|
||||
{...baseProps}
|
||||
isCallBatchAPI
|
||||
taskId={3}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(textGenerationResPropsSpy).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
taskId: '03',
|
||||
}))
|
||||
|
||||
rerender(
|
||||
<Result
|
||||
{...baseProps}
|
||||
isCallBatchAPI
|
||||
taskId={12}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(textGenerationResPropsSpy).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
taskId: '12',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render the mobile stop button layout while a batch run is responding', async () => {
|
||||
let completionHandlers: {
|
||||
onData: (chunk: string, isFirstMessage: boolean, info: { messageId: string, taskId?: string }) => void
|
||||
} | null = null
|
||||
|
||||
sendCompletionMessageMock.mockImplementation(async (_data, handlers) => {
|
||||
completionHandlers = handlers
|
||||
})
|
||||
|
||||
const { rerender } = render(
|
||||
<Result
|
||||
{...baseProps}
|
||||
isCallBatchAPI
|
||||
isMobile
|
||||
isPC={false}
|
||||
taskId={2}
|
||||
/>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<Result
|
||||
{...baseProps}
|
||||
controlSend={1}
|
||||
isCallBatchAPI
|
||||
isMobile
|
||||
isPC={false}
|
||||
taskId={2}
|
||||
/>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
completionHandlers?.onData('Hello', false, {
|
||||
messageId: 'message-batch',
|
||||
taskId: 'task-batch',
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'operation.stopResponding' }).parentElement?.className).toContain('justify-center')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,293 @@
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { PromptConfig } from '@/models/debug'
|
||||
import type { VisionFile, VisionSettings } from '@/types/app'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
import { buildResultRequestData, validateResultRequest } from '../result-request'
|
||||
|
||||
const createTranslator = () => vi.fn((key: string) => key)
|
||||
|
||||
const createFileEntity = (overrides: Partial<FileEntity> = {}): FileEntity => ({
|
||||
id: 'file-1',
|
||||
name: 'example.txt',
|
||||
size: 128,
|
||||
type: 'text/plain',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'document',
|
||||
uploadedId: 'uploaded-1',
|
||||
url: 'https://example.com/file.txt',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createVisionFile = (overrides: Partial<VisionFile> = {}): VisionFile => ({
|
||||
type: 'image',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
upload_file_id: 'upload-1',
|
||||
url: 'https://example.com/image.png',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const promptConfig: PromptConfig = {
|
||||
prompt_template: 'template',
|
||||
prompt_variables: [
|
||||
{ key: 'name', name: 'Name', type: 'string', required: true },
|
||||
{ key: 'enabled', name: 'Enabled', type: 'boolean', required: true },
|
||||
{ key: 'file', name: 'File', type: 'file', required: false },
|
||||
{ key: 'files', name: 'Files', type: 'file-list', required: false },
|
||||
],
|
||||
}
|
||||
|
||||
const visionConfig: VisionSettings = {
|
||||
enabled: true,
|
||||
number_limits: 2,
|
||||
detail: Resolution.low,
|
||||
transfer_methods: [TransferMethod.local_file],
|
||||
}
|
||||
|
||||
describe('result-request', () => {
|
||||
it('should reject missing required non-boolean inputs', () => {
|
||||
const t = createTranslator()
|
||||
|
||||
const result = validateResultRequest({
|
||||
completionFiles: [],
|
||||
inputs: {
|
||||
enabled: false,
|
||||
},
|
||||
isCallBatchAPI: false,
|
||||
promptConfig,
|
||||
t,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
canSend: false,
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: 'errorMessage.valueOfVarRequired',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow required number inputs with a value of zero', () => {
|
||||
const result = validateResultRequest({
|
||||
completionFiles: [],
|
||||
inputs: {
|
||||
count: 0,
|
||||
},
|
||||
isCallBatchAPI: false,
|
||||
promptConfig: {
|
||||
prompt_template: 'template',
|
||||
prompt_variables: [
|
||||
{ key: 'count', name: 'Count', type: 'number', required: true },
|
||||
],
|
||||
},
|
||||
t: createTranslator(),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ canSend: true })
|
||||
})
|
||||
|
||||
it('should reject required text inputs that only contain whitespace', () => {
|
||||
const result = validateResultRequest({
|
||||
completionFiles: [],
|
||||
inputs: {
|
||||
name: ' ',
|
||||
},
|
||||
isCallBatchAPI: false,
|
||||
promptConfig: {
|
||||
prompt_template: 'template',
|
||||
prompt_variables: [
|
||||
{ key: 'name', name: 'Name', type: 'string', required: true },
|
||||
],
|
||||
},
|
||||
t: createTranslator(),
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
canSend: false,
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: 'errorMessage.valueOfVarRequired',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should reject required file lists when no files are selected', () => {
|
||||
const result = validateResultRequest({
|
||||
completionFiles: [],
|
||||
inputs: {
|
||||
files: [],
|
||||
},
|
||||
isCallBatchAPI: false,
|
||||
promptConfig: {
|
||||
prompt_template: 'template',
|
||||
prompt_variables: [
|
||||
{ key: 'files', name: 'Files', type: 'file-list', required: true },
|
||||
],
|
||||
},
|
||||
t: createTranslator(),
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
canSend: false,
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: 'errorMessage.valueOfVarRequired',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow required file inputs when a file is selected', () => {
|
||||
const result = validateResultRequest({
|
||||
completionFiles: [],
|
||||
inputs: {
|
||||
file: createFileEntity(),
|
||||
},
|
||||
isCallBatchAPI: false,
|
||||
promptConfig: {
|
||||
prompt_template: 'template',
|
||||
prompt_variables: [
|
||||
{ key: 'file', name: 'File', type: 'file', required: true },
|
||||
],
|
||||
},
|
||||
t: createTranslator(),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ canSend: true })
|
||||
})
|
||||
|
||||
it('should reject pending local uploads outside batch mode', () => {
|
||||
const t = createTranslator()
|
||||
|
||||
const result = validateResultRequest({
|
||||
completionFiles: [
|
||||
createVisionFile({ upload_file_id: '' }),
|
||||
],
|
||||
inputs: {
|
||||
name: 'Alice',
|
||||
},
|
||||
isCallBatchAPI: false,
|
||||
promptConfig,
|
||||
t,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
canSend: false,
|
||||
notification: {
|
||||
type: 'info',
|
||||
message: 'errorMessage.waitForFileUpload',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle missing prompt metadata with and without pending uploads', () => {
|
||||
const t = createTranslator()
|
||||
|
||||
const blocked = validateResultRequest({
|
||||
completionFiles: [
|
||||
createVisionFile({ upload_file_id: '' }),
|
||||
],
|
||||
inputs: {},
|
||||
isCallBatchAPI: false,
|
||||
promptConfig: null,
|
||||
t,
|
||||
})
|
||||
|
||||
const allowed = validateResultRequest({
|
||||
completionFiles: [],
|
||||
inputs: {},
|
||||
isCallBatchAPI: false,
|
||||
promptConfig: null,
|
||||
t,
|
||||
})
|
||||
|
||||
expect(blocked).toEqual({
|
||||
canSend: false,
|
||||
notification: {
|
||||
type: 'info',
|
||||
message: 'errorMessage.waitForFileUpload',
|
||||
},
|
||||
})
|
||||
expect(allowed).toEqual({ canSend: true })
|
||||
})
|
||||
|
||||
it('should skip validation in batch mode', () => {
|
||||
const result = validateResultRequest({
|
||||
completionFiles: [
|
||||
createVisionFile({ upload_file_id: '' }),
|
||||
],
|
||||
inputs: {},
|
||||
isCallBatchAPI: true,
|
||||
promptConfig,
|
||||
t: createTranslator(),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ canSend: true })
|
||||
})
|
||||
|
||||
it('should build request data for single and list file inputs', () => {
|
||||
const file = createFileEntity()
|
||||
const secondFile = createFileEntity({
|
||||
id: 'file-2',
|
||||
name: 'second.txt',
|
||||
uploadedId: 'uploaded-2',
|
||||
url: 'https://example.com/second.txt',
|
||||
})
|
||||
|
||||
const result = buildResultRequestData({
|
||||
completionFiles: [
|
||||
createVisionFile(),
|
||||
createVisionFile({
|
||||
transfer_method: TransferMethod.remote_url,
|
||||
upload_file_id: '',
|
||||
url: 'https://example.com/remote.png',
|
||||
}),
|
||||
],
|
||||
inputs: {
|
||||
enabled: true,
|
||||
file,
|
||||
files: [file, secondFile],
|
||||
name: 'Alice',
|
||||
},
|
||||
promptConfig,
|
||||
visionConfig,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
files: [
|
||||
expect.objectContaining({
|
||||
transfer_method: TransferMethod.local_file,
|
||||
upload_file_id: 'upload-1',
|
||||
url: '',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
transfer_method: TransferMethod.remote_url,
|
||||
url: 'https://example.com/remote.png',
|
||||
}),
|
||||
],
|
||||
inputs: {
|
||||
enabled: true,
|
||||
file: {
|
||||
type: 'document',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
upload_file_id: 'uploaded-1',
|
||||
url: 'https://example.com/file.txt',
|
||||
},
|
||||
files: [
|
||||
{
|
||||
type: 'document',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
upload_file_id: 'uploaded-1',
|
||||
url: 'https://example.com/file.txt',
|
||||
},
|
||||
{
|
||||
type: 'document',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
upload_file_id: 'uploaded-2',
|
||||
url: 'https://example.com/second.txt',
|
||||
},
|
||||
],
|
||||
name: 'Alice',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,901 @@
|
||||
import type { WorkflowProcess } from '@/app/components/base/chat/types'
|
||||
import type { IOtherOptions } from '@/service/base'
|
||||
import type { HumanInputFormData, HumanInputFormTimeoutData, NodeTracing } from '@/types/workflow'
|
||||
import { act } from '@testing-library/react'
|
||||
import { BlockEnum, NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import {
|
||||
appendParallelNext,
|
||||
appendParallelStart,
|
||||
appendResultText,
|
||||
applyWorkflowFinishedState,
|
||||
applyWorkflowOutputs,
|
||||
applyWorkflowPaused,
|
||||
createWorkflowStreamHandlers,
|
||||
finishParallelTrace,
|
||||
finishWorkflowNode,
|
||||
markNodesStopped,
|
||||
replaceResultText,
|
||||
updateHumanInputFilled,
|
||||
updateHumanInputRequired,
|
||||
updateHumanInputTimeout,
|
||||
upsertWorkflowNode,
|
||||
} from '../workflow-stream-handlers'
|
||||
|
||||
const sseGetMock = vi.fn()
|
||||
|
||||
type TraceOverrides = Omit<Partial<NodeTracing>, 'execution_metadata'> & {
|
||||
execution_metadata?: Partial<NonNullable<NodeTracing['execution_metadata']>>
|
||||
}
|
||||
|
||||
vi.mock('@/service/base', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/service/base')>('@/service/base')
|
||||
return {
|
||||
...actual,
|
||||
sseGet: (...args: Parameters<typeof actual.sseGet>) => sseGetMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
const createTrace = (overrides: TraceOverrides = {}): NodeTracing => {
|
||||
const { execution_metadata, ...restOverrides } = overrides
|
||||
|
||||
return {
|
||||
id: 'trace-1',
|
||||
index: 0,
|
||||
predecessor_node_id: '',
|
||||
node_id: 'node-1',
|
||||
node_type: BlockEnum.LLM,
|
||||
title: 'Node',
|
||||
inputs: {},
|
||||
inputs_truncated: false,
|
||||
process_data: {},
|
||||
process_data_truncated: false,
|
||||
outputs: {},
|
||||
outputs_truncated: false,
|
||||
status: NodeRunningStatus.Running,
|
||||
elapsed_time: 0,
|
||||
metadata: {
|
||||
iterator_length: 0,
|
||||
iterator_index: 0,
|
||||
loop_length: 0,
|
||||
loop_index: 0,
|
||||
},
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
finished_at: 0,
|
||||
details: [[]],
|
||||
execution_metadata: {
|
||||
total_tokens: 0,
|
||||
total_price: 0,
|
||||
currency: 'USD',
|
||||
...execution_metadata,
|
||||
},
|
||||
...restOverrides,
|
||||
}
|
||||
}
|
||||
|
||||
const createWorkflowProcess = (): WorkflowProcess => ({
|
||||
status: WorkflowRunningStatus.Running,
|
||||
tracing: [],
|
||||
expand: false,
|
||||
resultText: '',
|
||||
})
|
||||
|
||||
const createHumanInput = (overrides: Partial<HumanInputFormData> = {}): HumanInputFormData => ({
|
||||
form_id: 'form-1',
|
||||
node_id: 'node-1',
|
||||
node_title: 'Node',
|
||||
form_content: 'content',
|
||||
inputs: [],
|
||||
actions: [],
|
||||
form_token: 'token-1',
|
||||
resolved_default_values: {},
|
||||
display_in_ui: true,
|
||||
expiration_time: 100,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('workflow-stream-handlers helpers', () => {
|
||||
it('should update tracing, result text, and human input state', () => {
|
||||
const parallelTrace = createTrace({
|
||||
node_id: 'parallel-node',
|
||||
execution_metadata: { parallel_id: 'parallel-1' },
|
||||
details: [[]],
|
||||
})
|
||||
|
||||
let workflowProcessData = appendParallelStart(undefined, parallelTrace)
|
||||
workflowProcessData = appendParallelNext(workflowProcessData, parallelTrace)
|
||||
workflowProcessData = finishParallelTrace(workflowProcessData, createTrace({
|
||||
node_id: 'parallel-node',
|
||||
execution_metadata: { parallel_id: 'parallel-1' },
|
||||
error: 'failed',
|
||||
}))
|
||||
workflowProcessData = upsertWorkflowNode(workflowProcessData, createTrace({
|
||||
node_id: 'node-1',
|
||||
execution_metadata: { parallel_id: 'parallel-2' },
|
||||
}))!
|
||||
workflowProcessData = appendResultText(workflowProcessData, 'Hello ')
|
||||
workflowProcessData = replaceResultText(workflowProcessData, 'Hello world')
|
||||
workflowProcessData = updateHumanInputRequired(workflowProcessData, createHumanInput())
|
||||
workflowProcessData = updateHumanInputFilled(workflowProcessData, {
|
||||
action_id: 'action-1',
|
||||
action_text: 'Submit',
|
||||
node_id: 'node-1',
|
||||
node_title: 'Node',
|
||||
rendered_content: 'Done',
|
||||
})
|
||||
workflowProcessData = updateHumanInputTimeout(workflowProcessData, {
|
||||
node_id: 'node-1',
|
||||
node_title: 'Node',
|
||||
expiration_time: 200,
|
||||
} satisfies HumanInputFormTimeoutData)
|
||||
workflowProcessData = applyWorkflowPaused(workflowProcessData)
|
||||
|
||||
expect(workflowProcessData.expand).toBe(false)
|
||||
expect(workflowProcessData.resultText).toBe('Hello world')
|
||||
expect(workflowProcessData.humanInputFilledFormDataList).toEqual([
|
||||
expect.objectContaining({
|
||||
action_text: 'Submit',
|
||||
}),
|
||||
])
|
||||
expect(workflowProcessData.tracing[0]).toEqual(expect.objectContaining({
|
||||
node_id: 'parallel-node',
|
||||
expand: true,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should initialize missing parallel details on start and next events', () => {
|
||||
const parallelTrace = createTrace({
|
||||
node_id: 'parallel-node',
|
||||
execution_metadata: { parallel_id: 'parallel-1' },
|
||||
})
|
||||
|
||||
const startedProcess = appendParallelStart(undefined, parallelTrace)
|
||||
const nextProcess = appendParallelNext(startedProcess, parallelTrace)
|
||||
|
||||
expect(startedProcess.tracing[0]?.details).toEqual([[]])
|
||||
expect(nextProcess.tracing[0]?.details).toEqual([[], []])
|
||||
})
|
||||
|
||||
it('should leave tracing unchanged when a parallel next event has no matching trace', () => {
|
||||
const process = createWorkflowProcess()
|
||||
process.tracing = [
|
||||
createTrace({
|
||||
node_id: 'parallel-node',
|
||||
execution_metadata: { parallel_id: 'parallel-1' },
|
||||
details: [[]],
|
||||
}),
|
||||
]
|
||||
|
||||
const nextProcess = appendParallelNext(process, createTrace({
|
||||
node_id: 'missing-node',
|
||||
execution_metadata: { parallel_id: 'parallel-2' },
|
||||
}))
|
||||
|
||||
expect(nextProcess.tracing).toEqual(process.tracing)
|
||||
expect(nextProcess.expand).toBe(true)
|
||||
})
|
||||
|
||||
it('should mark running nodes as stopped recursively', () => {
|
||||
const workflowProcessData = createWorkflowProcess()
|
||||
workflowProcessData.tracing = [
|
||||
createTrace({
|
||||
status: NodeRunningStatus.Running,
|
||||
details: [[createTrace({ status: NodeRunningStatus.Waiting })]],
|
||||
}),
|
||||
]
|
||||
|
||||
const stoppedWorkflow = applyWorkflowFinishedState(workflowProcessData, WorkflowRunningStatus.Stopped)
|
||||
markNodesStopped(stoppedWorkflow.tracing)
|
||||
|
||||
expect(stoppedWorkflow.status).toBe(WorkflowRunningStatus.Stopped)
|
||||
expect(stoppedWorkflow.tracing[0].status).toBe(NodeRunningStatus.Stopped)
|
||||
expect(stoppedWorkflow.tracing[0].details?.[0][0].status).toBe(NodeRunningStatus.Stopped)
|
||||
})
|
||||
|
||||
it('should cover unmatched and replacement helper branches', () => {
|
||||
const process = createWorkflowProcess()
|
||||
process.tracing = [
|
||||
createTrace({
|
||||
node_id: 'node-1',
|
||||
parallel_id: 'parallel-1',
|
||||
extras: {
|
||||
source: 'extra',
|
||||
},
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
}),
|
||||
]
|
||||
process.humanInputFormDataList = [
|
||||
createHumanInput({ node_id: 'node-1' }),
|
||||
]
|
||||
process.humanInputFilledFormDataList = [
|
||||
{
|
||||
action_id: 'action-0',
|
||||
action_text: 'Existing',
|
||||
node_id: 'node-0',
|
||||
node_title: 'Node 0',
|
||||
rendered_content: 'Existing',
|
||||
},
|
||||
]
|
||||
|
||||
const parallelMatched = appendParallelNext(process, createTrace({
|
||||
node_id: 'node-1',
|
||||
execution_metadata: {
|
||||
parallel_id: 'parallel-1',
|
||||
},
|
||||
}))
|
||||
const notFinished = finishParallelTrace(process, createTrace({
|
||||
node_id: 'missing',
|
||||
execution_metadata: {
|
||||
parallel_id: 'parallel-missing',
|
||||
},
|
||||
}))
|
||||
const ignoredIteration = upsertWorkflowNode(process, createTrace({
|
||||
iteration_id: 'iteration-1',
|
||||
}))
|
||||
const replacedNode = upsertWorkflowNode(process, createTrace({
|
||||
node_id: 'node-1',
|
||||
}))
|
||||
const ignoredFinish = finishWorkflowNode(process, createTrace({
|
||||
loop_id: 'loop-1',
|
||||
}))
|
||||
const unmatchedFinish = finishWorkflowNode(process, createTrace({
|
||||
node_id: 'missing',
|
||||
execution_metadata: {
|
||||
parallel_id: 'missing',
|
||||
},
|
||||
}))
|
||||
const finishedWithExtras = finishWorkflowNode(process, createTrace({
|
||||
node_id: 'node-1',
|
||||
execution_metadata: {
|
||||
parallel_id: 'parallel-1',
|
||||
},
|
||||
error: 'failed',
|
||||
}))
|
||||
const succeededWorkflow = applyWorkflowFinishedState(process, WorkflowRunningStatus.Succeeded)
|
||||
const outputlessWorkflow = applyWorkflowOutputs(undefined, null)
|
||||
const updatedHumanInput = updateHumanInputRequired(process, createHumanInput({
|
||||
node_id: 'node-1',
|
||||
expiration_time: 300,
|
||||
}))
|
||||
const appendedHumanInput = updateHumanInputRequired(process, createHumanInput({
|
||||
node_id: 'node-2',
|
||||
}))
|
||||
const noListFilled = updateHumanInputFilled(undefined, {
|
||||
action_id: 'action-1',
|
||||
action_text: 'Submit',
|
||||
node_id: 'node-1',
|
||||
node_title: 'Node',
|
||||
rendered_content: 'Done',
|
||||
})
|
||||
const appendedFilled = updateHumanInputFilled(process, {
|
||||
action_id: 'action-2',
|
||||
action_text: 'Append',
|
||||
node_id: 'node-2',
|
||||
node_title: 'Node 2',
|
||||
rendered_content: 'More',
|
||||
})
|
||||
const timeoutWithoutList = updateHumanInputTimeout(undefined, {
|
||||
node_id: 'node-1',
|
||||
node_title: 'Node',
|
||||
expiration_time: 200,
|
||||
})
|
||||
const timeoutWithMatch = updateHumanInputTimeout(process, {
|
||||
node_id: 'node-1',
|
||||
node_title: 'Node',
|
||||
expiration_time: 400,
|
||||
})
|
||||
|
||||
markNodesStopped(undefined)
|
||||
|
||||
expect(parallelMatched.tracing[0].details).toHaveLength(2)
|
||||
expect(notFinished).toEqual(expect.objectContaining({
|
||||
expand: true,
|
||||
tracing: process.tracing,
|
||||
}))
|
||||
expect(ignoredIteration).toEqual(process)
|
||||
expect(replacedNode?.tracing[0]).toEqual(expect.objectContaining({
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Running,
|
||||
}))
|
||||
expect(ignoredFinish).toEqual(process)
|
||||
expect(unmatchedFinish).toEqual(process)
|
||||
expect(finishedWithExtras?.tracing[0]).toEqual(expect.objectContaining({
|
||||
extras: {
|
||||
source: 'extra',
|
||||
},
|
||||
error: 'failed',
|
||||
}))
|
||||
expect(succeededWorkflow.status).toBe(WorkflowRunningStatus.Succeeded)
|
||||
expect(outputlessWorkflow.files).toEqual([])
|
||||
expect(updatedHumanInput.humanInputFormDataList?.[0].expiration_time).toBe(300)
|
||||
expect(appendedHumanInput.humanInputFormDataList).toHaveLength(2)
|
||||
expect(noListFilled.humanInputFilledFormDataList).toHaveLength(1)
|
||||
expect(appendedFilled.humanInputFilledFormDataList).toHaveLength(2)
|
||||
expect(timeoutWithoutList).toEqual(expect.objectContaining({
|
||||
status: WorkflowRunningStatus.Running,
|
||||
tracing: [],
|
||||
}))
|
||||
expect(timeoutWithMatch.humanInputFormDataList?.[0].expiration_time).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWorkflowStreamHandlers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const setupHandlers = (overrides: { isTimedOut?: () => boolean } = {}) => {
|
||||
let completionRes = ''
|
||||
let currentTaskId: string | null = null
|
||||
let isStopping = false
|
||||
let messageId: string | null = null
|
||||
let workflowProcessData: WorkflowProcess | undefined
|
||||
|
||||
const setCurrentTaskId = vi.fn((value: string | null | ((prev: string | null) => string | null)) => {
|
||||
currentTaskId = typeof value === 'function' ? value(currentTaskId) : value
|
||||
})
|
||||
const setIsStopping = vi.fn((value: boolean | ((prev: boolean) => boolean)) => {
|
||||
isStopping = typeof value === 'function' ? value(isStopping) : value
|
||||
})
|
||||
const setMessageId = vi.fn((value: string | null | ((prev: string | null) => string | null)) => {
|
||||
messageId = typeof value === 'function' ? value(messageId) : value
|
||||
})
|
||||
const setWorkflowProcessData = vi.fn((value: WorkflowProcess | undefined) => {
|
||||
workflowProcessData = value
|
||||
})
|
||||
const setCompletionRes = vi.fn((value: string) => {
|
||||
completionRes = value
|
||||
})
|
||||
const notify = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
const resetRunState = vi.fn()
|
||||
const setRespondingFalse = vi.fn()
|
||||
const markEnded = vi.fn()
|
||||
|
||||
const handlers = createWorkflowStreamHandlers({
|
||||
getCompletionRes: () => completionRes,
|
||||
getWorkflowProcessData: () => workflowProcessData,
|
||||
isTimedOut: overrides.isTimedOut ?? (() => false),
|
||||
markEnded,
|
||||
notify,
|
||||
onCompleted,
|
||||
resetRunState,
|
||||
setCompletionRes,
|
||||
setCurrentTaskId,
|
||||
setIsStopping,
|
||||
setMessageId,
|
||||
setRespondingFalse,
|
||||
setWorkflowProcessData,
|
||||
t: (key: string) => key,
|
||||
taskId: 3,
|
||||
})
|
||||
|
||||
return {
|
||||
currentTaskId: () => currentTaskId,
|
||||
handlers,
|
||||
isStopping: () => isStopping,
|
||||
messageId: () => messageId,
|
||||
notify,
|
||||
onCompleted,
|
||||
resetRunState,
|
||||
setCompletionRes,
|
||||
setCurrentTaskId,
|
||||
setMessageId,
|
||||
setRespondingFalse,
|
||||
workflowProcessData: () => workflowProcessData,
|
||||
}
|
||||
}
|
||||
|
||||
it('should process workflow success and paused events', () => {
|
||||
const setup = setupHandlers()
|
||||
const handlers = setup.handlers as Required<Pick<IOtherOptions, 'onWorkflowStarted' | 'onTextChunk' | 'onHumanInputRequired' | 'onHumanInputFormFilled' | 'onHumanInputFormTimeout' | 'onWorkflowPaused' | 'onWorkflowFinished' | 'onNodeStarted' | 'onNodeFinished' | 'onIterationStart' | 'onIterationNext' | 'onIterationFinish' | 'onLoopStart' | 'onLoopNext' | 'onLoopFinish'>>
|
||||
|
||||
act(() => {
|
||||
handlers.onWorkflowStarted({
|
||||
workflow_run_id: 'run-1',
|
||||
task_id: 'task-1',
|
||||
event: 'workflow_started',
|
||||
data: { id: 'run-1', workflow_id: 'wf-1', created_at: 0 },
|
||||
})
|
||||
handlers.onNodeStarted({
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
event: 'node_started',
|
||||
data: createTrace({ node_id: 'node-1' }),
|
||||
})
|
||||
handlers.onNodeFinished({
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
event: 'node_finished',
|
||||
data: createTrace({ node_id: 'node-1', error: '' }),
|
||||
})
|
||||
handlers.onIterationStart({
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
event: 'iteration_start',
|
||||
data: createTrace({
|
||||
node_id: 'iter-1',
|
||||
execution_metadata: { parallel_id: 'parallel-1' },
|
||||
details: [[]],
|
||||
}),
|
||||
})
|
||||
handlers.onIterationNext({
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
event: 'iteration_next',
|
||||
data: createTrace({
|
||||
node_id: 'iter-1',
|
||||
execution_metadata: { parallel_id: 'parallel-1' },
|
||||
details: [[]],
|
||||
}),
|
||||
})
|
||||
handlers.onIterationFinish({
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
event: 'iteration_finish',
|
||||
data: createTrace({
|
||||
node_id: 'iter-1',
|
||||
execution_metadata: { parallel_id: 'parallel-1' },
|
||||
}),
|
||||
})
|
||||
handlers.onLoopStart({
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
event: 'loop_start',
|
||||
data: createTrace({
|
||||
node_id: 'loop-1',
|
||||
execution_metadata: { parallel_id: 'parallel-2' },
|
||||
details: [[]],
|
||||
}),
|
||||
})
|
||||
handlers.onLoopNext({
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
event: 'loop_next',
|
||||
data: createTrace({
|
||||
node_id: 'loop-1',
|
||||
execution_metadata: { parallel_id: 'parallel-2' },
|
||||
details: [[]],
|
||||
}),
|
||||
})
|
||||
handlers.onLoopFinish({
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
event: 'loop_finish',
|
||||
data: createTrace({
|
||||
node_id: 'loop-1',
|
||||
execution_metadata: { parallel_id: 'parallel-2' },
|
||||
}),
|
||||
})
|
||||
handlers.onTextChunk({
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
event: 'text_chunk',
|
||||
data: { text: 'Hello' },
|
||||
})
|
||||
handlers.onHumanInputRequired({
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
event: 'human_input_required',
|
||||
data: createHumanInput({ node_id: 'node-1' }),
|
||||
})
|
||||
handlers.onHumanInputFormFilled({
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
event: 'human_input_form_filled',
|
||||
data: {
|
||||
node_id: 'node-1',
|
||||
node_title: 'Node',
|
||||
rendered_content: 'Done',
|
||||
action_id: 'action-1',
|
||||
action_text: 'Submit',
|
||||
},
|
||||
})
|
||||
handlers.onHumanInputFormTimeout({
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
event: 'human_input_form_timeout',
|
||||
data: {
|
||||
node_id: 'node-1',
|
||||
node_title: 'Node',
|
||||
expiration_time: 200,
|
||||
},
|
||||
})
|
||||
handlers.onWorkflowPaused({
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
event: 'workflow_paused',
|
||||
data: {
|
||||
outputs: {},
|
||||
paused_nodes: [],
|
||||
reasons: [],
|
||||
workflow_run_id: 'run-1',
|
||||
},
|
||||
})
|
||||
handlers.onWorkflowFinished({
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
event: 'workflow_finished',
|
||||
data: {
|
||||
id: 'run-1',
|
||||
workflow_id: 'wf-1',
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
outputs: { answer: 'Hello' },
|
||||
error: '',
|
||||
elapsed_time: 0,
|
||||
total_tokens: 0,
|
||||
total_steps: 0,
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
finished_at: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(setup.currentTaskId()).toBe('task-1')
|
||||
expect(setup.isStopping()).toBe(false)
|
||||
expect(setup.workflowProcessData()).toEqual(expect.objectContaining({
|
||||
resultText: 'Hello',
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
}))
|
||||
expect(sseGetMock).toHaveBeenCalledWith('/workflow/run-1/events', {}, expect.any(Object))
|
||||
expect(setup.messageId()).toBe('run-1')
|
||||
expect(setup.onCompleted).toHaveBeenCalledWith('{"answer":"Hello"}', 3, true)
|
||||
expect(setup.setRespondingFalse).toHaveBeenCalled()
|
||||
expect(setup.resetRunState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle timeout and workflow failures', () => {
|
||||
const timeoutSetup = setupHandlers({
|
||||
isTimedOut: () => true,
|
||||
})
|
||||
const timeoutHandlers = timeoutSetup.handlers as Required<Pick<IOtherOptions, 'onWorkflowFinished'>>
|
||||
|
||||
act(() => {
|
||||
timeoutHandlers.onWorkflowFinished({
|
||||
task_id: 'task-1',
|
||||
workflow_run_id: 'run-1',
|
||||
event: 'workflow_finished',
|
||||
data: {
|
||||
id: 'run-1',
|
||||
workflow_id: 'wf-1',
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
outputs: null,
|
||||
error: '',
|
||||
elapsed_time: 0,
|
||||
total_tokens: 0,
|
||||
total_steps: 0,
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
finished_at: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(timeoutSetup.notify).toHaveBeenCalledWith({
|
||||
type: 'warning',
|
||||
message: 'warningMessage.timeoutExceeded',
|
||||
})
|
||||
|
||||
const failureSetup = setupHandlers()
|
||||
const failureHandlers = failureSetup.handlers as Required<Pick<IOtherOptions, 'onWorkflowStarted' | 'onWorkflowFinished'>>
|
||||
|
||||
act(() => {
|
||||
failureHandlers.onWorkflowStarted({
|
||||
workflow_run_id: 'run-2',
|
||||
task_id: 'task-2',
|
||||
event: 'workflow_started',
|
||||
data: { id: 'run-2', workflow_id: 'wf-2', created_at: 0 },
|
||||
})
|
||||
failureHandlers.onWorkflowFinished({
|
||||
task_id: 'task-2',
|
||||
workflow_run_id: 'run-2',
|
||||
event: 'workflow_finished',
|
||||
data: {
|
||||
id: 'run-2',
|
||||
workflow_id: 'wf-2',
|
||||
status: WorkflowRunningStatus.Failed,
|
||||
outputs: null,
|
||||
error: 'failed',
|
||||
elapsed_time: 0,
|
||||
total_tokens: 0,
|
||||
total_steps: 0,
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
finished_at: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(failureSetup.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'failed',
|
||||
})
|
||||
expect(failureSetup.onCompleted).toHaveBeenCalledWith('', 3, false)
|
||||
})
|
||||
|
||||
it('should cover existing workflow starts, stopped runs, and non-string outputs', () => {
|
||||
const setup = setupHandlers()
|
||||
let existingProcess: WorkflowProcess = {
|
||||
status: WorkflowRunningStatus.Paused,
|
||||
tracing: [
|
||||
createTrace({
|
||||
node_id: 'existing-node',
|
||||
status: NodeRunningStatus.Waiting,
|
||||
}),
|
||||
],
|
||||
expand: false,
|
||||
resultText: '',
|
||||
}
|
||||
|
||||
const handlers = createWorkflowStreamHandlers({
|
||||
getCompletionRes: () => '',
|
||||
getWorkflowProcessData: () => existingProcess,
|
||||
isTimedOut: () => false,
|
||||
markEnded: vi.fn(),
|
||||
notify: setup.notify,
|
||||
onCompleted: setup.onCompleted,
|
||||
resetRunState: setup.resetRunState,
|
||||
setCompletionRes: setup.setCompletionRes,
|
||||
setCurrentTaskId: setup.setCurrentTaskId,
|
||||
setIsStopping: vi.fn(),
|
||||
setMessageId: setup.setMessageId,
|
||||
setRespondingFalse: setup.setRespondingFalse,
|
||||
setWorkflowProcessData: (value) => {
|
||||
existingProcess = value!
|
||||
},
|
||||
t: (key: string) => key,
|
||||
taskId: 5,
|
||||
}) as Required<Pick<IOtherOptions, 'onWorkflowStarted' | 'onWorkflowFinished' | 'onTextReplace'>>
|
||||
|
||||
act(() => {
|
||||
handlers.onWorkflowStarted({
|
||||
workflow_run_id: 'run-existing',
|
||||
task_id: '',
|
||||
event: 'workflow_started',
|
||||
data: { id: 'run-existing', workflow_id: 'wf-1', created_at: 0 },
|
||||
})
|
||||
handlers.onTextReplace({
|
||||
task_id: 'task-existing',
|
||||
workflow_run_id: 'run-existing',
|
||||
event: 'text_replace',
|
||||
data: { text: 'Replaced text' },
|
||||
})
|
||||
})
|
||||
|
||||
expect(existingProcess).toEqual(expect.objectContaining({
|
||||
expand: true,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
resultText: 'Replaced text',
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
handlers.onWorkflowFinished({
|
||||
task_id: 'task-existing',
|
||||
workflow_run_id: 'run-existing',
|
||||
event: 'workflow_finished',
|
||||
data: {
|
||||
id: 'run-existing',
|
||||
workflow_id: 'wf-1',
|
||||
status: WorkflowRunningStatus.Stopped,
|
||||
outputs: null,
|
||||
error: '',
|
||||
elapsed_time: 0,
|
||||
total_tokens: 0,
|
||||
total_steps: 0,
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
finished_at: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(existingProcess.status).toBe(WorkflowRunningStatus.Stopped)
|
||||
expect(existingProcess.tracing[0].status).toBe(NodeRunningStatus.Stopped)
|
||||
expect(setup.onCompleted).toHaveBeenCalledWith('', 5, false)
|
||||
|
||||
const noOutputSetup = setupHandlers()
|
||||
const noOutputHandlers = noOutputSetup.handlers as Required<Pick<IOtherOptions, 'onWorkflowStarted' | 'onWorkflowFinished' | 'onTextReplace'>>
|
||||
|
||||
act(() => {
|
||||
noOutputHandlers.onWorkflowStarted({
|
||||
workflow_run_id: 'run-no-output',
|
||||
task_id: '',
|
||||
event: 'workflow_started',
|
||||
data: { id: 'run-no-output', workflow_id: 'wf-2', created_at: 0 },
|
||||
})
|
||||
noOutputHandlers.onTextReplace({
|
||||
task_id: 'task-no-output',
|
||||
workflow_run_id: 'run-no-output',
|
||||
event: 'text_replace',
|
||||
data: { text: 'Draft' },
|
||||
})
|
||||
noOutputHandlers.onWorkflowFinished({
|
||||
task_id: 'task-no-output',
|
||||
workflow_run_id: 'run-no-output',
|
||||
event: 'workflow_finished',
|
||||
data: {
|
||||
id: 'run-no-output',
|
||||
workflow_id: 'wf-2',
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
outputs: null,
|
||||
error: '',
|
||||
elapsed_time: 0,
|
||||
total_tokens: 0,
|
||||
total_steps: 0,
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
finished_at: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(noOutputSetup.setCompletionRes).toHaveBeenCalledWith('')
|
||||
|
||||
const objectOutputSetup = setupHandlers()
|
||||
const objectOutputHandlers = objectOutputSetup.handlers as Required<Pick<IOtherOptions, 'onWorkflowStarted' | 'onWorkflowFinished'>>
|
||||
|
||||
act(() => {
|
||||
objectOutputHandlers.onWorkflowStarted({
|
||||
workflow_run_id: 'run-object',
|
||||
task_id: undefined as unknown as string,
|
||||
event: 'workflow_started',
|
||||
data: { id: 'run-object', workflow_id: 'wf-3', created_at: 0 },
|
||||
})
|
||||
objectOutputHandlers.onWorkflowFinished({
|
||||
task_id: 'task-object',
|
||||
workflow_run_id: 'run-object',
|
||||
event: 'workflow_finished',
|
||||
data: {
|
||||
id: 'run-object',
|
||||
workflow_id: 'wf-3',
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
outputs: {
|
||||
answer: 'Hello',
|
||||
meta: {
|
||||
mode: 'object',
|
||||
},
|
||||
},
|
||||
error: '',
|
||||
elapsed_time: 0,
|
||||
total_tokens: 0,
|
||||
total_steps: 0,
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
finished_at: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(objectOutputSetup.currentTaskId()).toBeNull()
|
||||
expect(objectOutputSetup.setCompletionRes).toHaveBeenCalledWith('{"answer":"Hello","meta":{"mode":"object"}}')
|
||||
expect(objectOutputSetup.workflowProcessData()).toEqual(expect.objectContaining({
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
resultText: '',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should serialize empty, string, and circular workflow outputs', () => {
|
||||
const noOutputSetup = setupHandlers()
|
||||
const noOutputHandlers = noOutputSetup.handlers as Required<Pick<IOtherOptions, 'onWorkflowFinished'>>
|
||||
|
||||
act(() => {
|
||||
noOutputHandlers.onWorkflowFinished({
|
||||
task_id: 'task-empty',
|
||||
workflow_run_id: 'run-empty',
|
||||
event: 'workflow_finished',
|
||||
data: {
|
||||
id: 'run-empty',
|
||||
workflow_id: 'wf-empty',
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
outputs: null,
|
||||
error: '',
|
||||
elapsed_time: 0,
|
||||
total_tokens: 0,
|
||||
total_steps: 0,
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
finished_at: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(noOutputSetup.setCompletionRes).toHaveBeenCalledWith('')
|
||||
|
||||
const stringOutputSetup = setupHandlers()
|
||||
const stringOutputHandlers = stringOutputSetup.handlers as Required<Pick<IOtherOptions, 'onWorkflowFinished'>>
|
||||
|
||||
act(() => {
|
||||
stringOutputHandlers.onWorkflowFinished({
|
||||
task_id: 'task-string',
|
||||
workflow_run_id: 'run-string',
|
||||
event: 'workflow_finished',
|
||||
data: {
|
||||
id: 'run-string',
|
||||
workflow_id: 'wf-string',
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
outputs: 'plain text output',
|
||||
error: '',
|
||||
elapsed_time: 0,
|
||||
total_tokens: 0,
|
||||
total_steps: 0,
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
finished_at: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(stringOutputSetup.setCompletionRes).toHaveBeenCalledWith('plain text output')
|
||||
|
||||
const circularOutputSetup = setupHandlers()
|
||||
const circularOutputHandlers = circularOutputSetup.handlers as Required<Pick<IOtherOptions, 'onWorkflowFinished'>>
|
||||
const circularOutputs: Record<string, unknown> = {
|
||||
answer: 'Hello',
|
||||
}
|
||||
circularOutputs.self = circularOutputs
|
||||
|
||||
act(() => {
|
||||
circularOutputHandlers.onWorkflowFinished({
|
||||
task_id: 'task-circular',
|
||||
workflow_run_id: 'run-circular',
|
||||
event: 'workflow_finished',
|
||||
data: {
|
||||
id: 'run-circular',
|
||||
workflow_id: 'wf-circular',
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
outputs: circularOutputs,
|
||||
error: '',
|
||||
elapsed_time: 0,
|
||||
total_tokens: 0,
|
||||
total_steps: 0,
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
finished_at: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(circularOutputSetup.setCompletionRes).toHaveBeenCalledWith('[object Object]')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,200 @@
|
||||
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import { useResultRunState } from '../use-result-run-state'
|
||||
|
||||
const {
|
||||
stopChatMessageRespondingMock,
|
||||
stopWorkflowMessageMock,
|
||||
updateFeedbackMock,
|
||||
} = vi.hoisted(() => ({
|
||||
stopChatMessageRespondingMock: vi.fn(),
|
||||
stopWorkflowMessageMock: vi.fn(),
|
||||
updateFeedbackMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/share', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/service/share')>('@/service/share')
|
||||
return {
|
||||
...actual,
|
||||
stopChatMessageResponding: (...args: Parameters<typeof actual.stopChatMessageResponding>) => stopChatMessageRespondingMock(...args),
|
||||
stopWorkflowMessage: (...args: Parameters<typeof actual.stopWorkflowMessage>) => stopWorkflowMessageMock(...args),
|
||||
updateFeedback: (...args: Parameters<typeof actual.updateFeedback>) => updateFeedbackMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
describe('useResultRunState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
stopChatMessageRespondingMock.mockResolvedValue(undefined)
|
||||
stopWorkflowMessageMock.mockResolvedValue(undefined)
|
||||
updateFeedbackMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('should expose run control and stop completion requests', async () => {
|
||||
const notify = vi.fn()
|
||||
const onRunControlChange = vi.fn()
|
||||
const { result } = renderHook(() => useResultRunState({
|
||||
appId: 'app-1',
|
||||
appSourceType: AppSourceType.webApp,
|
||||
controlStopResponding: 0,
|
||||
isWorkflow: false,
|
||||
notify,
|
||||
onRunControlChange,
|
||||
}))
|
||||
|
||||
const abort = vi.fn()
|
||||
|
||||
act(() => {
|
||||
result.current.abortControllerRef.current = { abort } as unknown as AbortController
|
||||
result.current.setCurrentTaskId('task-1')
|
||||
result.current.setRespondingTrue()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRunControlChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
isStopping: false,
|
||||
}))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleStop()
|
||||
})
|
||||
|
||||
expect(stopChatMessageRespondingMock).toHaveBeenCalledWith('app-1', 'task-1', AppSourceType.webApp, 'app-1')
|
||||
expect(abort).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should update feedback and react to external stop control', async () => {
|
||||
const notify = vi.fn()
|
||||
const onRunControlChange = vi.fn()
|
||||
const { result, rerender } = renderHook(({ controlStopResponding }) => useResultRunState({
|
||||
appId: 'app-2',
|
||||
appSourceType: AppSourceType.installedApp,
|
||||
controlStopResponding,
|
||||
isWorkflow: true,
|
||||
notify,
|
||||
onRunControlChange,
|
||||
}), {
|
||||
initialProps: { controlStopResponding: 0 },
|
||||
})
|
||||
|
||||
const abort = vi.fn()
|
||||
act(() => {
|
||||
result.current.abortControllerRef.current = { abort } as unknown as AbortController
|
||||
result.current.setMessageId('message-1')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFeedback({
|
||||
rating: 'like',
|
||||
} satisfies FeedbackType)
|
||||
})
|
||||
|
||||
expect(updateFeedbackMock).toHaveBeenCalledWith({
|
||||
url: '/messages/message-1/feedbacks',
|
||||
body: {
|
||||
rating: 'like',
|
||||
content: undefined,
|
||||
},
|
||||
}, AppSourceType.installedApp, 'app-2')
|
||||
expect(result.current.feedback).toEqual({
|
||||
rating: 'like',
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentTaskId('task-2')
|
||||
result.current.setRespondingTrue()
|
||||
})
|
||||
|
||||
rerender({ controlStopResponding: 1 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(abort).toHaveBeenCalled()
|
||||
expect(result.current.currentTaskId).toBeNull()
|
||||
expect(onRunControlChange).toHaveBeenLastCalledWith(null)
|
||||
})
|
||||
})
|
||||
|
||||
it('should stop workflow requests through the workflow stop API', async () => {
|
||||
const notify = vi.fn()
|
||||
const { result } = renderHook(() => useResultRunState({
|
||||
appId: 'app-3',
|
||||
appSourceType: AppSourceType.installedApp,
|
||||
controlStopResponding: 0,
|
||||
isWorkflow: true,
|
||||
notify,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentTaskId('task-3')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleStop()
|
||||
})
|
||||
|
||||
expect(stopWorkflowMessageMock).toHaveBeenCalledWith('app-3', 'task-3', AppSourceType.installedApp, 'app-3')
|
||||
})
|
||||
|
||||
it('should ignore invalid stops and report non-Error failures', async () => {
|
||||
const notify = vi.fn()
|
||||
stopChatMessageRespondingMock.mockRejectedValueOnce('stop failed')
|
||||
|
||||
const { result } = renderHook(() => useResultRunState({
|
||||
appSourceType: AppSourceType.webApp,
|
||||
controlStopResponding: 0,
|
||||
isWorkflow: false,
|
||||
notify,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleStop()
|
||||
})
|
||||
|
||||
expect(stopChatMessageRespondingMock).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentTaskId('task-4')
|
||||
result.current.setIsStopping(prev => !prev)
|
||||
result.current.setIsStopping(prev => !prev)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleStop()
|
||||
})
|
||||
|
||||
expect(stopChatMessageRespondingMock).toHaveBeenCalledWith(undefined, 'task-4', AppSourceType.webApp, '')
|
||||
expect(notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'stop failed',
|
||||
})
|
||||
expect(result.current.isStopping).toBe(false)
|
||||
})
|
||||
|
||||
it('should report Error instances from workflow stop failures without an app id fallback', async () => {
|
||||
const notify = vi.fn()
|
||||
stopWorkflowMessageMock.mockRejectedValueOnce(new Error('workflow stop failed'))
|
||||
|
||||
const { result } = renderHook(() => useResultRunState({
|
||||
appSourceType: AppSourceType.installedApp,
|
||||
controlStopResponding: 0,
|
||||
isWorkflow: true,
|
||||
notify,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentTaskId('task-5')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleStop()
|
||||
})
|
||||
|
||||
expect(stopWorkflowMessageMock).toHaveBeenCalledWith(undefined, 'task-5', AppSourceType.installedApp, '')
|
||||
expect(notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow stop failed',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,510 @@
|
||||
import type { ResultInputValue } from '../../result-request'
|
||||
import type { ResultRunStateController } from '../use-result-run-state'
|
||||
import type { PromptConfig } from '@/models/debug'
|
||||
import type { AppSourceType } from '@/service/share'
|
||||
import type { VisionSettings } from '@/types/app'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { AppSourceType as AppSourceTypeEnum } from '@/service/share'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
import { useResultSender } from '../use-result-sender'
|
||||
|
||||
const {
|
||||
buildResultRequestDataMock,
|
||||
createWorkflowStreamHandlersMock,
|
||||
sendCompletionMessageMock,
|
||||
sendWorkflowMessageMock,
|
||||
sleepMock,
|
||||
validateResultRequestMock,
|
||||
} = vi.hoisted(() => ({
|
||||
buildResultRequestDataMock: vi.fn(),
|
||||
createWorkflowStreamHandlersMock: vi.fn(),
|
||||
sendCompletionMessageMock: vi.fn(),
|
||||
sendWorkflowMessageMock: vi.fn(),
|
||||
sleepMock: vi.fn(),
|
||||
validateResultRequestMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/share', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/service/share')>('@/service/share')
|
||||
return {
|
||||
...actual,
|
||||
sendCompletionMessage: (...args: Parameters<typeof actual.sendCompletionMessage>) => sendCompletionMessageMock(...args),
|
||||
sendWorkflowMessage: (...args: Parameters<typeof actual.sendWorkflowMessage>) => sendWorkflowMessageMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/utils')>('@/utils')
|
||||
return {
|
||||
...actual,
|
||||
sleep: (...args: Parameters<typeof actual.sleep>) => sleepMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../result-request', () => ({
|
||||
buildResultRequestData: (...args: unknown[]) => buildResultRequestDataMock(...args),
|
||||
validateResultRequest: (...args: unknown[]) => validateResultRequestMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../workflow-stream-handlers', () => ({
|
||||
createWorkflowStreamHandlers: (...args: unknown[]) => createWorkflowStreamHandlersMock(...args),
|
||||
}))
|
||||
|
||||
type RunStateHarness = {
|
||||
state: {
|
||||
completionRes: string
|
||||
currentTaskId: string | null
|
||||
messageId: string | null
|
||||
workflowProcessData: ResultRunStateController['workflowProcessData']
|
||||
}
|
||||
runState: ResultRunStateController
|
||||
}
|
||||
|
||||
type CompletionHandlers = {
|
||||
getAbortController: (abortController: AbortController) => void
|
||||
onCompleted: () => void
|
||||
onData: (chunk: string, isFirstMessage: boolean, info: { messageId: string, taskId?: string }) => void
|
||||
onError: () => void
|
||||
onMessageReplace: (messageReplace: { answer: string }) => void
|
||||
}
|
||||
|
||||
const createRunStateHarness = (): RunStateHarness => {
|
||||
const state: RunStateHarness['state'] = {
|
||||
completionRes: '',
|
||||
currentTaskId: null,
|
||||
messageId: null,
|
||||
workflowProcessData: undefined,
|
||||
}
|
||||
|
||||
const runState: ResultRunStateController = {
|
||||
abortControllerRef: { current: null },
|
||||
clearMoreLikeThis: vi.fn(),
|
||||
completionRes: '',
|
||||
controlClearMoreLikeThis: 0,
|
||||
currentTaskId: null,
|
||||
feedback: { rating: null },
|
||||
getCompletionRes: vi.fn(() => state.completionRes),
|
||||
getWorkflowProcessData: vi.fn(() => state.workflowProcessData),
|
||||
handleFeedback: vi.fn(),
|
||||
handleStop: vi.fn(),
|
||||
isResponding: false,
|
||||
isStopping: false,
|
||||
messageId: null,
|
||||
prepareForNewRun: vi.fn(() => {
|
||||
state.completionRes = ''
|
||||
state.currentTaskId = null
|
||||
state.messageId = null
|
||||
state.workflowProcessData = undefined
|
||||
runState.completionRes = ''
|
||||
runState.currentTaskId = null
|
||||
runState.messageId = null
|
||||
runState.workflowProcessData = undefined
|
||||
}),
|
||||
resetRunState: vi.fn(() => {
|
||||
state.currentTaskId = null
|
||||
runState.currentTaskId = null
|
||||
runState.isStopping = false
|
||||
}),
|
||||
setCompletionRes: vi.fn((value: string) => {
|
||||
state.completionRes = value
|
||||
runState.completionRes = value
|
||||
}),
|
||||
setCurrentTaskId: vi.fn((value) => {
|
||||
state.currentTaskId = typeof value === 'function' ? value(state.currentTaskId) : value
|
||||
runState.currentTaskId = state.currentTaskId
|
||||
}),
|
||||
setIsStopping: vi.fn((value) => {
|
||||
runState.isStopping = typeof value === 'function' ? value(runState.isStopping) : value
|
||||
}),
|
||||
setMessageId: vi.fn((value) => {
|
||||
state.messageId = typeof value === 'function' ? value(state.messageId) : value
|
||||
runState.messageId = state.messageId
|
||||
}),
|
||||
setRespondingFalse: vi.fn(() => {
|
||||
runState.isResponding = false
|
||||
}),
|
||||
setRespondingTrue: vi.fn(() => {
|
||||
runState.isResponding = true
|
||||
}),
|
||||
setWorkflowProcessData: vi.fn((value) => {
|
||||
state.workflowProcessData = value
|
||||
runState.workflowProcessData = value
|
||||
}),
|
||||
workflowProcessData: undefined,
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
runState,
|
||||
}
|
||||
}
|
||||
|
||||
const promptConfig: PromptConfig = {
|
||||
prompt_template: 'template',
|
||||
prompt_variables: [
|
||||
{ key: 'name', name: 'Name', type: 'string', required: true },
|
||||
],
|
||||
}
|
||||
|
||||
const visionConfig: VisionSettings = {
|
||||
enabled: false,
|
||||
number_limits: 2,
|
||||
detail: Resolution.low,
|
||||
transfer_methods: [TransferMethod.local_file],
|
||||
}
|
||||
|
||||
type RenderSenderOptions = {
|
||||
appSourceType?: AppSourceType
|
||||
controlRetry?: number
|
||||
controlSend?: number
|
||||
inputs?: Record<string, ResultInputValue>
|
||||
isPC?: boolean
|
||||
isWorkflow?: boolean
|
||||
runState?: ResultRunStateController
|
||||
taskId?: number
|
||||
}
|
||||
|
||||
const renderSender = ({
|
||||
appSourceType = AppSourceTypeEnum.webApp,
|
||||
controlRetry = 0,
|
||||
controlSend = 0,
|
||||
inputs = { name: 'Alice' },
|
||||
isPC = true,
|
||||
isWorkflow = false,
|
||||
runState,
|
||||
taskId,
|
||||
}: RenderSenderOptions = {}) => {
|
||||
const notify = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
const onRunStart = vi.fn()
|
||||
const onShowRes = vi.fn()
|
||||
|
||||
const hook = renderHook((props: { controlRetry: number, controlSend: number }) => useResultSender({
|
||||
appId: 'app-1',
|
||||
appSourceType,
|
||||
completionFiles: [],
|
||||
controlRetry: props.controlRetry,
|
||||
controlSend: props.controlSend,
|
||||
inputs,
|
||||
isCallBatchAPI: false,
|
||||
isPC,
|
||||
isWorkflow,
|
||||
notify,
|
||||
onCompleted,
|
||||
onRunStart,
|
||||
onShowRes,
|
||||
promptConfig,
|
||||
runState: runState || createRunStateHarness().runState,
|
||||
t: (key: string) => key,
|
||||
taskId,
|
||||
visionConfig,
|
||||
}), {
|
||||
initialProps: {
|
||||
controlRetry,
|
||||
controlSend,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
...hook,
|
||||
notify,
|
||||
onCompleted,
|
||||
onRunStart,
|
||||
onShowRes,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useResultSender', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
validateResultRequestMock.mockReturnValue({ canSend: true })
|
||||
buildResultRequestDataMock.mockReturnValue({ inputs: { name: 'Alice' } })
|
||||
createWorkflowStreamHandlersMock.mockReturnValue({ onWorkflowFinished: vi.fn() })
|
||||
sendCompletionMessageMock.mockResolvedValue(undefined)
|
||||
sendWorkflowMessageMock.mockResolvedValue(undefined)
|
||||
sleepMock.mockImplementation(() => new Promise<void>(() => {}))
|
||||
})
|
||||
|
||||
it('should reject sends while a response is already in progress', async () => {
|
||||
const { runState } = createRunStateHarness()
|
||||
runState.isResponding = true
|
||||
const { result, notify } = renderSender({ runState })
|
||||
|
||||
await act(async () => {
|
||||
expect(await result.current.handleSend()).toBe(false)
|
||||
})
|
||||
|
||||
expect(notify).toHaveBeenCalledWith({
|
||||
type: 'info',
|
||||
message: 'errorMessage.waitForResponse',
|
||||
})
|
||||
expect(validateResultRequestMock).not.toHaveBeenCalled()
|
||||
expect(sendCompletionMessageMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should surface validation failures without building request payloads', async () => {
|
||||
const { runState } = createRunStateHarness()
|
||||
validateResultRequestMock.mockReturnValue({
|
||||
canSend: false,
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: 'invalid',
|
||||
},
|
||||
})
|
||||
|
||||
const { result, notify } = renderSender({ runState })
|
||||
|
||||
await act(async () => {
|
||||
expect(await result.current.handleSend()).toBe(false)
|
||||
})
|
||||
|
||||
expect(notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'invalid',
|
||||
})
|
||||
expect(buildResultRequestDataMock).not.toHaveBeenCalled()
|
||||
expect(sendCompletionMessageMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should send completion requests when controlSend changes and process callbacks', async () => {
|
||||
const harness = createRunStateHarness()
|
||||
let completionHandlers: CompletionHandlers | undefined
|
||||
|
||||
sendCompletionMessageMock.mockImplementation(async (_data, handlers) => {
|
||||
completionHandlers = handlers as CompletionHandlers
|
||||
})
|
||||
|
||||
const { rerender, onCompleted, onRunStart, onShowRes } = renderSender({
|
||||
controlSend: 0,
|
||||
isPC: false,
|
||||
runState: harness.runState,
|
||||
taskId: 7,
|
||||
})
|
||||
|
||||
rerender({
|
||||
controlRetry: 0,
|
||||
controlSend: 1,
|
||||
})
|
||||
|
||||
expect(validateResultRequestMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
inputs: { name: 'Alice' },
|
||||
isCallBatchAPI: false,
|
||||
}))
|
||||
expect(buildResultRequestDataMock).toHaveBeenCalled()
|
||||
expect(harness.runState.prepareForNewRun).toHaveBeenCalledTimes(1)
|
||||
expect(harness.runState.setRespondingTrue).toHaveBeenCalledTimes(1)
|
||||
expect(harness.runState.clearMoreLikeThis).toHaveBeenCalledTimes(1)
|
||||
expect(onShowRes).toHaveBeenCalledTimes(1)
|
||||
expect(onRunStart).toHaveBeenCalledTimes(1)
|
||||
expect(sendCompletionMessageMock).toHaveBeenCalledWith(
|
||||
{ inputs: { name: 'Alice' } },
|
||||
expect.objectContaining({
|
||||
onCompleted: expect.any(Function),
|
||||
onData: expect.any(Function),
|
||||
}),
|
||||
AppSourceTypeEnum.webApp,
|
||||
'app-1',
|
||||
)
|
||||
|
||||
const abortController = {} as AbortController
|
||||
expect(completionHandlers).toBeDefined()
|
||||
completionHandlers!.getAbortController(abortController)
|
||||
expect(harness.runState.abortControllerRef.current).toBe(abortController)
|
||||
|
||||
await act(async () => {
|
||||
completionHandlers!.onData('Hello', false, {
|
||||
messageId: 'message-1',
|
||||
taskId: 'task-1',
|
||||
})
|
||||
})
|
||||
|
||||
expect(harness.runState.setCurrentTaskId).toHaveBeenCalled()
|
||||
expect(harness.runState.currentTaskId).toBe('task-1')
|
||||
|
||||
await act(async () => {
|
||||
completionHandlers!.onMessageReplace({ answer: 'Replaced' })
|
||||
completionHandlers!.onCompleted()
|
||||
})
|
||||
|
||||
expect(harness.runState.setCompletionRes).toHaveBeenLastCalledWith('Replaced')
|
||||
expect(harness.runState.setRespondingFalse).toHaveBeenCalled()
|
||||
expect(harness.runState.resetRunState).toHaveBeenCalled()
|
||||
expect(harness.runState.setMessageId).toHaveBeenCalledWith('message-1')
|
||||
expect(onCompleted).toHaveBeenCalledWith('Replaced', 7, true)
|
||||
})
|
||||
|
||||
it('should trigger workflow sends on retry and report workflow request failures', async () => {
|
||||
const harness = createRunStateHarness()
|
||||
sendWorkflowMessageMock.mockRejectedValue(new Error('workflow failed'))
|
||||
|
||||
const { rerender, notify } = renderSender({
|
||||
controlRetry: 0,
|
||||
isWorkflow: true,
|
||||
runState: harness.runState,
|
||||
})
|
||||
|
||||
rerender({
|
||||
controlRetry: 2,
|
||||
controlSend: 0,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWorkflowStreamHandlersMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
getCompletionRes: harness.runState.getCompletionRes,
|
||||
resetRunState: harness.runState.resetRunState,
|
||||
setWorkflowProcessData: harness.runState.setWorkflowProcessData,
|
||||
}))
|
||||
expect(sendWorkflowMessageMock).toHaveBeenCalledWith(
|
||||
{ inputs: { name: 'Alice' } },
|
||||
expect.any(Object),
|
||||
AppSourceTypeEnum.webApp,
|
||||
'app-1',
|
||||
)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(harness.runState.setRespondingFalse).toHaveBeenCalled()
|
||||
expect(harness.runState.resetRunState).toHaveBeenCalled()
|
||||
expect(notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow failed',
|
||||
})
|
||||
})
|
||||
expect(harness.runState.clearMoreLikeThis).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stringify non-Error workflow failures', async () => {
|
||||
const harness = createRunStateHarness()
|
||||
sendWorkflowMessageMock.mockRejectedValue('workflow failed')
|
||||
|
||||
const { result, notify } = renderSender({
|
||||
isWorkflow: true,
|
||||
runState: harness.runState,
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow failed',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should timeout unfinished completion requests', async () => {
|
||||
const harness = createRunStateHarness()
|
||||
sleepMock.mockResolvedValue(undefined)
|
||||
|
||||
const { result, onCompleted } = renderSender({
|
||||
runState: harness.runState,
|
||||
taskId: 9,
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
expect(await result.current.handleSend()).toBe(true)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(harness.runState.setRespondingFalse).toHaveBeenCalled()
|
||||
expect(harness.runState.resetRunState).toHaveBeenCalled()
|
||||
expect(onCompleted).toHaveBeenCalledWith('', 9, false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore empty task ids and surface timeout warnings from stream callbacks', async () => {
|
||||
const harness = createRunStateHarness()
|
||||
let completionHandlers: CompletionHandlers | undefined
|
||||
|
||||
sleepMock.mockResolvedValue(undefined)
|
||||
sendCompletionMessageMock.mockImplementation(async (_data, handlers) => {
|
||||
completionHandlers = handlers as CompletionHandlers
|
||||
})
|
||||
|
||||
const { result, notify, onCompleted } = renderSender({
|
||||
runState: harness.runState,
|
||||
taskId: 11,
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
completionHandlers!.onData('Hello', false, {
|
||||
messageId: 'message-2',
|
||||
taskId: ' ',
|
||||
})
|
||||
completionHandlers!.onCompleted()
|
||||
completionHandlers!.onError()
|
||||
})
|
||||
|
||||
expect(harness.runState.currentTaskId).toBeNull()
|
||||
expect(notify).toHaveBeenNthCalledWith(1, {
|
||||
type: 'warning',
|
||||
message: 'warningMessage.timeoutExceeded',
|
||||
})
|
||||
expect(notify).toHaveBeenNthCalledWith(2, {
|
||||
type: 'warning',
|
||||
message: 'warningMessage.timeoutExceeded',
|
||||
})
|
||||
expect(onCompleted).toHaveBeenCalledWith('', 11, false)
|
||||
})
|
||||
|
||||
it('should avoid timeout fallback after a completion response has already ended', async () => {
|
||||
const harness = createRunStateHarness()
|
||||
let resolveSleep!: () => void
|
||||
let completionHandlers: CompletionHandlers | undefined
|
||||
|
||||
sleepMock.mockImplementation(() => new Promise<void>((resolve) => {
|
||||
resolveSleep = resolve
|
||||
}))
|
||||
sendCompletionMessageMock.mockImplementation(async (_data, handlers) => {
|
||||
completionHandlers = handlers as CompletionHandlers
|
||||
})
|
||||
|
||||
const { result, onCompleted } = renderSender({
|
||||
runState: harness.runState,
|
||||
taskId: 12,
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
harness.runState.setCompletionRes('Done')
|
||||
completionHandlers!.onCompleted()
|
||||
resolveSleep()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(onCompleted).toHaveBeenCalledWith('Done', 12, true)
|
||||
expect(onCompleted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle non-timeout stream errors as failed completions', async () => {
|
||||
const harness = createRunStateHarness()
|
||||
let completionHandlers: CompletionHandlers | undefined
|
||||
|
||||
sendCompletionMessageMock.mockImplementation(async (_data, handlers) => {
|
||||
completionHandlers = handlers as CompletionHandlers
|
||||
})
|
||||
|
||||
const { result, onCompleted } = renderSender({
|
||||
runState: harness.runState,
|
||||
taskId: 13,
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
completionHandlers!.onError()
|
||||
})
|
||||
|
||||
expect(harness.runState.setRespondingFalse).toHaveBeenCalled()
|
||||
expect(harness.runState.resetRunState).toHaveBeenCalled()
|
||||
expect(onCompleted).toHaveBeenCalledWith('', 13, false)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,237 @@
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
|
||||
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
|
||||
import type { WorkflowProcess } from '@/app/components/base/chat/types'
|
||||
import type { AppSourceType } from '@/service/share'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
|
||||
import {
|
||||
stopChatMessageResponding,
|
||||
stopWorkflowMessage,
|
||||
updateFeedback,
|
||||
} from '@/service/share'
|
||||
|
||||
type Notify = (payload: { type: 'error', message: string }) => void
|
||||
|
||||
type RunControlState = {
|
||||
currentTaskId: string | null
|
||||
isStopping: boolean
|
||||
}
|
||||
|
||||
type RunControlAction
|
||||
= | { type: 'reset' }
|
||||
| { type: 'setCurrentTaskId', value: SetStateAction<string | null> }
|
||||
| { type: 'setIsStopping', value: SetStateAction<boolean> }
|
||||
|
||||
type UseResultRunStateOptions = {
|
||||
appId?: string
|
||||
appSourceType: AppSourceType
|
||||
controlStopResponding?: number
|
||||
isWorkflow: boolean
|
||||
notify: Notify
|
||||
onRunControlChange?: (control: { onStop: () => Promise<void> | void, isStopping: boolean } | null) => void
|
||||
}
|
||||
|
||||
export type ResultRunStateController = {
|
||||
abortControllerRef: MutableRefObject<AbortController | null>
|
||||
clearMoreLikeThis: () => void
|
||||
completionRes: string
|
||||
controlClearMoreLikeThis: number
|
||||
currentTaskId: string | null
|
||||
feedback: FeedbackType
|
||||
getCompletionRes: () => string
|
||||
getWorkflowProcessData: () => WorkflowProcess | undefined
|
||||
handleFeedback: (feedback: FeedbackType) => Promise<void>
|
||||
handleStop: () => Promise<void>
|
||||
isResponding: boolean
|
||||
isStopping: boolean
|
||||
messageId: string | null
|
||||
prepareForNewRun: () => void
|
||||
resetRunState: () => void
|
||||
setCompletionRes: (res: string) => void
|
||||
setCurrentTaskId: Dispatch<SetStateAction<string | null>>
|
||||
setIsStopping: Dispatch<SetStateAction<boolean>>
|
||||
setMessageId: Dispatch<SetStateAction<string | null>>
|
||||
setRespondingFalse: () => void
|
||||
setRespondingTrue: () => void
|
||||
setWorkflowProcessData: (data: WorkflowProcess | undefined) => void
|
||||
workflowProcessData: WorkflowProcess | undefined
|
||||
}
|
||||
|
||||
const runControlReducer = (state: RunControlState, action: RunControlAction): RunControlState => {
|
||||
switch (action.type) {
|
||||
case 'reset':
|
||||
return {
|
||||
currentTaskId: null,
|
||||
isStopping: false,
|
||||
}
|
||||
case 'setCurrentTaskId':
|
||||
return {
|
||||
...state,
|
||||
currentTaskId: typeof action.value === 'function' ? action.value(state.currentTaskId) : action.value,
|
||||
}
|
||||
case 'setIsStopping':
|
||||
return {
|
||||
...state,
|
||||
isStopping: typeof action.value === 'function' ? action.value(state.isStopping) : action.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useResultRunState = ({
|
||||
appId,
|
||||
appSourceType,
|
||||
controlStopResponding,
|
||||
isWorkflow,
|
||||
notify,
|
||||
onRunControlChange,
|
||||
}: UseResultRunStateOptions): ResultRunStateController => {
|
||||
const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false)
|
||||
const [completionResState, setCompletionResState] = useState<string>('')
|
||||
const completionResRef = useRef<string>('')
|
||||
const [workflowProcessDataState, setWorkflowProcessDataState] = useState<WorkflowProcess>()
|
||||
const workflowProcessDataRef = useRef<WorkflowProcess | undefined>(undefined)
|
||||
const [messageId, setMessageId] = useState<string | null>(null)
|
||||
const [feedback, setFeedback] = useState<FeedbackType>({
|
||||
rating: null,
|
||||
})
|
||||
const [controlClearMoreLikeThis, setControlClearMoreLikeThis] = useState(0)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const [{ currentTaskId, isStopping }, dispatchRunControl] = useReducer(runControlReducer, {
|
||||
currentTaskId: null,
|
||||
isStopping: false,
|
||||
})
|
||||
|
||||
const setCurrentTaskId = useCallback<Dispatch<SetStateAction<string | null>>>((value) => {
|
||||
dispatchRunControl({
|
||||
type: 'setCurrentTaskId',
|
||||
value,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setIsStopping = useCallback<Dispatch<SetStateAction<boolean>>>((value) => {
|
||||
dispatchRunControl({
|
||||
type: 'setIsStopping',
|
||||
value,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setCompletionRes = useCallback((res: string) => {
|
||||
completionResRef.current = res
|
||||
setCompletionResState(res)
|
||||
}, [])
|
||||
|
||||
const getCompletionRes = useCallback(() => completionResRef.current, [])
|
||||
|
||||
const setWorkflowProcessData = useCallback((data: WorkflowProcess | undefined) => {
|
||||
workflowProcessDataRef.current = data
|
||||
setWorkflowProcessDataState(data)
|
||||
}, [])
|
||||
|
||||
const getWorkflowProcessData = useCallback(() => workflowProcessDataRef.current, [])
|
||||
|
||||
const resetRunState = useCallback(() => {
|
||||
dispatchRunControl({ type: 'reset' })
|
||||
abortControllerRef.current = null
|
||||
onRunControlChange?.(null)
|
||||
}, [onRunControlChange])
|
||||
|
||||
const prepareForNewRun = useCallback(() => {
|
||||
setMessageId(null)
|
||||
setFeedback({ rating: null })
|
||||
setCompletionRes('')
|
||||
setWorkflowProcessData(undefined)
|
||||
resetRunState()
|
||||
}, [resetRunState, setCompletionRes, setWorkflowProcessData])
|
||||
|
||||
const handleFeedback = useCallback(async (nextFeedback: FeedbackType) => {
|
||||
await updateFeedback({
|
||||
url: `/messages/${messageId}/feedbacks`,
|
||||
body: {
|
||||
rating: nextFeedback.rating,
|
||||
content: nextFeedback.content,
|
||||
},
|
||||
}, appSourceType, appId)
|
||||
setFeedback(nextFeedback)
|
||||
}, [appId, appSourceType, messageId])
|
||||
|
||||
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, appSourceType, currentTaskId, isStopping, isWorkflow, notify, setIsStopping])
|
||||
|
||||
const clearMoreLikeThis = useCallback(() => {
|
||||
setControlClearMoreLikeThis(Date.now())
|
||||
}, [])
|
||||
|
||||
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,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onRunControlChange(null)
|
||||
}, [currentTaskId, handleStop, isResponding, isStopping, onRunControlChange])
|
||||
|
||||
return {
|
||||
abortControllerRef,
|
||||
clearMoreLikeThis,
|
||||
completionRes: completionResState,
|
||||
controlClearMoreLikeThis,
|
||||
currentTaskId,
|
||||
feedback,
|
||||
getCompletionRes,
|
||||
getWorkflowProcessData,
|
||||
handleFeedback,
|
||||
handleStop,
|
||||
isResponding,
|
||||
isStopping,
|
||||
messageId,
|
||||
prepareForNewRun,
|
||||
resetRunState,
|
||||
setCompletionRes,
|
||||
setCurrentTaskId,
|
||||
setIsStopping,
|
||||
setMessageId,
|
||||
setRespondingFalse,
|
||||
setRespondingTrue,
|
||||
setWorkflowProcessData,
|
||||
workflowProcessData: workflowProcessDataState,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,230 @@
|
||||
import type { ResultInputValue } from '../result-request'
|
||||
import type { ResultRunStateController } from './use-result-run-state'
|
||||
import type { PromptConfig } from '@/models/debug'
|
||||
import type { AppSourceType } from '@/service/share'
|
||||
import type { VisionFile, VisionSettings } from '@/types/app'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { TEXT_GENERATION_TIMEOUT_MS } from '@/config'
|
||||
import {
|
||||
sendCompletionMessage,
|
||||
sendWorkflowMessage,
|
||||
} from '@/service/share'
|
||||
import { sleep } from '@/utils'
|
||||
import { buildResultRequestData, validateResultRequest } from '../result-request'
|
||||
import { createWorkflowStreamHandlers } from '../workflow-stream-handlers'
|
||||
|
||||
type Notify = (payload: { type: 'error' | 'info' | 'warning', message: string }) => void
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string
|
||||
|
||||
type UseResultSenderOptions = {
|
||||
appId?: string
|
||||
appSourceType: AppSourceType
|
||||
completionFiles: VisionFile[]
|
||||
controlRetry?: number
|
||||
controlSend?: number
|
||||
inputs: Record<string, ResultInputValue>
|
||||
isCallBatchAPI: boolean
|
||||
isPC: boolean
|
||||
isWorkflow: boolean
|
||||
notify: Notify
|
||||
onCompleted: (completionRes: string, taskId?: number, success?: boolean) => void
|
||||
onRunStart: () => void
|
||||
onShowRes: () => void
|
||||
promptConfig: PromptConfig | null
|
||||
runState: ResultRunStateController
|
||||
t: Translate
|
||||
taskId?: number
|
||||
visionConfig: VisionSettings
|
||||
}
|
||||
|
||||
const logRequestError = (notify: Notify, error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
notify({ type: 'error', message })
|
||||
}
|
||||
|
||||
export const useResultSender = ({
|
||||
appId,
|
||||
appSourceType,
|
||||
completionFiles,
|
||||
controlRetry,
|
||||
controlSend,
|
||||
inputs,
|
||||
isCallBatchAPI,
|
||||
isPC,
|
||||
isWorkflow,
|
||||
notify,
|
||||
onCompleted,
|
||||
onRunStart,
|
||||
onShowRes,
|
||||
promptConfig,
|
||||
runState,
|
||||
t,
|
||||
taskId,
|
||||
visionConfig,
|
||||
}: UseResultSenderOptions) => {
|
||||
const { clearMoreLikeThis } = runState
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (runState.isResponding) {
|
||||
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
|
||||
return false
|
||||
}
|
||||
|
||||
const validation = validateResultRequest({
|
||||
completionFiles,
|
||||
inputs,
|
||||
isCallBatchAPI,
|
||||
promptConfig,
|
||||
t,
|
||||
})
|
||||
if (!validation.canSend) {
|
||||
notify(validation.notification!)
|
||||
return false
|
||||
}
|
||||
|
||||
const data = buildResultRequestData({
|
||||
completionFiles,
|
||||
inputs,
|
||||
promptConfig,
|
||||
visionConfig,
|
||||
})
|
||||
|
||||
runState.prepareForNewRun()
|
||||
|
||||
if (!isPC) {
|
||||
onShowRes()
|
||||
onRunStart()
|
||||
}
|
||||
|
||||
runState.setRespondingTrue()
|
||||
|
||||
let isEnd = false
|
||||
let isTimeout = false
|
||||
let completionChunks: string[] = []
|
||||
let tempMessageId = ''
|
||||
|
||||
void (async () => {
|
||||
await sleep(TEXT_GENERATION_TIMEOUT_MS)
|
||||
if (!isEnd) {
|
||||
runState.setRespondingFalse()
|
||||
onCompleted(runState.getCompletionRes(), taskId, false)
|
||||
runState.resetRunState()
|
||||
isTimeout = true
|
||||
}
|
||||
})()
|
||||
|
||||
if (isWorkflow) {
|
||||
const otherOptions = createWorkflowStreamHandlers({
|
||||
getCompletionRes: runState.getCompletionRes,
|
||||
getWorkflowProcessData: runState.getWorkflowProcessData,
|
||||
isTimedOut: () => isTimeout,
|
||||
markEnded: () => {
|
||||
isEnd = true
|
||||
},
|
||||
notify,
|
||||
onCompleted,
|
||||
resetRunState: runState.resetRunState,
|
||||
setCompletionRes: runState.setCompletionRes,
|
||||
setCurrentTaskId: runState.setCurrentTaskId,
|
||||
setIsStopping: runState.setIsStopping,
|
||||
setMessageId: runState.setMessageId,
|
||||
setRespondingFalse: runState.setRespondingFalse,
|
||||
setWorkflowProcessData: runState.setWorkflowProcessData,
|
||||
t,
|
||||
taskId,
|
||||
})
|
||||
|
||||
void sendWorkflowMessage(data, otherOptions, appSourceType, appId).catch((error) => {
|
||||
runState.setRespondingFalse()
|
||||
runState.resetRunState()
|
||||
logRequestError(notify, error)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
void sendCompletionMessage(data, {
|
||||
onData: (chunk, _isFirstMessage, { messageId, taskId: nextTaskId }) => {
|
||||
tempMessageId = messageId
|
||||
if (nextTaskId && nextTaskId.trim() !== '')
|
||||
runState.setCurrentTaskId(prev => prev ?? nextTaskId)
|
||||
|
||||
completionChunks.push(chunk)
|
||||
runState.setCompletionRes(completionChunks.join(''))
|
||||
},
|
||||
onCompleted: () => {
|
||||
if (isTimeout) {
|
||||
notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) })
|
||||
return
|
||||
}
|
||||
|
||||
runState.setRespondingFalse()
|
||||
runState.resetRunState()
|
||||
runState.setMessageId(tempMessageId)
|
||||
onCompleted(runState.getCompletionRes(), taskId, true)
|
||||
isEnd = true
|
||||
},
|
||||
onMessageReplace: (messageReplace) => {
|
||||
completionChunks = [messageReplace.answer]
|
||||
runState.setCompletionRes(completionChunks.join(''))
|
||||
},
|
||||
onError: () => {
|
||||
if (isTimeout) {
|
||||
notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) })
|
||||
return
|
||||
}
|
||||
|
||||
runState.setRespondingFalse()
|
||||
runState.resetRunState()
|
||||
onCompleted(runState.getCompletionRes(), taskId, false)
|
||||
isEnd = true
|
||||
},
|
||||
getAbortController: (abortController) => {
|
||||
runState.abortControllerRef.current = abortController
|
||||
},
|
||||
}, appSourceType, appId)
|
||||
|
||||
return true
|
||||
}, [
|
||||
appId,
|
||||
appSourceType,
|
||||
completionFiles,
|
||||
inputs,
|
||||
isCallBatchAPI,
|
||||
isPC,
|
||||
isWorkflow,
|
||||
notify,
|
||||
onCompleted,
|
||||
onRunStart,
|
||||
onShowRes,
|
||||
promptConfig,
|
||||
runState,
|
||||
t,
|
||||
taskId,
|
||||
visionConfig,
|
||||
])
|
||||
|
||||
const handleSendRef = useRef(handleSend)
|
||||
|
||||
useEffect(() => {
|
||||
handleSendRef.current = handleSend
|
||||
}, [handleSend])
|
||||
|
||||
useEffect(() => {
|
||||
if (!controlSend)
|
||||
return
|
||||
|
||||
void handleSendRef.current()
|
||||
clearMoreLikeThis()
|
||||
}, [clearMoreLikeThis, controlSend])
|
||||
|
||||
useEffect(() => {
|
||||
if (!controlRetry)
|
||||
return
|
||||
|
||||
void handleSendRef.current()
|
||||
}, [controlRetry])
|
||||
|
||||
return {
|
||||
handleSend,
|
||||
}
|
||||
}
|
||||
@ -1,46 +1,18 @@
|
||||
'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 { PromptConfig } from '@/models/debug'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import type {
|
||||
IOtherOptions,
|
||||
} from '@/service/base'
|
||||
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 {
|
||||
sseGet,
|
||||
} from '@/service/base'
|
||||
import {
|
||||
AppSourceType,
|
||||
sendCompletionMessage,
|
||||
sendWorkflowMessage,
|
||||
stopChatMessageResponding,
|
||||
stopWorkflowMessage,
|
||||
updateFeedback,
|
||||
} from '@/service/share'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { sleep } from '@/utils'
|
||||
import { formatBooleanInputs } from '@/utils/model-config'
|
||||
import { useResultRunState } from './hooks/use-result-run-state'
|
||||
import { useResultSender } from './hooks/use-result-sender'
|
||||
|
||||
export type IResultProps = {
|
||||
isWorkflow: boolean
|
||||
@ -95,554 +67,52 @@ const Result: FC<IResultProps> = ({
|
||||
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 = useCallback((data: WorkflowProcess | undefined) => {
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
const abortCurrentRequest = () => {
|
||||
abortControllerRef.current?.abort()
|
||||
}
|
||||
|
||||
if (controlStopResponding) {
|
||||
abortCurrentRequest()
|
||||
setRespondingFalse()
|
||||
resetRunState()
|
||||
}
|
||||
|
||||
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 runState = useResultRunState({
|
||||
appId,
|
||||
appSourceType,
|
||||
controlStopResponding,
|
||||
isWorkflow,
|
||||
notify,
|
||||
onRunControlChange,
|
||||
})
|
||||
|
||||
const handleFeedback = async (feedback: FeedbackType) => {
|
||||
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, appSourceType, appId)
|
||||
setFeedback(feedback)
|
||||
}
|
||||
const { handleSend } = useResultSender({
|
||||
appId,
|
||||
appSourceType,
|
||||
completionFiles,
|
||||
controlRetry,
|
||||
controlSend,
|
||||
inputs,
|
||||
isCallBatchAPI,
|
||||
isPC,
|
||||
isWorkflow,
|
||||
notify,
|
||||
onCompleted,
|
||||
onRunStart,
|
||||
onShowRes,
|
||||
promptConfig,
|
||||
runState,
|
||||
t,
|
||||
taskId,
|
||||
visionConfig,
|
||||
})
|
||||
|
||||
const logError = (message: string) => {
|
||||
notify({ type: 'error', message })
|
||||
}
|
||||
|
||||
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, 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.some(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.some(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('')
|
||||
setWorkflowProcessData(undefined)
|
||||
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) {
|
||||
const otherOptions: IOtherOptions = {
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
|
||||
const workflowProcessData = getWorkflowProcessData()
|
||||
if (workflowProcessData && workflowProcessData.tracing.length > 0) {
|
||||
setWorkflowProcessData(produce(workflowProcessData, (draft) => {
|
||||
draft.expand = true
|
||||
draft.status = WorkflowRunningStatus.Running
|
||||
}))
|
||||
}
|
||||
else {
|
||||
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
|
||||
const workflowProcessData = getWorkflowProcessData()
|
||||
setWorkflowProcessData(produce(workflowProcessData!, (draft) => {
|
||||
if (draft.tracing.length > 0) {
|
||||
const currentIndex = draft.tracing.findIndex(item => item.node_id === data.node_id)
|
||||
if (currentIndex > -1) {
|
||||
draft.expand = true
|
||||
draft.tracing![currentIndex] = {
|
||||
...data,
|
||||
status: NodeRunningStatus.Running,
|
||||
expand: true,
|
||||
}
|
||||
}
|
||||
else {
|
||||
draft.expand = true
|
||||
draft.tracing.push({
|
||||
...data,
|
||||
status: NodeRunningStatus.Running,
|
||||
expand: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
else {
|
||||
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
|
||||
}))
|
||||
},
|
||||
onHumanInputRequired: ({ data: humanInputRequiredData }) => {
|
||||
const workflowProcessData = getWorkflowProcessData()
|
||||
setWorkflowProcessData(produce(workflowProcessData!, (draft) => {
|
||||
if (!draft.humanInputFormDataList) {
|
||||
draft.humanInputFormDataList = [humanInputRequiredData]
|
||||
}
|
||||
else {
|
||||
const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === humanInputRequiredData.node_id)
|
||||
if (currentFormIndex > -1) {
|
||||
draft.humanInputFormDataList[currentFormIndex] = humanInputRequiredData
|
||||
}
|
||||
else {
|
||||
draft.humanInputFormDataList.push(humanInputRequiredData)
|
||||
}
|
||||
}
|
||||
const currentIndex = draft.tracing!.findIndex(item => item.node_id === humanInputRequiredData.node_id)
|
||||
if (currentIndex > -1) {
|
||||
draft.tracing![currentIndex].status = NodeRunningStatus.Paused
|
||||
}
|
||||
}))
|
||||
},
|
||||
onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => {
|
||||
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
|
||||
if (draft.humanInputFormDataList?.length) {
|
||||
const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === humanInputFilledFormData.node_id)
|
||||
draft.humanInputFormDataList.splice(currentFormIndex, 1)
|
||||
}
|
||||
if (!draft.humanInputFilledFormDataList) {
|
||||
draft.humanInputFilledFormDataList = [humanInputFilledFormData]
|
||||
}
|
||||
else {
|
||||
draft.humanInputFilledFormDataList.push(humanInputFilledFormData)
|
||||
}
|
||||
}))
|
||||
},
|
||||
onHumanInputFormTimeout: ({ data: humanInputFormTimeoutData }) => {
|
||||
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
|
||||
if (draft.humanInputFormDataList?.length) {
|
||||
const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === humanInputFormTimeoutData.node_id)
|
||||
draft.humanInputFormDataList[currentFormIndex].expiration_time = humanInputFormTimeoutData.expiration_time
|
||||
}
|
||||
}))
|
||||
},
|
||||
onWorkflowPaused: ({ data: workflowPausedData }) => {
|
||||
tempMessageId = workflowPausedData.workflow_run_id
|
||||
const url = `/workflow/${workflowPausedData.workflow_run_id}/events`
|
||||
sseGet(
|
||||
url,
|
||||
{},
|
||||
otherOptions,
|
||||
)
|
||||
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
|
||||
draft.expand = false
|
||||
draft.status = WorkflowRunningStatus.Paused
|
||||
}))
|
||||
},
|
||||
}
|
||||
sendWorkflowMessage(
|
||||
data,
|
||||
otherOptions,
|
||||
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 isNoData = !runState.completionRes
|
||||
|
||||
const renderTextGenerationRes = () => (
|
||||
<>
|
||||
{!hideInlineStopButton && isResponding && currentTaskId && (
|
||||
{!hideInlineStopButton && runState.isResponding && runState.currentTaskId && (
|
||||
<div className={`mb-3 flex ${isPC ? 'justify-end' : 'justify-center'}`}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isStopping}
|
||||
onClick={handleStop}
|
||||
disabled={runState.isStopping}
|
||||
onClick={runState.handleStop}
|
||||
>
|
||||
{
|
||||
isStopping
|
||||
? <RiLoader2Line className="mr-[5px] h-3.5 w-3.5 animate-spin" />
|
||||
: <StopCircle className="mr-[5px] h-3.5 w-3.5" />
|
||||
runState.isStopping
|
||||
? <span aria-hidden className="i-ri-loader-2-line mr-[5px] h-3.5 w-3.5 animate-spin" />
|
||||
: <span aria-hidden className="i-ri-stop-circle-fill mr-[5px] h-3.5 w-3.5" />
|
||||
}
|
||||
<span className="text-xs font-normal">{t('operation.stopResponding', { ns: 'appDebug' })}</span>
|
||||
</Button>
|
||||
@ -650,15 +120,15 @@ const Result: FC<IResultProps> = ({
|
||||
)}
|
||||
<TextGenerationRes
|
||||
isWorkflow={isWorkflow}
|
||||
workflowProcessData={workflowProcessData}
|
||||
workflowProcessData={runState.workflowProcessData}
|
||||
isError={isError}
|
||||
onRetry={handleSend}
|
||||
content={completionRes}
|
||||
messageId={messageId}
|
||||
content={runState.completionRes}
|
||||
messageId={runState.messageId}
|
||||
isInWebApp
|
||||
moreLikeThis={moreLikeThisEnabled}
|
||||
onFeedback={handleFeedback}
|
||||
feedback={feedback}
|
||||
onFeedback={runState.handleFeedback}
|
||||
feedback={runState.feedback}
|
||||
onSave={handleSaveMessage}
|
||||
isMobile={isMobile}
|
||||
appSourceType={appSourceType}
|
||||
@ -666,7 +136,7 @@ const Result: FC<IResultProps> = ({
|
||||
// isLoading={isCallBatchAPI ? (!completionRes && isResponding) : false}
|
||||
isLoading={false}
|
||||
taskId={isCallBatchAPI ? ((taskId as number) < 10 ? `0${taskId}` : `${taskId}`) : undefined}
|
||||
controlClearMoreLikeThis={controlClearMoreLikeThis}
|
||||
controlClearMoreLikeThis={runState.controlClearMoreLikeThis}
|
||||
isShowTextToSpeech={isShowTextToSpeech}
|
||||
hideProcessDetail
|
||||
siteInfo={siteInfo}
|
||||
@ -677,7 +147,7 @@ const Result: FC<IResultProps> = ({
|
||||
return (
|
||||
<>
|
||||
{!isCallBatchAPI && !isWorkflow && (
|
||||
(isResponding && !completionRes)
|
||||
(runState.isResponding && !runState.completionRes)
|
||||
? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loading type="area" />
|
||||
@ -692,13 +162,13 @@ const Result: FC<IResultProps> = ({
|
||||
)
|
||||
)}
|
||||
{!isCallBatchAPI && isWorkflow && (
|
||||
(isResponding && !workflowProcessData)
|
||||
(runState.isResponding && !runState.workflowProcessData)
|
||||
? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loading type="area" />
|
||||
</div>
|
||||
)
|
||||
: !workflowProcessData
|
||||
: !runState.workflowProcessData
|
||||
? <NoData />
|
||||
: renderTextGenerationRes()
|
||||
)}
|
||||
|
||||
@ -0,0 +1,156 @@
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { PromptConfig } from '@/models/debug'
|
||||
import type { VisionFile, VisionSettings } from '@/types/app'
|
||||
import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { formatBooleanInputs } from '@/utils/model-config'
|
||||
|
||||
export type ResultInputValue
|
||||
= | string
|
||||
| boolean
|
||||
| number
|
||||
| string[]
|
||||
| Record<string, unknown>
|
||||
| FileEntity
|
||||
| FileEntity[]
|
||||
| undefined
|
||||
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string
|
||||
|
||||
type ValidationResult = {
|
||||
canSend: boolean
|
||||
notification?: {
|
||||
type: 'error' | 'info'
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
type ValidateResultRequestParams = {
|
||||
completionFiles: VisionFile[]
|
||||
inputs: Record<string, ResultInputValue>
|
||||
isCallBatchAPI: boolean
|
||||
promptConfig: PromptConfig | null
|
||||
t: Translate
|
||||
}
|
||||
|
||||
type BuildResultRequestDataParams = {
|
||||
completionFiles: VisionFile[]
|
||||
inputs: Record<string, ResultInputValue>
|
||||
promptConfig: PromptConfig | null
|
||||
visionConfig: VisionSettings
|
||||
}
|
||||
|
||||
const isMissingRequiredInput = (
|
||||
variable: PromptConfig['prompt_variables'][number],
|
||||
value: ResultInputValue,
|
||||
) => {
|
||||
if (value === undefined || value === null)
|
||||
return true
|
||||
|
||||
if (variable.type === 'file-list')
|
||||
return !Array.isArray(value) || value.length === 0
|
||||
|
||||
if (['string', 'paragraph', 'number', 'json_object', 'select'].includes(variable.type))
|
||||
return typeof value !== 'string' ? false : value.trim() === ''
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const hasPendingLocalFiles = (completionFiles: VisionFile[]) => {
|
||||
return completionFiles.some(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)
|
||||
}
|
||||
|
||||
export const validateResultRequest = ({
|
||||
completionFiles,
|
||||
inputs,
|
||||
isCallBatchAPI,
|
||||
promptConfig,
|
||||
t,
|
||||
}: ValidateResultRequestParams): ValidationResult => {
|
||||
if (isCallBatchAPI)
|
||||
return { canSend: true }
|
||||
|
||||
const promptVariables = promptConfig?.prompt_variables
|
||||
if (!promptVariables?.length) {
|
||||
if (hasPendingLocalFiles(completionFiles)) {
|
||||
return {
|
||||
canSend: false,
|
||||
notification: {
|
||||
type: 'info',
|
||||
message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { canSend: true }
|
||||
}
|
||||
|
||||
const requiredVariables = promptVariables.filter(({ key, name, required, type }) => {
|
||||
if (type === 'boolean' || type === 'checkbox')
|
||||
return false
|
||||
|
||||
return (!key || !key.trim()) || (!name || !name.trim()) || required === undefined || required === null || required
|
||||
})
|
||||
|
||||
const missingRequiredVariable = requiredVariables.find(variable => isMissingRequiredInput(variable, inputs[variable.key]))?.name
|
||||
if (missingRequiredVariable) {
|
||||
return {
|
||||
canSend: false,
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: t('errorMessage.valueOfVarRequired', {
|
||||
ns: 'appDebug',
|
||||
key: missingRequiredVariable,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPendingLocalFiles(completionFiles)) {
|
||||
return {
|
||||
canSend: false,
|
||||
notification: {
|
||||
type: 'info',
|
||||
message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { canSend: true }
|
||||
}
|
||||
|
||||
export const buildResultRequestData = ({
|
||||
completionFiles,
|
||||
inputs,
|
||||
promptConfig,
|
||||
visionConfig,
|
||||
}: BuildResultRequestDataParams) => {
|
||||
const processedInputs = {
|
||||
...formatBooleanInputs(promptConfig?.prompt_variables, inputs as Record<string, string | number | boolean | object>),
|
||||
}
|
||||
|
||||
promptConfig?.prompt_variables.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]
|
||||
return
|
||||
}
|
||||
|
||||
if (variable.type === 'file-list' && Array.isArray(value) && value.length > 0)
|
||||
processedInputs[variable.key] = getProcessedFiles(value as FileEntity[])
|
||||
})
|
||||
|
||||
return {
|
||||
inputs: processedInputs,
|
||||
...(visionConfig.enabled && completionFiles.length > 0
|
||||
? {
|
||||
files: completionFiles.map((item) => {
|
||||
if (item.transfer_method === TransferMethod.local_file)
|
||||
return { ...item, url: '' }
|
||||
|
||||
return item
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,404 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import type { WorkflowProcess } from '@/app/components/base/chat/types'
|
||||
import type { IOtherOptions } from '@/service/base'
|
||||
import type { HumanInputFormTimeoutData, 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'
|
||||
import { sseGet } from '@/service/base'
|
||||
|
||||
type Notify = (payload: { type: 'error' | 'warning', message: string }) => void
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string
|
||||
|
||||
type CreateWorkflowStreamHandlersParams = {
|
||||
getCompletionRes: () => string
|
||||
getWorkflowProcessData: () => WorkflowProcess | undefined
|
||||
isTimedOut: () => boolean
|
||||
markEnded: () => void
|
||||
notify: Notify
|
||||
onCompleted: (completionRes: string, taskId?: number, success?: boolean) => void
|
||||
resetRunState: () => void
|
||||
setCompletionRes: (res: string) => void
|
||||
setCurrentTaskId: Dispatch<SetStateAction<string | null>>
|
||||
setIsStopping: Dispatch<SetStateAction<boolean>>
|
||||
setMessageId: Dispatch<SetStateAction<string | null>>
|
||||
setRespondingFalse: () => void
|
||||
setWorkflowProcessData: (data: WorkflowProcess | undefined) => void
|
||||
t: Translate
|
||||
taskId?: number
|
||||
}
|
||||
|
||||
const createInitialWorkflowProcess = (): WorkflowProcess => ({
|
||||
status: WorkflowRunningStatus.Running,
|
||||
tracing: [],
|
||||
expand: false,
|
||||
resultText: '',
|
||||
})
|
||||
|
||||
const updateWorkflowProcess = (
|
||||
current: WorkflowProcess | undefined,
|
||||
updater: (draft: WorkflowProcess) => void,
|
||||
) => {
|
||||
return produce(current ?? createInitialWorkflowProcess(), updater)
|
||||
}
|
||||
|
||||
const matchParallelTrace = (trace: WorkflowProcess['tracing'][number], data: NodeTracing) => {
|
||||
return trace.node_id === data.node_id
|
||||
&& (trace.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id
|
||||
|| trace.parallel_id === data.execution_metadata?.parallel_id)
|
||||
}
|
||||
|
||||
const ensureParallelTraceDetails = (details?: NodeTracing['details']) => {
|
||||
return details?.length ? details : [[]]
|
||||
}
|
||||
|
||||
const appendParallelStart = (current: WorkflowProcess | undefined, data: NodeTracing) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
draft.expand = true
|
||||
draft.tracing.push({
|
||||
...data,
|
||||
details: ensureParallelTraceDetails(data.details),
|
||||
status: NodeRunningStatus.Running,
|
||||
expand: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const appendParallelNext = (current: WorkflowProcess | undefined, data: NodeTracing) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
draft.expand = true
|
||||
const trace = draft.tracing.find(item => matchParallelTrace(item, data))
|
||||
if (!trace)
|
||||
return
|
||||
|
||||
trace.details = ensureParallelTraceDetails(trace.details)
|
||||
trace.details.push([])
|
||||
})
|
||||
}
|
||||
|
||||
const finishParallelTrace = (current: WorkflowProcess | undefined, data: NodeTracing) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
draft.expand = true
|
||||
const traceIndex = draft.tracing.findIndex(item => matchParallelTrace(item, data))
|
||||
if (traceIndex > -1) {
|
||||
draft.tracing[traceIndex] = {
|
||||
...data,
|
||||
expand: !!data.error,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const upsertWorkflowNode = (current: WorkflowProcess | undefined, data: NodeTracing) => {
|
||||
if (data.iteration_id || data.loop_id)
|
||||
return current
|
||||
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
draft.expand = true
|
||||
const currentIndex = draft.tracing.findIndex(item => item.node_id === data.node_id)
|
||||
const nextTrace = {
|
||||
...data,
|
||||
status: NodeRunningStatus.Running,
|
||||
expand: true,
|
||||
}
|
||||
|
||||
if (currentIndex > -1)
|
||||
draft.tracing[currentIndex] = nextTrace
|
||||
else
|
||||
draft.tracing.push(nextTrace)
|
||||
})
|
||||
}
|
||||
|
||||
const finishWorkflowNode = (current: WorkflowProcess | undefined, data: NodeTracing) => {
|
||||
if (data.iteration_id || data.loop_id)
|
||||
return current
|
||||
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
const currentIndex = draft.tracing.findIndex(trace => matchParallelTrace(trace, data))
|
||||
if (currentIndex > -1) {
|
||||
draft.tracing[currentIndex] = {
|
||||
...(draft.tracing[currentIndex].extras
|
||||
? { extras: draft.tracing[currentIndex].extras }
|
||||
: {}),
|
||||
...data,
|
||||
expand: !!data.error,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const applyWorkflowFinishedState = (
|
||||
current: WorkflowProcess | undefined,
|
||||
status: WorkflowRunningStatus,
|
||||
) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
draft.status = status
|
||||
if ([WorkflowRunningStatus.Stopped, WorkflowRunningStatus.Failed].includes(status))
|
||||
markNodesStopped(draft.tracing)
|
||||
})
|
||||
}
|
||||
|
||||
const applyWorkflowOutputs = (
|
||||
current: WorkflowProcess | undefined,
|
||||
outputs: WorkflowFinishedResponse['data']['outputs'],
|
||||
) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
draft.status = WorkflowRunningStatus.Succeeded
|
||||
draft.files = getFilesInLogs(outputs || []) as unknown as WorkflowProcess['files']
|
||||
})
|
||||
}
|
||||
|
||||
const appendResultText = (current: WorkflowProcess | undefined, text: string) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
draft.resultText = `${draft.resultText || ''}${text}`
|
||||
})
|
||||
}
|
||||
|
||||
const replaceResultText = (current: WorkflowProcess | undefined, text: string) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
draft.resultText = text
|
||||
})
|
||||
}
|
||||
|
||||
const updateHumanInputRequired = (
|
||||
current: WorkflowProcess | undefined,
|
||||
data: NonNullable<WorkflowProcess['humanInputFormDataList']>[number],
|
||||
) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
if (!draft.humanInputFormDataList) {
|
||||
draft.humanInputFormDataList = [data]
|
||||
}
|
||||
else {
|
||||
const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === data.node_id)
|
||||
if (currentFormIndex > -1)
|
||||
draft.humanInputFormDataList[currentFormIndex] = data
|
||||
else
|
||||
draft.humanInputFormDataList.push(data)
|
||||
}
|
||||
|
||||
const currentIndex = draft.tracing.findIndex(item => item.node_id === data.node_id)
|
||||
if (currentIndex > -1)
|
||||
draft.tracing[currentIndex].status = NodeRunningStatus.Paused
|
||||
})
|
||||
}
|
||||
|
||||
const updateHumanInputFilled = (
|
||||
current: WorkflowProcess | undefined,
|
||||
data: NonNullable<WorkflowProcess['humanInputFilledFormDataList']>[number],
|
||||
) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
if (draft.humanInputFormDataList?.length) {
|
||||
const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === data.node_id)
|
||||
if (currentFormIndex > -1)
|
||||
draft.humanInputFormDataList.splice(currentFormIndex, 1)
|
||||
}
|
||||
|
||||
if (!draft.humanInputFilledFormDataList)
|
||||
draft.humanInputFilledFormDataList = [data]
|
||||
else
|
||||
draft.humanInputFilledFormDataList.push(data)
|
||||
})
|
||||
}
|
||||
|
||||
const updateHumanInputTimeout = (
|
||||
current: WorkflowProcess | undefined,
|
||||
data: HumanInputFormTimeoutData,
|
||||
) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
if (!draft.humanInputFormDataList?.length)
|
||||
return
|
||||
|
||||
const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === data.node_id)
|
||||
if (currentFormIndex > -1)
|
||||
draft.humanInputFormDataList[currentFormIndex].expiration_time = data.expiration_time
|
||||
})
|
||||
}
|
||||
|
||||
const applyWorkflowPaused = (current: WorkflowProcess | undefined) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
draft.expand = false
|
||||
draft.status = WorkflowRunningStatus.Paused
|
||||
})
|
||||
}
|
||||
|
||||
const serializeWorkflowOutputs = (outputs: WorkflowFinishedResponse['data']['outputs']) => {
|
||||
if (outputs === undefined || outputs === null)
|
||||
return ''
|
||||
|
||||
if (typeof outputs === 'string')
|
||||
return outputs
|
||||
|
||||
try {
|
||||
return JSON.stringify(outputs) ?? ''
|
||||
}
|
||||
catch {
|
||||
return String(outputs)
|
||||
}
|
||||
}
|
||||
|
||||
export const createWorkflowStreamHandlers = ({
|
||||
getCompletionRes,
|
||||
getWorkflowProcessData,
|
||||
isTimedOut,
|
||||
markEnded,
|
||||
notify,
|
||||
onCompleted,
|
||||
resetRunState,
|
||||
setCompletionRes,
|
||||
setCurrentTaskId,
|
||||
setIsStopping,
|
||||
setMessageId,
|
||||
setRespondingFalse,
|
||||
setWorkflowProcessData,
|
||||
t,
|
||||
taskId,
|
||||
}: CreateWorkflowStreamHandlersParams): IOtherOptions => {
|
||||
let tempMessageId = ''
|
||||
|
||||
const finishWithFailure = () => {
|
||||
setRespondingFalse()
|
||||
resetRunState()
|
||||
onCompleted(getCompletionRes(), taskId, false)
|
||||
markEnded()
|
||||
}
|
||||
|
||||
const finishWithSuccess = () => {
|
||||
setRespondingFalse()
|
||||
resetRunState()
|
||||
setMessageId(tempMessageId)
|
||||
onCompleted(getCompletionRes(), taskId, true)
|
||||
markEnded()
|
||||
}
|
||||
|
||||
const otherOptions: IOtherOptions = {
|
||||
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
|
||||
const workflowProcessData = getWorkflowProcessData()
|
||||
if (workflowProcessData?.tracing.length) {
|
||||
setWorkflowProcessData(updateWorkflowProcess(workflowProcessData, (draft) => {
|
||||
draft.expand = true
|
||||
draft.status = WorkflowRunningStatus.Running
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
tempMessageId = workflow_run_id
|
||||
setCurrentTaskId(task_id || null)
|
||||
setIsStopping(false)
|
||||
setWorkflowProcessData(createInitialWorkflowProcess())
|
||||
},
|
||||
onIterationStart: ({ data }) => {
|
||||
setWorkflowProcessData(appendParallelStart(getWorkflowProcessData(), data))
|
||||
},
|
||||
onIterationNext: ({ data }) => {
|
||||
setWorkflowProcessData(appendParallelNext(getWorkflowProcessData(), data))
|
||||
},
|
||||
onIterationFinish: ({ data }) => {
|
||||
setWorkflowProcessData(finishParallelTrace(getWorkflowProcessData(), data))
|
||||
},
|
||||
onLoopStart: ({ data }) => {
|
||||
setWorkflowProcessData(appendParallelStart(getWorkflowProcessData(), data))
|
||||
},
|
||||
onLoopNext: ({ data }) => {
|
||||
setWorkflowProcessData(appendParallelNext(getWorkflowProcessData(), data))
|
||||
},
|
||||
onLoopFinish: ({ data }) => {
|
||||
setWorkflowProcessData(finishParallelTrace(getWorkflowProcessData(), data))
|
||||
},
|
||||
onNodeStarted: ({ data }) => {
|
||||
setWorkflowProcessData(upsertWorkflowNode(getWorkflowProcessData(), data))
|
||||
},
|
||||
onNodeFinished: ({ data }) => {
|
||||
setWorkflowProcessData(finishWorkflowNode(getWorkflowProcessData(), data))
|
||||
},
|
||||
onWorkflowFinished: ({ data }) => {
|
||||
if (isTimedOut()) {
|
||||
notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) })
|
||||
return
|
||||
}
|
||||
|
||||
const workflowStatus = data.status as WorkflowRunningStatus | undefined
|
||||
if (workflowStatus === WorkflowRunningStatus.Stopped) {
|
||||
setWorkflowProcessData(applyWorkflowFinishedState(getWorkflowProcessData(), WorkflowRunningStatus.Stopped))
|
||||
finishWithFailure()
|
||||
return
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
notify({ type: 'error', message: data.error })
|
||||
setWorkflowProcessData(applyWorkflowFinishedState(getWorkflowProcessData(), WorkflowRunningStatus.Failed))
|
||||
finishWithFailure()
|
||||
return
|
||||
}
|
||||
|
||||
setWorkflowProcessData(applyWorkflowOutputs(getWorkflowProcessData(), data.outputs))
|
||||
const serializedOutputs = serializeWorkflowOutputs(data.outputs)
|
||||
setCompletionRes(serializedOutputs)
|
||||
if (data.outputs) {
|
||||
const outputKeys = Object.keys(data.outputs)
|
||||
const isStringOutput = outputKeys.length === 1 && typeof data.outputs[outputKeys[0]] === 'string'
|
||||
if (isStringOutput) {
|
||||
setWorkflowProcessData(updateWorkflowProcess(getWorkflowProcessData(), (draft) => {
|
||||
draft.resultText = data.outputs[outputKeys[0]]
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
finishWithSuccess()
|
||||
},
|
||||
onTextChunk: ({ data: { text } }) => {
|
||||
setWorkflowProcessData(appendResultText(getWorkflowProcessData(), text))
|
||||
},
|
||||
onTextReplace: ({ data: { text } }) => {
|
||||
setWorkflowProcessData(replaceResultText(getWorkflowProcessData(), text))
|
||||
},
|
||||
onHumanInputRequired: ({ data }) => {
|
||||
setWorkflowProcessData(updateHumanInputRequired(getWorkflowProcessData(), data))
|
||||
},
|
||||
onHumanInputFormFilled: ({ data }) => {
|
||||
setWorkflowProcessData(updateHumanInputFilled(getWorkflowProcessData(), data))
|
||||
},
|
||||
onHumanInputFormTimeout: ({ data }) => {
|
||||
setWorkflowProcessData(updateHumanInputTimeout(getWorkflowProcessData(), data))
|
||||
},
|
||||
onWorkflowPaused: ({ data }) => {
|
||||
tempMessageId = data.workflow_run_id
|
||||
void sseGet(`/workflow/${data.workflow_run_id}/events`, {}, otherOptions)
|
||||
setWorkflowProcessData(applyWorkflowPaused(getWorkflowProcessData()))
|
||||
},
|
||||
}
|
||||
|
||||
return otherOptions
|
||||
}
|
||||
|
||||
export {
|
||||
appendParallelNext,
|
||||
appendParallelStart,
|
||||
appendResultText,
|
||||
applyWorkflowFinishedState,
|
||||
applyWorkflowOutputs,
|
||||
applyWorkflowPaused,
|
||||
finishParallelTrace,
|
||||
finishWorkflowNode,
|
||||
markNodesStopped,
|
||||
replaceResultText,
|
||||
updateHumanInputFilled,
|
||||
updateHumanInputRequired,
|
||||
updateHumanInputTimeout,
|
||||
upsertWorkflowNode,
|
||||
}
|
||||
@ -5964,11 +5964,8 @@
|
||||
}
|
||||
},
|
||||
"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
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/share/text-generation/run-batch/csv-download/index.tsx": {
|
||||
|
||||
@ -92,10 +92,10 @@ export const COMPONENT_MODULE_THRESHOLDS = {
|
||||
branches: 90,
|
||||
},
|
||||
'share': {
|
||||
lines: 15,
|
||||
statements: 15,
|
||||
functions: 20,
|
||||
branches: 20,
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 95,
|
||||
},
|
||||
'signin': {
|
||||
lines: 95,
|
||||
|
||||
Reference in New Issue
Block a user