SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
5.0 KB · 87 lines tsx
Raw Blame History
1import { useState } from 'react';2import { Check, Copy, RefreshCw, ThumbsDown, ThumbsUp, Paperclip, AlertTriangle } from 'lucide-react';3import type { Message } from '@/lib/types';4import { StreamingText } from './streaming-text';5import { ToolCallTimeline } from './tool-call-timeline';6import { cn } from '@/lib/cn';7import { fmtDate } from '@/lib/format';89function TutorAvatar() {10  return (11    <span className="h-8 w-8 rounded-full bg-uqo-gradient items-center justify-center shrink-0 mt-1 hidden sm:inline-flex shadow-soft" aria-hidden="true">12      <img src="/uqo-logo-white.png" alt="" className="h-3 w-auto" />13    </span>14  );15}1617export function MessageBubble({ msg, conversationId, initials, onRegenerate, onFeedback, isLast }: {18  msg: Message;19  conversationId: string;20  initials: string;21  onRegenerate?: () => void;22  onFeedback?: (fb: 'up' | 'down' | null) => void;23  isLast: boolean;24}) {25  const [copied, setCopied] = useState(false);26  const copy = async () => {27    await navigator.clipboard.writeText(msg.content);28    setCopied(true);29    setTimeout(() => setCopied(false), 1200);30  };3132  if (msg.role === 'user') {33    return (34      <div className="flex justify-end gap-2 animate-fadein">35        <div className="max-w-[86%] sm:max-w-[72%]">36          <div className="rounded-[20px] rounded-br-lg bg-uqo-gradient text-white px-4 py-3 text-[15px] leading-relaxed whitespace-pre-wrap break-words shadow-soft">{msg.content}</div>37          <div className="mt-1 flex items-center justify-end gap-2 text-[11px] text-neutral-muted">38            {msg.attachments && msg.attachments.length > 0 && <span className="inline-flex items-center gap-1"><Paperclip size={11} /> {msg.attachments.length} fichier(s)</span>}39            <span>{fmtDate(msg.created_at)}</span>40          </div>41        </div>42        <span className="hidden sm:inline-flex h-8 w-8 rounded-full bg-uqo-blue-light text-uqo-blue-dark items-center justify-center text-xs font-semibold shrink-0 mt-1">{initials}</span>43      </div>44    );45  }4647  const empty = !msg.content && !(msg.tool_calls && msg.tool_calls.length);48  return (49    <div className="flex gap-2 sm:gap-3 animate-fadein group">50      <TutorAvatar />51      <div className="min-w-0 flex-1 max-w-[900px]">52        <div className="sm:hidden mb-1 flex items-center gap-1.5 text-[11px] font-semibold text-uqo-blue-dark"><img src="/uqo-logo.png" alt="" className="h-2.5 w-auto" /> Tuteur</div>53        {msg.tool_calls && msg.tool_calls.length > 0 && <ToolCallTimeline calls={msg.tool_calls} conversationId={conversationId} />}54        <div className={cn('rounded-[20px] rounded-tl-lg bg-white border border-neutral-line/80 shadow-soft px-4 py-3.5 text-[15px]', empty && msg.streaming && 'inline-block')}>55          {empty && msg.streaming ? (56            <span className="inline-flex gap-1 items-center h-5" aria-label="Le tuteur réfléchit">57              <i className="h-2 w-2 rounded-full bg-uqo-blue/60 animate-bounce [animation-delay:-0.2s]" />58              <i className="h-2 w-2 rounded-full bg-uqo-blue/60 animate-bounce [animation-delay:-0.1s]" />59              <i className="h-2 w-2 rounded-full bg-uqo-blue/60 animate-bounce" />60            </span>61          ) : (62            <StreamingText text={msg.content} streaming={msg.streaming} conversationId={conversationId} />63          )}64          {msg.error && (65            <div className="mt-2 rounded-lg bg-red-50 text-semantic-error text-sm px-3 py-2 inline-flex items-center gap-2"><AlertTriangle size={16} /> {msg.error}</div>66          )}67        </div>68        {!msg.streaming && (69          <div className="mt-1 flex items-center gap-0.5 text-neutral-muted">70            <button onClick={copy} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-white active:scale-95 transition" aria-label="Copier">{copied ? <Check size={15} className="text-uqo-green" /> : <Copy size={15} />}</button>71            {onFeedback && (72              <>73                <button onClick={() => onFeedback(msg.feedback === 'up' ? null : 'up')} className={cn('h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-white active:scale-95 transition', msg.feedback === 'up' && 'text-uqo-green')} aria-label="Réponse utile"><ThumbsUp size={15} /></button>74                <button onClick={() => onFeedback(msg.feedback === 'down' ? null : 'down')} className={cn('h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-white active:scale-95 transition', msg.feedback === 'down' && 'text-semantic-error')} aria-label="Réponse à améliorer"><ThumbsDown size={15} /></button>75              </>76            )}77            {isLast && onRegenerate && (78              <button onClick={onRegenerate} className="h-9 px-2 inline-flex items-center gap-1 rounded-lg hover:bg-white text-xs" aria-label="Régénérer"><RefreshCw size={14} /> Régénérer</button>79            )}80            <span className="ml-auto text-[11px] truncate">{msg.model ? msg.model.split('/').pop() : ''} · {fmtDate(msg.created_at)}</span>81          </div>82        )}83      </div>84    </div>85  );86}87