'use client'; import { useCallback, useMemo, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import { cn } from '@/lib/cn'; import type { Message } from '@/lib/types'; interface MessageBubbleProps { message: Message; } export function MessageBubble({ message }: MessageBubbleProps): React.ReactElement { const isUser = message.role === 'user'; const isSystem = message.role === 'system'; const [copied, setCopied] = useState(false); const [thinkingExpanded, setThinkingExpanded] = useState(false); const { response, thinking } = useMemo( () => parseThinking(message.content, message.thinking), [message.content, message.thinking], ); const handleCopy = useCallback(async (): Promise => { try { await navigator.clipboard.writeText(response); setCopied(true); window.setTimeout(() => setCopied(false), 1800); } catch (error) { console.error('[MessageBubble] Failed to copy message:', error); } }, [response]); if (isSystem) { return (
{response}
); } return (
{isUser ? 'You' : 'Assistant'} {!isUser && message.model ? ( {message.model} ) : null} {!isUser && typeof message.totalTokens === 'number' && message.totalTokens > 0 ? ( {formatTokenCount(message.totalTokens)} ) : null} {formatTimestamp(message.createdAt)}
{thinking && !isUser ? (
{thinkingExpanded ? (
                {thinking}
              
) : null}
) : null}

{children}

, ul: ({ children }) =>
    {children}
, ol: ({ children }) => (
    {children}
), li: ({ children }) =>
  • {children}
  • , a: ({ href, children }) => ( {children} ), pre: ({ children }) =>
    {children}
    , code: ({ className, children, ...props }) => { const language = className?.replace('language-', ''); const content = String(children).replace(/\n$/, ''); const isInline = !className; if (isInline) { return ( {children} ); } return (
    {language || 'code'}
                            
                              {content}
                            
                          
    ); }, blockquote: ({ children }) => (
    {children}
    ), }} > {response}
    ); } function parseThinking( content: string, thinking?: string, ): { response: string; thinking: string | null } { if (thinking) { return { response: content, thinking }; } const regex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi; const matches = [...content.matchAll(regex)]; if (matches.length === 0) { return { response: content, thinking: null }; } return { response: content.replace(regex, '').trim(), thinking: matches .map((match) => match[1]?.trim() ?? '') .filter(Boolean) .join('\n\n') || null, }; } function formatTimestamp(createdAt: string): string { return new Date(createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', }); } function formatTokenCount(totalTokens: number): string { if (totalTokens >= 1_000_000) return `${(totalTokens / 1_000_000).toFixed(1)}M tokens`; if (totalTokens >= 1_000) return `${(totalTokens / 1_000).toFixed(1)}k tokens`; return `${totalTokens} tokens`; }