/* Voyage Assistant — chatbot interactif branché au backend FastAPI.
- Sidebar gauche : historique (logué) ou CTA login
- Hamburger toggle dans le header
- Tool labels affichés sur les réponses bot
- Reprise d'une conversation passée via /chat/{token}/history */
const TOOL_LABELS = {
query_billet: 'consulte la BDD billets',
query_trajet: 'consulte la BDD trajets',
identity_check: 'vérifie l’identité',
create_reclamation: 'crée la réclamation',
rag_stub: 'documentation',
rag: 'documentation',
web: 'recherche web',
web_search: 'recherche web',
api: 'infos temps réel',
};
// "gemini" est volontairement exclu : pas besoin d'étiquette quand c'est juste l'IA qui répond.
// Les tags composés ("api+web", "rag+web") sont décomposés et joints par " + ".
const labelForTool = (toolKey) => {
if (!toolKey) return null;
const labels = String(toolKey).split('+').map((p) => TOOL_LABELS[p]).filter(Boolean).map((l) => t(l));
return labels.length ? labels.join(' + ') : null;
};
/* Parse les liens Markdown [texte](url) et les URLs nues en éléments React cliquables.
Préserve les retours à la ligne. */
const renderRichText = (text) => {
if (!text) return null;
const linkRe = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s)]+)/g;
const lines = String(text).split('\n');
return lines.map((line, lineIdx) => {
const parts = [];
let last = 0;
let m;
linkRe.lastIndex = 0;
while ((m = linkRe.exec(line)) !== null) {
if (m.index > last) parts.push(line.slice(last, m.index));
const url = m[2] || m[3];
const label = m[1] || url.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
parts.push(
{label}
);
last = m.index + m[0].length;
}
if (last < line.length) parts.push(line.slice(last));
return (
{parts}
{lineIdx < lines.length - 1 && }
);
});
};
const QUICK_ITEMS = [
{ id: 'problem', Icon: IAlert, title: 'Problème avec mon voyage', sub: 'Retard, annulation, info trafic' },
{ id: 'modify', Icon: IEdit, title: 'Modifier ma réservation', sub: 'Date, nom, point relais' },
{ id: 'claim', Icon: IFile, title: 'Faire une réclamation', sub: 'Bagage, service, remboursement' },
{ id: 'help', Icon: IHelp, title: 'Question générale', sub: 'Tarifs, conditions, destinations' },
];
// Le titre est envoyé au backend comme message ; on traduit uniquement l'affichage.
const Msg = {
Bot: ({ children, time, tool }) => (
{t('Assistant')}
{tool && <>· {tool} >}
{time && <>· {time} >}
{children}
),
User: ({ children, time }) => (
{time && <>{time} · >}
{t('Vous')}
{children}
),
Thinking: () => (
{t('Assistant')}
·
{t('écrit…')}
),
};
const QuickRepliesInt = ({ onPick }) => (
{QUICK_ITEMS.map(({ id, Icon, title, sub }) => (
onPick(title)}>
{t(title)}
{t(sub)}
))}
);
const ChipReplies = ({ items, onPick }) => (
{items.map((label, i) => (
onPick(label)}>
{label}
))}
);
/* Cartes de résultats de recherche affichées sous une réponse bot.
Chips de filtre transport (client-side) + cartes compactes cliquables → page réservation. */
const RESULT_FILTERS = [
{ key: 'all', label: 'Tous', type: null },
{ key: 'train', label: 'Train', type: 'train' },
{ key: 'avion', label: 'Avion', type: 'avion' },
{ key: 'bus', label: 'Bus', type: 'bus' },
{ key: 'bateau', label: 'Bateau', type: 'bateau' },
];
const ResultCards = ({ results, onSelect }) => {
const [filter, setFilter] = React.useState('all');
const active = RESULT_FILTERS.find((f) => f.key === filter) || RESULT_FILTERS[0];
const shown = active.type ? results.filter((it) => it.type === active.type) : results;
const fmtDur = (it) => {
const dep = new Date(it.date_depart);
const arr = new Date(it.date_arrivee);
const mins = Math.round((arr - dep) / 60000);
if (isNaN(mins) || mins < 0) return '';
return `${Math.floor(mins / 60)} h ${String(mins % 60).padStart(2, '0')}`;
};
const fmtTime = (s) => {
const d = new Date(s);
return isNaN(d) ? '' : d.toLocaleTimeString(window.VA_I18N.locale(), { hour: '2-digit', minute: '2-digit' });
};
const fmtDate = (s) => {
const d = new Date(s);
return isNaN(d) ? '' : d.toLocaleDateString(window.VA_I18N.locale(), { weekday: 'short', day: 'numeric', month: 'short' });
};
const modeLabel = { train: 'Train', avion: 'Avion', bus: 'Bus', bateau: 'Bateau' };
return (
{RESULT_FILTERS.map((f) => (
setFilter(f.key)}
>
{t(f.label)}
))}
{shown.length === 0 ? (
{t('Aucun résultat pour ce filtre.')}
) : shown.map((it) => (
onSelect(it)}
style={{
textAlign: 'left', cursor: 'pointer',
background: 'var(--va-surface)', border: '1px solid var(--va-border)',
borderRadius: 12, padding: '12px 14px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
transition: 'border-color 120ms ease, box-shadow 120ms ease',
}}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'var(--va-accent)'; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'var(--va-border)'; }}
>
{it.compagnie}
{t(modeLabel[it.type] || 'Train')}
{it.depart} → {it.arrivee}
{fmtDate(it.date_depart)} · {fmtTime(it.date_depart)} · {fmtDur(it)}
{Math.round(it.prix)} €
{t('Réserver')}
))}
);
};
const ChatHistorySidebar = ({ isAuth, sessions, currentToken, onLoadSession, onRequestDelete, onLogin }) => (
{!isAuth ? (
{t('Connectez-vous pour retrouver l’historique de vos conversations sur tous vos appareils.')}
{t('Se connecter')}
) : sessions.length === 0 ? (
{t('Aucune conversation pour l’instant.')}
) : (
{sessions.map((s) => (
onLoadSession(s.session_token)}
>
{s.title || t('Conversation')}
{s.message_count > 1 ? t('{count} messages', { count: s.message_count }) : t('{count} message', { count: s.message_count })}
{ e.stopPropagation(); onRequestDelete(s); }}
>
))}
)}
);
const ChatHeadInt = ({ onEnd, onToggleSidebar, sidebarOpen, canEnd }) => (
);
/* Composant : étoiles cliquables (1 à 5) */
const StarRating = ({ value, onChange }) => (
{[1, 2, 3, 4, 5].map((n) => (
onChange(n)}
aria-label={n > 1 ? t('{n} étoiles', { n }) : t('{n} étoile', { n })}
style={{
background: 'transparent', border: 'none', cursor: 'pointer',
padding: 4, fontSize: 32, lineHeight: 1,
color: n <= value ? 'var(--va-accent)' : 'var(--va-border)',
transition: 'color 120ms ease',
}}
>
★
))}
);
/* Modale d'inactivité — propose de continuer ou de mettre fin */
const InactivityModal = ({ onStay, onEnd }) => (
{t('Êtes-vous toujours là ?')}
{t('Aucun message depuis quelques minutes. Souhaitez-vous mettre fin à cette conversation ? Votre échange sera conservé et vous pourrez nous laisser une note.')}
{t('Continuer la conversation')}
{t('Mettre fin')}
);
/* Modale de fin — étoiles + commentaire */
const RatingModal = ({ onSubmit, onClose, submitting }) => {
const [rating, setRating] = React.useState(0);
const [feedback, setFeedback] = React.useState('');
return (
{t('Comment s’est passée votre expérience ?')}
{t('Votre avis nous aide à améliorer l’assistant pour les prochains voyageurs.')}
{rating === 0 ? t('Sélectionnez une note') :
rating === 5 ? t('Excellent — merci !') :
rating === 4 ? t('Très bien') :
rating === 3 ? t('Correct') :
rating === 2 ? t('Peut mieux faire') :
t('Décevant')}
);
};
/* Modale de confirmation de suppression d'une conversation */
const ConfirmDeleteModal = ({ session, onConfirm, onCancel, submitting }) => (
{ if (e.target === e.currentTarget && !submitting) onCancel(); }}
>
{t('Supprimer définitivement cette conversation ?')}
{session && session.title ? (
<>{t('L’échange')} « {session.title} » {t('sera retiré de votre historique.')}>
) : (
<>{t('Cette conversation sera retirée de votre historique.')}>
)}
{t('Cette action est irréversible.')}
{t('Annuler')}
{submitting ? t('Suppression…') : t('Supprimer')}
);
const VA_CHAT_LS_KEY = 'va_chat_session_token';
const InteractiveChat = ({ go, ctx, auth, onRequestLogin }) => {
const [sessionToken, setSessionToken] = React.useState(null);
const [messages, setMessages] = React.useState([]);
const [quickReplies, setQuickReplies] = React.useState([]);
const [input, setInput] = React.useState('');
const [thinking, setThinking] = React.useState(false);
const [errorMsg, setErrorMsg] = React.useState(null);
const [sessions, setSessions] = React.useState([]);
// Sidebar fermée par défaut sur mobile : sinon la colonne 280px écrase le chat
// (la zone de saisie passait hors écran → impression que « le chatbot ne réagit plus »).
const [sidebarOpen, setSidebarOpen] = React.useState(
() => typeof window === 'undefined' || window.innerWidth > 860
);
const [showInactivity, setShowInactivity] = React.useState(false);
const [showRating, setShowRating] = React.useState(false);
const [submittingRating, setSubmittingRating] = React.useState(false);
const [pendingDelete, setPendingDelete] = React.useState(null);
const [deletingSession, setDeletingSession] = React.useState(false);
const [listening, setListening] = React.useState(false);
const [speakEnabled, setSpeakEnabled] = React.useState(() => {
try { return localStorage.getItem('va.speak') === '1'; } catch { return false; }
});
const scrollRef = React.useRef(null);
const inactivityRef = React.useRef(null);
const recognitionRef = React.useRef(null);
const fileInputRef = React.useRef(null);
// Synthèse vocale d'un texte (lit à voix haute la dernière réponse bot)
const speak = React.useCallback((text) => {
if (!speakEnabled || !window.speechSynthesis || !text) return;
try {
window.speechSynthesis.cancel();
const cleaned = String(text).replace(/\*\*/g, '').replace(/`/g, '').slice(0, 500);
const u = new SpeechSynthesisUtterance(cleaned);
u.lang = 'fr-FR';
u.rate = 1.0;
u.pitch = 1.0;
window.speechSynthesis.speak(u);
} catch {}
}, [speakEnabled]);
React.useEffect(() => {
try { localStorage.setItem('va.speak', speakEnabled ? '1' : '0'); } catch {}
if (!speakEnabled && window.speechSynthesis) window.speechSynthesis.cancel();
}, [speakEnabled]);
// Démarre/arrête la reconnaissance vocale (Web Speech API)
const toggleListening = () => {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) {
setErrorMsg(t('Reconnaissance vocale non supportée par ce navigateur. Utilisez Chrome.'));
return;
}
if (listening && recognitionRef.current) {
try { recognitionRef.current.stop(); } catch {}
return;
}
const rec = new SR();
rec.lang = 'fr-FR';
rec.interimResults = true;
rec.continuous = false;
rec.maxAlternatives = 1;
rec.onstart = () => setListening(true);
rec.onresult = (e) => {
let interim = '';
let final = '';
for (let i = e.resultIndex; i < e.results.length; i++) {
const r = e.results[i];
if (r.isFinal) final += r[0].transcript;
else interim += r[0].transcript;
}
setInput(final || interim);
};
rec.onerror = (e) => {
setListening(false);
if (e.error !== 'no-speech' && e.error !== 'aborted') {
setErrorMsg(t('Reconnaissance vocale : {error}', { error: e.error }));
}
};
rec.onend = () => setListening(false);
recognitionRef.current = rec;
try { rec.start(); } catch (err) { setListening(false); }
};
const now = () => {
const d = new Date();
return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
};
React.useEffect(() => {
if (scrollRef.current && messages.length > 0) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, thinking]);
const refreshSessions = React.useCallback(async () => {
if (!auth || !auth.isAuth) { setSessions([]); return; }
try {
const list = await window.VA_API.chatSessions();
setSessions(list || []);
} catch { setSessions([]); }
}, [auth && auth.isAuth]);
const startSession = React.useCallback(async (forceNew = false) => {
setErrorMsg(null);
try {
// Reprise : on tente d'abord de rouvrir la session sauvegardée localement
let existing = null;
if (!forceNew) {
try { existing = localStorage.getItem(VA_CHAT_LS_KEY); } catch { /* ignore */ }
}
if (existing) {
try {
const hist = await window.VA_API.chatHistory(existing);
setSessionToken(existing);
setMessages(hist.map((m) => ({
kind: m.role === 'user' ? 'user' : 'bot',
content: m.content,
time: new Date(m.created_at).toLocaleTimeString(window.VA_I18N.locale(), { hour: '2-digit', minute: '2-digit' }),
tool: labelForTool(m.tool_used),
})));
setQuickReplies([]);
refreshSessions();
return;
} catch {
// session expirée / supprimée côté serveur : on en crée une neuve
try { localStorage.removeItem(VA_CHAT_LS_KEY); } catch {}
}
}
const res = await window.VA_API.chatStart();
setSessionToken(res.session_token);
try { localStorage.setItem(VA_CHAT_LS_KEY, res.session_token); } catch {}
setMessages([]);
setQuickReplies(res.quick_replies || []);
refreshSessions();
} catch (e) {
setErrorMsg(t('Impossible de joindre l’assistant ({error}). Le backend tourne-t-il sur {url} ?', { error: e.message, url: window.VA_CONFIG.API_BASE }));
}
}, [refreshSessions]);
React.useEffect(() => { startSession(); }, [startSession]);
React.useEffect(() => { refreshSessions(); }, [refreshSessions]);
// Quand l'utilisateur se connecte : on relie la session anonyme courante à son compte,
// puis on rafraîchit l'historique pour qu'elle apparaisse.
const prevIsAuth = React.useRef(false);
React.useEffect(() => {
const nowAuth = !!(auth && auth.isAuth);
if (nowAuth && !prevIsAuth.current && sessionToken) {
window.VA_API.chatStart(sessionToken).catch(() => {}).finally(() => refreshSessions());
}
prevIsAuth.current = nowAuth;
}, [auth && auth.isAuth, sessionToken]);
// Timer d'inactivité : 2 minutes après le dernier message → modale "êtes-vous toujours là ?"
const resetInactivity = React.useCallback(() => {
if (inactivityRef.current) clearTimeout(inactivityRef.current);
if (showInactivity || showRating) return;
inactivityRef.current = setTimeout(() => setShowInactivity(true), 2 * 60 * 1000);
}, [showInactivity, showRating]);
React.useEffect(() => {
if (messages.length === 0) return;
resetInactivity();
return () => { if (inactivityRef.current) clearTimeout(inactivityRef.current); };
}, [messages, resetInactivity]);
const loadSession = async (tok) => {
if (tok === sessionToken) return;
try {
const hist = await window.VA_API.chatHistory(tok);
setSessionToken(tok);
try { localStorage.setItem(VA_CHAT_LS_KEY, tok); } catch {}
setMessages(hist.map((m) => ({
kind: m.role === 'user' ? 'user' : 'bot',
content: m.content,
time: new Date(m.created_at).toLocaleTimeString(window.VA_I18N.locale(), { hour: '2-digit', minute: '2-digit' }),
tool: labelForTool(m.tool_used),
})));
setQuickReplies([]);
} catch (e) {
setErrorMsg(t('Impossible de charger la conversation : {error}', { error: e.message }));
}
};
const send = async (text) => {
if (!sessionToken || !text.trim()) return;
const userText = text.trim();
setMessages((m) => [...m, { kind: 'user', content: userText, time: now() }]);
setQuickReplies([]);
setThinking(true);
try {
const res = await window.VA_API.chatMessage(sessionToken, userText);
const tool = (res.tools_used && res.tools_used[0]) || null;
setMessages((m) => [...m, {
kind: 'bot', content: res.answer, time: now(),
tool: labelForTool(tool),
results: Array.isArray(res.results) ? res.results : null,
}]);
setQuickReplies(res.quick_replies || []);
refreshSessions();
speak(res.answer);
} catch (e) {
setErrorMsg(t('Erreur : {error}', { error: e.message }));
} finally {
setThinking(false);
}
};
const onSendInput = () => {
const txt = input.trim();
if (!txt) return;
setInput('');
send(txt);
};
// Ouvre la page de réservation pour un trajet (depuis une carte de résultat dans le chat).
// Construit la route object via window.enrichTrajet (même mapping que ResultsPage).
const openTrajet = (item) => {
if (!go) return;
const route = window.enrichTrajet ? window.enrichTrajet(item) : item;
go('booking', { route, pax: { adultes: 1, enfants: 0, bebes: 0 }, classId: null });
};
// Clic sur le trombone → ouvre le sélecteur de fichier PDF
const onPickFile = async (e) => {
const file = e.target.files && e.target.files[0];
e.target.value = ''; // permet de re-sélectionner le même fichier
if (!file || !sessionToken) return;
setMessages((m) => [...m, { kind: 'user', content: `📎 ${file.name}`, time: now() }]);
setThinking(true);
try {
const res = await window.VA_API.extractBillet(file);
setThinking(false);
if (res && res.found && res.numero_billet) {
send(t('Voici mon billet : {num}', { num: res.numero_billet }));
} else {
setMessages((m) => [...m, {
kind: 'bot', time: now(),
content: t('Ce PDF ne semble pas être un billet Voyage Assistant. Vérifiez que c’est bien le PDF reçu par e-mail.'),
}]);
}
} catch (err) {
setThinking(false);
setErrorMsg(t('Impossible de lire ce billet : {error}', { error: err.message }));
}
};
const reset = (forceNew = true) => {
setMessages([]);
setQuickReplies([]);
setInput('');
setSessionToken(null);
try { localStorage.removeItem(VA_CHAT_LS_KEY); } catch {}
startSession(forceNew);
};
const submitRating = async (rating, feedback) => {
if (!sessionToken) { setShowRating(false); reset(); return; }
setSubmittingRating(true);
try {
await window.VA_API.endSession(sessionToken, rating, feedback);
} catch (e) {
setErrorMsg(t('Impossible d’enregistrer votre note : {error}', { error: e.message }));
} finally {
setSubmittingRating(false);
setShowRating(false);
setShowInactivity(false);
reset();
}
};
const isInitial = messages.length === 0;
const canEnd = !!sessionToken && messages.length > 0;
return (
{sidebarOpen && (
setSidebarOpen(false)} />
)}
{sidebarOpen && (
setPendingDelete(s)}
onLogin={() => onRequestLogin && onRequestLogin('login')}
/>
)}
{showInactivity && (
{ setShowInactivity(false); resetInactivity(); }}
onEnd={() => { setShowInactivity(false); setShowRating(true); }}
/>
)}
{showRating && (
{ if (!submittingRating) { setShowRating(false); setShowInactivity(false); } }}
/>
)}
{pendingDelete && (
setPendingDelete(null)}
onConfirm={async () => {
setDeletingSession(true);
try {
await window.VA_API.deleteSession(pendingDelete.session_token);
if (pendingDelete.session_token === sessionToken) reset();
refreshSessions();
setPendingDelete(null);
} catch (e) {
setErrorMsg(t('Suppression impossible : {error}', { error: e.message }));
} finally {
setDeletingSession(false);
}
}}
/>
)}
setShowRating(true)}
onToggleSidebar={() => setSidebarOpen((v) => !v)}
sidebarOpen={sidebarOpen}
canEnd={canEnd}
/>
{errorMsg && (
{errorMsg}
)}
{isInitial ? (
<>
{auth && auth.user
? t('Bonjour, {name}, je suis l’assistant Voyage.', { name: auth.user.prenom })
: t('Bonjour, je suis l’assistant Voyage.')}
{t('Je peux consulter vos billets, modifier vos réservations et lancer des réclamations à votre place — après une rapide vérification d’identité. Posez-moi aussi vos questions sur les bagages, tarifs, destinations.')}
{t('Vos informations restent privées. Connexion chiffrée (HTTPS).')}
>
) : (
<>
{messages.map((m, i) => (
m.kind === 'user'
?
{m.content}
: (
{renderRichText(m.content)}
{m.results && m.results.length > 0 && (
)}
)
))}
{thinking &&
}
{!thinking && quickReplies.length > 0 && (
)}
>
)}
{t('L’assistant vérifie votre identité avant tout accès à vos billets. Connexion chiffrée (HTTPS).')}
);
};
Object.assign(window, { InteractiveChat });