import type { FileEntity } from '../../file-uploader/types' import type { HumanInputFormSubmitData } from '../chat/answer/human-input-content/type' import type { ChatConfig, ChatItem, ChatItemInTree, OnSend } from '../types' import { Avatar } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import AnswerIcon from '@/app/components/base/answer-icon' import AppIcon from '@/app/components/base/app-icon' import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions' import InputsForm from '@/app/components/base/chat/embedded-chatbot/inputs-form' import LogoAvatar from '@/app/components/base/logo/logo-embedded-chat-avatar' import { Markdown } from '@/app/components/base/markdown' import { InputVarType } from '@/app/components/workflow/types' import { AppSourceType, fetchSuggestedQuestions, getUrl, stopChatMessageResponding, submitHumanInputForm, } from '@/service/share' import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow' import { TransferMethod } from '@/types/app' import Chat from '../chat' import { useChat } from '../chat/hooks' import { getLastAnswer, isValidGeneratedAnswer } from '../utils' import { useEmbeddedChatbotContext } from './context' import { isDify } from './utils' const ChatWrapper = () => { const { t } = useTranslation() const { appData, appParams, appPrevChatList, currentConversationId, currentConversationItem, currentConversationInputs, inputsForms, newConversationInputs, newConversationInputsRef, handleNewConversationCompleted, isMobile, isInstalledApp, appId, appMeta, disableFeedback, handleFeedback, currentChatInstanceRef, themeBuilder, clearChatList, setClearChatList, setIsResponding, allInputsHidden, initUserVariables, appSourceType, } = useEmbeddedChatbotContext() // Read sendOnEnter from URL params (e.g., ?sendOnEnter=false) const sendOnEnter = useMemo(() => { if (typeof window === 'undefined') return true const urlParams = new URLSearchParams(window.location.search) const param = urlParams.get('sendOnEnter') return param !== 'false' }, []) const appConfig = useMemo(() => { const config = appParams || {} return { ...config, file_upload: { ...(config as any).file_upload, fileUploadConfig: (config as any).system_parameters, }, supportFeedback: true, opening_statement: currentConversationItem?.introduction || (config as any).opening_statement, } as ChatConfig }, [appParams, currentConversationItem?.introduction]) const timezone = appSourceType === AppSourceType.webApp ? new Intl.DateTimeFormat().resolvedOptions().timeZone : undefined const { chatList, handleSend, handleStop, handleSwitchSibling, isResponding: respondingState, suggestedQuestions, } = useChat( appConfig, { inputs: (currentConversationId ? currentConversationInputs : newConversationInputs) as any, inputsForm: inputsForms, }, appPrevChatList, (taskId) => stopChatMessageResponding('', taskId, appSourceType, appId), clearChatList, setClearChatList, undefined, { timezone }, ) const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current const inputDisabled = useMemo(() => { if (allInputsHidden) return false let hasEmptyInput = '' let fileIsUploading = false const requiredVars = inputsForms.filter( ({ required, type }) => required && type !== InputVarType.checkbox, ) // boolean can be not checked if (requiredVars.length) { requiredVars.forEach(({ variable, label, type }) => { if (hasEmptyInput) return if (fileIsUploading) return if (!inputsFormValue?.[variable]) hasEmptyInput = label as string if ( (type === InputVarType.singleFile || type === InputVarType.multiFiles) && inputsFormValue?.[variable] ) { const files = inputsFormValue[variable] if (Array.isArray(files)) fileIsUploading = files.find( (item) => item.transferMethod === TransferMethod.local_file && !item.uploadedId, ) else fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId } }) } if (hasEmptyInput) return true if (fileIsUploading) return true return false }, [inputsFormValue, inputsForms, allInputsHidden]) useEffect(() => { if (currentChatInstanceRef.current) currentChatInstanceRef.current.handleStop = handleStop }, [currentChatInstanceRef, handleStop]) useEffect(() => { 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 [hasSent, setHasSent] = useState(false) const [prevConversationId, setPrevConversationId] = useState(currentConversationId) if (prevConversationId !== currentConversationId) { setPrevConversationId(currentConversationId) if (!currentConversationId) setHasSent(false) } const doSend: OnSend = useCallback( (message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { if (!currentConversationId) setHasSent(true) const data: any = { query: message, files, inputs: currentConversationId ? currentConversationInputs : newConversationInputs, conversation_id: currentConversationId, parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null, } handleSend(getUrl('chat-messages', appSourceType, appId || ''), data, { onGetSuggestedQuestions: (responseItemId) => fetchSuggestedQuestions(responseItemId, appSourceType, appId), onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, isPublicAPI: appSourceType === AppSourceType.webApp, }) }, [ 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)! const parentAnswer = chatList.find((item) => item.id === question.parentMessageId) 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 // Without messages we are in the welcome screen, so hide the opening statement from chatlist return chatList.filter((item) => !item.isOpeningStatement) }, [chatList, currentConversationId]) const isTryApp = appSourceType === AppSourceType.tryApp const [collapsed, setCollapsed] = useState(!!currentConversationId && !isTryApp) // try app always use the new chat const [descExpanded, setDescExpanded] = useState(false) const description = appData?.site.description const [showDescToggle, setShowDescToggle] = useState(false) const descRef = useRef(null) useEffect(() => { const el = descRef.current if (!el) return if (el.offsetWidth > 0) { setShowDescToggle(el.scrollHeight > el.clientHeight) return } const observer = new ResizeObserver((entries) => { if (!entries[0] || entries[0].contentRect.width === 0) return setShowDescToggle(el.scrollHeight > el.clientHeight) observer.disconnect() }) observer.observe(el) return () => observer.disconnect() }, [description]) const descriptionNode = useMemo(() => { if (!description || currentConversationId || hasSent) return null return (
{description} {!descExpanded && showDescToggle && (
)}
{showDescToggle && ( )}
) }, [description, isMobile, currentConversationId, hasSent, descExpanded, showDescToggle, t]) const chatNode = useMemo(() => { if (allInputsHidden || !inputsForms.length) return null if (isMobile) { if (!currentConversationId) return return
} else { return } }, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden]) const handleSubmitHumanInputForm = useCallback( async (formToken: string, formData: HumanInputFormSubmitData) => { if (isInstalledApp) await submitHumanInputFormService(formToken, formData) else await submitHumanInputForm(formToken, formData) }, [isInstalledApp], ) const welcome = useMemo(() => { const welcomeMessage = chatList.find((item) => item.isOpeningStatement) if (respondingState) return null if (currentConversationId) return null if (!welcomeMessage) return null if (!collapsed && inputsForms.length > 0 && !allInputsHidden) return null if (!appData?.site) return null if (welcomeMessage.suggestedQuestions && welcomeMessage.suggestedQuestions?.length > 0) { return (
) } return (
) }, [ chatList, respondingState, currentConversationId, collapsed, inputsForms.length, allInputsHidden, appData?.site, isMobile, ]) const answerIcon = isDify() ? ( ) : appData?.site && appData.site.use_icon_as_answer_icon ? ( ) : null const speechToTextTarget = appSourceType === AppSourceType.webApp ? { type: 'app' as const, appSourceType } : appId ? { type: 'app' as const, appId, appSourceType } : undefined return ( {descriptionNode} {chatNode} {welcome} } allToolIcons={appMeta?.tool_icons || {}} disableFeedback={disableFeedback} onFeedback={handleFeedback} suggestedQuestions={suggestedQuestions} answerIcon={answerIcon} hideProcessDetail themeBuilder={themeBuilder} switchSibling={doSwitchSibling} inputDisabled={inputDisabled} sendOnEnter={sendOnEnter} questionIcon={ initUserVariables?.avatar_url ? ( ) : undefined } /> ) } export default ChatWrapper