Files
nav-carte/components/ChatbotPensees.vue

1 line
22 KiB
Vue

<template> <!-- Mode overlay : bouton flottant bottom-right (legacy) --> <template v-if="!inline"> <button v-if="!open" @click="open = true" class="fixed bottom-6 right-6 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg" style="height:48px;background:var(--nav-primary);color:var(--nav-text-on-primary);font-size:0.875rem;font-weight:600;" aria-label="Chatbot Pensees Ecologiques"> <svg width="16" height="16" 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> <span>Pensees ?</span> </button> <Transition name="cpanel"> <div v-if="open" class="fixed bottom-6 right-6 z-[1000] flex flex-col" style="width:min(360px,calc(100vw - 24px));max-height:60vh;background:var(--nav-surface);border-radius:14px;box-shadow:0 8px 32px rgba(26,34,56,0.22);overflow:hidden;border:1px solid var(--nav-bg-alt);" role="dialog" aria-modal="true" aria-label="RAG Pensees Ecologiques"> <!-- Header overlay --> <div class="flex items-center justify-between px-4 py-3 shrink-0" style="border-bottom:1px solid var(--nav-bg-alt);background:var(--nav-bg);"> <div> <p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p> </div> <button @click="open = false" class="flex items-center justify-center w-7 h-7 rounded-full hover:opacity-70" style="background:var(--nav-bg-alt);color:var(--nav-text-muted);" aria-label="Fermer"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"> <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> </svg> </button> </div> <!-- Corpus toggle overlay --> <div class="shrink-0 px-3 pt-2 pb-1" style="background:var(--nav-bg);border-bottom:1px solid var(--nav-bg-alt);"> <div class="flex gap-1" role="group" aria-label="Choisir le corpus"> <button v-for="opt in corpusOptions" :key="opt.value" @click="setCorpus(opt.value)" :title="opt.tooltip" class="flex-1 px-2 py-1 rounded text-xs font-medium transition-colors" :style="corpus === opt.value ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'background:var(--nav-bg-alt);color:var(--nav-text-muted);'" :aria-pressed="corpus === opt.value">{{ opt.label }}</button> </div> </div> <!-- Messages overlay --> <div ref="msgElOverlay" class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3" style="min-height:0;"> <div v-if="messages.length === 0" style="font-size:0.8rem;color:var(--nav-text-muted);line-height:1.5;"> <template v-if="corpus === 'pensees'">Pose une question sur les pensees ecologiques...</template> <template v-else-if="corpus === 'projets'">Pose une question sur les projets d'architecture de Jules...</template> <template v-else>Pose une question sur les pensees ecologiques ancrees dans les projets archi de Jules.</template> </div> <template v-for="(msg, i) in messages" :key="i"> <div v-if="msg.role === 'user'" class="self-end max-w-[85%] px-3 py-2 rounded-xl text-sm" style="background:var(--nav-primary);color:var(--nav-text-on-primary);font-weight:500;">{{ msg.content }}</div> <div v-else class="self-start max-w-full"> <div class="px-3 py-2 rounded-xl text-sm leading-relaxed" style="background:var(--nav-bg-alt);color:var(--nav-text);" v-html="renderMd(stripSrc(msg.content))" /> <div v-if="filteredSources(msg.content).length" class="mt-1.5"> <button @click="toggled[i] = !toggled[i]" class="flex items-center gap-1 text-xs hover:opacity-70" style="color:var(--nav-text-muted);"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" :style="`transform:rotate(${toggled[i] ? 90 : 0}deg);transition:transform 0.15s`"><polyline points="9 18 15 12 9 6"/></svg> Sources ({{ filteredSources(msg.content).length }}) </button> <div v-if="toggled[i]" class="mt-1 flex flex-col gap-1"> <div v-for="(s, si) in filteredSources(msg.content)" :key="si" class="px-2 py-1 rounded text-xs" style="background:var(--nav-bg-alt);color:var(--nav-text-muted);border-left:2px solid var(--nav-primary-solid);"> <span style="font-weight:600;color:var(--nav-text);">[{{ si + 1 }}]</span> {{ s }} </div> </div> </div> </div> </template> <div v-if="loading" class="self-start px-3 py-2 rounded-xl" style="background:var(--nav-bg-alt);"> <span class="dots"><span/><span style="animation-delay:150ms"/><span style="animation-delay:300ms"/></span> </div> <div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div> </div> <!-- Input overlay --> <div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);"> <div class="flex items-center gap-2" style="position:relative;"> <!-- Hashtag autocomplete dropdown (Slack/Discord pattern, au-dessus de l'input) --> <div v-if="hashtagDropdownOpen && hashtagSuggestions.length" class="hashtag-dropdown" style="position:absolute;bottom:100%;left:0;right:0;margin-bottom:6px;max-height:220px;overflow-y:auto;background:var(--nav-surface);border:1px solid var(--nav-bg-alt);border-radius:8px;box-shadow:0 -4px 12px rgba(0,0,0,0.12);z-index:50;"> <div v-for="(auteur, idx) in hashtagSuggestions" :key="auteur.id" @mousedown.prevent="applyHashtagSuggestion(auteur)" @mouseenter="hashtagSelectedIndex = idx" class="px-3 py-2 cursor-pointer text-sm" :style="idx === hashtagSelectedIndex ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'color:var(--nav-text);'"> <span style="font-weight:600;">#{{ auteur.id }}</span> <span :style="idx === hashtagSelectedIndex ? 'opacity:0.85;margin-left:8px;font-size:0.78rem;' : 'opacity:0.65;margin-left:8px;font-size:0.78rem;color:var(--nav-text-muted);'">{{ auteur.nom }}</span> </div> </div> <input ref="inputElOverlay" v-model="q" type="text" placeholder="Ta question, tape #auteur pour cibler" maxlength="500" class="flex-1 px-3 py-2 rounded-lg text-sm outline-none" style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);" @keydown="onInputKeydown" /> <button @click="send" :disabled="loading || !q.trim()" class="flex items-center justify-center w-9 h-9 rounded-lg" :style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'" aria-label="Envoyer"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:white;"> <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> </div> </Transition> </template> <!-- Mode inline : remplit 100% de son parent slot --> <div v-else class="flex flex-col w-full h-full" style="background:var(--nav-surface);overflow:hidden;" role="region" aria-label="RAG Pensees Ecologiques"> <!-- Header inline --> <div class="flex items-center justify-between px-4 py-2 shrink-0" style="border-bottom:1px solid var(--nav-bg-alt);background:var(--nav-bg);"> <div> <p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p> </div> </div> <!-- Corpus toggle inline --> <div class="shrink-0 px-3 pt-2 pb-1" style="background:var(--nav-bg);border-bottom:1px solid var(--nav-bg-alt);"> <div class="flex gap-1" role="group" aria-label="Choisir le corpus"> <button v-for="opt in corpusOptions" :key="opt.value" @click="setCorpus(opt.value)" :title="opt.tooltip" class="flex-1 px-2 py-1 rounded text-xs font-medium transition-colors" :style="corpus === opt.value ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'background:var(--nav-bg-alt);color:var(--nav-text-muted);'" :aria-pressed="corpus === opt.value">{{ opt.label }}</button> </div> </div> <!-- Messages inline --> <div ref="msgElInline" class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3" style="min-height:0;"> <div v-if="messages.length === 0" style="font-size:0.8rem;color:var(--nav-text-muted);line-height:1.5;"> <template v-if="corpus === 'pensees'">Pose une question sur les pensees ecologiques : ecosocialisme, decroissance, ecofeminismes, technocritique, deep ecology...</template> <template v-else-if="corpus === 'projets'">Pose une question sur les projets d'architecture de Jules : Butte Pinson, strategie thermique, partis pris constructifs...</template> <template v-else>Pose une question sur les pensees ecologiques ancrees dans les projets archi de Jules (corpus croise, defaut).</template> </div> <template v-for="(msg, i) in messages" :key="i"> <div v-if="msg.role === 'user'" class="self-end max-w-[85%] px-3 py-2 rounded-xl text-sm" style="background:var(--nav-primary);color:var(--nav-text-on-primary);font-weight:500;">{{ msg.content }}</div> <div v-else class="self-start max-w-full"> <div class="px-3 py-2 rounded-xl text-sm leading-relaxed" style="background:var(--nav-bg-alt);color:var(--nav-text);" v-html="renderMd(stripSrc(msg.content))" /> <div v-if="filteredSources(msg.content).length" class="mt-1.5"> <button @click="toggled[i] = !toggled[i]" class="flex items-center gap-1 text-xs hover:opacity-70" style="color:var(--nav-text-muted);"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" :style="`transform:rotate(${toggled[i] ? 90 : 0}deg);transition:transform 0.15s`"><polyline points="9 18 15 12 9 6"/></svg> Sources ({{ filteredSources(msg.content).length }}) </button> <div v-if="toggled[i]" class="mt-1 flex flex-col gap-1"> <div v-for="(s, si) in filteredSources(msg.content)" :key="si" class="px-2 py-1 rounded text-xs" style="background:var(--nav-bg-alt);color:var(--nav-text-muted);border-left:2px solid var(--nav-primary-solid);"> <span style="font-weight:600;color:var(--nav-text);">[{{ si + 1 }}]</span> {{ s }} </div> </div> </div> </div> </template> <div v-if="loading" class="self-start px-3 py-2 rounded-xl" style="background:var(--nav-bg-alt);"> <span class="dots"><span/><span style="animation-delay:150ms"/><span style="animation-delay:300ms"/></span> </div> <div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div> </div> <!-- Input inline --> <div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);"> <div class="flex items-center gap-2" style="position:relative;"> <!-- Hashtag autocomplete dropdown (Slack/Discord pattern, au-dessus de l'input) --> <div v-if="hashtagDropdownOpen && hashtagSuggestions.length" class="hashtag-dropdown" style="position:absolute;bottom:100%;left:0;right:0;margin-bottom:6px;max-height:220px;overflow-y:auto;background:var(--nav-surface);border:1px solid var(--nav-bg-alt);border-radius:8px;box-shadow:0 -4px 12px rgba(0,0,0,0.12);z-index:50;"> <div v-for="(auteur, idx) in hashtagSuggestions" :key="auteur.id" @mousedown.prevent="applyHashtagSuggestion(auteur)" @mouseenter="hashtagSelectedIndex = idx" class="px-3 py-2 cursor-pointer text-sm" :style="idx === hashtagSelectedIndex ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'color:var(--nav-text);'"> <span style="font-weight:600;">#{{ auteur.id }}</span> <span :style="idx === hashtagSelectedIndex ? 'opacity:0.85;margin-left:8px;font-size:0.78rem;' : 'opacity:0.65;margin-left:8px;font-size:0.78rem;color:var(--nav-text-muted);'">{{ auteur.nom }}</span> </div> </div> <input ref="inputElInline" v-model="q" type="text" placeholder="Ta question, tape #auteur pour cibler" maxlength="500" class="flex-1 px-3 py-2 rounded-lg text-sm outline-none" style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);" @keydown="onInputKeydown" /> <button @click="send" :disabled="loading || !q.trim()" class="flex items-center justify-center w-9 h-9 rounded-lg" :style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'" aria-label="Envoyer"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:white;"> <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> </div> </template> <script setup lang="ts"> interface Message { role: 'user' | 'assistant'; content: string } interface AuteurMini { id: string; nom: string } type CorpusMode = 'pensees' | 'projets' | 'both' const CORPUS_STORAGE_KEY = 'chatbot-pensees-corpus' const PROJECT_SOURCE_PATTERNS = [/butte.?pinson/i, /butte_pinson/i] function isProjectSource(s: string): boolean { return PROJECT_SOURCE_PATTERNS.some(p => p.test(s)) } const corpusOptions: { value: CorpusMode; label: string; tooltip: string }[] = [ { value: 'pensees', label: 'Pensees', tooltip: 'Corpus FRACAS uniquement (auteurs ecologie politique)' }, { value: 'projets', label: 'Projets', tooltip: 'Projets archi de Jules uniquement' }, { value: 'both', label: 'Croise*', tooltip: 'Projets ancres + pensees en eclairage (defaut)' }, ] const props = defineProps<{ auteurContext?: string | null inline?: boolean }>() const open = ref(false) const q = ref('') const messages = ref<Message[]>([]) const loading = ref(false) const err = ref('') const toggled = ref<Record<number, boolean>>({}) const msgElOverlay = ref<HTMLElement | null>(null) const msgElInline = ref<HTMLElement | null>(null) const inputElOverlay = ref<HTMLInputElement | null>(null) const inputElInline = ref<HTMLInputElement | null>(null) const corpusCount = 18 const corpus = ref<CorpusMode>('pensees') // Phase 8.E : hashtag mentions const auteursIngeres = ref<AuteurMini[]>([]) const hashtagSuggestions = ref<AuteurMini[]>([]) const hashtagDropdownOpen = ref(false) const hashtagSelectedIndex = ref(0) function getActiveInput(): HTMLInputElement | null { return props.inline ? inputElInline.value : inputElOverlay.value } function detectHashtagAtCursor(input: string, cursorPos: number): { start: number; partial: string } | null { const before = input.slice(0, cursorPos) const m = before.match(/#([a-z0-9-]*)$/i) if (!m) return null return { start: m.index ?? 0, partial: (m[1] || '').toLowerCase() } } function updateHashtagSuggestions() { const el = getActiveInput() const cursorPos = el?.selectionStart ?? q.value.length const detection = detectHashtagAtCursor(q.value, cursorPos) // Ouvrir dès que le # est présent (partial vide accepté pour afficher la liste) if (!detection) { hashtagDropdownOpen.value = false return } const partial = detection.partial const list = partial.length === 0 ? auteursIngeres.value.slice(0, 8) : auteursIngeres.value .filter(a => a.id.toLowerCase().includes(partial) || a.nom.toLowerCase().includes(partial)) .slice(0, 8) hashtagSuggestions.value = list hashtagDropdownOpen.value = list.length > 0 hashtagSelectedIndex.value = 0 } function applyHashtagSuggestion(auteur: AuteurMini) { const el = getActiveInput() const cursorPos = el?.selectionStart ?? q.value.length const detection = detectHashtagAtCursor(q.value, cursorPos) if (!detection) return const before = q.value.slice(0, detection.start) const after = q.value.slice(cursorPos) const insert = '#' + auteur.id + ' ' q.value = before + insert + after hashtagDropdownOpen.value = false nextTick(() => { const focusEl = getActiveInput() if (!focusEl) return focusEl.focus() const newPos = before.length + insert.length focusEl.setSelectionRange(newPos, newPos) }) } function onInputKeydown(e: KeyboardEvent) { if (hashtagDropdownOpen.value && hashtagSuggestions.value.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault() hashtagSelectedIndex.value = (hashtagSelectedIndex.value + 1) % hashtagSuggestions.value.length return } if (e.key === 'ArrowUp') { e.preventDefault() hashtagSelectedIndex.value = (hashtagSelectedIndex.value - 1 + hashtagSuggestions.value.length) % hashtagSuggestions.value.length return } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault() applyHashtagSuggestion(hashtagSuggestions.value[hashtagSelectedIndex.value]) return } if (e.key === 'Escape') { e.preventDefault() hashtagDropdownOpen.value = false return } } if (e.key === 'Enter') { e.preventDefault() send() } } watch(q, () => { updateHashtagSuggestions() }) onMounted(async () => { const saved = window.localStorage.getItem(CORPUS_STORAGE_KEY) as CorpusMode | null if (saved && ['pensees', 'projets', 'both'].includes(saved)) { corpus.value = saved } // Chargement liste auteurs ingérés pour autocomplete hashtag try { const data = await $fetch<any>('/data/auteurs-pensees.json') auteursIngeres.value = (data?.auteurs ?? []) .filter((a: any) => a.ingere === true) .map((a: any) => ({ id: String(a.id), nom: String(a.nom) })) } catch (e) { console.error('Erreur chargement auteurs-pensees.json pour hashtag', e) } }) function setCorpus(val: CorpusMode) { corpus.value = val window.localStorage.setItem(CORPUS_STORAGE_KEY, val) } watch(open, (val) => { if (!val) return nextTick(() => inputElOverlay.value?.focus()) if (props.auteurContext && messages.value.length === 0) q.value = `Quelles sont les theses centrales de ${props.auteurContext} ?` }) watch(() => props.auteurContext, (ctx) => { if (!ctx) return if (!props.inline && !open.value) open.value = true if (messages.value.length === 0) q.value = `Quelles sont les theses centrales de ${ctx} ?` }) async function send() { const query = q.value.trim() if (!query || loading.value) return // Extraire le premier hashtag matchant un auteur ingéré let auteurSlug: string | null = null const matches = [...query.matchAll(/#([a-z0-9-]+)/gi)] for (const m of matches) { const slug = m[1].toLowerCase() if (auteursIngeres.value.find(a => a.id === slug)) { auteurSlug = slug break } } // Premier hashtag non-matché (pour info utilisateur si jamais ne match aucun) let auteurSlugUnmatched: string | null = null if (!auteurSlug && matches.length > 0) { auteurSlugUnmatched = matches[0][1].toLowerCase() } err.value = '' messages.value.push({ role: 'user', content: query }) q.value = '' hashtagDropdownOpen.value = false loading.value = true await nextTick() scrollBottom() try { const res = await $fetch<any>('/api/chatbot-pensees', { method: 'POST', body: { query, mode: 'hybrid', corpus: corpus.value, auteur_slug: auteurSlug ?? auteurSlugUnmatched, }, }) let responseText = res.response ?? '' if (res.auteur_unmatched) { responseText = `*(Aucun livre de #${res.auteur_unmatched} n'est ingéré dans le RAG. Je réponds depuis la carte entière.)*\n\n` + responseText } messages.value.push({ role: 'assistant', content: responseText }) } catch (e: any) { const s = e?.response?.status ?? e?.statusCode err.value = s === 429 ? 'Limite atteinte.' : s === 503 ? 'RAG indisponible.' : 'Erreur, reessaie.' } finally { loading.value = false await nextTick() scrollBottom() } } function scrollBottom() { const el = props.inline ? msgElInline.value : msgElOverlay.value if (el) el.scrollTop = el.scrollHeight } function renderMd(t: string) { return '<p>' + t.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>').replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>') + '</p>' } function stripSrc(t: string) { return t.replace(/\n*(?:Sources?|References?)\s*:[\s\S]*$/i, '').trim() } function parseSrc(t: string): string[] { const bloc = t.match(/\n*(?:Sources?|References?)\s*:\n?([\s\S]+?)$/i) if (bloc) return bloc[1].split('\n').map(l => l.replace(/^[-*\d.[\]]+\s*/, '').trim()).filter(l => l.length > 3) return [...new Set([...t.matchAll(/\[([^\]]{5,80})\]/g)].filter(m => m[1].includes(' - ')).map(m => m[1]))] } function filteredSources(t: string): string[] { const all = parseSrc(t) if (corpus.value === 'both') return all if (corpus.value === 'projets') return all.filter(s => isProjectSource(s)) return all.filter(s => !isProjectSource(s)) } </script> <style scoped> .cpanel-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); } .cpanel-leave-active { transition: opacity 0.18s, transform 0.15s ease-in; } .cpanel-enter-from { opacity: 0; transform: translateY(12px) scale(0.95); } .cpanel-leave-to { opacity: 0; transform: translateY(8px) scale(0.97); } .dots span { display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--nav-text-muted);margin:0 2px;animation:bounce 1s infinite; } @keyframes bounce { 0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-5px)} } </style>