mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
feat(workflow): support human input in loop and iteration (#39243)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import type { ChatWithHistoryContextValue } from '../context'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { AppData, AppMeta, ConversationItem } from '@/models/share'
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import {
|
||||
fetchChatList,
|
||||
@@ -144,6 +144,7 @@ const defaultChatHookReturn: Partial<ChatHookReturn> = {
|
||||
handleSend: vi.fn(),
|
||||
handleStop: vi.fn(),
|
||||
handleSwitchSibling: vi.fn(),
|
||||
prepareHumanInputSubmission: vi.fn().mockResolvedValue(true),
|
||||
isResponding: false,
|
||||
suggestedQuestions: [],
|
||||
}
|
||||
@@ -910,6 +911,13 @@ describe('ChatWrapper', () => {
|
||||
it('should handle human input form submission for installed app', async () => {
|
||||
const { submitHumanInputForm: submitWorkflowForm } = await import('@/service/workflow')
|
||||
vi.mocked(submitWorkflowForm).mockResolvedValue({} as unknown as void)
|
||||
let resolveWorkflowEventsReady: (isReady: boolean) => void = () => {}
|
||||
const prepareHumanInputSubmission = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveWorkflowEventsReady = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
vi.mocked(useChatWithHistoryContext).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
@@ -918,6 +926,7 @@ describe('ChatWrapper', () => {
|
||||
|
||||
vi.mocked(useChat).mockReturnValue({
|
||||
...defaultChatHookReturn,
|
||||
prepareHumanInputSubmission,
|
||||
chatList: [
|
||||
{ id: 'q1', content: 'Question' },
|
||||
{
|
||||
@@ -961,6 +970,12 @@ describe('ChatWrapper', () => {
|
||||
const runButton = screen.getByText('Run')
|
||||
fireEvent.click(runButton)
|
||||
|
||||
expect(prepareHumanInputSubmission).toHaveBeenCalledOnce()
|
||||
expect(submitWorkflowForm).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
resolveWorkflowEventsReady(true)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(submitWorkflowForm).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -86,6 +86,7 @@ const ChatWrapper = () => {
|
||||
handleSend,
|
||||
handleStop,
|
||||
handleSwitchSibling,
|
||||
prepareHumanInputSubmission,
|
||||
isResponding: respondingState,
|
||||
suggestedQuestions,
|
||||
} = useChat(
|
||||
@@ -284,10 +285,12 @@ const ChatWrapper = () => {
|
||||
|
||||
const handleSubmitHumanInputForm = useCallback(
|
||||
async (formToken: string, formData: any) => {
|
||||
if (!(await prepareHumanInputSubmission())) return
|
||||
|
||||
if (isInstalledApp) await submitHumanInputFormService(formToken, formData)
|
||||
else await submitHumanInputForm(formToken, formData)
|
||||
},
|
||||
[isInstalledApp],
|
||||
[isInstalledApp, prepareHumanInputSubmission],
|
||||
)
|
||||
|
||||
const [collapsed, setCollapsed] = useState(!!currentConversationId)
|
||||
|
||||
@@ -723,6 +723,151 @@ describe('useChat', () => {
|
||||
expect(result.current.isResponding).toBe(true)
|
||||
})
|
||||
|
||||
it('should only allow submission after the continuation stream observes the pause', async () => {
|
||||
let postCallbacks: HookCallbacks
|
||||
let continuationCallbacks: HookCallbacks
|
||||
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
|
||||
postCallbacks = options as HookCallbacks
|
||||
})
|
||||
vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => {
|
||||
continuationCallbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useChat())
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', { query: 'human input test' }, {})
|
||||
postCallbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' })
|
||||
postCallbacks.onHumanInputRequired({
|
||||
workflow_run_id: 'wr-1',
|
||||
data: { node_id: 'human-1' },
|
||||
})
|
||||
})
|
||||
expect(sseGet).not.toHaveBeenCalled()
|
||||
|
||||
let isReady: boolean | undefined
|
||||
const readyPromise = result.current
|
||||
.prepareHumanInputSubmission()
|
||||
.then((ready) => (isReady = ready))
|
||||
await act(async () => Promise.resolve())
|
||||
expect(isReady).toBeUndefined()
|
||||
|
||||
act(() => {
|
||||
postCallbacks.onWorkflowPaused({ data: { workflow_run_id: 'wr-1' } })
|
||||
})
|
||||
expect(sseGet).toHaveBeenCalledWith(
|
||||
'/workflow/wr-1/events?include_state_snapshot=true&continue_on_pause=true',
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
)
|
||||
await act(async () => Promise.resolve())
|
||||
expect(isReady).toBeUndefined()
|
||||
|
||||
act(() => {
|
||||
continuationCallbacks.onWorkflowPaused({ data: { workflow_run_id: 'wr-1' } })
|
||||
})
|
||||
await act(async () => readyPromise)
|
||||
expect(isReady).toBe(true)
|
||||
})
|
||||
|
||||
it('should register a new paused conversation without running final completion twice', async () => {
|
||||
let postCallbacks: HookCallbacks
|
||||
let continuationCallbacks: HookCallbacks
|
||||
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
|
||||
postCallbacks = options as HookCallbacks
|
||||
})
|
||||
vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => {
|
||||
continuationCallbacks = options as HookCallbacks
|
||||
})
|
||||
const onConversationComplete = vi.fn()
|
||||
const onGetConversationMessages = vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'm-1',
|
||||
answer: 'completed answer',
|
||||
message: [],
|
||||
workflow_run_id: 'wr-1',
|
||||
inputs: {},
|
||||
query: 'human input test',
|
||||
},
|
||||
],
|
||||
})
|
||||
const onGetSuggestedQuestions = vi.fn().mockResolvedValue({ data: ['Next question'] })
|
||||
const config = { suggested_questions_after_answer: { enabled: true } }
|
||||
|
||||
const { result } = renderHook(() => useChat(config as ChatConfig))
|
||||
act(() => {
|
||||
result.current.handleSend(
|
||||
'test-url',
|
||||
{ query: 'human input test' },
|
||||
{
|
||||
onConversationComplete,
|
||||
onGetConversationMessages,
|
||||
onGetSuggestedQuestions,
|
||||
},
|
||||
)
|
||||
postCallbacks.onWorkflowStarted({
|
||||
workflow_run_id: 'wr-1',
|
||||
task_id: 't-1',
|
||||
conversation_id: 'c-1',
|
||||
message_id: 'm-1',
|
||||
})
|
||||
postCallbacks.onHumanInputRequired({
|
||||
workflow_run_id: 'wr-1',
|
||||
data: { node_id: 'human-1' },
|
||||
})
|
||||
postCallbacks.onWorkflowPaused({ data: { workflow_run_id: 'wr-1' } })
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await postCallbacks.onCompleted()
|
||||
})
|
||||
|
||||
expect(onConversationComplete).toHaveBeenCalledOnce()
|
||||
expect(onConversationComplete).toHaveBeenCalledWith('c-1', 'wr-1')
|
||||
expect(onGetConversationMessages).not.toHaveBeenCalled()
|
||||
expect(onGetSuggestedQuestions).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
continuationCallbacks.onWorkflowPaused({ data: { workflow_run_id: 'wr-1' } })
|
||||
continuationCallbacks.onWorkflowFinished({ data: { status: 'succeeded' } })
|
||||
await continuationCallbacks.onCompleted()
|
||||
})
|
||||
|
||||
expect(onConversationComplete).toHaveBeenCalledOnce()
|
||||
expect(onGetConversationMessages).toHaveBeenCalledOnce()
|
||||
expect(onGetSuggestedQuestions).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should reject a pending form submission if the initial stream fails before pausing', async () => {
|
||||
let postCallbacks: HookCallbacks
|
||||
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
|
||||
postCallbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useChat())
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', { query: 'human input test' }, {})
|
||||
postCallbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' })
|
||||
postCallbacks.onNodeStarted({ data: { node_id: 'human-1', id: 'human-1' } })
|
||||
postCallbacks.onHumanInputRequired({
|
||||
workflow_run_id: 'wr-1',
|
||||
data: { node_id: 'human-1' },
|
||||
})
|
||||
})
|
||||
|
||||
const readyPromise = result.current.prepareHumanInputSubmission()
|
||||
await act(async () => Promise.resolve())
|
||||
expect(result.current.chatList[1]!.humanInputFormDataList).toHaveLength(1)
|
||||
|
||||
act(() => {
|
||||
postCallbacks.onError('stream failed')
|
||||
})
|
||||
|
||||
await expect(readyPromise).resolves.toBe(false)
|
||||
expect(result.current.chatList[1]!.humanInputFormDataList).toHaveLength(0)
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle file uploads in onFile', () => {
|
||||
let callbacks: HookCallbacks
|
||||
|
||||
@@ -1344,7 +1489,7 @@ describe('useChat', () => {
|
||||
})
|
||||
|
||||
expect(sseGet).toHaveBeenCalledWith(
|
||||
'/workflow/wr-1/events?include_state_snapshot=true',
|
||||
'/workflow/wr-1/events?include_state_snapshot=true&continue_on_pause=true',
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
)
|
||||
@@ -1397,6 +1542,7 @@ describe('useChat', () => {
|
||||
})
|
||||
callbacks.onMessageReplace({ answer: 'replaced resume' })
|
||||
|
||||
callbacks.onWorkflowPaused({ data: { workflow_run_id: 'wr-1' } })
|
||||
callbacks.onWorkflowPaused({ data: { workflow_run_id: 'wr-1' } })
|
||||
|
||||
callbacks.onError()
|
||||
@@ -1414,6 +1560,164 @@ describe('useChat', () => {
|
||||
expect(lastResponse!.humanInputFilledFormDataList).toHaveLength(1)
|
||||
expect(lastResponse!.humanInputFormDataList).toHaveLength(0)
|
||||
expect(lastResponse!.content).toBe('replaced resume')
|
||||
expect(sseGet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should wait for the resumed event stream before allowing a restored form submission', async () => {
|
||||
let callbacks: HookCallbacks
|
||||
vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const prevChatTree = [
|
||||
{
|
||||
id: 'q-1',
|
||||
content: 'query',
|
||||
isAnswer: false,
|
||||
children: [
|
||||
{
|
||||
id: 'm-1',
|
||||
content: '',
|
||||
isAnswer: true,
|
||||
workflow_run_id: 'wr-1',
|
||||
humanInputFormDataList: [{ node_id: 'human-1' }],
|
||||
workflowProcess: { status: WorkflowRunningStatus.Paused, tracing: [] },
|
||||
siblingIndex: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const { result } = renderHook(() =>
|
||||
useChat(undefined, undefined, prevChatTree as unknown as ChatItemInTree[]),
|
||||
)
|
||||
|
||||
let isReady: boolean | undefined
|
||||
const readyPromise = result.current
|
||||
.prepareHumanInputSubmission()
|
||||
.then((ready) => (isReady = ready))
|
||||
|
||||
await act(async () => Promise.resolve())
|
||||
expect(isReady).toBeUndefined()
|
||||
|
||||
act(() => {
|
||||
result.current.handleSwitchSibling('m-1', { isPublicAPI: true })
|
||||
})
|
||||
expect(sseGet).toHaveBeenCalledWith(
|
||||
'/workflow/wr-1/events?include_state_snapshot=true&continue_on_pause=true',
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
callbacks.onWorkflowPaused({ data: { workflow_run_id: 'wr-1' } })
|
||||
})
|
||||
await act(async () => readyPromise)
|
||||
|
||||
expect(isReady).toBe(true)
|
||||
expect(sseGet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should reconnect an idle paused event stream before the next submission', async () => {
|
||||
const callbacksList: HookCallbacks[] = []
|
||||
const onConversationComplete = vi.fn()
|
||||
vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => {
|
||||
callbacksList.push(options as HookCallbacks)
|
||||
})
|
||||
|
||||
const prevChatTree = [
|
||||
{
|
||||
id: 'q-1',
|
||||
content: 'query',
|
||||
isAnswer: false,
|
||||
children: [
|
||||
{
|
||||
id: 'm-1',
|
||||
content: '',
|
||||
isAnswer: true,
|
||||
workflow_run_id: 'wr-1',
|
||||
humanInputFormDataList: [{ node_id: 'human-1' }],
|
||||
workflowProcess: { status: WorkflowRunningStatus.Paused, tracing: [] },
|
||||
siblingIndex: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const { result } = renderHook(() =>
|
||||
useChat(undefined, undefined, prevChatTree as unknown as ChatItemInTree[]),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleResume('m-1', 'wr-1', {
|
||||
isPublicAPI: true,
|
||||
onConversationComplete,
|
||||
})
|
||||
callbacksList[0]!.onWorkflowPaused({ data: { workflow_run_id: 'wr-1' } })
|
||||
})
|
||||
await act(async () => {
|
||||
await callbacksList[0]!.onCompleted()
|
||||
})
|
||||
expect(onConversationComplete).not.toHaveBeenCalled()
|
||||
|
||||
let isReady: boolean | undefined
|
||||
const readyPromise = result.current
|
||||
.prepareHumanInputSubmission()
|
||||
.then((ready) => (isReady = ready))
|
||||
expect(sseGet).toHaveBeenCalledTimes(2)
|
||||
await act(async () => Promise.resolve())
|
||||
expect(isReady).toBeUndefined()
|
||||
|
||||
act(() => {
|
||||
callbacksList[1]!.onWorkflowPaused({ data: { workflow_run_id: 'wr-1' } })
|
||||
})
|
||||
await act(async () => readyPromise)
|
||||
|
||||
expect(isReady).toBe(true)
|
||||
expect(onConversationComplete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reconnect immediately if the event stream idles after submission resumes the run', async () => {
|
||||
const callbacksList: HookCallbacks[] = []
|
||||
vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => {
|
||||
callbacksList.push(options as HookCallbacks)
|
||||
})
|
||||
|
||||
const prevChatTree = [
|
||||
{
|
||||
id: 'q-1',
|
||||
content: 'query',
|
||||
isAnswer: false,
|
||||
children: [
|
||||
{
|
||||
id: 'm-1',
|
||||
content: '',
|
||||
isAnswer: true,
|
||||
workflow_run_id: 'wr-1',
|
||||
humanInputFormDataList: [{ node_id: 'human-1' }],
|
||||
workflowProcess: { status: WorkflowRunningStatus.Paused, tracing: [] },
|
||||
siblingIndex: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const { result } = renderHook(() =>
|
||||
useChat(undefined, undefined, prevChatTree as unknown as ChatItemInTree[]),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleResume('m-1', 'wr-1', { isPublicAPI: true })
|
||||
callbacksList[0]!.onWorkflowPaused({ data: { workflow_run_id: 'wr-1' } })
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.prepareHumanInputSubmission()
|
||||
await callbacksList[0]!.onCompleted()
|
||||
})
|
||||
|
||||
expect(sseGet).toHaveBeenCalledTimes(2)
|
||||
expect(sseGet).toHaveBeenLastCalledWith(
|
||||
'/workflow/wr-1/events?include_state_snapshot=true&continue_on_pause=true',
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle non-agent mode resume', async () => {
|
||||
@@ -1674,6 +1978,7 @@ describe('useChat', () => {
|
||||
conversationId: 'c-resume',
|
||||
taskId: 't-resume',
|
||||
})
|
||||
callbacks.onWorkflowFinished({ data: { status: 'succeeded' } })
|
||||
await callbacks.onCompleted()
|
||||
})
|
||||
|
||||
@@ -1874,7 +2179,7 @@ describe('useChat', () => {
|
||||
})
|
||||
|
||||
expect(sseGet).toHaveBeenCalledWith(
|
||||
'/workflow/wr-tts-app/events?include_state_snapshot=true',
|
||||
'/workflow/wr-tts-app/events?include_state_snapshot=true&continue_on_pause=true',
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
)
|
||||
@@ -2004,6 +2309,38 @@ describe('useChat', () => {
|
||||
expect(suggestedAbort.abort).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should mark an unmounted continuation stream as an intentional abort', () => {
|
||||
let callbacks: HookCallbacks
|
||||
vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
const workflowAbort = createAbortControllerMock()
|
||||
const prevChatTree = [
|
||||
{
|
||||
id: 'q-1',
|
||||
content: 'query',
|
||||
isAnswer: false,
|
||||
children: [{ id: 'm-1', content: '', isAnswer: true, siblingIndex: 0 }],
|
||||
},
|
||||
]
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useChat(undefined, undefined, prevChatTree as ChatItemInTree[]),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleResume('m-1', 'wr-1', { isPublicAPI: true })
|
||||
callbacks.getAbortController(workflowAbort)
|
||||
})
|
||||
unmount()
|
||||
|
||||
expect(workflowAbort.abort).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'AbortError',
|
||||
message: 'The user aborted a request.',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should clear chat list when clearChatList flag is true and reset flag via callback', () => {
|
||||
const clearChatListCallback = vi.fn()
|
||||
|
||||
@@ -2108,7 +2445,7 @@ describe('useChat', () => {
|
||||
|
||||
// Should automatically call handleResume -> sseGet for human input
|
||||
expect(sseGet).toHaveBeenCalledWith(
|
||||
'/workflow/wr-1/events?include_state_snapshot=true',
|
||||
'/workflow/wr-1/events?include_state_snapshot=true&continue_on_pause=true',
|
||||
expect.any(Object),
|
||||
expect.any(Object),
|
||||
)
|
||||
@@ -3141,6 +3478,7 @@ describe('useChat', () => {
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
resumeCallbacks.onWorkflowFinished({ data: { status: 'succeeded' } })
|
||||
await resumeCallbacks.onCompleted()
|
||||
})
|
||||
expect(result.current.suggestedQuestions).toEqual(['Suggested 1', 'Suggested 2'])
|
||||
|
||||
@@ -180,6 +180,10 @@ function getConversationMessagesData(response: unknown): ConversationMessagesRes
|
||||
return Array.isArray(data) ? data.filter(isHistoryConversationMessage) : []
|
||||
}
|
||||
|
||||
function abortWorkflowEventsRequest(abortController: AbortController | null) {
|
||||
abortController?.abort(new DOMException('The user aborted a request.', 'AbortError'))
|
||||
}
|
||||
|
||||
export const useChat = (
|
||||
config?: ChatConfig,
|
||||
formSettings?: {
|
||||
@@ -206,6 +210,26 @@ export const useChat = (
|
||||
const conversationMessagesAbortControllerRef = useRef<AbortController | null>(null)
|
||||
const suggestedQuestionsAbortControllerRef = useRef<AbortController | null>(null)
|
||||
const workflowEventsAbortControllerRef = useRef<AbortController | null>(null)
|
||||
const pausedWorkflowEventsAbortControllerRef = useRef<AbortController | null>(null)
|
||||
const pausedWorkflowEventsRef = useRef<{
|
||||
workflowRunId: string
|
||||
options: IOtherOptions
|
||||
} | null>(null)
|
||||
const workflowEventsSubscriptionActiveRef = useRef(false)
|
||||
const workflowEventsSubscriptionRunIdRef = useRef<string | null>(null)
|
||||
const workflowEventsSubscriptionGenerationRef = useRef(0)
|
||||
const workflowRequestGenerationRef = useRef(0)
|
||||
const workflowEventsReadyRef = useRef(false)
|
||||
const workflowPauseConfirmedRef = useRef(false)
|
||||
const workflowEventsReadyWaitersRef = useRef<
|
||||
Array<{
|
||||
workflowRunId: string | null
|
||||
resolve: (isReady: boolean) => void
|
||||
}>
|
||||
>([])
|
||||
const startWorkflowEventsSubscriptionRef = useRef<
|
||||
((workflowRunId: string, options: IOtherOptions) => void) | null
|
||||
>(null)
|
||||
const params = useParams()
|
||||
const pathname = usePathname()
|
||||
|
||||
@@ -331,6 +355,172 @@ export const useChat = (
|
||||
isRespondingRef.current = isResponding
|
||||
}, [])
|
||||
|
||||
const resolveWorkflowEventsReadyWaiters = useCallback((isReady: boolean) => {
|
||||
const waiters = workflowEventsReadyWaitersRef.current.splice(0)
|
||||
waiters.forEach(({ resolve }) => resolve(isReady))
|
||||
}, [])
|
||||
|
||||
const bindWorkflowEventsReadyWaiters = useCallback((workflowRunId: string) => {
|
||||
workflowEventsReadyWaitersRef.current = workflowEventsReadyWaitersRef.current.filter(
|
||||
(waiter) => {
|
||||
if (waiter.workflowRunId && waiter.workflowRunId !== workflowRunId) {
|
||||
waiter.resolve(false)
|
||||
return false
|
||||
}
|
||||
|
||||
waiter.workflowRunId = workflowRunId
|
||||
return true
|
||||
},
|
||||
)
|
||||
}, [])
|
||||
|
||||
const markWorkflowEventsPending = useCallback(() => {
|
||||
workflowEventsReadyRef.current = false
|
||||
}, [])
|
||||
|
||||
const startWorkflowEventsSubscription = useCallback(
|
||||
(workflowRunId: string, options: IOtherOptions) => {
|
||||
const generation = ++workflowEventsSubscriptionGenerationRef.current
|
||||
abortWorkflowEventsRequest(pausedWorkflowEventsAbortControllerRef.current)
|
||||
pausedWorkflowEventsAbortControllerRef.current = null
|
||||
pausedWorkflowEventsRef.current = { workflowRunId, options }
|
||||
bindWorkflowEventsReadyWaiters(workflowRunId)
|
||||
workflowEventsSubscriptionActiveRef.current = true
|
||||
workflowEventsSubscriptionRunIdRef.current = workflowRunId
|
||||
markWorkflowEventsPending()
|
||||
|
||||
let hasWorkflowFinished = false
|
||||
const releaseSubscription = () => {
|
||||
if (generation !== workflowEventsSubscriptionGenerationRef.current) return false
|
||||
|
||||
workflowEventsSubscriptionActiveRef.current = false
|
||||
workflowEventsSubscriptionRunIdRef.current = null
|
||||
pausedWorkflowEventsAbortControllerRef.current = null
|
||||
return true
|
||||
}
|
||||
const subscriptionOptions: IOtherOptions = {
|
||||
...options,
|
||||
getAbortController: (abortController) => {
|
||||
if (generation !== workflowEventsSubscriptionGenerationRef.current) {
|
||||
abortWorkflowEventsRequest(abortController)
|
||||
return
|
||||
}
|
||||
pausedWorkflowEventsAbortControllerRef.current = abortController
|
||||
},
|
||||
onHumanInputRequired: (event) => {
|
||||
if (generation !== workflowEventsSubscriptionGenerationRef.current) return
|
||||
options.onHumanInputRequired?.(event)
|
||||
},
|
||||
onWorkflowFinished: (event) => {
|
||||
if (generation !== workflowEventsSubscriptionGenerationRef.current) return
|
||||
hasWorkflowFinished = true
|
||||
options.onWorkflowFinished?.(event)
|
||||
},
|
||||
onWorkflowPaused: (event) => {
|
||||
if (generation !== workflowEventsSubscriptionGenerationRef.current) return
|
||||
|
||||
options.onWorkflowPaused?.(event)
|
||||
workflowEventsReadyRef.current = true
|
||||
resolveWorkflowEventsReadyWaiters(true)
|
||||
},
|
||||
onError: (...args) => {
|
||||
if (!releaseSubscription()) return
|
||||
|
||||
markWorkflowEventsPending()
|
||||
resolveWorkflowEventsReadyWaiters(false)
|
||||
options.onError?.(...args)
|
||||
},
|
||||
async onCompleted(hasError?: boolean, errorMessage?: string) {
|
||||
if (!releaseSubscription()) return
|
||||
|
||||
markWorkflowEventsPending()
|
||||
if (!hasWorkflowFinished) {
|
||||
if (hasError) {
|
||||
resolveWorkflowEventsReadyWaiters(false)
|
||||
await options.onCompleted?.(hasError, errorMessage)
|
||||
} else {
|
||||
resolveWorkflowEventsReadyWaiters(false)
|
||||
if (!workflowPauseConfirmedRef.current)
|
||||
startWorkflowEventsSubscriptionRef.current?.(workflowRunId, options)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
workflowPauseConfirmedRef.current = false
|
||||
pausedWorkflowEventsRef.current = null
|
||||
resolveWorkflowEventsReadyWaiters(false)
|
||||
await options.onCompleted?.(hasError, errorMessage)
|
||||
},
|
||||
}
|
||||
|
||||
void sseGet(
|
||||
`/workflow/${workflowRunId}/events?include_state_snapshot=true&continue_on_pause=true`,
|
||||
{},
|
||||
subscriptionOptions,
|
||||
)
|
||||
},
|
||||
[bindWorkflowEventsReadyWaiters, markWorkflowEventsPending, resolveWorkflowEventsReadyWaiters],
|
||||
)
|
||||
startWorkflowEventsSubscriptionRef.current = startWorkflowEventsSubscription
|
||||
|
||||
const ensureWorkflowEventsSubscription = useCallback(
|
||||
(workflowRunId: string, options: IOtherOptions) => {
|
||||
pausedWorkflowEventsRef.current = { workflowRunId, options }
|
||||
if (
|
||||
workflowEventsSubscriptionActiveRef.current &&
|
||||
workflowEventsSubscriptionRunIdRef.current === workflowRunId
|
||||
)
|
||||
return
|
||||
|
||||
startWorkflowEventsSubscription(workflowRunId, options)
|
||||
},
|
||||
[startWorkflowEventsSubscription],
|
||||
)
|
||||
|
||||
const prepareHumanInputSubmission = useCallback(async () => {
|
||||
if (workflowEventsReadyRef.current) {
|
||||
workflowPauseConfirmedRef.current = false
|
||||
return true
|
||||
}
|
||||
|
||||
const isReady = await new Promise<boolean>((resolve) => {
|
||||
const pausedWorkflowEvents = pausedWorkflowEventsRef.current
|
||||
workflowEventsReadyWaitersRef.current.push({
|
||||
workflowRunId: pausedWorkflowEvents?.workflowRunId ?? null,
|
||||
resolve,
|
||||
})
|
||||
if (
|
||||
pausedWorkflowEvents &&
|
||||
workflowPauseConfirmedRef.current &&
|
||||
!workflowEventsSubscriptionActiveRef.current
|
||||
) {
|
||||
startWorkflowEventsSubscription(
|
||||
pausedWorkflowEvents.workflowRunId,
|
||||
pausedWorkflowEvents.options,
|
||||
)
|
||||
}
|
||||
})
|
||||
if (isReady) {
|
||||
workflowPauseConfirmedRef.current = false
|
||||
}
|
||||
return isReady
|
||||
}, [startWorkflowEventsSubscription])
|
||||
|
||||
const resetWorkflowEventsSubscription = useCallback(() => {
|
||||
workflowRequestGenerationRef.current += 1
|
||||
workflowEventsSubscriptionGenerationRef.current += 1
|
||||
workflowEventsSubscriptionActiveRef.current = false
|
||||
workflowEventsSubscriptionRunIdRef.current = null
|
||||
workflowEventsReadyRef.current = false
|
||||
workflowPauseConfirmedRef.current = false
|
||||
pausedWorkflowEventsRef.current = null
|
||||
abortWorkflowEventsRequest(pausedWorkflowEventsAbortControllerRef.current)
|
||||
pausedWorkflowEventsAbortControllerRef.current = null
|
||||
resolveWorkflowEventsReadyWaiters(false)
|
||||
}, [resolveWorkflowEventsReadyWaiters])
|
||||
|
||||
useEffect(() => resetWorkflowEventsSubscription, [resetWorkflowEventsSubscription])
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
hasStopRespondedRef.current = true
|
||||
handleResponding(false)
|
||||
@@ -340,7 +530,8 @@ export const useChat = (
|
||||
if (suggestedQuestionsAbortControllerRef.current)
|
||||
suggestedQuestionsAbortControllerRef.current.abort()
|
||||
if (workflowEventsAbortControllerRef.current) workflowEventsAbortControllerRef.current.abort()
|
||||
}, [stopChat, handleResponding])
|
||||
resetWorkflowEventsSubscription()
|
||||
}, [stopChat, handleResponding, resetWorkflowEventsSubscription])
|
||||
|
||||
const handleRestart = useCallback(
|
||||
(cb?: any) => {
|
||||
@@ -389,6 +580,16 @@ export const useChat = (
|
||||
workflowRunId: string,
|
||||
{ onGetSuggestedQuestions, onConversationComplete, onSendSettled, isPublicAPI }: SendCallback,
|
||||
) => {
|
||||
const hasActiveSubscription =
|
||||
workflowEventsSubscriptionActiveRef.current &&
|
||||
workflowEventsSubscriptionRunIdRef.current === workflowRunId
|
||||
const requestGeneration = hasActiveSubscription
|
||||
? workflowRequestGenerationRef.current
|
||||
: ++workflowRequestGenerationRef.current
|
||||
if (!hasActiveSubscription) {
|
||||
workflowEventsAbortControllerRef.current?.abort()
|
||||
workflowEventsAbortControllerRef.current = null
|
||||
}
|
||||
const getOrCreatePlayer = createAudioPlayerManager()
|
||||
let hasSettled = false
|
||||
const settleSend = (hasError?: boolean) => {
|
||||
@@ -397,9 +598,6 @@ export const useChat = (
|
||||
hasSettled = true
|
||||
onSendSettled?.(hasError)
|
||||
}
|
||||
// Re-subscribe to workflow events for the specific message
|
||||
const url = `/workflow/${workflowRunId}/events?include_state_snapshot=true`
|
||||
|
||||
const otherOptions: IOtherOptions = {
|
||||
isPublicAPI,
|
||||
getAbortController: (abortController) => {
|
||||
@@ -440,6 +638,8 @@ export const useChat = (
|
||||
})
|
||||
},
|
||||
async onCompleted(hasError?: boolean) {
|
||||
if (requestGeneration !== workflowRequestGenerationRef.current) return
|
||||
|
||||
handleResponding(false)
|
||||
|
||||
try {
|
||||
@@ -575,10 +775,14 @@ export const useChat = (
|
||||
})
|
||||
},
|
||||
onError() {
|
||||
if (requestGeneration !== workflowRequestGenerationRef.current) return
|
||||
|
||||
handleResponding(false)
|
||||
settleSend(true)
|
||||
},
|
||||
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
|
||||
if (requestGeneration !== workflowRequestGenerationRef.current) return
|
||||
|
||||
handleResponding(true)
|
||||
hasStopRespondedRef.current = false
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
@@ -599,6 +803,9 @@ export const useChat = (
|
||||
})
|
||||
},
|
||||
onWorkflowFinished: ({ data: workflowFinishedData }) => {
|
||||
if (requestGeneration !== workflowRequestGenerationRef.current) return
|
||||
|
||||
pausedStateRef.current = false
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (responseItem.workflowProcess) {
|
||||
responseItem.workflowProcess = {
|
||||
@@ -724,7 +931,18 @@ export const useChat = (
|
||||
}
|
||||
})
|
||||
},
|
||||
onHumanInputRequired: ({ data: humanInputRequiredData }) => {
|
||||
onHumanInputRequired: ({
|
||||
workflow_run_id: pausedWorkflowRunId,
|
||||
data: humanInputRequiredData,
|
||||
}) => {
|
||||
if (requestGeneration !== workflowRequestGenerationRef.current) return
|
||||
|
||||
markWorkflowEventsPending()
|
||||
workflowPauseConfirmedRef.current = false
|
||||
pausedWorkflowEventsRef.current = {
|
||||
workflowRunId: pausedWorkflowRunId || workflowRunId,
|
||||
options: otherOptions,
|
||||
}
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (!responseItem.humanInputFormDataList) {
|
||||
responseItem.humanInputFormDataList = [humanInputRequiredData]
|
||||
@@ -749,6 +967,8 @@ export const useChat = (
|
||||
})
|
||||
},
|
||||
onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => {
|
||||
workflowPauseConfirmedRef.current = false
|
||||
handleResponding(true)
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
let requiredFormData:
|
||||
| NonNullable<ChatItem['humanInputFormDataList']>[number]
|
||||
@@ -785,18 +1005,20 @@ export const useChat = (
|
||||
})
|
||||
},
|
||||
onWorkflowPaused: ({ data: workflowPausedData }) => {
|
||||
const resumeUrl = `/workflow/${workflowPausedData.workflow_run_id}/events`
|
||||
if (requestGeneration !== workflowRequestGenerationRef.current) return
|
||||
|
||||
pausedStateRef.current = true
|
||||
sseGet(resumeUrl, {}, otherOptions)
|
||||
workflowPauseConfirmedRef.current = true
|
||||
handleResponding(false)
|
||||
ensureWorkflowEventsSubscription(workflowPausedData.workflow_run_id, otherOptions)
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
responseItem.workflowProcess!.status = WorkflowRunningStatus.Paused
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
if (workflowEventsAbortControllerRef.current) workflowEventsAbortControllerRef.current.abort()
|
||||
|
||||
sseGet(url, {}, otherOptions)
|
||||
workflowPauseConfirmedRef.current = true
|
||||
ensureWorkflowEventsSubscription(workflowRunId, otherOptions)
|
||||
},
|
||||
[
|
||||
updateChatTreeNode,
|
||||
@@ -804,6 +1026,8 @@ export const useChat = (
|
||||
createAudioPlayerManager,
|
||||
config?.suggested_questions_after_answer,
|
||||
options.isNewAgent,
|
||||
ensureWorkflowEventsSubscription,
|
||||
markWorkflowEventsPending,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -871,6 +1095,10 @@ export const useChat = (
|
||||
return false
|
||||
}
|
||||
|
||||
pausedStateRef.current = false
|
||||
resetWorkflowEventsSubscription()
|
||||
const requestGeneration = ++workflowRequestGenerationRef.current
|
||||
|
||||
const parentMessage = threadMessages.find((item) => item.id === data.parent_message_id)
|
||||
|
||||
const placeholderQuestionId = `question-${Date.now()}`
|
||||
@@ -938,12 +1166,21 @@ export const useChat = (
|
||||
let isAgentMode = false
|
||||
let hasSetResponseId = false
|
||||
let hasSettled = false
|
||||
let hasPaused = false
|
||||
let hasNotifiedConversationComplete = false
|
||||
let currentWorkflowRunId = ''
|
||||
const settleSend = (hasError?: boolean) => {
|
||||
if (hasSettled) return
|
||||
|
||||
hasSettled = true
|
||||
onSendSettled?.(hasError)
|
||||
}
|
||||
const notifyConversationComplete = (workflowRunId?: string) => {
|
||||
if (hasNotifiedConversationComplete) return
|
||||
|
||||
hasNotifiedConversationComplete = true
|
||||
onConversationComplete?.(conversationIdRef.current, workflowRunId)
|
||||
}
|
||||
|
||||
const getOrCreatePlayer = createAudioPlayerManager()
|
||||
|
||||
@@ -1005,6 +1242,8 @@ export const useChat = (
|
||||
})
|
||||
},
|
||||
async onCompleted(hasError?: boolean) {
|
||||
if (requestGeneration !== workflowRequestGenerationRef.current) return
|
||||
|
||||
handleResponding(false)
|
||||
|
||||
try {
|
||||
@@ -1025,8 +1264,7 @@ export const useChat = (
|
||||
const data = getConversationMessagesData(conversationMessagesResponse)
|
||||
const newResponseItem = data.find((item) => item.id === responseItem.id)
|
||||
completedWorkflowRunId = newResponseItem?.workflow_run_id ?? completedWorkflowRunId
|
||||
if (!newResponseItem)
|
||||
return onConversationComplete?.(conversationIdRef.current, completedWorkflowRunId)
|
||||
if (!newResponseItem) return notifyConversationComplete(completedWorkflowRunId)
|
||||
|
||||
const historyAgentThoughts = getHistoryAgentThoughts(newResponseItem)
|
||||
const lastHistoryAgentThought = historyAgentThoughts.at(-1)
|
||||
@@ -1082,7 +1320,7 @@ export const useChat = (
|
||||
})
|
||||
}
|
||||
|
||||
onConversationComplete?.(conversationIdRef.current, completedWorkflowRunId)
|
||||
notifyConversationComplete(completedWorkflowRunId)
|
||||
|
||||
if (
|
||||
config?.suggested_questions_after_answer?.enabled &&
|
||||
@@ -1236,6 +1474,8 @@ export const useChat = (
|
||||
responseItem.content = messageReplace.answer
|
||||
},
|
||||
onError() {
|
||||
if (requestGeneration !== workflowRequestGenerationRef.current) return
|
||||
|
||||
handleResponding(false)
|
||||
settleSend(true)
|
||||
updateCurrentQAOnTree({
|
||||
@@ -1246,6 +1486,9 @@ export const useChat = (
|
||||
})
|
||||
},
|
||||
onWorkflowStarted: ({ workflow_run_id, task_id, conversation_id, message_id }) => {
|
||||
if (requestGeneration !== workflowRequestGenerationRef.current) return
|
||||
|
||||
currentWorkflowRunId = workflow_run_id
|
||||
handleResponding(true)
|
||||
// If there are no streaming messages, we still need to set the conversation_id to avoid create a new conversation when regeneration in chat-flow.
|
||||
if (conversation_id) {
|
||||
@@ -1280,6 +1523,8 @@ export const useChat = (
|
||||
})
|
||||
},
|
||||
onWorkflowFinished: ({ data: workflowFinishedData }) => {
|
||||
if (requestGeneration !== workflowRequestGenerationRef.current) return
|
||||
|
||||
if (pausedStateRef.current) pausedStateRef.current = false
|
||||
responseItem.workflowProcess = {
|
||||
...responseItem.workflowProcess!,
|
||||
@@ -1425,7 +1670,18 @@ export const useChat = (
|
||||
parentId: data.parent_message_id,
|
||||
})
|
||||
},
|
||||
onHumanInputRequired: ({ data: humanInputRequiredData }) => {
|
||||
onHumanInputRequired: ({
|
||||
workflow_run_id: pausedWorkflowRunId,
|
||||
data: humanInputRequiredData,
|
||||
}) => {
|
||||
if (requestGeneration !== workflowRequestGenerationRef.current) return
|
||||
|
||||
markWorkflowEventsPending()
|
||||
workflowPauseConfirmedRef.current = false
|
||||
pausedWorkflowEventsRef.current = {
|
||||
workflowRunId: pausedWorkflowRunId || currentWorkflowRunId,
|
||||
options: otherOptions,
|
||||
}
|
||||
if (!responseItem.humanInputFormDataList) {
|
||||
responseItem.humanInputFormDataList = [humanInputRequiredData]
|
||||
} else {
|
||||
@@ -1453,6 +1709,8 @@ export const useChat = (
|
||||
}
|
||||
},
|
||||
onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => {
|
||||
workflowPauseConfirmedRef.current = false
|
||||
handleResponding(true)
|
||||
let requiredFormData: NonNullable<ChatItem['humanInputFormDataList']>[number] | undefined
|
||||
if (responseItem.humanInputFormDataList?.length) {
|
||||
const currentFormIndex = responseItem.humanInputFormDataList!.findIndex(
|
||||
@@ -1495,9 +1753,13 @@ export const useChat = (
|
||||
})
|
||||
},
|
||||
onWorkflowPaused: ({ data: workflowPausedData }) => {
|
||||
const url = `/workflow/${workflowPausedData.workflow_run_id}/events`
|
||||
if (requestGeneration !== workflowRequestGenerationRef.current) return
|
||||
|
||||
hasPaused = true
|
||||
pausedStateRef.current = true
|
||||
sseGet(url, {}, otherOptions)
|
||||
workflowPauseConfirmedRef.current = true
|
||||
handleResponding(false)
|
||||
ensureWorkflowEventsSubscription(workflowPausedData.workflow_run_id, otherOptions)
|
||||
responseItem.workflowProcess!.status = WorkflowRunningStatus.Paused
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
@@ -1511,12 +1773,34 @@ export const useChat = (
|
||||
// Abort the previous workflow events SSE request
|
||||
if (workflowEventsAbortControllerRef.current) workflowEventsAbortControllerRef.current.abort()
|
||||
|
||||
const postOptions: IOtherOptions = {
|
||||
...otherOptions,
|
||||
onError: (...args) => {
|
||||
if (requestGeneration !== workflowRequestGenerationRef.current) return
|
||||
|
||||
if (!hasPaused) {
|
||||
markWorkflowEventsPending()
|
||||
workflowPauseConfirmedRef.current = false
|
||||
pausedWorkflowEventsRef.current = null
|
||||
resolveWorkflowEventsReadyWaiters(false)
|
||||
responseItem.humanInputFormDataList = []
|
||||
}
|
||||
otherOptions.onError?.(...args)
|
||||
},
|
||||
onCompleted: (hasError?: boolean, errorMessage?: string) => {
|
||||
if (hasPaused && !hasError) {
|
||||
notifyConversationComplete(currentWorkflowRunId)
|
||||
return
|
||||
}
|
||||
return otherOptions.onCompleted?.(hasError, errorMessage)
|
||||
},
|
||||
}
|
||||
ssePost(
|
||||
url,
|
||||
{
|
||||
body: bodyParams,
|
||||
},
|
||||
otherOptions,
|
||||
postOptions,
|
||||
)
|
||||
return true
|
||||
},
|
||||
@@ -1532,6 +1816,10 @@ export const useChat = (
|
||||
createAudioPlayerManager,
|
||||
formSettings,
|
||||
options.isNewAgent,
|
||||
ensureWorkflowEventsSubscription,
|
||||
markWorkflowEventsPending,
|
||||
resetWorkflowEventsSubscription,
|
||||
resolveWorkflowEventsReadyWaiters,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1640,6 +1928,7 @@ export const useChat = (
|
||||
handleSend,
|
||||
handleResume,
|
||||
handleSwitchSibling,
|
||||
prepareHumanInputSubmission,
|
||||
suggestedQuestions,
|
||||
handleRestart,
|
||||
handleStop,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { HumanInputFieldValue } from '../../chat/answer/human-input-content
|
||||
import type { ChatConfig, ChatItem, ChatItemInTree } from '../../types'
|
||||
import type { EmbeddedChatbotContextValue } from '../context'
|
||||
import type { ConversationItem } from '@/models/share'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { AppSourceType, fetchSuggestedQuestions, submitHumanInputForm } from '@/service/share'
|
||||
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
|
||||
@@ -210,6 +210,7 @@ const createUseChatReturn = (overrides: Partial<UseChatReturn> = {}): UseChatRet
|
||||
setIsResponding: vi.fn() as UseChatReturn['setIsResponding'],
|
||||
handleStop: vi.fn(),
|
||||
handleSwitchSibling: vi.fn(),
|
||||
prepareHumanInputSubmission: vi.fn().mockResolvedValue(true),
|
||||
isResponding: false,
|
||||
suggestedQuestions: [],
|
||||
handleRestart: vi.fn(),
|
||||
@@ -457,6 +458,18 @@ describe('EmbeddedChatbot chat-wrapper', () => {
|
||||
|
||||
describe('Human input submit behavior', () => {
|
||||
it('should submit via installed app service when the app is installed', async () => {
|
||||
let resolveWorkflowEventsReady: (isReady: boolean) => void = () => {}
|
||||
const prepareHumanInputSubmission = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveWorkflowEventsReady = resolve
|
||||
}),
|
||||
)
|
||||
vi.mocked(useChat).mockReturnValue(
|
||||
createUseChatReturn({
|
||||
prepareHumanInputSubmission,
|
||||
}),
|
||||
)
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(
|
||||
createContextValue({
|
||||
isInstalledApp: true,
|
||||
@@ -466,6 +479,12 @@ describe('EmbeddedChatbot chat-wrapper', () => {
|
||||
render(<ChatWrapper />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'submit human input' }))
|
||||
|
||||
expect(prepareHumanInputSubmission).toHaveBeenCalledOnce()
|
||||
expect(submitHumanInputFormService).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
resolveWorkflowEventsReady(true)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(submitHumanInputFormService).toHaveBeenCalledWith('form-token', {
|
||||
inputs: { answer: 'ok' },
|
||||
|
||||
@@ -88,6 +88,7 @@ const ChatWrapper = () => {
|
||||
handleSend,
|
||||
handleStop,
|
||||
handleSwitchSibling,
|
||||
prepareHumanInputSubmission,
|
||||
isResponding: respondingState,
|
||||
suggestedQuestions,
|
||||
} = useChat(
|
||||
@@ -344,10 +345,12 @@ const ChatWrapper = () => {
|
||||
|
||||
const handleSubmitHumanInputForm = useCallback(
|
||||
async (formToken: string, formData: HumanInputFormSubmitData) => {
|
||||
if (!(await prepareHumanInputSubmission())) return
|
||||
|
||||
if (isInstalledApp) await submitHumanInputFormService(formToken, formData)
|
||||
else await submitHumanInputForm(formToken, formData)
|
||||
},
|
||||
[isInstalledApp],
|
||||
[isInstalledApp, prepareHumanInputSubmission],
|
||||
)
|
||||
|
||||
const welcome = useMemo(() => {
|
||||
|
||||
+33
-1
@@ -594,6 +594,17 @@ describe('createWorkflowStreamHandlers', () => {
|
||||
workflow_run_id: 'run-1',
|
||||
},
|
||||
})
|
||||
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',
|
||||
@@ -627,16 +638,37 @@ describe('createWorkflowStreamHandlers', () => {
|
||||
}),
|
||||
)
|
||||
expect(sseGetMock).toHaveBeenCalledWith(
|
||||
'/workflow/run-1/events',
|
||||
'/workflow/run-1/events?include_state_snapshot=true&continue_on_pause=true',
|
||||
{},
|
||||
expect.objectContaining({ isPublicAPI: true }),
|
||||
)
|
||||
expect(sseGetMock).toHaveBeenCalledTimes(1)
|
||||
expect(setup.messageId()).toBe('run-1')
|
||||
expect(setup.onCompleted).toHaveBeenCalledWith('{"answer":"Hello"}', 3, true)
|
||||
expect(setup.setRespondingFalse).toHaveBeenCalled()
|
||||
expect(setup.resetRunState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep one resumable stream for installed apps', () => {
|
||||
const { handlers } = setupHandlers({ isPublicAPI: false })
|
||||
const onWorkflowPaused = handlers.onWorkflowPaused!
|
||||
const pausedEvent = {
|
||||
data: {
|
||||
workflow_run_id: 'run-installed',
|
||||
},
|
||||
} as never
|
||||
|
||||
onWorkflowPaused(pausedEvent)
|
||||
onWorkflowPaused(pausedEvent)
|
||||
|
||||
expect(sseGetMock).toHaveBeenCalledWith(
|
||||
'/workflow/run-installed/events?include_state_snapshot=true&continue_on_pause=true',
|
||||
{},
|
||||
expect.objectContaining({ isPublicAPI: false }),
|
||||
)
|
||||
expect(sseGetMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should finish timed-out workflow state and warn without applying late outputs', () => {
|
||||
const timeoutSetup = setupHandlers({
|
||||
isTimedOut: () => true,
|
||||
|
||||
@@ -280,6 +280,7 @@ export const createWorkflowStreamHandlers = ({
|
||||
taskId,
|
||||
}: CreateWorkflowStreamHandlersParams): IOtherOptions => {
|
||||
let tempMessageId = ''
|
||||
let hasStartedResumeStream = false
|
||||
|
||||
const finishWithFailure = () => {
|
||||
setRespondingFalse()
|
||||
@@ -420,8 +421,14 @@ export const createWorkflowStreamHandlers = ({
|
||||
},
|
||||
onWorkflowPaused: ({ data }) => {
|
||||
tempMessageId = data.workflow_run_id
|
||||
// WebApp workflows must keep using the public API namespace after pause/resume.
|
||||
void sseGet(`/workflow/${data.workflow_run_id}/events`, {}, otherOptions)
|
||||
if (!hasStartedResumeStream) {
|
||||
hasStartedResumeStream = true
|
||||
void sseGet(
|
||||
`/workflow/${data.workflow_run_id}/events?include_state_snapshot=true&continue_on_pause=true`,
|
||||
{},
|
||||
otherOptions,
|
||||
)
|
||||
}
|
||||
setWorkflowProcessData(applyWorkflowPaused(getWorkflowProcessData()))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -141,10 +141,16 @@ describe('useWorkflowRun callbacks helpers', () => {
|
||||
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
|
||||
expect(mockResetMsgId).toHaveBeenCalledWith('message-1')
|
||||
|
||||
callbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
|
||||
callbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
|
||||
expect(handlers.handleWorkflowPaused).toHaveBeenCalled()
|
||||
expect(userOnWorkflowPaused).toHaveBeenCalled()
|
||||
expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, callbacks)
|
||||
expect(mockSseGet).toHaveBeenCalledWith(
|
||||
'/workflow/run-2/events?include_state_snapshot=true&continue_on_pause=true',
|
||||
{},
|
||||
callbacks,
|
||||
)
|
||||
expect(mockSseGet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should create final callbacks that preserve rest callback override order and eager abort-controller wiring', () => {
|
||||
@@ -267,6 +273,7 @@ describe('useWorkflowRun callbacks helpers', () => {
|
||||
callbacks.onTTSChunk?.('message-1', 'audio-chunk')
|
||||
callbacks.onTTSEnd?.('message-1', 'audio-finished')
|
||||
callbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
|
||||
callbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
|
||||
callbacks.onError?.({ error: 'failed', node_type: 'llm' } as never, '500')
|
||||
|
||||
expect(handlers.handleWorkflowStarted).toHaveBeenCalled()
|
||||
@@ -320,7 +327,12 @@ describe('useWorkflowRun callbacks helpers', () => {
|
||||
expect(mockResetMsgId).toHaveBeenCalledWith('message-1')
|
||||
expect(handlers.handleWorkflowPaused).toHaveBeenCalled()
|
||||
expect(userCallbacks.onWorkflowPaused).toHaveBeenCalled()
|
||||
expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, callbacks)
|
||||
expect(mockSseGet).toHaveBeenCalledWith(
|
||||
'/workflow/run-2/events?include_state_snapshot=true&continue_on_pause=true',
|
||||
{},
|
||||
callbacks,
|
||||
)
|
||||
expect(mockSseGet).toHaveBeenCalledTimes(1)
|
||||
expect(clearAbortController).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowFailed).toHaveBeenCalled()
|
||||
expect(userCallbacks.onError).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' }, '500')
|
||||
@@ -439,6 +451,7 @@ describe('useWorkflowRun callbacks helpers', () => {
|
||||
finalCallbacks.onHumanInputFormFilled?.({ node_id: 'node-1' } as never)
|
||||
finalCallbacks.onHumanInputFormTimeout?.({ node_id: 'node-1' } as never)
|
||||
finalCallbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
|
||||
finalCallbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
|
||||
finalCallbacks.onTTSChunk?.('message-2', 'audio-chunk')
|
||||
finalCallbacks.onTTSEnd?.('message-2', 'audio-finished')
|
||||
await finalCallbacks.onCompleted?.(true, 'done')
|
||||
@@ -482,7 +495,12 @@ describe('useWorkflowRun callbacks helpers', () => {
|
||||
expect(userCallbacks.onHumanInputFormTimeout).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowPaused).toHaveBeenCalled()
|
||||
expect(userCallbacks.onWorkflowPaused).toHaveBeenCalled()
|
||||
expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, finalCallbacks)
|
||||
expect(mockSseGet).toHaveBeenCalledWith(
|
||||
'/workflow/run-2/events?include_state_snapshot=true&continue_on_pause=true',
|
||||
{},
|
||||
finalCallbacks,
|
||||
)
|
||||
expect(mockSseGet).toHaveBeenCalledTimes(1)
|
||||
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
|
||||
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-finished', false)
|
||||
expect(clearAbortController).toHaveBeenCalled()
|
||||
|
||||
@@ -147,6 +147,7 @@ export const createBaseWorkflowRunCallbacks = ({
|
||||
onHumanInputFormTimeout,
|
||||
onCompleted,
|
||||
} = callbacks
|
||||
let hasStartedResumeStream = false
|
||||
|
||||
const wrappedOnError: IOtherOptions['onError'] = (params, code) => {
|
||||
clearAbortController()
|
||||
@@ -260,8 +261,11 @@ export const createBaseWorkflowRunCallbacks = ({
|
||||
handleWorkflowPaused()
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
if (onWorkflowPaused) onWorkflowPaused(params)
|
||||
const url = `/workflow/${params.workflow_run_id}/events`
|
||||
sseGet(url, {}, baseSseOptions)
|
||||
if (!hasStartedResumeStream) {
|
||||
hasStartedResumeStream = true
|
||||
const url = `/workflow/${params.workflow_run_id}/events?include_state_snapshot=true&continue_on_pause=true`
|
||||
sseGet(url, {}, baseSseOptions)
|
||||
}
|
||||
},
|
||||
onHumanInputRequired: (params) => {
|
||||
handleWorkflowNodeHumanInputRequired(params)
|
||||
@@ -340,6 +344,7 @@ export const createFinalWorkflowRunCallbacks = ({
|
||||
onHumanInputFormFilled,
|
||||
onHumanInputFormTimeout,
|
||||
} = callbacks
|
||||
let hasStartedResumeStream = false
|
||||
|
||||
const finalCallbacks: IOtherOptions = {
|
||||
...baseSseOptions,
|
||||
@@ -437,8 +442,11 @@ export const createFinalWorkflowRunCallbacks = ({
|
||||
handleWorkflowPaused()
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
if (onWorkflowPaused) onWorkflowPaused(params)
|
||||
const url = `/workflow/${params.workflow_run_id}/events`
|
||||
sseGet(url, {}, finalCallbacks)
|
||||
if (!hasStartedResumeStream) {
|
||||
hasStartedResumeStream = true
|
||||
const url = `/workflow/${params.workflow_run_id}/events?include_state_snapshot=true&continue_on_pause=true`
|
||||
sseGet(url, {}, finalCallbacks)
|
||||
}
|
||||
},
|
||||
onHumanInputRequired: (params) => {
|
||||
handleWorkflowNodeHumanInputRequired(params)
|
||||
|
||||
@@ -143,6 +143,23 @@ describe('HumanInputFormList', () => {
|
||||
expect(screen.queryByTestId('tips')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset inputs when the same node produces a new form', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(<HumanInputFormList humanInputFormDataList={[createFormData()]} />)
|
||||
|
||||
const input = screen.getByTestId('content-item-textarea')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'previous response')
|
||||
|
||||
rerender(
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={[createFormData({ form_id: 'form-2', form_token: 'token-2' })]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('content-item-textarea')).toHaveValue('prefill')
|
||||
})
|
||||
|
||||
it('should render an empty container when there are no visible forms', () => {
|
||||
render(<HumanInputFormList humanInputFormDataList={[]} />)
|
||||
|
||||
|
||||
+3
-3
@@ -125,7 +125,7 @@ describe('useChat – handleResume', () => {
|
||||
})
|
||||
|
||||
expect(mockSseGet).toHaveBeenCalledWith(
|
||||
'/workflow/wfr-1/events?include_state_snapshot=true',
|
||||
'/workflow/wfr-1/events?include_state_snapshot=true&continue_on_pause=true',
|
||||
{},
|
||||
expect.any(Object),
|
||||
)
|
||||
@@ -889,7 +889,7 @@ describe('useChat – handleResume', () => {
|
||||
})
|
||||
|
||||
describe('onWorkflowPaused', () => {
|
||||
it('should re-subscribe via sseGet and set status to Paused', async () => {
|
||||
it('should keep the resumable stream and set status to Paused', async () => {
|
||||
const { result } = await setupResumeWithTree()
|
||||
const sseGetCallsBefore = mockSseGet.mock.calls.length
|
||||
|
||||
@@ -899,7 +899,7 @@ describe('useChat – handleResume', () => {
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockSseGet.mock.calls.length).toBeGreaterThan(sseGetCallsBefore)
|
||||
expect(mockSseGet.mock.calls.length).toBe(sseGetCallsBefore)
|
||||
const answer = result.current.chatList.find((item) => item.id === 'msg-resume')
|
||||
expect(answer!.workflowProcess!.status).toBe('paused')
|
||||
})
|
||||
|
||||
@@ -732,7 +732,7 @@ export const useChat = (
|
||||
const handleResume = useCallback(
|
||||
(messageId: string, workflowRunId: string, { onGetSuggestedQuestions }: SendCallback) => {
|
||||
// Re-subscribe to workflow events for the specific message
|
||||
const url = `/workflow/${workflowRunId}/events?include_state_snapshot=true`
|
||||
const url = `/workflow/${workflowRunId}/events?include_state_snapshot=true&continue_on_pause=true`
|
||||
|
||||
const otherOptions: IOtherOptions = {
|
||||
getAbortController: (abortController) => {
|
||||
@@ -1002,9 +1002,7 @@ export const useChat = (
|
||||
}
|
||||
})
|
||||
},
|
||||
onWorkflowPaused: ({ data: workflowPausedData }) => {
|
||||
const resumeUrl = `/workflow/${workflowPausedData.workflow_run_id}/events`
|
||||
sseGet(resumeUrl, {}, otherOptions)
|
||||
onWorkflowPaused: () => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
responseItem.workflowProcess!.status = WorkflowRunningStatus.Paused
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HumanInputFormSubmitData } from '@/app/components/base/chat/chat/answer/human-input-content/type'
|
||||
import type { DeliveryMethod } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
@@ -9,7 +10,7 @@ import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/
|
||||
|
||||
type HumanInputFormListProps = {
|
||||
humanInputFormDataList: HumanInputFormData[]
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise<void>
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
|
||||
}
|
||||
|
||||
const HumanInputFormList = ({
|
||||
@@ -77,12 +78,12 @@ const HumanInputFormList = ({
|
||||
<div className="flex flex-col gap-y-3">
|
||||
{filteredHumanInputFormDataList.map((formData) => (
|
||||
<ContentWrapper
|
||||
key={formData.node_id}
|
||||
key={formData.form_id}
|
||||
nodeTitle={formData.node_title}
|
||||
className="bg-components-panel-bg"
|
||||
>
|
||||
<UnsubmittedHumanInputContent
|
||||
key={formData.node_id}
|
||||
key={formData.form_id}
|
||||
formData={formData}
|
||||
showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip}
|
||||
isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode}
|
||||
|
||||
Reference in New Issue
Block a user