| import { Button } from "@/components/ui/button"; |
| import { Textarea } from "@/components/ui/textarea"; |
| import { ScrollArea } from "@/components/ui/scroll-area"; |
| import { cn } from "@/lib/utils"; |
| import { Loader2, Send, User, Sparkles } from "lucide-react"; |
| import { useState, useEffect, useRef } from "react"; |
| import { Streamdown } from "streamdown"; |
|
|
| |
| |
| |
| export type Message = { |
| role: "system" | "user" | "assistant"; |
| content: string; |
| }; |
|
|
| export type AIChatBoxProps = { |
| |
| |
| |
| |
| messages: Message[]; |
|
|
| |
| |
| |
| |
| onSendMessage: (content: string) => void; |
|
|
| |
| |
| |
| isLoading?: boolean; |
|
|
| |
| |
| |
| placeholder?: string; |
|
|
| |
| |
| |
| className?: string; |
|
|
| |
| |
| |
| height?: string | number; |
|
|
| |
| |
| |
| emptyStateMessage?: string; |
|
|
| |
| |
| |
| |
| suggestedPrompts?: string[]; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function AIChatBox({ |
| messages, |
| onSendMessage, |
| isLoading = false, |
| placeholder = "Type your message...", |
| className, |
| height = "600px", |
| emptyStateMessage = "Start a conversation with AI", |
| suggestedPrompts, |
| }: AIChatBoxProps) { |
| const [input, setInput] = useState(""); |
| const scrollAreaRef = useRef<HTMLDivElement>(null); |
| const containerRef = useRef<HTMLDivElement>(null); |
| const inputAreaRef = useRef<HTMLFormElement>(null); |
| const textareaRef = useRef<HTMLTextAreaElement>(null); |
|
|
| |
| const displayMessages = messages.filter((msg) => msg.role !== "system"); |
|
|
| |
| const [minHeightForLastMessage, setMinHeightForLastMessage] = useState(0); |
|
|
| useEffect(() => { |
| if (containerRef.current && inputAreaRef.current) { |
| const containerHeight = containerRef.current.offsetHeight; |
| const inputHeight = inputAreaRef.current.offsetHeight; |
| const scrollAreaHeight = containerHeight - inputHeight; |
|
|
| |
| |
| |
| |
| const userMessageReservedHeight = 56; |
| const calculatedHeight = scrollAreaHeight - 32 - userMessageReservedHeight; |
|
|
| setMinHeightForLastMessage(Math.max(0, calculatedHeight)); |
| } |
| }, []); |
|
|
| |
| const scrollToBottom = () => { |
| const viewport = scrollAreaRef.current?.querySelector( |
| '[data-radix-scroll-area-viewport]' |
| ) as HTMLDivElement; |
|
|
| if (viewport) { |
| requestAnimationFrame(() => { |
| viewport.scrollTo({ |
| top: viewport.scrollHeight, |
| behavior: 'smooth' |
| }); |
| }); |
| } |
| }; |
|
|
| const handleSubmit = (e: React.FormEvent) => { |
| e.preventDefault(); |
| const trimmedInput = input.trim(); |
| if (!trimmedInput || isLoading) return; |
|
|
| onSendMessage(trimmedInput); |
| setInput(""); |
|
|
| |
| scrollToBottom(); |
|
|
| |
| textareaRef.current?.focus(); |
| }; |
|
|
| const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { |
| if (e.key === "Enter" && !e.shiftKey) { |
| e.preventDefault(); |
| handleSubmit(e); |
| } |
| }; |
|
|
| return ( |
| <div |
| ref={containerRef} |
| className={cn( |
| "flex flex-col bg-card text-card-foreground rounded-lg border shadow-sm", |
| className |
| )} |
| style={{ height }} |
| > |
| {/* Messages Area */} |
| <div ref={scrollAreaRef} className="flex-1 overflow-hidden"> |
| {displayMessages.length === 0 ? ( |
| <div className="flex h-full flex-col p-4"> |
| <div className="flex flex-1 flex-col items-center justify-center gap-6 text-muted-foreground"> |
| <div className="flex flex-col items-center gap-3"> |
| <Sparkles className="size-12 opacity-20" /> |
| <p className="text-sm">{emptyStateMessage}</p> |
| </div> |
| |
| {suggestedPrompts && suggestedPrompts.length > 0 && ( |
| <div className="flex max-w-2xl flex-wrap justify-center gap-2"> |
| {suggestedPrompts.map((prompt, index) => ( |
| <button |
| key={index} |
| onClick={() => onSendMessage(prompt)} |
| disabled={isLoading} |
| className="rounded-lg border border-border bg-card px-4 py-2 text-sm transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50" |
| > |
| {prompt} |
| </button> |
| ))} |
| </div> |
| )} |
| </div> |
| </div> |
| ) : ( |
| <ScrollArea className="h-full"> |
| <div className="flex flex-col space-y-4 p-4"> |
| {displayMessages.map((message, index) => { |
| // Apply min-height to last message only if NOT loading (when loading, the loading indicator gets it) |
| const isLastMessage = index === displayMessages.length - 1; |
| const shouldApplyMinHeight = |
| isLastMessage && !isLoading && minHeightForLastMessage > 0; |
| |
| return ( |
| <div |
| key={index} |
| className={cn( |
| "flex gap-3", |
| message.role === "user" |
| ? "justify-end items-start" |
| : "justify-start items-start" |
| )} |
| style={ |
| shouldApplyMinHeight |
| ? { minHeight: `${minHeightForLastMessage}px` } |
| : undefined |
| } |
| > |
| {message.role === "assistant" && ( |
| <div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center"> |
| <Sparkles className="size-4 text-primary" /> |
| </div> |
| )} |
| |
| <div |
| className={cn( |
| "max-w-[80%] rounded-lg px-4 py-2.5", |
| message.role === "user" |
| ? "bg-primary text-primary-foreground" |
| : "bg-muted text-foreground" |
| )} |
| > |
| {message.role === "assistant" ? ( |
| <div className="prose prose-sm dark:prose-invert max-w-none"> |
| <Streamdown>{message.content}</Streamdown> |
| </div> |
| ) : ( |
| <p className="whitespace-pre-wrap text-sm"> |
| {message.content} |
| </p> |
| )} |
| </div> |
| |
| {message.role === "user" && ( |
| <div className="size-8 shrink-0 mt-1 rounded-full bg-secondary flex items-center justify-center"> |
| <User className="size-4 text-secondary-foreground" /> |
| </div> |
| )} |
| </div> |
| ); |
| })} |
| |
| {isLoading && ( |
| <div |
| className="flex items-start gap-3" |
| style={ |
| minHeightForLastMessage > 0 |
| ? { minHeight: `${minHeightForLastMessage}px` } |
| : undefined |
| } |
| > |
| <div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center"> |
| <Sparkles className="size-4 text-primary" /> |
| </div> |
| <div className="rounded-lg bg-muted px-4 py-2.5"> |
| <Loader2 className="size-4 animate-spin text-muted-foreground" /> |
| </div> |
| </div> |
| )} |
| </div> |
| </ScrollArea> |
| )} |
| </div> |
| |
| {/* Input Area */} |
| <form |
| ref={inputAreaRef} |
| onSubmit={handleSubmit} |
| className="flex gap-2 p-4 border-t bg-background/50 items-end" |
| > |
| <Textarea |
| ref={textareaRef} |
| value={input} |
| onChange={(e) => setInput(e.target.value)} |
| onKeyDown={handleKeyDown} |
| placeholder={placeholder} |
| className="flex-1 max-h-32 resize-none min-h-9" |
| rows={1} |
| /> |
| <Button |
| type="submit" |
| size="icon" |
| disabled={!input.trim() || isLoading} |
| className="shrink-0 h-[38px] w-[38px]" |
| > |
| {isLoading ? ( |
| <Loader2 className="size-4 animate-spin" /> |
| ) : ( |
| <Send className="size-4" /> |
| )} |
| </Button> |
| </form> |
| </div> |
| ); |
| } |
|
|