Files
astro-site-cerveau/src/components/vue/ChatbotV2.vue
Jules Neny be7fc09085 feat: PC7 chatbot V1 onglet centre HAUT + endpoint Astro proxy SSR
- ChatbotV2.vue : Vue island, thread chat (input + messages bot/user),
  persistance sessionStorage, bandeau beta '120 fiches AEP, RAG-PE bientot',
  gestion erreurs 429/502/504 ; pas de streaming ni markdown V1
- /api/chatbot.ts : endpoint Astro server proxy POST vers CHATBOT_UPSTREAM
  (default https://aep.trans-former.fr/api/chatbot), timeout 25s,
  body { question, history } -> upstream classique chatbot AEP Mistral Small
- astro.config.mjs : output 'server' + adapter @astrojs/node standalone
  (Astro 6 a supprime mode hybrid ; on opt-in prerender sur les pages)
- Toutes les pages publiques (index, manifeste, manifeste/commander,
  a-propos, mentions-legales) ont 'export const prerender = true'
- ColCentre.astro : remplace ChatbotPlaceholder par ChatbotV2 dans le tab
- .env.example : ajoute CHATBOT_UPSTREAM (V1.5 = switch LightRAG-PE 1 ligne)

Decision V1 : endpoint AEP /api/chatbot (classique, repond bien) au lieu
de /api/chatbot-v2 qui retourne v2_ready=false ('base vectorielle en cours').
Bandeau beta reste valide ; switch v2 quand ready cote AEP via env var.

Note PC8 deploy : Coolify doit booter avec 'node ./dist/server/entry.mjs'
(SSR Node standalone) au lieu de servir dist/client/ static.

Test end-to-end OK : SSR boot port 4399 + curl POST /api/chatbot ->
reponse_texte 800+ chars de l'AEP backend.
2026-05-09 01:22:01 +02:00

188 lines
4.9 KiB
Vue

<script setup lang="ts">
// PC7 — Chatbot V1 brancha sur endpoint AEP (proxy Astro server-side).
// Persistance thread sessionStorage. Bandeau beta info ; pas de streaming, pas de markdown V1.
import { ref, onMounted, nextTick, useTemplateRef } from 'vue'
interface Msg {
role: 'user' | 'bot'
content: string
ts: number
}
const props = defineProps<{ apiUrl?: string }>()
const messages = ref<Msg[]>([])
const input = ref('')
const sending = ref(false)
const errorBanner = ref('')
const threadEl = useTemplateRef<HTMLElement>('threadEl')
const STORAGE_KEY = 'tf-chatbot-v2-thread'
const ENDPOINT = props.apiUrl || '/api/chatbot'
onMounted(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY)
if (saved) messages.value = JSON.parse(saved)
} catch {
// sessionStorage indisponible ; on ignore.
}
})
const persist = () => {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages.value))
} catch {
// ignore
}
}
const scrollToBottom = async () => {
await nextTick()
const el = threadEl.value
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
}
const send = async () => {
const q = input.value.trim()
if (!q || sending.value) return
messages.value.push({ role: 'user', content: q, ts: Date.now() })
input.value = ''
sending.value = true
errorBanner.value = ''
persist()
scrollToBottom()
try {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
question: q,
history: messages.value.slice(-10).map((m) => ({ role: m.role, content: m.content })),
}),
})
if (!res.ok) {
if (res.status === 429) {
errorBanner.value = 'Limite de questions atteinte ; ressaie plus tard.'
} else if (res.status === 502 || res.status === 504) {
errorBanner.value = "Le service de chat n'est pas joignable ; ressaie dans un instant."
} else {
errorBanner.value = `Erreur ${res.status} ; ressaie ou recharge la page.`
}
sending.value = false
return
}
const data = await res.json()
const answer =
data.reponse_texte ||
data.answer ||
data.text ||
data.message ||
'Pas de reponse.'
messages.value.push({ role: 'bot', content: answer, ts: Date.now() })
} catch (e) {
errorBanner.value = "Probleme reseau ; verifie ta connexion."
messages.value.push({
role: 'bot',
content: `Erreur : ${(e as Error).message || 'inconnue'}`,
ts: Date.now(),
})
} finally {
sending.value = false
persist()
scrollToBottom()
}
}
const reset = () => {
messages.value = []
errorBanner.value = ''
persist()
}
</script>
<template>
<div class="h-full flex flex-col bg-white">
<div class="bg-amber-50 border-b border-amber-200 px-3 py-2 text-xs text-amber-900 leading-snug">
<span class="font-medium">Beta :</span> 120 fiches AEP indexees (Mistral Small) ; bientot RAG-PE multi-corpus.
</div>
<div
v-if="errorBanner"
class="bg-rose-50 border-b border-rose-200 px-3 py-2 text-xs text-rose-800"
role="alert"
>
{{ errorBanner }}
</div>
<div
ref="threadEl"
class="flex-1 overflow-y-auto px-3 py-3 space-y-3"
role="log"
aria-live="polite"
>
<p
v-if="!messages.length"
class="text-neutral-400 text-sm italic text-center mt-8 px-4"
>
Pose une question sur l'AEP ; le manifeste ; les agences locales...
</p>
<div
v-for="m in messages"
:key="m.ts"
:class="[
'max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap break-words',
m.role === 'user'
? 'bg-neutral-900 text-white ml-auto'
: 'bg-neutral-100 text-neutral-800 mr-auto',
]"
>
{{ m.content }}
</div>
<div
v-if="sending"
class="bg-neutral-100 max-w-[60%] rounded-lg px-3 py-2 text-sm text-neutral-500 italic mr-auto"
>
Le bot reflechit...
</div>
</div>
<form
class="border-t border-neutral-200 p-2 flex gap-2 items-stretch"
@submit.prevent="send"
>
<input
v-model="input"
type="text"
placeholder="Ta question..."
:disabled="sending"
class="flex-1 px-3 py-2 border border-neutral-300 rounded-lg text-sm focus:outline-none focus:border-neutral-900 disabled:opacity-60"
aria-label="Saisis ta question"
/>
<button
type="submit"
:disabled="sending || !input.trim()"
class="px-3 py-2 bg-neutral-900 text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
Envoyer
</button>
<button
v-if="messages.length"
type="button"
class="px-2 py-2 text-neutral-500 text-xs hover:text-neutral-900 underline"
@click="reset"
>
Reset
</button>
</form>
</div>
</template>