Spaces:
Running
Running
| import { | |
| useState, | |
| useRef, | |
| useEffect, | |
| useCallback, | |
| useLayoutEffect, | |
| } from "react"; | |
| import { | |
| Send, | |
| Square, | |
| Plus, | |
| Lightbulb, | |
| LightbulbOff, | |
| ChevronDown, | |
| } from "lucide-react"; | |
| import { useLLM } from "../hooks/useLLM"; | |
| import { MessageBubble } from "./MessageBubble"; | |
| import { MODEL_CONFIG } from "../model-config"; | |
| import type { ThinkingMode } from "../hooks/LLMContext"; | |
| const TEXTAREA_MIN_HEIGHT = "7.5rem"; | |
| const THINKING_OPTIONS: { value: ThinkingMode; label: string }[] = [ | |
| { value: "enabled", label: "Reasoning On" }, | |
| { value: "disabled", label: "Reasoning Off" }, | |
| ]; | |
| export function ChatApp() { | |
| const { | |
| messages, | |
| isGenerating, | |
| tps, | |
| send, | |
| stop, | |
| status, | |
| clearChat, | |
| thinkingMode, | |
| setThinkingMode, | |
| } = useLLM(); | |
| const [input, setInput] = useState(""); | |
| const scrollRef = useRef<HTMLElement>(null); | |
| const textareaRef = useRef<HTMLTextAreaElement>(null); | |
| const [showThinkingMenu, setShowThinkingMenu] = useState(false); | |
| const thinkingMenuRef = useRef<HTMLDivElement>(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 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); | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if ( | |
| thinkingMenuRef.current && | |
| !thinkingMenuRef.current.contains(event.target as Node) | |
| ) { | |
| setShowThinkingMenu(false); | |
| } | |
| }; | |
| document.addEventListener("mousedown", handleClickOutside); | |
| return () => document.removeEventListener("mousedown", handleClickOutside); | |
| }, []); | |
| useEffect(() => { | |
| if (prevIsGeneratingRef.current && !isGenerating) { | |
| const lastMessage = messagesRef.current.at(-1); | |
| if ( | |
| lastMessage?.role === "assistant" && | |
| lastMessage.reasoning && | |
| thinkingSecondsRef.current > 0 | |
| ) { | |
| thinkingSecondsMapRef.current.set( | |
| lastMessage.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]); | |
| 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; | |
| // Compute the user element's offset from the top of the scroll content | |
| // (immune to browser scrollTop clamping). | |
| 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; | |
| // Distance from user element top to the spacer top (everything between them). | |
| const contentBelowUser = | |
| spacer.getBoundingClientRect().top - | |
| userElement.getBoundingClientRect().top; | |
| spacer.style.height = `${Math.max(0, usableHeight - contentBelowUser)}px`; | |
| // Only re-anchor if the user hasn't manually scrolled away. | |
| 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) { | |
| // Reset manual-scroll flag so the initial snap works for the new message. | |
| 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]); | |
| // Detect user-initiated scrolls (wheel or touch) to unlock free scrolling | |
| // during generation. | |
| 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]); | |
| // Re-anchor the user message whenever the scroll container's own size changes | |
| 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"> | |
| <form onSubmit={handleSubmit} className="mx-auto max-w-3xl"> | |
| <div className="relative"> | |
| <textarea | |
| ref={textareaRef} | |
| className="w-full rounded-xl border border-[#2a2a2a] bg-[#111111] px-4 py-3 pb-11 text-[15px] text-[#f0f0f0] placeholder-[#888888] focus:border-[#c9a84c] focus:outline-none focus:ring-1 focus:ring-[#c9a84c] disabled:opacity-50 resize-none max-h-40 shadow-sm" | |
| 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-between pb-3 px-2"> | |
| <div ref={thinkingMenuRef} className="relative"> | |
| <button | |
| type="button" | |
| onClick={() => setShowThinkingMenu((value) => !value)} | |
| className="flex items-center gap-1 rounded-lg text-xs text-[#888888] hover:text-[#f0f0f0] transition-colors cursor-pointer" | |
| title="Reasoning mode" | |
| > | |
| {thinkingMode === "enabled" ? ( | |
| <Lightbulb className="h-3.5 w-3.5 text-[#76b900]" /> | |
| ) : ( | |
| <LightbulbOff className="h-3.5 w-3.5 text-[#888888]" /> | |
| )} | |
| <span> | |
| { | |
| THINKING_OPTIONS.find( | |
| (option) => option.value === thinkingMode, | |
| )?.label | |
| } | |
| </span> | |
| <ChevronDown className="h-3 w-3" /> | |
| </button> | |
| {showThinkingMenu && ( | |
| <div className="absolute bottom-full left-0 mb-1 min-w-[160px] rounded-lg border border-[#2a2a2a] bg-[#1a1a1a] py-1 shadow-xl z-50"> | |
| {THINKING_OPTIONS.map((option) => ( | |
| <button | |
| key={option.value} | |
| type="button" | |
| onClick={() => { | |
| setThinkingMode(option.value); | |
| setShowThinkingMenu(false); | |
| }} | |
| className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors cursor-pointer ${ | |
| thinkingMode === option.value | |
| ? "bg-[#222222] text-[#f0f0f0]" | |
| : "text-[#888888] hover:bg-[#222222] hover:text-[#f0f0f0]" | |
| }`} | |
| > | |
| {option.value === "enabled" ? ( | |
| <Lightbulb className="h-3.5 w-3.5 text-[#76b900]" /> | |
| ) : ( | |
| <LightbulbOff className="h-3.5 w-3.5 text-[#888888]" /> | |
| )} | |
| <span>{option.label}</span> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {isGenerating ? ( | |
| <button | |
| type="button" | |
| onClick={stop} | |
| className="flex items-center justify-center rounded-lg text-[#76b900] hover:text-[#8fd400] 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-[#76b900] hover:text-[#8fd400] 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-[#888888]"> | |
| 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 h-full flex-col bg-[#0a0a0a] text-[#f0f0f0]"> | |
| <header className="flex-none flex items-center justify-between border-b border-[#2a2a2a] px-6 py-3 h-14"> | |
| <div className="flex items-center gap-2"> | |
| <svg | |
| height="16" | |
| viewBox="0 0 20 20" | |
| width="16" | |
| fill="none" | |
| xmlns="http://www.w3.org/2000/svg" | |
| aria-hidden="true" | |
| > | |
| <path d="M10 1.5 2 6v8l8 4.5 8-4.5V6l-8-4.5Z" fill="#76B900" /> | |
| <path d="M10 5.1 6 7.4v5.2l4 2.3 4-2.3V7.4l-4-2.3Z" fill="#fff" /> | |
| </svg> | |
| <h1 className="text-base font-semibold text-[#f0f0f0]"> | |
| {MODEL_CONFIG.label} | |
| </h1> | |
| <span className="text-base font-semibold text-[#888888]">WebGPU</span> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <button | |
| onClick={clearChat} | |
| className={`flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-[#888888] hover:text-[#f0f0f0] hover:bg-[#1a1a1a] 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> | |
| </div> | |
| </header> | |
| {isReady && !hasMessages ? ( | |
| <div className="flex flex-1 flex-col items-center justify-center px-4"> | |
| <div className="mb-8 text-center"> | |
| <p className="text-3xl font-medium text-[#f0f0f0]"> | |
| What can I help you with? | |
| </p> | |
| </div> | |
| {renderInputArea(false)} | |
| <div className="mt-6 flex flex-wrap justify-center gap-3 max-w-3xl"> | |
| {MODEL_CONFIG.examplePrompts.map(({ label, prompt }) => ( | |
| <button | |
| key={label} | |
| onClick={() => send(prompt)} | |
| className="relative rounded-[14px] border-[2px] border-[rgba(220,210,180,0.3)] bg-[linear-gradient(160deg,rgba(28,28,28,0.97)_0%,rgba(10,10,10,0.99)_100%)] px-4 py-2.5 text-xs text-[#888888] hover:text-[#f0f0f0] hover:border-[rgba(235,225,195,0.6)] hover:-translate-y-[2px] hover:shadow-[0_4px_16px_rgba(0,0,0,0.4),0_0_10px_rgba(220,210,180,0.05)] transition-all duration-300 cursor-pointer overflow-hidden before:pointer-events-none before:absolute before:inset-0 before:rounded-[14px] before:bg-[radial-gradient(ellipse_at_50%_0%,rgba(235,225,190,0.05)_0%,transparent_55%)]" | |
| > | |
| {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-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} | |
| thinkingSeconds={ | |
| isLastAssistant | |
| ? thinkingSeconds | |
| : thinkingSecondsMapRef.current.get(message.id) | |
| } | |
| isGenerating={isGenerating} | |
| /> | |
| </div> | |
| ); | |
| })} | |
| <div ref={bottomSpacerRef} /> | |
| </div> | |
| </main> | |
| <footer className="flex-none px-4 py-3 animate-fade-in"> | |
| <p | |
| className={`mx-auto max-w-3xl mb-3 text-center text-xs font-mono tabular-nums h-4 ${isGenerating && tps > 0 ? "text-[#888888]" : "text-transparent"}`} | |
| > | |
| {isGenerating && tps > 0 | |
| ? `${tps.toFixed(1)} tokens/s` | |
| : "\u00A0"} | |
| </p> | |
| {renderInputArea(true)} | |
| </footer> | |
| </> | |
| )} | |
| </div> | |
| ); | |
| } | |