| | 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> |
| | ); |
| | } |
| |
|