index.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. 'use client'
  2. import type { FC, ReactNode } from 'react'
  3. import React, { useState } from 'react'
  4. import { useTranslation } from 'react-i18next'
  5. import { UserCircleIcon } from '@heroicons/react/24/solid'
  6. import cn from 'classnames'
  7. import type { CitationItem, DisplayScene, FeedbackFunc, Feedbacktype, IChatItem } from '../type'
  8. import OperationBtn from '../operation'
  9. import LoadingAnim from '../loading-anim'
  10. import { EditIconSolid, RatingIcon } from '../icon-component'
  11. import s from '../style.module.css'
  12. import MoreInfo from '../more-info'
  13. import CopyBtn from '../copy-btn'
  14. import Thought from '../thought'
  15. import Citation from '../citation'
  16. import AudioBtn from '@/app/components/base/audio-btn'
  17. import { randomString } from '@/utils'
  18. import type { MessageRating } from '@/models/log'
  19. import Tooltip from '@/app/components/base/tooltip'
  20. import { Markdown } from '@/app/components/base/markdown'
  21. import type { DataSet } from '@/models/datasets'
  22. import AnnotationCtrlBtn from '@/app/components/app/configuration/toolbox/annotation/annotation-ctrl-btn'
  23. import EditReplyModal from '@/app/components/app/annotation/edit-annotation-modal'
  24. import { EditTitle } from '@/app/components/app/annotation/edit-annotation-modal/edit-item'
  25. import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
  26. import type { Emoji } from '@/app/components/tools/types'
  27. import type { VisionFile } from '@/types/app'
  28. import ImageGallery from '@/app/components/base/image-gallery'
  29. const Divider: FC<{ name: string }> = ({ name }) => {
  30. const { t } = useTranslation()
  31. return <div className='flex items-center my-2'>
  32. <span className='text-xs text-gray-500 inline-flex items-center mr-2'>
  33. <EditIconSolid className='mr-1' />{t('appLog.detail.annotationTip', { user: name })}
  34. </span>
  35. <div className='h-[1px] bg-gray-200 flex-1'></div>
  36. </div>
  37. }
  38. const IconWrapper: FC<{ children: React.ReactNode | string }> = ({ children }) => {
  39. return <div className={'rounded-lg h-6 w-6 flex items-center justify-center hover:bg-gray-100'}>
  40. {children}
  41. </div>
  42. }
  43. export type IAnswerProps = {
  44. item: IChatItem
  45. index: number
  46. feedbackDisabled: boolean
  47. isHideFeedbackEdit: boolean
  48. onQueryChange: (query: string) => void
  49. onFeedback?: FeedbackFunc
  50. displayScene: DisplayScene
  51. isResponsing?: boolean
  52. answerIcon?: ReactNode
  53. citation?: CitationItem[]
  54. dataSets?: DataSet[]
  55. isShowCitation?: boolean
  56. isShowCitationHitInfo?: boolean
  57. isShowTextToSpeech?: boolean
  58. // Annotation props
  59. supportAnnotation?: boolean
  60. appId?: string
  61. question: string
  62. onAnnotationEdited?: (question: string, answer: string, index: number) => void
  63. onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void
  64. onAnnotationRemoved?: (index: number) => void
  65. allToolIcons?: Record<string, string | Emoji>
  66. }
  67. // The component needs to maintain its own state to control whether to display input component
  68. const Answer: FC<IAnswerProps> = ({
  69. item,
  70. index,
  71. onQueryChange,
  72. feedbackDisabled = false,
  73. isHideFeedbackEdit = false,
  74. onFeedback,
  75. displayScene = 'web',
  76. isResponsing,
  77. answerIcon,
  78. citation,
  79. isShowCitation,
  80. isShowCitationHitInfo = false,
  81. isShowTextToSpeech,
  82. supportAnnotation,
  83. appId,
  84. question,
  85. onAnnotationEdited,
  86. onAnnotationAdded,
  87. onAnnotationRemoved,
  88. allToolIcons,
  89. }) => {
  90. const { id, content, more, feedback, adminFeedback, annotation, agent_thoughts } = item
  91. const isAgentMode = !!agent_thoughts && agent_thoughts.length > 0
  92. const hasAnnotation = !!annotation?.id
  93. const [showEdit, setShowEdit] = useState(false)
  94. const [loading, setLoading] = useState(false)
  95. // const [annotation, setAnnotation] = useState<Annotation | undefined | null>(initAnnotation)
  96. // const [inputValue, setInputValue] = useState<string>(initAnnotation?.content ?? '')
  97. const [localAdminFeedback, setLocalAdminFeedback] = useState<Feedbacktype | undefined | null>(adminFeedback)
  98. // const { userProfile } = useContext(AppContext)
  99. const { t } = useTranslation()
  100. const [isShowReplyModal, setIsShowReplyModal] = useState(false)
  101. /**
  102. * Render feedback results (distinguish between users and administrators)
  103. * User reviews cannot be cancelled in Console
  104. * @param rating feedback result
  105. * @param isUserFeedback Whether it is user's feedback
  106. * @param isWebScene Whether it is web scene
  107. * @returns comp
  108. */
  109. const renderFeedbackRating = (rating: MessageRating | undefined, isUserFeedback = true, isWebScene = true) => {
  110. if (!rating)
  111. return null
  112. const isLike = rating === 'like'
  113. const ratingIconClassname = isLike ? 'text-primary-600 bg-primary-100 hover:bg-primary-200' : 'text-red-600 bg-red-100 hover:bg-red-200'
  114. const UserSymbol = <UserCircleIcon className='absolute top-[-2px] left-[18px] w-3 h-3 rounded-lg text-gray-400 bg-white' />
  115. // The tooltip is always displayed, but the content is different for different scenarios.
  116. return (
  117. <Tooltip
  118. selector={`user-feedback-${randomString(16)}`}
  119. content={((isWebScene || (!isUserFeedback && !isWebScene)) ? isLike ? t('appDebug.operation.cancelAgree') : t('appDebug.operation.cancelDisagree') : (!isWebScene && isUserFeedback) ? `${t('appDebug.operation.userAction')}${isLike ? t('appDebug.operation.agree') : t('appDebug.operation.disagree')}` : '') as string}
  120. >
  121. <div
  122. className={`relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-gray-500 hover:text-gray-800 ${(!isWebScene && isUserFeedback) ? '!cursor-default' : ''}`}
  123. style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
  124. {...((isWebScene || (!isUserFeedback && !isWebScene))
  125. ? {
  126. onClick: async () => {
  127. const res = await onFeedback?.(id, { rating: null })
  128. if (res && !isWebScene)
  129. setLocalAdminFeedback({ rating: null })
  130. },
  131. }
  132. : {})}
  133. >
  134. <div className={`${ratingIconClassname} rounded-lg h-6 w-6 flex items-center justify-center`}>
  135. <RatingIcon isLike={isLike} />
  136. </div>
  137. {!isWebScene && isUserFeedback && UserSymbol}
  138. </div>
  139. </Tooltip>
  140. )
  141. }
  142. const renderHasAnnotationBtn = () => {
  143. return (
  144. <div
  145. className={cn(s.hasAnnotationBtn, 'relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-[#444CE7]')}
  146. style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
  147. >
  148. <div className='p-1 rounded-lg bg-[#EEF4FF] '>
  149. <MessageFast className='w-4 h-4' />
  150. </div>
  151. </div>
  152. )
  153. }
  154. /**
  155. * Different scenarios have different operation items.
  156. * @param isWebScene Whether it is web scene
  157. * @returns comp
  158. */
  159. const renderItemOperation = (isWebScene = true) => {
  160. const userOperation = () => {
  161. return feedback?.rating
  162. ? null
  163. : <div className='flex gap-1'>
  164. <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('appLog.detail.operation.like') as string}>
  165. {OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={true} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'like' }) })}
  166. </Tooltip>
  167. <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('appLog.detail.operation.dislike') as string}>
  168. {OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={false} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'dislike' }) })}
  169. </Tooltip>
  170. </div>
  171. }
  172. const adminOperation = () => {
  173. return <div className='flex gap-1'>
  174. {!localAdminFeedback?.rating && <>
  175. <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('appLog.detail.operation.like') as string}>
  176. {OperationBtn({
  177. innerContent: <IconWrapper><RatingIcon isLike={true} /></IconWrapper>,
  178. onClick: async () => {
  179. const res = await onFeedback?.(id, { rating: 'like' })
  180. if (res)
  181. setLocalAdminFeedback({ rating: 'like' })
  182. },
  183. })}
  184. </Tooltip>
  185. <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('appLog.detail.operation.dislike') as string}>
  186. {OperationBtn({
  187. innerContent: <IconWrapper><RatingIcon isLike={false} /></IconWrapper>,
  188. onClick: async () => {
  189. const res = await onFeedback?.(id, { rating: 'dislike' })
  190. if (res)
  191. setLocalAdminFeedback({ rating: 'dislike' })
  192. },
  193. })}
  194. </Tooltip>
  195. </>}
  196. </div>
  197. }
  198. return (
  199. <div className={`${s.itemOperation} flex gap-2`}>
  200. {isWebScene ? userOperation() : adminOperation()}
  201. </div>
  202. )
  203. }
  204. const getImgs = (list?: VisionFile[]) => {
  205. if (!list)
  206. return []
  207. return list.filter(file => file.type === 'image' && file.belongs_to === 'assistant')
  208. }
  209. const agentModeAnswer = (
  210. <div>
  211. {agent_thoughts?.map((item, index) => (
  212. <div key={index}>
  213. {item.thought && (
  214. <Markdown content={item.thought} />
  215. )}
  216. {/* {item.tool} */}
  217. {/* perhaps not use tool */}
  218. {!!item.tool && (
  219. <Thought
  220. thought={item}
  221. allToolIcons={allToolIcons || {}}
  222. isFinished={!!item.observation || !isResponsing}
  223. />
  224. )}
  225. {getImgs(item.message_files).length > 0 && (
  226. <ImageGallery srcs={getImgs(item.message_files).map(item => item.url)} />
  227. )}
  228. </div>
  229. ))}
  230. </div>
  231. )
  232. return (
  233. // data-id for debug the item message is right
  234. <div key={id} data-id={id}>
  235. <div className='flex items-start'>
  236. {
  237. answerIcon || (
  238. <div className={`${s.answerIcon} w-10 h-10 shrink-0`}>
  239. {isResponsing
  240. && <div className={s.typeingIcon}>
  241. <LoadingAnim type='avatar' />
  242. </div>
  243. }
  244. </div>
  245. )
  246. }
  247. <div className={cn(s.answerWrapWrap, 'chat-answer-container group')}>
  248. <div className={`${s.answerWrap} ${showEdit ? 'w-full' : ''}`}>
  249. <div className={`${s.answer} relative text-sm text-gray-900`}>
  250. <div className={'ml-2 py-3 px-4 bg-gray-100 rounded-tr-2xl rounded-b-2xl'}>
  251. {(isResponsing && (isAgentMode ? (!content && (agent_thoughts || []).length === 0) : !content))
  252. ? (
  253. <div className='flex items-center justify-center w-6 h-5'>
  254. <LoadingAnim type='text' />
  255. </div>
  256. )
  257. : (
  258. <div>
  259. {annotation?.logAnnotation && (
  260. <div className='mb-1'>
  261. <div className='mb-3'>
  262. {isAgentMode
  263. ? (<div className='line-through !text-gray-400'>{agentModeAnswer}</div>)
  264. : (
  265. <Markdown className='line-through !text-gray-400' content={content} />
  266. )}
  267. </div>
  268. <EditTitle title={t('appAnnotation.editBy', {
  269. author: annotation?.logAnnotation.account?.name,
  270. })} />
  271. </div>
  272. )}
  273. <div>
  274. {annotation?.logAnnotation
  275. ? (
  276. <Markdown content={annotation?.logAnnotation.content || ''} />
  277. )
  278. : (isAgentMode
  279. ? agentModeAnswer
  280. : (
  281. <Markdown content={content} />
  282. ))}
  283. </div>
  284. {(hasAnnotation && !annotation?.logAnnotation) && (
  285. <EditTitle className='mt-1' title={t('appAnnotation.editBy', {
  286. author: annotation.authorName,
  287. })} />
  288. )}
  289. {item.isOpeningStatement && item.suggestedQuestions && item.suggestedQuestions.length > 0 && (
  290. <div className='flex flex-wrap'>
  291. {item.suggestedQuestions.map((question, index) => (
  292. <div
  293. key={index}
  294. className='mt-1 mr-1 max-w-full last:mr-0 shrink-0 py-[5px] leading-[18px] items-center px-4 rounded-lg border border-gray-200 shadow-xs bg-white text-xs font-medium text-primary-600 cursor-pointer'
  295. onClick={() => onQueryChange(question)}
  296. >
  297. {question}
  298. </div>),
  299. )}
  300. </div>
  301. )}
  302. </div>
  303. )}
  304. {
  305. !!citation?.length && isShowCitation && !isResponsing && (
  306. <Citation data={citation} showHitInfo={isShowCitationHitInfo} />
  307. )
  308. }
  309. </div>
  310. <div className='absolute top-[-14px] right-[-14px] flex flex-row justify-end gap-1'>
  311. {!item.isOpeningStatement && (
  312. <CopyBtn
  313. value={content}
  314. className={cn(s.copyBtn, 'mr-1')}
  315. />
  316. )}
  317. {!item.isOpeningStatement && isShowTextToSpeech && (
  318. <AudioBtn
  319. value={content}
  320. className={cn(s.playBtn, 'mr-1')}
  321. />
  322. )}
  323. {(!item.isOpeningStatement && supportAnnotation) && (
  324. <AnnotationCtrlBtn
  325. appId={appId!}
  326. messageId={id}
  327. annotationId={annotation?.id || ''}
  328. className={cn(s.annotationBtn, 'ml-1')}
  329. cached={hasAnnotation}
  330. query={question}
  331. answer={content}
  332. onAdded={(id, authorName) => onAnnotationAdded?.(id, authorName, question, content, index)}
  333. onEdit={() => setIsShowReplyModal(true)}
  334. onRemoved={() => onAnnotationRemoved!(index)}
  335. />
  336. )}
  337. <EditReplyModal
  338. isShow={isShowReplyModal}
  339. onHide={() => setIsShowReplyModal(false)}
  340. query={question}
  341. answer={content}
  342. onEdited={(editedQuery, editedAnswer) => onAnnotationEdited!(editedQuery, editedAnswer, index)}
  343. onAdded={(annotationId, authorName, editedQuery, editedAnswer) => onAnnotationAdded!(annotationId, authorName, editedQuery, editedAnswer, index)}
  344. appId={appId!}
  345. messageId={id}
  346. annotationId={annotation?.id || ''}
  347. createdAt={annotation?.created_at}
  348. onRemove={() => { }}
  349. />
  350. {hasAnnotation && renderHasAnnotationBtn()}
  351. {!feedbackDisabled && !item.feedbackDisabled && renderItemOperation(displayScene !== 'console')}
  352. {/* Admin feedback is displayed only in the background. */}
  353. {!feedbackDisabled && renderFeedbackRating(localAdminFeedback?.rating, false, false)}
  354. {/* User feedback must be displayed */}
  355. {!feedbackDisabled && renderFeedbackRating(feedback?.rating, !isHideFeedbackEdit, displayScene !== 'console')}
  356. </div>
  357. </div>
  358. {more && <MoreInfo className='invisible group-hover:visible' more={more} isQuestion={false} />}
  359. </div>
  360. </div>
  361. </div>
  362. </div>
  363. )
  364. }
  365. export default React.memo(Answer)