From fa828e3231ab571ac0d0031b58a5d0e0130f002f Mon Sep 17 00:00:00 2001 From: Jules Neny Date: Fri, 9 Oct 2026 04:02:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(chatbot-pensees):=20attente=20progressive,?= =?UTF-8?q?=20timeout=20front=2095=20s=20et=20bouton=20R=C3=A9essayer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit États « Je fouille le corpus… » (0-6 s), « Je rédige… » (6-30 s), « Le corpus est vaste, encore un instant… » (30 s+), annoncés via une région aria-live persistante ; points fixes si prefers-reduced-motion. Timeout front 95 s (> 90 s serveur), message d'excuse et bouton « Réessayer » qui reposte la même question sans dupliquer le message ; messages humains distincts pour 429 (quota / saturé), 503, 504, réseau. États d'attente et erreurs non persistés (M2 inchangé), contrat de la route inchangé. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD --- components/ChatbotPensees.vue | 154 +++++++++++++++++++++++++++++----- 1 file changed, 134 insertions(+), 20 deletions(-) diff --git a/components/ChatbotPensees.vue b/components/ChatbotPensees.vue index 737c9f9..c5cd8b8 100644 --- a/components/ChatbotPensees.vue +++ b/components/ChatbotPensees.vue @@ -50,13 +50,19 @@ - -
+ + +
{{ loading ? attenteTexte : '' }}
-
{{ errorMsg }}
+
@@ -120,13 +126,66 @@ const errorMsg = ref('') const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees') const messagesContainer = ref(null) +// --- Attente et erreurs : état d'interface uniquement, jamais persisté (hors useChatSession) --- +// Mesuré le 09/10 : récupération 2-5 s, génération 10-18 s, 19-29 s bout en bout. Les seuils suivent ces +// phases ; le texte est une minuterie (le serveur renvoie tout d'un bloc : il faut la réponse complète +// pour vérifier les citations, donc pas de flux de texte). +const SEUIL_REDIGE_S = 6 +const SEUIL_LONG_S = 30 +// Au-delà du timeout serveur (90 s) : le front ne lâche jamais avant la route, et ne tourne jamais sans fin. +const TIMEOUT_FRONT_MS = 95000 + +const attenteS = ref(0) +const attenteTexte = computed(() => { + if (attenteS.value >= SEUIL_LONG_S) return 'Le corpus est vaste, encore un instant…' + if (attenteS.value >= SEUIL_REDIGE_S) return 'Je rédige…' + return 'Je fouille le corpus…' +}) +let attenteTimer: ReturnType | null = null + +function demarrerAttente() { + arreterAttente() + attenteS.value = 0 + const t0 = Date.now() + attenteTimer = setInterval(() => { attenteS.value = Math.floor((Date.now() - t0) / 1000) }, 1000) +} +function arreterAttente() { + if (attenteTimer) { clearInterval(attenteTimer); attenteTimer = null } +} +onBeforeUnmount(arreterAttente) + +interface Envoi { query: string; profondeur: string; corpus: string; auteurSlug?: string } +const dernierEnvoi = ref(null) +const peutReessayer = ref(false) + watch(sessionKey, () => { errorMsg.value = '' + peutReessayer.value = false + dernierEnvoi.value = null }) function clearMessages() { clear() errorMsg.value = '' + peutReessayer.value = false + dernierEnvoi.value = null +} + +function messageErreur(e: any): { texte: string; reessayer: boolean } { + const status = e?.statusCode ?? e?.status ?? e?.response?.status + const detail = String(e?.data?.message ?? e?.statusMessage ?? e?.message ?? '') + if (status === 429) { + // 429 de la route : quota du jour (pas de réessai utile) ou RAG saturé (réessai possible) + if (/satur/i.test(detail)) return { texte: 'Le corpus est très sollicité en ce moment. Patiente quelques secondes, puis réessaie.', reessayer: true } + return { texte: 'Tu as atteint la limite de 20 questions par jour. Reviens demain, ou explore les fiches auteurs en attendant.', reessayer: false } + } + if (status === 503) return { texte: 'La bibliothèque est indisponible pour l\'instant. Réessaie dans quelques minutes.', reessayer: true } + if (status === 504) return { texte: 'La bibliothèque met trop de temps à répondre : la question est peut-être trop large. Réessaie, ou reformule-la plus précisément.', reessayer: true } + if (!status && /timeout|timed out|abort/i.test(`${detail} ${e?.name ?? ''} ${e?.cause?.name ?? ''}`)) { + return { texte: 'Désolé, la réponse met plus de temps que prévu et n\'a pas abouti. Tu peux relancer la même question.', reessayer: true } + } + if (!status) return { texte: 'La connexion semble coupée : la question n\'est pas partie. Vérifie ton réseau puis réessaie.', reessayer: true } + return { texte: 'Une erreur est survenue. Réessaie dans quelques instants.', reessayer: true } } function scrollToBottom() { @@ -140,10 +199,27 @@ async function sendMessage() { if (!query || loading.value) return inputText.value = '' - errorMsg.value = '' messages.value.push({ role: 'user', content: query }) + await envoyer({ + query, + profondeur: profondeur.value, + corpus: corpusMode.value, + auteurSlug: props.auteurContext ? slugify(props.auteurContext) : undefined, + }) +} + +// Reposte la même question (même mode, même corpus) sans dupliquer le message de l'usager. +async function reessayer() { + if (!dernierEnvoi.value || loading.value) return + await envoyer(dernierEnvoi.value) +} + +async function envoyer(envoi: Envoi) { + errorMsg.value = '' + peutReessayer.value = false + dernierEnvoi.value = envoi loading.value = true - const mode = profondeur.value + demarrerAttente() await nextTick() scrollToBottom() @@ -154,31 +230,27 @@ async function sendMessage() { auteur: { slug: string; nom: string } | null }>('/api/chatbot-pensees', { method: 'POST', + timeout: TIMEOUT_FRONT_MS, body: { - query, - corpus: corpusMode.value, - profondeur: mode, - auteur_slug: props.auteurContext ? slugify(props.auteurContext) : undefined, + query: envoi.query, + corpus: envoi.corpus, + profondeur: envoi.profondeur, + auteur_slug: envoi.auteurSlug, }, }) messages.value.push({ role: 'assistant', content: res.response, auteur: res.auteur ?? null, - profondeur: mode, + profondeur: envoi.profondeur, }) + dernierEnvoi.value = null } catch (e: any) { - const status = e?.statusCode ?? e?.status - if (status === 429) { - errorMsg.value = 'Limite de 20 questions par jour atteinte.' - } else if (status === 503) { - errorMsg.value = 'Le RAG est indisponible pour l\'instant — réessaie dans quelques minutes.' - } else if (status === 504) { - errorMsg.value = 'Le RAG met du temps à répondre — réessaie dans quelques secondes.' - } else { - errorMsg.value = 'Une erreur est survenue. Réessaie dans quelques instants.' - } + const { texte, reessayer: ok } = messageErreur(e) + errorMsg.value = texte + peutReessayer.value = ok } finally { + arreterAttente() loading.value = false await nextTick() scrollToBottom() @@ -367,6 +439,25 @@ function slugify(str: string): string { padding: 10px 12px; } +.attente-texte { + margin-left: 6px; + font-size: 0.775rem; + color: var(--nav-text-muted); + font-style: italic; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .dot { width: 6px; height: 6px; @@ -383,6 +474,11 @@ function slugify(str: string): string { 40% { opacity: 1; } } +/* Pas d'animation si l'usager la réduit : points fixes, le texte d'attente porte l'information */ +@media (prefers-reduced-motion: reduce) { + .dot { animation: none; opacity: 0.6; } +} + /* Erreur */ .chatbot-error { align-self: flex-start; @@ -392,8 +488,26 @@ function slugify(str: string): string { border-radius: 8px; padding: 7px 10px; max-width: 90%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; } +.chatbot-retry-btn { + font: inherit; + font-size: 0.75rem; + font-weight: 600; + color: var(--nav-text-on-primary); + background: var(--nav-primary); + border: none; + border-radius: 6px; + padding: 4px 12px; + cursor: pointer; +} +.chatbot-retry-btn:hover { opacity: 0.85; } +.chatbot-retry-btn:focus-visible { outline: 2px solid var(--nav-primary); outline-offset: 2px; } + /* Mode de réponse */ .chatbot-mode-row { flex-shrink: 0;