index.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  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 classNames from 'classnames'
  15. import type {
  16. ChatConfig,
  17. ChatItem,
  18. Feedback,
  19. OnSend,
  20. } from '../types'
  21. import Question from './question'
  22. import Answer from './answer'
  23. import ChatInput from './chat-input'
  24. import TryToAsk from './try-to-ask'
  25. import { ChatContextProvider } from './context'
  26. import type { Emoji } from '@/app/components/tools/types'
  27. import Button from '@/app/components/base/button'
  28. import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
  29. import PromptLogModal from '@/app/components/base/prompt-log-modal'
  30. import { useStore as useAppStore } from '@/app/components/app/store'
  31. export type ChatProps = {
  32. chatList: ChatItem[]
  33. config?: ChatConfig
  34. isResponding?: boolean
  35. noStopResponding?: boolean
  36. onStopResponding?: () => void
  37. noChatInput?: boolean
  38. onSend?: OnSend
  39. chatContainerClassName?: string
  40. chatContainerInnerClassName?: string
  41. chatFooterClassName?: string
  42. chatFooterInnerClassName?: string
  43. suggestedQuestions?: string[]
  44. showPromptLog?: boolean
  45. questionIcon?: ReactNode
  46. answerIcon?: ReactNode
  47. allToolIcons?: Record<string, string | Emoji>
  48. onAnnotationEdited?: (question: string, answer: string, index: number) => void
  49. onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void
  50. onAnnotationRemoved?: (index: number) => void
  51. chatNode?: ReactNode
  52. onFeedback?: (messageId: string, feedback: Feedback) => void
  53. chatAnswerContainerInner?: string
  54. }
  55. const Chat: FC<ChatProps> = ({
  56. config,
  57. onSend,
  58. chatList,
  59. isResponding,
  60. noStopResponding,
  61. onStopResponding,
  62. noChatInput,
  63. chatContainerClassName,
  64. chatContainerInnerClassName,
  65. chatFooterClassName,
  66. chatFooterInnerClassName,
  67. suggestedQuestions,
  68. showPromptLog,
  69. questionIcon,
  70. answerIcon,
  71. allToolIcons,
  72. onAnnotationAdded,
  73. onAnnotationEdited,
  74. onAnnotationRemoved,
  75. chatNode,
  76. onFeedback,
  77. chatAnswerContainerInner,
  78. }) => {
  79. const { t } = useTranslation()
  80. const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal } = useAppStore()
  81. const [width, setWidth] = useState(0)
  82. const chatContainerRef = useRef<HTMLDivElement>(null)
  83. const chatContainerInnerRef = useRef<HTMLDivElement>(null)
  84. const chatFooterRef = useRef<HTMLDivElement>(null)
  85. const chatFooterInnerRef = useRef<HTMLDivElement>(null)
  86. const userScrolledRef = useRef(false)
  87. const handleScrolltoBottom = useCallback(() => {
  88. if (chatContainerRef.current && !userScrolledRef.current)
  89. chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
  90. }, [])
  91. const handleWindowResize = useCallback(() => {
  92. if (chatContainerRef.current)
  93. setWidth(document.body.clientWidth - (chatContainerRef.current?.clientWidth + 16) - 8)
  94. if (chatContainerRef.current && chatFooterRef.current)
  95. chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px`
  96. if (chatContainerInnerRef.current && chatFooterInnerRef.current)
  97. chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px`
  98. }, [])
  99. useEffect(() => {
  100. handleScrolltoBottom()
  101. handleWindowResize()
  102. }, [handleScrolltoBottom, handleWindowResize])
  103. useEffect(() => {
  104. if (chatContainerRef.current) {
  105. requestAnimationFrame(() => {
  106. handleScrolltoBottom()
  107. handleWindowResize()
  108. })
  109. }
  110. })
  111. useEffect(() => {
  112. window.addEventListener('resize', debounce(handleWindowResize))
  113. return () => window.removeEventListener('resize', handleWindowResize)
  114. }, [handleWindowResize])
  115. useEffect(() => {
  116. if (chatFooterRef.current && chatContainerRef.current) {
  117. const resizeObserver = new ResizeObserver((entries) => {
  118. for (const entry of entries) {
  119. const { blockSize } = entry.borderBoxSize[0]
  120. chatContainerRef.current!.style.paddingBottom = `${blockSize}px`
  121. handleScrolltoBottom()
  122. }
  123. })
  124. resizeObserver.observe(chatFooterRef.current)
  125. return () => {
  126. resizeObserver.disconnect()
  127. }
  128. }
  129. }, [handleScrolltoBottom])
  130. useEffect(() => {
  131. const chatContainer = chatContainerRef.current
  132. if (chatContainer) {
  133. const setUserScrolled = () => {
  134. if (chatContainer)
  135. userScrolledRef.current = chatContainer.scrollHeight - chatContainer.scrollTop >= chatContainer.clientHeight + 300
  136. }
  137. chatContainer.addEventListener('scroll', setUserScrolled)
  138. return () => chatContainer.removeEventListener('scroll', setUserScrolled)
  139. }
  140. }, [])
  141. const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend
  142. return (
  143. <ChatContextProvider
  144. config={config}
  145. chatList={chatList}
  146. isResponding={isResponding}
  147. showPromptLog={showPromptLog}
  148. questionIcon={questionIcon}
  149. answerIcon={answerIcon}
  150. allToolIcons={allToolIcons}
  151. onSend={onSend}
  152. onAnnotationAdded={onAnnotationAdded}
  153. onAnnotationEdited={onAnnotationEdited}
  154. onAnnotationRemoved={onAnnotationRemoved}
  155. onFeedback={onFeedback}
  156. >
  157. <div className='relative h-full'>
  158. <div
  159. ref={chatContainerRef}
  160. className={classNames('relative h-full overflow-y-auto', chatContainerClassName)}
  161. >
  162. {chatNode}
  163. <div
  164. ref={chatContainerInnerRef}
  165. className={`${chatContainerInnerClassName}`}
  166. >
  167. {
  168. chatList.map((item, index) => {
  169. if (item.isAnswer) {
  170. const isLast = item.id === chatList[chatList.length - 1]?.id
  171. return (
  172. <Answer
  173. key={item.id}
  174. item={item}
  175. question={chatList[index - 1]?.content}
  176. index={index}
  177. config={config}
  178. answerIcon={answerIcon}
  179. responding={isLast && isResponding}
  180. allToolIcons={allToolIcons}
  181. showPromptLog={showPromptLog}
  182. chatAnswerContainerInner={chatAnswerContainerInner}
  183. />
  184. )
  185. }
  186. return (
  187. <Question
  188. key={item.id}
  189. item={item}
  190. questionIcon={questionIcon}
  191. />
  192. )
  193. })
  194. }
  195. </div>
  196. </div>
  197. <div
  198. className={`absolute bottom-0 ${(hasTryToAsk || !noChatInput || !noStopResponding) && chatFooterClassName}`}
  199. ref={chatFooterRef}
  200. style={{
  201. background: 'linear-gradient(0deg, #F9FAFB 40%, rgba(255, 255, 255, 0.00) 100%)',
  202. }}
  203. >
  204. <div
  205. ref={chatFooterInnerRef}
  206. className={`${chatFooterInnerClassName}`}
  207. >
  208. {
  209. !noStopResponding && isResponding && (
  210. <div className='flex justify-center mb-2'>
  211. <Button className='py-0 px-3 h-7 bg-white shadow-xs' onClick={onStopResponding}>
  212. <StopCircle className='mr-[5px] w-3.5 h-3.5 text-gray-500' />
  213. <span className='text-xs text-gray-500 font-normal'>{t('appDebug.operation.stopResponding')}</span>
  214. </Button>
  215. </div>
  216. )
  217. }
  218. {
  219. hasTryToAsk && (
  220. <TryToAsk
  221. suggestedQuestions={suggestedQuestions}
  222. onSend={onSend}
  223. />
  224. )
  225. }
  226. {
  227. !noChatInput && (
  228. <ChatInput
  229. visionConfig={config?.file_upload?.image}
  230. speechToTextConfig={config?.speech_to_text}
  231. onSend={onSend}
  232. />
  233. )
  234. }
  235. </div>
  236. </div>
  237. {showPromptLogModal && (
  238. <PromptLogModal
  239. width={width}
  240. currentLogItem={currentLogItem}
  241. onCancel={() => {
  242. setCurrentLogItem()
  243. setShowPromptLogModal(false)
  244. }}
  245. />
  246. )}
  247. </div>
  248. </ChatContextProvider>
  249. )
  250. }
  251. export default memo(Chat)