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) {