| import { forwardRef, useState } from 'react'; |
| import Avatar from '../components/Avatar'; |
| import remarkGfm from 'remark-gfm'; |
| import { FEEDBACK, MESSAGE_TYPE } from './conversationModels'; |
| import classes from './ConversationBubble.module.css'; |
| import Alert from './../assets/alert.svg'; |
| import Like from './../assets/like.svg?react'; |
| import Dislike from './../assets/dislike.svg?react'; |
| import Copy from './../assets/copy.svg?react'; |
| import CheckMark from './../assets/checkmark.svg?react'; |
| import ReactMarkdown from 'react-markdown'; |
| import copy from 'copy-to-clipboard'; |
| import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; |
| import { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism'; |
| import DocsGPT3 from '../assets/cute_docsgpt3.svg'; |
|
|
| const DisableSourceFE = import.meta.env.VITE_DISABLE_SOURCE_FE || false; |
|
|
| const ConversationBubble = forwardRef< |
| HTMLDivElement, |
| { |
| message: string; |
| type: MESSAGE_TYPE; |
| className?: string; |
| feedback?: FEEDBACK; |
| handleFeedback?: (feedback: FEEDBACK) => void; |
| sources?: { title: string; text: string }[]; |
| } |
| >(function ConversationBubble( |
| { message, type, className, feedback, handleFeedback, sources }, |
| ref, |
| ) { |
| const [openSource, setOpenSource] = useState<number | null>(null); |
| const [copied, setCopied] = useState(false); |
|
|
| const handleCopyClick = (text: string) => { |
| copy(text); |
| setCopied(true); |
| |
| setTimeout(() => { |
| setCopied(false); |
| }, 3000); |
| }; |
| const [isCopyHovered, setIsCopyHovered] = useState(false); |
| const [isLikeHovered, setIsLikeHovered] = useState(false); |
| const [isDislikeHovered, setIsDislikeHovered] = useState(false); |
| const [isLikeClicked, setIsLikeClicked] = useState(false); |
| const [isDislikeClicked, setIsDislikeClicked] = useState(false); |
|
|
| let bubble; |
|
|
| if (type === 'QUESTION') { |
| bubble = ( |
| <div ref={ref} className={`flex flex-row-reverse self-end ${className}`}> |
| <Avatar className="mt-2 text-2xl" avatar="🧑💻"></Avatar> |
| <div className="mr-2 ml-10 flex items-center rounded-3xl bg-purple-30 p-3.5 text-white"> |
| <ReactMarkdown className="whitespace-pre-wrap break-all"> |
| {message} |
| </ReactMarkdown> |
| </div> |
| </div> |
| ); |
| } else { |
| bubble = ( |
| <div |
| ref={ref} |
| className={`flex self-start ${className} group flex-col pr-20 dark:text-bright-gray`} |
| > |
| <div className="flex self-start"> |
| <Avatar |
| className="mt-2 h-12 w-12 text-2xl" |
| avatar={ |
| <img |
| src={DocsGPT3} |
| alt="DocsGPT" |
| className="h-full w-full object-cover" |
| /> |
| } |
| /> |
| |
| <div |
| className={`ml-2 mr-5 flex rounded-3xl bg-gray-1000 dark:bg-gun-metal p-3.5 ${ |
| type === 'ERROR' |
| ? 'flex-row items-center rounded-full border border-transparent bg-[#FFE7E7] p-2 py-5 text-sm font-normal text-red-3000 dark:border-red-2000 dark:text-white' |
| : 'flex-col rounded-3xl' |
| }`} |
| > |
| {type === 'ERROR' && ( |
| <img src={Alert} alt="alert" className="mr-2 inline" /> |
| )} |
| <ReactMarkdown |
| className="max-w-screen-md whitespace-pre-wrap break-words" |
| remarkPlugins={[remarkGfm]} |
| components={{ |
| code({ node, inline, className, children, ...props }) { |
| const match = /language-(\w+)/.exec(className || ''); |
| |
| return !inline && match ? ( |
| <SyntaxHighlighter |
| PreTag="div" |
| language={match[1]} |
| {...props} |
| style={vscDarkPlus} |
| > |
| {String(children).replace(/\n$/, '')} |
| </SyntaxHighlighter> |
| ) : ( |
| <code className={className ? className : ''} {...props}> |
| {children} |
| </code> |
| ); |
| }, |
| ul({ children }) { |
| return ( |
| <ul |
| className={`list-inside list-disc whitespace-normal pl-4 ${classes.list}`} |
| > |
| {children} |
| </ul> |
| ); |
| }, |
| ol({ children }) { |
| return ( |
| <ol |
| className={`list-inside list-decimal whitespace-normal pl-4 ${classes.list}`} |
| > |
| {children} |
| </ol> |
| ); |
| }, |
| table({ children }) { |
| return ( |
| <div className="relative overflow-x-auto rounded-lg border"> |
| <table className="w-full text-left text-sm text-gray-700"> |
| {children} |
| </table> |
| </div> |
| ); |
| }, |
| thead({ children }) { |
| return ( |
| <thead className="text-xs uppercase text-gray-900 [&>.table-row]:bg-gray-50"> |
| {children} |
| </thead> |
| ); |
| }, |
| tr({ children }) { |
| return ( |
| <tr className="table-row border-b odd:bg-white even:bg-gray-50"> |
| {children} |
| </tr> |
| ); |
| }, |
| td({ children }) { |
| return <td className="px-6 py-3">{children}</td>; |
| }, |
| th({ children }) { |
| return <th className="px-6 py-3">{children}</th>; |
| }, |
| }} |
| > |
| {message} |
| </ReactMarkdown> |
| {DisableSourceFE || type === 'ERROR' ? null : ( |
| <> |
| <span className="mt-3 h-px w-full bg-[#DEDEDE]"></span> |
| <div className="mt-3 flex w-full flex-row flex-wrap items-center justify-start gap-2"> |
| <div className="py-1 text-base font-semibold">Sources:</div> |
| <div className="flex flex-row flex-wrap items-center justify-start gap-2"> |
| {sources?.map((source, index) => ( |
| <div |
| key={index} |
| className={`max-w-fit cursor-pointer rounded-[28px] py-1 px-4 ${ |
| openSource === index |
| ? 'bg-[#007DFF]' |
| : 'bg-[#D7EBFD] hover:bg-[#BFE1FF]' |
| }`} |
| onClick={() => |
| setOpenSource(openSource === index ? null : index) |
| } |
| > |
| <p |
| className={`truncate text-center text-base font-medium ${ |
| openSource === index |
| ? 'text-white' |
| : 'text-[#007DFF]' |
| }`} |
| > |
| {index + 1}. {source.title.substring(0, 45)} |
| </p> |
| </div> |
| ))} |
| </div> |
| </div> |
| </> |
| )} |
| </div> |
| <div |
| className={`relative mr-5 flex items-center justify-center md:invisible ${ |
| type !== 'ERROR' ? 'group-hover:md:visible' : '' |
| }`} |
| > |
| <div className="absolute left-2 top-4"> |
| <div |
| className={`flex items-center justify-center rounded-full p-2 |
| ${isCopyHovered ? 'bg-[#EEEEEE] dark:bg-purple-taupe' : 'bg-[#ffffff] dark:bg-transparent'}`} |
| > |
| {copied ? ( |
| <CheckMark |
| className="cursor-pointer stroke-green-2000" |
| onMouseEnter={() => setIsCopyHovered(true)} |
| onMouseLeave={() => setIsCopyHovered(false)} |
| /> |
| ) : ( |
| <Copy |
| className={`cursor-pointer fill-none`} |
| onClick={() => { |
| handleCopyClick(message); |
| }} |
| onMouseEnter={() => setIsCopyHovered(true)} |
| onMouseLeave={() => setIsCopyHovered(false)} |
| ></Copy> |
| )} |
| </div> |
| </div> |
| </div> |
| <div |
| className={`relative mr-5 flex items-center justify-center ${ |
| !isLikeClicked ? 'md:invisible' : '' |
| } ${ |
| feedback === 'LIKE' || type !== 'ERROR' |
| ? 'group-hover:md:visible' |
| : '' |
| }`} |
| > |
| <div className="absolute left-6 top-4"> |
| <div |
| className={`flex items-center justify-center rounded-full p-2 dark:bg-transparent ${isLikeHovered ? 'bg-[#EEEEEE] dark:bg-purple-taupe' : 'bg-[#ffffff] dark:bg-transparent'}`} |
| > |
| <Like |
| className={`cursor-pointer |
| ${isLikeClicked || feedback === 'LIKE' |
| ? 'fill-white-3000 stroke-purple-30 dark:fill-transparent' |
| : 'fill-none stroke-gray-4000' |
| }`} |
| onClick={() => { |
| handleFeedback?.('LIKE'); |
| setIsLikeClicked(true); |
| setIsDislikeClicked(false); |
| }} |
| onMouseEnter={() => setIsLikeHovered(true)} |
| onMouseLeave={() => setIsLikeHovered(false)} |
| ></Like> |
| </div> |
| </div> |
| </div> |
| <div |
| className={`mr-13 relative flex items-center justify-center ${ |
| !isDislikeClicked ? 'md:invisible' : '' |
| } ${ |
| feedback === 'DISLIKE' || type !== 'ERROR' |
| ? 'group-hover:md:visible' |
| : '' |
| }`} |
| > |
| <div className="absolute left-10 top-4"> |
| <div |
| |
| className={`flex items-center justify-center rounded-full p-2 ${isDislikeHovered ? 'bg-[#EEEEEE] dark:bg-purple-taupe' : 'bg-[#ffffff] dark:bg-transparent'}`} |
| > |
| <Dislike |
| className={`cursor-pointer ${ |
| isDislikeClicked || feedback === 'DISLIKE' |
| ? 'fill-white-3000 dark:fill-transparent stroke-red-2000' |
| : 'fill-none stroke-gray-4000' |
| }`} |
| onClick={() => { |
| handleFeedback?.('DISLIKE'); |
| setIsDislikeClicked(true); |
| setIsLikeClicked(false); |
| }} |
| onMouseEnter={() => setIsDislikeHovered(true)} |
| onMouseLeave={() => setIsDislikeHovered(false)} |
| ></Dislike> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| {sources && openSource !== null && sources[openSource] && ( |
| <div className="ml-10 mt-2 max-w-[800px] rounded-xl bg-blue-200 dark:bg-gun-metal p-2"> |
| <p className="m-1 w-3/4 truncate text-xs text-gray-500 dark:text-bright-gray"> |
| Source: {sources[openSource].title} |
| </p> |
| |
| <div className="m-2 rounded-xl border-2 border-gray-200 dark:border-chinese-silver bg-white dark:bg-dark-charcoal p-2"> |
| <p className="text-break text-black dark:text-bright-gray"> |
| {sources[openSource].text} |
| </p> |
| </div> |
| </div> |
| )} |
| </div> |
| ); |
| } |
| return bubble; |
| }); |
|
|
| export default ConversationBubble; |
|
|