index.tsx 16 KB

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