history_chatbot

This commit is contained in:
Ladebeze66 2026-05-01 12:30:39 +02:00
parent 5021251dbf
commit ee79d56d66
2 changed files with 149 additions and 17 deletions

View File

@ -1,10 +1,16 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
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";
/**
* GrasBot UI du chatbot (Stitch).
@ -25,6 +31,12 @@ import { askAI } from "../utils/askAI";
* 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.
@ -34,10 +46,27 @@ import { askAI } from "../utils/askAI";
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;
@ -51,7 +80,7 @@ export default function ChatBot({ onClose }) {
const handleAsk = async () => {
if (!question.trim() || isWaiting) return;
const userMessage = { sender: "user", text: question };
const userMessage = { sender: "user", text: question, id: newChatMessageId() };
setMessages((prev) => [...prev, userMessage]);
setQuestion("");
setIsWaiting(true);
@ -66,6 +95,7 @@ export default function ChatBot({ onClose }) {
sources: payload.sources || [],
grounded: Boolean(payload.grounded),
timeout: Boolean(payload._timeout),
id: newChatMessageId(),
},
]);
} catch (_error) {
@ -77,6 +107,7 @@ export default function ChatBot({ onClose }) {
sources: [],
grounded: false,
error: true,
id: newChatMessageId(),
},
]);
} finally {
@ -109,20 +140,34 @@ export default function ChatBot({ onClose }) {
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label="Fermer le chat"
className="flex h-9 w-9 items-center justify-center rounded-full text-white transition-colors hover:bg-primary-container focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-fixed"
>
<span
className="material-symbols-outlined"
aria-hidden="true"
translate="no"
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={handleClearHistory}
disabled={messages.length === 0}
aria-label="Effacer l'historique de conversation"
title="Effacer l'historique"
className="flex h-9 w-9 items-center justify-center rounded-full text-white transition-colors hover:bg-primary-container focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-fixed disabled:pointer-events-none disabled:opacity-40"
>
close
</span>
</button>
<span className="material-symbols-outlined text-xl" aria-hidden="true" translate="no">
delete_sweep
</span>
</button>
<button
type="button"
onClick={onClose}
aria-label="Fermer le chat"
className="flex h-9 w-9 items-center justify-center rounded-full text-white transition-colors hover:bg-primary-container focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-fixed"
>
<span
className="material-symbols-outlined"
aria-hidden="true"
translate="no"
>
close
</span>
</button>
</div>
</div>
<div
@ -136,10 +181,11 @@ export default function ChatBot({ onClose }) {
)}
{messages.map((msg, index) => {
const msgKey = msg.id ?? `legacy-${index}`;
if (msg.sender === "user") {
return (
<div
key={index}
key={msgKey}
className="ml-auto max-w-[80%] rounded-sheet bg-primary px-3 py-2 font-headline text-xs leading-relaxed text-white"
>
{msg.text}
@ -147,7 +193,7 @@ export default function ChatBot({ onClose }) {
);
}
return (
<div key={index} className="mr-auto flex max-w-[85%] flex-col gap-1.5">
<div key={msgKey} className="mr-auto flex max-w-[85%] flex-col gap-1.5">
<div
className="rounded-sheet bg-surface-container px-3 py-2 text-on-surface [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
>

View File

@ -0,0 +1,86 @@
/**
* Persistance locale du fil GrasBot (sans envoi à Ollama).
*
* Clé : `grasbot_chat_v1:<user_id>` `user_id` est celui de `grasbotIds.js`.
* Limite : les derniers messages uniquement pour éviter quota localStorage (~5 Mo).
*/
import { getGrasbotUserId } from "./grasbotIds";
export const GRASBOT_CHAT_STORAGE_VERSION = 1;
export const GRASBOT_CHAT_MAX_MESSAGES = 80;
function storageKey(userId) {
return `grasbot_chat_v${GRASBOT_CHAT_STORAGE_VERSION}:${userId}`;
}
function safeRandomId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `m_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
/**
* @param {unknown} m
* @returns {object | null}
*/
function sanitizeMessage(m) {
if (!m || typeof m !== "object") return null;
if (m.sender !== "user" && m.sender !== "bot") return null;
if (typeof m.text !== "string") return null;
const out = { sender: m.sender, text: m.text };
if (typeof m.id === "string") out.id = m.id;
if (m.sender === "bot") {
if (Array.isArray(m.sources)) out.sources = m.sources;
if (typeof m.grounded === "boolean") out.grounded = m.grounded;
if (typeof m.timeout === "boolean") out.timeout = m.timeout;
if (typeof m.error === "boolean") out.error = m.error;
}
return out;
}
/** @returns {object[]} */
export function loadGrasbotChatMessages() {
if (typeof window === "undefined") return [];
try {
const uid = getGrasbotUserId();
if (!uid) return [];
const raw = window.localStorage.getItem(storageKey(uid));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.map(sanitizeMessage).filter(Boolean);
} catch {
return [];
}
}
/** @param {object[]} messages */
export function saveGrasbotChatMessages(messages) {
if (typeof window === "undefined") return;
try {
const uid = getGrasbotUserId();
if (!uid) return;
const trimmed = messages.slice(-GRASBOT_CHAT_MAX_MESSAGES);
const serialized = trimmed.map(sanitizeMessage).filter(Boolean);
window.localStorage.setItem(storageKey(uid), JSON.stringify(serialized));
} catch {
// quota / mode privé strict
}
}
export function clearGrasbotChatMessages() {
if (typeof window === "undefined") return;
try {
const uid = getGrasbotUserId();
if (!uid) return;
window.localStorage.removeItem(storageKey(uid));
} catch {
/* ignore */
}
}
export function newChatMessageId() {
return safeRandomId();
}