| import { memo, useEffect, useMemo, useRef } from 'react' |
| import { motion } from 'framer-motion' |
| import { Copy, FileText, Pencil, RefreshCw, Volume2, VolumeX } from 'lucide-react' |
| import { renderMarkdown } from '../../utils/markdown' |
| import TypingIndicator from './TypingIndicator' |
|
|
|
|
| function addCodeCopyButtons(container) { |
| container.querySelectorAll('pre').forEach((pre) => { |
| if (pre.dataset.enhanced === 'true') return |
| pre.dataset.enhanced = 'true' |
| const wrapper = document.createElement('div') |
| wrapper.className = 'code-shell' |
| const toolbar = document.createElement('div') |
| toolbar.className = 'code-toolbar' |
| const badge = document.createElement('span') |
| badge.className = 'code-badge' |
| badge.textContent = pre.querySelector('code')?.className?.replace('hljs language-', '') || 'code' |
| const button = document.createElement('button') |
| button.className = 'code-copy-button' |
| button.type = 'button' |
| button.textContent = 'Copy' |
| button.addEventListener('click', async () => { |
| const code = pre.querySelector('code')?.textContent || pre.textContent || '' |
| await navigator.clipboard.writeText(code) |
| button.textContent = 'Copied' |
| window.setTimeout(() => { |
| button.textContent = 'Copy' |
| }, 1400) |
| }) |
| toolbar.appendChild(badge) |
| toolbar.appendChild(button) |
| pre.parentNode.insertBefore(wrapper, pre) |
| wrapper.appendChild(toolbar) |
| wrapper.appendChild(pre) |
| }) |
| } |
|
|
| function MessageBubbleNext({ |
| message, |
| user, |
| onCopyMessage, |
| onRegenerate, |
| onEdit, |
| onSpeak, |
| onPreviewFile, |
| speaking, |
| }) { |
| const isUser = message.role === 'user' |
| const contentRef = useRef(null) |
|
|
| const html = useMemo( |
| () => (isUser ? '' : renderMarkdown(message.content || '')), |
| [isUser, message.content], |
| ) |
|
|
| useEffect(() => { |
| if (!isUser && contentRef.current) { |
| addCodeCopyButtons(contentRef.current) |
| } |
| }, [html, isUser]) |
|
|
| return ( |
| <motion.article |
| initial={{ opacity: 0, y: 16 }} |
| animate={{ opacity: 1, y: 0 }} |
| className={`group mx-auto flex w-full max-w-3xl px-4 py-3 sm:px-6 ${ |
| isUser ? 'justify-end' : 'justify-start' |
| }`} |
| > |
| <div |
| className={`min-w-0 ${ |
| isUser |
| ? 'max-w-[85%] rounded-lg bg-secondary px-4 py-3 text-foreground' |
| : 'w-full text-foreground' |
| }`} |
| > |
| {message.attachments?.length ? ( |
| <div className="mb-3 flex flex-wrap gap-2"> |
| {message.attachments.map((attachment, index) => ( |
| <button |
| key={`${attachment.filename}-${index}`} |
| type="button" |
| onClick={() => onPreviewFile(attachment)} |
| className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium ${ |
| isUser |
| ? 'border-border bg-background/70 text-muted-foreground' |
| : 'border-border bg-background/70 text-muted-foreground hover:text-foreground' |
| }`} |
| > |
| <FileText className="h-3.5 w-3.5" /> |
| {attachment.filename} |
| </button> |
| ))} |
| </div> |
| ) : null} |
| |
| {isUser ? ( |
| <p className="whitespace-pre-wrap break-words text-sm leading-7">{message.content}</p> |
| ) : message.streaming && !message.content ? ( |
| <div> |
| <TypingIndicator /> |
| </div> |
| ) : ( |
| <div |
| ref={contentRef} |
| className="markdown-body text-sm leading-7" |
| dangerouslySetInnerHTML={{ __html: html }} |
| /> |
| )} |
| |
| {!isUser && message.streaming && message.content ? <span className="typing-cursor" /> : null} |
| |
| <div className="mt-2 flex flex-wrap items-center gap-1 opacity-100 sm:opacity-0 sm:transition sm:group-hover:opacity-100 sm:focus-within:opacity-100"> |
| {!isUser ? ( |
| <> |
| <MessageAction icon={Copy} label="Copy" onClick={() => onCopyMessage(message)} /> |
| <MessageAction |
| icon={speaking ? VolumeX : Volume2} |
| label={speaking ? 'Stop audio' : 'Read aloud'} |
| onClick={() => onSpeak(message)} |
| /> |
| <MessageAction |
| icon={RefreshCw} |
| label="Regenerate" |
| onClick={() => onRegenerate(message.id)} |
| /> |
| </> |
| ) : ( |
| <MessageAction icon={Pencil} label="Edit" onClick={() => onEdit(message)} /> |
| )} |
| </div> |
| </div> |
| </motion.article> |
| ) |
| } |
|
|
| function MessageAction({ icon: Icon, label, onClick }) { |
| return ( |
| <button |
| type="button" |
| onClick={onClick} |
| className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition hover:bg-secondary hover:text-foreground" |
| aria-label={label} |
| title={label} |
| > |
| <Icon className="h-3.5 w-3.5" /> |
| </button> |
| ) |
| } |
|
|
| export default memo(MessageBubbleNext) |
|
|
|
|