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.

index.tsx 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. import type {
  2. FC,
  3. ReactNode,
  4. } from 'react'
  5. import {
  6. memo,
  7. useCallback,
  8. useEffect,
  9. useRef,
  10. useState,
  11. } from 'react'
  12. import { useTranslation } from 'react-i18next'
  13. import { debounce } from 'lodash-es'
  14. import { useShallow } from 'zustand/react/shallow'
  15. import type {
  16. ChatConfig,
  17. ChatItem,
  18. Feedback,
  19. OnRegenerate,
  20. OnSend,
  21. } from '../types'
  22. import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
  23. import Question from './question'
  24. import Answer from './answer'
  25. import ChatInputArea from './chat-input-area'
  26. import TryToAsk from './try-to-ask'
  27. import { ChatContextProvider } from './context'
  28. import type { InputForm } from './type'
  29. import cn from '@/utils/classnames'
  30. import type { Emoji } from '@/app/components/tools/types'
  31. import Button from '@/app/components/base/button'
  32. import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
  33. import AgentLogModal from '@/app/components/base/agent-log-modal'
  34. import PromptLogModal from '@/app/components/base/prompt-log-modal'
  35. import { useStore as useAppStore } from '@/app/components/app/store'
  36. import type { AppData } from '@/models/share'
  37. export type ChatProps = {
  38. appData?: AppData
  39. chatList: ChatItem[]
  40. config?: ChatConfig
  41. isResponding?: boolean
  42. noStopResponding?: boolean
  43. onStopResponding?: () => void
  44. noChatInput?: boolean
  45. onSend?: OnSend
  46. inputs?: Record<string, any>
  47. inputsForm?: InputForm[]
  48. onRegenerate?: OnRegenerate
  49. chatContainerClassName?: string
  50. chatContainerInnerClassName?: string
  51. chatFooterClassName?: string
  52. chatFooterInnerClassName?: string
  53. suggestedQuestions?: string[]
  54. showPromptLog?: boolean
  55. questionIcon?: ReactNode
  56. answerIcon?: ReactNode
  57. allToolIcons?: Record<string, string | Emoji>
  58. onAnnotationEdited?: (question: string, answer: string, index: number) => void
  59. onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void
  60. onAnnotationRemoved?: (index: number) => void
  61. chatNode?: ReactNode
  62. onFeedback?: (messageId: string, feedback: Feedback) => void
  63. chatAnswerContainerInner?: string
  64. hideProcessDetail?: boolean
  65. hideLogModal?: boolean
  66. themeBuilder?: ThemeBuilder
  67. switchSibling?: (siblingMessageId: string) => void
  68. showFeatureBar?: boolean
  69. showFileUpload?: boolean
  70. onFeatureBarClick?: (state: boolean) => void
  71. noSpacing?: boolean
  72. inputDisabled?: boolean
  73. isMobile?: boolean
  74. sidebarCollapseState?: boolean
  75. }
  76. const Chat: FC<ChatProps> = ({
  77. appData,
  78. config,
  79. onSend,
  80. inputs,
  81. inputsForm,
  82. onRegenerate,
  83. chatList,
  84. isResponding,
  85. noStopResponding,
  86. onStopResponding,
  87. noChatInput,
  88. chatContainerClassName,
  89. chatContainerInnerClassName,
  90. chatFooterClassName,
  91. chatFooterInnerClassName,
  92. suggestedQuestions,
  93. showPromptLog,
  94. questionIcon,
  95. answerIcon,
  96. onAnnotationAdded,
  97. onAnnotationEdited,
  98. onAnnotationRemoved,
  99. chatNode,
  100. onFeedback,
  101. chatAnswerContainerInner,
  102. hideProcessDetail,
  103. hideLogModal,
  104. themeBuilder,
  105. switchSibling,
  106. showFeatureBar,
  107. showFileUpload,
  108. onFeatureBarClick,
  109. noSpacing,
  110. inputDisabled,
  111. isMobile,
  112. sidebarCollapseState,
  113. }) => {
  114. const { t } = useTranslation()
  115. const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
  116. currentLogItem: state.currentLogItem,
  117. setCurrentLogItem: state.setCurrentLogItem,
  118. showPromptLogModal: state.showPromptLogModal,
  119. setShowPromptLogModal: state.setShowPromptLogModal,
  120. showAgentLogModal: state.showAgentLogModal,
  121. setShowAgentLogModal: state.setShowAgentLogModal,
  122. })))
  123. const [width, setWidth] = useState(0)
  124. const chatContainerRef = useRef<HTMLDivElement>(null)
  125. const chatContainerInnerRef = useRef<HTMLDivElement>(null)
  126. const chatFooterRef = useRef<HTMLDivElement>(null)
  127. const chatFooterInnerRef = useRef<HTMLDivElement>(null)
  128. const userScrolledRef = useRef(false)
  129. const handleScrollToBottom = useCallback(() => {
  130. if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current)
  131. chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
  132. }, [chatList.length])
  133. const handleWindowResize = useCallback(() => {
  134. if (chatContainerRef.current)
  135. setWidth(document.body.clientWidth - (chatContainerRef.current?.clientWidth + 16) - 8)
  136. if (chatContainerRef.current && chatFooterRef.current)
  137. chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px`
  138. if (chatContainerInnerRef.current && chatFooterInnerRef.current)
  139. chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px`
  140. }, [])
  141. useEffect(() => {
  142. handleScrollToBottom()
  143. handleWindowResize()
  144. }, [handleScrollToBottom, handleWindowResize])
  145. useEffect(() => {
  146. if (chatContainerRef.current) {
  147. requestAnimationFrame(() => {
  148. handleScrollToBottom()
  149. handleWindowResize()
  150. })
  151. }
  152. })
  153. useEffect(() => {
  154. window.addEventListener('resize', debounce(handleWindowResize))
  155. return () => window.removeEventListener('resize', handleWindowResize)
  156. }, [handleWindowResize])
  157. useEffect(() => {
  158. if (chatFooterRef.current && chatContainerRef.current) {
  159. // container padding bottom
  160. const resizeContainerObserver = new ResizeObserver((entries) => {
  161. for (const entry of entries) {
  162. const { blockSize } = entry.borderBoxSize[0]
  163. chatContainerRef.current!.style.paddingBottom = `${blockSize}px`
  164. handleScrollToBottom()
  165. }
  166. })
  167. resizeContainerObserver.observe(chatFooterRef.current)
  168. // footer width
  169. const resizeFooterObserver = new ResizeObserver((entries) => {
  170. for (const entry of entries) {
  171. const { inlineSize } = entry.borderBoxSize[0]
  172. chatFooterRef.current!.style.width = `${inlineSize}px`
  173. }
  174. })
  175. resizeFooterObserver.observe(chatContainerRef.current)
  176. return () => {
  177. resizeContainerObserver.disconnect()
  178. resizeFooterObserver.disconnect()
  179. }
  180. }
  181. }, [handleScrollToBottom])
  182. useEffect(() => {
  183. const chatContainer = chatContainerRef.current
  184. if (chatContainer) {
  185. const setUserScrolled = () => {
  186. // eslint-disable-next-line sonarjs/no-gratuitous-expressions
  187. if (chatContainer) // its in event callback, chatContainer may be null
  188. userScrolledRef.current = chatContainer.scrollHeight - chatContainer.scrollTop > chatContainer.clientHeight
  189. }
  190. chatContainer.addEventListener('scroll', setUserScrolled)
  191. return () => chatContainer.removeEventListener('scroll', setUserScrolled)
  192. }
  193. }, [])
  194. useEffect(() => {
  195. if (!sidebarCollapseState)
  196. setTimeout(() => handleWindowResize(), 200)
  197. }, [handleWindowResize, sidebarCollapseState])
  198. const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend
  199. return (
  200. <ChatContextProvider
  201. config={config}
  202. chatList={chatList}
  203. isResponding={isResponding}
  204. showPromptLog={showPromptLog}
  205. questionIcon={questionIcon}
  206. answerIcon={answerIcon}
  207. onSend={onSend}
  208. onRegenerate={onRegenerate}
  209. onAnnotationAdded={onAnnotationAdded}
  210. onAnnotationEdited={onAnnotationEdited}
  211. onAnnotationRemoved={onAnnotationRemoved}
  212. onFeedback={onFeedback}
  213. >
  214. <div className='relative h-full'>
  215. <div
  216. ref={chatContainerRef}
  217. className={cn('relative h-full overflow-y-auto overflow-x-hidden', chatContainerClassName)}
  218. >
  219. {chatNode}
  220. <div
  221. ref={chatContainerInnerRef}
  222. className={cn('w-full', !noSpacing && 'px-8', chatContainerInnerClassName)}
  223. >
  224. {
  225. chatList.map((item, index) => {
  226. if (item.isAnswer) {
  227. const isLast = item.id === chatList[chatList.length - 1]?.id
  228. return (
  229. <Answer
  230. appData={appData}
  231. key={item.id}
  232. item={item}
  233. question={chatList[index - 1]?.content}
  234. index={index}
  235. config={config}
  236. answerIcon={answerIcon}
  237. responding={isLast && isResponding}
  238. showPromptLog={showPromptLog}
  239. chatAnswerContainerInner={chatAnswerContainerInner}
  240. hideProcessDetail={hideProcessDetail}
  241. noChatInput={noChatInput}
  242. switchSibling={switchSibling}
  243. />
  244. )
  245. }
  246. return (
  247. <Question
  248. key={item.id}
  249. item={item}
  250. questionIcon={questionIcon}
  251. theme={themeBuilder?.theme}
  252. enableEdit={config?.questionEditEnable}
  253. switchSibling={switchSibling}
  254. />
  255. )
  256. })
  257. }
  258. </div>
  259. </div>
  260. <div
  261. className={`absolute bottom-0 flex justify-center bg-chat-input-mask ${(hasTryToAsk || !noChatInput || !noStopResponding) && chatFooterClassName}`}
  262. ref={chatFooterRef}
  263. >
  264. <div
  265. ref={chatFooterInnerRef}
  266. className={cn('relative', chatFooterInnerClassName)}
  267. >
  268. {
  269. !noStopResponding && isResponding && (
  270. <div className='mb-2 flex justify-center'>
  271. <Button onClick={onStopResponding}>
  272. <StopCircle className='mr-[5px] h-3.5 w-3.5 text-gray-500' />
  273. <span className='text-xs font-normal text-gray-500'>{t('appDebug.operation.stopResponding')}</span>
  274. </Button>
  275. </div>
  276. )
  277. }
  278. {
  279. hasTryToAsk && (
  280. <TryToAsk
  281. suggestedQuestions={suggestedQuestions}
  282. onSend={onSend}
  283. isMobile={isMobile}
  284. />
  285. )
  286. }
  287. {
  288. !noChatInput && (
  289. <ChatInputArea
  290. botName={appData?.site.title || ''}
  291. disabled={inputDisabled}
  292. showFeatureBar={showFeatureBar}
  293. showFileUpload={showFileUpload}
  294. featureBarDisabled={isResponding}
  295. onFeatureBarClick={onFeatureBarClick}
  296. visionConfig={config?.file_upload}
  297. speechToTextConfig={config?.speech_to_text}
  298. onSend={onSend}
  299. inputs={inputs}
  300. inputsForm={inputsForm}
  301. theme={themeBuilder?.theme}
  302. isResponding={isResponding}
  303. />
  304. )
  305. }
  306. </div>
  307. </div>
  308. {showPromptLogModal && !hideLogModal && (
  309. <PromptLogModal
  310. width={width}
  311. currentLogItem={currentLogItem}
  312. onCancel={() => {
  313. setCurrentLogItem()
  314. setShowPromptLogModal(false)
  315. }}
  316. />
  317. )}
  318. {showAgentLogModal && !hideLogModal && (
  319. <AgentLogModal
  320. width={width}
  321. currentLogItem={currentLogItem}
  322. onCancel={() => {
  323. setCurrentLogItem()
  324. setShowAgentLogModal(false)
  325. }}
  326. />
  327. )}
  328. </div>
  329. </ChatContextProvider>
  330. )
  331. }
  332. export default memo(Chat)