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 @@
-
-
+
+
+ {{ attenteTexte }}
+
{{ loading ? attenteTexte : '' }}
-
{{ errorMsg }}
+
+ {{ 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;