- composables/useMarkdown.ts : renderer MD léger (bold/italic/listes/titres) - ChatbotSheet.vue + trouver-du-taf.vue : v-html renderMd() sur messages bot - assets/css/main.css : styles .md-content globaux pour tous les chatbots - taff-header centré + phrase cible 'architectes indépendants, 70% de la profession' Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
409 lines
12 KiB
Vue
409 lines
12 KiB
Vue
<template>
|
|
<Teleport to="body">
|
|
<!-- Backdrop -->
|
|
<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>
|
|
|
|
<!-- Bottom sheet plein écran -->
|
|
<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;
|
|
border-radius: 0;
|
|
box-shadow: 0 -4px 32px rgba(26,34,56,0.18);
|
|
"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Assistant AEP"
|
|
>
|
|
<!-- Poignée visuelle -->
|
|
<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>
|
|
|
|
<!-- Header -->
|
|
<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" aria-hidden="true">
|
|
<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" aria-hidden="true" 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);">Chatbot</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Zone conversation -->
|
|
<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">
|
|
<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 -->
|
|
<template v-for="(msg, i) in messages" :key="i">
|
|
<!-- Message utilisateur -->
|
|
<div v-if="msg.role === 'user'" class="user-bubble">
|
|
{{ msg.content }}
|
|
</div>
|
|
|
|
<!-- Message assistant -->
|
|
<div v-else class="assistant-bubble">
|
|
<div class="md-content" v-html="renderMd(msg.content)" />
|
|
|
|
<!-- Fiches recommandées -->
|
|
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
|
<p class="fiches-title">Fiches recommandées :</p>
|
|
<a
|
|
v-for="fiche in msg.fiches"
|
|
:key="fiche.id"
|
|
:href="`/fiche/${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>
|
|
|
|
<!-- Indicateur de chargement -->
|
|
<div v-if="loading" class="assistant-bubble loading-bubble">
|
|
<span class="dot" /><span class="dot" /><span class="dot" />
|
|
</div>
|
|
|
|
<!-- Message d'erreur -->
|
|
<div v-if="errorMsg" class="error-bubble">
|
|
{{ errorMsg }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Input -->
|
|
<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="Pose ta question…"
|
|
class="flex-1 px-4 py-3 rounded-xl text-sm border"
|
|
:class="loading ? 'cursor-not-allowed' : ''"
|
|
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" aria-hidden="true" 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">
|
|
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]
|
|
'highlightOrgs': [ids: (number | string)[]]
|
|
}>()
|
|
|
|
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
|
|
if (open) {
|
|
document.body.style.overflow = 'hidden'
|
|
document.documentElement.style.overflow = 'hidden'
|
|
} else {
|
|
document.body.style.overflow = ''
|
|
document.documentElement.style.overflow = ''
|
|
}
|
|
})
|
|
|
|
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()
|
|
scrollToBottom()
|
|
|
|
try {
|
|
const res = await $fetch<{
|
|
reponse_texte: string
|
|
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
|
|
}>('/api/chatbot', {
|
|
method: 'POST',
|
|
body: { question },
|
|
})
|
|
|
|
const assistantMsg: ChatMessage = {
|
|
role: 'assistant',
|
|
content: res.reponse_texte,
|
|
fiches: res.fiches_recommandees || [],
|
|
}
|
|
messages.value.push(assistantMsg)
|
|
|
|
// Highlight carte si des fiches sont recommandées
|
|
if (assistantMsg.fiches && assistantMsg.fiches.length > 0) {
|
|
emit('highlightOrgs', assistantMsg.fiches.map((f) => f.id))
|
|
}
|
|
} catch (e: any) {
|
|
const status = e?.statusCode ?? e?.status
|
|
if (status === 429) {
|
|
errorMsg.value = 'Limite de 10 questions par jour atteinte. Reviens demain.'
|
|
} else if (status === 503) {
|
|
errorMsg.value = 'Le budget IA mensuel est épuisé. Soutiens NAV sur Liberapay pour continuer.'
|
|
} else {
|
|
errorMsg.value = 'Une erreur est survenue. Réessaie dans quelques instants.'
|
|
}
|
|
} finally {
|
|
loading.value = false
|
|
await nextTick()
|
|
scrollToBottom()
|
|
}
|
|
}
|
|
|
|
function scrollToBottom() {
|
|
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 */
|
|
.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);
|
|
white-space: pre-line;
|
|
}
|
|
.onboarding-bubble p { margin-bottom: 10px; }
|
|
.onboarding-bubble ul { margin: 8px 0; padding: 0; list-style: none; }
|
|
.onboarding-bubble li { margin-bottom: 4px; }
|
|
.onboarding-bubble .example {
|
|
margin-top: 12px;
|
|
font-style: italic;
|
|
opacity: 0.8;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
/* Bulles utilisateur */
|
|
.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;
|
|
}
|
|
|
|
/* Bulles assistant */
|
|
.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);
|
|
}
|
|
.assistant-bubble p { margin: 0; }
|
|
|
|
/* Fiches recommandées */
|
|
.fiches-list {
|
|
margin-top: 12px;
|
|
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: border-color 0.15s, background 0.15s;
|
|
}
|
|
.fiche-card:hover {
|
|
border-color: var(--nav-primary);
|
|
background: var(--nav-surface);
|
|
}
|
|
.fiche-nom {
|
|
display: block;
|
|
font-size: 0.825rem;
|
|
font-weight: 600;
|
|
color: var(--nav-text);
|
|
}
|
|
.fiche-expl {
|
|
display: block;
|
|
font-size: 0.775rem;
|
|
color: var(--nav-text-muted);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
/* Chargement */
|
|
.loading-bubble {
|
|
display: flex;
|
|
gap: 5px;
|
|
padding: 12px 16px;
|
|
}
|
|
.dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
background: var(--nav-text-muted);
|
|
border-radius: 50%;
|
|
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.3; transform: scale(0.85); }
|
|
40% { opacity: 1; transform: scale(1); }
|
|
}
|
|
|
|
/* Erreur */
|
|
.error-bubble {
|
|
align-self: center;
|
|
background: rgba(220, 50, 50, 0.08);
|
|
border: 1px solid rgba(220, 50, 50, 0.2);
|
|
border-radius: 10px;
|
|
padding: 10px 14px;
|
|
font-size: 0.825rem;
|
|
color: #c0392b;
|
|
text-align: center;
|
|
max-width: 90%;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.sheet-enter-active,
|
|
.sheet-leave-active { transition: none; }
|
|
.backdrop-enter-active,
|
|
.backdrop-leave-active { transition: none; }
|
|
.dot { animation: none; opacity: 0.5; }
|
|
}
|
|
</style>
|