chore(web): pre-align HITL frontend from build/feat/hitl

This commit is contained in:
yyh
2026-02-09 15:34:15 +08:00
parent b289e6a2b6
commit ca243d7efc
261 changed files with 14122 additions and 1328 deletions

View File

@ -2,6 +2,7 @@ import type { FileEntity } from '../../file-uploader/types'
import type {
ChatConfig,
ChatItem,
ChatItemInTree,
OnSend,
} from '../types'
import { useCallback, useEffect, useMemo, useState } from 'react'
@ -16,7 +17,9 @@ import {
fetchSuggestedQuestions,
getUrl,
stopChatMessageResponding,
submitHumanInputForm,
} from '@/service/share'
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
import { TransferMethod } from '@/types/app'
import { cn } from '@/utils/classnames'
import { formatBooleanInputs } from '@/utils/model-config'
@ -73,9 +76,9 @@ const ChatWrapper = () => {
}, [appParams, currentConversationItem?.introduction])
const {
chatList,
setTargetMessageId,
handleSend,
handleStop,
handleSwitchSibling,
isResponding: respondingState,
suggestedQuestions,
} = useChat(
@ -122,8 +125,11 @@ const ChatWrapper = () => {
if (fileIsUploading)
return true
if (chatList.some(item => item.isAnswer && item.humanInputFormDataList && item.humanInputFormDataList.length > 0))
return true
return false
}, [inputsFormValue, inputsForms, allInputsHidden])
}, [allInputsHidden, inputsForms, chatList, inputsFormValue])
useEffect(() => {
if (currentChatInstanceRef.current)
@ -134,6 +140,40 @@ const ChatWrapper = () => {
setIsResponding(respondingState)
}, [respondingState, setIsResponding])
// Resume paused workflows when chat history is loaded
useEffect(() => {
if (!appPrevChatTree || appPrevChatTree.length === 0)
return
// Find the last answer item with workflow_run_id that needs resumption (DFS - find deepest first)
let lastPausedNode: ChatItemInTree | undefined
const findLastPausedWorkflow = (nodes: ChatItemInTree[]) => {
nodes.forEach((node) => {
// DFS: recurse to children first
if (node.children && node.children.length > 0)
findLastPausedWorkflow(node.children)
// Track the last node with humanInputFormDataList
if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0)
lastPausedNode = node
})
}
findLastPausedWorkflow(appPrevChatTree)
// Only resume the last paused workflow
if (lastPausedNode) {
handleSwitchSibling(
lastPausedNode.id,
{
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
isPublicAPI: appSourceType === AppSourceType.webApp,
},
)
}
}, [])
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
const data: any = {
query: message,
@ -149,10 +189,10 @@ const ChatWrapper = () => {
{
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted,
isPublicAPI: !isInstalledApp,
isPublicAPI: appSourceType === AppSourceType.webApp,
},
)
}, [chatList, handleNewConversationCompleted, handleSend, currentConversationId, currentConversationInputs, newConversationInputs, isInstalledApp, appId])
}, [inputsForms, currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, isHistoryConversation, handleNewConversationCompleted])
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
@ -160,12 +200,27 @@ const ChatWrapper = () => {
doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null)
}, [chatList, doSend])
const doSwitchSibling = useCallback((siblingMessageId: string) => {
handleSwitchSibling(siblingMessageId, {
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
isPublicAPI: appSourceType === AppSourceType.webApp,
})
}, [handleSwitchSibling, currentConversationId, handleNewConversationCompleted, appSourceType, appId])
const messageList = useMemo(() => {
if (currentConversationId || chatList.length > 1)
return chatList
// Without messages we are in the welcome screen, so hide the opening statement from chatlist
return chatList.filter(item => !item.isOpeningStatement)
}, [chatList])
}, [chatList, currentConversationId])
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: any) => {
if (isInstalledApp)
await submitHumanInputFormService(formToken, formData)
else
await submitHumanInputForm(formToken, formData)
}, [isInstalledApp])
const [collapsed, setCollapsed] = useState(!!currentConversationId)
@ -274,6 +329,7 @@ const ChatWrapper = () => {
inputsForm={inputsForms}
onRegenerate={doRegenerate}
onStopResponding={handleStop}
onHumanInputFormSubmit={handleSubmitHumanInputForm}
chatNode={(
<>
{chatNode}
@ -286,7 +342,7 @@ const ChatWrapper = () => {
answerIcon={answerIcon}
hideProcessDetail
themeBuilder={themeBuilder}
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
switchSibling={doSwitchSibling}
inputDisabled={inputDisabled}
sidebarCollapseState={sidebarCollapseState}
questionIcon={

View File

@ -1,3 +1,4 @@
import type { ExtraContent } from '../chat/type'
import type {
Callback,
ChatConfig,
@ -9,6 +10,7 @@ import type {
AppData,
ConversationItem,
} from '@/models/share'
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
import { useLocalStorageState } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
@ -57,6 +59,24 @@ function getFormattedChatList(messages: any[]) {
parentMessageId: item.parent_message_id || undefined,
})
const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || []
const humanInputFormDataList: HumanInputFormData[] = []
const humanInputFilledFormDataList: HumanInputFilledFormData[] = []
let workflowRunId = ''
if (item.status === 'paused') {
item.extra_contents?.forEach((content: ExtraContent) => {
if (content.type === 'human_input' && !content.submitted) {
humanInputFormDataList.push(content.form_definition)
workflowRunId = content.workflow_run_id
}
})
}
else if (item.status === 'normal') {
item.extra_contents?.forEach((content: ExtraContent) => {
if (content.type === 'human_input' && content.submitted) {
humanInputFilledFormDataList.push(content.form_submission_data)
}
})
}
newChatList.push({
id: item.id,
content: buildLLMGenerationItemsFromHistorySequence(item).message,
@ -67,6 +87,9 @@ function getFormattedChatList(messages: any[]) {
citation: item.retriever_resources,
message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id, upload_file_id: item.upload_file_id }))),
parentMessageId: `question-${item.id}`,
humanInputFormDataList,
humanInputFilledFormDataList,
workflow_run_id: workflowRunId,
})
})
return newChatList

