"use client"; import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { askAI } from "../utils/askAI"; /** * GrasBot — UI du chatbot (Stitch). * * L'API publique ne change pas (`onClose` propagé par le parent). Le bouton * d'ouverture est porté par le FAB global `GrasBotFab` monté dans * `app/layout.tsx`. Ce composant se concentre sur le panneau de conversation. * * v3 (2026-04-22) — bascule retrieval graph + BM25 : * - Affichage des `sources` renvoyées par l'API (pill par source, cliquable * vers `/portfolio/` ou `/competences/` si dispo). * - Badge `grounded` sous chaque réponse (paperclip si sources exploitées, * info si réponse générale faute de contexte pertinent). * - Timeout 45 s côté fetch (géré dans `askAI.js`) avec message éditorial. * * Design : * - Fond `surface-container-lowest/95 backdrop-blur-vellum rounded-sheet shadow-ambient`. * - Bulles : user = `bg-primary text-white` à droite, bot = `bg-surface-container` à gauche. * - Sources : petites pills `bg-surface-container-low text-primary` sous la bulle bot. * - Auto-scroll en bas, envoi à Enter, focus auto, disabled pendant attente. */ export default function ChatBot({ onClose }) { const [question, setQuestion] = useState(""); const [messages, setMessages] = useState([]); const [isWaiting, setIsWaiting] = useState(false); const scrollRef = useRef(null); const inputRef = useRef(null); useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [messages, isWaiting]); useEffect(() => { inputRef.current?.focus(); }, []); const handleAsk = async () => { if (!question.trim() || isWaiting) return; const userMessage = { sender: "user", text: question }; setMessages((prev) => [...prev, userMessage]); setQuestion(""); setIsWaiting(true); try { const payload = await askAI(userMessage.text); setMessages((prev) => [ ...prev, { sender: "bot", text: payload.response, sources: payload.sources || [], grounded: Boolean(payload.grounded), timeout: Boolean(payload._timeout), }, ]); } catch (_error) { setMessages((prev) => [ ...prev, { sender: "bot", text: "Erreur de réponse. Réessayez plus tard.", sources: [], grounded: false, error: true, }, ]); } finally { setIsWaiting(false); } }; const handleKeyDown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleAsk(); } }; return (

GrasBot

Assistant IA locale

{messages.length === 0 && !isWaiting && (

Posez-moi une question sur le site, les projets ou les compétences.

)} {messages.map((msg, index) => { if (msg.sender === "user") { return (
{msg.text}
); } return (
{msg.text}
{(msg.sources?.length > 0 || msg.grounded !== undefined) && !msg.error && !msg.timeout && ( )}
); })} {isWaiting && (
GrasBot réfléchit . . .
)}
setQuestion(e.target.value)} onKeyDown={handleKeyDown} disabled={isWaiting} aria-label="Votre question pour GrasBot" />
); } /** * Sous-composant : pied d'une réponse bot avec badge grounded + sources. * Extrait pour alléger la lisibilité et garder les styles groupés. */ function BotFooter({ sources, grounded }) { // Filtrer les sources internes sans url + dédoublonner par slug const uniqueSources = []; const seen = new Set(); for (const s of sources || []) { if (!s?.slug || seen.has(s.slug)) continue; seen.add(s.slug); uniqueSources.push(s); } const clickable = uniqueSources.filter((s) => s.url); const displayed = clickable.slice(0, 4); return (
{grounded ? "Basé sur le vault" : "Réponse générale"} {displayed.map((s) => ( {s.slug} ))}
); }