File size: 7,746 Bytes
bb17288 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | 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>
);
};
|