spb/spboucher.ai Public
spboucher.ai — personal website of Simon-Pierre Boucher.
TypeScript 93.4%
HTML 5.5%
CSS 1%
1/*2 agent-chat.tsx3 spboucher.ai Web4 Author: Simon-Pierre Boucher5 Mail: contact@spboucher.ai6*/78"use client";910import { useCallback, useEffect, useRef, useState } from "react";11import { AnimatePresence, motion, useReducedMotion } from "framer-motion";12import { ArrowUp, Minus, Sparkles, X } from "lucide-react";1314interface ChatMessage {15 role: "user" | "assistant";16 content: string;17}1819const SUGGESTIONS = [20 "Who is Simon-Pierre Boucher?",21 "What does his research focus on?",22 "Tell me about the Zyquo apps",23 "Qu'est-ce que HF Market Data ?",24];2526const GREETING =27 "Hi — I'm SPB Agent, Simon-Pierre Boucher's AI assistant. Ask me anything about his research, teaching, or the software he builds. Je réponds aussi en français.";2829/** Floating chat bubble + panel, streaming answers from /api/agent. */30export function AgentChat() {31 const [open, setOpen] = useState(false);32 const [messages, setMessages] = useState<ChatMessage[]>([]);33 const [input, setInput] = useState("");34 const [streaming, setStreaming] = useState(false);35 const scrollRef = useRef<HTMLDivElement>(null);36 const inputRef = useRef<HTMLTextAreaElement>(null);37 const abortRef = useRef<AbortController | null>(null);38 const reducedMotion = useReducedMotion();3940 useEffect(() => {41 const el = scrollRef.current;42 if (el) el.scrollTop = el.scrollHeight;43 }, [messages, open]);4445 useEffect(() => {46 if (open) inputRef.current?.focus();47 }, [open]);4849 useEffect(() => {50 const onKey = (e: KeyboardEvent) => {51 if (e.key === "Escape") setOpen(false);52 };53 window.addEventListener("keydown", onKey);54 return () => window.removeEventListener("keydown", onKey);55 }, []);5657 const send = useCallback(58 async (text: string) => {59 const question = text.trim();60 if (!question || streaming) return;6162 const history: ChatMessage[] = [63 ...messages,64 { role: "user", content: question },65 ];66 setMessages([...history, { role: "assistant", content: "" }]);67 setInput("");68 setStreaming(true);6970 const controller = new AbortController();71 abortRef.current = controller;7273 try {74 const res = await fetch("/api/agent", {75 method: "POST",76 headers: { "Content-Type": "application/json" },77 body: JSON.stringify({ messages: history }),78 signal: controller.signal,79 });8081 if (!res.ok || !res.body) {82 throw new Error(`HTTP ${res.status}`);83 }8485 const reader = res.body.getReader();86 const decoder = new TextDecoder();87 let answer = "";88 for (;;) {89 const { done, value } = await reader.read();90 if (done) break;91 answer += decoder.decode(value, { stream: true });92 const snapshot = answer;93 setMessages([94 ...history,95 { role: "assistant", content: snapshot },96 ]);97 }98 } catch (err) {99 if (!(err instanceof DOMException && err.name === "AbortError")) {100 setMessages([101 ...history,102 {103 role: "assistant",104 content:105 "Sorry — I couldn't reach the agent right now. Please try again in a moment, or email contact@spboucher.ai.",106 },107 ]);108 }109 } finally {110 setStreaming(false);111 abortRef.current = null;112 }113 },114 [messages, streaming],115 );116117 const onSubmit = (e: React.FormEvent) => {118 e.preventDefault();119 void send(input);120 };121122 return (123 <>124 {/* Panel */}125 <AnimatePresence>126 {open && (127 <motion.div128 initial={reducedMotion ? false : { opacity: 0, y: 16, scale: 0.98 }}129 animate={{ opacity: 1, y: 0, scale: 1 }}130 exit={131 reducedMotion132 ? { opacity: 0 }133 : { opacity: 0, y: 16, scale: 0.98 }134 }135 transition={{ duration: 0.18, ease: "easeOut" }}136 className="fixed inset-x-3 bottom-3 z-50 flex max-h-[min(40rem,calc(100dvh-1.5rem))] flex-col overflow-hidden rounded-[0.9rem] border-2 border-foreground bg-background shadow-[var(--shadow-lift)] sm:inset-x-auto sm:right-5 sm:bottom-5 sm:w-[24rem]"137 role="dialog"138 aria-label="SPB Agent chat"139 >140 {/* Header */}141 <div className="flex items-center justify-between border-b-2 border-foreground bg-card/70 px-4 py-3">142 <div className="flex items-center gap-3">143 <div144 aria-hidden="true"145 className="flex h-9 w-9 items-center justify-center rounded-[0.6rem] bg-foreground font-mono text-[0.6rem] font-semibold tracking-[0.08em] text-background"146 >147 SPB148 </div>149 <div>150 <p className="font-display text-base font-semibold leading-tight tracking-tight">151 SPB Agent152 </p>153 <p className="font-mono text-[0.58rem] uppercase tracking-[0.14em] text-muted-foreground">154 <span155 aria-hidden="true"156 className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-emerald-500 align-middle"157 />158 Claude Haiku 4.5 · live159 </p>160 </div>161 </div>162 <button163 type="button"164 onClick={() => setOpen(false)}165 aria-label="Close chat"166 className="flex h-8 w-8 items-center justify-center rounded-[0.5rem] border border-border text-muted-foreground transition-colors hover:border-foreground hover:text-foreground"167 >168 <Minus className="h-4 w-4" aria-hidden="true" />169 </button>170 </div>171172 {/* Messages */}173 <div174 ref={scrollRef}175 className="flex-1 space-y-4 overflow-y-auto px-4 py-4"176 >177 <Bubble role="assistant" content={GREETING} />178 {messages.map((m, i) => (179 <Bubble180 key={i}181 role={m.role}182 content={m.content}183 pending={184 streaming &&185 i === messages.length - 1 &&186 m.role === "assistant" &&187 m.content.length === 0188 }189 />190 ))}191192 {messages.length === 0 && (193 <div className="space-y-2 pt-1">194 <p className="font-mono text-[0.58rem] uppercase tracking-[0.14em] text-muted-foreground">195 Try asking196 </p>197 <div className="flex flex-wrap gap-2">198 {SUGGESTIONS.map((s) => (199 <button200 key={s}201 type="button"202 onClick={() => void send(s)}203 className="rounded-full border border-border bg-card px-3 py-1.5 text-left text-xs text-foreground/85 transition-colors hover:border-foreground"204 >205 {s}206 </button>207 ))}208 </div>209 </div>210 )}211 </div>212213 {/* Input */}214 <form215 onSubmit={onSubmit}216 className="flex items-end gap-2 border-t-2 border-foreground bg-card/70 px-3 py-3"217 >218 <textarea219 ref={inputRef}220 value={input}221 onChange={(e) => setInput(e.target.value)}222 onKeyDown={(e) => {223 if (e.key === "Enter" && !e.shiftKey) {224 e.preventDefault();225 void send(input);226 }227 }}228 rows={1}229 placeholder="Ask about Simon-Pierre…"230 aria-label="Message for SPB Agent"231 className="max-h-28 min-h-[2.5rem] flex-1 resize-none rounded-[0.6rem] border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/70 focus:border-foreground focus:outline-none"232 />233 <button234 type="submit"235 disabled={streaming || input.trim().length === 0}236 aria-label="Send message"237 className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[0.6rem] bg-foreground text-background transition-opacity disabled:opacity-35"238 >239 <ArrowUp className="h-4 w-4" aria-hidden="true" />240 </button>241 </form>242 </motion.div>243 )}244 </AnimatePresence>245246 {/* Bubble */}247 <AnimatePresence>248 {!open && (249 <motion.button250 initial={reducedMotion ? false : { opacity: 0, scale: 0.85 }}251 animate={{ opacity: 1, scale: 1 }}252 exit={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.85 }}253 transition={{ duration: 0.15 }}254 type="button"255 onClick={() => setOpen(true)}256 aria-label="Open SPB Agent chat"257 className="group fixed right-5 bottom-5 z-50 flex items-center gap-2.5 rounded-full border-2 border-foreground bg-foreground py-2.5 pr-5 pl-3.5 text-background shadow-[var(--shadow-lift)] transition-transform hover:-translate-y-0.5"258 >259 <Sparkles260 className="h-4 w-4 text-background/90 transition-transform group-hover:rotate-12"261 aria-hidden="true"262 />263 <span className="font-mono text-[0.66rem] font-semibold uppercase tracking-[0.14em]">264 Ask SPB Agent265 </span>266 </motion.button>267 )}268 </AnimatePresence>269 </>270 );271}272273function Bubble({274 role,275 content,276 pending,277}: {278 role: "user" | "assistant";279 content: string;280 pending?: boolean;281}) {282 if (role === "user") {283 return (284 <div className="flex justify-end">285 <div className="max-w-[85%] rounded-[0.8rem] rounded-br-[0.2rem] bg-foreground px-3.5 py-2.5 text-sm leading-relaxed whitespace-pre-wrap text-background">286 {content}287 </div>288 </div>289 );290 }291 return (292 <div className="flex justify-start">293 <div className="max-w-[90%] rounded-[0.8rem] rounded-bl-[0.2rem] border border-border bg-card px-3.5 py-2.5 text-sm leading-relaxed whitespace-pre-wrap text-foreground/90 shadow-[var(--shadow-soft)]">294 {pending ? (295 <span296 className="inline-flex items-center gap-1"297 aria-label="SPB Agent is typing"298 >299 <Dot delay="0s" />300 <Dot delay="0.15s" />301 <Dot delay="0.3s" />302 </span>303 ) : (304 content305 )}306 </div>307 </div>308 );309}310311function Dot({ delay }: { delay: string }) {312 return (313 <span314 aria-hidden="true"315 className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/70"316 style={{ animationDelay: delay }}317 />318 );319}320