|
import { |
|
IconCheck, |
|
IconCopy, |
|
IconEdit, |
|
IconRobot, |
|
IconTrash, |
|
IconUser, |
|
} from '@tabler/icons-react'; |
|
import { FC, memo, useContext, useEffect, useRef, useState } from 'react'; |
|
|
|
import { useTranslation } from 'next-i18next'; |
|
|
|
import { updateConversation } from '@/utils/app/conversation'; |
|
|
|
import { Message } from '@/types/chat'; |
|
|
|
import HomeContext from '@/pages/api/home/home.context'; |
|
|
|
import { CodeBlock } from '../Markdown/CodeBlock'; |
|
import { MemoizedReactMarkdown } from '../Markdown/MemoizedReactMarkdown'; |
|
|
|
import rehypeMathjax from 'rehype-mathjax'; |
|
import remarkGfm from 'remark-gfm'; |
|
import remarkMath from 'remark-math'; |
|
|
|
export interface Props { |
|
message: Message; |
|
messageIndex: number; |
|
onEdit?: (editedMessage: Message) => void |
|
} |
|
|
|
export const ChatMessage: FC<Props> = memo(({ message, messageIndex, onEdit }) => { |
|
const { t } = useTranslation('chat'); |
|
|
|
const { |
|
state: { selectedConversation, conversations, currentMessage, messageIsStreaming }, |
|
dispatch: homeDispatch, |
|
} = useContext(HomeContext); |
|
|
|
const [isEditing, setIsEditing] = useState<boolean>(false); |
|
const [isTyping, setIsTyping] = useState<boolean>(false); |
|
const [messageContent, setMessageContent] = useState(message.content); |
|
const [messagedCopied, setMessageCopied] = useState(false); |
|
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null); |
|
|
|
const toggleEditing = () => { |
|
setIsEditing(!isEditing); |
|
}; |
|
|
|
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => { |
|
setMessageContent(event.target.value); |
|
if (textareaRef.current) { |
|
textareaRef.current.style.height = 'inherit'; |
|
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; |
|
} |
|
}; |
|
|
|
const handleEditMessage = () => { |
|
if (message.content != messageContent) { |
|
if (selectedConversation && onEdit) { |
|
onEdit({ ...message, content: messageContent }); |
|
} |
|
} |
|
setIsEditing(false); |
|
}; |
|
|
|
const handleDeleteMessage = () => { |
|
if (!selectedConversation) return; |
|
|
|
const { messages } = selectedConversation; |
|
const findIndex = messages.findIndex((elm) => elm === message); |
|
|
|
if (findIndex < 0) return; |
|
|
|
if ( |
|
findIndex < messages.length - 1 && |
|
messages[findIndex + 1].role === 'assistant' |
|
) { |
|
messages.splice(findIndex, 2); |
|
} else { |
|
messages.splice(findIndex, 1); |
|
} |
|
const updatedConversation = { |
|
...selectedConversation, |
|
messages, |
|
}; |
|
|
|
const { single, all } = updateConversation( |
|
updatedConversation, |
|
conversations, |
|
); |
|
homeDispatch({ field: 'selectedConversation', value: single }); |
|
homeDispatch({ field: 'conversations', value: all }); |
|
}; |
|
|
|
const handlePressEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { |
|
if (e.key === 'Enter' && !isTyping && !e.shiftKey) { |
|
e.preventDefault(); |
|
handleEditMessage(); |
|
} |
|
}; |
|
|
|
const copyOnClick = () => { |
|
if (!navigator.clipboard) return; |
|
|
|
navigator.clipboard.writeText(message.content).then(() => { |
|
setMessageCopied(true); |
|
setTimeout(() => { |
|
setMessageCopied(false); |
|
}, 2000); |
|
}); |
|
}; |
|
|
|
useEffect(() => { |
|
setMessageContent(message.content); |
|
}, [message.content]); |
|
|
|
|
|
useEffect(() => { |
|
if (textareaRef.current) { |
|
textareaRef.current.style.height = 'inherit'; |
|
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; |
|
} |
|
}, [isEditing]); |
|
|
|
return ( |
|
<div |
|
className={`group md:px-4 ${ |
|
message.role === 'assistant' |
|
? 'border-b border-black/10 bg-gray-50 text-gray-800 dark:border-gray-900/50 dark:bg-[#444654] dark:text-gray-100' |
|
: 'border-b border-black/10 bg-white text-gray-800 dark:border-gray-900/50 dark:bg-[#343541] dark:text-gray-100' |
|
}`} |
|
style={{ overflowWrap: 'anywhere' }} |
|
> |
|
<div className="relative m-auto flex p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl"> |
|
<div className="min-w-[40px] text-right font-bold"> |
|
{message.role === 'assistant' ? ( |
|
<IconRobot size={30} /> |
|
) : ( |
|
<IconUser size={30} /> |
|
)} |
|
</div> |
|
|
|
<div className="prose mt-[-2px] w-full dark:prose-invert"> |
|
{message.role === 'user' ? ( |
|
<div className="flex w-full"> |
|
{isEditing ? ( |
|
<div className="flex w-full flex-col"> |
|
<textarea |
|
ref={textareaRef} |
|
className="w-full resize-none whitespace-pre-wrap border-none dark:bg-[#343541]" |
|
value={messageContent} |
|
onChange={handleInputChange} |
|
onKeyDown={handlePressEnter} |
|
onCompositionStart={() => setIsTyping(true)} |
|
onCompositionEnd={() => setIsTyping(false)} |
|
style={{ |
|
fontFamily: 'inherit', |
|
fontSize: 'inherit', |
|
lineHeight: 'inherit', |
|
padding: '0', |
|
margin: '0', |
|
overflow: 'hidden', |
|
}} |
|
/> |
|
|
|
<div className="mt-10 flex justify-center space-x-4"> |
|
<button |
|
className="h-[40px] rounded-md bg-blue-500 px-4 py-1 text-sm font-medium text-white enabled:hover:bg-blue-600 disabled:opacity-50" |
|
onClick={handleEditMessage} |
|
disabled={messageContent.trim().length <= 0} |
|
> |
|
{t('Save & Submit')} |
|
</button> |
|
<button |
|
className="h-[40px] rounded-md border border-neutral-300 px-4 py-1 text-sm font-medium text-neutral-700 hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800" |
|
onClick={() => { |
|
setMessageContent(message.content); |
|
setIsEditing(false); |
|
}} |
|
> |
|
{t('Cancel')} |
|
</button> |
|
</div> |
|
</div> |
|
) : ( |
|
<div className="prose whitespace-pre-wrap dark:prose-invert flex-1"> |
|
{message.content} |
|
</div> |
|
)} |
|
|
|
{!isEditing && ( |
|
<div className="md:-mr-8 ml-1 md:ml-0 flex flex-col md:flex-row gap-4 md:gap-1 items-center md:items-start justify-end md:justify-start"> |
|
<button |
|
className="invisible group-hover:visible focus:visible text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300" |
|
onClick={toggleEditing} |
|
> |
|
<IconEdit size={20} /> |
|
</button> |
|
<button |
|
className="invisible group-hover:visible focus:visible text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300" |
|
onClick={handleDeleteMessage} |
|
> |
|
<IconTrash size={20} /> |
|
</button> |
|
</div> |
|
)} |
|
</div> |
|
) : ( |
|
<div className="flex flex-row"> |
|
<MemoizedReactMarkdown |
|
className="prose dark:prose-invert flex-1" |
|
remarkPlugins={[remarkGfm, remarkMath]} |
|
rehypePlugins={[rehypeMathjax]} |
|
components={{ |
|
code({ node, inline, className, children, ...props }) { |
|
if (children.length) { |
|
if (children[0] == 'β') { |
|
return <span className="animate-pulse cursor-default mt-1">β</span> |
|
} |
|
|
|
children[0] = (children[0] as string).replace("`β`", "β") |
|
} |
|
|
|
const match = /language-(\w+)/.exec(className || ''); |
|
|
|
return !inline ? ( |
|
<CodeBlock |
|
key={Math.random()} |
|
language={(match && match[1]) || ''} |
|
value={String(children).replace(/\n$/, '')} |
|
{...props} |
|
/> |
|
) : ( |
|
<code className={className} {...props}> |
|
{children} |
|
</code> |
|
); |
|
}, |
|
table({ children }) { |
|
return ( |
|
<table className="border-collapse border border-black px-3 py-1 dark:border-white"> |
|
{children} |
|
</table> |
|
); |
|
}, |
|
th({ children }) { |
|
return ( |
|
<th className="break-words border border-black bg-gray-500 px-3 py-1 text-white dark:border-white"> |
|
{children} |
|
</th> |
|
); |
|
}, |
|
td({ children }) { |
|
return ( |
|
<td className="break-words border border-black px-3 py-1 dark:border-white"> |
|
{children} |
|
</td> |
|
); |
|
}, |
|
}} |
|
> |
|
{`${message.content}${ |
|
messageIsStreaming && messageIndex == (selectedConversation?.messages.length ?? 0) - 1 ? '`β`' : '' |
|
}`} |
|
</MemoizedReactMarkdown> |
|
|
|
<div className="md:-mr-8 ml-1 md:ml-0 flex flex-col md:flex-row gap-4 md:gap-1 items-center md:items-start justify-end md:justify-start"> |
|
{messagedCopied ? ( |
|
<IconCheck |
|
size={20} |
|
className="text-green-500 dark:text-green-400" |
|
/> |
|
) : ( |
|
<button |
|
className="invisible group-hover:visible focus:visible text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300" |
|
onClick={copyOnClick} |
|
> |
|
<IconCopy size={20} /> |
|
</button> |
|
)} |
|
</div> |
|
</div> |
|
)} |
|
</div> |
|
</div> |
|
</div> |
|
); |
|
}); |
|
ChatMessage.displayName = 'ChatMessage'; |
|
|