feat(chatbot-pensees): attente progressive, timeout front 95 s et bouton Réessayer

É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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD
This commit is contained in:
Jules Neny
2026-10-09 04:02:29 +02:00
co-authored by Claude Opus 5.5
parent 85cd31d201
commit fa828e3231
+134 -20
View File
@@ -50,13 +50,19 @@
</div> </div>
</template> </template>
<!-- Chargement --> <!-- Chargement : texte d'attente progressif. L'annonce aux lecteurs d'écran passe par la région
<div v-if="loading" class="chatbot-bubble assistant loading"> live persistante ci-dessous (une région insérée à chaud n'est pas fiablement annoncée). -->
<div v-if="loading" class="chatbot-bubble assistant loading" aria-hidden="true">
<span class="dot" /><span class="dot" /><span class="dot" /> <span class="dot" /><span class="dot" /><span class="dot" />
<span class="attente-texte">{{ attenteTexte }}</span>
</div> </div>
<div class="sr-only" role="status" aria-live="polite" aria-atomic="true">{{ loading ? attenteTexte : '' }}</div>
<!-- Erreur --> <!-- Erreur -->
<div v-if="errorMsg" class="chatbot-error">{{ errorMsg }}</div> <div v-if="errorMsg" class="chatbot-error" role="alert">
<span>{{ errorMsg }}</span>
<button v-if="peutReessayer" type="button" class="chatbot-retry-btn" @click="reessayer">Réessayer</button>
</div>
<ChatNewConversation v-if="hasHistory && !loading" @reset="clearMessages" /> <ChatNewConversation v-if="hasHistory && !loading" @reset="clearMessages" />
</div> </div>
@@ -120,13 +126,66 @@ const errorMsg = ref('')
const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees') const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees')
const messagesContainer = ref<HTMLElement | null>(null) const messagesContainer = ref<HTMLElement | null>(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<typeof setInterval> | 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<Envoi | null>(null)
const peutReessayer = ref(false)
watch(sessionKey, () => { watch(sessionKey, () => {
errorMsg.value = '' errorMsg.value = ''
peutReessayer.value = false
dernierEnvoi.value = null
}) })
function clearMessages() { function clearMessages() {
clear() clear()
errorMsg.value = '' 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() { function scrollToBottom() {
@@ -140,10 +199,27 @@ async function sendMessage() {
if (!query || loading.value) return if (!query || loading.value) return
inputText.value = '' inputText.value = ''
errorMsg.value = ''
messages.value.push({ role: 'user', content: query }) 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 loading.value = true
const mode = profondeur.value demarrerAttente()
await nextTick() await nextTick()
scrollToBottom() scrollToBottom()
@@ -154,31 +230,27 @@ async function sendMessage() {
auteur: { slug: string; nom: string } | null auteur: { slug: string; nom: string } | null
}>('/api/chatbot-pensees', { }>('/api/chatbot-pensees', {
method: 'POST', method: 'POST',
timeout: TIMEOUT_FRONT_MS,
body: { body: {
query, query: envoi.query,
corpus: corpusMode.value, corpus: envoi.corpus,
profondeur: mode, profondeur: envoi.profondeur,
auteur_slug: props.auteurContext ? slugify(props.auteurContext) : undefined, auteur_slug: envoi.auteurSlug,
}, },
}) })
messages.value.push({ messages.value.push({
role: 'assistant', role: 'assistant',
content: res.response, content: res.response,
auteur: res.auteur ?? null, auteur: res.auteur ?? null,
profondeur: mode, profondeur: envoi.profondeur,
}) })
dernierEnvoi.value = null
} catch (e: any) { } catch (e: any) {
const status = e?.statusCode ?? e?.status const { texte, reessayer: ok } = messageErreur(e)
if (status === 429) { errorMsg.value = texte
errorMsg.value = 'Limite de 20 questions par jour atteinte.' peutReessayer.value = ok
} 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.'
}
} finally { } finally {
arreterAttente()
loading.value = false loading.value = false
await nextTick() await nextTick()
scrollToBottom() scrollToBottom()
@@ -367,6 +439,25 @@ function slugify(str: string): string {
padding: 10px 12px; 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 { .dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
@@ -383,6 +474,11 @@ function slugify(str: string): string {
40% { opacity: 1; } 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 */ /* Erreur */
.chatbot-error { .chatbot-error {
align-self: flex-start; align-self: flex-start;
@@ -392,8 +488,26 @@ function slugify(str: string): string {
border-radius: 8px; border-radius: 8px;
padding: 7px 10px; padding: 7px 10px;
max-width: 90%; 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 */ /* Mode de réponse */
.chatbot-mode-row { .chatbot-mode-row {
flex-shrink: 0; flex-shrink: 0;