fix(chatbot): séparation définitive Carte1/Carte2 + markdown inline styles
- ChatbotReseaux.vue : composant standalone, endpoint hardcodé /api/chatbot-reseaux, onboarding 120 réseaux AEP, aucun prop partagé avec ChatbotSheet - ChatbotSheet.vue : restauré état simple, /api/chatbot hardcodé, onboarding Carte 1 - agences.vue : ChatbotReseaux au lieu de ChatbotSheet - useMarkdown.ts : inline styles (font-weight:700 etc) — zéro dépendance CSS, fonctionne dans tout contexte Vue scoped/v-html sans exception Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
208
components/ChatbotReseaux.vue
Normal file
208
components/ChatbotReseaux.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<transition name="backdrop">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-[1010]"
|
||||
style="background: rgba(26,34,56,0.5);"
|
||||
@click="emit('update:modelValue', false)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</transition>
|
||||
|
||||
<transition name="sheet">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-x-0 bottom-0 z-[1011] flex flex-col"
|
||||
style="background: var(--nav-surface); height: 100dvh; max-height: 100dvh; box-shadow: 0 -4px 32px rgba(26,34,56,0.18);"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Assistant Réseaux AEP"
|
||||
>
|
||||
<div class="flex justify-center pt-3 pb-1 shrink-0">
|
||||
<div class="rounded-full" style="width: 36px; height: 4px; background: var(--nav-bg-alt);" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-4 py-3 shrink-0 border-b" style="border-color: var(--nav-bg-alt);">
|
||||
<button
|
||||
@click="emit('update:modelValue', false)"
|
||||
class="flex items-center gap-2 text-sm font-medium transition-opacity hover:opacity-70"
|
||||
style="color: var(--nav-text-muted);"
|
||||
aria-label="Retour"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
Retour
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 rounded-full flex items-center justify-center shrink-0" style="background: var(--nav-primary);">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--nav-text-on-primary);">
|
||||
<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="font-bold text-sm" style="color: var(--nav-text);">Réseaux AEP</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||
<p>Je connais les <strong>120 réseaux, collectifs et agences</strong> cartographiés dans AEP — ceux qui portent une vision écologique et politique de l'architecture.</p>
|
||||
<p>Décris ta situation : tu cherches un collectif, une agence inspirante, un partenaire sur un projet en Occitanie, en transition énergétique ?</p>
|
||||
</div>
|
||||
|
||||
<template v-for="(msg, i) in messages" :key="i">
|
||||
<div v-if="msg.role === 'user'" class="user-bubble">{{ msg.content }}</div>
|
||||
<div v-else class="assistant-bubble">
|
||||
<div v-html="renderMd(msg.content)" />
|
||||
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list" style="margin-top:12px;">
|
||||
<p class="fiches-title">Structures recommandées :</p>
|
||||
<a
|
||||
v-for="fiche in msg.fiches"
|
||||
:key="fiche.id"
|
||||
:href="`/agences#${fiche.id}`"
|
||||
class="fiche-card"
|
||||
>
|
||||
<span class="fiche-nom">{{ fiche.nom }}</span>
|
||||
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="assistant-bubble loading-bubble">
|
||||
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||
</div>
|
||||
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 px-4 pt-3 border-t" style="border-color: var(--nav-bg-alt); padding-bottom: max(1rem, env(safe-area-inset-bottom));">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="inputText"
|
||||
type="text"
|
||||
:disabled="loading"
|
||||
placeholder="Décris ta situation…"
|
||||
class="flex-1 px-4 py-3 rounded-xl text-sm border"
|
||||
style="border-color: var(--nav-bg-alt); background: var(--nav-bg); color: var(--nav-text); font-family: var(--nav-font); font-size: 16px;"
|
||||
@keydown.enter.prevent="sendMessage"
|
||||
/>
|
||||
<button
|
||||
:disabled="loading || !inputText.trim()"
|
||||
class="w-11 h-11 rounded-xl flex items-center justify-center shrink-0 transition-opacity"
|
||||
style="background: var(--nav-primary);"
|
||||
:style="{ opacity: (loading || !inputText.trim()) ? 0.4 : 1 }"
|
||||
aria-label="Envoyer"
|
||||
@click="sendMessage"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color: var(--nav-text-on-primary);">
|
||||
<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>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMarkdown } from '~/composables/useMarkdown'
|
||||
const { render: renderMd } = useMarkdown()
|
||||
|
||||
interface FicheReco { id: number | string; nom: string; explication?: string }
|
||||
interface ChatMessage { role: 'user' | 'assistant'; content: string; fiches?: FicheReco[] }
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const inputText = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const messagesContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
document.documentElement.style.overflow = open ? 'hidden' : ''
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.style.overflow = ''
|
||||
document.documentElement.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
async function sendMessage() {
|
||||
const question = inputText.value.trim()
|
||||
if (!question || loading.value) return
|
||||
inputText.value = ''
|
||||
errorMsg.value = ''
|
||||
messages.value.push({ role: 'user', content: question })
|
||||
loading.value = true
|
||||
await nextTick()
|
||||
if (messagesContainer.value) messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
|
||||
try {
|
||||
const res = await $fetch<{ reponse_texte: string; fiches_recommandees: FicheReco[] }>(
|
||||
'/api/chatbot-reseaux',
|
||||
{ method: 'POST', body: { question } }
|
||||
)
|
||||
messages.value.push({ role: 'assistant', content: res.reponse_texte, fiches: res.fiches_recommandees || [] })
|
||||
} catch (e: any) {
|
||||
const s = e?.statusCode ?? e?.status
|
||||
errorMsg.value = s === 429
|
||||
? 'Limite de 20 questions par jour atteinte.'
|
||||
: 'Une erreur est survenue. Réessaie dans quelques instants.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
if (messagesContainer.value) messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||
.sheet-enter-active, .sheet-leave-active { transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); }
|
||||
.sheet-enter-from, .sheet-leave-to { transform: translateY(100%); }
|
||||
|
||||
.onboarding-bubble {
|
||||
background: var(--nav-bg); border: 1px solid var(--nav-bg-alt);
|
||||
border-radius: 12px; padding: 16px;
|
||||
font-size: 0.85rem; line-height: 1.65; color: var(--nav-text-muted);
|
||||
}
|
||||
.onboarding-bubble p { margin-bottom: 10px; }
|
||||
.onboarding-bubble strong { font-weight: 700; color: var(--nav-text); }
|
||||
|
||||
.user-bubble {
|
||||
align-self: flex-end; max-width: 80%;
|
||||
background: var(--nav-primary); color: var(--nav-text-on-primary);
|
||||
border-radius: 16px 16px 4px 16px; padding: 10px 14px;
|
||||
font-size: 0.875rem; line-height: 1.5;
|
||||
}
|
||||
.assistant-bubble {
|
||||
align-self: flex-start; max-width: 90%;
|
||||
background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);
|
||||
border-radius: 16px 16px 16px 4px; padding: 12px 14px;
|
||||
font-size: 0.875rem; line-height: 1.6; color: var(--nav-text);
|
||||
}
|
||||
.loading-bubble { display: flex; gap: 5px; align-items: center; }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--nav-text-muted); animation: blink 1.2s infinite; }
|
||||
.dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes blink { 0%,80%,100% { opacity: 0.3; } 40% { opacity: 1; } }
|
||||
|
||||
.error-bubble { align-self: flex-start; max-width: 90%; color: #a85d3e; font-size: 0.8rem; padding: 8px 12px; border-radius: 8px; background: rgba(168,93,62,0.08); }
|
||||
|
||||
.fiches-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.fiches-title { font-size: 0.75rem; font-weight: 600; color: var(--nav-text-muted); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; }
|
||||
.fiche-card { display: block; background: var(--nav-bg); border: 1px solid var(--nav-bg-alt); border-radius: 8px; padding: 8px 12px; text-decoration: none; transition: background 0.15s; }
|
||||
.fiche-card:hover { background: var(--nav-bg-alt); }
|
||||
.fiche-nom { display: block; font-size: 0.875rem; font-weight: 600; color: var(--nav-text); }
|
||||
.fiche-expl { display: block; font-size: 0.8rem; color: var(--nav-text-muted); margin-top: 2px; }
|
||||
</style>
|
||||
@@ -61,9 +61,7 @@
|
||||
<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="font-bold text-sm" style="color: var(--nav-text);">
|
||||
{{ props.title ?? (activeEndpoint === '/api/chatbot-reseaux' ? 'Réseaux AEP' : 'Chatbot') }}
|
||||
</span>
|
||||
<span class="font-bold text-sm" style="color: var(--nav-text);">Chatbot</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,19 +69,14 @@
|
||||
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
||||
<!-- Message onboarding (avant la première question) -->
|
||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||
<template v-if="props.onboarding">
|
||||
<div class="md-content" v-html="renderMd(props.onboarding)" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>Ce chatbot fonctionne sur un serveur européen souverain (Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
||||
<p>Pour m'aider à te répondre efficacement, formule ta requête ainsi :</p>
|
||||
<ul>
|
||||
<li>• Besoin : [ce que tu cherches]</li>
|
||||
<li>• Thématique : [juridique / technique / économique / ...]</li>
|
||||
<li>• Lieu : [région ou ville]</li>
|
||||
</ul>
|
||||
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon employeur, besoin conseil juridique droit du travail, Île-de-France."</p>
|
||||
</template>
|
||||
<p>Ce chatbot fonctionne sur un serveur européen souverain (Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
||||
<p>Pour m'aider à te répondre efficacement, formule ta requête ainsi :</p>
|
||||
<ul>
|
||||
<li>• Besoin : [ce que tu cherches]</li>
|
||||
<li>• Thématique : [juridique / technique / économique / ...]</li>
|
||||
<li>• Lieu : [région ou ville]</li>
|
||||
</ul>
|
||||
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon employeur, besoin conseil juridique droit du travail, Île-de-France."</p>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -170,14 +163,6 @@
|
||||
import { useMarkdown } from '~/composables/useMarkdown'
|
||||
const { render: renderMd } = useMarkdown()
|
||||
|
||||
// Détection double-sécurité : prop endpoint > route-based fallback
|
||||
const route = useRoute()
|
||||
const activeEndpoint = computed(() => {
|
||||
if (props.endpoint) return props.endpoint
|
||||
if (route.path.startsWith('/agences')) return '/api/chatbot-reseaux'
|
||||
return '/api/chatbot'
|
||||
})
|
||||
|
||||
interface FicheReco {
|
||||
id: number | string
|
||||
nom: string
|
||||
@@ -192,9 +177,6 @@ interface ChatMessage {
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
endpoint?: string // défaut: /api/chatbot (Carte 1 NocoDB)
|
||||
title?: string // label dans le header
|
||||
onboarding?: string // message d'accueil personnalisé (markdown ok)
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -242,7 +224,7 @@ async function sendMessage() {
|
||||
const res = await $fetch<{
|
||||
reponse_texte: string
|
||||
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
|
||||
}>(activeEndpoint.value, {
|
||||
}>('/api/chatbot', {
|
||||
method: 'POST',
|
||||
body: { question },
|
||||
})
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
/**
|
||||
* Convertit du Markdown Mistral en HTML sécurisé.
|
||||
* Gère : **bold**, *italic*, ## titres, listes - et •, retours à la ligne.
|
||||
* Pas de dépendance externe.
|
||||
* Convertit du Markdown Mistral en HTML avec inline styles.
|
||||
* Inline styles = zéro dépendance CSS, fonctionne dans tout contexte Vue (scoped, v-html, etc.)
|
||||
*/
|
||||
export function useMarkdown() {
|
||||
const S = {
|
||||
p: 'style="margin:0 0 0.45em;line-height:1.6;"',
|
||||
strong: 'style="font-weight:700;"',
|
||||
em: 'style="font-style:italic;"',
|
||||
h2: 'style="font-weight:700;display:block;margin-bottom:0.2em;"',
|
||||
h3: 'style="font-weight:700;display:block;font-size:0.95em;margin-bottom:0.15em;"',
|
||||
ul: 'style="margin:0.3em 0 0.3em 1.2em;padding:0;list-style:disc;"',
|
||||
li: 'style="margin-bottom:0.15em;"',
|
||||
a: 'style="text-decoration:underline;opacity:0.85;"',
|
||||
}
|
||||
|
||||
function render(text: string): string {
|
||||
if (!text) return ''
|
||||
let html = text
|
||||
// Échappement XSS de base
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// Titres ## et ###
|
||||
.replace(/^### (.+)$/gm, '<strong class="md-h3">$1</strong>')
|
||||
.replace(/^## (.+)$/gm, '<strong class="md-h2">$1</strong>')
|
||||
.replace(/^# (.+)$/gm, '<strong class="md-h1">$1</strong>')
|
||||
// Bold et italic
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
// Listes (- item ou • item en début de ligne)
|
||||
.replace(/^[-•]\s+(.+)$/gm, '<li>$1</li>')
|
||||
// Liens [texte](url)
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" class="md-link">$1</a>')
|
||||
// Grouper les <li> consécutifs dans un <ul>
|
||||
html = html.replace(/(<li>.*<\/li>\n?)+/g, match => `<ul class="md-list">${match}</ul>`)
|
||||
// Paragraphes : double saut de ligne → séparateur
|
||||
html = html.replace(/\n{2,}/g, '</p><p>')
|
||||
// Saut de ligne simple → <br>
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/^### (.+)$/gm, `<strong ${S.h3}>$1</strong>`)
|
||||
.replace(/^## (.+)$/gm, `<strong ${S.h2}>$1</strong>`)
|
||||
.replace(/^# (.+)$/gm, `<strong ${S.h2}>$1</strong>`)
|
||||
.replace(/\*\*(.+?)\*\*/g, `<strong ${S.strong}>$1</strong>`)
|
||||
.replace(/\*(.+?)\*/g, `<em ${S.em}>$1</em>`)
|
||||
.replace(/^[-•]\s+(.+)$/gm, `<li ${S.li}>$1</li>`)
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `<a href="$2" target="_blank" rel="noopener" ${S.a}>$1</a>`)
|
||||
|
||||
html = html.replace(/(<li[^>]*>.*<\/li>\n?)+/g, m => `<ul ${S.ul}>${m}</ul>`)
|
||||
html = html.replace(/\n{2,}/g, `</p><p ${S.p}>`)
|
||||
html = html.replace(/\n/g, '<br>')
|
||||
return `<p>${html}</p>`
|
||||
return `<p ${S.p}>${html}</p>`
|
||||
}
|
||||
|
||||
return { render }
|
||||
|
||||
@@ -374,13 +374,9 @@
|
||||
</button>
|
||||
|
||||
<!-- ═══════════════════════════════════════ CHATBOT BOTTOM SHEET (mobile) -->
|
||||
<ChatbotSheet
|
||||
<ChatbotReseaux
|
||||
:modelValue="chatbotOpen"
|
||||
endpoint="/api/chatbot-reseaux"
|
||||
title="Réseaux AEP"
|
||||
onboarding="Je connais les **120 réseaux, collectifs et agences** cartographiés dans AEP — ceux qui portent une vision écologique et politique de l'architecture.\n\nDécris ta situation : tu cherches un collectif, une agence inspirante, un partenaire sur un projet ?"
|
||||
@update:modelValue="chatbotOpen = $event"
|
||||
@highlightOrgs="() => {}"
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user