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:
-LAN-
2026-08-03 02:24:18 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent 8dbac96621
commit 351577bdb0
86 changed files with 1454 additions and 762 deletions
@@ -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'])
+306 -17
View File
@@ -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(() => {
@@ -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={[]} />)
@@ -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}