mlabonne's picture
Upload demo files (#1)
4fb0ce9
raw
history blame
9.44 kB
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>
);
}