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:
Jules Neny
2026-10-09 03:38:15 +02:00
co-authored by Claude Opus 5.5
parent 31edbd37af
commit 75d8440601
8 changed files with 174 additions and 19 deletions
+103
View File
@@ -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 }
}