import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; 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 { ThinkingIndicator } from './ThinkingIndicator'; 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, Info, Webhook, Braces } from 'lucide-react'; import { t, getLocale } from '../lib/i18n'; import { useLocale } from '../hooks/useLocale'; import { stripWebhookScaffolding, hasWebhookScaffolding } from '../lib/systemEvent'; // 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 ; } function MarkdownLink(props: React.AnchorHTMLAttributes) { const { href, children, ...rest } = props; const isExternal = href && /^https?:\/\//.test(href); return ( {children} ); } const markdownComponents = { pre: CodeBlock, img: MarkdownImage, a: MarkdownLink }; 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 ( ); } function MetadataViewer({ metadata }: { metadata?: Record }) { const [open, setOpen] = useState(false); const btnRef = useRef(null); const panelRef = useRef(null); const [pos, setPos] = useState<{ top: number; left: number } | null>(null); useEffect(() => { if (!open) return; const btn = btnRef.current; if (btn) { const r = btn.getBoundingClientRect(); setPos({ top: r.top - 4, left: r.left }); } const handleClick = (e: MouseEvent) => { if (panelRef.current && !panelRef.current.contains(e.target as Node) && !btnRef.current?.contains(e.target as Node)) { setOpen(false); } }; document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); }, [open]); if (!metadata || Object.keys(metadata).length === 0) return null; return (
{open && pos && createPortal(
{Object.entries(metadata).map(([k, v]) => (
{k}: {typeof v === 'object' ? JSON.stringify(v) : String(v)}
))}
, document.body )}
); } function RawJsonToggle({ isOpen, onToggle }: { isOpen: boolean; onToggle: () => void }) { return ( ); } function RawJsonPanel({ message }: { message: ChatMessageType }) { const [copied, setCopied] = useState(false); const json = JSON.stringify(message, null, 2); const handleCopy = useCallback(() => { navigator.clipboard.writeText(json).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }, [json]); return (
Raw JSON
        {json}
      
); } /** 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: rawMessage, onRetry, agentAvatarUrl }: { message: ChatMessageType; onRetry?: (text: string) => void; agentAvatarUrl?: string }) { useLocale(); // re-render on locale change const [showRawJson, setShowRawJson] = useState(false); // Strip webhook/hook scaffolding from user messages before rendering const message = useMemo(() => { if (rawMessage.role !== 'user') return rawMessage; const content = rawMessage.content || ''; const textBlocks = getTextBlocks(rawMessage.blocks); const contentHasScaffolding = hasWebhookScaffolding(content); const anyBlockHasScaffolding = textBlocks.some(b => hasWebhookScaffolding((b as Extract).text) ); if (!contentHasScaffolding && !anyBlockHasScaffolding) return rawMessage; // Clean the content and blocks const cleaned: ChatMessageType = { ...rawMessage }; if (cleaned.content) { cleaned.content = stripWebhookScaffolding(cleaned.content); } if (cleaned.blocks.length > 0) { cleaned.blocks = cleaned.blocks.map(b => { if (b.type === 'text') { const tb = b as Extract; return { ...tb, text: stripWebhookScaffolding(tb.text) }; } return b; }); } return cleaned; }, [rawMessage]); const wasWebhookMessage = rawMessage !== message; 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 ? : agentAvatarUrl ? Agent : }
{/* Bubble */}
{/* Action buttons */} {!isUser && !message.isStreaming && getPlainText(message).trim() && ( )}
setShowRawJson(o => !o)} />
{/* 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 indicator */} {message.isStreaming && (() => { const hasVisibleContent = message.content?.trim(); if (!hasVisibleContent) { return ; } return (
); })()} {/* Tool calls & thinking (inline) */} {!isUser && } {/* Raw JSON viewer */} {showRawJson && }
{(message.timestamp || wasWebhookMessage) && (
{wasWebhookMessage && ( webhook )} {message.timestamp && formatTimestamp(message.timestamp)}
)}
); }