| import { useEffect, useRef } from 'react' |
| import { renderMarkdown } from '../utils/markdown' |
|
|
| const PROVIDER_ICONS = { |
| groq: 'β‘', google: 'π', mistral: 'π«', huggingface: 'π€', nvidia: 'β', openrouter: 'π', |
| } |
|
|
| function addCopyButtons(el) { |
| el.querySelectorAll('.hljs-pre').forEach(pre => { |
| if (pre.parentElement?.classList.contains('code-wrap')) return |
| const lang = pre.dataset.lang || 'code' |
| const wrap = document.createElement('div'); wrap.className = 'code-wrap' |
| const header = document.createElement('div'); header.className = 'code-header' |
| const langLabel = document.createElement('span'); langLabel.className = 'code-lang'; langLabel.textContent = lang |
| const copyBtn = document.createElement('button'); copyBtn.className = 'copy-btn' |
| copyBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy` |
| copyBtn.addEventListener('click', async () => { |
| const code = pre.querySelector('code')?.textContent || pre.textContent |
| await navigator.clipboard.writeText(code).catch(() => {}) |
| copyBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg> Copied!` |
| copyBtn.classList.add('copied') |
| setTimeout(() => { |
| copyBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy` |
| copyBtn.classList.remove('copied') |
| }, 2000) |
| }) |
| header.appendChild(langLabel); header.appendChild(copyBtn) |
| pre.parentNode.insertBefore(wrap, pre); wrap.appendChild(header); wrap.appendChild(pre) |
| }) |
| } |
|
|
| export default function MessageBubble({ message, index = 0, user }) { |
| const { role, content, model_used, provider, attachments } = message |
| const bubbleRef = useRef(null) |
| const delay = Math.min(index * 60, 500) + 'ms' |
|
|
| const html = role === 'assistant' ? renderMarkdown(content || '') : null |
|
|
| useEffect(() => { |
| if (role === 'assistant' && bubbleRef.current) { |
| addCopyButtons(bubbleRef.current) |
| } |
| }, [content, role]) |
|
|
| if (role === 'user') { |
| return ( |
| <div className="msg-row user" style={{ animationDelay: delay }}> |
| <div className="user-bubble"> |
| {attachments?.length > 0 && ( |
| <div className="msg-atts"> |
| {attachments.map((a, i) => ( |
| <span key={i} className="msg-att-chip"> |
| π {a.filename} |
| </span> |
| ))} |
| </div> |
| )} |
| <span style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{content}</span> |
| </div> |
| <div |
| className="msg-indicator user" |
| style={user?.avatar_url ? { backgroundImage: `url(${user.avatar_url})`, backgroundSize: 'cover', backgroundPosition: 'center', color: 'transparent', border: 'none' } : {}} |
| > |
| {!user?.avatar_url && 'You'} |
| </div> |
| </div> |
| ) |
| } |
|
|
| return ( |
| <div className="msg-row" style={{ animationDelay: delay }}> |
| <div className="msg-indicator ai">β¦</div> |
| <div className="msg-card"> |
| <div className="msg-actions"> |
| <button |
| className="msg-action-btn" |
| onClick={() => navigator.clipboard.writeText(content).catch(() => {})} |
| title="Copy raw text" |
| > |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> |
| </button> |
| </div> |
| {(!content) ? ( |
| <div className="typing-wave" style={{ padding: '10px 0' }}> |
| <div className="typing-dot" /> |
| <div className="typing-dot" /> |
| <div className="typing-dot" /> |
| </div> |
| ) : ( |
| <div |
| ref={bubbleRef} |
| className="msg-markdown" |
| dangerouslySetInnerHTML={{ __html: html }} |
| /> |
| )} |
| {model_used && ( |
| <div style={{ marginTop: 12, display: 'flex', alignItems: 'center', gap: 6 }}> |
| <span className="msg-badge"> |
| {PROVIDER_ICONS[provider] || 'π€'} {provider} Β· {model_used} |
| </span> |
| </div> |
| )} |
| </div> |
| </div> |
| ) |
| } |
|
|