/* 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 }) => ( ))}
); const ChipReplies = ({ items, onPick }) => (
{items.map((label, i) => ( ))}
); /* 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) => ( ))}
{shown.length === 0 ? (
{t('Aucun résultat pour ce filtre.')}
) : shown.map((it) => ( ))}
); }; const ChatHistorySidebar = ({ isAuth, sessions, currentToken, onLoadSession, onRequestDelete, onLogin }) => ( ); const ChatHeadInt = ({ onEnd, onToggleSidebar, sidebarOpen, canEnd }) => (
{t('Assistant')}
{t('En ligne · répond en 2 s')}
); /* Composant : étoiles cliquables (1 à 5) */ const StarRating = ({ value, onChange }) => (
{[1, 2, 3, 4, 5].map((n) => ( ))}
); /* 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.')}
); /* 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')}