human input form display & submit in preview

This commit is contained in:
JzoNg 2025-08-25 16:17:12 +08:00
parent 2d89d59d74
commit e5a2172a85
22 changed files with 467 additions and 42 deletions

View File

@ -11,7 +11,7 @@ import Loading from '@/app/components/base/loading'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import ContentItem from './content-item'
import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import type { GeneratedFormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
import { getHumanInputForm, submitHumanInputForm } from '@/service/share'

View File

@ -0,0 +1,85 @@
'use client'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import ContentItem from './content-item'
import type { GeneratedFormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
import { getButtonStyle, initializeInputs, splitByOutputVar } from './utils'
// import { getHumanInputForm, submitHumanInputForm } from '@/service/share'
// import cn from '@/utils/classnames'
export type FormData = {
form_id: string
site: any
form_content: string
inputs: GeneratedFormInputItem[]
user_actions: UserAction[]
timeout: number
timeout_unit: 'hour' | 'day'
}
export type Props = {
formData: FormData
showTimeout?: boolean
onSubmit?: (formID: string, data: any) => void
}
const HumanInputForm = ({
formData,
showTimeout,
onSubmit,
}: Props) => {
const { t } = useTranslation()
const formID = formData.form_id
const defaultInputs = initializeInputs(formData.inputs)
const contentList = splitByOutputVar(formData.form_content)
const [inputs, setInputs] = useState(defaultInputs)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleInputsChange = (name: string, value: any) => {
setInputs(prev => ({
...prev,
[name]: value,
}))
}
const submit = async (formID: string, actionID: string, inputs: Record<string, any>) => {
setIsSubmitting(true)
await onSubmit?.(formID, { inputs, action: actionID })
setIsSubmitting(false)
}
return (
<>
{contentList.map((content, index) => (
<ContentItem
key={index}
content={content}
formInputFields={formData.inputs}
inputs={inputs}
onInputChange={handleInputsChange}
/>
))}
<div className='flex flex-wrap gap-1 py-1'>
{formData.user_actions.map((action: any) => (
<Button
key={action.id}
disabled={isSubmitting}
variant={getButtonStyle(action.button_style) as any}
onClick={() => submit(formID, action.id, inputs)}
>
{action.title}
</Button>
))}
</div>
{showTimeout && (
<div className='system-xs-regular mt-1 text-text-tertiary'>
{formData.timeout_unit === 'day' ? t('share.humanInput.timeoutDay', { count: formData.timeout }) : t('share.humanInput.timeoutHour', { count: formData.timeout })}
</div>
)}
</>
)
}
export default React.memo(HumanInputForm)

View File

@ -0,0 +1,22 @@
import HumanInputForm from './human-input-form'
import type { FormData } from './human-input-form'
type Props = {
formData: FormData
showDebugTip?: boolean
showTimeout?: boolean
onSubmit?: (formID: string, data: any) => void
}
const HumanInputContent = ({ formData, onSubmit }: Props) => {
return (
<>
<HumanInputForm
formData={formData}
onSubmit={onSubmit}
/>
</>
)
}
export default HumanInputContent

View File

@ -0,0 +1,30 @@
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import type { GeneratedFormInputItem } from '@/app/components/workflow/nodes/human-input/types'
export const getButtonStyle = (style: UserActionButtonType) => {
if (style === UserActionButtonType.Primary)
return 'primary'
if (style === UserActionButtonType.Default)
return 'secondary'
if (style === UserActionButtonType.Accent)
return 'secondary-accent'
if (style === UserActionButtonType.Ghost)
return 'ghost'
}
export const splitByOutputVar = (content: string): string[] => {
const outputVarRegex = /({{#\$output\.[^#]+#}})/g
const parts = content.split(outputVarRegex)
return parts.filter(part => part.length > 0)
}
export const initializeInputs = (formInputs: GeneratedFormInputItem[]) => {
const initialInputs: Record<string, any> = {}
formInputs.forEach((item) => {
if (item.type === 'text-input' || item.type === 'paragraph')
initialInputs[item.output_variable_name] = ''
else
initialInputs[item.output_variable_name] = undefined
})
return initialInputs
}

View File

@ -22,6 +22,7 @@ import AnswerIcon from '@/app/components/base/answer-icon'
import cn from '@/utils/classnames'
import { FileList } from '@/app/components/base/file-uploader'
import ContentSwitch from '../content-switch'
import HumanInputContent from './human-input-content'
type AnswerProps = {
item: ChatItem
@ -36,6 +37,8 @@ type AnswerProps = {
appData?: AppData
noChatInput?: boolean
switchSibling?: (siblingMessageId: string) => void
hideAvatar?: boolean
onHumanInputFormSubmit?: (formID: string, formData: any) => void
}
const Answer: FC<AnswerProps> = ({
item,
@ -50,6 +53,8 @@ const Answer: FC<AnswerProps> = ({
appData,
noChatInput,
switchSibling,
hideAvatar,
onHumanInputFormSubmit,
}) => {
const { t } = useTranslation()
const {
@ -61,6 +66,7 @@ const Answer: FC<AnswerProps> = ({
workflowProcess,
allFiles,
message_files,
humanInputFormData,
} = item
const hasAgentThoughts = !!agent_thoughts?.length
@ -109,14 +115,16 @@ const Answer: FC<AnswerProps> = ({
return (
<div className='mb-2 flex last:mb-0'>
<div className='relative h-10 w-10 shrink-0'>
{answerIcon || <AnswerIcon />}
{responding && (
<div className='absolute left-[-3px] top-[-3px] flex h-4 w-4 items-center rounded-full border-[0.5px] border-divider-subtle bg-background-section-burn pl-[6px] shadow-xs'>
<LoadingAnim type='avatar' />
</div>
)}
</div>
{!hideAvatar && (
<div className='relative h-10 w-10 shrink-0'>
{answerIcon || <AnswerIcon />}
{responding && (
<div className='absolute left-[-3px] top-[-3px] flex h-4 w-4 items-center rounded-full border-[0.5px] border-divider-subtle bg-background-section-burn pl-[6px] shadow-xs'>
<LoadingAnim type='avatar' />
</div>
)}
</div>
)}
<div className='chat-answer-container group ml-4 w-0 grow pb-4' ref={containerRef}>
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
<div
@ -170,6 +178,12 @@ const Answer: FC<AnswerProps> = ({
<BasicContent item={item} />
)
}
{humanInputFormData && (
<HumanInputContent
formData={humanInputFormData as any} // TODO type
onSubmit={onHumanInputFormSubmit}
/>
)}
{
(hasAgentThoughts) && (
<AgentContent

View File

@ -60,6 +60,7 @@ const Operation: FC<OperationProps> = ({
feedback,
adminFeedback,
agent_thoughts,
humanInputFormData,
} = item
const [localFeedback, setLocalFeedback] = useState(config?.supportAnnotation ? adminFeedback : feedback)
@ -115,25 +116,27 @@ const Operation: FC<OperationProps> = ({
)}
{!isOpeningStatement && (
<div className='ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex'>
{(config?.text_to_speech?.enabled) && (
{(config?.text_to_speech?.enabled) && !humanInputFormData && (
<NewAudioButton
id={id}
value={content}
voice={config?.text_to_speech?.voice}
/>
)}
<ActionButton onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') })
}}>
<RiClipboardLine className='h-4 w-4' />
</ActionButton>
{!noChatInput && (
{!humanInputFormData && (
<ActionButton onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') })
}}>
<RiClipboardLine className='h-4 w-4' />
</ActionButton>
)}
{!noChatInput && !humanInputFormData && (
<ActionButton onClick={() => onRegenerate?.(item)}>
<RiResetLeftLine className='h-4 w-4' />
</ActionButton>
)}
{(config?.supportAnnotation && config.annotation_reply?.enabled) && (
{(config?.supportAnnotation && config.annotation_reply?.enabled) && !humanInputFormData && (
<AnnotationCtrlButton
appId={config?.appId || ''}
messageId={id}

View File

@ -6,6 +6,7 @@ import {
RiArrowRightSLine,
RiErrorWarningFill,
RiLoader2Line,
RiPauseCircleFill,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import type { ChatItem, WorkflowProcess } from '../../types'
@ -34,6 +35,8 @@ const WorkflowProcessItem = ({
const running = data.status === WorkflowRunningStatus.Running
const succeeded = data.status === WorkflowRunningStatus.Succeeded
const failed = data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped
const suspended = data.status === WorkflowRunningStatus.Suspended
const latestNode = data.tracing[data.tracing.length - 1]
useEffect(() => {
setCollapse(!expand)
@ -47,7 +50,10 @@ const WorkflowProcessItem = ({
running && !collapse && 'bg-background-section-burn',
succeeded && !collapse && 'bg-state-success-hover',
failed && !collapse && 'bg-state-destructive-hover',
collapse && 'bg-workflow-process-bg',
suspended && !collapse && 'bg-state-warning-hover',
collapse && !failed && !suspended && 'bg-workflow-process-bg',
collapse && suspended && 'bg-workflow-process-suspended-bg',
collapse && failed && 'bg-workflow-process-failed-bg',
)}
>
<div
@ -69,8 +75,13 @@ const WorkflowProcessItem = ({
<RiErrorWarningFill className='mr-1 h-3.5 w-3.5 shrink-0 text-text-destructive' />
)
}
{
suspended && (
<RiPauseCircleFill className='mr-1 h-3.5 w-3.5 shrink-0 text-text-warning-secondary' />
)
}
<div className={cn('system-xs-medium text-text-secondary', !collapse && 'grow')}>
{t('workflow.common.workflowProcess')}
{!collapse ? t('workflow.common.workflowProcess') : latestNode?.title}
</div>
{!readonly && <RiArrowRightSLine className={cn('ml-1 h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />}
</div>

View File

@ -73,6 +73,8 @@ export type ChatProps = {
inputDisabled?: boolean
isMobile?: boolean
sidebarCollapseState?: boolean
hideAvatar?: boolean
onHumanInputFormSubmit?: (formID: string, formData: any) => void
}
const Chat: FC<ChatProps> = ({
@ -112,6 +114,8 @@ const Chat: FC<ChatProps> = ({
inputDisabled,
isMobile,
sidebarCollapseState,
hideAvatar,
onHumanInputFormSubmit,
}) => {
const { t } = useTranslation()
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
@ -256,6 +260,8 @@ const Chat: FC<ChatProps> = ({
hideProcessDetail={hideProcessDetail}
noChatInput={noChatInput}
switchSibling={switchSibling}
hideAvatar={hideAvatar}
onHumanInputFormSubmit={onHumanInputFormSubmit}
/>
)
}
@ -267,6 +273,7 @@ const Chat: FC<ChatProps> = ({
theme={themeBuilder?.theme}
enableEdit={config?.questionEditEnable}
switchSibling={switchSibling}
hideAvatar={hideAvatar}
/>
)
})

View File

@ -32,6 +32,7 @@ type QuestionProps = {
theme: Theme | null | undefined
enableEdit?: boolean
switchSibling?: (siblingMessageId: string) => void
hideAvatar?: boolean
}
const Question: FC<QuestionProps> = ({
@ -40,6 +41,7 @@ const Question: FC<QuestionProps> = ({
theme,
enableEdit = true,
switchSibling,
hideAvatar,
}) => {
const { t } = useTranslation()
@ -162,15 +164,17 @@ const Question: FC<QuestionProps> = ({
</div>
<div className='mt-1 h-[18px]' />
</div>
<div className='h-10 w-10 shrink-0'>
{
questionIcon || (
<div className='h-full w-full rounded-full border-[0.5px] border-black/5'>
<User className='h-full w-full' />
</div>
)
}
</div>
{!hideAvatar && (
<div className='h-10 w-10 shrink-0'>
{
questionIcon || (
<div className='h-full w-full rounded-full border-[0.5px] border-black/5'>
<User className='h-full w-full' />
</div>
)
}
</div>
)}
</div>
)
}

View File

@ -2,7 +2,7 @@ import type { TypeWithI18N } from '@/app/components/header/account-setting/model
import type { Annotation, MessageRating } from '@/models/log'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { InputVarType } from '@/app/components/workflow/types'
import type { FileResponse } from '@/types/workflow'
import type { FileResponse, HumanInputFormData } from '@/types/workflow'
export type MessageMore = {
time: string
@ -103,6 +103,8 @@ export type IChatItem = {
siblingIndex?: number
prevSibling?: string
nextSibling?: string
// for human input
humanInputFormData?: HumanInputFormData
}
export type Metadata = {

View File

@ -85,7 +85,7 @@ export type OnSend = {
(message: string, files: FileEntity[] | undefined, isRegenerate: boolean, lastAnswer?: ChatItem | null): void
}
export type OnRegenerate = (chatItem: ChatItem) => void
export type OnRegenerate = (chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => void
export type Callback = {
onSuccess: () => void

View File

@ -1,6 +1,6 @@
import { memo, useCallback, useEffect, useImperativeHandle, useMemo } from 'react'
import { useNodes } from 'reactflow'
import { BlockEnum } from '../../types'
import { BlockEnum, WorkflowRunningStatus } from '../../types'
import {
useStore,
useWorkflowStore,
@ -87,6 +87,7 @@ const ChatWrapper = (
handleSend,
handleRestart,
setTargetMessageId,
handleSubmitHumanInputForm,
} = useChat(
config,
{
@ -127,6 +128,16 @@ const ChatWrapper = (
)
}, [chatList, doSend])
const doHumanInputFormSubmit = useCallback(async (formID: string, formData: any) => {
// Handle human input form submission
await handleSubmitHumanInputForm(formID, formData)
}, [handleSubmitHumanInputForm])
const inputDisabled = useMemo(() => {
const latestMessage = chatList[chatList.length - 1]
return latestMessage.isAnswer && (latestMessage.workflowProcess?.status === WorkflowRunningStatus.Suspended)
}, [chatList])
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v: any) => {
if (v.type === EVENT_WORKFLOW_STOP)
@ -174,6 +185,7 @@ const ChatWrapper = (
inputsForm={(startVariables || []) as any}
onRegenerate={doRegenerate}
onStopResponding={handleStop}
onHumanInputFormSubmit={doHumanInputFormSubmit}
chatNode={(
<>
{showInputsFieldsPanel && <UserInput />}
@ -189,6 +201,8 @@ const ChatWrapper = (
showPromptLog
chatAnswerContainerInner='!pr-2'
switchSibling={setTargetMessageId}
inputDisabled={inputDisabled}
hideAvatar
/>
{showConversationVariableModal && (
<ConversationVariableModal

View File

@ -35,6 +35,7 @@ import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { getThreadMessages } from '@/app/components/base/chat/utils'
import { useInvalidAllLastRun } from '@/service/use-workflow'
import { useParams } from 'next/navigation'
import { submitHumanInputForm } from '@/service/workflow'
type GetAbortController = (abortController: AbortController) => void
type SendCallback = {
@ -499,10 +500,34 @@ export const useChat = (
})
}
},
onHumanInputRequired: ({ data }) => {
responseItem.humanInputFormData = data
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
onWorkflowSuspended: ({ data }) => {
console.log(data.suspended_at_node_ids)
responseItem.workflowProcess!.status = WorkflowRunningStatus.Suspended
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
},
)
}, [threadMessages, chatTree.length, updateCurrentQAOnTree, handleResponding, formSettings?.inputsForm, handleRun, notify, t, config?.suggested_questions_after_answer?.enabled, fetchInspectVars, invalidAllLastRun])
const handleSubmitHumanInputForm = async (formID: string, formData: any) => {
await submitHumanInputForm(formID, formData)
// TODO deal with success
}
return {
conversationId: conversationId.current,
chatList,
@ -510,6 +535,7 @@ export const useChat = (
handleSend,
handleStop,
handleRestart,
handleSubmitHumanInputForm,
isResponding,
suggestedQuestions,
}

View File

@ -6,7 +6,7 @@ import {
RiAlertFill,
RiArrowRightSLine,
RiCheckboxCircleFill,
RiErrorWarningLine,
RiErrorWarningFill,
RiLoader2Line,
RiPauseCircleFill,
} from '@remixicon/react'
@ -148,7 +148,7 @@ const NodePanel: FC<Props> = ({
<RiCheckboxCircleFill className='ml-2 h-3.5 w-3.5 shrink-0 text-text-success' />
)}
{nodeInfo.status === 'failed' && (
<RiErrorWarningLine className='ml-2 h-3.5 w-3.5 shrink-0 text-text-warning' />
<RiErrorWarningFill className='ml-2 h-3.5 w-3.5 shrink-0 text-text-destructive' />
)}
{nodeInfo.status === 'stopped' && (
<RiAlertFill className={cn('ml-2 h-4 w-4 shrink-0 text-text-warning-secondary', inMessage && 'h-3.5 w-3.5')} />

View File

@ -324,6 +324,7 @@ export enum WorkflowRunningStatus {
Succeeded = 'succeeded',
Failed = 'failed',
Stopped = 'stopped',
Suspended = 'suspended',
}
export enum WorkflowVersion {

View File

@ -1,11 +1,12 @@
import { API_PREFIX, IS_CE_EDITION, PUBLIC_API_PREFIX } from '@/config'
import { refreshAccessTokenOrRelogin } from './refresh-token'
import { refreshAccessTokenOrReLogin } from './refresh-token'
import Toast from '@/app/components/base/toast'
import { basePath } from '@/utils/var'
import type { AnnotationReply, MessageEnd, MessageReplace, ThoughtItem } from '@/app/components/base/chat/chat/type'
import type { VisionFile } from '@/types/app'
import type {
AgentLogResponse,
HumanInputRequiredResponse,
IterationFinishedResponse,
IterationNextResponse,
IterationStartedResponse,
@ -20,6 +21,7 @@ import type {
TextReplaceResponse,
WorkflowFinishedResponse,
WorkflowStartedResponse,
WorkflowSuspendedResponse,
} from '@/types/workflow'
import { removeAccessToken } from '@/app/components/share/utils'
import type { FetchOptionType, ResponseError } from './fetch'
@ -63,6 +65,9 @@ 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 IOWorkflowSuspended = (workflowSuspended: WorkflowSuspendedResponse) => void
export type IOtherOptions = {
isPublicAPI?: boolean
isMarketplaceAPI?: boolean
@ -97,6 +102,8 @@ export type IOtherOptions = {
onLoopNext?: IOnLoopNext
onLoopFinish?: IOnLoopFinished
onAgentLog?: IOnAgentLog
onHumanInputRequired?: IOHumanInputRequired
onWorkflowSuspended?: IOWorkflowSuspended
}
function unicodeToChar(text: string) {
@ -118,6 +125,14 @@ function requiredWebSSOLogin(message?: string, code?: number) {
globalThis.location.href = `${globalThis.location.origin}${basePath}/webapp-signin?${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'))
@ -152,6 +167,8 @@ const handleStream = (
onTTSEnd?: IOnTTSEnd,
onTextReplace?: IOnTextReplace,
onAgentLog?: IOnAgentLog,
onHumanInputRequired?: IOHumanInputRequired,
onWorkflowSuspended?: IOWorkflowSuspended,
) => {
if (!response.ok)
throw new Error('Network response was not ok')
@ -270,6 +287,12 @@ 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 === 'workflow_suspended') {
onWorkflowSuspended?.(bufferObj as WorkflowSuspendedResponse)
}
}
})
buffer = lines[lines.length - 1]
@ -363,6 +386,8 @@ export const ssePost = async (
onLoopStart,
onLoopNext,
onLoopFinish,
onHumanInputRequired,
onWorkflowSuspended,
} = otherOptions
const abortController = new AbortController()
@ -382,10 +407,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)
@ -398,7 +420,7 @@ export const ssePost = async (
.then((res) => {
if (!/^[23]\d{2}$/.test(String(res.status))) {
if (res.status === 401) {
refreshAccessTokenOrRelogin(TIME_OUT).then(() => {
refreshAccessTokenOrReLogin(TIME_OUT).then(() => {
ssePost(url, fetchOptions, otherOptions)
}).catch(() => {
res.json().then((data: any) => {
@ -460,6 +482,141 @@ export const ssePost = async (
onTTSEnd,
onTextReplace,
onAgentLog,
onHumanInputRequired,
onWorkflowSuspended,
)
}).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,
onWorkflowSuspended,
} = otherOptions
const abortController = new AbortController()
const token = localStorage.getItem('console_token')
const options = Object.assign({}, baseOptions, {
signal: abortController.signal,
headers: new Headers({
Authorization: `Bearer ${token}`,
}),
} 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)
const accessToken = await getAccessToken(isPublicAPI)
; (options.headers as Headers).set('Authorization', `Bearer ${accessToken}`)
globalThis.fetch(urlWithPrefix, options as RequestInit)
.then((res) => {
if (!/^[23]\d{2}$/.test(String(res.status))) {
if (res.status === 401) {
refreshAccessTokenOrReLogin(TIME_OUT).then(() => {
sseGet(url, fetchOptions, otherOptions)
}).catch(() => {
res.json().then((data: any) => {
if (isPublicAPI) {
if (data.code === 'web_app_access_denied')
requiredWebSSOLogin(data.message, 403)
if (data.code === 'web_sso_auth_required') {
removeAccessToken()
requiredWebSSOLogin()
}
if (data.code === 'unauthorized') {
removeAccessToken()
requiredWebSSOLogin()
}
}
})
})
}
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,
onWorkflowSuspended,
)
}).catch((e) => {
if (e.toString() !== 'AbortError: The user aborted a request.' && !e.toString().errorMessage.includes('TypeError: Cannot assign to read only property'))
@ -524,7 +681,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

@ -84,7 +84,7 @@ function 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

@ -99,3 +99,10 @@ export const fetchNodeInspectVars = async (appId: string, nodeId: string): Promi
const { items } = (await get(`apps/${appId}/workflows/draft/nodes/${nodeId}/variables`)) as { items: VarInInspect[] }
return items
}
export const submitHumanInputForm = (token: string, data: {
inputs: Record<string, any>
action: string
}) => {
return post(`/form/human_input/${token}`, { body: data })
}

View File

@ -11,6 +11,12 @@ html[data-theme="dark"] {
--color-workflow-process-bg: linear-gradient(90deg,
rgba(24, 24, 27, 0.25) 0%,
rgba(24, 24, 27, 0.04) 100%);
--color-workflow-process-suspended-bg: linear-gradient(90deg,
rgba(247, 144, 9, 0.14) 0%,
rgba(247, 144, 9, 0.00) 100%);
--color-workflow-process-failed-bg: linear-gradient(90deg,
rgba(240, 68, 56, 0.14) 0%,
rgba(240, 68, 56, 0.00) 100%);
--color-workflow-run-failed-bg: linear-gradient(98deg,
rgba(240, 68, 56, 0.12) 0%,
rgba(0, 0, 0, 0) 26.01%);

View File

@ -11,6 +11,12 @@ html[data-theme="light"] {
--color-workflow-process-bg: linear-gradient(90deg,
rgba(200, 206, 218, 0.2) 0%,
rgba(200, 206, 218, 0.04) 100%);
--color-workflow-process-suspended-bg: linear-gradient(90deg,
#FFFAEB 0%,
rgba(255, 250, 235, 0.00) 100%);
--color-workflow-process-failed-bg: linear-gradient(90deg,
#FEF3F2 0%,
rgba(254, 243, 242, 0.00) 100%);
--color-workflow-run-failed-bg: linear-gradient(98deg,
rgba(240, 68, 56, 0.10) 0%,
rgba(255, 255, 255, 0) 26.01%);

View File

@ -4,6 +4,7 @@ import type { TransferMethod } from '@/types/app'
import type { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import type { BeforeRunFormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form'
import type { SpecialResultPanelProps } from '@/app/components/workflow/run/special-result-panel'
import type { GeneratedFormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { MutableRefObject } from 'react'
export type AgentLogItem = {
@ -159,6 +160,18 @@ export type WorkflowStartedResponse = {
}
}
export type WorkflowSuspendedResponse = {
task_id: string
workflow_run_id: string
event: string
data: {
id: string
workflow_id: string
created_at: number
suspended_at_node_ids: string[]
}
}
export type WorkflowFinishedResponse = {
task_id: string
workflow_run_id: string
@ -290,6 +303,23 @@ export type AgentLogResponse = {
data: AgentLogItemWithChildren
}
export type HumanInputFormData = {
id: string
workflow_id: string
form_id: string
node_id: string
form_content: string
inputs: GeneratedFormInputItem[]
web_app_form_token: string
}
export type HumanInputRequiredResponse = {
task_id: string
workflow_run_id: string
event: string
data: HumanInputFormData
}
export type WorkflowRunHistory = {
id: string
version: string