visitapp-web/src/components/assistant/AssistantBubble.tsx
Thomas Fransolet cb7f045b0b J4 — mention d'information aux visiteurs dans l'assistant
§8.3 des CGU : la mention doit être accessible depuis l'assistant lui-même, là où
la collecte a lieu. Panneau dépliable dans l'en-tête, borné en hauteur et
défilant pour ne pas chasser le fil de conversation hors de l'écran.

Texte repris mot pour mot de DOCS/mention-information-visiteurs.md, comme la
version Dart de mymuseum-visitapp : les trois doivent rester alignés, et une
correction se fait dans le document d'abord.

FR/NL/EN avec repli sur l'anglais, alors que le reste de cette interface parle
six langues : traduire une mention de protection des données sans relecture
humaine serait pire que la servir en anglais.

npm run build vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:29:36 +02:00

496 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext'
import { sendAssistantMessage, AssistantUnavailableError } from '@/lib/api/client'
import { buildSuggestions } from '@/lib/assistantSuggestions'
import { stripHtml } from '@/lib/i18n'
import type { AiChatMessageDTO, AiChatResponseDTO, SectionDTO } from '@/lib/api/types'
import './assistant.css'
type Turn =
| { from: 'visitor'; text: string }
| { from: 'guide'; response: AiChatResponseDTO }
| { from: 'guide'; error: 'failed' | 'unavailable' }
interface Props {
instanceId: string
apiKey: string
configId: string
slug: string
sections?: SectionDTO[]
/// Nom du lieu, affiché sous le titre — le visiteur sait à qui il parle.
venueName?: string
}
const UI: Record<string, Record<string, string>> = {
title: { FR: 'Votre guide', NL: 'Uw gids', EN: 'Your guide', DE: 'Ihr Guide', ES: 'Su guía', IT: 'La tua guida' },
open: { FR: "Ouvrir l'assistant", NL: 'Assistent openen', EN: 'Open the assistant', DE: 'Assistent öffnen', ES: 'Abrir el asistente', IT: 'Apri lassistente' },
close: { FR: "Fermer l'assistant", NL: 'Assistent sluiten', EN: 'Close the assistant', DE: 'Assistent schließen', ES: 'Cerrar el asistente', IT: 'Chiudi lassistente' },
hint: { FR: 'Une question ?', NL: 'Een vraag?', EN: 'A question?', DE: 'Eine Frage?', ES: '¿Una pregunta?', IT: 'Una domanda?' },
welcome: {
FR: 'Bonjour ! Je connais ce lieu par cœur. Que puis-je vous montrer ?',
NL: 'Hallo! Ik ken deze plek van binnen en van buiten. Wat kan ik u tonen?',
EN: 'Hello! I know this place inside out. What can I show you?',
DE: 'Hallo! Ich kenne diesen Ort in- und auswendig. Was darf ich Ihnen zeigen?',
ES: '¡Hola! Conozco este lugar a fondo. ¿Qué puedo enseñarle?',
IT: 'Salve! Conosco questo luogo a memoria. Cosa posso mostrarle?',
},
placeholder: { FR: 'Posez votre question…', NL: 'Stel uw vraag…', EN: 'Ask your question…', DE: 'Stellen Sie Ihre Frage…', ES: 'Haga su pregunta…', IT: 'Fai la tua domanda…' },
send: { FR: 'Envoyer', NL: 'Verzenden', EN: 'Send', DE: 'Senden', ES: 'Enviar', IT: 'Invia' },
dictate: { FR: 'Dicter la question', NL: 'Vraag dicteren', EN: 'Dictate your question', DE: 'Frage diktieren', ES: 'Dictar la pregunta', IT: 'Detta la domanda' },
listening: { FR: 'Je vous écoute…', NL: 'Ik luister…', EN: 'Listening…', DE: 'Ich höre zu…', ES: 'Le escucho…', IT: 'Ti ascolto…' },
writing: { FR: 'Le guide rédige', NL: 'De gids schrijft', EN: 'The guide is writing', DE: 'Der Guide schreibt', ES: 'La guía está escribiendo', IT: 'La guida sta scrivendo' },
openSection: { FR: 'Ouvrir cette section', NL: 'Open dit onderdeel', EN: 'Open this section', DE: 'Diesen Bereich öffnen', ES: 'Abrir esta sección', IT: 'Apri questa sezione' },
failed: { FR: "La réponse n'est pas arrivée.", NL: 'Het antwoord is niet aangekomen.', EN: "The answer didn't come through.", DE: 'Die Antwort kam nicht an.', ES: 'La respuesta no ha llegado.', IT: 'La risposta non è arrivata.' },
retry: { FR: 'Réessayer', NL: 'Opnieuw', EN: 'Try again', DE: 'Erneut versuchen', ES: 'Reintentar', IT: 'Riprova' },
unavailable: {
FR: "Le guide se repose pour aujourd'hui. Revenez demain.",
NL: 'De gids rust vandaag uit. Kom morgen terug.',
EN: 'The guide is resting for today. Come back tomorrow.',
DE: 'Der Guide ruht sich heute aus. Kommen Sie morgen wieder.',
ES: 'La guía descansa por hoy. Vuelva mañana.',
IT: 'La guida riposa per oggi. Torna domani.',
},
privacy: {
FR: 'Vos questions au guide',
NL: 'Uw vragen aan de gids',
EN: 'Your questions to the guide',
DE: 'Ihre Fragen an den Guide',
ES: 'Sus preguntas a la guía',
IT: 'Le tue domande alla guida',
},
}
const label = (key: keyof typeof UI, lang: string) => UI[key][lang] ?? UI[key].FR
/// Mention d'information aux visiteurs (§8.3 des CGU) — miroir de
/// `mymuseum-visitapp/lib/Components/VisitorPrivacyNotice.dart`, lui-même repris mot pour
/// mot de `DOCS/mention-information-visiteurs.md`. **Les trois doivent rester alignés.**
///
/// ⚠️ **Trois langues, repli sur l'anglais — c'est un choix, pas un oubli.** Le reste de
/// cette interface parle six langues, mais traduire une mention de protection des données
/// sans relecture humaine serait pire que la servir en anglais : une nuance perdue sur
/// « nous n'enregistrons pas votre adresse IP » n'est pas une coquille d'interface. Les
/// autres langues attendent une traduction relue, à demander avec la relecture juridique.
const PRIVACY_NOTICE: Record<string, string> = {
FR:
"Lorsque vous posez une question à notre guide, votre question et la réponse qui vous est " +
"donnée sont enregistrées. Cela nous sert à repérer ce que nos visiteurs cherchent sans le " +
"trouver, et à compléter nos contenus en conséquence.\n\n" +
"Nous n'enregistrons ni votre nom, ni votre compte, ni votre adresse IP. Un identifiant de " +
"session tiré au hasard permet seulement de relier entre elles les questions d'une même " +
"conversation ; il disparaît avec elle.\n\n" +
"Votre question est transmise à notre fournisseur d'intelligence artificielle (Google) pour " +
"produire la réponse. Le texte de vos questions est supprimé au bout de 90 jours ; seuls des " +
"regroupements par sujet, sans le texte de vos questions, sont conservés au-delà.\n\n" +
"Le champ de question est libre : nous vous invitons à ne pas y saisir d'informations " +
"personnelles.",
NL:
"Wanneer u onze gids een vraag stelt, worden uw vraag en het gegeven antwoord opgeslagen. Zo " +
"zien we wat onze bezoekers zoeken zonder het te vinden, en vullen we onze inhoud aan.\n\n" +
"Wij registreren noch uw naam, noch een account, noch uw IP-adres. Een willekeurig " +
"gegenereerde sessie-identificatie dient enkel om de vragen van eenzelfde gesprek aan elkaar " +
"te koppelen; ze verdwijnt samen met dat gesprek.\n\n" +
"Uw vraag wordt doorgestuurd naar onze aanbieder van kunstmatige intelligentie (Google) om " +
"het antwoord op te stellen. De tekst van uw vragen wordt na 90 dagen verwijderd; daarna " +
"blijven enkel groeperingen per onderwerp bewaard, zonder de tekst van uw vragen.\n\n" +
"Het vraagveld is vrij in te vullen: wij raden u aan er geen persoonsgegevens in te typen.",
EN:
"When you ask our guide a question, your question and the answer you are given are recorded. " +
"This helps us see what our visitors look for without finding it, and improve our content " +
"accordingly.\n\n" +
"We record neither your name, nor an account, nor your IP address. A randomly generated " +
"session identifier only links together the questions of a single conversation; it " +
"disappears with it.\n\n" +
"Your question is sent to our artificial intelligence provider (Google) to produce the " +
"answer. The text of your questions is deleted after 90 days; beyond that, only groupings by " +
"topic are kept, without the text of your questions.\n\n" +
"The question field is free text: we invite you not to enter personal information in it.",
}
const privacyNotice = (lang: string) => PRIVACY_NOTICE[lang] ?? PRIVACY_NOTICE.EN
/// L'API de reconnaissance vocale n'est pas standardisée partout : on ne montre le
/// micro que si le navigateur la propose réellement.
function getSpeechRecognition(): any {
if (typeof window === 'undefined') return null
return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null
}
export default function AssistantBubble({
instanceId, apiKey, configId, slug, sections, venueName,
}: Props) {
const { language } = useVisitor()
const router = useRouter()
const pathname = usePathname()
const [open, setOpen] = useState(false)
const [turns, setTurns] = useState<Turn[]>([])
const [draft, setDraft] = useState('')
const [pending, setPending] = useState(false)
const [listening, setListening] = useState(false)
const [speechReady, setSpeechReady] = useState(false)
const [noticeOpen, setNoticeOpen] = useState(false)
const threadRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const launcherRef = useRef<HTMLButtonElement>(null)
const recognitionRef = useRef<any>(null)
const lastQuestionRef = useRef<string>('')
// La section ouverte se lit dans l'URL — inutile de la faire descendre depuis
// le layout, qui ne la connaît pas.
const currentSection = useMemo(() => {
const match = pathname?.match(/\/sections\/([^/]+)/)
if (!match) return null
return sections?.find((s) => s.id === match[1]) ?? null
}, [pathname, sections])
const suggestions = useMemo(
() => buildSuggestions(sections, language, currentSection),
[sections, language, currentSection]
)
useEffect(() => { setSpeechReady(!!getSpeechRecognition()) }, [])
useEffect(() => {
const el = threadRef.current
if (el) el.scrollTop = el.scrollHeight
}, [turns, pending])
useEffect(() => {
if (open) inputRef.current?.focus()
}, [open])
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && open) {
setOpen(false)
launcherRef.current?.focus()
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open])
// Historique envoyé au modèle : seulement les tours aboutis.
function historyFrom(list: Turn[]): AiChatMessageDTO[] {
return list.flatMap<AiChatMessageDTO>((turn) => {
if (turn.from === 'visitor') return [{ role: 'user', content: turn.text }]
if ('response' in turn) return [{ role: 'assistant', content: stripHtml(turn.response.reply) }]
return []
})
}
async function ask(question: string) {
const text = question.trim()
if (!text || pending) return
lastQuestionRef.current = text
setDraft('')
const withQuestion: Turn[] = [...turns, { from: 'visitor', text }]
setTurns(withQuestion)
setPending(true)
try {
const response = await sendAssistantMessage(
{
message: text,
instanceId,
configurationId: configId,
language,
history: historyFrom(turns),
},
apiKey
)
setTurns([...withQuestion, { from: 'guide', response }])
// `expectsReply: false` = le modèle clôt l'échange (politesse, info pure).
// On ne réarme alors pas la dictée — c'est sa raison d'être côté mains-libres.
if (listening && response.expectsReply === false) stopListening()
} catch (err) {
const kind = err instanceof AssistantUnavailableError ? 'unavailable' : 'failed'
setTurns([...withQuestion, { from: 'guide', error: kind }])
} finally {
setPending(false)
inputRef.current?.focus()
}
}
function stopListening() {
recognitionRef.current?.stop()
recognitionRef.current = null
setListening(false)
}
function toggleListening() {
if (listening) return stopListening()
const Recognition = getSpeechRecognition()
if (!Recognition) return
const recognition = new Recognition()
recognition.lang = { FR: 'fr-FR', NL: 'nl-NL', EN: 'en-US', DE: 'de-DE', ES: 'es-ES', IT: 'it-IT' }[language] ?? 'fr-FR'
recognition.interimResults = true
recognition.continuous = false
recognition.onresult = (event: any) => {
const transcript = Array.from(event.results).map((r: any) => r[0].transcript).join('')
setDraft(transcript)
if (event.results[event.results.length - 1].isFinal) {
stopListening()
ask(transcript)
}
}
recognition.onerror = () => stopListening()
recognition.onend = () => setListening(false)
recognitionRef.current = recognition
setListening(true)
recognition.start()
}
function openSection(sectionId: string) {
setOpen(false)
router.push(`/${slug}/${configId}/sections/${sectionId}`)
}
return (
<>
{!open && (
<span className="mim-assistant-hint" aria-hidden="true">{label('hint', language)}</span>
)}
<button
ref={launcherRef}
type="button"
className="mim-assistant-launcher"
aria-expanded={open}
aria-controls="mim-assistant-panel"
aria-label={label(open ? 'close' : 'open', language)}
onClick={() => setOpen(!open)}
>
{open ? <IconClose /> : <IconChat />}
</button>
<div
id="mim-assistant-panel"
className="mim-assistant-panel"
data-open={open}
role="dialog"
aria-label={label('title', language)}
aria-hidden={!open}
>
<header className="mim-assistant-head">
<span className="mim-assistant-avatar" aria-hidden="true"><IconChat /></span>
<span className="mim-assistant-identity">
<strong>{label('title', language)}</strong>
{venueName && <span>{venueName}</span>}
</span>
{/* §8.3 des CGU : la mention doit être accessible depuis l'assistant lui-même,
c'est-à-dire là où la collecte a lieu. */}
<button
type="button"
className="mim-assistant-icon-btn"
onClick={() => setNoticeOpen((v) => !v)}
aria-label={label('privacy', language)}
aria-expanded={noticeOpen}
title={label('privacy', language)}
>
<IconPrivacy />
</button>
<button
type="button"
className="mim-assistant-icon-btn"
onClick={() => setOpen(false)}
aria-label={label('close', language)}
>
<IconClose />
</button>
</header>
{noticeOpen && (
<section className="mim-assistant-notice">
<strong>{label('privacy', language)}</strong>
{privacyNotice(language).split('\n\n').map((paragraph, i) => (
<p key={i}>{paragraph}</p>
))}
</section>
)}
<div className="mim-assistant-thread" ref={threadRef} aria-live="polite">
{turns.length === 0 && (
<div className="mim-assistant-opener">
<span className="mim-assistant-mark" aria-hidden="true"><IconChat /></span>
<p>{label('welcome', language)}</p>
{suggestions.length > 0 && (
<div className="mim-assistant-chips">
{suggestions.map((s) => (
<button key={s} type="button" className="mim-assistant-chip" onClick={() => ask(s)}>
{s}
</button>
))}
</div>
)}
</div>
)}
{turns.map((turn, i) =>
turn.from === 'visitor' ? (
<div key={i} className="mim-msg mim-msg-visitor">
<div className="mim-bubble">{turn.text}</div>
</div>
) : 'error' in turn ? (
<div key={i} className={`mim-banner mim-banner-${turn.error === 'unavailable' ? 'notice' : 'error'}`}>
<span>
{label(turn.error === 'unavailable' ? 'unavailable' : 'failed', language)}
{turn.error === 'failed' && (
<button
type="button"
className="mim-assistant-chip mim-retry"
onClick={() => ask(lastQuestionRef.current)}
>
{label('retry', language)}
</button>
)}
</span>
</div>
) : (
<div key={i} className="mim-msg mim-msg-guide">
{turn.response.reply && (
<div
className="mim-bubble"
// Les réponses viennent de l'éditeur riche, comme le reste des contenus.
dangerouslySetInnerHTML={{ __html: turn.response.reply }}
/>
)}
{!!turn.response.cards?.length && (
<div className="mim-cards">
{turn.response.cards.map((card, j) => (
<div key={j} className="mim-card">
{card.icon && <span className="mim-card-icon" aria-hidden="true">{card.icon}</span>}
<span className="mim-card-text">
<strong>{card.title}</strong>
{card.subtitle && <span>{card.subtitle}</span>}
</span>
</div>
))}
</div>
)}
{turn.response.navigation && (
<button
type="button"
className="mim-nav-action"
onClick={() => openSection(turn.response.navigation!.sectionId)}
>
<span className="mim-nav-thumb" aria-hidden="true">
{turn.response.navigation.imageUrl
? <img src={turn.response.navigation.imageUrl} alt="" />
: <IconPin />}
</span>
<span className="mim-nav-text">
<strong>{stripHtml(turn.response.navigation.sectionTitle)}</strong>
<span>{label('openSection', language)}</span>
</span>
<span className="mim-nav-chevron" aria-hidden="true"><IconChevron /></span>
</button>
)}
</div>
)
)}
{pending && (
<div className="mim-typing" aria-label={label('writing', language)}>
<i /><i /><i />
</div>
)}
{listening && (
<div className="mim-listening">
<IconMic />
<span>{label('listening', language)}</span>
<span className="mim-wave" aria-hidden="true">
<i /><i /><i /><i /><i />
</span>
</div>
)}
</div>
<form
className="mim-assistant-composer"
onSubmit={(e) => { e.preventDefault(); ask(draft) }}
>
<input
ref={inputRef}
className="mim-assistant-field"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={listening ? label('listening', language) : label('placeholder', language)}
aria-label={label('placeholder', language)}
disabled={pending}
/>
{speechReady && (
<button
type="button"
className="mim-round-btn mim-mic"
data-listening={listening}
onClick={toggleListening}
aria-label={label('dictate', language)}
aria-pressed={listening}
>
<IconMic />
</button>
)}
<button
type="submit"
className="mim-round-btn mim-send"
aria-label={label('send', language)}
disabled={pending || !draft.trim()}
>
<IconSend />
</button>
</form>
</div>
</>
)
}
/* ── Icônes ─────────────────────────────────────────────────────────────── */
const stroke = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.9, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const }
const IconChat = () => (
<svg viewBox="0 0 24 24" {...stroke} aria-hidden="true">
<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>
)
const IconClose = () => (
<svg viewBox="0 0 24 24" {...stroke} strokeWidth={2.1} aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
)
const IconPrivacy = () => (
<svg viewBox="0 0 24 24" {...stroke} aria-hidden="true">
<path d="M12 3 4 6v6c0 5 3.4 8.4 8 9 4.6-.6 8-4 8-9V6l-8-3z" />
<path d="M12 11v4M12 8.2v.1" />
</svg>
)
const IconPin = () => (
<svg viewBox="0 0 24 24" {...stroke} aria-hidden="true">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" /><circle cx="12" cy="10" r="3" />
</svg>
)
const IconChevron = () => (
<svg viewBox="0 0 24 24" {...stroke} strokeWidth={2.2} aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
)
const IconMic = () => (
<svg viewBox="0 0 24 24" {...stroke} strokeWidth={2} aria-hidden="true">
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3" />
</svg>
)
const IconSend = () => (
<svg viewBox="0 0 24 24" {...stroke} strokeWidth={2} aria-hidden="true"><path d="m22 2-7 20-4-9-9-4z" /></svg>
)