SPB Git

spb/spboucher.ai Public

spboucher.ai — personal website of Simon-Pierre Boucher.

TypeScript 93.4% HTML 5.5% CSS 1%
10.9 KB · 320 lines tsx
Raw Blame History
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