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"; /** * Message type matching server-side LLM Message interface */ export type Message = { role: "system" | "user" | "assistant"; content: string; }; export type AIChatBoxProps = { /** * Messages array to display in the chat. * Should match the format used by invokeLLM on the server. */ messages: Message[]; /** * Callback when user sends a message. * Typically you'll call a tRPC mutation here to invoke the LLM. */ onSendMessage: (content: string) => void; /** * Whether the AI is currently generating a response */ isLoading?: boolean; /** * Placeholder text for the input field */ placeholder?: string; /** * Custom className for the container */ className?: string; /** * Height of the chat box (default: 600px) */ height?: string | number; /** * Empty state message to display when no messages */ emptyStateMessage?: string; /** * Suggested prompts to display in empty state * Click to send directly */ suggestedPrompts?: string[]; }; /** * A ready-to-use AI chat box component that integrates with the LLM system. * * Features: * - Matches server-side Message interface for seamless integration * - Markdown rendering with Streamdown * - Auto-scrolls to latest message * - Loading states * - Uses global theme colors from index.css * * @example * ```tsx * const ChatPage = () => { * const [messages, setMessages] = useState([ * { role: "system", content: "You are a helpful assistant." } * ]); * * const chatMutation = trpc.ai.chat.useMutation({ * onSuccess: (response) => { * // Assuming your tRPC endpoint returns the AI response as a string * setMessages(prev => [...prev, { * role: "assistant", * content: response * }]); * }, * onError: (error) => { * console.error("Chat error:", error); * // Optionally show error message to user * } * }); * * const handleSend = (content: string) => { * const newMessages = [...messages, { role: "user", content }]; * setMessages(newMessages); * chatMutation.mutate({ messages: newMessages }); * }; * * return ( * * ); * }; * ``` */ 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(null); const containerRef = useRef(null); const inputAreaRef = useRef(null); const textareaRef = useRef(null); // Filter out system messages const displayMessages = messages.filter((msg) => msg.role !== "system"); // Calculate min-height for last assistant message to push user message to top 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; // Reserve space for: // - padding (p-4 = 32px top+bottom) // - user message: 40px (item height) + 16px (margin-top from space-y-4) = 56px // Note: margin-bottom is not counted because it naturally pushes the assistant message down const userMessageReservedHeight = 56; const calculatedHeight = scrollAreaHeight - 32 - userMessageReservedHeight; setMinHeightForLastMessage(Math.max(0, calculatedHeight)); } }, []); // Scroll to bottom helper function with smooth animation 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(""); // Scroll immediately after sending scrollToBottom(); // Keep focus on input textareaRef.current?.focus(); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(e); } }; return (
{/* Messages Area */}
{displayMessages.length === 0 ? (

{emptyStateMessage}

{suggestedPrompts && suggestedPrompts.length > 0 && (
{suggestedPrompts.map((prompt, index) => ( ))}
)}
) : (
{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 (
{message.role === "assistant" && (
)}
{message.role === "assistant" ? (
{message.content}
) : (

{message.content}

)}
{message.role === "user" && (
)}
); })} {isLoading && (
0 ? { minHeight: `${minHeightForLastMessage}px` } : undefined } >
)}
)}
{/* Input Area */}