"use client"; import Link from "next/link"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { askAI } from "../utils/askAI"; import { clearGrasbotChatMessages, loadGrasbotChatMessages, newChatMessageId, saveGrasbotChatMessages, } from "../utils/grasbotChatStorage"; import { getGrasbotSourceIconName, resolveGrasbotSourceHref } from "../utils/grasbotSourceUrl"; /** * 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). L'URL est * résolue via `resolveGrasbotSourceHref` (API `url` / `route_parent` + fallback * pour l'historique localStorage sans métadonnées à jour). * - 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. * * v3.2 (2026-04-26) : * - Réponses bot rendues en Markdown (`ReactMarkdown` + `remark-gfm`) : gras, * listes, liens cliquables. Texte justifié dans la bulle. Les messages * utilisateur restent en texte brut. * * v3.3 (2026-04-26) : * - Historique persisté en **localStorage** par `grasbot_user_id` (voir * `grasbotChatStorage.js`) : même navigateur / effacement cookies selon usage, * jusqu'à 80 messages récents. Aucune réinjection dans Ollama. * - Bouton pour effacer la conversation locale. * * 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 [hydrated, setHydrated] = useState(false); const [isWaiting, setIsWaiting] = useState(false); const scrollRef = useRef(null); const inputRef = useRef(null); useLayoutEffect(() => { setMessages(loadGrasbotChatMessages()); setHydrated(true); }, []); useEffect(() => { if (!hydrated) return; saveGrasbotChatMessages(messages); }, [messages, hydrated]); const handleClearHistory = () => { setMessages([]); clearGrasbotChatMessages(); inputRef.current?.focus(); }; 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, id: newChatMessageId() }; 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), id: newChatMessageId(), }, ]); } catch (_error) { setMessages((prev) => [ ...prev, { sender: "bot", text: "Erreur de réponse. Réessayez plus tard.", sources: [], grounded: false, error: true, id: newChatMessageId(), }, ]); } finally { setIsWaiting(false); } }; const handleKeyDown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleAsk(); } }; return (
GrasBot
Assistant IA locale
Posez-moi une question sur le site, les projets ou les compétences.
)} {messages.map((msg, index) => { const msgKey = msg.id ?? `legacy-${index}`; if (msg.sender === "user") { return (