| import React from "react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { ClipboardCopy } from "lucide-react"; | |
| import { toast } from "sonner"; | |
| import { MessageProps } from "../types"; | |
| import ReactMarkdown from "react-markdown"; | |
| import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; | |
| import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; | |
| import remarkMath from "remark-math"; | |
| import rehypeKatex from "rehype-katex"; | |
| import remarkGfm from "remark-gfm"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import "katex/dist/katex.min.css"; | |
| export const AIMessageComponent = React.memo(({ message }: MessageProps) => { | |
| const handleCopy = React.useCallback(() => { | |
| const content = String(message.content); | |
| navigator.clipboard.writeText(content) | |
| .then(() => toast.success("Response copied to clipboard")) | |
| .catch(() => toast.error("Failed to copy response")); | |
| }, [message.content]); | |
| return ( | |
| <div className="flex flex-col gap-1 group"> | |
| <div className="prose prose-sm dark:prose-invert max-w-none"> | |
| <ReactMarkdown | |
| remarkPlugins={[remarkMath, remarkGfm]} | |
| rehypePlugins={[rehypeKatex]} | |
| components={{ | |
| p(props) { | |
| return <p {...props} className="leading-7 mb-4" />; | |
| }, | |
| h1(props) { | |
| return <h1 {...props} className="text-3xl font-bold tracking-tight mb-4 mt-8" />; | |
| }, | |
| h2(props) { | |
| return <h2 {...props} className="text-2xl font-semibold tracking-tight mb-4 mt-8" />; | |
| }, | |
| h3(props) { | |
| return <h3 {...props} className="text-xl font-semibold tracking-tight mb-4 mt-6" />; | |
| }, | |
| h4(props) { | |
| return <h4 {...props} className="text-lg font-semibold tracking-tight mb-4 mt-6" />; | |
| }, | |
| code(props) { | |
| const {children, className, ...rest} = props; | |
| const match = /language-(\w+)/.exec(className || ''); | |
| const language = match ? match[1] : ''; | |
| const code = String(children).replace(/\n$/, ''); | |
| const copyToClipboard = () => { | |
| navigator.clipboard.writeText(code); | |
| toast.success("Code copied to clipboard"); | |
| }; | |
| return match ? ( | |
| <div className="relative rounded-md overflow-hidden my-6"> | |
| <div className="absolute right-2 top-2 flex items-center gap-2"> | |
| {language && ( | |
| <Badge variant="secondary" className="text-xs font-mono"> | |
| {language} | |
| </Badge> | |
| )} | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="h-6 w-6 bg-muted/50 hover:bg-muted" | |
| onClick={copyToClipboard} | |
| > | |
| <ClipboardCopy className="h-3 w-3" /> | |
| </Button> | |
| </div> | |
| <SyntaxHighlighter | |
| style={oneDark} | |
| language={language} | |
| PreTag="div" | |
| customStyle={{ margin: 0, borderRadius: 0, padding: "1.5rem" }} | |
| > | |
| {code} | |
| </SyntaxHighlighter> | |
| </div> | |
| ) : ( | |
| <code {...rest} className={`${className} bg-muted px-1.5 py-0.5 rounded-md text-sm`}> | |
| {children} | |
| </code> | |
| ); | |
| }, | |
| a(props) { | |
| return <a {...props} className="text-primary hover:underline font-medium" target="_blank" rel="noopener noreferrer" />; | |
| }, | |
| table(props) { | |
| return <div className="my-6 w-full overflow-y-auto"><table {...props} className="w-full border-collapse table-auto" /></div>; | |
| }, | |
| th(props) { | |
| return <th {...props} className="border border-muted-foreground px-4 py-2 text-left font-semibold" />; | |
| }, | |
| td(props) { | |
| return <td {...props} className="border border-muted-foreground px-4 py-2" />; | |
| }, | |
| blockquote(props) { | |
| return <blockquote {...props} className="mt-6 border-l-4 border-primary pl-6 italic" />; | |
| }, | |
| ul(props) { | |
| return <ul {...props} className="my-6 ml-6 list-disc [&>li]:mt-2" />; | |
| }, | |
| ol(props) { | |
| return <ol {...props} className="my-6 ml-6 list-decimal [&>li]:mt-2" />; | |
| }, | |
| li(props) { | |
| return <li {...props} className="leading-7" />; | |
| }, | |
| hr(props) { | |
| return <hr {...props} className="my-6 border-muted" />; | |
| } | |
| }} | |
| > | |
| {String(message.content)} | |
| </ReactMarkdown> | |
| </div> | |
| <div className="flex flex-row gap-1 opacity-0 group-hover:opacity-100"> | |
| <Button variant="ghost" size="sm" onClick={handleCopy}> | |
| <ClipboardCopy className="h-4 w-4 mr-2" /> | |
| Copy response | |
| </Button> | |
| </div> | |
| </div> | |
| ); | |
| }); | |
| AIMessageComponent.displayName = "AIMessageComponent"; |