Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
| 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<Message[]>([ | |
| * { 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 ( | |
| * <AIChatBox | |
| * messages={messages} | |
| * onSendMessage={handleSend} | |
| * isLoading={chatMutation.isPending} | |
| * suggestedPrompts={[ | |
| * "Explain quantum computing", | |
| * "Write a hello world in Python" | |
| * ]} | |
| * /> | |
| * ); | |
| * }; | |
| * ``` | |
| */ | |
| 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); | |
| // 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<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> | |
| ); | |
| } | |