import { useState, useCallback } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkBreaks from 'remark-breaks'; import rehypeHighlight from 'rehype-highlight'; import type { ChatMessage as ChatMessageType, MessageBlock } from '../types'; import { ThinkingBlock } from './ThinkingBlock'; import { CodeBlock } from './CodeBlock'; import { ToolCall } from './ToolCall'; import { ImageBlock } from './ImageBlock'; import { buildImageSrc } from '../lib/image'; import { Bot, User, Wrench, Copy, Check, RefreshCw, Zap } from 'lucide-react'; import { t, getLocale } from '../lib/i18n'; import { useLocale } from '../hooks/useLocale'; // ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage function getBcp47(): string { return getLocale() === 'fr' ? 'fr-FR' : 'en-US'; } function formatTimestamp(ts: number): string { const bcp47Locale = getBcp47(); const date = new Date(ts); const now = new Date(); const time = date.toLocaleTimeString(bcp47Locale, { hour: '2-digit', minute: '2-digit' }); const isToday = date.toDateString() === now.toDateString(); const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); const isYesterday = date.toDateString() === yesterday.toDateString(); if (isToday) return time; if (isYesterday) return `${t('time.yesterday')} ${time}`; return `${date.toLocaleDateString(bcp47Locale, { day: 'numeric', month: 'short' })} ${time}`; } /** Guess a language hint from content patterns */ function guessLanguage(lines: string[]): string { const joined = lines.join('\n'); if (/^import .+ from ['"]/.test(joined) || /^export (function|const|default|class|interface|type) /.test(joined) || /React\./.test(joined) || /<\w+[\s/>]/.test(joined) && /className=/.test(joined)) return 'tsx'; if (/^(import|export|const|let|var|function|class|interface|type) /.test(joined) || /=>\s*{/.test(joined) || /: (string|number|boolean|any)\b/.test(joined)) return 'typescript'; if (/^(use |fn |let mut |pub |impl |struct |enum |mod |crate::)/.test(joined) || /-> (Self|Result|Option|Vec|String|bool|i32|u32)/.test(joined)) return 'rust'; if (/^(def |class |import |from .+ import |if __name__)/.test(joined) || /self\.\w+/.test(joined) && !/this\./.test(joined)) return 'python'; if (/^\s*(server|location|upstream|proxy_pass|listen \d)/.test(joined)) return 'nginx'; if (/^\[.*\]\s*$/.test(lines[0] || '') && /=/.test(joined)) return 'ini'; if (/^(apiVersion|kind|metadata|spec):/.test(joined)) return 'yaml'; if (/^\{/.test(joined.trim()) && /\}$/.test(joined.trim())) return 'json'; if (/^#!\/(bin|usr)/.test(joined) || /^\s*(if \[|then|fi|echo |export |source )/.test(joined)) return 'bash'; if (/^(\s*[{(]/, /\.\w+\(.*\)\s*[;,]?\s*$/, ]; for (const line of lines) { for (const pat of patterns) { if (pat.test(line)) { codeSignals++; break; } } } return codeSignals / lines.length > 0.3; } /** Auto-wrap unformatted code/terminal output in fenced code blocks */ function autoFormatText(text: string): string { // Already has code fences — leave as-is if (text.includes('```')) return text; const lines = text.split('\n'); // If most of the text looks like code, wrap the whole thing const nonEmptyLines = lines.filter(l => l.trim()); if (nonEmptyLines.length >= 3 && looksLikeCode(nonEmptyLines)) { const lang = guessLanguage(nonEmptyLines); return '```' + lang + '\n' + text + '\n```'; } // Otherwise, detect contiguous code blocks within prose const result: string[] = []; let codeBuffer: string[] = []; const flushCode = () => { if (codeBuffer.length >= 3 && looksLikeCode(codeBuffer)) { const lang = guessLanguage(codeBuffer); result.push('```' + lang); result.push(...codeBuffer); result.push('```'); } else { result.push(...codeBuffer); } codeBuffer = []; }; const isCodeLine = (line: string): boolean => { return /^[\s]+(import|export|const|let|var|function|return|if|else|for)/.test(line) || /[{};]\s*$/.test(line) || /^\s*(\/\/|#)/.test(line) || /[├└│┬─]──/.test(line) || /^\s+\w+\(.*\)/.test(line); }; for (const line of lines) { if (isCodeLine(line) || (codeBuffer.length > 0 && (line.trim() === '' || /^\s{2,}/.test(line)))) { codeBuffer.push(line); } else { flushCode(); result.push(line); } } flushCode(); return result.join('\n'); } function getTextBlocks(blocks: MessageBlock[]): MessageBlock[] { return blocks.filter(b => b.type === 'text' && b.text.trim()); } function getImageBlocks(blocks: MessageBlock[]): MessageBlock[] { return blocks.filter(b => b.type === 'image'); } function getInternalBlocks(blocks: MessageBlock[]): MessageBlock[] { return blocks.filter(b => b.type === 'thinking' || b.type === 'tool_use' || b.type === 'tool_result'); } function MarkdownImage(props: React.ImgHTMLAttributes) { return ; } const markdownComponents = { pre: CodeBlock, img: MarkdownImage }; function renderTextBlocks(blocks: MessageBlock[]) { return getTextBlocks(blocks).map((block, i) => (
{autoFormatText((block as Extract).text)}
)); } function renderImageBlocks(blocks: MessageBlock[]) { return getImageBlocks(blocks).map((block, i) => { const b = block as { type: 'image'; mediaType: string; data?: string; url?: string }; const src = buildImageSrc(b.mediaType, b.data, b.url); if (!src) return null; return ; }); } function renderInternalBlocks(blocks: MessageBlock[]) { const elements: React.ReactElement[] = []; const internals = getInternalBlocks(blocks); for (let i = 0; i < internals.length; i++) { const block = internals[i]; if (block.type === 'thinking') { elements.push(); } else if (block.type === 'tool_use') { const nextBlock = internals[i + 1]; const result = nextBlock?.type === 'tool_result' ? nextBlock.content : undefined; elements.push(); if (result !== undefined) i++; } else if (block.type === 'tool_result') { elements.push(); } } return elements; } function InternalsSummary({ blocks }: { blocks: MessageBlock[] }) { const internals = getInternalBlocks(blocks); if (internals.length === 0) return null; return (
{renderInternalBlocks(blocks)}
); } /** Message with ONLY internal blocks (no text for the user) */ function InternalOnlyMessage({ message }: { message: ChatMessageType }) { return (
{renderInternalBlocks(message.blocks)}
{message.timestamp && (
{formatTimestamp(message.timestamp)}
)}
); } function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); const handleCopy = useCallback(() => { navigator.clipboard.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }, [text]); return ( ); } /** Extract plain text from message blocks for clipboard copy */ function getPlainText(message: ChatMessageType): string { if (message.blocks.length > 0) { return getTextBlocks(message.blocks).map(b => (b as Extract).text).join('\n\n'); } return message.content; } /** System event displayed as a subtle inline notification */ function SystemEventMessage({ message }: { message: ChatMessageType }) { const text = message.content || getTextBlocks(message.blocks).map(b => (b as Extract).text).join(' '); // Trim leading brackets like [cron:xxx] or [EVENT] for cleaner display const display = text.replace(/^\[.*?\]\s*/, '').trim() || text.trim(); const label = text.match(/^\[([^\]]+)\]/)?.[1] || 'system'; return (
{label} {display} {message.timestamp && ( {formatTimestamp(message.timestamp)} )}
); } export function ChatMessageComponent({ message, onRetry }: { message: ChatMessageType; onRetry?: (text: string) => void }) { useLocale(); // re-render on locale change const isUser = message.role === 'user'; // System events render as subtle inline notifications if (message.isSystemEvent) { return ; } // Assistant message with no text content — only tool calls / thinking if (!isUser && message.blocks.length > 0) { const textBlocks = getTextBlocks(message.blocks); const imageBlocks = getImageBlocks(message.blocks); const hasText = textBlocks.length > 0 || imageBlocks.length > 0 || (message.isStreaming && message.content?.trim()); if (!hasText && !message.isStreaming) { return ; } } return (
{/* Avatar */}
{isUser ? : }
{/* Bubble */}
{/* Copy button (assistant messages only) */} {!isUser && !message.isStreaming && getPlainText(message).trim() && ( )} {/* Retry button (user messages only) */} {isUser && onRetry && ( )} {/* User-visible text */} {message.blocks.length > 0 ? renderTextBlocks(message.blocks) : (
{autoFormatText(message.content)}
)} {/* Inline images */} {renderImageBlocks(message.blocks)} {/* Streaming dots */} {message.isStreaming && (
)} {/* Tool calls & thinking (inline) */} {!isUser && }
{message.timestamp && (
{formatTimestamp(message.timestamp)}
)}
); }