View File

@ -0,0 +1,54 @@
import type { ContentItemProps } from './type'
import * as React from 'react'
import { useMemo } from 'react'
import { Markdown } from '@/app/components/base/markdown'
import Textarea from '@/app/components/base/textarea'
const ContentItem = ({
content,
formInputFields,
inputs,
onInputChange,
}: ContentItemProps) => {
const isInputField = (field: string) => {
const outputVarRegex = /\{\{#\$output\.[^#]+#\}\}/
return outputVarRegex.test(field)
}
const extractFieldName = (str: string): string => {
const outputVarRegex = /\{\{#\$output\.([^#]+)#\}\}/
const match = str.match(outputVarRegex)
return match ? match[1] : ''
}
const fieldName = useMemo(() => {
return extractFieldName(content)
}, [content])
const formInputField = useMemo(() => {
return formInputFields.find(field => field.output_variable_name === fieldName)
}, [formInputFields, fieldName])
if (!isInputField(content)) {
return (
<Markdown content={content} />
)
}
if (!formInputField)
return null
return (
<div className="py-3">
{formInputField.type === 'paragraph' && (
<Textarea
className="h-[104px] sm:text-xs"
value={inputs[fieldName]}
onChange={(e) => { onInputChange(fieldName, e.target.value) }}
/>
)}
</div>
)
}
export default React.memo(ContentItem)

View File

@ -0,0 +1,64 @@
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import { useCallback, useState } from 'react'
import BlockIcon from '@/app/components/workflow/block-icon'
import { BlockEnum } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
type ContentWrapperProps = {
nodeTitle: string
children: React.ReactNode
showExpandIcon?: boolean
className?: string
expanded?: boolean
}
const ContentWrapper = ({
nodeTitle,
children,
showExpandIcon = false,
className,
expanded = false,
}: ContentWrapperProps) => {
const [isExpanded, setIsExpanded] = useState(expanded)
const handleToggleExpand = useCallback(() => {
setIsExpanded(!isExpanded)
}, [isExpanded])
return (
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-background-section p-2 shadow-md', className)}>
<div className="flex items-center gap-2 p-2">
{/* node icon */}
<BlockIcon type={BlockEnum.HumanInput} className="shrink-0" />
{/* node name */}
<div
className="system-sm-semibold-uppercase grow truncate text-text-primary"
title={nodeTitle}
>
{nodeTitle}
</div>
{showExpandIcon && (
<div className="shrink-0 cursor-pointer" onClick={handleToggleExpand}>
{
isExpanded
? (
<RiArrowDownSLine className="size-4" />
)
: (
<RiArrowRightSLine className="size-4" />
)
}
</div>
)}
</div>
{(!showExpandIcon || isExpanded) && (
<div className="px-2 py-1">
{/* human input form content */}
{children}
</div>
)}
</div>
)
}
export default ContentWrapper

View File

@ -0,0 +1,30 @@
import type { ExecutedAction as ExecutedActionType } from './type'
import { memo } from 'react'
import { Trans } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
type ExecutedActionProps = {
executedAction: ExecutedActionType
}
const ExecutedAction = ({
executedAction,
}: ExecutedActionProps) => {
return (
<div className="flex flex-col gap-y-1 py-1">
<Divider className="mb-2 mt-1 w-[30px]" />
<div className="system-xs-regular flex items-center gap-x-1 text-text-tertiary">
<TriggerAll className="size-3.5 shrink-0" />
<Trans
i18nKey="nodes.humanInput.userActions.triggered"
ns="workflow"
components={{ strong: <span className="system-xs-medium text-text-secondary"></span> }}
values={{ actionName: executedAction.id }}
/>
</div>
</div>
)
}
export default memo(ExecutedAction)

View File

@ -0,0 +1,46 @@
'use client'
import { RiAlertFill, RiTimeLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useLocale } from '@/context/i18n'
import { cn } from '@/utils/classnames'
import { getRelativeTime, isRelativeTimeSameOrAfter } from './utils'
type ExpirationTimeProps = {
expirationTime: number
}
const ExpirationTime = ({
expirationTime,
}: ExpirationTimeProps) => {
const { t } = useTranslation()
const locale = useLocale()
const relativeTime = getRelativeTime(expirationTime, locale)
const isSameOrAfter = isRelativeTimeSameOrAfter(expirationTime)
return (
<div
className={cn(
'system-xs-regular mt-1 flex items-center gap-x-1 text-text-tertiary',
!isSameOrAfter && 'text-text-warning',
)}
>
{
isSameOrAfter
? (
<>
<RiTimeLine className="size-3.5" />
<span>{t('humanInput.expirationTimeNowOrFuture', { relativeTime, ns: 'share' })}</span>
</>
)
: (
<>
<RiAlertFill className="size-3.5" />
<span>{t('humanInput.expiredTip', { ns: 'share' })}</span>
</>
)
}
</div>
)
}
export default ExpirationTime

View File

@ -0,0 +1,61 @@
'use client'
import type { HumanInputFormProps } from './type'
import type { ButtonProps } from '@/app/components/base/button'
import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
import * as React from 'react'
import { useCallback, useState } from 'react'
import Button from '@/app/components/base/button'
import ContentItem from './content-item'
import { getButtonStyle, initializeInputs, splitByOutputVar } from './utils'
const HumanInputForm = ({
formData,
onSubmit,
}: HumanInputFormProps) => {
const formToken = formData.form_token
const defaultInputs = initializeInputs(formData.inputs, formData.resolved_default_values || {})
const contentList = splitByOutputVar(formData.form_content)
const [inputs, setInputs] = useState(defaultInputs)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleInputsChange = useCallback((name: string, value: string) => {
setInputs(prev => ({
...prev,
[name]: value,
}))
}, [])
const submit = async (formToken: string, actionID: string, inputs: Record<string, string>) => {
setIsSubmitting(true)
await onSubmit?.(formToken, { 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.actions.map((action: UserAction) => (
<Button
key={action.id}
disabled={isSubmitting}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
onClick={() => submit(formToken, action.id, inputs)}
>
{action.title}
</Button>
))}
</div>
</>
)
}
export default React.memo(HumanInputForm)

View File

@ -0,0 +1,16 @@
import * as React from 'react'
import { Markdown } from '@/app/components/base/markdown'
type SubmittedContentProps = {
content: string
}
const SubmittedContent = ({
content,
}: SubmittedContentProps) => {
return (
<Markdown content={content} />
)
}
export default React.memo(SubmittedContent)

View File

@ -0,0 +1,25 @@
import type { SubmittedHumanInputContentProps } from './type'
import { useMemo } from 'react'
import ExecutedAction from './executed-action'
import SubmittedContent from './submitted-content'
export const SubmittedHumanInputContent = ({
formData,
}: SubmittedHumanInputContentProps) => {
const { rendered_content, action_id, action_text } = formData
const executedAction = useMemo(() => {
return {
id: action_id,
title: action_text,
}
}, [action_id, action_text])
return (
<>
<SubmittedContent content={rendered_content} />
{/* Executed Action */}
<ExecutedAction executedAction={executedAction} />
</>
)
}

View File

@ -0,0 +1,43 @@
import { memo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { useSelector as useAppSelector } from '@/context/app-context'
type TipsProps = {
showEmailTip: boolean
isEmailDebugMode: boolean
showDebugModeTip: boolean
}
const Tips = ({
showEmailTip,
isEmailDebugMode,
showDebugModeTip,
}: TipsProps) => {
const { t } = useTranslation()
const email = useAppSelector(s => s.userProfile.email)
return (
<>
<Divider className="!my-2 w-[30px]" />
<div className="space-y-1 pt-1">
{showEmailTip && !isEmailDebugMode && (
<div className="system-xs-regular text-text-secondary">{t('common.humanInputEmailTip', { ns: 'workflow' })}</div>
)}
{showEmailTip && isEmailDebugMode && (
<div className="system-xs-regular text-text-secondary">
<Trans
i18nKey="common.humanInputEmailTipInDebugMode"
ns="workflow"
components={{ email: <span className="system-xs-semibold"></span> }}
values={{ email }}
/>
</div>
)}
{showDebugModeTip && <div className="system-xs-medium text-text-warning">{t('common.humanInputWebappTip', { ns: 'workflow' })}</div>}
</div>
</>
)
}
export default memo(Tips)

View File

@ -0,0 +1,31 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
export type ExecutedAction = {
id: string
title: string
}
export type UnsubmittedHumanInputContentProps = {
formData: HumanInputFormData
showEmailTip?: boolean
isEmailDebugMode?: boolean
showDebugModeTip?: boolean
onSubmit?: (formToken: string, data: { inputs: Record<string, string>, action: string }) => Promise<void>
}
export type SubmittedHumanInputContentProps = {
formData: HumanInputFilledFormData
}
export type HumanInputFormProps = {
formData: HumanInputFormData
onSubmit?: (formToken: string, data: { inputs: Record<string, string>, action: string }) => Promise<void>
}
export type ContentItemProps = {
content: string
formInputFields: FormInputItem[]
inputs: Record<string, string>
onInputChange: (name: string, value: string) => void
}

View File

@ -0,0 +1,36 @@
import type { UnsubmittedHumanInputContentProps } from './type'
import ExpirationTime from './expiration-time'
import HumanInputForm from './human-input-form'
import Tips from './tips'
export const UnsubmittedHumanInputContent = ({
formData,
showEmailTip = false,
isEmailDebugMode = false,
showDebugModeTip = false,
onSubmit,
}: UnsubmittedHumanInputContentProps) => {
const { expiration_time } = formData
return (
<>
{/* Form */}
<HumanInputForm
formData={formData}
onSubmit={onSubmit}
/>
{/* Tips */}
{(showEmailTip || showDebugModeTip) && (
<Tips
showEmailTip={showEmailTip}
isEmailDebugMode={isEmailDebugMode}
showDebugModeTip={showDebugModeTip}
/>
)}
{/* Expiration Time */}
{typeof expiration_time === 'number' && (
<ExpirationTime expirationTime={expiration_time * 1000} />
)}
</>
)
}

View File

@ -0,0 +1,64 @@
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Locale } from '@/i18n-config'
import dayjs from 'dayjs'
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
import relativeTime from 'dayjs/plugin/relativeTime'
import utc from 'dayjs/plugin/utc'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import 'dayjs/locale/en'
import 'dayjs/locale/zh-cn'
import 'dayjs/locale/ja'
dayjs.extend(utc)
dayjs.extend(relativeTime)
dayjs.extend(isSameOrAfter)
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: FormInputItem[], defaultValues: Record<string, string> = {}) => {
const initialInputs: Record<string, any> = {}
formInputs.forEach((item) => {
if (item.type === 'text-input' || item.type === 'paragraph')
initialInputs[item.output_variable_name] = item.default.type === 'variable' ? defaultValues[item.output_variable_name] || '' : item.default.value
else
initialInputs[item.output_variable_name] = undefined
})
return initialInputs
}
const localeMap: Record<string, string> = {
'en-US': 'en',
'zh-Hans': 'zh-cn',
'ja-JP': 'ja',
}
export const getRelativeTime = (
utcTimestamp: string | number,
locale: Locale = 'en-US',
) => {
const dayjsLocale = localeMap[locale] ?? 'en'
return dayjs
.utc(utcTimestamp)
.locale(dayjsLocale)
.fromNow()
}
export const isRelativeTimeSameOrAfter = (utcTimestamp: string | number) => {
return dayjs.utc(utcTimestamp).isSameOrAfter(dayjs())
}

View File

@ -0,0 +1,32 @@
import type { HumanInputFilledFormData } from '@/types/workflow'
import ContentWrapper from './human-input-content/content-wrapper'
import { SubmittedHumanInputContent } from './human-input-content/submitted'
type HumanInputFilledFormListProps = {
humanInputFilledFormDataList: HumanInputFilledFormData[]
}
const HumanInputFilledFormList = ({
humanInputFilledFormDataList,
}: HumanInputFilledFormListProps) => {
return (
<div className="mt-2 flex flex-col gap-y-2">
{
humanInputFilledFormDataList.map(formData => (
<ContentWrapper
key={formData.node_id}
nodeTitle={formData.node_title}
showExpandIcon
>
<SubmittedHumanInputContent
key={formData.node_id}
formData={formData}
/>
</ContentWrapper>
))
}
</div>
)
}
export default HumanInputFilledFormList

View File

@ -0,0 +1,70 @@
import type { DeliveryMethod, HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
import type { Node } from '@/app/components/workflow/types'
import type { HumanInputFormData } from '@/types/workflow'
import { useMemo } from 'react'
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
import ContentWrapper from './human-input-content/content-wrapper'
import { UnsubmittedHumanInputContent } from './human-input-content/unsubmitted'
type HumanInputFormListProps = {
humanInputFormDataList: HumanInputFormData[]
onHumanInputFormSubmit?: (formToken: string, formData: { inputs: Record<string, string>, action: string }) => Promise<void>
getHumanInputNodeData?: (nodeID: string) => Node<HumanInputNodeType> | undefined
}
const HumanInputFormList = ({
humanInputFormDataList,
onHumanInputFormSubmit,
getHumanInputNodeData,
}: HumanInputFormListProps) => {
const deliveryMethodsConfig = useMemo((): Record<string, { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }> => {
if (!humanInputFormDataList.length)
return {}
return humanInputFormDataList.reduce((acc, formData) => {
const deliveryMethodsConfig = getHumanInputNodeData?.(formData.node_id)?.data.delivery_methods || []
if (!deliveryMethodsConfig.length) {
acc[formData.node_id] = {
showEmailTip: false,
isEmailDebugMode: false,
showDebugModeTip: false,
}
return acc
}
const isWebappEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.WebApp && method.enabled)
const isEmailEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.enabled)
const isEmailDebugMode = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.config?.debug_mode)
acc[formData.node_id] = {
showEmailTip: isEmailEnabled,
isEmailDebugMode,
showDebugModeTip: !isWebappEnabled,
}
return acc
}, {} as Record<string, { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }>)
}, [getHumanInputNodeData, humanInputFormDataList])
const filteredHumanInputFormDataList = humanInputFormDataList.filter(formData => formData.display_in_ui)
return (
<div className="mt-2 flex flex-col gap-y-2">
{
filteredHumanInputFormDataList.map(formData => (
<ContentWrapper
key={formData.form_id}
nodeTitle={formData.node_title}
>
<UnsubmittedHumanInputContent
key={formData.form_id}
formData={formData}
showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip}
isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode}
showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip}
onSubmit={onHumanInputFormSubmit}
/>
</ContentWrapper>
))
}
</div>
)
}
export default HumanInputFormList

