// AGENTFORGE-IA — Agente de voz + chat de soporte (disponible en toda la app) // Limpia el texto de un mensaje antes de mostrarlo const _cleanMsg = (raw) => { if (!raw) return ''; // Quita el token BOOKING let s = raw.replace(/\s*BOOKING:\{[^}]*\}/g, '').trim(); // Si lo que queda es JSON puro, lo descartamos o simplificamos if (s.startsWith('{') && s.endsWith('}')) { try { const obj = JSON.parse(s); const note = obj.note || obj.message || obj.mode_name || obj.source || ''; return note ? String(note) : ''; } catch (_) { return ''; } } return s; }; // Web Speech API helpers const _SR = window.SpeechRecognition || window.webkitSpeechRecognition || null; const _SS = window.speechSynthesis || null; // Selecciona la voz más natural disponible: prioriza voces neurales/online const _pickVoice = (lang) => { const voices = _SS ? _SS.getVoices() : []; const langCode = lang.substring(0, 2); const candidates = voices.filter(v => v.lang.startsWith(langCode)); // 1. Voz online/neural (Google, Microsoft Neural) — la más humana const online = candidates.find(v => !v.localService && ( v.name.includes('Google') || v.name.includes('Neural') || v.name.includes('Online') )); if (online) return online; // 2. Cualquier voz online del idioma const anyOnline = candidates.find(v => !v.localService); if (anyOnline) return anyOnline; // 3. Voz local del idioma como último recurso return candidates[0] || null; }; const _speak = (text, lang = 'es-ES') => { if (!_SS) return; _SS.cancel(); const utt = new SpeechSynthesisUtterance(text); utt.lang = lang; utt.rate = 0.91; // ligeramente más lento = más natural utt.pitch = 0.93; // tono algo más suave y cálido utt.volume = 1; // Las voces pueden no estar listas en el primer render; intentamos en onvoiceschanged si es necesario const voice = _pickVoice(lang); if (voice) utt.voice = voice; else if (_SS.onvoiceschanged !== undefined) { _SS.onvoiceschanged = () => { const v = _pickVoice(lang); if (v) utt.voice = v; }; } _SS.speak(utt); }; const SupportChat = ({ currentUser, overlayOpen = false }) => { if (currentUser?.is_admin) return null; const [uiLang, setUiLang] = React.useState(() => { try { return localStorage.getItem('af_ui_lang') || window.afUiLang || 'es'; } catch { return 'es'; } }); const [open, setOpen] = React.useState(false); const [mode, setMode] = React.useState('chat'); // 'chat' | 'voice' const [forcePublicForm, setForcePublicForm] = React.useState(false); const [loading, setLoading] = React.useState(false); const [messages, setMessages] = React.useState([]); const [text, setText] = React.useState(''); const [listening, setListening] = React.useState(false); const [voiceStatus, setVoiceStatus] = React.useState(''); // 'listening' | 'processing' | 'speaking' | '' const [bookingWidget, setBookingWidget] = React.useState(null); const [bookingForm, setBookingForm] = React.useState({ meeting_type: 'demo', preferred_date: '', preferred_time: '10:00', message: '' }); const [bookingStatus, setBookingStatus] = React.useState(''); const [userInfo, setUserInfo] = React.useState({ name: '', email: '' }); const [publicForm, setPublicForm] = React.useState({ full_name: '', email: '', reason: '', company: '', preferred_datetime: '', message: '', }); const srRef = React.useRef(null); const bodyRef = React.useRef(null); const token = sessionStorage.getItem('af_token') || ''; const canUse = !!currentUser && !forcePublicForm; const srAvailable = !!_SR; const tomorrowStr = () => { const d = new Date(); d.setDate(d.getDate() + 1); return d.toISOString().slice(0, 10); }; const openBooking = (bd) => { setBookingWidget(bd); setBookingForm({ meeting_type: bd.meeting_type || 'demo', preferred_date: tomorrowStr(), preferred_time: '10:00', message: bd.reason || bd.message || '' }); setBookingStatus(''); }; const scrollBottom = () => { setTimeout(() => { if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; }, 50); }; const loadMessages = React.useCallback(async () => { if (!canUse) return; setMessages([]); setLoading(true); try { const res = await fetch('/api/v1/support/messages', { headers: { Authorization: 'Bearer ' + (sessionStorage.getItem('af_token') || '') }, }); if (!res.ok) return; const data = await res.json(); const clean = (Array.isArray(data) ? data : []).filter(m => _cleanMsg(m.message)); setMessages(clean); scrollBottom(); } finally { setLoading(false); } }, [canUse]); React.useEffect(() => { if (!canUse || !token) return; let fallbackName = '', fallbackEmail = ''; try { const p = JSON.parse(atob(token.split('.')[1])); fallbackEmail = p.sub || p.email || ''; if (fallbackEmail.includes('@')) { const raw = fallbackEmail.split('@')[0]; fallbackName = raw.charAt(0).toUpperCase() + raw.slice(1).replace(/[._-]/g, ' '); } } catch (_) {} fetch('/api/v1/customers/me', { headers: { Authorization: 'Bearer ' + token } }) .then(r => r.ok ? r.json() : null) .then(me => setUserInfo({ name: ((me?.full_name || me?.fullName || fallbackName) + '').trim(), email: me?.email || fallbackEmail })) .catch(() => setUserInfo({ name: fallbackName, email: fallbackEmail })); }, [canUse, token]); React.useEffect(() => { if (open) setMessages([]); }, [open]); React.useEffect(() => { const h = (e) => { setForcePublicForm(!!e?.detail?.public); setOpen(true); }; window.addEventListener('af:contact:open', h); return () => window.removeEventListener('af:contact:open', h); }, []); React.useEffect(() => { if (overlayOpen && open) setOpen(false); }, [overlayOpen, open]); React.useEffect(() => { const h = (e) => setUiLang((e && e.detail) || window.afUiLang || 'es'); window.addEventListener('af:lang', h); return () => window.removeEventListener('af:lang', h); }, []); // Stop voice when closing React.useEffect(() => { if (!open) { srRef.current?.abort(); _SS?.cancel(); setListening(false); setVoiceStatus(''); } }, [open]); const sendText = async (msg) => { const clean = msg.trim(); if (!clean || !canUse) return; setText(''); setLoading(true); if (mode === 'voice') setVoiceStatus('processing'); try { const res = await fetch('/api/v1/support/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (sessionStorage.getItem('af_token') || '') }, body: JSON.stringify({ message: clean }), }); if (!res.ok) return; const data = await res.json(); const msgs = (Array.isArray(data) ? data : []).filter(m => _cleanMsg(m.message)); setMessages(msgs); scrollBottom(); const last = msgs[msgs.length - 1]; if (last && last.role === 'support') { const visibleText = _cleanMsg(last.message); // Voz: leer respuesta en voz alta if (mode === 'voice' && visibleText) { setVoiceStatus('speaking'); _speak(visibleText, uiLang === 'en' ? 'en-US' : 'es-ES'); if (_SS) { const check = setInterval(() => { if (!_SS.speaking) { setVoiceStatus(''); clearInterval(check); } }, 300); } else { setVoiceStatus(''); } } // Booking intent — abre widget inline en el chat if (last.message.includes('BOOKING:')) { try { const match = last.message.match(/BOOKING:(\{[^}]+\})/); openBooking(match ? JSON.parse(match[1]) : {}); } catch (_) {} } } } finally { setLoading(false); if (mode !== 'voice') setVoiceStatus(''); } }; const send = () => sendText(text); const submitBooking = async () => { setBookingStatus('submitting'); try { const res = await fetch('/api/v1/booking/public', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ full_name: userInfo.name, email: userInfo.email, meeting_type: bookingForm.meeting_type, preferred_date: bookingForm.preferred_date, preferred_time: bookingForm.preferred_time, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Madrid', duration_minutes: { demo: 30, discovery: 30, support: 60, consulting: 60, qbr: 90, other: 30 }[bookingForm.meeting_type] || 30, message: bookingForm.message || '', }), }); if (!res.ok) throw new Error(); setBookingStatus('done'); setTimeout(() => { setBookingWidget(null); setBookingStatus(''); }, 4000); } catch (_) { setBookingStatus('error'); } }; // Iniciar/detener escucha de voz const toggleListen = () => { if (!_SR) return; if (listening) { srRef.current?.stop(); setListening(false); setVoiceStatus(''); return; } const sr = new _SR(); srRef.current = sr; sr.lang = uiLang === 'en' ? 'en-US' : 'es-ES'; sr.interimResults = false; sr.maxAlternatives = 1; sr.onstart = () => { setListening(true); setVoiceStatus('listening'); }; sr.onresult = (e) => { const transcript = e.results[0][0].transcript; setListening(false); sendText(transcript); }; sr.onerror = () => { setListening(false); setVoiceStatus(''); }; sr.onend = () => { setListening(false); }; sr.start(); }; const sendPublicContact = async () => { const payload = { full_name: publicForm.full_name.trim(), email: publicForm.email.trim(), reason: publicForm.reason.trim(), company: publicForm.company.trim(), preferred_datetime: publicForm.preferred_datetime.trim(), message: publicForm.message.trim(), }; if (!payload.full_name || !payload.email || !payload.reason || !payload.message) return; setLoading(true); try { const res = await fetch('/api/v1/support/public-contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) return; setPublicForm({ full_name: '', email: '', reason: '', company: '', preferred_datetime: '', message: '' }); window.afNotify && window.afNotify('Solicitud enviada. Te contactaremos por email.'); setOpen(false); } finally { setLoading(false); } }; const voiceStatusLabel = { listening: '🎙 Escuchando…', processing: '⏳ Procesando…', speaking: '🔊 Respondiendo…' }; return ( <> {!overlayOpen && (
Envíanos tu consulta y te respondemos desde agentforge@agentforge-ia.pro.
setPublicForm((p) => ({ ...p, full_name: e.target.value }))} /> setPublicForm((p) => ({ ...p, email: e.target.value }))} /> setPublicForm((p) => ({ ...p, reason: e.target.value }))} /> setPublicForm((p) => ({ ...p, company: e.target.value }))} /> setPublicForm((p) => ({ ...p, preferred_datetime: e.target.value }))} />