- 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
104 lines
3.5 KiB
TypeScript
104 lines
3.5 KiB
TypeScript
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 }
|
|
}
|