feat(chatbot): add corpus toggle UI (pensees/projets/both) + refs filtering
- 3 toggle buttons in chatbot header, default Croise
- Pass corpus param to /api/chatbot-pensees
- Filter references UI side based on corpus (no FRACAS leak in projets mode)
- localStorage persistence with key chatbot-pensees-corpus
V2 Phase 2.2 -- frontend toggle, paired with B.1 backend (commit 8d673482)
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
<button v-if="!open" @click="open = true"
|
<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"
|
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;"
|
style="height:48px;background:var(--nav-primary);color:var(--nav-text-on-primary);font-size:0.875rem;font-weight:600;"
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
<div v-if="open" class="fixed bottom-6 right-6 z-[1000] flex flex-col"
|
<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);"
|
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">
|
role="dialog" aria-modal="true" aria-label="RAG Pensees Ecologiques">
|
||||||
|
<!-- Header -->
|
||||||
<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 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>
|
<div>
|
||||||
<p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p>
|
<p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p>
|
||||||
@@ -25,9 +26,31 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Corpus toggle -->
|
||||||
|
<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 -->
|
||||||
<div ref="msgEl" class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3" style="min-height:0;">
|
<div ref="msgEl" 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;">
|
<div v-if="messages.length === 0" style="font-size:0.8rem;color:var(--nav-text-muted);line-height:1.5;">
|
||||||
Pose une question sur les pensees ecologiques : ecosocialisme, decroissance, ecofeminismes, technocritique, deep ecology...
|
<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>
|
</div>
|
||||||
<template v-for="(msg, i) in messages" :key="i">
|
<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"
|
<div v-if="msg.role === 'user'" class="self-end max-w-[85%] px-3 py-2 rounded-xl text-sm"
|
||||||
@@ -35,14 +58,14 @@
|
|||||||
<div v-else class="self-start max-w-full">
|
<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);"
|
<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))" />
|
v-html="renderMd(stripSrc(msg.content))" />
|
||||||
<div v-if="parseSrc(msg.content).length" class="mt-1.5">
|
<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);">
|
<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"
|
<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>
|
:style="`transform:rotate(${toggled[i] ? 90 : 0}deg);transition:transform 0.15s`"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
Sources ({{ parseSrc(msg.content).length }})
|
Sources ({{ filteredSources(msg.content).length }})
|
||||||
</button>
|
</button>
|
||||||
<div v-if="toggled[i]" class="mt-1 flex flex-col gap-1">
|
<div v-if="toggled[i]" class="mt-1 flex flex-col gap-1">
|
||||||
<div v-for="(s, si) in parseSrc(msg.content)" :key="si" class="px-2 py-1 rounded text-xs"
|
<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);">
|
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 }}
|
<span style="font-weight:600;color:var(--nav-text);">[{{ si + 1 }}]</span> {{ s }}
|
||||||
</div>
|
</div>
|
||||||
@@ -55,6 +78,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div>
|
<div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Input -->
|
||||||
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
|
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input ref="inputEl" v-model="q" type="text" placeholder="Ta question..." maxlength="500"
|
<input ref="inputEl" v-model="q" type="text" placeholder="Ta question..." maxlength="500"
|
||||||
@@ -77,6 +102,25 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
interface Message { role: 'user' | 'assistant'; content: string }
|
interface Message { role: 'user' | 'assistant'; content: string }
|
||||||
|
|
||||||
|
type CorpusMode = 'pensees' | 'projets' | 'both'
|
||||||
|
|
||||||
|
const CORPUS_STORAGE_KEY = 'chatbot-pensees-corpus'
|
||||||
|
|
||||||
|
// Patterns projet : les sources qui matchent sont des refs projet archi
|
||||||
|
// Pattern actuel : butte pinson. Extensible en ajoutant d'autres slugs.
|
||||||
|
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 }>()
|
const props = defineProps<{ auteurContext?: string | null }>()
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
const q = ref('')
|
const q = ref('')
|
||||||
@@ -88,6 +132,23 @@ const msgEl = ref<HTMLElement | null>(null)
|
|||||||
const inputEl = ref<HTMLInputElement | null>(null)
|
const inputEl = ref<HTMLInputElement | null>(null)
|
||||||
const corpusCount = 18
|
const corpusCount = 18
|
||||||
|
|
||||||
|
// Corpus state - init depuis localStorage
|
||||||
|
const corpus = ref<CorpusMode>('both')
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const saved = window.localStorage.getItem(CORPUS_STORAGE_KEY) as CorpusMode | null
|
||||||
|
if (saved && ['pensees', 'projets', 'both'].includes(saved)) {
|
||||||
|
corpus.value = saved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCorpus(val: CorpusMode) {
|
||||||
|
corpus.value = val
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(CORPUS_STORAGE_KEY, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(open, (val) => {
|
watch(open, (val) => {
|
||||||
if (!val) return
|
if (!val) return
|
||||||
nextTick(() => inputEl.value?.focus())
|
nextTick(() => inputEl.value?.focus())
|
||||||
@@ -109,7 +170,10 @@ async function send() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
await nextTick(); scrollBottom()
|
await nextTick(); scrollBottom()
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<{ response: string }>('/api/chatbot-pensees', { method: 'POST', body: { query, mode: 'hybrid' } })
|
const res = await $fetch<{ response: string }>('/api/chatbot-pensees', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { query, mode: 'hybrid', corpus: corpus.value },
|
||||||
|
})
|
||||||
messages.value.push({ role: 'assistant', content: res.response ?? '' })
|
messages.value.push({ role: 'assistant', content: res.response ?? '' })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const s = e?.response?.status ?? e?.statusCode
|
const s = e?.response?.status ?? e?.statusCode
|
||||||
@@ -119,16 +183,28 @@ async function send() {
|
|||||||
await nextTick(); scrollBottom()
|
await nextTick(); scrollBottom()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollBottom() { if (msgEl.value) msgEl.value.scrollTop = msgEl.value.scrollHeight }
|
function scrollBottom() { if (msgEl.value) msgEl.value.scrollTop = msgEl.value.scrollHeight }
|
||||||
|
|
||||||
function renderMd(t: string) {
|
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>'
|
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 stripSrc(t: string) { return t.replace(/\n*(?:Sources?|References?)\s*:[\s\S]*$/i, '').trim() }
|
||||||
|
|
||||||
function parseSrc(t: string): string[] {
|
function parseSrc(t: string): string[] {
|
||||||
const bloc = t.match(/\n*(?:Sources?|References?)\s*:\n?([\s\S]+?)$/i)
|
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)
|
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]))]
|
return [...new Set([...t.matchAll(/\[([^\]]{5,80})\]/g)].filter(m => m[1].includes(' - ')).map(m => m[1]))]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtrage UI des sources selon corpus actif
|
||||||
|
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))
|
||||||
|
// corpus === 'pensees' : exclure les sources projet
|
||||||
|
return all.filter(s => !isProjectSource(s))
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -138,4 +214,4 @@ function parseSrc(t: string): string[] {
|
|||||||
.cpanel-leave-to { opacity: 0; transform: translateY(8px) scale(0.97); }
|
.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; }
|
.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)} }
|
@keyframes bounce { 0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-5px)} }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user