import { useState } from 'react'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Copy, RefreshCw, Check } from 'lucide-react'; import { toast } from 'sonner'; import type { Message } from '@/api/types'; import { api } from '@/api/client'; import { ToolCallCard } from './ToolCallCard'; import { CodeBlock } from './CodeBlock'; interface Props { message: Message; sessionId: string; } function MarkdownBody({ content }: { content: string }) { return ( <>{children}, code: (props) => { const { children, className, ...rest } = props as { children?: unknown; className?: string; }; const text = String(children ?? '').replace(/\n$/, ''); const langMatch = /language-([\w-]+)/.exec(className ?? ''); const isBlock = !!langMatch || text.includes('\n'); if (isBlock) { return ; } return ( {children as React.ReactNode} ); }, a: ({ children, href }) => ( {children} ), ul: ({ children }) => ( ), ol: ({ children }) => (
    {children}
), p: ({ children }) =>

{children}

, h1: ({ children }) =>

{children}

, h2: ({ children }) =>

{children}

, h3: ({ children }) =>

{children}

, blockquote: ({ children }) => (
{children}
), table: ({ children }) => (
{children}
), th: ({ children }) => ( {children} ), td: ({ children }) => ( {children} ), }} > {content}
); } function StatsLine({ message }: { message: Message }) { const tokens = message.tokens_used; if (typeof tokens !== 'number' || tokens <= 0) return null; const started = message.started_at ? Date.parse(message.started_at) : NaN; const finished = message.finished_at ? Date.parse(message.finished_at) : NaN; let tps: number | null = null; if (!Number.isNaN(started) && !Number.isNaN(finished) && finished > started) { const seconds = (finished - started) / 1000; if (seconds > 0) tps = Math.round((tokens / seconds) * 10) / 10; } const ctxUsed = message.ctx_used; const ctxMax = message.ctx_max; const ctxPart = typeof ctxUsed === 'number' ? typeof ctxMax === 'number' && ctxMax > 0 ? `${ctxUsed} / ${ctxMax} ctx` : `${ctxUsed} ctx` : null; const parts: string[] = [`${tokens} tokens`]; if (tps !== null) parts.push(`${tps.toFixed(1)} tok/s`); if (ctxPart) parts.push(ctxPart); return (
{parts.join(' ยท ')}
); } function ActionRow({ message, sessionId, }: { message: Message; sessionId: string; }) { const [justCopied, setJustCopied] = useState(false); const [regenerating, setRegenerating] = useState(false); async function copy() { try { await navigator.clipboard.writeText(message.content); setJustCopied(true); setTimeout(() => setJustCopied(false), 1200); } catch (err) { toast.error(err instanceof Error ? err.message : 'copy failed'); } } async function regenerate() { if (regenerating || message.status === 'streaming') return; setRegenerating(true); try { await api.messages.regenerate(sessionId, message.id); } catch (err) { toast.error(err instanceof Error ? err.message : 'regenerate failed'); } finally { setRegenerating(false); } } const isAssistant = message.role === 'assistant'; const canRegen = isAssistant && message.status !== 'streaming'; return (
{isAssistant && ( )}
); } export function MessageBubble({ message, sessionId }: Props) { if (message.role === 'tool') { return ; } if (message.role === 'user') { return (
{message.content}
); } const isStreaming = message.status === 'streaming'; const failed = message.status === 'failed'; const hasContent = message.content.length > 0; const hasToolCalls = (message.tool_calls?.length ?? 0) > 0; return (
{message.tool_calls?.map((tc) => ( ))} {(hasContent || (!hasToolCalls && isStreaming)) && (
{hasContent ? : null} {isStreaming && ( )}
)} {failed && (
message failed
)} {!isStreaming && } {!isStreaming && (hasContent || hasToolCalls) && ( )}
); }