Spaces:
Running
Running
| import { useState, useRef, useEffect, useCallback, useLayoutEffect } from "react"; | |
| import { Send, Square, Plus } from "lucide-react"; | |
| import { useLLM } from "../hooks/useLLM"; | |
| import { MessageBubble } from "./MessageBubble"; | |
| import { BrandMark } from "./BrandMark"; | |
| import { MODEL_CONFIG } from "../model-config"; | |
| const TEXTAREA_MIN_HEIGHT = "7.5rem"; | |
| export function ChatApp() { | |
| const { messages, isGenerating, tps, send, stop, status, clearChat } = useLLM(); | |
| const [input, setInput] = useState(""); | |
| const scrollRef = useRef<HTMLElement>(null); | |
| const textareaRef = useRef<HTMLTextAreaElement>(null); | |
| const isReady = status.state === "ready"; | |
| const hasMessages = messages.length > 0; | |
| const hasCompletedRef = useRef(false); | |
| useEffect(() => { | |
| if (hasMessages && !isGenerating) hasCompletedRef.current = true; | |
| if (!hasMessages) hasCompletedRef.current = false; | |
| }, [hasMessages, isGenerating]); | |
| const showNewChat = isReady && hasMessages && !isGenerating && hasCompletedRef.current; | |
| const prevMsgCountRef = useRef(0); | |
| const lastUserRef = useRef<HTMLDivElement>(null); | |
| const bottomSpacerRef = useRef<HTMLDivElement>(null); | |
| const userHasScrolledRef = useRef(false); | |
| const getContainerPadTop = useCallback(() => { | |
| const container = scrollRef.current; | |
| if (!container) return 0; | |
| return parseFloat(getComputedStyle(container).paddingTop) || 0; | |
| }, []); | |
| const recalcSpacer = useCallback(() => { | |
| const container = scrollRef.current; | |
| const userElement = lastUserRef.current; | |
| const spacer = bottomSpacerRef.current; | |
| if (!container || !userElement || !spacer) return; | |
| const userOffsetInContent = | |
| userElement.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop; | |
| const padTop = getContainerPadTop(); | |
| const padBottom = parseFloat(getComputedStyle(container).paddingBottom) || 0; | |
| const usableHeight = container.clientHeight - padTop - padBottom; | |
| const contentBelowUser = spacer.getBoundingClientRect().top - userElement.getBoundingClientRect().top; | |
| spacer.style.height = `${Math.max(0, usableHeight - contentBelowUser)}px`; | |
| if (!userHasScrolledRef.current) { | |
| const desiredScrollTop = userOffsetInContent - padTop; | |
| if (Math.abs(container.scrollTop - desiredScrollTop) > 0.5) { | |
| container.scrollTo({ top: desiredScrollTop, behavior: "smooth" }); | |
| } | |
| } | |
| }, [getContainerPadTop]); | |
| useLayoutEffect(() => { | |
| recalcSpacer(); | |
| const isNewMessage = messages.length > prevMsgCountRef.current; | |
| prevMsgCountRef.current = messages.length; | |
| if (isNewMessage) { | |
| userHasScrolledRef.current = false; | |
| const container = scrollRef.current; | |
| const userElement = lastUserRef.current; | |
| if (!container || !userElement) return; | |
| const scrollTarget = | |
| container.scrollTop + | |
| (userElement.getBoundingClientRect().top - container.getBoundingClientRect().top) - | |
| getContainerPadTop(); | |
| container.scrollTo({ top: scrollTarget, behavior: "smooth" }); | |
| } | |
| }, [messages, isGenerating, recalcSpacer, getContainerPadTop]); | |
| useEffect(() => { | |
| window.addEventListener("resize", recalcSpacer); | |
| return () => window.removeEventListener("resize", recalcSpacer); | |
| }, [recalcSpacer]); | |
| useEffect(() => { | |
| const container = scrollRef.current; | |
| if (!container) return; | |
| const markScrolled = () => { | |
| if (isGenerating) { | |
| userHasScrolledRef.current = true; | |
| } | |
| }; | |
| container.addEventListener("wheel", markScrolled, { passive: true }); | |
| container.addEventListener("touchmove", markScrolled, { passive: true }); | |
| return () => { | |
| container.removeEventListener("wheel", markScrolled); | |
| container.removeEventListener("touchmove", markScrolled); | |
| }; | |
| }, [isGenerating]); | |
| useLayoutEffect(() => { | |
| const container = scrollRef.current; | |
| if (!container) return; | |
| let lastHeight = container.clientHeight; | |
| const observer = new ResizeObserver(() => { | |
| const h = container.clientHeight; | |
| if (h !== lastHeight) { | |
| lastHeight = h; | |
| recalcSpacer(); | |
| } | |
| }); | |
| observer.observe(container); | |
| return () => observer.disconnect(); | |
| }, [recalcSpacer]); | |
| const handleSubmit = useCallback( | |
| (event?: React.FormEvent) => { | |
| event?.preventDefault(); | |
| const text = input.trim(); | |
| if (!text || !isReady || isGenerating) return; | |
| setInput(""); | |
| if (textareaRef.current) { | |
| textareaRef.current.style.height = TEXTAREA_MIN_HEIGHT; | |
| } | |
| send(text); | |
| }, | |
| [input, isReady, isGenerating, send], | |
| ); | |
| const handleInputKeyDown = useCallback( | |
| (event: React.KeyboardEvent<HTMLTextAreaElement>) => { | |
| if (event.key === "Enter" && !event.shiftKey) { | |
| event.preventDefault(); | |
| handleSubmit(); | |
| } | |
| }, | |
| [handleSubmit], | |
| ); | |
| const lastUserIndex = messages.findLastIndex((message) => message.role === "user"); | |
| const renderInputArea = (showDisclaimer: boolean) => ( | |
| <div className="w-full max-w-3xl mx-auto"> | |
| <form onSubmit={handleSubmit} className="relative"> | |
| <textarea | |
| ref={textareaRef} | |
| className="w-full min-h-[7.5rem] pt-3.5 px-4 pb-11 border border-line rounded-[20px] bg-[rgba(255,255,255,0.04)] text-text font-body text-[0.95rem] leading-[1.5] resize-none max-h-40 placeholder:text-text-muted focus:outline-[1px] focus:outline-[rgba(157,224,255,0.44)] focus:border-[rgba(157,224,255,0.44)] disabled:opacity-50" | |
| style={{ | |
| minHeight: TEXTAREA_MIN_HEIGHT, | |
| height: TEXTAREA_MIN_HEIGHT, | |
| }} | |
| placeholder={isReady ? "Type a message…" : "Loading model…"} | |
| value={input} | |
| onChange={(event) => { | |
| setInput(event.target.value); | |
| event.target.style.height = TEXTAREA_MIN_HEIGHT; | |
| event.target.style.height = Math.max(event.target.scrollHeight, 120) + "px"; | |
| }} | |
| onKeyDown={handleInputKeyDown} | |
| disabled={!isReady} | |
| autoFocus | |
| /> | |
| <div className="absolute bottom-2 left-2 right-2 flex items-center justify-end px-2 pb-3"> | |
| {isGenerating ? ( | |
| <button | |
| type="button" | |
| onClick={stop} | |
| className="flex items-center justify-center p-1.5 rounded-lg text-accent bg-transparent transition-[color,opacity] duration-[180ms] ease-[ease] hover:text-accent-strong" | |
| title="Stop generating" | |
| > | |
| <Square size={16} fill="currentColor" /> | |
| </button> | |
| ) : ( | |
| <button | |
| type="submit" | |
| disabled={!isReady || !input.trim()} | |
| className="flex items-center justify-center p-1.5 rounded-lg text-accent bg-transparent transition-[color,opacity] duration-[180ms] ease-[ease] hover:text-accent-strong disabled:opacity-30" | |
| title="Send message" | |
| > | |
| <Send size={16} /> | |
| </button> | |
| )} | |
| </div> | |
| </form> | |
| {showDisclaimer && ( | |
| <p className="max-w-3xl mx-auto mt-1 text-center text-[0.75rem] text-text-muted"> | |
| No chats are sent to a server. Everything runs locally in your browser. AI can make mistakes. Check important | |
| info. | |
| </p> | |
| )} | |
| </div> | |
| ); | |
| return ( | |
| <div className="flex flex-col h-full bg-bg text-text"> | |
| <header className="flex-none flex items-center justify-between gap-4 py-4 px-6 border-b border-line max-sm:py-3 max-sm:px-4"> | |
| <BrandMark /> | |
| <button | |
| onClick={clearChat} | |
| className={`inline-flex items-center gap-2.5 py-2.5 px-4 border border-line rounded-full bg-[rgba(8,13,24,0.44)] text-text text-[0.88rem] backdrop-blur-[16px] transition-[transform,border-color,background-color,opacity] duration-[180ms] ease-[ease] hover:-translate-y-0.5 ${ | |
| showNewChat ? "" : "opacity-0 pointer-events-none" | |
| }`} | |
| title="New chat" | |
| > | |
| <Plus className="shrink-0 text-accent" size={16} strokeWidth={1.8} /> | |
| New chat | |
| </button> | |
| </header> | |
| {isReady && !hasMessages ? ( | |
| <div className="flex-1 flex flex-col items-center justify-center p-4"> | |
| <h2 className="mb-8 text-[clamp(1.6rem,4vw,2.4rem)] font-bold tracking-[-0.04em] text-center"> | |
| What can I help you with? | |
| </h2> | |
| {renderInputArea(false)} | |
| <div className="flex flex-wrap justify-center gap-2.5 max-w-3xl mt-6"> | |
| {MODEL_CONFIG.examplePrompts.map(({ label, prompt }) => ( | |
| <button | |
| key={label} | |
| onClick={() => send(prompt)} | |
| className="py-2.5 px-3.5 border border-line rounded-full bg-[rgba(255,255,255,0.05)] text-text-soft text-[0.88rem] transition-[transform,border-color,background-color,opacity] duration-[180ms] ease-[ease] hover:-translate-y-0.5 hover:border-[rgba(157,224,255,0.42)] hover:bg-[rgba(157,224,255,0.14)] hover:text-accent-strong" | |
| type="button" | |
| > | |
| {label} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| ) : ( | |
| <> | |
| <main ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto py-6 px-4 animate-fade-in"> | |
| <div className="max-w-3xl mx-auto flex flex-col gap-6"> | |
| {messages.map((message, index) => { | |
| const isLastAssistant = index === messages.length - 1 && message.role === "assistant"; | |
| const isLastUser = message.role === "user" && index === lastUserIndex; | |
| return ( | |
| <div key={message.id} ref={isLastUser ? lastUserRef : undefined}> | |
| <MessageBubble | |
| msg={message} | |
| index={index} | |
| isStreaming={isGenerating && isLastAssistant} | |
| isGenerating={isGenerating} | |
| /> | |
| </div> | |
| ); | |
| })} | |
| <div ref={bottomSpacerRef} /> | |
| </div> | |
| </main> | |
| <footer className="flex-none pt-3 px-4 pb-4 animate-fade-in"> | |
| {isGenerating && tps > 0 && ( | |
| <p className="max-w-3xl mx-auto mb-3 text-center font-mono text-[0.78rem] tabular-nums h-4 text-text-muted"> | |
| {`${tps.toFixed(1)} tokens/s`} | |
| </p> | |
| )} | |
| {renderInputArea(true)} | |
| </footer> | |
| </> | |
| )} | |
| </div> | |
| ); | |
| } | |