From e5a2172a85586c69527ae87724e1a7263269e176 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Mon, 25 Aug 2025 16:17:12 +0800 Subject: [PATCH] human input form display & submit in preview --- .../(humanInputLayout)/form/[token]/form.tsx | 2 +- .../human-input-content}/content-item.tsx | 0 .../human-input-content/human-input-form.tsx | 85 +++++++++ .../chat/answer/human-input-content/index.tsx | 22 +++ .../chat/answer/human-input-content/utils.ts | 30 +++ .../base/chat/chat/answer/index.tsx | 30 ++- .../base/chat/chat/answer/operation.tsx | 21 ++- .../chat/chat/answer/workflow-process.tsx | 15 +- web/app/components/base/chat/chat/index.tsx | 7 + .../components/base/chat/chat/question.tsx | 22 ++- web/app/components/base/chat/chat/type.ts | 4 +- web/app/components/base/chat/types.ts | 2 +- .../panel/debug-and-preview/chat-wrapper.tsx | 16 +- .../workflow/panel/debug-and-preview/hooks.ts | 26 +++ web/app/components/workflow/run/node.tsx | 4 +- web/app/components/workflow/types.ts | 1 + web/service/base.ts | 171 +++++++++++++++++- web/service/refresh-token.ts | 2 +- web/service/workflow.ts | 7 + web/themes/manual-dark.css | 6 + web/themes/manual-light.css | 6 + web/types/workflow.ts | 30 +++ 22 files changed, 467 insertions(+), 42 deletions(-) rename web/app/{(humanInputLayout)/form/[token] => components/base/chat/chat/answer/human-input-content}/content-item.tsx (100%) create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/index.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/utils.ts diff --git a/web/app/(humanInputLayout)/form/[token]/form.tsx b/web/app/(humanInputLayout)/form/[token]/form.tsx index a23335bc96..5000f38443 100644 --- a/web/app/(humanInputLayout)/form/[token]/form.tsx +++ b/web/app/(humanInputLayout)/form/[token]/form.tsx @@ -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' diff --git a/web/app/(humanInputLayout)/form/[token]/content-item.tsx b/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx similarity index 100% rename from web/app/(humanInputLayout)/form/[token]/content-item.tsx rename to web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx diff --git a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx new file mode 100644 index 0000000000..5842c0dcdc --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx @@ -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) => { + setIsSubmitting(true) + await onSubmit?.(formID, { inputs, action: actionID }) + setIsSubmitting(false) + } + + return ( + <> + {contentList.map((content, index) => ( + + ))} +
+ {formData.user_actions.map((action: any) => ( + + ))} +
+ {showTimeout && ( +
+ {formData.timeout_unit === 'day' ? t('share.humanInput.timeoutDay', { count: formData.timeout }) : t('share.humanInput.timeoutHour', { count: formData.timeout })} +
+ )} + + ) +} + +export default React.memo(HumanInputForm) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/index.tsx b/web/app/components/base/chat/chat/answer/human-input-content/index.tsx new file mode 100644 index 0000000000..b52be06747 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/index.tsx @@ -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 ( + <> + + + ) +} + +export default HumanInputContent diff --git a/web/app/components/base/chat/chat/answer/human-input-content/utils.ts b/web/app/components/base/chat/chat/answer/human-input-content/utils.ts new file mode 100644 index 0000000000..26f4ab1152 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/utils.ts @@ -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 = {} + 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 +} diff --git a/web/app/components/base/chat/chat/answer/index.tsx b/web/app/components/base/chat/chat/answer/index.tsx index 3722556931..0945b41b02 100644 --- a/web/app/components/base/chat/chat/answer/index.tsx +++ b/web/app/components/base/chat/chat/answer/index.tsx @@ -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 = ({ item, @@ -50,6 +53,8 @@ const Answer: FC = ({ appData, noChatInput, switchSibling, + hideAvatar, + onHumanInputFormSubmit, }) => { const { t } = useTranslation() const { @@ -61,6 +66,7 @@ const Answer: FC = ({ workflowProcess, allFiles, message_files, + humanInputFormData, } = item const hasAgentThoughts = !!agent_thoughts?.length @@ -109,14 +115,16 @@ const Answer: FC = ({ return (
-
- {answerIcon || } - {responding && ( -
- -
- )} -
+ {!hideAvatar && ( +
+ {answerIcon || } + {responding && ( +
+ +
+ )} +
+ )}
= ({ ) } + {humanInputFormData && ( + + )} { (hasAgentThoughts) && ( = ({ feedback, adminFeedback, agent_thoughts, + humanInputFormData, } = item const [localFeedback, setLocalFeedback] = useState(config?.supportAnnotation ? adminFeedback : feedback) @@ -115,25 +116,27 @@ const Operation: FC = ({ )} {!isOpeningStatement && (
- {(config?.text_to_speech?.enabled) && ( + {(config?.text_to_speech?.enabled) && !humanInputFormData && ( )} - { - copy(content) - Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') }) - }}> - - - {!noChatInput && ( + {!humanInputFormData && ( + { + copy(content) + Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') }) + }}> + + + )} + {!noChatInput && !humanInputFormData && ( onRegenerate?.(item)}> )} - {(config?.supportAnnotation && config.annotation_reply?.enabled) && ( + {(config?.supportAnnotation && config.annotation_reply?.enabled) && !humanInputFormData && ( { 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', )} >
) } + { + suspended && ( + + ) + }
- {t('workflow.common.workflowProcess')} + {!collapse ? t('workflow.common.workflowProcess') : latestNode?.title}
{!readonly && }
diff --git a/web/app/components/base/chat/chat/index.tsx b/web/app/components/base/chat/chat/index.tsx index bee37cf2cd..6d5f9732b9 100644 --- a/web/app/components/base/chat/chat/index.tsx +++ b/web/app/components/base/chat/chat/index.tsx @@ -73,6 +73,8 @@ export type ChatProps = { inputDisabled?: boolean isMobile?: boolean sidebarCollapseState?: boolean + hideAvatar?: boolean + onHumanInputFormSubmit?: (formID: string, formData: any) => void } const Chat: FC = ({ @@ -112,6 +114,8 @@ const Chat: FC = ({ 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 = ({ hideProcessDetail={hideProcessDetail} noChatInput={noChatInput} switchSibling={switchSibling} + hideAvatar={hideAvatar} + onHumanInputFormSubmit={onHumanInputFormSubmit} /> ) } @@ -267,6 +273,7 @@ const Chat: FC = ({ theme={themeBuilder?.theme} enableEdit={config?.questionEditEnable} switchSibling={switchSibling} + hideAvatar={hideAvatar} /> ) }) diff --git a/web/app/components/base/chat/chat/question.tsx b/web/app/components/base/chat/chat/question.tsx index 6630d9bb9d..059dba80bf 100644 --- a/web/app/components/base/chat/chat/question.tsx +++ b/web/app/components/base/chat/chat/question.tsx @@ -32,6 +32,7 @@ type QuestionProps = { theme: Theme | null | undefined enableEdit?: boolean switchSibling?: (siblingMessageId: string) => void + hideAvatar?: boolean } const Question: FC = ({ @@ -40,6 +41,7 @@ const Question: FC = ({ theme, enableEdit = true, switchSibling, + hideAvatar, }) => { const { t } = useTranslation() @@ -162,15 +164,17 @@ const Question: FC = ({
-
- { - questionIcon || ( -
- -
- ) - } -
+ {!hideAvatar && ( +
+ { + questionIcon || ( +
+ +
+ ) + } +
+ )}
) } diff --git a/web/app/components/base/chat/chat/type.ts b/web/app/components/base/chat/chat/type.ts index a9e2ada262..464e502597 100644 --- a/web/app/components/base/chat/chat/type.ts +++ b/web/app/components/base/chat/chat/type.ts @@ -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 = { diff --git a/web/app/components/base/chat/types.ts b/web/app/components/base/chat/types.ts index c463879a53..31ac852194 100644 --- a/web/app/components/base/chat/types.ts +++ b/web/app/components/base/chat/types.ts @@ -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 diff --git a/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx b/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx index 1a97357da5..fa55a1c640 100644 --- a/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx @@ -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 && } @@ -189,6 +201,8 @@ const ChatWrapper = ( showPromptLog chatAnswerContainerInner='!pr-2' switchSibling={setTargetMessageId} + inputDisabled={inputDisabled} + hideAvatar /> {showConversationVariableModal && ( 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, } diff --git a/web/app/components/workflow/run/node.tsx b/web/app/components/workflow/run/node.tsx index 538f9bc618..6d6cec3553 100644 --- a/web/app/components/workflow/run/node.tsx +++ b/web/app/components/workflow/run/node.tsx @@ -6,7 +6,7 @@ import { RiAlertFill, RiArrowRightSLine, RiCheckboxCircleFill, - RiErrorWarningLine, + RiErrorWarningFill, RiLoader2Line, RiPauseCircleFill, } from '@remixicon/react' @@ -148,7 +148,7 @@ const NodePanel: FC = ({ )} {nodeInfo.status === 'failed' && ( - + )} {nodeInfo.status === 'stopped' && ( diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index c766f35e93..325e622668 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -324,6 +324,7 @@ export enum WorkflowRunningStatus { Succeeded = 'succeeded', Failed = 'failed', Stopped = 'stopped', + Suspended = 'suspended', } export enum WorkflowVersion { diff --git a/web/service/base.ts b/web/service/base.ts index aa6c77716f..1cf4c9dc4a 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -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(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(url, options, otherOptionsForBaseFetch) if (location.pathname !== `${basePath}/signin` || !IS_CE_EDITION) { diff --git a/web/service/refresh-token.ts b/web/service/refresh-token.ts index 7eff08b52f..2f06c46c49 100644 --- a/web/service/refresh-token.ts +++ b/web/service/refresh-token.ts @@ -84,7 +84,7 @@ function releaseRefreshLock() { } } -export async function refreshAccessTokenOrRelogin(timeout: number) { +export async function refreshAccessTokenOrReLogin(timeout: number) { return Promise.race([new Promise((resolve, reject) => setTimeout(() => { releaseRefreshLock() reject(new Error('request timeout')) diff --git a/web/service/workflow.ts b/web/service/workflow.ts index 7d504b2f4d..f8d08d7d74 100644 --- a/web/service/workflow.ts +++ b/web/service/workflow.ts @@ -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 + action: string +}) => { + return post(`/form/human_input/${token}`, { body: data }) +} diff --git a/web/themes/manual-dark.css b/web/themes/manual-dark.css index fc9a644207..d701c6c554 100644 --- a/web/themes/manual-dark.css +++ b/web/themes/manual-dark.css @@ -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%); diff --git a/web/themes/manual-light.css b/web/themes/manual-light.css index bb17e0940f..c4342e5667 100644 --- a/web/themes/manual-light.css +++ b/web/themes/manual-light.css @@ -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%); diff --git a/web/types/workflow.ts b/web/types/workflow.ts index 1cbcc942a5..03694b05a4 100644 --- a/web/types/workflow.ts +++ b/web/types/workflow.ts @@ -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