mirror of
https://github.com/langgenius/dify.git
synced 2026-07-23 20:18:40 +08:00
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Yansong Zhang <916125788@qq.com>
481 lines
16 KiB
TypeScript
481 lines
16 KiB
TypeScript
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<HTMLDivElement>(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 (
|
|
<div className={cn('flex flex-col items-center px-4 pt-6', isMobile && 'pt-4')}>
|
|
<div className="w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md">
|
|
<div className={cn('p-6', isMobile && 'p-4')}>
|
|
<div
|
|
ref={descRef}
|
|
className={cn(
|
|
'relative system-xs-regular break-words whitespace-pre-wrap text-text-tertiary',
|
|
!descExpanded && 'line-clamp-3',
|
|
descExpanded && 'max-h-32 overflow-y-auto',
|
|
)}
|
|
>
|
|
{description}
|
|
{!descExpanded && showDescToggle && (
|
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-linear-to-b from-components-panel-bg-transparent to-components-panel-bg" />
|
|
)}
|
|
</div>
|
|
{showDescToggle && (
|
|
<button
|
|
type="button"
|
|
className="mt-0.5 flex items-center gap-0.5 system-xs-regular text-text-accent hover:opacity-80"
|
|
onClick={() => setDescExpanded((v) => !v)}
|
|
>
|
|
{descExpanded ? (
|
|
<>
|
|
<RiArrowUpSLine className="size-3" />
|
|
{t(($) => $['chat.collapse'], { ns: 'share' })}
|
|
</>
|
|
) : (
|
|
<>
|
|
<RiArrowDownSLine className="size-3" />
|
|
{t(($) => $['chat.expand'], { ns: 'share' })}
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}, [description, isMobile, currentConversationId, hasSent, descExpanded, showDescToggle, t])
|
|
|
|
const chatNode = useMemo(() => {
|
|
if (allInputsHidden || !inputsForms.length) return null
|
|
if (isMobile) {
|
|
if (!currentConversationId)
|
|
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
|
|
return <div className="mb-4"></div>
|
|
} else {
|
|
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
|
|
}
|
|
}, [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 (
|
|
<div
|
|
className={cn(
|
|
'flex items-center justify-center px-4 py-12',
|
|
isMobile ? 'min-h-[30vh] py-0' : 'h-[50vh]',
|
|
)}
|
|
>
|
|
<div className="flex max-w-[720px] grow gap-4">
|
|
<AppIcon
|
|
size="xl"
|
|
iconType={appData?.site.icon_type}
|
|
icon={appData?.site.icon}
|
|
background={appData?.site.icon_background}
|
|
imageUrl={appData?.site.icon_url}
|
|
/>
|
|
<div className="grow rounded-2xl bg-chat-bubble-bg px-4 py-3 body-lg-regular text-text-primary">
|
|
<Markdown content={welcomeMessage.content} />
|
|
<SuggestedQuestions item={welcomeMessage} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex min-h-[50vh] flex-col items-center justify-center gap-3 py-12',
|
|
isMobile ? 'min-h-[30vh] py-0' : 'h-[50vh]',
|
|
)}
|
|
>
|
|
<AppIcon
|
|
size="xl"
|
|
iconType={appData?.site.icon_type}
|
|
icon={appData?.site.icon}
|
|
background={appData?.site.icon_background}
|
|
imageUrl={appData?.site.icon_url}
|
|
/>
|
|
<div className="max-w-[768px] px-4">
|
|
<Markdown
|
|
className="body-2xl-regular! text-text-tertiary!"
|
|
content={welcomeMessage.content}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}, [
|
|
chatList,
|
|
respondingState,
|
|
currentConversationId,
|
|
collapsed,
|
|
inputsForms.length,
|
|
allInputsHidden,
|
|
appData?.site,
|
|
isMobile,
|
|
])
|
|
|
|
const answerIcon = isDify() ? (
|
|
<LogoAvatar className="relative shrink-0" />
|
|
) : appData?.site && appData.site.use_icon_as_answer_icon ? (
|
|
<AnswerIcon
|
|
iconType={appData.site.icon_type}
|
|
icon={appData.site.icon}
|
|
background={appData.site.icon_background}
|
|
imageUrl={appData.site.icon_url}
|
|
/>
|
|
) : null
|
|
const speechToTextTarget =
|
|
appSourceType === AppSourceType.webApp
|
|
? { type: 'app' as const, appSourceType }
|
|
: appId
|
|
? { type: 'app' as const, appId, appSourceType }
|
|
: undefined
|
|
|
|
return (
|
|
<Chat
|
|
isTryApp={isTryApp}
|
|
appData={appData || undefined}
|
|
config={appConfig}
|
|
speechToTextTarget={speechToTextTarget}
|
|
chatList={messageList}
|
|
isResponding={respondingState}
|
|
chatContainerInnerClassName={cn(
|
|
'mx-auto w-full max-w-full px-4',
|
|
messageList.length && 'pt-4',
|
|
)}
|
|
chatFooterClassName={cn('pb-4', !isMobile && 'rounded-b-2xl')}
|
|
chatFooterInnerClassName={cn('mx-auto w-full max-w-full px-4', isMobile && 'px-2')}
|
|
onSend={doSend}
|
|
inputs={currentConversationId ? (currentConversationInputs as any) : newConversationInputs}
|
|
inputsForm={inputsForms}
|
|
onRegenerate={doRegenerate}
|
|
onStopResponding={handleStop}
|
|
onHumanInputFormSubmit={handleSubmitHumanInputForm}
|
|
chatNode={
|
|
<>
|
|
{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 ? (
|
|
<Avatar
|
|
avatar={initUserVariables.avatar_url}
|
|
name={initUserVariables.name || 'user'}
|
|
size="xl"
|
|
/>
|
|
) : undefined
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export default ChatWrapper
|