Python 64.6%
TypeScript 33.7%
CSS 0.8%
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