From 473fc3acabe3dc808c04e41004cf0fad0775d482 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Sun, 9 Aug 2026 22:15:01 +0200 Subject: [PATCH] =?UTF-8?q?Assistant=20IA=20+=20watermark=20d'essai=20+=20?= =?UTF-8?q?parcours=20guid=C3=A9s=20+=20fix=20du=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assistant IA (débloque l'add-on sur le plan Essentiel, qui est web-only) components/assistant/ (AssistantBubble.tsx + assistant.css), lib/assistantSuggestions.ts + test. Suggestions dérivées du contenu réel de l'instance, zéro configuration côté client. Onboarding TrialWatermark.tsx — bandeau « Aperçu » pendant l'essai. Parcours & carte ParcoursSection.tsx (+108), LeafletMap.tsx, map.css, MapSection.tsx : centrage de carte et icônes corrigés (M1, M2, W2 — des champs que le client saisissait sans qu'ils aient le moindre effet). ResourceViewer.tsx : rendu des ressources d'étape. API client.ts (+44) et types.ts (+41) alignés sur les nouveaux champs backend. Fix du build lib/geo.ts — helper getGeoPointLatLng(). Les 7 erreurs venaient d'un unknown non narrowé. ⚠️ Ce type d'erreur est INVISIBLE en next dev : seul npm run build le voit. npm run build ✅. Reste ouvert : W1 — LeafletMap a toujours un TileLayer en dur, un client qui choisit « Google Hybrid » voit de l'OSM. --- src/app/[slug]/[configId]/layout.tsx | 11 + src/app/[slug]/layout.tsx | 2 + src/components/assistant/AssistantBubble.tsx | 412 +++++++++++++++++++ src/components/assistant/assistant.css | 387 +++++++++++++++++ src/components/sections/MapSection.tsx | 7 +- src/components/sections/ParcoursSection.tsx | 108 +++-- src/components/sections/map/LeafletMap.tsx | 40 +- src/components/sections/map/map.css | 12 + src/components/ui/ResourceViewer.tsx | 5 + src/components/ui/TrialWatermark.tsx | 15 + src/lib/api/client.ts | 44 +- src/lib/api/types.ts | 41 +- src/lib/assistantSuggestions.test.ts | 91 ++++ src/lib/assistantSuggestions.ts | 119 ++++++ src/lib/geo.ts | 11 + 15 files changed, 1260 insertions(+), 45 deletions(-) create mode 100644 src/components/assistant/AssistantBubble.tsx create mode 100644 src/components/assistant/assistant.css create mode 100644 src/components/ui/TrialWatermark.tsx create mode 100644 src/lib/assistantSuggestions.test.ts create mode 100644 src/lib/assistantSuggestions.ts diff --git a/src/app/[slug]/[configId]/layout.tsx b/src/app/[slug]/[configId]/layout.tsx index bfec46e..8ac4d51 100644 --- a/src/app/[slug]/[configId]/layout.tsx +++ b/src/app/[slug]/[configId]/layout.tsx @@ -2,6 +2,7 @@ import { notFound } from 'next/navigation' import { getInstanceBySlug, getConfiguration } from '@/lib/api/client' import { resolveColors } from '@/lib/theme' import SplashScreen from '@/components/SplashScreen' +import AssistantBubble from '@/components/assistant/AssistantBubble' export default async function ConfigLayout({ children, @@ -27,6 +28,16 @@ export default async function ConfigLayout({
{loaderImageUrl && } {children} + {instance.isAssistant && instance.publicApiKey && ( + + )}
) } diff --git a/src/app/[slug]/layout.tsx b/src/app/[slug]/layout.tsx index 57dee21..b989196 100644 --- a/src/app/[slug]/layout.tsx +++ b/src/app/[slug]/layout.tsx @@ -2,6 +2,7 @@ import { notFound } from 'next/navigation' import { getInstanceBySlug } from '@/lib/api/client' import { resolveColors } from '@/lib/theme' import { VisitorProvider } from '@/context/VisitorContext' +import TrialWatermark from '@/components/ui/TrialWatermark' export default async function SlugLayout({ children, @@ -25,6 +26,7 @@ export default async function SlugLayout({
{children} + {instance.isTrialActive && }
) diff --git a/src/components/assistant/AssistantBubble.tsx b/src/components/assistant/AssistantBubble.tsx new file mode 100644 index 0000000..f082719 --- /dev/null +++ b/src/components/assistant/AssistantBubble.tsx @@ -0,0 +1,412 @@ +'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.', + }, +} + +const label = (key: keyof typeof UI, lang: string) => UI[key][lang] ?? UI[key].FR + +/// 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 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 && ( + + )} + + + +