index.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. 'use client'
  2. import type { FC } from 'react'
  3. import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
  4. import { useContext } from 'use-context-selector'
  5. import cn from 'classnames'
  6. import Recorder from 'js-audio-recorder'
  7. import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
  8. import { UserCircleIcon } from '@heroicons/react/24/solid'
  9. import { useTranslation } from 'react-i18next'
  10. import { randomString } from '../../app-sidebar/basic'
  11. import s from './style.module.css'
  12. import LoadingAnim from './loading-anim'
  13. import CopyBtn from './copy-btn'
  14. import Tooltip from '@/app/components/base/tooltip'
  15. import { ToastContext } from '@/app/components/base/toast'
  16. import AutoHeightTextarea from '@/app/components/base/auto-height-textarea'
  17. import Button from '@/app/components/base/button'
  18. import type { Annotation, MessageRating } from '@/models/log'
  19. import AppContext from '@/context/app-context'
  20. import { Markdown } from '@/app/components/base/markdown'
  21. import { formatNumber } from '@/utils/format'
  22. import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
  23. import VoiceInput from '@/app/components/base/voice-input'
  24. import { Microphone01 } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
  25. import { Microphone01 as Microphone01Solid } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
  26. import { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
  27. const stopIcon = (
  28. <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
  29. <path fillRule="evenodd" clipRule="evenodd" d="M7.00004 0.583313C3.45621 0.583313 0.583374 3.45615 0.583374 6.99998C0.583374 10.5438 3.45621 13.4166 7.00004 13.4166C10.5439 13.4166 13.4167 10.5438 13.4167 6.99998C13.4167 3.45615 10.5439 0.583313 7.00004 0.583313ZM4.73029 4.98515C4.66671 5.10993 4.66671 5.27328 4.66671 5.59998V8.39998C4.66671 8.72668 4.66671 8.89003 4.73029 9.01481C4.78621 9.12457 4.87545 9.21381 4.98521 9.26973C5.10999 9.33331 5.27334 9.33331 5.60004 9.33331H8.40004C8.72674 9.33331 8.89009 9.33331 9.01487 9.26973C9.12463 9.21381 9.21387 9.12457 9.2698 9.01481C9.33337 8.89003 9.33337 8.72668 9.33337 8.39998V5.59998C9.33337 5.27328 9.33337 5.10993 9.2698 4.98515C9.21387 4.87539 9.12463 4.78615 9.01487 4.73023C8.89009 4.66665 8.72674 4.66665 8.40004 4.66665H5.60004C5.27334 4.66665 5.10999 4.66665 4.98521 4.73023C4.87545 4.78615 4.78621 4.87539 4.73029 4.98515Z" fill="#667085" />
  30. </svg>
  31. )
  32. export type Feedbacktype = {
  33. rating: MessageRating
  34. content?: string | null
  35. }
  36. export type FeedbackFunc = (messageId: string, feedback: Feedbacktype) => Promise<any>
  37. export type SubmitAnnotationFunc = (messageId: string, content: string) => Promise<any>
  38. export type DisplayScene = 'web' | 'console'
  39. export type IChatProps = {
  40. chatList: IChatItem[]
  41. /**
  42. * Whether to display the editing area and rating status
  43. */
  44. feedbackDisabled?: boolean
  45. /**
  46. * Whether to display the input area
  47. */
  48. isHideFeedbackEdit?: boolean
  49. isHideSendInput?: boolean
  50. onFeedback?: FeedbackFunc
  51. onSubmitAnnotation?: SubmitAnnotationFunc
  52. checkCanSend?: () => boolean
  53. onSend?: (message: string) => void
  54. displayScene?: DisplayScene
  55. useCurrentUserAvatar?: boolean
  56. isResponsing?: boolean
  57. canStopResponsing?: boolean
  58. abortResponsing?: () => void
  59. controlClearQuery?: number
  60. controlFocus?: number
  61. isShowSuggestion?: boolean
  62. suggestionList?: string[]
  63. isShowSpeechToText?: boolean
  64. }
  65. export type MessageMore = {
  66. time: string
  67. tokens: number
  68. latency: number | string
  69. }
  70. export type IChatItem = {
  71. id: string
  72. content: string
  73. /**
  74. * Specific message type
  75. */
  76. isAnswer: boolean
  77. /**
  78. * The user feedback result of this message
  79. */
  80. feedback?: Feedbacktype
  81. /**
  82. * The admin feedback result of this message
  83. */
  84. adminFeedback?: Feedbacktype
  85. /**
  86. * Whether to hide the feedback area
  87. */
  88. feedbackDisabled?: boolean
  89. /**
  90. * More information about this message
  91. */
  92. more?: MessageMore
  93. annotation?: Annotation
  94. useCurrentUserAvatar?: boolean
  95. isOpeningStatement?: boolean
  96. }
  97. const OperationBtn = ({ innerContent, onClick, className }: { innerContent: React.ReactNode; onClick?: () => void; className?: string }) => (
  98. <div
  99. 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 ${className ?? ''}`}
  100. style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
  101. onClick={onClick && onClick}
  102. >
  103. {innerContent}
  104. </div>
  105. )
  106. const MoreInfo: FC<{ more: MessageMore; isQuestion: boolean }> = ({ more, isQuestion }) => {
  107. const { t } = useTranslation()
  108. return (<div className={`mt-1 space-x-2 text-xs text-gray-400 ${isQuestion ? 'mr-2 text-right ' : 'ml-2 text-left float-right'}`}>
  109. <span>{`${t('appLog.detail.timeConsuming')} ${more.latency}${t('appLog.detail.second')}`}</span>
  110. <span>{`${t('appLog.detail.tokenCost')} ${formatNumber(more.tokens)}`}</span>
  111. <span>· </span>
  112. <span>{more.time} </span>
  113. </div>)
  114. }
  115. const OpeningStatementIcon: FC<{ className?: string }> = ({ className }) => (
  116. <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
  117. <path fillRule="evenodd" clipRule="evenodd" d="M6.25002 1C3.62667 1 1.50002 3.12665 1.50002 5.75C1.50002 6.28 1.58702 6.79071 1.7479 7.26801C1.7762 7.35196 1.79285 7.40164 1.80368 7.43828L1.80722 7.45061L1.80535 7.45452C1.79249 7.48102 1.77339 7.51661 1.73766 7.58274L0.911727 9.11152C0.860537 9.20622 0.807123 9.30503 0.770392 9.39095C0.733879 9.47635 0.674738 9.63304 0.703838 9.81878C0.737949 10.0365 0.866092 10.2282 1.05423 10.343C1.21474 10.4409 1.38213 10.4461 1.475 10.4451C1.56844 10.444 1.68015 10.4324 1.78723 10.4213L4.36472 10.1549C4.406 10.1506 4.42758 10.1484 4.44339 10.1472L4.44542 10.147L4.45161 10.1492C4.47103 10.1562 4.49738 10.1663 4.54285 10.1838C5.07332 10.3882 5.64921 10.5 6.25002 10.5C8.87338 10.5 11 8.37335 11 5.75C11 3.12665 8.87338 1 6.25002 1ZM4.48481 4.29111C5.04844 3.81548 5.7986 3.9552 6.24846 4.47463C6.69831 3.9552 7.43879 3.82048 8.01211 4.29111C8.58544 4.76175 8.6551 5.562 8.21247 6.12453C7.93825 6.47305 7.24997 7.10957 6.76594 7.54348C6.58814 7.70286 6.49924 7.78255 6.39255 7.81466C6.30103 7.84221 6.19589 7.84221 6.10436 7.81466C5.99767 7.78255 5.90878 7.70286 5.73098 7.54348C5.24694 7.10957 4.55867 6.47305 4.28444 6.12453C3.84182 5.562 3.92117 4.76675 4.48481 4.29111Z" fill="#667085" />
  118. </svg>
  119. )
  120. const RatingIcon: FC<{ isLike: boolean }> = ({ isLike }) => {
  121. return isLike ? <HandThumbUpIcon className='w-4 h-4' /> : <HandThumbDownIcon className='w-4 h-4' />
  122. }
  123. const EditIcon: FC<{ className?: string }> = ({ className }) => {
  124. return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
  125. <path d="M14 11.9998L13.3332 12.7292C12.9796 13.1159 12.5001 13.3332 12.0001 13.3332C11.5001 13.3332 11.0205 13.1159 10.6669 12.7292C10.3128 12.3432 9.83332 12.1265 9.33345 12.1265C8.83359 12.1265 8.35409 12.3432 7.99998 12.7292M2 13.3332H3.11636C3.44248 13.3332 3.60554 13.3332 3.75899 13.2963C3.89504 13.2637 4.0251 13.2098 4.1444 13.1367C4.27895 13.0542 4.39425 12.9389 4.62486 12.7083L13 4.33316C13.5523 3.78087 13.5523 2.88544 13 2.33316C12.4477 1.78087 11.5523 1.78087 11 2.33316L2.62484 10.7083C2.39424 10.9389 2.27894 11.0542 2.19648 11.1888C2.12338 11.3081 2.0695 11.4381 2.03684 11.5742C2 11.7276 2 11.8907 2 12.2168V13.3332Z" stroke="#6B7280" strokeLinecap="round" strokeLinejoin="round" />
  126. </svg>
  127. }
  128. export const EditIconSolid: FC<{ className?: string }> = ({ className }) => {
  129. return <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
  130. <path fillRule="evenodd" clipRule="evenodd" d="M10.8374 8.63108C11.0412 8.81739 11.0554 9.13366 10.8691 9.33747L10.369 9.88449C10.0142 10.2725 9.52293 10.5001 9.00011 10.5001C8.47746 10.5001 7.98634 10.2727 7.63157 9.8849C7.45561 9.69325 7.22747 9.59515 7.00014 9.59515C6.77271 9.59515 6.54446 9.69335 6.36846 9.88517C6.18177 10.0886 5.86548 10.1023 5.66201 9.91556C5.45853 9.72888 5.44493 9.41259 5.63161 9.20911C5.98678 8.82201 6.47777 8.59515 7.00014 8.59515C7.52251 8.59515 8.0135 8.82201 8.36867 9.20911L8.36924 9.20974C8.54486 9.4018 8.77291 9.50012 9.00011 9.50012C9.2273 9.50012 9.45533 9.40182 9.63095 9.20979L10.131 8.66276C10.3173 8.45895 10.6336 8.44476 10.8374 8.63108Z" fill="#6B7280" />
  131. <path fillRule="evenodd" clipRule="evenodd" d="M7.89651 1.39656C8.50599 0.787085 9.49414 0.787084 10.1036 1.39656C10.7131 2.00604 10.7131 2.99419 10.1036 3.60367L3.82225 9.88504C3.81235 9.89494 3.80254 9.90476 3.79281 9.91451C3.64909 10.0585 3.52237 10.1855 3.3696 10.2791C3.23539 10.3613 3.08907 10.4219 2.93602 10.4587C2.7618 10.5005 2.58242 10.5003 2.37897 10.5001C2.3652 10.5001 2.35132 10.5001 2.33732 10.5001H1.50005C1.22391 10.5001 1.00005 10.2763 1.00005 10.0001V9.16286C1.00005 9.14886 1.00004 9.13497 1.00003 9.1212C0.999836 8.91776 0.999669 8.73838 1.0415 8.56416C1.07824 8.4111 1.13885 8.26479 1.22109 8.13058C1.31471 7.97781 1.44166 7.85109 1.58566 7.70736C1.5954 7.69764 1.60523 7.68783 1.61513 7.67793L7.89651 1.39656Z" fill="#6B7280" />
  132. </svg>
  133. }
  134. const TryToAskIcon = (
  135. <svg width="11" height="10" viewBox="0 0 11 10" fill="none" xmlns="http://www.w3.org/2000/svg">
  136. <path d="M5.88889 0.683718C5.827 0.522805 5.67241 0.416626 5.5 0.416626C5.3276 0.416626 5.173 0.522805 5.11111 0.683718L4.27279 2.86334C4.14762 3.18877 4.10829 3.28255 4.05449 3.35821C4.00051 3.43413 3.93418 3.50047 3.85826 3.55445C3.78259 3.60825 3.68881 3.64758 3.36338 3.77275L1.18376 4.61106C1.02285 4.67295 0.916668 4.82755 0.916668 4.99996C0.916668 5.17236 1.02285 5.32696 1.18376 5.38885L3.36338 6.22717C3.68881 6.35234 3.78259 6.39167 3.85826 6.44547C3.93418 6.49945 4.00051 6.56578 4.05449 6.6417C4.10829 6.71737 4.14762 6.81115 4.27279 7.13658L5.11111 9.3162C5.173 9.47711 5.3276 9.58329 5.5 9.58329C5.67241 9.58329 5.82701 9.47711 5.8889 9.3162L6.72721 7.13658C6.85238 6.81115 6.89171 6.71737 6.94551 6.6417C6.99949 6.56578 7.06583 6.49945 7.14175 6.44547C7.21741 6.39167 7.31119 6.35234 7.63662 6.22717L9.81624 5.38885C9.97715 5.32696 10.0833 5.17236 10.0833 4.99996C10.0833 4.82755 9.97715 4.67295 9.81624 4.61106L7.63662 3.77275C7.31119 3.64758 7.21741 3.60825 7.14175 3.55445C7.06583 3.50047 6.99949 3.43413 6.94551 3.35821C6.89171 3.28255 6.85238 3.18877 6.72721 2.86334L5.88889 0.683718Z" fill="#667085" />
  137. </svg>
  138. )
  139. const Divider: FC<{ name: string }> = ({ name }) => {
  140. const { t } = useTranslation()
  141. return <div className='flex items-center my-2'>
  142. <span className='text-xs text-gray-500 inline-flex items-center mr-2'>
  143. <EditIconSolid className='mr-1' />{t('appLog.detail.annotationTip', { user: name })}
  144. </span>
  145. <div className='h-[1px] bg-gray-200 flex-1'></div>
  146. </div>
  147. }
  148. const IconWrapper: FC<{ children: React.ReactNode | string }> = ({ children }) => {
  149. return <div className={'rounded-lg h-6 w-6 flex items-center justify-center hover:bg-gray-100'}>
  150. {children}
  151. </div>
  152. }
  153. type IAnswerProps = {
  154. item: IChatItem
  155. feedbackDisabled: boolean
  156. isHideFeedbackEdit: boolean
  157. onFeedback?: FeedbackFunc
  158. onSubmitAnnotation?: SubmitAnnotationFunc
  159. displayScene: DisplayScene
  160. isResponsing?: boolean
  161. }
  162. // The component needs to maintain its own state to control whether to display input component
  163. const Answer: FC<IAnswerProps> = ({ item, feedbackDisabled = false, isHideFeedbackEdit = false, onFeedback, onSubmitAnnotation, displayScene = 'web', isResponsing }) => {
  164. const { id, content, more, feedback, adminFeedback, annotation: initAnnotation } = item
  165. const [showEdit, setShowEdit] = useState(false)
  166. const [loading, setLoading] = useState(false)
  167. const [annotation, setAnnotation] = useState<Annotation | undefined | null>(initAnnotation)
  168. const [inputValue, setInputValue] = useState<string>(initAnnotation?.content ?? '')
  169. const [localAdminFeedback, setLocalAdminFeedback] = useState<Feedbacktype | undefined | null>(adminFeedback)
  170. const { userProfile } = useContext(AppContext)
  171. const { t } = useTranslation()
  172. /**
  173. * Render feedback results (distinguish between users and administrators)
  174. * User reviews cannot be cancelled in Console
  175. * @param rating feedback result
  176. * @param isUserFeedback Whether it is user's feedback
  177. * @param isWebScene Whether it is web scene
  178. * @returns comp
  179. */
  180. const renderFeedbackRating = (rating: MessageRating | undefined, isUserFeedback = true, isWebScene = true) => {
  181. if (!rating)
  182. return null
  183. const isLike = rating === 'like'
  184. const ratingIconClassname = isLike ? 'text-primary-600 bg-primary-100 hover:bg-primary-200' : 'text-red-600 bg-red-100 hover:bg-red-200'
  185. const UserSymbol = <UserCircleIcon className='absolute top-[-2px] left-[18px] w-3 h-3 rounded-lg text-gray-400 bg-white' />
  186. // The tooltip is always displayed, but the content is different for different scenarios.
  187. return (
  188. <Tooltip
  189. selector={`user-feedback-${randomString(16)}`}
  190. 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}
  191. >
  192. <div
  193. 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' : ''}`}
  194. style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
  195. {...((isWebScene || (!isUserFeedback && !isWebScene))
  196. ? {
  197. onClick: async () => {
  198. const res = await onFeedback?.(id, { rating: null })
  199. if (res && !isWebScene)
  200. setLocalAdminFeedback({ rating: null })
  201. },
  202. }
  203. : {})}
  204. >
  205. <div className={`${ratingIconClassname} rounded-lg h-6 w-6 flex items-center justify-center`}>
  206. <RatingIcon isLike={isLike} />
  207. </div>
  208. {!isWebScene && isUserFeedback && UserSymbol}
  209. </div>
  210. </Tooltip>
  211. )
  212. }
  213. /**
  214. * Different scenarios have different operation items.
  215. * @param isWebScene Whether it is web scene
  216. * @returns comp
  217. */
  218. const renderItemOperation = (isWebScene = true) => {
  219. const userOperation = () => {
  220. return feedback?.rating
  221. ? null
  222. : <div className='flex gap-1'>
  223. <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('appLog.detail.operation.like') as string}>
  224. {OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={true} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'like' }) })}
  225. </Tooltip>
  226. <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('appLog.detail.operation.dislike') as string}>
  227. {OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={false} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'dislike' }) })}
  228. </Tooltip>
  229. </div>
  230. }
  231. const adminOperation = () => {
  232. return <div className='flex gap-1'>
  233. <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('appLog.detail.operation.addAnnotation') as string}>
  234. {OperationBtn({
  235. innerContent: <IconWrapper><EditIcon className='hover:text-gray-800' /></IconWrapper>,
  236. onClick: () => setShowEdit(true),
  237. })}
  238. </Tooltip>
  239. {!localAdminFeedback?.rating && <>
  240. <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('appLog.detail.operation.like') as string}>
  241. {OperationBtn({
  242. innerContent: <IconWrapper><RatingIcon isLike={true} /></IconWrapper>,
  243. onClick: async () => {
  244. const res = await onFeedback?.(id, { rating: 'like' })
  245. if (res)
  246. setLocalAdminFeedback({ rating: 'like' })
  247. },
  248. })}
  249. </Tooltip>
  250. <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('appLog.detail.operation.dislike') as string}>
  251. {OperationBtn({
  252. innerContent: <IconWrapper><RatingIcon isLike={false} /></IconWrapper>,
  253. onClick: async () => {
  254. const res = await onFeedback?.(id, { rating: 'dislike' })
  255. if (res)
  256. setLocalAdminFeedback({ rating: 'dislike' })
  257. },
  258. })}
  259. </Tooltip>
  260. </>}
  261. </div>
  262. }
  263. return (
  264. <div className={`${s.itemOperation} flex gap-2`}>
  265. {isWebScene ? userOperation() : adminOperation()}
  266. </div>
  267. )
  268. }
  269. return (
  270. <div key={id}>
  271. <div className='flex items-start'>
  272. <div className={`${s.answerIcon} w-10 h-10 shrink-0`}>
  273. {isResponsing
  274. && <div className={s.typeingIcon}>
  275. <LoadingAnim type='avatar' />
  276. </div>
  277. }
  278. </div>
  279. <div className={s.answerWrapWrap}>
  280. <div className={`${s.answerWrap} ${showEdit ? 'w-full' : ''}`}>
  281. <div className={`${s.answer} relative text-sm text-gray-900`}>
  282. <div className={'ml-2 py-3 px-4 bg-gray-100 rounded-tr-2xl rounded-b-2xl'}>
  283. {item.isOpeningStatement && (
  284. <div className='flex items-center mb-1 gap-1'>
  285. <OpeningStatementIcon />
  286. <div className='text-xs text-gray-500'>{t('appDebug.openingStatement.title')}</div>
  287. </div>
  288. )}
  289. {(isResponsing && !content)
  290. ? (
  291. <div className='flex items-center justify-center w-6 h-5'>
  292. <LoadingAnim type='text' />
  293. </div>
  294. )
  295. : (
  296. <Markdown content={content} />
  297. )}
  298. {!showEdit
  299. ? (annotation?.content
  300. && <>
  301. <Divider name={annotation?.account?.name || userProfile?.name} />
  302. {annotation.content}
  303. </>)
  304. : <>
  305. <Divider name={annotation?.account?.name || userProfile?.name} />
  306. <AutoHeightTextarea
  307. placeholder={t('appLog.detail.operation.annotationPlaceholder') as string}
  308. value={inputValue}
  309. onChange={e => setInputValue(e.target.value)}
  310. minHeight={58}
  311. className={`${cn(s.textArea)} !py-2 resize-none block w-full !px-3 bg-gray-50 border border-gray-200 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm text-gray-700 tracking-[0.2px]`}
  312. />
  313. <div className="mt-2 flex flex-row">
  314. <Button
  315. type='primary'
  316. className='mr-2'
  317. loading={loading}
  318. onClick={async () => {
  319. if (!inputValue)
  320. return
  321. setLoading(true)
  322. const res = await onSubmitAnnotation?.(id, inputValue)
  323. if (res)
  324. setAnnotation({ ...annotation, content: inputValue } as any)
  325. setLoading(false)
  326. setShowEdit(false)
  327. }}>{t('common.operation.confirm')}</Button>
  328. <Button
  329. onClick={() => {
  330. setInputValue(annotation?.content ?? '')
  331. setShowEdit(false)
  332. }}>{t('common.operation.cancel')}</Button>
  333. </div>
  334. </>
  335. }
  336. </div>
  337. <div className='absolute top-[-14px] right-[-14px] flex flex-row justify-end gap-1'>
  338. {!item.isOpeningStatement && (
  339. <CopyBtn
  340. value={content}
  341. className={cn(s.copyBtn, 'mr-1')}
  342. />
  343. )}
  344. {!feedbackDisabled && !item.feedbackDisabled && renderItemOperation(displayScene !== 'console')}
  345. {/* Admin feedback is displayed only in the background. */}
  346. {!feedbackDisabled && renderFeedbackRating(localAdminFeedback?.rating, false, false)}
  347. {/* User feedback must be displayed */}
  348. {!feedbackDisabled && renderFeedbackRating(feedback?.rating, !isHideFeedbackEdit, displayScene !== 'console')}
  349. </div>
  350. </div>
  351. {more && <MoreInfo more={more} isQuestion={false} />}
  352. </div>
  353. </div>
  354. </div>
  355. </div>
  356. )
  357. }
  358. type IQuestionProps = Pick<IChatItem, 'id' | 'content' | 'more' | 'useCurrentUserAvatar'>
  359. const Question: FC<IQuestionProps> = ({ id, content, more, useCurrentUserAvatar }) => {
  360. const { userProfile } = useContext(AppContext)
  361. const userName = userProfile?.name
  362. return (
  363. <div className='flex items-start justify-end' key={id}>
  364. <div className={s.questionWrapWrap}>
  365. <div className={`${s.question} relative text-sm text-gray-900`}>
  366. <div
  367. className={'mr-2 py-3 px-4 bg-blue-500 rounded-tl-2xl rounded-b-2xl'}
  368. >
  369. <Markdown content={content} />
  370. </div>
  371. </div>
  372. {more && <MoreInfo more={more} isQuestion={true} />}
  373. </div>
  374. {useCurrentUserAvatar
  375. ? (
  376. <div className='w-10 h-10 shrink-0 leading-10 text-center mr-2 rounded-full bg-primary-600 text-white'>
  377. {userName?.[0].toLocaleUpperCase()}
  378. </div>
  379. )
  380. : (
  381. <div className={`${s.questionIcon} w-10 h-10 shrink-0 `}></div>
  382. )}
  383. </div>
  384. )
  385. }
  386. const Chat: FC<IChatProps> = ({
  387. chatList,
  388. feedbackDisabled = false,
  389. isHideFeedbackEdit = false,
  390. isHideSendInput = false,
  391. onFeedback,
  392. onSubmitAnnotation,
  393. checkCanSend,
  394. onSend = () => { },
  395. displayScene,
  396. useCurrentUserAvatar,
  397. isResponsing,
  398. canStopResponsing,
  399. abortResponsing,
  400. controlClearQuery,
  401. controlFocus,
  402. isShowSuggestion,
  403. suggestionList,
  404. isShowSpeechToText,
  405. }) => {
  406. const { t } = useTranslation()
  407. const { notify } = useContext(ToastContext)
  408. const isUseInputMethod = useRef(false)
  409. const [query, setQuery] = React.useState('')
  410. const handleContentChange = (e: any) => {
  411. const value = e.target.value
  412. setQuery(value)
  413. }
  414. const logError = (message: string) => {
  415. notify({ type: 'error', message, duration: 3000 })
  416. }
  417. const valid = () => {
  418. if (!query || query.trim() === '') {
  419. logError('Message cannot be empty')
  420. return false
  421. }
  422. return true
  423. }
  424. useEffect(() => {
  425. if (controlClearQuery)
  426. setQuery('')
  427. }, [controlClearQuery])
  428. const handleSend = () => {
  429. if (!valid() || (checkCanSend && !checkCanSend()))
  430. return
  431. onSend(query)
  432. if (!isResponsing)
  433. setQuery('')
  434. }
  435. const handleKeyUp = (e: any) => {
  436. if (e.code === 'Enter') {
  437. e.preventDefault()
  438. // prevent send message when using input method enter
  439. if (!e.shiftKey && !isUseInputMethod.current)
  440. handleSend()
  441. }
  442. }
  443. const handleKeyDown = (e: any) => {
  444. isUseInputMethod.current = e.nativeEvent.isComposing
  445. if (e.code === 'Enter' && !e.shiftKey) {
  446. setQuery(query.replace(/\n$/, ''))
  447. e.preventDefault()
  448. }
  449. }
  450. const media = useBreakpoints()
  451. const isMobile = media === MediaType.mobile
  452. const sendBtn = <div className={cn(!(!query || query.trim() === '') && s.sendBtnActive, `${s.sendBtn} w-8 h-8 cursor-pointer rounded-md`)} onClick={handleSend}></div>
  453. const suggestionListRef = useRef<HTMLDivElement>(null)
  454. const [hasScrollbar, setHasScrollbar] = useState(false)
  455. useLayoutEffect(() => {
  456. if (suggestionListRef.current) {
  457. const listDom = suggestionListRef.current
  458. const hasScrollbar = listDom.scrollWidth > listDom.clientWidth
  459. setHasScrollbar(hasScrollbar)
  460. }
  461. }, [suggestionList])
  462. const [voiceInputShow, setVoiceInputShow] = useState(false)
  463. const handleVoiceInputShow = () => {
  464. (Recorder as any).getPermission().then(() => {
  465. setVoiceInputShow(true)
  466. }, () => {
  467. logError(t('common.voiceInput.notAllow'))
  468. })
  469. }
  470. return (
  471. <div className={cn('px-3.5', 'h-full')}>
  472. {/* Chat List */}
  473. <div className="h-full space-y-[30px]">
  474. {chatList.map((item) => {
  475. if (item.isAnswer) {
  476. const isLast = item.id === chatList[chatList.length - 1].id
  477. return <Answer
  478. key={item.id}
  479. item={item}
  480. feedbackDisabled={feedbackDisabled}
  481. isHideFeedbackEdit={isHideFeedbackEdit}
  482. onFeedback={onFeedback}
  483. onSubmitAnnotation={onSubmitAnnotation}
  484. displayScene={displayScene ?? 'web'}
  485. isResponsing={isResponsing && isLast}
  486. />
  487. }
  488. return <Question key={item.id} id={item.id} content={item.content} more={item.more} useCurrentUserAvatar={useCurrentUserAvatar} />
  489. })}
  490. </div>
  491. {
  492. !isHideSendInput && (
  493. <div className={cn(!feedbackDisabled && '!left-3.5 !right-3.5', 'absolute z-10 bottom-0 left-0 right-0')}>
  494. {(isResponsing && canStopResponsing) && (
  495. <div className='flex justify-center mb-4'>
  496. <Button className='flex items-center space-x-1 bg-white' onClick={() => abortResponsing?.()}>
  497. {stopIcon}
  498. <span className='text-xs text-gray-500 font-normal'>{t('appDebug.operation.stopResponding')}</span>
  499. </Button>
  500. </div>
  501. )}
  502. {
  503. isShowSuggestion && (
  504. <div className='pt-2 mb-2 '>
  505. <div className='flex items-center justify-center mb-2.5'>
  506. <div className='grow h-[1px]'
  507. style={{
  508. background: 'linear-gradient(270deg, #F3F4F6 0%, rgba(243, 244, 246, 0) 100%)',
  509. }}></div>
  510. <div className='shrink-0 flex items-center px-3 space-x-1'>
  511. {TryToAskIcon}
  512. <span className='text-xs text-gray-500 font-medium'>{t('appDebug.feature.suggestedQuestionsAfterAnswer.tryToAsk')}</span>
  513. </div>
  514. <div className='grow h-[1px]'
  515. style={{
  516. background: 'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, #F3F4F6 100%)',
  517. }}></div>
  518. </div>
  519. {/* has scrollbar would hide part of first item */}
  520. <div ref={suggestionListRef} className={cn(!hasScrollbar && 'justify-center', 'flex overflow-x-auto pb-2')}>
  521. {suggestionList?.map((item, index) => (
  522. <div className='shrink-0 flex justify-center mr-2'>
  523. <Button
  524. key={index}
  525. onClick={() => setQuery(item)}
  526. >
  527. <span className='text-primary-600 text-xs font-medium'>{item}</span>
  528. </Button>
  529. </div>
  530. ))}
  531. </div>
  532. </div>)
  533. }
  534. <div className="relative">
  535. <AutoHeightTextarea
  536. value={query}
  537. onChange={handleContentChange}
  538. onKeyUp={handleKeyUp}
  539. onKeyDown={handleKeyDown}
  540. minHeight={48}
  541. autoFocus
  542. controlFocus={controlFocus}
  543. className={`${cn(s.textArea)} resize-none block w-full pl-3 bg-gray-50 border border-gray-200 rounded-md focus:outline-none sm:text-sm text-gray-700`}
  544. />
  545. <div className="absolute top-0 right-2 flex items-center h-[48px]">
  546. <div className={`${s.count} mr-4 h-5 leading-5 text-sm bg-gray-50 text-gray-500`}>{query.trim().length}</div>
  547. {
  548. query
  549. ? (
  550. <div className='flex justify-center items-center w-8 h-8 cursor-pointer hover:bg-gray-100 rounded-lg' onClick={() => setQuery('')}>
  551. <XCircle className='w-4 h-4 text-[#98A2B3]' />
  552. </div>
  553. )
  554. : isShowSpeechToText
  555. ? (
  556. <div
  557. className='group flex justify-center items-center w-8 h-8 hover:bg-primary-50 rounded-lg cursor-pointer'
  558. onClick={handleVoiceInputShow}
  559. >
  560. <Microphone01 className='block w-4 h-4 text-gray-500 group-hover:hidden' />
  561. <Microphone01Solid className='hidden w-4 h-4 text-primary-600 group-hover:block' />
  562. </div>
  563. )
  564. : null
  565. }
  566. <div className='mx-2 w-[1px] h-4 bg-black opacity-5' />
  567. {isMobile
  568. ? sendBtn
  569. : (
  570. <Tooltip
  571. selector='send-tip'
  572. htmlContent={
  573. <div>
  574. <div>{t('common.operation.send')} Enter</div>
  575. <div>{t('common.operation.lineBreak')} Shift Enter</div>
  576. </div>
  577. }
  578. >
  579. {sendBtn}
  580. </Tooltip>
  581. )}
  582. </div>
  583. {
  584. voiceInputShow && (
  585. <VoiceInput
  586. onCancel={() => setVoiceInputShow(false)}
  587. onConverted={text => setQuery(text)}
  588. />
  589. )
  590. }
  591. </div>
  592. </div>
  593. )
  594. }
  595. </div>
  596. )
  597. }
  598. export default React.memo(Chat)