index.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. 'use client'
  2. import type { FC, ReactNode } from 'react'
  3. import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
  4. import Textarea from 'rc-textarea'
  5. import { useContext } from 'use-context-selector'
  6. import cn from 'classnames'
  7. import Recorder from 'js-audio-recorder'
  8. import { useTranslation } from 'react-i18next'
  9. import s from './style.module.css'
  10. import type { DisplayScene, FeedbackFunc, IChatItem } from './type'
  11. import { TryToAskIcon, stopIcon } from './icon-component'
  12. import Answer from './answer'
  13. import Question from './question'
  14. import TooltipPlus from '@/app/components/base/tooltip-plus'
  15. import { ToastContext } from '@/app/components/base/toast'
  16. import Button from '@/app/components/base/button'
  17. import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
  18. import VoiceInput from '@/app/components/base/voice-input'
  19. import { Microphone01 } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
  20. import { Microphone01 as Microphone01Solid } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
  21. import { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
  22. import type { DataSet } from '@/models/datasets'
  23. import ChatImageUploader from '@/app/components/base/image-uploader/chat-image-uploader'
  24. import ImageList from '@/app/components/base/image-uploader/image-list'
  25. import { TransferMethod, type VisionFile, type VisionSettings } from '@/types/app'
  26. import { useClipboardUploader, useDraggableUploader, useImageFiles } from '@/app/components/base/image-uploader/hooks'
  27. import type { Annotation } from '@/models/log'
  28. import type { Emoji } from '@/app/components/tools/types'
  29. export type IChatProps = {
  30. appId?: string
  31. configElem?: React.ReactNode
  32. chatList: IChatItem[]
  33. onChatListChange?: (chatList: IChatItem[]) => void
  34. controlChatUpdateAllConversation?: number
  35. /**
  36. * Whether to display the editing area and rating status
  37. */
  38. feedbackDisabled?: boolean
  39. /**
  40. * Whether to display the input area
  41. */
  42. isHideFeedbackEdit?: boolean
  43. isHideSendInput?: boolean
  44. onFeedback?: FeedbackFunc
  45. checkCanSend?: () => boolean
  46. query?: string
  47. onQueryChange?: (query: string) => void
  48. onSend?: (message: string, files: VisionFile[]) => void
  49. displayScene?: DisplayScene
  50. useCurrentUserAvatar?: boolean
  51. isResponsing?: boolean
  52. canStopResponsing?: boolean
  53. abortResponsing?: () => void
  54. controlClearQuery?: number
  55. controlFocus?: number
  56. isShowSuggestion?: boolean
  57. suggestionList?: string[]
  58. isShowSpeechToText?: boolean
  59. isShowTextToSpeech?: boolean
  60. isShowCitation?: boolean
  61. answerIcon?: ReactNode
  62. isShowConfigElem?: boolean
  63. dataSets?: DataSet[]
  64. isShowCitationHitInfo?: boolean
  65. isShowPromptLog?: boolean
  66. visionConfig?: VisionSettings
  67. supportAnnotation?: boolean
  68. allToolIcons?: Record<string, string | Emoji>
  69. }
  70. const Chat: FC<IChatProps> = ({
  71. configElem,
  72. chatList,
  73. query = '',
  74. onQueryChange = () => { },
  75. feedbackDisabled = false,
  76. isHideFeedbackEdit = false,
  77. isHideSendInput = false,
  78. onFeedback,
  79. checkCanSend,
  80. onSend = () => { },
  81. displayScene,
  82. useCurrentUserAvatar,
  83. isResponsing,
  84. canStopResponsing,
  85. abortResponsing,
  86. controlClearQuery,
  87. controlFocus,
  88. isShowSuggestion,
  89. suggestionList,
  90. isShowSpeechToText,
  91. isShowTextToSpeech,
  92. isShowCitation,
  93. answerIcon,
  94. isShowConfigElem,
  95. dataSets,
  96. isShowCitationHitInfo,
  97. isShowPromptLog,
  98. visionConfig,
  99. appId,
  100. supportAnnotation,
  101. onChatListChange,
  102. allToolIcons,
  103. }) => {
  104. const { t } = useTranslation()
  105. const { notify } = useContext(ToastContext)
  106. const {
  107. files,
  108. onUpload,
  109. onRemove,
  110. onReUpload,
  111. onImageLinkLoadError,
  112. onImageLinkLoadSuccess,
  113. onClear,
  114. } = useImageFiles()
  115. const { onPaste } = useClipboardUploader({ onUpload, visionConfig, files })
  116. const { onDragEnter, onDragLeave, onDragOver, onDrop, isDragActive } = useDraggableUploader<HTMLTextAreaElement>({ onUpload, files, visionConfig })
  117. const isUseInputMethod = useRef(false)
  118. const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
  119. const value = e.target.value
  120. onQueryChange(value)
  121. }
  122. const logError = (message: string) => {
  123. notify({ type: 'error', message, duration: 3000 })
  124. }
  125. const valid = (q?: string) => {
  126. const sendQuery = q || query
  127. if (!sendQuery || sendQuery.trim() === '') {
  128. logError('Message cannot be empty')
  129. return false
  130. }
  131. return true
  132. }
  133. useEffect(() => {
  134. if (controlClearQuery)
  135. onQueryChange('')
  136. }, [controlClearQuery])
  137. const handleSend = (q?: string) => {
  138. if (!valid(q) || (checkCanSend && !checkCanSend()))
  139. return
  140. onSend(q || query, files.filter(file => file.progress !== -1).map(fileItem => ({
  141. type: 'image',
  142. transfer_method: fileItem.type,
  143. url: fileItem.url,
  144. upload_file_id: fileItem.fileId,
  145. })))
  146. if (!files.find(item => item.type === TransferMethod.local_file && !item.fileId)) {
  147. if (files.length)
  148. onClear()
  149. if (!isResponsing)
  150. onQueryChange('')
  151. }
  152. }
  153. const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  154. if (e.code === 'Enter') {
  155. e.preventDefault()
  156. // prevent send message when using input method enter
  157. if (!e.shiftKey && !isUseInputMethod.current)
  158. handleSend()
  159. }
  160. }
  161. const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  162. isUseInputMethod.current = e.nativeEvent.isComposing
  163. if (e.code === 'Enter' && !e.shiftKey) {
  164. onQueryChange(query.replace(/\n$/, ''))
  165. e.preventDefault()
  166. }
  167. }
  168. const media = useBreakpoints()
  169. const isMobile = media === MediaType.mobile
  170. const sendBtn = <div className={cn(!(!query || query.trim() === '') && s.sendBtnActive, `${s.sendBtn} w-8 h-8 cursor-pointer rounded-md`)} onClick={() => handleSend()}></div>
  171. const suggestionListRef = useRef<HTMLDivElement>(null)
  172. const [hasScrollbar, setHasScrollbar] = useState(false)
  173. useLayoutEffect(() => {
  174. if (suggestionListRef.current) {
  175. const listDom = suggestionListRef.current
  176. const hasScrollbar = listDom.scrollWidth > listDom.clientWidth
  177. setHasScrollbar(hasScrollbar)
  178. }
  179. }, [suggestionList])
  180. const [voiceInputShow, setVoiceInputShow] = useState(false)
  181. const handleVoiceInputShow = () => {
  182. (Recorder as any).getPermission().then(() => {
  183. setVoiceInputShow(true)
  184. }, () => {
  185. logError(t('common.voiceInput.notAllow'))
  186. })
  187. }
  188. const handleQueryChangeFromAnswer = useCallback((val: string) => {
  189. onQueryChange(val)
  190. handleSend(val)
  191. }, [])
  192. const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => {
  193. onChatListChange?.(chatList.map((item, i) => {
  194. if (i === index - 1) {
  195. return {
  196. ...item,
  197. content: query,
  198. }
  199. }
  200. if (i === index) {
  201. return {
  202. ...item,
  203. content: answer,
  204. annotation: {
  205. ...item.annotation,
  206. logAnnotation: undefined,
  207. } as any,
  208. }
  209. }
  210. return item
  211. }))
  212. }, [])
  213. const handleAnnotationAdded = useCallback((annotationId: string, authorName: string, query: string, answer: string, index: number) => {
  214. onChatListChange?.(chatList.map((item, i) => {
  215. if (i === index - 1) {
  216. return {
  217. ...item,
  218. content: query,
  219. }
  220. }
  221. if (i === index) {
  222. const answerItem = {
  223. ...item,
  224. content: item.content,
  225. annotation: {
  226. id: annotationId,
  227. authorName,
  228. logAnnotation: {
  229. content: answer,
  230. account: {
  231. id: '',
  232. name: authorName,
  233. email: '',
  234. },
  235. },
  236. } as Annotation,
  237. }
  238. return answerItem
  239. }
  240. return item
  241. }))
  242. }, [])
  243. const handleAnnotationRemoved = useCallback((index: number) => {
  244. onChatListChange?.(chatList.map((item, i) => {
  245. if (i === index) {
  246. return {
  247. ...item,
  248. content: item.content,
  249. annotation: {
  250. ...(item.annotation || {}),
  251. id: '',
  252. } as Annotation,
  253. }
  254. }
  255. return item
  256. }))
  257. }, [])
  258. return (
  259. <div className={cn('px-3.5', 'h-full')}>
  260. {isShowConfigElem && (configElem || null)}
  261. {/* Chat List */}
  262. <div className={cn((isShowConfigElem && configElem) ? 'h-0' : 'h-full', 'space-y-[30px]')}>
  263. {chatList.map((item, index) => {
  264. if (item.isAnswer) {
  265. const isLast = item.id === chatList[chatList.length - 1].id
  266. const citation = item.citation
  267. return <Answer
  268. key={item.id}
  269. item={item}
  270. index={index}
  271. onQueryChange={handleQueryChangeFromAnswer}
  272. feedbackDisabled={feedbackDisabled}
  273. isHideFeedbackEdit={isHideFeedbackEdit}
  274. onFeedback={onFeedback}
  275. displayScene={displayScene ?? 'web'}
  276. isResponsing={isResponsing && isLast}
  277. answerIcon={answerIcon}
  278. citation={citation}
  279. dataSets={dataSets}
  280. isShowCitation={isShowCitation}
  281. isShowCitationHitInfo={isShowCitationHitInfo}
  282. isShowTextToSpeech={isShowTextToSpeech}
  283. supportAnnotation={supportAnnotation}
  284. appId={appId}
  285. question={chatList[index - 1]?.content}
  286. onAnnotationEdited={handleAnnotationEdited}
  287. onAnnotationAdded={handleAnnotationAdded}
  288. onAnnotationRemoved={handleAnnotationRemoved}
  289. allToolIcons={allToolIcons}
  290. />
  291. }
  292. return (
  293. <Question
  294. key={item.id}
  295. id={item.id}
  296. content={item.content}
  297. more={item.more}
  298. useCurrentUserAvatar={useCurrentUserAvatar}
  299. item={item}
  300. isShowPromptLog={isShowPromptLog}
  301. isResponsing={isResponsing}
  302. />
  303. )
  304. })}
  305. </div>
  306. {
  307. !isHideSendInput && (
  308. <div className={cn(!feedbackDisabled && '!left-3.5 !right-3.5', 'absolute z-10 bottom-0 left-0 right-0')}>
  309. {/* Thinking is sync and can not be stopped */}
  310. {(isResponsing && canStopResponsing && ((!!chatList[chatList.length - 1]?.content) || (chatList[chatList.length - 1]?.agent_thoughts && chatList[chatList.length - 1].agent_thoughts!.length > 0))) && (
  311. <div className='flex justify-center mb-4'>
  312. <Button className='flex items-center space-x-1 bg-white' onClick={() => abortResponsing?.()}>
  313. {stopIcon}
  314. <span className='text-xs text-gray-500 font-normal'>{t('appDebug.operation.stopResponding')}</span>
  315. </Button>
  316. </div>
  317. )}
  318. {
  319. isShowSuggestion && (
  320. <div className='pt-2'>
  321. <div className='flex items-center justify-center mb-2.5'>
  322. <div className='grow h-[1px]'
  323. style={{
  324. background: 'linear-gradient(270deg, #F3F4F6 0%, rgba(243, 244, 246, 0) 100%)',
  325. }}></div>
  326. <div className='shrink-0 flex items-center px-3 space-x-1'>
  327. {TryToAskIcon}
  328. <span className='text-xs text-gray-500 font-medium'>{t('appDebug.feature.suggestedQuestionsAfterAnswer.tryToAsk')}</span>
  329. </div>
  330. <div className='grow h-[1px]'
  331. style={{
  332. background: 'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, #F3F4F6 100%)',
  333. }}></div>
  334. </div>
  335. {/* has scrollbar would hide part of first item */}
  336. <div ref={suggestionListRef} className={cn(!hasScrollbar && 'justify-center', 'flex overflow-x-auto pb-2')}>
  337. {suggestionList?.map((item, index) => (
  338. <div key={item} className='shrink-0 flex justify-center mr-2'>
  339. <Button
  340. key={index}
  341. onClick={() => onQueryChange(item)}
  342. >
  343. <span className='text-primary-600 text-xs font-medium'>{item}</span>
  344. </Button>
  345. </div>
  346. ))}
  347. </div>
  348. </div>)
  349. }
  350. <div className={cn('p-[5.5px] max-h-[150px] bg-white border-[1.5px] border-gray-200 rounded-xl overflow-y-auto', isDragActive && 'border-primary-600')}>
  351. {
  352. visionConfig?.enabled && (
  353. <>
  354. <div className='absolute bottom-2 left-2 flex items-center'>
  355. <ChatImageUploader
  356. settings={visionConfig}
  357. onUpload={onUpload}
  358. disabled={files.length >= visionConfig.number_limits}
  359. />
  360. <div className='mx-1 w-[1px] h-4 bg-black/5' />
  361. </div>
  362. <div className='pl-[52px]'>
  363. <ImageList
  364. list={files}
  365. onRemove={onRemove}
  366. onReUpload={onReUpload}
  367. onImageLinkLoadSuccess={onImageLinkLoadSuccess}
  368. onImageLinkLoadError={onImageLinkLoadError}
  369. />
  370. </div>
  371. </>
  372. )
  373. }
  374. <Textarea
  375. className={`
  376. block w-full px-2 pr-[118px] py-[7px] leading-5 max-h-none text-sm text-gray-700 outline-none appearance-none resize-none
  377. ${visionConfig?.enabled && 'pl-12'}
  378. `}
  379. value={query}
  380. onChange={handleContentChange}
  381. onKeyUp={handleKeyUp}
  382. onKeyDown={handleKeyDown}
  383. onPaste={onPaste}
  384. onDragEnter={onDragEnter}
  385. onDragLeave={onDragLeave}
  386. onDragOver={onDragOver}
  387. onDrop={onDrop}
  388. autoSize
  389. />
  390. <div className="absolute bottom-2 right-2 flex items-center h-8">
  391. <div className={`${s.count} mr-4 h-5 leading-5 text-sm bg-gray-50 text-gray-500`}>{query.trim().length}</div>
  392. {
  393. query
  394. ? (
  395. <div className='flex justify-center items-center w-8 h-8 cursor-pointer hover:bg-gray-100 rounded-lg' onClick={() => onQueryChange('')}>
  396. <XCircle className='w-4 h-4 text-[#98A2B3]' />
  397. </div>
  398. )
  399. : isShowSpeechToText
  400. ? (
  401. <div
  402. className='group flex justify-center items-center w-8 h-8 hover:bg-primary-50 rounded-lg cursor-pointer'
  403. onClick={handleVoiceInputShow}
  404. >
  405. <Microphone01 className='block w-4 h-4 text-gray-500 group-hover:hidden' />
  406. <Microphone01Solid className='hidden w-4 h-4 text-primary-600 group-hover:block' />
  407. </div>
  408. )
  409. : null
  410. }
  411. <div className='mx-2 w-[1px] h-4 bg-black opacity-5' />
  412. {isMobile
  413. ? sendBtn
  414. : (
  415. <TooltipPlus
  416. popupContent={
  417. <div>
  418. <div>{t('common.operation.send')} Enter</div>
  419. <div>{t('common.operation.lineBreak')} Shift Enter</div>
  420. </div>
  421. }
  422. >
  423. {sendBtn}
  424. </TooltipPlus>
  425. )}
  426. </div>
  427. {
  428. voiceInputShow && (
  429. <VoiceInput
  430. onCancel={() => setVoiceInputShow(false)}
  431. onConverted={text => onQueryChange(text)}
  432. />
  433. )
  434. }
  435. </div>
  436. </div>
  437. )
  438. }
  439. </div>
  440. )
  441. }
  442. export default React.memo(Chat)