View File

@ -16,9 +16,12 @@ import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
import { FileList } from '@/app/components/base/file-uploader'
import { cn } from '@/utils/classnames'
import ContentSwitch from '../content-switch'
import { useChatContext } from '../context'
import AgentContent from './agent-content'
import BasicContent from './basic-content'
import GenerationContent from './generation-content'
import HumanInputFilledFormList from './human-input-filled-form-list'
import HumanInputFormList from './human-input-form-list'
import More from './more'
import Operation from './operation'
import SuggestedQuestions from './suggested-questions'
@ -37,6 +40,8 @@ type AnswerProps = {
appData?: AppData
noChatInput?: boolean
switchSibling?: (siblingMessageId: string) => void
hideAvatar?: boolean
onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise<void>
}
const Answer: FC<AnswerProps> = ({
item,
@ -51,6 +56,8 @@ const Answer: FC<AnswerProps> = ({
appData,
noChatInput,
switchSibling,
hideAvatar,
onHumanInputFormSubmit,
}) => {
const { t } = useTranslation()
const {
@ -63,13 +70,22 @@ const Answer: FC<AnswerProps> = ({
allFiles,
message_files,
llmGenerationItems,
humanInputFormDataList,
humanInputFilledFormDataList,
} = item
const hasAgentThoughts = !!agent_thoughts?.length
const hasHumanInputs = !!humanInputFormDataList?.length || !!humanInputFilledFormDataList?.length
const [containerWidth, setContainerWidth] = useState(0)
const [contentWidth, setContentWidth] = useState(0)
const [humanInputFormContainerWidth, setHumanInputFormContainerWidth] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const humanInputFormContainerRef = useRef<HTMLDivElement>(null)
const {
getHumanInputNodeData,
} = useChatContext()
const getContainerWidth = () => {
if (containerRef.current)
@ -89,12 +105,23 @@ const Answer: FC<AnswerProps> = ({
getContentWidth()
}, [responding])
const getHumanInputFormContainerWidth = () => {
if (humanInputFormContainerRef.current)
setHumanInputFormContainerWidth(humanInputFormContainerRef.current?.clientWidth)
}
useEffect(() => {
if (hasHumanInputs)
getHumanInputFormContainerWidth()
}, [hasHumanInputs])
// Recalculate contentWidth when content changes (e.g., SVG preview/source toggle)
useEffect(() => {
if (!containerRef.current)
return
const resizeObserver = new ResizeObserver(() => {
getContentWidth()
getHumanInputFormContainerWidth()
})
resizeObserver.observe(containerRef.current)
return () => {
@ -120,120 +147,294 @@ 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" />
{!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}>
{/* Block 1: Workflow Process + Human Input Forms */}
{hasHumanInputs && (
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
<div
ref={humanInputFormContainerRef}
className={cn('body-lg-regular relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary')}
>
{
!responding && contentIsEmpty && !hasAgentThoughts && (
<Operation
hasWorkflowProcess={!!workflowProcess}
maxSize={containerWidth - humanInputFormContainerWidth - 4}
contentWidth={humanInputFormContainerWidth}
item={item}
question={question}
index={index}
showPromptLog={showPromptLog}
noChatInput={noChatInput}
/>
)
}
{/** Render workflow process */}
{
workflowProcess && (
<WorkflowProcessItem
data={workflowProcess}
item={item}
hideProcessDetail={hideProcessDetail}
readonly={hideProcessDetail && appData ? !appData.site?.show_workflow_steps : undefined}
/>
)
}
{
humanInputFormDataList && humanInputFormDataList.length > 0 && (
<HumanInputFormList
humanInputFormDataList={humanInputFormDataList}
onHumanInputFormSubmit={onHumanInputFormSubmit}
getHumanInputNodeData={getHumanInputNodeData}
/>
)
}
{
humanInputFilledFormDataList && humanInputFilledFormDataList.length > 0 && (
<HumanInputFilledFormList
humanInputFilledFormDataList={humanInputFilledFormDataList}
/>
)
}
{
typeof item.siblingCount === 'number'
&& item.siblingCount > 1
&& !responding
&& contentIsEmpty
&& !hasAgentThoughts
&& (
<ContentSwitch
count={item.siblingCount}
currentIndex={item.siblingIndex}
prevDisabled={!item.prevSibling}
nextDisabled={!item.nextSibling}
switchSibling={handleSwitchSibling}
/>
)
}
</div>
</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
ref={contentRef}
className={cn('body-lg-regular relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary', workflowProcess && 'w-full')}
>
{
!responding && (
<Operation
hasWorkflowProcess={!!workflowProcess}
maxSize={containerWidth - contentWidth - 4}
contentWidth={contentWidth}
item={item}
question={question}
index={index}
showPromptLog={showPromptLog}
noChatInput={noChatInput}
/>
)
}
{/** Render workflow process */}
{
workflowProcess && (
<WorkflowProcessItem
data={workflowProcess}
item={item}
hideProcessDetail={hideProcessDetail}
readonly={hideProcessDetail && appData ? !appData.site?.show_workflow_steps : undefined}
/>
)
}
{
generationContentRenderIsUsed && (
<GenerationContent llmGenerationItems={llmGenerationItems} />
)
}
{
responding && contentIsEmpty && !hasAgentThoughts && (
<div className="flex h-5 w-6 items-center justify-center">
<LoadingAnim type="text" />
</div>
)
}
{
!contentIsEmpty && !hasAgentThoughts && !generationContentRenderIsUsed && (
<BasicContent item={item} />
)
}
{
(hasAgentThoughts) && (
<AgentContent
item={item}
responding={responding}
content={content}
/>
)
}
{
!!allFiles?.length && (
<FileList
className="my-1"
files={allFiles}
showDeleteAction={false}
showDownloadAction
canPreview
/>
)
}
{
!!message_files?.length && (
<FileList
className="my-1"
files={message_files}
showDeleteAction={false}
showDownloadAction
canPreview
/>
)
}
{
annotation?.id && annotation.authorName && (
<EditTitle
className="mt-1"
title={t('editBy', { ns: 'appAnnotation', author: annotation.authorName })}
/>
)
}
<SuggestedQuestions item={item} />
{
!!citation?.length && !responding && (
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
)
}
{
!!(item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined) && (
<ContentSwitch
count={item.siblingCount}
currentIndex={item.siblingIndex}
prevDisabled={!item.prevSibling}
nextDisabled={!item.nextSibling}
switchSibling={handleSwitchSibling}
/>
)
}
{/* Block 2: Response Content (when human inputs exist) */}
{hasHumanInputs && (responding || !contentIsEmpty || hasAgentThoughts || generationContentRenderIsUsed) && (
<div className={cn('group relative mt-2 pr-10', chatAnswerContainerInner)}>
<div className="absolute -top-2 left-6 h-3 w-0.5 bg-chat-answer-human-input-form-divider-bg" />
<div
ref={contentRef}
className="body-lg-regular relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary"
>
{
!responding && (
<Operation
hasWorkflowProcess={!!workflowProcess}
maxSize={containerWidth - contentWidth - 4}
contentWidth={contentWidth}
item={item}
question={question}
index={index}
showPromptLog={showPromptLog}
noChatInput={noChatInput}
/>
)
}
{
generationContentRenderIsUsed && (
<GenerationContent llmGenerationItems={llmGenerationItems} />
)
}
{
responding && contentIsEmpty && !hasAgentThoughts && (
<div className="flex h-5 w-6 items-center justify-center">
<LoadingAnim type="text" />
</div>
)
}
{
!contentIsEmpty && !hasAgentThoughts && !generationContentRenderIsUsed && (
<BasicContent item={item} />
)
}
{
hasAgentThoughts && (
<AgentContent
item={item}
responding={responding}
content={content}
/>
)
}
{
!!allFiles?.length && (
<FileList
className="my-1"
files={allFiles}
showDeleteAction={false}
showDownloadAction
canPreview
/>
)
}
{
!!message_files?.length && (
<FileList
className="my-1"
files={message_files}
showDeleteAction={false}
showDownloadAction
canPreview
/>
)
}
{
annotation?.id && annotation.authorName && (
<EditTitle
className="mt-1"
title={t('editBy', { ns: 'appAnnotation', author: annotation.authorName })}
/>
)
}
<SuggestedQuestions item={item} />
{
!!citation?.length && !responding && (
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
)
}
{
typeof item.siblingCount === 'number'
&& item.siblingCount > 1
&& (
<ContentSwitch
count={item.siblingCount}
currentIndex={item.siblingIndex}
prevDisabled={!item.prevSibling}
nextDisabled={!item.nextSibling}
switchSibling={handleSwitchSibling}
/>
)
}
</div>
</div>
</div>
)}
{/* Original single block layout (when no human inputs) */}
{!hasHumanInputs && (
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
<div
ref={contentRef}
className={cn('body-lg-regular relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary', workflowProcess && 'w-full')}
>
{
!responding && (
<Operation
hasWorkflowProcess={!!workflowProcess}
maxSize={containerWidth - contentWidth - 4}
contentWidth={contentWidth}
item={item}
question={question}
index={index}
showPromptLog={showPromptLog}
noChatInput={noChatInput}
/>
)
}
{/** Render workflow process */}
{
workflowProcess && (
<WorkflowProcessItem
data={workflowProcess}
item={item}
hideProcessDetail={hideProcessDetail}
readonly={hideProcessDetail && appData ? !appData.site?.show_workflow_steps : undefined}
/>
)
}
{
generationContentRenderIsUsed && (
<GenerationContent llmGenerationItems={llmGenerationItems} />
)
}
{
responding && contentIsEmpty && !hasAgentThoughts && (
<div className="flex h-5 w-6 items-center justify-center">
<LoadingAnim type="text" />
</div>
)
}
{
!contentIsEmpty && !hasAgentThoughts && !generationContentRenderIsUsed && (
<BasicContent item={item} />
)
}
{
hasAgentThoughts && (
<AgentContent
item={item}
responding={responding}
content={content}
/>
)
}
{
!!allFiles?.length && (
<FileList
className="my-1"
files={allFiles}
showDeleteAction={false}
showDownloadAction
canPreview
/>
)
}
{
!!message_files?.length && (
<FileList
className="my-1"
files={message_files}
showDeleteAction={false}
showDownloadAction
canPreview
/>
)
}
{
annotation?.id && annotation.authorName && (
<EditTitle
className="mt-1"
title={t('editBy', { ns: 'appAnnotation', author: annotation.authorName })}
/>
)
}
<SuggestedQuestions item={item} />
{
!!citation?.length && !responding && (
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
)
}
{
typeof item.siblingCount === 'number'
&& item.siblingCount > 1 && (
<ContentSwitch
count={item.siblingCount}
currentIndex={item.siblingIndex}
prevDisabled={!item.prevSibling}
nextDisabled={!item.nextSibling}
switchSibling={handleSwitchSibling}
/>
)
}
</div>
</div>
)}
<More more={more} />
</div>
</div>

View File

@ -69,6 +69,7 @@ const Operation: FC<OperationProps> = ({
feedback,
adminFeedback,
agent_thoughts,
humanInputFormDataList,
} = item
const [userLocalFeedback, setUserLocalFeedback] = useState(feedback)
const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback)
@ -186,7 +187,7 @@ const Operation: FC<OperationProps> = ({
)}
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
>
{shouldShowUserFeedbackBar && (
{shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
<div className={cn(
'ml-1 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',
hasUserFeedback ? 'flex' : 'hidden group-hover:flex',
@ -226,7 +227,7 @@ const Operation: FC<OperationProps> = ({
)}
</div>
)}
{shouldShowAdminFeedbackBar && (
{shouldShowAdminFeedbackBar && !humanInputFormDataList?.length && (
<div className={cn(
'ml-1 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',
(hasAdminFeedback || hasUserFeedback) ? 'flex' : 'hidden group-hover:flex',
@ -305,26 +306,28 @@ 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 && !humanInputFormDataList?.length) && (
<NewAudioButton
id={id}
value={content}
voice={config?.text_to_speech?.voice}
/>
)}
<ActionButton onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
}}
>
<RiClipboardLine className="h-4 w-4" />
</ActionButton>
{!humanInputFormDataList?.length && (
<ActionButton onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
}}
>
<RiClipboardLine className="h-4 w-4" />
</ActionButton>
)}
{!noChatInput && (
<ActionButton onClick={() => onRegenerate?.(item)}>
<RiResetLeftLine className="h-4 w-4" />
</ActionButton>
)}
{(config?.supportAnnotation && config.annotation_reply?.enabled) && (
{config?.supportAnnotation && config.annotation_reply?.enabled && !humanInputFormDataList?.length && (
<AnnotationCtrlButton
appId={config?.appId || ''}
messageId={id}

View File

@ -3,6 +3,7 @@ import {
RiArrowRightSLine,
RiErrorWarningFill,
RiLoader2Line,
RiPauseCircleFill,
} from '@remixicon/react'
import {
useEffect,
@ -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 paused = data.status === WorkflowRunningStatus.Paused
const latestNode = data.tracing[data.tracing.length - 1]
useEffect(() => {
setCollapse(!expand)
@ -50,7 +53,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',
paused && !collapse && 'bg-state-warning-hover',
collapse && !failed && !paused && 'bg-workflow-process-bg',
collapse && paused && 'bg-workflow-process-paused-bg',
collapse && failed && 'bg-workflow-process-failed-bg',
)}
>
<div
@ -72,8 +78,13 @@ const WorkflowProcessItem = ({
<RiErrorWarningFill className="mr-1 h-3.5 w-3.5 shrink-0 text-text-destructive" />
)
}
{
paused && (
<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('common.workflowProcess', { ns: 'workflow' })}
{!collapse ? t('common.workflowProcess', { ns: 'workflow' }) : latestNode?.title}
</div>
<RiArrowRightSLine className={cn('ml-1 h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
</div>

View File

@ -16,7 +16,8 @@ export type ChatContextValue = Pick<ChatProps, 'config'
| 'onAnnotationAdded'
| 'onAnnotationRemoved'
| 'disableFeedback'
| 'onFeedback'> & {
| 'onFeedback'
| 'getHumanInputNodeData'> & {
readonly?: boolean
}
@ -45,6 +46,7 @@ export const ChatContextProvider = ({
onAnnotationRemoved,
disableFeedback,
onFeedback,
getHumanInputNodeData,
}: ChatContextProviderProps) => {
return (
<ChatContext.Provider value={{
@ -62,6 +64,7 @@ export const ChatContextProvider = ({
onAnnotationRemoved,
disableFeedback,
onFeedback,
getHumanInputNodeData,
}}
>
{children}

View File

@ -0,0 +1,141 @@
import { act, renderHook } from '@testing-library/react'
import { vi } from 'vitest'
import { useChat } from './hooks'
const { mockSseGet, mockSsePost } = vi.hoisted(() => ({
mockSseGet: vi.fn(),
mockSsePost: vi.fn(),
}))
vi.mock('next/navigation', () => ({
useParams: () => ({}),
usePathname: () => '/app/test',
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: () => '12:00 PM',
}),
}))
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: vi.fn(),
}),
}))
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
AudioPlayerManager: {
getInstance: () => ({
getAudioPlayer: vi.fn(),
}),
},
}))
vi.mock('@/app/components/base/file-uploader/utils', () => ({
getProcessedFiles: (files: unknown[]) => files,
getProcessedFilesFromResponse: (files: unknown[]) => files,
}))
vi.mock('@/service/base', () => ({
sseGet: (...args: unknown[]) => mockSseGet(...args),
ssePost: (...args: unknown[]) => mockSsePost(...args),
}))
type HitlTreeNode = {
id: string
content: string
isAnswer: boolean
workflow_run_id?: string
humanInputFormDataList?: Array<{ node_id: string }>
children?: HitlTreeNode[]
}
describe('useChat HITL regression', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should subscribe with include_state_snapshot when switching to sibling with pending human input', () => {
const prevChatTree: HitlTreeNode[] = [
{
id: 'question-1',
content: 'Q1',
isAnswer: false,
children: [
{
id: 'answer-1',
content: '',
isAnswer: true,
workflow_run_id: 'workflow-run-1',
humanInputFormDataList: [{ node_id: 'node-1' }],
children: [],
},
],
},
]
const { result } = renderHook(() => useChat(
undefined,
{ inputs: {}, inputsForm: [] },
prevChatTree as unknown as NonNullable<Parameters<typeof useChat>[2]>,
))
act(() => {
result.current.handleSwitchSibling('answer-1', {})
})
expect(mockSseGet).toHaveBeenCalledWith(
'/workflow/workflow-run-1/events?include_state_snapshot=true',
{},
expect.any(Object),
)
})
it('should open paused workflow event stream when workflow_paused event arrives in send flow', () => {
mockSsePost.mockImplementation((_, __, otherOptions: {
onWorkflowStarted?: (payload: { workflow_run_id: string, task_id: string, conversation_id?: string, message_id?: string }) => void
onWorkflowPaused?: (payload: { data: { workflow_run_id: string } }) => void
}) => {
otherOptions.onWorkflowStarted?.({
workflow_run_id: 'workflow-run-2',
task_id: 'task-1',
conversation_id: 'conversation-1',
message_id: 'message-1',
})
otherOptions.onWorkflowPaused?.({
data: {
workflow_run_id: 'workflow-run-2',
},
})
})
const { result } = renderHook(() => useChat(
undefined,
{ inputs: {}, inputsForm: [] },
[],
))
act(() => {
result.current.handleSend(
'/chat-messages',
{
query: 'hello',
},
{},
)
})
expect(mockSseGet).toHaveBeenCalledWith(
'/workflow/workflow-run-2/events',
{},
expect.any(Object),
)
})
})

File diff suppressed because it is too large Load Diff

View File

@ -75,6 +75,9 @@ export type ChatProps = {
noSpacing?: boolean
inputDisabled?: boolean
sidebarCollapseState?: boolean
hideAvatar?: boolean
onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise<void>
getHumanInputNodeData?: (nodeID: string) => any
}
const Chat: FC<ChatProps> = ({
@ -116,6 +119,9 @@ const Chat: FC<ChatProps> = ({
noSpacing,
inputDisabled,
sidebarCollapseState,
hideAvatar,
onHumanInputFormSubmit,
getHumanInputNodeData,
}) => {
const { t } = useTranslation()
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
@ -265,6 +271,7 @@ const Chat: FC<ChatProps> = ({
onAnnotationRemoved={onAnnotationRemoved}
disableFeedback={disableFeedback}
onFeedback={onFeedback}
getHumanInputNodeData={getHumanInputNodeData}
>
<div className={cn('relative h-full', isTryApp && 'flex flex-col')}>
<div
@ -295,6 +302,8 @@ const Chat: FC<ChatProps> = ({
hideProcessDetail={hideProcessDetail}
noChatInput={noChatInput}
switchSibling={switchSibling}
hideAvatar={hideAvatar}
onHumanInputFormSubmit={onHumanInputFormSubmit}
/>
)
}
@ -306,6 +315,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()
@ -174,15 +176,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,13 @@ import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { InputVarType } from '@/app/components/workflow/types'
import type { Annotation, MessageRating } from '@/models/log'
import type { FileResponse, IconObject, LLMGenerationItem } from '@/types/workflow'
import type {
FileResponse,
HumanInputFilledFormData,
HumanInputFormData,
IconObject,
LLMGenerationItem,
} from '@/types/workflow'
export type MessageMore = {
time: string
@ -64,6 +70,19 @@ export type CitationItem = {
word_count: number
}
export type ExtraContent
= {
type: 'human_input'
submitted: false
form_definition: HumanInputFormData
workflow_run_id: string
}
| {
type: 'human_input'
submitted: true
form_submission_data: HumanInputFilledFormData
}
export type IChatItem = {
id: string
content: string
@ -105,6 +124,10 @@ export type IChatItem = {
prevSibling?: string
nextSibling?: string
llmGenerationItems?: LLMGenerationItem[]
// for human input
humanInputFormDataList?: HumanInputFormData[]
humanInputFilledFormDataList?: HumanInputFilledFormData[]
extra_contents?: ExtraContent[]
}
export type Metadata = {

View File

@ -2,6 +2,7 @@ import type { FileEntity } from '../../file-uploader/types'
import type {
ChatConfig,
ChatItem,
ChatItemInTree,
OnSend,
} from '../types'
import { useCallback, useEffect, useMemo, useState } from 'react'
@ -17,7 +18,9 @@ import {
fetchSuggestedQuestions,
getUrl,
stopChatMessageResponding,
submitHumanInputForm,
} from '@/service/share'
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
import { TransferMethod } from '@/types/app'
import { cn } from '@/utils/classnames'
import Avatar from '../../avatar'
@ -70,9 +73,9 @@ const ChatWrapper = () => {
}, [appParams, currentConversationItem?.introduction])
const {
chatList,
setTargetMessageId,
handleSend,
handleStop,
handleSwitchSibling,
isResponding: respondingState,
suggestedQuestions,
} = useChat(
@ -130,6 +133,40 @@ const ChatWrapper = () => {
setIsResponding(respondingState)
}, [respondingState, setIsResponding])
// Resume paused workflows when chat history is loaded
useEffect(() => {
if (!appPrevChatList || appPrevChatList.length === 0)
return
// Find the last answer item with workflow_run_id that needs resumption (DFS - find deepest first)
let lastPausedNode: ChatItemInTree | undefined
const findLastPausedWorkflow = (nodes: ChatItemInTree[]) => {
nodes.forEach((node) => {
// DFS: recurse to children first
if (node.children && node.children.length > 0)
findLastPausedWorkflow(node.children)
// Track the last node with humanInputFormDataList
if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0)
lastPausedNode = node
})
}
findLastPausedWorkflow(appPrevChatList)
// Only resume the last paused workflow
if (lastPausedNode) {
handleSwitchSibling(
lastPausedNode.id,
{
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
isPublicAPI: appSourceType === AppSourceType.webApp,
},
)
}
}, [])
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
const data: any = {
query: message,
@ -147,7 +184,7 @@ const ChatWrapper = () => {
isPublicAPI: appSourceType === AppSourceType.webApp,
},
)
}, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, isInstalledApp, appId, handleNewConversationCompleted])
}, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, handleNewConversationCompleted])
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
@ -155,6 +192,14 @@ const ChatWrapper = () => {
doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null)
}, [chatList, doSend])
const doSwitchSibling = useCallback((siblingMessageId: string) => {
handleSwitchSibling(siblingMessageId, {
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
isPublicAPI: appSourceType === AppSourceType.webApp,
})
}, [handleSwitchSibling, appSourceType, appId, currentConversationId, handleNewConversationCompleted])
const messageList = useMemo(() => {
if (currentConversationId || chatList.length > 1)
return chatList
@ -178,6 +223,13 @@ const ChatWrapper = () => {
}
}, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden])
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: any) => {
if (isInstalledApp)
await submitHumanInputFormService(formToken, formData)
else
await submitHumanInputForm(formToken, formData)
}, [isInstalledApp])
const welcome = useMemo(() => {
const welcomeMessage = chatList.find(item => item.isOpeningStatement)
if (respondingState)
@ -223,7 +275,7 @@ const ChatWrapper = () => {
</div>
</div>
)
}, [appData?.site, chatList, collapsed, currentConversationId, inputsForms.length, respondingState, allInputsHidden])
}, [chatList, respondingState, currentConversationId, collapsed, inputsForms.length, allInputsHidden, appData?.site, isMobile])
const answerIcon = isDify()
? <LogoAvatar className="relative shrink-0" />
@ -253,6 +305,7 @@ const ChatWrapper = () => {
inputsForm={inputsForms}
onRegenerate={doRegenerate}
onStopResponding={handleStop}
onHumanInputFormSubmit={handleSubmitHumanInputForm}
chatNode={(
<>
{chatNode}
@ -266,7 +319,7 @@ const ChatWrapper = () => {
answerIcon={answerIcon}
hideProcessDetail
themeBuilder={themeBuilder}
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
switchSibling={doSwitchSibling}
inputDisabled={inputDisabled}
questionIcon={
initUserVariables?.avatar_url

View File

@ -5,7 +5,7 @@ import type {
ModelConfig,
VisionSettings,
} from '@/types/app'
import type { NodeTracing } from '@/types/workflow'
import type { HumanInputFilledFormData, HumanInputFormData, NodeTracing } from '@/types/workflow'
export type {
Inputs,
@ -67,6 +67,8 @@ export type WorkflowProcess = {
expand?: boolean // for UI
resultText?: string
files?: FileEntity[]
humanInputFormDataList?: HumanInputFormData[]
humanInputFilledFormDataList?: HumanInputFilledFormData[]
}
export type ChatItem = IChatItem & {