OwnGPT.v2 / client /src /components /Chat /MessageBubble.jsx
parthib07's picture
Upload 199 files
212c959 verified
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)