| | 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; |
| |
|