You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

chat-wrapper.tsx 9.9KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. import { useCallback, useEffect, useMemo, useState } from 'react'
  2. import Chat from '../chat'
  3. import type {
  4. ChatConfig,
  5. ChatItem,
  6. ChatItemInTree,
  7. OnSend,
  8. } from '../types'
  9. import { useChat } from '../chat/hooks'
  10. import { getLastAnswer, isValidGeneratedAnswer } from '../utils'
  11. import { useChatWithHistoryContext } from './context'
  12. import { InputVarType } from '@/app/components/workflow/types'
  13. import { TransferMethod } from '@/types/app'
  14. import InputsForm from '@/app/components/base/chat/chat-with-history/inputs-form'
  15. import {
  16. fetchSuggestedQuestions,
  17. getUrl,
  18. stopChatMessageResponding,
  19. } from '@/service/share'
  20. import AppIcon from '@/app/components/base/app-icon'
  21. import AnswerIcon from '@/app/components/base/answer-icon'
  22. import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions'
  23. import { Markdown } from '@/app/components/base/markdown'
  24. import cn from '@/utils/classnames'
  25. import type { FileEntity } from '../../file-uploader/types'
  26. import { formatBooleanInputs } from '@/utils/model-config'
  27. import Avatar from '../../avatar'
  28. const ChatWrapper = () => {
  29. const {
  30. appParams,
  31. appPrevChatTree,
  32. currentConversationId,
  33. currentConversationItem,
  34. currentConversationInputs,
  35. inputsForms,
  36. newConversationInputs,
  37. newConversationInputsRef,
  38. handleNewConversationCompleted,
  39. isMobile,
  40. isInstalledApp,
  41. appId,
  42. appMeta,
  43. handleFeedback,
  44. currentChatInstanceRef,
  45. appData,
  46. themeBuilder,
  47. sidebarCollapseState,
  48. clearChatList,
  49. setClearChatList,
  50. setIsResponding,
  51. allInputsHidden,
  52. initUserVariables,
  53. } = useChatWithHistoryContext()
  54. // Semantic variable for better code readability
  55. const isHistoryConversation = !!currentConversationId
  56. const appConfig = useMemo(() => {
  57. const config = appParams || {}
  58. return {
  59. ...config,
  60. file_upload: {
  61. ...(config as any).file_upload,
  62. fileUploadConfig: (config as any).system_parameters,
  63. },
  64. supportFeedback: true,
  65. opening_statement: isHistoryConversation ? currentConversationItem?.introduction : (config as any).opening_statement,
  66. } as ChatConfig
  67. }, [appParams, currentConversationItem?.introduction, isHistoryConversation])
  68. const {
  69. chatList,
  70. setTargetMessageId,
  71. handleSend,
  72. handleStop,
  73. isResponding: respondingState,
  74. suggestedQuestions,
  75. } = useChat(
  76. appConfig,
  77. {
  78. inputs: (isHistoryConversation ? currentConversationInputs : newConversationInputs) as any,
  79. inputsForm: inputsForms,
  80. },
  81. appPrevChatTree,
  82. taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId),
  83. clearChatList,
  84. setClearChatList,
  85. )
  86. const inputsFormValue = isHistoryConversation ? currentConversationInputs : newConversationInputsRef?.current
  87. const inputDisabled = useMemo(() => {
  88. if (allInputsHidden)
  89. return false
  90. let hasEmptyInput = ''
  91. let fileIsUploading = false
  92. const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox)
  93. if (requiredVars.length) {
  94. requiredVars.forEach(({ variable, label, type }) => {
  95. if (hasEmptyInput)
  96. return
  97. if (fileIsUploading)
  98. return
  99. if (!inputsFormValue?.[variable])
  100. hasEmptyInput = label as string
  101. if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && inputsFormValue?.[variable]) {
  102. const files = inputsFormValue[variable]
  103. if (Array.isArray(files))
  104. fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)
  105. else
  106. fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId
  107. }
  108. })
  109. }
  110. if (hasEmptyInput)
  111. return true
  112. if (fileIsUploading)
  113. return true
  114. return false
  115. }, [inputsFormValue, inputsForms, allInputsHidden])
  116. useEffect(() => {
  117. if (currentChatInstanceRef.current)
  118. currentChatInstanceRef.current.handleStop = handleStop
  119. }, [])
  120. useEffect(() => {
  121. setIsResponding(respondingState)
  122. }, [respondingState, setIsResponding])
  123. const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
  124. const data: any = {
  125. query: message,
  126. files,
  127. inputs: formatBooleanInputs(inputsForms, isHistoryConversation ? currentConversationInputs : newConversationInputs),
  128. conversation_id: currentConversationId,
  129. parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
  130. }
  131. handleSend(
  132. getUrl('chat-messages', isInstalledApp, appId || ''),
  133. data,
  134. {
  135. onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId),
  136. onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted,
  137. isPublicAPI: !isInstalledApp,
  138. },
  139. )
  140. }, [chatList, handleNewConversationCompleted, handleSend, isHistoryConversation, currentConversationInputs, newConversationInputs, isInstalledApp, appId])
  141. const doRegenerate = useCallback((chatItem: ChatItemInTree, editedQuestion?: { message: string, files?: FileEntity[] }) => {
  142. const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
  143. const parentAnswer = chatList.find(item => item.id === question.parentMessageId)
  144. doSend(editedQuestion ? editedQuestion.message : question.content,
  145. editedQuestion ? editedQuestion.files : question.message_files,
  146. true,
  147. isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null,
  148. )
  149. }, [chatList, doSend])
  150. const messageList = useMemo(() => {
  151. // Always filter out opening statement from message list as it's handled separately in welcome component
  152. return chatList.filter(item => !item.isOpeningStatement)
  153. }, [chatList])
  154. const [collapsed, setCollapsed] = useState(isHistoryConversation)
  155. const chatNode = useMemo(() => {
  156. if (allInputsHidden || !inputsForms.length)
  157. return null
  158. if (isMobile) {
  159. if (!isHistoryConversation)
  160. return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
  161. return null
  162. }
  163. else {
  164. return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
  165. }
  166. }, [inputsForms.length, isMobile, isHistoryConversation, collapsed, allInputsHidden])
  167. const welcome = useMemo(() => {
  168. const welcomeMessage = chatList.find(item => item.isOpeningStatement)
  169. if (respondingState)
  170. return null
  171. if (isHistoryConversation)
  172. return null
  173. if (!welcomeMessage)
  174. return null
  175. if (!collapsed && inputsForms.length > 0 && !allInputsHidden)
  176. return null
  177. if (welcomeMessage.suggestedQuestions && welcomeMessage.suggestedQuestions?.length > 0) {
  178. return (
  179. <div className='flex min-h-[50vh] items-center justify-center px-4 py-12'>
  180. <div className='flex max-w-[720px] grow gap-4'>
  181. <AppIcon
  182. size='xl'
  183. iconType={appData?.site.icon_type}
  184. icon={appData?.site.icon}
  185. background={appData?.site.icon_background}
  186. imageUrl={appData?.site.icon_url}
  187. />
  188. <div className='w-0 grow'>
  189. <div className='body-lg-regular grow rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary'>
  190. <Markdown content={welcomeMessage.content} />
  191. <SuggestedQuestions item={welcomeMessage} />
  192. </div>
  193. </div>
  194. </div>
  195. </div>
  196. )
  197. }
  198. return (
  199. <div className={cn('flex h-[50vh] flex-col items-center justify-center gap-3 py-12')}>
  200. <AppIcon
  201. size='xl'
  202. iconType={appData?.site.icon_type}
  203. icon={appData?.site.icon}
  204. background={appData?.site.icon_background}
  205. imageUrl={appData?.site.icon_url}
  206. />
  207. <div className='max-w-[768px] px-4'>
  208. <Markdown className='!body-2xl-regular !text-text-tertiary' content={welcomeMessage.content} />
  209. </div>
  210. </div>
  211. )
  212. }, [appData?.site.icon, appData?.site.icon_background, appData?.site.icon_type, appData?.site.icon_url, chatList, collapsed, isHistoryConversation, inputsForms.length, respondingState, allInputsHidden])
  213. const answerIcon = (appData?.site && appData.site.use_icon_as_answer_icon)
  214. ? <AnswerIcon
  215. iconType={appData.site.icon_type}
  216. icon={appData.site.icon}
  217. background={appData.site.icon_background}
  218. imageUrl={appData.site.icon_url}
  219. />
  220. : null
  221. return (
  222. <div
  223. className='h-full overflow-hidden bg-chatbot-bg'
  224. >
  225. <Chat
  226. appData={appData ?? undefined}
  227. config={appConfig}
  228. chatList={messageList}
  229. isResponding={respondingState}
  230. chatContainerInnerClassName={`mx-auto pt-6 w-full max-w-[768px] ${isMobile && 'px-4'}`}
  231. chatFooterClassName='pb-4'
  232. chatFooterInnerClassName={`mx-auto w-full max-w-[768px] ${isMobile ? 'px-2' : 'px-4'}`}
  233. onSend={doSend}
  234. inputs={isHistoryConversation ? currentConversationInputs as any : newConversationInputs}
  235. inputsForm={inputsForms}
  236. onRegenerate={doRegenerate}
  237. onStopResponding={handleStop}
  238. chatNode={
  239. <>
  240. {chatNode}
  241. {welcome}
  242. </>
  243. }
  244. allToolIcons={appMeta?.tool_icons || {}}
  245. onFeedback={handleFeedback}
  246. suggestedQuestions={suggestedQuestions}
  247. answerIcon={answerIcon}
  248. hideProcessDetail
  249. themeBuilder={themeBuilder}
  250. switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
  251. inputDisabled={inputDisabled}
  252. isMobile={isMobile}
  253. sidebarCollapseState={sidebarCollapseState}
  254. questionIcon={
  255. initUserVariables?.avatar_url
  256. ? <Avatar
  257. avatar={initUserVariables.avatar_url}
  258. name={initUserVariables.name || 'user'}
  259. size={40}
  260. /> : undefined
  261. }
  262. />
  263. </div>
  264. )
  265. }
  266. export default ChatWrapper