markdown-body.tsx
1 'use client' 2 3 import type { ReactNode } from 'react' 4 import ReactMarkdown from 'react-markdown' 5 import remarkGfm from 'remark-gfm' 6 import rehypeHighlight from 'rehype-highlight' 7 import { CodeBlock } from '@/components/chat/code-block' 8 9 export interface MarkdownBodyProps { 10 text: string 11 /** Custom link renderer — return non-null to override default handling */ 12 renderLink?: (href: string, children: ReactNode) => ReactNode | null 13 /** Custom inline code renderer — return non-null to override default */ 14 renderInlineCode?: (text: string, children: ReactNode, className?: string) => ReactNode | null 15 /** Custom paragraph renderer — return non-null to override default */ 16 renderParagraph?: (node: unknown, children: ReactNode) => ReactNode | null 17 /** Media URLs to skip (already rendered elsewhere, e.g. tool events) */ 18 skipMediaUrls?: Set<string> 19 } 20 21 export function MarkdownBody({ 22 text, 23 renderLink, 24 renderInlineCode, 25 renderParagraph, 26 skipMediaUrls, 27 }: MarkdownBodyProps) { 28 return ( 29 <ReactMarkdown 30 remarkPlugins={[remarkGfm]} 31 rehypePlugins={[rehypeHighlight]} 32 components={{ 33 pre({ children }) { 34 return <pre>{children}</pre> 35 }, 36 ...(renderParagraph && { 37 p({ node, children }: { node?: unknown; children?: ReactNode }) { 38 const custom = renderParagraph(node, children) 39 if (custom !== null) return <>{custom}</> 40 return <p>{children}</p> 41 }, 42 }), 43 code({ className: cn, children }) { 44 const isBlock = cn?.startsWith('language-') || cn?.startsWith('hljs') 45 if (isBlock) return <CodeBlock className={cn}>{children}</CodeBlock> 46 if (renderInlineCode) { 47 const rawText = typeof children === 'string' ? children : '' 48 const custom = renderInlineCode(rawText, children, cn) 49 if (custom !== null) return <>{custom}</> 50 } 51 return <code className={cn}>{children}</code> 52 }, 53 img({ src, alt }) { 54 if (!src || typeof src !== 'string') return null 55 if (skipMediaUrls?.has(src)) return null 56 const isVideo = /\.(mp4|webm|mov|avi)$/i.test(src) 57 if (isVideo) { 58 return <video src={src} controls preload="none" className="max-w-full rounded-[10px] border border-white/10 my-2" /> 59 } 60 return ( 61 <a href={src} download target="_blank" rel="noopener noreferrer" className="block my-2"> 62 {/* eslint-disable-next-line @next/next/no-img-element */} 63 <img 64 src={src} 65 alt={alt || 'Image'} 66 loading="lazy" 67 className="max-w-full max-h-[400px] rounded-[10px] border border-white/[0.06] hover:border-white/25 transition-colors" 68 onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} 69 /> 70 </a> 71 ) 72 }, 73 a({ href, children }) { 74 if (!href) return <>{children}</> 75 if (renderLink) { 76 const custom = renderLink(href, children) 77 if (custom !== null) return <>{custom}</> 78 } 79 // YouTube embed 80 const ytMatch = href.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/) 81 if (ytMatch) { 82 return ( 83 <div className="my-2"> 84 <iframe 85 src={`https://www.youtube-nocookie.com/embed/${ytMatch[1]}`} 86 className="w-full max-w-[480px] aspect-video rounded-[10px] border border-white/[0.06]" 87 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" 88 allowFullScreen 89 title="YouTube video" 90 /> 91 </div> 92 ) 93 } 94 // Upload download links 95 if (href.includes('/api/uploads/')) { 96 const filename = href.split('/').pop() || 'file' 97 return ( 98 <a href={href} download={filename} className="text-accent-bright hover:underline"> 99 {children} 100 </a> 101 ) 102 } 103 // Default external link 104 return ( 105 <a href={href} target="_blank" rel="noopener noreferrer" className="text-accent-bright hover:underline"> 106 {children} 107 </a> 108 ) 109 }, 110 }} 111 > 112 {text} 113 </ReactMarkdown> 114 ) 115 }