File size: 4,730 Bytes
212c959
 
 
 
48b8720
212c959
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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>
  )
}