| import React, { useState, useEffect } from 'react'; |
| import { motion } from 'framer-motion'; |
| import { User, Copy, Check } from 'lucide-react'; |
| import ReactMarkdown from 'react-markdown'; |
| import remarkGfm from 'remark-gfm'; |
| import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; |
| import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; |
|
|
| export interface MessageProps { |
| id: string; |
| role: 'user' | 'bot'; |
| content: string; |
| isNew?: boolean; |
| } |
|
|
| const CodeBlock = ({ inline, className, children, ...props }: { inline?: boolean; className?: string; children?: React.ReactNode } & React.HTMLAttributes<HTMLElement>) => { |
| const match = /language-(\w+)/.exec(className || ''); |
| const [copied, setCopied] = useState(false); |
|
|
| const handleCopy = () => { |
| navigator.clipboard.writeText(String(children).replace(/\n$/, '')); |
| setCopied(true); |
| setTimeout(() => setCopied(false), 2000); |
| }; |
|
|
| return !inline && match ? ( |
| <div className="relative group mt-5 mb-5 shadow-lg rounded-lg overflow-hidden border border-white/5"> |
| <div className="flex justify-between items-center bg-[#18181b] px-4 py-2 border-b border-white/5"> |
| <span className="text-xs font-mono-custom text-slate-400 capitalize">{match[1]}</span> |
| <button |
| onClick={handleCopy} |
| className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors" |
| > |
| {copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />} |
| <span>{copied ? 'Copied!' : 'Copy code'}</span> |
| </button> |
| </div> |
| <SyntaxHighlighter |
| {...props} |
| style={vscDarkPlus} |
| language={match[1]} |
| PreTag="div" |
| className="!m-0 !bg-[#0b0b0f] !p-4 font-mono-custom text-sm custom-scrollbar" |
| > |
| {String(children).replace(/\n$/, '')} |
| </SyntaxHighlighter> |
| </div> |
| ) : ( |
| <code {...props} className={`${className} bg-primary/10 px-1.5 py-0.5 rounded-md text-[13px] font-mono-custom text-primary`}> |
| {children} |
| </code> |
| ); |
| }; |
|
|
| export const ChatMessage: React.FC<{ message: MessageProps }> = ({ message }) => { |
| const isUser = message.role === 'user'; |
| const [displayedContent, setDisplayedContent] = useState( |
| !isUser && message.isNew ? '' : message.content |
| ); |
|
|
| useEffect(() => { |
| if (isUser || !message.isNew) { |
| setDisplayedContent(message.content); |
| return; |
| } |
|
|
| let index = 0; |
| setDisplayedContent(''); |
| const timer = setInterval(() => { |
| if (index < message.content.length) { |
| setDisplayedContent(message.content.substring(0, index + 1)); |
| index++; |
| } else { |
| clearInterval(timer); |
| } |
| }, 15); |
|
|
| return () => clearInterval(timer); |
| }, [message.content, isUser, message.isNew]); |
|
|
| return ( |
| <motion.div |
| initial={{ opacity: 0, y: 15 }} |
| animate={{ opacity: 1, y: 0 }} |
| exit={{ opacity: 0, scale: 0.95 }} |
| transition={{ duration: 0.3, ease: 'easeOut' }} |
| className={`flex w-full mb-6 ${isUser ? 'justify-end' : 'justify-start'}`} |
| > |
| <div className={`flex max-w-[85%] sm:max-w-[75%] gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row items-start'}`}> |
| {/* Avatar */} |
| <div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center mt-1 |
| ${isUser ? 'bg-[#3c4043]' : 'bg-transparent'} |
| `}> |
| {isUser ? ( |
| <User size={18} className="text-white" /> |
| ) : ( |
| <div className="w-8 h-8 relative flex items-center justify-center bg-white/5 rounded-full border border-white/10 shadow-[0_0_15px_rgba(139,92,246,0.2)]"> |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" className="text-primary drop-shadow-[0_0_8px_rgba(139,92,246,0.6)]"> |
| <path d="M12 2L14.809 9.19098L22 12L14.809 14.809L12 22L9.19098 14.809L2 12L9.19098 9.19098L12 2Z" fill="currentColor" /> |
| </svg> |
| </div> |
| )} |
| </div> |
| |
| {/* Message Bubble */} |
| <div className={` |
| leading-[1.65] text-[16px] break-words overflow-x-auto |
| ${isUser |
| ? 'bg-primary text-white px-5 py-3 rounded-[24px] rounded-br-sm shadow-[0_4px_15px_rgba(139,92,246,0.15)]' |
| : 'text-foreground pt-1.5' |
| } |
| `}> |
| {isUser ? ( |
| message.content |
| ) : ( |
| <div className="markdown-prose z-10 w-full"> |
| <ReactMarkdown |
| remarkPlugins={[remarkGfm]} |
| components={{ |
| code: CodeBlock, |
| p: ({ children }) => <p className="mb-4 last:mb-0 leading-[1.65] text-slate-300">{children}</p>, |
| ul: ({ children }) => <ul className="list-disc ml-5 mb-4 space-y-1 text-slate-300">{children}</ul>, |
| ol: ({ children }) => <ol className="list-decimal ml-5 mb-4 space-y-1 text-slate-300">{children}</ol>, |
| li: ({ children }) => <li className="mb-1 leading-[1.65]">{children}</li>, |
| a: ({ href, children }) => <a href={href} className="text-primary hover:text-primary-hover underline underline-offset-2 transition-colors" target="_blank" rel="noreferrer">{children}</a>, |
| h1: ({ children }) => <h1 className="text-2xl font-bold mb-4 mt-6 text-slate-100">{children}</h1>, |
| h2: ({ children }) => <h2 className="text-xl font-bold mb-3 mt-5 text-slate-100">{children}</h2>, |
| h3: ({ children }) => <h3 className="text-lg font-bold mb-3 mt-4 text-slate-100">{children}</h3>, |
| strong: ({ children }) => <strong className="font-semibold text-slate-200">{children}</strong>, |
| blockquote: ({ children }) => <blockquote className="border-l-4 border-primary/50 pl-4 py-1.5 my-4 bg-white/5 rounded-r-lg text-slate-300 italic">{children}</blockquote> |
| }} |
| > |
| {displayedContent} |
| </ReactMarkdown> |
| {!displayedContent && !isUser && message.isNew && ( |
| <div className="flex gap-1.5 mt-2 h-6 items-center"> |
| <span className="w-1.5 h-1.5 bg-primary/80 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} /> |
| <span className="w-1.5 h-1.5 bg-primary/80 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} /> |
| <span className="w-1.5 h-1.5 bg-primary/80 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} /> |
| </div> |
| )} |
| </div> |
| )} |
| </div> |
| </div> |
| </motion.div> |
| ); |
| }; |
|
|