feat: Human Input Node (#32060)

The frontend and backend implementation for the human input node.

Co-authored-by: twwu <twwu@dify.ai>
Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: zhsama <torvalds@linux.do>
This commit is contained in:
QuantumGhost
2026-02-09 14:57:23 +08:00
committed by GitHub
parent 56e3a55023
commit a1fc280102
474 changed files with 32667 additions and 2050 deletions

View File

@@ -8,6 +8,9 @@ import type {
} from '@/types/pipeline'
import type {
AgentLogResponse,
HumanInputFormFilledResponse,
HumanInputFormTimeoutResponse,
HumanInputRequiredResponse,
IterationFinishedResponse,
IterationNextResponse,
IterationStartedResponse,
@@ -21,6 +24,7 @@ import type {
TextChunkResponse,
TextReplaceResponse,
WorkflowFinishedResponse,
WorkflowPausedResponse,
WorkflowStartedResponse,
} from '@/types/workflow'
import Cookies from 'js-cookie'
@@ -29,7 +33,7 @@ import { API_PREFIX, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, IS_CE_EDITION, PASSPORT
import { asyncRunSafe } from '@/utils'
import { basePath } from '@/utils/var'
import { base, ContentType, getBaseOptions } from './fetch'
import { refreshAccessTokenOrRelogin } from './refresh-token'
import { refreshAccessTokenOrReLogin } from './refresh-token'
import { getWebAppPassport } from './webapp-auth'
const TIME_OUT = 100000
@@ -70,6 +74,10 @@ export type IOnLoopNext = (workflowStarted: LoopNextResponse) => void
export type IOnLoopFinished = (workflowFinished: LoopFinishedResponse) => void
export type IOnAgentLog = (agentLog: AgentLogResponse) => void
export type IOHumanInputRequired = (humanInputRequired: HumanInputRequiredResponse) => void
export type IOnHumanInputFormFilled = (humanInputFormFilled: HumanInputFormFilledResponse) => void
export type IOnHumanInputFormTimeout = (humanInputFormTimeout: HumanInputFormTimeoutResponse) => void
export type IOWorkflowPaused = (workflowPaused: WorkflowPausedResponse) => void
export type IOnDataSourceNodeProcessing = (dataSourceNodeProcessing: DataSourceNodeProcessingResponse) => void
export type IOnDataSourceNodeCompleted = (dataSourceNodeCompleted: DataSourceNodeCompletedResponse) => void
export type IOnDataSourceNodeError = (dataSourceNodeError: DataSourceNodeErrorResponse) => void
@@ -113,6 +121,10 @@ export type IOtherOptions = {
onLoopNext?: IOnLoopNext
onLoopFinish?: IOnLoopFinished
onAgentLog?: IOnAgentLog
onHumanInputRequired?: IOHumanInputRequired
onHumanInputFormFilled?: IOnHumanInputFormFilled
onHumanInputFormTimeout?: IOnHumanInputFormTimeout
onWorkflowPaused?: IOWorkflowPaused
// Pipeline data source node run
onDataSourceNodeProcessing?: IOnDataSourceNodeProcessing
@@ -153,6 +165,14 @@ function requiredWebSSOLogin(message?: string, code?: number) {
globalThis.location.href = `${globalThis.location.origin}${basePath}${WBB_APP_LOGIN_PATH}?${params.toString()}`
}
function formatURL(url: string, isPublicAPI: boolean) {
const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX
if (url.startsWith('http://') || url.startsWith('https://'))
return url
const urlWithoutProtocol = url.startsWith('/') ? url : `/${url}`
return `${urlPrefix}${urlWithoutProtocol}`
}
export function format(text: string) {
let res = text.trim()
if (res.startsWith('\n'))
@@ -187,6 +207,10 @@ export const handleStream = (
onTTSEnd?: IOnTTSEnd,
onTextReplace?: IOnTextReplace,
onAgentLog?: IOnAgentLog,
onHumanInputRequired?: IOHumanInputRequired,
onHumanInputFormFilled?: IOnHumanInputFormFilled,
onHumanInputFormTimeout?: IOnHumanInputFormTimeout,
onWorkflowPaused?: IOWorkflowPaused,
onDataSourceNodeProcessing?: IOnDataSourceNodeProcessing,
onDataSourceNodeCompleted?: IOnDataSourceNodeCompleted,
onDataSourceNodeError?: IOnDataSourceNodeError,
@@ -319,6 +343,18 @@ export const handleStream = (
else if (bufferObj.event === 'tts_message_end') {
onTTSEnd?.(bufferObj.message_id, bufferObj.audio)
}
else if (bufferObj.event === 'human_input_required') {
onHumanInputRequired?.(bufferObj as HumanInputRequiredResponse)
}
else if (bufferObj.event === 'human_input_form_filled') {
onHumanInputFormFilled?.(bufferObj as HumanInputFormFilledResponse)
}
else if (bufferObj.event === 'human_input_form_timeout') {
onHumanInputFormTimeout?.(bufferObj as HumanInputFormTimeoutResponse)
}
else if (bufferObj.event === 'workflow_paused') {
onWorkflowPaused?.(bufferObj as WorkflowPausedResponse)
}
else if (bufferObj.event === 'datasource_processing') {
onDataSourceNodeProcessing?.(bufferObj as DataSourceNodeProcessingResponse)
}
@@ -441,6 +477,10 @@ export const ssePost = async (
onLoopStart,
onLoopNext,
onLoopFinish,
onHumanInputRequired,
onHumanInputFormFilled,
onHumanInputFormTimeout,
onWorkflowPaused,
onDataSourceNodeProcessing,
onDataSourceNodeCompleted,
onDataSourceNodeError,
@@ -467,10 +507,7 @@ export const ssePost = async (
getAbortController?.(abortController)
const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX
const urlWithPrefix = (url.startsWith('http://') || url.startsWith('https://'))
? url
: `${urlPrefix}${url.startsWith('/') ? url : `/${url}`}`
const urlWithPrefix = formatURL(url, isPublicAPI)
const { body } = options
if (body)
@@ -495,7 +532,7 @@ export const ssePost = async (
})
}
else {
refreshAccessTokenOrRelogin(TIME_OUT).then(() => {
refreshAccessTokenOrReLogin(TIME_OUT).then(() => {
ssePost(url, fetchOptions, otherOptions)
}).catch((err) => {
console.error(err)
@@ -545,6 +582,157 @@ export const ssePost = async (
onTTSEnd,
onTextReplace,
onAgentLog,
onHumanInputRequired,
onHumanInputFormFilled,
onHumanInputFormTimeout,
onWorkflowPaused,
onDataSourceNodeProcessing,
onDataSourceNodeCompleted,
onDataSourceNodeError,
)
})
.catch((e) => {
if (e.toString() !== 'AbortError: The user aborted a request.' && !e.toString().errorMessage.includes('TypeError: Cannot assign to read only property'))
Toast.notify({ type: 'error', message: e })
onError?.(e)
})
}
export const sseGet = async (
url: string,
fetchOptions: FetchOptionType,
otherOptions: IOtherOptions,
) => {
const {
isPublicAPI = false,
onData,
onCompleted,
onThought,
onFile,
onMessageEnd,
onMessageReplace,
onWorkflowStarted,
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onNodeRetry,
onParallelBranchStarted,
onParallelBranchFinished,
onTextChunk,
onTTSChunk,
onTTSEnd,
onTextReplace,
onAgentLog,
onError,
getAbortController,
onLoopStart,
onLoopNext,
onLoopFinish,
onHumanInputRequired,
onHumanInputFormFilled,
onHumanInputFormTimeout,
onWorkflowPaused,
onDataSourceNodeProcessing,
onDataSourceNodeCompleted,
onDataSourceNodeError,
} = otherOptions
const abortController = new AbortController()
const baseOptions = getBaseOptions()
const shareCode = globalThis.location.pathname.split('/').slice(-1)[0]
const options = Object.assign({}, baseOptions, {
signal: abortController.signal,
headers: new Headers({
[CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME()) || '',
[WEB_APP_SHARE_CODE_HEADER_NAME]: shareCode,
[PASSPORT_HEADER_NAME]: getWebAppPassport(shareCode),
}),
} as RequestInit, fetchOptions)
const contentType = (options.headers as Headers).get('Content-Type')
if (!contentType)
(options.headers as Headers).set('Content-Type', ContentType.json)
getAbortController?.(abortController)
const urlWithPrefix = formatURL(url, isPublicAPI)
globalThis.fetch(urlWithPrefix, options as RequestInit)
.then((res) => {
if (!/^[23]\d{2}$/.test(String(res.status))) {
if (res.status === 401) {
if (isPublicAPI) {
res.json().then((data: { code?: string, message?: string }) => {
if (isPublicAPI) {
if (data.code === 'web_app_access_denied')
requiredWebSSOLogin(data.message, 403)
if (data.code === 'web_sso_auth_required')
requiredWebSSOLogin()
if (data.code === 'unauthorized')
requiredWebSSOLogin()
}
})
}
else {
refreshAccessTokenOrReLogin(TIME_OUT).then(() => {
sseGet(url, fetchOptions, otherOptions)
}).catch((err) => {
console.error(err)
})
}
}
else {
res.json().then((data) => {
Toast.notify({ type: 'error', message: data.message || 'Server Error' })
})
onError?.('Server Error')
}
return
}
return handleStream(
res,
(str: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => {
if (moreInfo.errorMessage) {
onError?.(moreInfo.errorMessage, moreInfo.errorCode)
// TypeError: Cannot assign to read only property ... will happen in page leave, so it should be ignored.
if (moreInfo.errorMessage !== 'AbortError: The user aborted a request.' && !moreInfo.errorMessage.includes('TypeError: Cannot assign to read only property'))
Toast.notify({ type: 'error', message: moreInfo.errorMessage })
return
}
onData?.(str, isFirstMessage, moreInfo)
},
onCompleted,
onThought,
onMessageEnd,
onMessageReplace,
onFile,
onWorkflowStarted,
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onNodeRetry,
onParallelBranchStarted,
onParallelBranchFinished,
onTextChunk,
onTTSChunk,
onTTSEnd,
onTextReplace,
onAgentLog,
onHumanInputRequired,
onHumanInputFormFilled,
onHumanInputFormTimeout,
onWorkflowPaused,
onDataSourceNodeProcessing,
onDataSourceNodeCompleted,
onDataSourceNodeError,
@@ -612,7 +800,7 @@ export const request = async<T>(url: string, options = {}, otherOptions?: IOther
}
// refresh token
const [refreshErr] = await asyncRunSafe(refreshAccessTokenOrRelogin(TIME_OUT))
const [refreshErr] = await asyncRunSafe(refreshAccessTokenOrReLogin(TIME_OUT))
if (refreshErr === null)
return baseFetch<T>(url, options, otherOptionsForBaseFetch)
if (location.pathname !== `${basePath}/signin` || !IS_CE_EDITION) {

View File

@@ -80,7 +80,7 @@ function releaseRefreshLock() {
globalThis.removeEventListener('beforeunload', releaseRefreshLock)
}
export async function refreshAccessTokenOrRelogin(timeout: number) {
export async function refreshAccessTokenOrReLogin(timeout: number) {
return Promise.race([new Promise<void>((resolve, reject) => setTimeout(() => {
releaseRefreshLock()
reject(new Error('request timeout'))

View File

@@ -2,20 +2,10 @@ import type {
IOnCompleted,
IOnData,
IOnError,
IOnIterationFinished,
IOnIterationNext,
IOnIterationStarted,
IOnLoopFinished,
IOnLoopNext,
IOnLoopStarted,
IOnMessageReplace,
IOnNodeFinished,
IOnNodeStarted,
IOnTextChunk,
IOnTextReplace,
IOnWorkflowFinished,
IOnWorkflowStarted,
IOtherOptions,
} from './base'
import type { FormData as HumanInputFormData } from '@/app/(humanInputLayout)/form/[token]/form'
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { AccessMode } from '@/models/access-control'
@@ -95,33 +85,7 @@ export const sendCompletionMessage = async (body: Record<string, any>, { onData,
export const sendWorkflowMessage = async (
body: Record<string, any>,
{
onWorkflowStarted,
onNodeStarted,
onNodeFinished,
onWorkflowFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onTextChunk,
onTextReplace,
}: {
onWorkflowStarted: IOnWorkflowStarted
onNodeStarted: IOnNodeStarted
onNodeFinished: IOnNodeFinished
onWorkflowFinished: IOnWorkflowFinished
onIterationStart: IOnIterationStarted
onIterationNext: IOnIterationNext
onIterationFinish: IOnIterationFinished
onLoopStart: IOnLoopStarted
onLoopNext: IOnLoopNext
onLoopFinish: IOnLoopFinished
onTextChunk: IOnTextChunk
onTextReplace: IOnTextReplace
},
otherOptions: IOtherOptions,
appSourceType: AppSourceType,
appId = '',
) => {
@@ -131,19 +95,8 @@ export const sendWorkflowMessage = async (
response_mode: 'streaming',
},
}, {
onNodeStarted,
onWorkflowStarted,
onWorkflowFinished,
...otherOptions,
isPublicAPI: getIsPublicAPI(appSourceType),
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onTextChunk,
onTextReplace,
})
}
@@ -320,3 +273,14 @@ export const getUserCanAccess = (appId: string, isInstalledApp: boolean) => {
export const getAppAccessModeByAppCode = (appCode: string) => {
return get<{ accessMode: AccessMode }>(`/webapp/access-mode?appCode=${appCode}`)
}
export const getHumanInputForm = (token: string) => {
return get<HumanInputFormData>(`/form/human_input/${token}`)
}
export const submitHumanInputForm = (token: string, data: {
inputs: Record<string, string>
action: string
}) => {
return post(`/form/human_input/${token}`, { body: data })
}

View File

@@ -108,7 +108,7 @@ export const useLangGeniusVersion = (currentVersion?: string | null, enabled?: b
export const useCurrentWorkspace = () => {
return useQuery<ICurrentWorkspace>({
queryKey: commonQueryKeys.currentWorkspace,
queryFn: () => post<ICurrentWorkspace>('/workspaces/current', { body: {} }),
queryFn: () => post<ICurrentWorkspace>('/workspaces/current'),
})
}

View File

@@ -7,6 +7,7 @@ import type {
CompletionConversationsRequest,
CompletionConversationsResponse,
WorkflowLogsResponse,
WorkflowPausedDetailsResponse,
} from '@/models/log'
import { useQuery } from '@tanstack/react-query'
import { get } from './base'
@@ -87,3 +88,18 @@ export const useWorkflowLogs = ({ appId, params }: WorkflowLogsParams) => {
enabled: !!appId,
})
}
// ============ Workflow Pause Details ============
type WorkflowPausedDetailsParams = {
workflowRunId: string
enabled?: boolean
}
export const useWorkflowPausedDetails = ({ workflowRunId, enabled = true }: WorkflowPausedDetailsParams) => {
return useQuery<WorkflowPausedDetailsResponse>({
queryKey: [NAME_SPACE, 'workflow-paused-details', workflowRunId],
queryFn: () => get<WorkflowPausedDetailsResponse>(`/workflow/${workflowRunId}/pause-details`),
enabled: enabled && !!workflowRunId,
})
}

View File

@@ -1,5 +1,6 @@
import type { FormData as HumanInputFormData } from '@/app/(humanInputLayout)/form/[token]/form'
import type { AppConversationData, ConversationItem } from '@/models/share'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import {
AppSourceType,
fetchAppInfo,
@@ -9,6 +10,8 @@ import {
fetchConversations,
generationConversationName,
getAppAccessModeByAppCode,
getHumanInputForm,
submitHumanInputForm,
} from './share'
import { useInvalid } from './use-base'
@@ -49,6 +52,7 @@ export const shareQueryKeys = {
conversationList: (params: ShareConversationsParams) => [NAME_SPACE, 'conversations', params] as const,
chatList: (params: ShareChatListParams) => [NAME_SPACE, 'chatList', params] as const,
conversationName: (params: ShareConversationNameParams) => [NAME_SPACE, 'conversationName', params] as const,
humanInputForm: (token: string) => [NAME_SPACE, 'humanInputForm', token] as const,
}
export const useGetWebAppAccessModeByCode = (code: string | null) => {
@@ -149,3 +153,60 @@ export const useShareConversationName = (params: ShareConversationNameParams, op
export const useInvalidateShareConversations = () => {
return useInvalid(shareQueryKeys.conversations)
}
export class HumanInputFormError extends Error {
code: string
status: number
constructor(code: string, message: string, status: number) {
super(message)
this.name = 'HumanInputFormError'
this.code = code
this.status = status
}
}
export const useGetHumanInputForm = (token: string, options: ShareQueryOptions = {}) => {
const {
enabled = true,
refetchOnReconnect,
refetchOnWindowFocus,
} = options
return useQuery<HumanInputFormData, HumanInputFormError>({
queryKey: shareQueryKeys.humanInputForm(token),
queryFn: async () => {
try {
return await getHumanInputForm(token)
}
catch (error) {
const response = error as Response
if (response.status && response.json) {
const errorData = await response.json() as { code: string, message: string }
throw new HumanInputFormError(errorData.code, errorData.message, response.status)
}
throw error
}
},
enabled: enabled && !!token,
refetchOnReconnect,
refetchOnWindowFocus,
retry: false,
})
}
export type SubmitHumanInputFormParams = {
token: string
data: {
inputs: Record<string, string>
action: string
}
}
export const useSubmitHumanInputForm = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'submit-human-input-form'],
mutationFn: ({ token, data }: SubmitHumanInputFormParams) => {
return submitHumanInputForm(token, data)
},
})
}

View File

@@ -227,3 +227,18 @@ export const useEditInspectorVar = (flowType: FlowType, flowId: string) => {
},
})
}
export const useTestEmailSender = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'test email sender'],
mutationFn: async (data: { appID: string, nodeID: string, deliveryID: string, inputs: Record<string, any> }) => {
const { appID, nodeID, deliveryID, inputs } = data
return post<CommonResponse>(`/apps/${appID}/workflows/draft/human-input/nodes/${nodeID}/delivery-test`, {
body: {
delivery_method_id: deliveryID,
inputs,
},
})
},
})
}

View File

@@ -4,6 +4,7 @@ import type { FlowType } from '@/types/common'
import type {
ConversationVariableResponse,
FetchWorkflowDraftResponse,
HumanInputFormData,
NodesDefaultConfigsResponse,
VarInInspect,
} from '@/types/workflow'
@@ -94,3 +95,30 @@ export const fetchNodeInspectVars = async (flowType: FlowType, flowId: string, n
const { items } = (await get(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/nodes/${nodeId}/variables`)) as { items: VarInInspect[] }
return items
}
export const submitHumanInputForm = (token: string, data: {
inputs: Record<string, string>
action: string
}) => {
return post(`/form/human_input/${token}`, { body: data })
}
export const fetchHumanInputNodeStepRunForm = (
url: string,
data: {
inputs: Record<string, string>
},
) => {
return post<HumanInputFormData>(`${url}/preview`, { body: data })
}
export const submitHumanInputNodeStepRunForm = (
url: string,
data: {
inputs: Record<string, string> | undefined
form_inputs: Record<string, string> | undefined
action: string
},
) => {
return post<CommonResponse>(`${url}/run`, { body: data })
}