| import { memo, useEffect, useMemo, useRef } from 'react' |
| import { motion } from 'framer-motion' |
| import { Copy, FileText, Pencil, RefreshCw, Sparkles, 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 MessageBubble({ |
| 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-4xl gap-3 px-4 py-3 sm:px-6 ${ |
| isUser ? 'justify-end' : 'justify-start' |
| }`} |
| > |
| {!isUser ? ( |
| <div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-accent/12 text-accent shadow-soft"> |
| <Sparkles className="h-4 w-4" /> |
| </div> |
| ) : null} |
| |
| <div |
| className={`max-w-[min(100%,54rem)] rounded-[28px] border px-4 py-4 shadow-soft sm:px-5 ${ |
| isUser |
| ? 'border-accent/20 bg-accent text-accent-foreground' |
| : 'border-border/70 bg-panel 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-full border px-3 py-1.5 text-xs font-medium ${ |
| isUser |
| ? 'border-white/20 bg-white/10 text-white/90' |
| : 'border-border bg-background 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 ? ( |
| <TypingIndicator /> |
| ) : ( |
| <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-4 flex flex-wrap items-center gap-2"> |
| {!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 & branch" onClick={() => onEdit(message)} /> |
| )} |
| |
| {message.model_used ? ( |
| <> |
| <span className="hidden ml-auto rounded-full border border-border/80 bg-background px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground"> |
| {(message.provider || 'assistant').replaceAll('-', ' ')} · {message.model_used} |
| </span> |
| <span className="ml-auto rounded-full border border-border/80 bg-background px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground"> |
| {`${(message.provider || 'assistant').replaceAll('-', ' ')} / ${message.model_used}`} |
| </span> |
| </> |
| ) : null} |
| </div> |
| </div> |
|
|
| {isUser ? ( |
| <div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-border/70 bg-card text-sm font-semibold text-foreground shadow-soft"> |
| {user?.avatar_url ? ( |
| <img src={user.avatar_url} alt={user.username} className="h-full w-full rounded-2xl object-cover" /> |
| ) : ( |
| (user?.username || 'You').slice(0, 1).toUpperCase() |
| )} |
| </div> |
| ) : null} |
| </motion.article> |
| ) |
| } |
|
|
| function MessageAction({ icon: Icon, label, onClick }) { |
| return ( |
| <button |
| type="button" |
| onClick={onClick} |
| className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground transition hover:border-accent/20 hover:text-foreground" |
| > |
| <Icon className="h-3.5 w-3.5" /> |
| {label} |
| </button> |
| ) |
| } |
|
|
| export default memo(MessageBubble) |
|
|