É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
579 lines
16 KiB
Vue
579 lines
16 KiB
Vue
<template>
|
|
<div class="chatbot-pensees" :class="{ inline: inline }">
|
|
<!-- Header compact -->
|
|
<div class="chatbot-header">
|
|
<div class="chatbot-header-left">
|
|
<div class="chatbot-icon">
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
</svg>
|
|
</div>
|
|
<span class="chatbot-title">
|
|
<template v-if="auteurContext">{{ auteurContext }}</template>
|
|
<template v-else>Bibliothèque des pensées</template>
|
|
</span>
|
|
<span v-if="auteurContext" class="chatbot-corpus-badge">auteur</span>
|
|
<span v-else class="chatbot-corpus-badge">FRACAS</span>
|
|
</div>
|
|
<div class="chatbot-header-right">
|
|
<select v-model="corpusMode" class="chatbot-corpus-select" title="Corpus interrogé">
|
|
<option value="pensees">Pensées éco.</option>
|
|
<option value="both">Pensées + Projets</option>
|
|
<option value="projets">Projets</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Zone messages -->
|
|
<div ref="messagesContainer" class="chatbot-messages">
|
|
<!-- Onboarding -->
|
|
<div v-if="messages.length === 0" class="chatbot-onboarding">
|
|
<template v-if="auteurContext">
|
|
<p>Interroge le corpus de <strong>{{ auteurContext }}</strong> — ses livres, ses thèses, ses arguments.</p>
|
|
<p class="chatbot-onboarding-hint">Ex : "Quelle est sa critique du capitalisme vert ?"</p>
|
|
</template>
|
|
<template v-else>
|
|
<p>Bibliothèque des pensées écologiques — <strong>~140 auteurs FRACAS</strong>, de l'écosocialisme à l'éthique environnementale.</p>
|
|
<p class="chatbot-onboarding-hint">Ex : "Quelles différences entre décroissance et doughnut économics ?"</p>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Messages -->
|
|
<template v-for="(msg, i) in messages" :key="i">
|
|
<div v-if="msg.role === 'user'" class="chatbot-bubble user">{{ msg.content }}</div>
|
|
<div v-else class="chatbot-bubble assistant">
|
|
<ChatModeBadge :profondeur="msg.profondeur" />
|
|
<div class="chatbot-md" v-html="renderMd(msg.content)" />
|
|
<div v-if="msg.auteur" class="chatbot-auteur-tag">
|
|
<span>{{ msg.auteur.nom }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Chargement : texte d'attente progressif. L'annonce aux lecteurs d'écran passe par la région
|
|
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="attente-texte">{{ attenteTexte }}</span>
|
|
</div>
|
|
<div class="sr-only" role="status" aria-live="polite" aria-atomic="true">{{ loading ? attenteTexte : '' }}</div>
|
|
|
|
<!-- Erreur -->
|
|
<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" />
|
|
</div>
|
|
|
|
<!-- Mode de réponse -->
|
|
<div class="chatbot-mode-row">
|
|
<ChatModeToggle v-model="profondeur" :disabled="loading" />
|
|
</div>
|
|
|
|
<!-- Input -->
|
|
<div class="chatbot-input-row">
|
|
<input
|
|
v-model="inputText"
|
|
type="text"
|
|
:disabled="loading"
|
|
:placeholder="auteurContext ? `Interroger ${auteurContext}…` : 'Pose ta question sur les pensées éco…'"
|
|
class="chatbot-input"
|
|
@keydown.enter.prevent="sendMessage"
|
|
/>
|
|
<button
|
|
:disabled="loading || !inputText.trim()"
|
|
class="chatbot-send-btn"
|
|
aria-label="Envoyer"
|
|
@click="sendMessage"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<line x1="22" y1="2" x2="11" y2="13"/>
|
|
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useMarkdown } from '~/composables/useMarkdown'
|
|
|
|
const { render: renderMd } = useMarkdown()
|
|
|
|
interface ChatMessage {
|
|
role: 'user' | 'assistant'
|
|
content: string
|
|
auteur?: { slug: string; nom: string } | null
|
|
profondeur?: string
|
|
}
|
|
|
|
const props = defineProps<{
|
|
auteurContext: string | null
|
|
inline?: boolean
|
|
}>()
|
|
|
|
// Une conversation par contexte (corpus général ou un auteur) : changer d'auteur
|
|
// ne mélange pas les historiques, et un rechargement restaure le bon.
|
|
const sessionKey = computed(() => props.auteurContext ? `pensees-${slugify(props.auteurContext)}` : 'pensees')
|
|
const { messages, hasHistory, clear } = useChatSession<ChatMessage>(sessionKey)
|
|
// `profondeur` (et non `mode`, réservé au mode de recherche LightRAG côté serveur)
|
|
const profondeur = useChatProfondeur('pensees')
|
|
const inputText = ref('')
|
|
const loading = ref(false)
|
|
const errorMsg = ref('')
|
|
const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees')
|
|
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, () => {
|
|
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() {
|
|
if (messagesContainer.value) {
|
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
|
}
|
|
}
|
|
|
|
async function sendMessage() {
|
|
const query = inputText.value.trim()
|
|
if (!query || loading.value) return
|
|
|
|
inputText.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
|
|
demarrerAttente()
|
|
|
|
await nextTick()
|
|
scrollToBottom()
|
|
|
|
try {
|
|
const res = await $fetch<{
|
|
response: string
|
|
auteur: { slug: string; nom: string } | null
|
|
}>('/api/chatbot-pensees', {
|
|
method: 'POST',
|
|
timeout: TIMEOUT_FRONT_MS,
|
|
body: {
|
|
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: envoi.profondeur,
|
|
})
|
|
dernierEnvoi.value = null
|
|
} catch (e: any) {
|
|
const { texte, reessayer: ok } = messageErreur(e)
|
|
errorMsg.value = texte
|
|
peutReessayer.value = ok
|
|
} finally {
|
|
arreterAttente()
|
|
loading.value = false
|
|
await nextTick()
|
|
scrollToBottom()
|
|
}
|
|
}
|
|
|
|
function slugify(str: string): string {
|
|
return str.toLowerCase()
|
|
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.chatbot-pensees {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
min-height: 0;
|
|
background: var(--nav-bg);
|
|
}
|
|
|
|
/* Header */
|
|
.chatbot-header {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
padding: 6px 12px;
|
|
background: var(--nav-surface);
|
|
border-bottom: 1px solid rgba(180, 170, 160, 0.22);
|
|
min-height: 36px;
|
|
}
|
|
|
|
.chatbot-header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.chatbot-icon {
|
|
flex-shrink: 0;
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 50%;
|
|
background: var(--nav-primary);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--nav-text-on-primary);
|
|
}
|
|
|
|
.chatbot-title {
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
color: var(--nav-text);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 180px;
|
|
}
|
|
|
|
.chatbot-corpus-badge {
|
|
flex-shrink: 0;
|
|
font-size: 0.65rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--nav-text-muted);
|
|
background: var(--nav-bg-alt);
|
|
border-radius: 4px;
|
|
padding: 1px 5px;
|
|
}
|
|
|
|
.chatbot-header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.chatbot-clear-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 4px;
|
|
background: transparent;
|
|
color: var(--nav-text-muted);
|
|
border: none;
|
|
cursor: pointer;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
|
|
.chatbot-clear-btn:hover {
|
|
background: var(--nav-bg-alt);
|
|
color: var(--nav-text);
|
|
}
|
|
|
|
.chatbot-corpus-select {
|
|
font-size: 0.7rem;
|
|
padding: 2px 4px;
|
|
border-radius: 4px;
|
|
border: 1px solid rgba(180, 170, 160, 0.3);
|
|
background: var(--nav-bg-alt);
|
|
color: var(--nav-text-muted);
|
|
cursor: pointer;
|
|
max-width: 110px;
|
|
}
|
|
|
|
/* Messages */
|
|
.chatbot-messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 10px 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
min-height: 0;
|
|
}
|
|
|
|
.chatbot-onboarding {
|
|
font-size: 0.8rem;
|
|
line-height: 1.55;
|
|
color: var(--nav-text-muted);
|
|
background: var(--nav-bg-alt);
|
|
border-radius: 8px;
|
|
padding: 10px 12px;
|
|
}
|
|
|
|
.chatbot-onboarding p { margin: 0 0 6px; }
|
|
.chatbot-onboarding p:last-child { margin-bottom: 0; }
|
|
.chatbot-onboarding strong { color: var(--nav-text); font-weight: 600; }
|
|
.chatbot-onboarding-hint { font-style: italic; font-size: 0.75rem; opacity: 0.75; }
|
|
|
|
/* Bulles */
|
|
.chatbot-bubble {
|
|
max-width: 88%;
|
|
font-size: 0.825rem;
|
|
line-height: 1.55;
|
|
border-radius: 10px;
|
|
padding: 8px 11px;
|
|
}
|
|
|
|
.chatbot-bubble.user {
|
|
align-self: flex-end;
|
|
background: var(--nav-primary);
|
|
color: var(--nav-text-on-primary);
|
|
border-radius: 10px 10px 3px 10px;
|
|
}
|
|
|
|
.chatbot-bubble.assistant {
|
|
align-self: flex-start;
|
|
background: var(--nav-surface);
|
|
border: 1px solid rgba(180, 170, 160, 0.22);
|
|
color: var(--nav-text);
|
|
border-radius: 10px 10px 10px 3px;
|
|
}
|
|
|
|
/* Markdown dans les bulles */
|
|
:deep(.chatbot-md) { font-size: inherit; line-height: 1.55; }
|
|
:deep(.chatbot-md p) { margin: 0 0 0.35em; }
|
|
:deep(.chatbot-md p:last-child) { margin-bottom: 0; }
|
|
:deep(.chatbot-md strong) { font-weight: 700; }
|
|
:deep(.chatbot-md em) { font-style: italic; }
|
|
:deep(.chatbot-md ul) { margin: 0.25em 0 0.25em 1em; list-style: disc; padding: 0; }
|
|
:deep(.chatbot-md li) { margin-bottom: 0.1em; }
|
|
|
|
.chatbot-auteur-tag {
|
|
margin-top: 6px;
|
|
font-size: 0.7rem;
|
|
color: var(--nav-text-muted);
|
|
font-style: italic;
|
|
}
|
|
|
|
/* Loading */
|
|
.chatbot-bubble.loading {
|
|
display: flex;
|
|
gap: 4px;
|
|
align-items: center;
|
|
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;
|
|
border-radius: 50%;
|
|
background: var(--nav-text-muted);
|
|
animation: blink 1.2s infinite ease-in-out;
|
|
}
|
|
|
|
.dot:nth-child(2) { animation-delay: 0.2s; }
|
|
.dot:nth-child(3) { animation-delay: 0.4s; }
|
|
|
|
@keyframes blink {
|
|
0%, 80%, 100% { opacity: 0.25; }
|
|
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;
|
|
font-size: 0.775rem;
|
|
color: #a85d3e;
|
|
background: rgba(168, 93, 62, 0.08);
|
|
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;
|
|
display: flex;
|
|
padding: 6px 10px 0;
|
|
border-top: 1px solid rgba(180, 170, 160, 0.22);
|
|
background: var(--nav-surface);
|
|
}
|
|
.chatbot-mode-row + .chatbot-input-row { border-top: none; }
|
|
|
|
/* Input */
|
|
.chatbot-input-row {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 6px 10px;
|
|
border-top: 1px solid rgba(180, 170, 160, 0.22);
|
|
background: var(--nav-surface);
|
|
}
|
|
|
|
.chatbot-input {
|
|
flex: 1;
|
|
padding: 6px 10px;
|
|
border-radius: 8px;
|
|
border: 1px solid rgba(180, 170, 160, 0.3);
|
|
background: var(--nav-bg);
|
|
color: var(--nav-text);
|
|
font-family: var(--nav-font);
|
|
font-size: 0.8rem;
|
|
min-width: 0;
|
|
transition: border-color 0.15s;
|
|
}
|
|
|
|
.chatbot-input:focus {
|
|
outline: none;
|
|
border-color: var(--nav-primary);
|
|
}
|
|
|
|
.chatbot-input:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.chatbot-send-btn {
|
|
flex-shrink: 0;
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: 7px;
|
|
border: none;
|
|
background: var(--nav-primary);
|
|
color: var(--nav-text-on-primary);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.chatbot-send-btn:disabled {
|
|
opacity: 0.35;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.chatbot-send-btn:not(:disabled):hover {
|
|
opacity: 0.85;
|
|
}
|
|
</style>
|