mirror of https://github.com/langgenius/dify.git
human input form display & submit in preview
This commit is contained in:
parent
2d89d59d74
commit
e5a2172a85
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')} />
|
||||
|
|
|
|||
|
|
@ -324,6 +324,7 @@ export enum WorkflowRunningStatus {
|
|||
Succeeded = 'succeeded',
|
||||
Failed = 'failed',
|
||||
Stopped = 'stopped',
|
||||
Suspended = 'suspended',
|
||||
}
|
||||
|
||||
export enum WorkflowVersion {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue