|
| 1 | +import { useMemo } from "react" |
| 2 | +import { CopyIcon, CheckIcon } from "@radix-ui/react-icons" |
| 3 | +import { BrainCircuit, CircleUserRound } from "lucide-react" |
| 4 | + |
| 5 | +import { cn } from "@/lib/utils" |
| 6 | +import { useClipboard } from "@/components/ui/hooks" |
| 7 | +import { Badge } from "@/components/ui" |
| 8 | +import { Markdown } from "@/components/ui/markdown" |
| 9 | + |
| 10 | +import { BadgeData, ChatHandler, Message, MessageAnnotationType } from "./types" |
| 11 | +import { ChatMessageProvider } from "./ChatMessageProvider" |
| 12 | +import { useChatUI } from "./useChatUI" |
| 13 | +import { useChatMessage } from "./useChatMessage" |
| 14 | + |
| 15 | +interface ChatMessageProps { |
| 16 | + message: Message |
| 17 | + isLast: boolean |
| 18 | + isHeaderVisible: boolean |
| 19 | + isLoading?: boolean |
| 20 | + append?: ChatHandler["append"] |
| 21 | +} |
| 22 | + |
| 23 | +export function ChatMessage({ message, isLast, isHeaderVisible, isLoading, append }: ChatMessageProps) { |
| 24 | + const badges = useMemo( |
| 25 | + () => |
| 26 | + message.annotations |
| 27 | + ?.filter(({ type }) => type === MessageAnnotationType.BADGE) |
| 28 | + .map(({ data }) => data as BadgeData), |
| 29 | + [message.annotations], |
| 30 | + ) |
| 31 | + |
| 32 | + return ( |
| 33 | + <ChatMessageProvider value={{ message, isLast }}> |
| 34 | + <div |
| 35 | + className={cn("relative group flex flex-col text-secondary-foreground", { |
| 36 | + "bg-vscode-input-background/50": message.role === "user", |
| 37 | + })}> |
| 38 | + {isHeaderVisible && <ChatMessageHeader badges={badges} />} |
| 39 | + <ChatMessageContent isHeaderVisible={isHeaderVisible} /> |
| 40 | + <ChatMessageActions /> |
| 41 | + </div> |
| 42 | + </ChatMessageProvider> |
| 43 | + ) |
| 44 | +} |
| 45 | + |
| 46 | +interface ChatMessageHeaderProps { |
| 47 | + badges?: BadgeData[] |
| 48 | +} |
| 49 | + |
| 50 | +function ChatMessageHeader({ badges }: ChatMessageHeaderProps) { |
| 51 | + return ( |
| 52 | + <div className="flex flex-row items-center justify-between border-t border-accent px-3 pt-3 pb-1"> |
| 53 | + <ChatMessageAvatar /> |
| 54 | + {badges?.map(({ label, variant = "outline" }) => ( |
| 55 | + <Badge variant={variant} key={label}> |
| 56 | + {label} |
| 57 | + </Badge> |
| 58 | + ))} |
| 59 | + </div> |
| 60 | + ) |
| 61 | +} |
| 62 | + |
| 63 | +const icons: Record<string, React.ReactNode> = { |
| 64 | + user: <CircleUserRound className="h-4 w-4" />, |
| 65 | + assistant: <BrainCircuit className="h-4 w-4" />, |
| 66 | +} |
| 67 | + |
| 68 | +function ChatMessageAvatar() { |
| 69 | + const { assistantName } = useChatUI() |
| 70 | + const { message } = useChatMessage() |
| 71 | + |
| 72 | + return icons[message.role] ? ( |
| 73 | + <div className="flex flex-row items-center gap-1"> |
| 74 | + <div className="opacity-25 select-none">{icons[message.role]}</div> |
| 75 | + <div className="text-muted">{message.role === "user" ? "You" : assistantName}</div> |
| 76 | + </div> |
| 77 | + ) : null |
| 78 | +} |
| 79 | + |
| 80 | +interface ChatMessageContentProps { |
| 81 | + isHeaderVisible: boolean |
| 82 | +} |
| 83 | + |
| 84 | +function ChatMessageContent({ isHeaderVisible }: ChatMessageContentProps) { |
| 85 | + const { message } = useChatMessage() |
| 86 | + |
| 87 | + return ( |
| 88 | + <div className={cn("flex flex-col gap-4 flex-1 min-w-0 px-4 pb-6", { "pt-4": isHeaderVisible })}> |
| 89 | + <Markdown content={message.content} /> |
| 90 | + </div> |
| 91 | + ) |
| 92 | +} |
| 93 | + |
| 94 | +function ChatMessageActions() { |
| 95 | + const { message } = useChatMessage() |
| 96 | + const { isCopied, copy } = useClipboard() |
| 97 | + |
| 98 | + return ( |
| 99 | + <div |
| 100 | + className="absolute right-2 bottom-2 opacity-0 group-hover:opacity-25 cursor-pointer" |
| 101 | + onClick={() => copy(message.content)}> |
| 102 | + {isCopied ? <CheckIcon /> : <CopyIcon />} |
| 103 | + </div> |
| 104 | + ) |
| 105 | +} |
0 commit comments