'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> = { 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 l’assistente' }, close: { FR: "Fermer l'assistant", NL: 'Assistent sluiten', EN: 'Close the assistant', DE: 'Assistent schließen', ES: 'Cerrar el asistente', IT: 'Chiudi l’assistente' }, 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 = { 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([]) 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(null) const inputRef = useRef(null) const launcherRef = useRef(null) const recognitionRef = useRef(null) const lastQuestionRef = useRef('') // 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((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 && ( )}