feat(chatbot): persistance des conversations en sessionStorage + bouton Nouvelle conversation
- composables/useChatSession.ts : historique réactif partagé (useState) et persisté sous aep-chat-<clé>, version de schéma {v:1}, 40 derniers messages, restauration au mount uniquement (SSR-safe), try/catch partout, messages transient jamais écrits
- clés : accueil (Placeholder + Sheet), agences (Placeholder réseaux + ChatbotReseaux), taff, pensees (suffixée par l'auteur)
- ChatbotPlaceholder reçoit sa clé par prop chatKey (défaut accueil)
- ChatNewConversation : bouton sobre, visible seulement s'il y a un historique
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
31edbd37af
commit
75d8440601
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<button type="button" class="chat-new-conversation" @click="emit('reset')">
|
||||||
|
Nouvelle conversation
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Bouton sobre « Nouvelle conversation », partagé par les chatbots.
|
||||||
|
// Le parent le rend seulement s'il y a un historique et gère la purge.
|
||||||
|
const emit = defineEmits<{ reset: [] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-new-conversation {
|
||||||
|
align-self: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chat-new-conversation:hover { color: var(--nav-text); background: var(--nav-bg-alt); }
|
||||||
|
.chat-new-conversation:focus-visible { outline: 2px solid var(--nav-primary); outline-offset: 2px; }
|
||||||
|
</style>
|
||||||
@@ -16,16 +16,6 @@
|
|||||||
<span v-else class="chatbot-corpus-badge">FRACAS</span>
|
<span v-else class="chatbot-corpus-badge">FRACAS</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chatbot-header-right">
|
<div class="chatbot-header-right">
|
||||||
<button
|
|
||||||
v-if="messages.length > 0"
|
|
||||||
class="chatbot-clear-btn"
|
|
||||||
title="Effacer la conversation"
|
|
||||||
@click="clearMessages"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
|
||||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<select v-model="corpusMode" class="chatbot-corpus-select" title="Corpus interrogé">
|
<select v-model="corpusMode" class="chatbot-corpus-select" title="Corpus interrogé">
|
||||||
<option value="pensees">Pensées éco.</option>
|
<option value="pensees">Pensées éco.</option>
|
||||||
<option value="both">Pensées + Projets</option>
|
<option value="both">Pensées + Projets</option>
|
||||||
@@ -66,6 +56,8 @@
|
|||||||
|
|
||||||
<!-- Erreur -->
|
<!-- Erreur -->
|
||||||
<div v-if="errorMsg" class="chatbot-error">{{ errorMsg }}</div>
|
<div v-if="errorMsg" class="chatbot-error">{{ errorMsg }}</div>
|
||||||
|
|
||||||
|
<ChatNewConversation v-if="hasHistory && !loading" @reset="clearMessages" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
@@ -109,20 +101,22 @@ const props = defineProps<{
|
|||||||
inline?: boolean
|
inline?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const messages = ref<ChatMessage[]>([])
|
// Une conversation par contexte (corpus général ou un auteur) : changer d'auteur
|
||||||
|
// ne mélange pas les historiques, et un rechargement restaure le bon.
|
||||||
|
const sessionKey = computed(() => props.auteurContext ? `pensees-${slugify(props.auteurContext)}` : 'pensees')
|
||||||
|
const { messages, hasHistory, clear } = useChatSession<ChatMessage>(sessionKey)
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees')
|
const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees')
|
||||||
const messagesContainer = ref<HTMLElement | null>(null)
|
const messagesContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
watch(() => props.auteurContext, () => {
|
watch(sessionKey, () => {
|
||||||
messages.value = []
|
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
function clearMessages() {
|
function clearMessages() {
|
||||||
messages.value = []
|
clear()
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,8 @@
|
|||||||
|
|
||||||
<!-- Erreur -->
|
<!-- Erreur -->
|
||||||
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
|
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
|
||||||
|
|
||||||
|
<ChatNewConversation v-if="hasHistory && !loading" @reset="nouvelleConversation" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
@@ -169,7 +171,10 @@ const props = withDefaults(defineProps<{
|
|||||||
rendreMarkdown?: boolean
|
rendreMarkdown?: boolean
|
||||||
/** Message affiché quand la limite quotidienne (429) est atteinte */
|
/** Message affiché quand la limite quotidienne (429) est atteinte */
|
||||||
messageLimite?: string
|
messageLimite?: string
|
||||||
|
/** Clé de persistance (sessionStorage). Même clé = même conversation (ex. mobile + desktop) */
|
||||||
|
chatKey?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
|
chatKey: 'accueil',
|
||||||
endpoint: '/api/chatbot',
|
endpoint: '/api/chatbot',
|
||||||
titre: 'Chatbot AEP',
|
titre: 'Chatbot AEP',
|
||||||
placeholderReplie: 'Pose une question sur le réseau…',
|
placeholderReplie: 'Pose une question sur le réseau…',
|
||||||
@@ -188,7 +193,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isExpanded = ref(false)
|
const isExpanded = ref(false)
|
||||||
const messages = ref<ChatMessage[]>([])
|
const { messages, hasHistory, clear } = useChatSession<ChatMessage>(() => props.chatKey)
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
@@ -225,6 +230,11 @@ function detectHashtagsFromQuery(query: string): string[] {
|
|||||||
return detected.slice(0, 3)
|
return detected.slice(0, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nouvelleConversation() {
|
||||||
|
clear()
|
||||||
|
errorMsg.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
function toggleExpand() {
|
function toggleExpand() {
|
||||||
isExpanded.value = !isExpanded.value
|
isExpanded.value = !isExpanded.value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
<span class="dot" /><span class="dot" /><span class="dot" />
|
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
|
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
|
||||||
|
<ChatNewConversation v-if="hasHistory && !loading" @reset="nouvelleConversation" />
|
||||||
</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="shrink-0 px-4 pt-3 border-t" style="border-color: var(--nav-bg-alt); padding-bottom: max(1rem, env(safe-area-inset-bottom));">
|
||||||
@@ -117,7 +118,7 @@ interface ChatMessage { role: 'user' | 'assistant'; content: string; fiches?: Fi
|
|||||||
const props = defineProps<{ modelValue: boolean }>()
|
const props = defineProps<{ modelValue: boolean }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
|
|
||||||
const messages = ref<ChatMessage[]>([])
|
const { messages, hasHistory, clear } = useChatSession<ChatMessage>('agences')
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
@@ -136,6 +137,11 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function nouvelleConversation() {
|
||||||
|
clear()
|
||||||
|
errorMsg.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const question = inputText.value.trim()
|
const question = inputText.value.trim()
|
||||||
if (!question || loading.value) return
|
if (!question || loading.value) return
|
||||||
|
|||||||
@@ -114,6 +114,8 @@
|
|||||||
<div v-if="errorMsg" class="error-bubble">
|
<div v-if="errorMsg" class="error-bubble">
|
||||||
{{ errorMsg }}
|
{{ errorMsg }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ChatNewConversation v-if="hasHistory && !loading" @reset="nouvelleConversation" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
@@ -183,7 +185,7 @@ const emit = defineEmits<{
|
|||||||
'highlightOrgs': [ids: (number | string)[]]
|
'highlightOrgs': [ids: (number | string)[]]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const messages = ref<ChatMessage[]>([])
|
const { messages, hasHistory, clear } = useChatSession<ChatMessage>('accueil')
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
@@ -207,6 +209,11 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function nouvelleConversation() {
|
||||||
|
clear()
|
||||||
|
errorMsg.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const question = inputText.value.trim()
|
const question = inputText.value.trim()
|
||||||
if (!question || loading.value) return
|
if (!question || loading.value) return
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { MaybeRefOrGetter } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistance de l'historique d'un chatbot dans sessionStorage.
|
||||||
|
*
|
||||||
|
* - Clé de stockage : `aep-chat-<pageKey>`, valeur `{ v: 1, messages: [...] }`
|
||||||
|
* (une valeur d'une autre version est ignorée).
|
||||||
|
* - SSR-safe : aucun accès au stockage hors de onMounted. Côté serveur et à
|
||||||
|
* l'hydratation l'historique est vide, il est restauré juste après le mount.
|
||||||
|
* - Partagé : deux composants (desktop + mobile) qui déclarent la même clé
|
||||||
|
* lisent et écrivent le même tableau (useState), donc un redimensionnement
|
||||||
|
* ne perd rien et un composant n'écrase pas l'autre.
|
||||||
|
* - Tolérant : toute lecture/écriture est dans un try/catch (navigation
|
||||||
|
* privée, stockage bloqué, quota). En cas d'échec le chatbot fonctionne
|
||||||
|
* comme avant, sans persistance.
|
||||||
|
* - Les messages marqués `transient: true` (erreurs) restent affichés mais ne
|
||||||
|
* sont jamais écrits. Chargement et erreurs vivent hors de l'historique.
|
||||||
|
* - La forme des messages est libre (fiches, auteur, mode...) : tout champ
|
||||||
|
* sérialisable est conservé.
|
||||||
|
*/
|
||||||
|
export interface ChatSessionMessage {
|
||||||
|
role: string
|
||||||
|
content: string
|
||||||
|
transient?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'aep-chat-'
|
||||||
|
const SCHEMA_VERSION = 1
|
||||||
|
const DEFAULT_MAX = 40
|
||||||
|
|
||||||
|
export function useChatSession<T extends ChatSessionMessage>(
|
||||||
|
pageKey: MaybeRefOrGetter<string>,
|
||||||
|
options: { max?: number } = {},
|
||||||
|
) {
|
||||||
|
const max = options.max ?? DEFAULT_MAX
|
||||||
|
const store = useState<Record<string, T[]>>('aep-chat-store', () => ({}))
|
||||||
|
const restored = useState<Record<string, boolean>>('aep-chat-restored', () => ({}))
|
||||||
|
const key = computed(() => toValue(pageKey))
|
||||||
|
const mounted = ref(false)
|
||||||
|
|
||||||
|
function ensure(k: string) {
|
||||||
|
if (!store.value[k]) store.value[k] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = computed<T[]>({
|
||||||
|
get: () => store.value[key.value] ?? [],
|
||||||
|
set: (list) => { store.value[key.value] = list },
|
||||||
|
})
|
||||||
|
const hasHistory = computed(() => messages.value.length > 0)
|
||||||
|
|
||||||
|
function isValid(m: any): m is T {
|
||||||
|
return !!m && typeof m === 'object' && typeof m.role === 'string' && typeof m.content === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore() {
|
||||||
|
const k = key.value
|
||||||
|
if (restored.value[k]) return
|
||||||
|
restored.value[k] = true
|
||||||
|
ensure(k)
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(STORAGE_PREFIX + k)
|
||||||
|
if (!raw) return
|
||||||
|
const data = JSON.parse(raw)
|
||||||
|
if (!data || data.v !== SCHEMA_VERSION || !Array.isArray(data.messages)) return
|
||||||
|
const valid = (data.messages as unknown[]).filter(isValid).slice(-max)
|
||||||
|
if (valid.length && store.value[k].length === 0) store.value[k] = valid
|
||||||
|
} catch {
|
||||||
|
/* stockage indisponible ou valeur corrompue : on repart de zéro */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
if (!mounted.value) return
|
||||||
|
try {
|
||||||
|
const list = messages.value.filter(m => !m.transient).slice(-max)
|
||||||
|
const sk = STORAGE_PREFIX + key.value
|
||||||
|
if (list.length === 0) sessionStorage.removeItem(sk)
|
||||||
|
else sessionStorage.setItem(sk, JSON.stringify({ v: SCHEMA_VERSION, messages: list }))
|
||||||
|
} catch {
|
||||||
|
/* quota, navigation privée : pas de persistance, pas d'erreur */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Efface l'historique affiché et la clé de stockage. */
|
||||||
|
function clear() {
|
||||||
|
store.value[key.value] = []
|
||||||
|
try { sessionStorage.removeItem(STORAGE_PREFIX + key.value) } catch { /* ignoré */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(key, (k) => {
|
||||||
|
ensure(k)
|
||||||
|
if (mounted.value) restore()
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(messages, save, { deep: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
restore()
|
||||||
|
mounted.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
return { messages, hasHistory, clear }
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
<div v-if="vf.etat.sousVue !== 'outremer'" class="hidden lg:block shrink-0">
|
<div v-if="vf.etat.sousVue !== 'outremer'" class="hidden lg:block shrink-0">
|
||||||
<ChatbotPlaceholder
|
<ChatbotPlaceholder
|
||||||
endpoint="/api/chatbot-reseaux"
|
endpoint="/api/chatbot-reseaux"
|
||||||
|
chat-key="agences"
|
||||||
titre="Assistant Réseaux AEP"
|
titre="Assistant Réseaux AEP"
|
||||||
placeholder-replie="Pose une question sur les structures et agences du réseau…"
|
placeholder-replie="Pose une question sur les structures et agences du réseau…"
|
||||||
placeholder-saisie="Décris ta situation…"
|
placeholder-saisie="Décris ta situation…"
|
||||||
|
|||||||
@@ -275,6 +275,8 @@
|
|||||||
<div v-if="chatLoading" class="taff-msg taff-msg--bot">
|
<div v-if="chatLoading" class="taff-msg taff-msg--bot">
|
||||||
<span class="taff-typing"><span/><span/><span/></span>
|
<span class="taff-typing"><span/><span/><span/></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ChatNewConversation v-if="chatHasHistory && !chatLoading" @reset="clearChat" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
@@ -581,6 +583,8 @@ interface ChatMessage {
|
|||||||
role: 'user' | 'bot'
|
role: 'user' | 'bot'
|
||||||
content: string
|
content: string
|
||||||
recommandations?: { id: string; nom: string; raison: string }[]
|
recommandations?: { id: string; nom: string; raison: string }[]
|
||||||
|
/** Message d'erreur : affiché mais jamais persisté */
|
||||||
|
transient?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const { render: renderMd } = useMarkdown()
|
const { render: renderMd } = useMarkdown()
|
||||||
@@ -588,7 +592,7 @@ const { render: renderMd } = useMarkdown()
|
|||||||
const chatOpen = ref(false)
|
const chatOpen = ref(false)
|
||||||
const chatInput = ref('')
|
const chatInput = ref('')
|
||||||
const chatLoading = ref(false)
|
const chatLoading = ref(false)
|
||||||
const chatMessages = ref<ChatMessage[]>([])
|
const { messages: chatMessages, hasHistory: chatHasHistory, clear: clearChat } = useChatSession<ChatMessage>('taff')
|
||||||
const chatMessagesEl = ref<HTMLElement | null>(null)
|
const chatMessagesEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
async function sendChat() {
|
async function sendChat() {
|
||||||
@@ -610,7 +614,7 @@ async function sendChat() {
|
|||||||
recommandations: res.plateformes_recommandees ?? [],
|
recommandations: res.plateformes_recommandees ?? [],
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
chatMessages.value.push({ role: 'bot', content: e?.data?.statusMessage ?? 'Erreur — réessaie dans un instant.' })
|
chatMessages.value.push({ role: 'bot', content: e?.data?.statusMessage ?? 'Erreur — réessaie dans un instant.', transient: true })
|
||||||
} finally {
|
} finally {
|
||||||
chatLoading.value = false
|
chatLoading.value = false
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|||||||
Reference in New Issue
Block a user