| import { useState, useRef, useEffect, useCallback } from "react"; | |
| import { Send, Square, Plus } from "lucide-react"; | |
| import { useLLM } from "../hooks/useLLM"; | |
| import { MessageBubble } from "./MessageBubble"; | |
| import { StatusBar } from "./StatusBar"; | |
| const EXAMPLE_PROMPTS = [ | |
| { | |
| label: "Solve x² + x - 12 = 0", | |
| prompt: "Solve x^2 + x - 12 = 0", | |
| }, | |
| { | |
| label: "Explain quantum computing", | |
| prompt: | |
| "Explain quantum computing in simple terms. What makes it different from classical computing, and what are some real-world applications?", | |
| }, | |
| { | |
| label: "Write a Python quicksort", | |
| prompt: | |
| "Write a clean, well-commented Python implementation of the quicksort algorithm. Include an example of how to use it.", | |
| }, | |
| { | |
| label: "Solve a logic puzzle", | |
| prompt: "Five people were eating apples, A finished before B, but behind C. D finished before E, but behind B. What was the finishing order?", | |
| }, | |
| ] as const; | |
| interface ChatInputProps { | |
| showDisclaimer: boolean; | |
| animated?: boolean; | |
| } | |
| function ChatInput({ showDisclaimer, animated }: ChatInputProps) { | |
| const { send, stop, status, isGenerating } = useLLM(); | |
| const isReady = status.state === "ready"; | |
| const [input, setInput] = useState(""); | |
| const textareaRef = useRef<HTMLTextAreaElement>(null); | |
| const handleSubmit = useCallback( | |
| (e?: React.FormEvent) => { | |
| e?.preventDefault(); | |
| const text = input.trim(); | |
| if (!text || !isReady || isGenerating) return; | |
| setInput(""); | |
| if (textareaRef.current) { | |
| textareaRef.current.style.height = "7.5rem"; | |
| } | |
| send(text); | |
| }, | |
| [input, isReady, isGenerating, send], | |
| ); | |
| const handleKeyDown = useCallback( | |
| (e: React.KeyboardEvent<HTMLTextAreaElement>) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSubmit(); | |
| } | |
| }, | |
| [handleSubmit], | |
| ); | |
| return ( | |
| <div className={`w-full ${animated ? "animate-rise-in-delayed" : ""}`}> | |
| <form onSubmit={handleSubmit} className="mx-auto max-w-3xl"> | |
| <div className="relative"> | |
| <textarea | |
| ref={textareaRef} | |
| className="w-full rounded-xl border border-[#0000001f] bg-white px-4 py-3 pb-11 text-[15px] text-black placeholder-[#6d6d6d] focus:border-[#5505af] focus:outline-none focus:ring-1 focus:ring-[#5505af] disabled:opacity-50 resize-none max-h-40 shadow-sm" | |
| style={{ minHeight: "7.5rem", height: "7.5rem" }} | |
| placeholder={isReady ? "Type a message…" : "Loading model…"} | |
| value={input} | |
| onChange={(e) => { | |
| setInput(e.target.value); | |
| e.target.style.height = "7.5rem"; | |
| e.target.style.height = | |
| Math.max(e.target.scrollHeight, 120) + "px"; | |
| }} | |
| onKeyDown={handleKeyDown} | |
| disabled={!isReady} | |
| autoFocus | |
| /> | |
| <div className="absolute bottom-2 left-2 right-2 flex items-center justify-end pb-3 px-2"> | |
| {isGenerating ? ( | |
| <button | |
| type="button" | |
| onClick={stop} | |
| className="flex items-center justify-center rounded-lg text-[#6d6d6d] hover:text-black transition-colors cursor-pointer" | |
| title="Stop generating" | |
| > | |
| <Square className="h-4 w-4 fill-current" /> | |
| </button> | |
| ) : ( | |
| <button | |
| type="submit" | |
| disabled={!isReady || !input.trim()} | |
| className="flex items-center justify-center rounded-lg text-[#6d6d6d] hover:text-black disabled:opacity-30 transition-colors cursor-pointer" | |
| title="Send message" | |
| > | |
| <Send className="h-4 w-4" /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </form> | |
| {showDisclaimer && ( | |
| <p className="mx-auto max-w-3xl mt-1 text-center text-xs text-[#6d6d6d]"> | |
| No chats are sent to a server. Everything runs locally in your | |
| browser. AI can make mistakes. Check important info. | |
| </p> | |
| )} | |
| </div> | |
| ); | |
| } | |
| interface ChatAppProps { | |
| onGoHome: () => void; | |
| } | |
| export function ChatApp({ onGoHome }: ChatAppProps) { | |
| const { messages, isGenerating, send, status, clearChat } = useLLM(); | |
| const scrollRef = useRef<HTMLElement>(null); | |
| const [thinkingSeconds, setThinkingSeconds] = useState(0); | |
| const thinkingStartRef = useRef<number | null>(null); | |
| const thinkingSecondsMapRef = useRef<Map<number, number>>(new Map()); | |
| const prevIsGeneratingRef = useRef(false); | |
| const messagesRef = useRef(messages); | |
| const thinkingSecondsRef = useRef(thinkingSeconds); | |
| messagesRef.current = messages; | |
| thinkingSecondsRef.current = thinkingSeconds; | |
| const isReady = status.state === "ready"; | |
| const hasMessages = messages.length > 0; | |
| const showNewChat = isReady && hasMessages && !isGenerating; | |
| useEffect(() => { | |
| const el = scrollRef.current; | |
| if (el) { | |
| el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); | |
| } | |
| }, [messages]); | |
| useEffect(() => { | |
| if (prevIsGeneratingRef.current && !isGenerating) { | |
| const lastMsg = messagesRef.current.at(-1); | |
| if (lastMsg?.role === "assistant" && lastMsg.reasoning && thinkingSecondsRef.current > 0) { | |
| thinkingSecondsMapRef.current.set(lastMsg.id, thinkingSecondsRef.current); | |
| } | |
| } | |
| prevIsGeneratingRef.current = isGenerating; | |
| }, [isGenerating]); | |
| useEffect(() => { | |
| if (!isGenerating) { | |
| thinkingStartRef.current = null; | |
| return; | |
| } | |
| thinkingStartRef.current = Date.now(); | |
| setThinkingSeconds(0); | |
| const interval = setInterval(() => { | |
| if (thinkingStartRef.current) { | |
| setThinkingSeconds( | |
| Math.round((Date.now() - thinkingStartRef.current) / 1000), | |
| ); | |
| } | |
| }, 500); | |
| return () => clearInterval(interval); | |
| }, [isGenerating]); | |
| const lastAssistant = messages.at(-1); | |
| useEffect(() => { | |
| if (isGenerating && lastAssistant?.role === "assistant" && lastAssistant.content) { | |
| thinkingStartRef.current = null; | |
| } | |
| }, [isGenerating, lastAssistant?.role, lastAssistant?.content]); | |
| return ( | |
| <div className="flex h-full flex-col brand-surface text-black"> | |
| <header className="flex-none flex items-center justify-between border-b border-[#0000001f] px-6 py-3 h-14"> | |
| <button | |
| onClick={onGoHome} | |
| className="cursor-pointer transition-transform duration-300 hover:scale-[1.02]" | |
| title="Back to home" | |
| > | |
| <img | |
| src="/liquid.svg" | |
| alt="Liquid AI" | |
| className="h-6 w-auto" | |
| draggable={false} | |
| /> | |
| </button> | |
| <button | |
| onClick={clearChat} | |
| className={`flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-[#6d6d6d] hover:text-black hover:bg-[#f5f5f5] transition-opacity duration-300 cursor-pointer ${ | |
| showNewChat ? "opacity-100" : "opacity-0 pointer-events-none" | |
| }`} | |
| title="New chat" | |
| > | |
| <Plus className="h-3.5 w-3.5" /> | |
| New chat | |
| </button> | |
| </header> | |
| {!hasMessages ? ( | |
| <div className="flex flex-1 flex-col items-center justify-center px-4"> | |
| <div className="mb-8 text-center animate-rise-in"> | |
| <p className="text-3xl font-medium text-black"> | |
| What can I help you with? | |
| </p> | |
| </div> | |
| <ChatInput showDisclaimer={false} animated /> | |
| <div className="mt-6 flex flex-wrap justify-center gap-2 max-w-3xl animate-rise-in-delayed"> | |
| {EXAMPLE_PROMPTS.map(({ label, prompt }) => ( | |
| <button | |
| key={label} | |
| onClick={() => send(prompt)} | |
| className="rounded-lg border border-[#0000001f] bg-white px-3 py-2 text-xs text-[#6d6d6d] hover:text-black hover:border-[#5505af] transition-colors cursor-pointer shadow-sm" | |
| > | |
| {label} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| ) : ( | |
| <> | |
| <main | |
| ref={scrollRef} | |
| className="min-h-0 flex-1 overflow-y-auto px-4 py-6 animate-fade-in" | |
| > | |
| <div className="mx-auto flex max-w-3xl flex-col gap-4"> | |
| {!isReady && <StatusBar />} | |
| {messages.map((msg, i) => { | |
| const isLast = i === messages.length - 1 && msg.role === "assistant"; | |
| return ( | |
| <MessageBubble | |
| key={msg.id} | |
| msg={msg} | |
| index={i} | |
| isStreaming={isGenerating && isLast} | |
| thinkingSeconds={isLast ? thinkingSeconds : thinkingSecondsMapRef.current.get(msg.id)} | |
| isGenerating={isGenerating} | |
| /> | |
| ); | |
| })} | |
| </div> | |
| </main> | |
| <footer className="flex-none px-4 py-3 animate-fade-in relative"> | |
| {isReady && ( | |
| <div className="absolute bottom-full left-0 right-0 flex justify-center pointer-events-none mb-[-8px]"> | |
| <div className="pointer-events-auto"> | |
| <StatusBar /> | |
| </div> | |
| </div> | |
| )} | |
| <ChatInput showDisclaimer animated /> | |
| </footer> | |
| </> | |
| )} | |
| </div> | |
| ); | |
| } | |