// vita-app.jsx — flow orchestration: splash → choose Vita → conversation → clinical history const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "persona": "calida", "waveStyle": "barras", "dark": true, "tts": true, }/*EDITMODE-END*/; // ── Screen background wrapper ─────────────────────────────────── function Stage({ t, children, pad = true }) { // Antes: position absolute sobre el lienzo fijo de 402x874 del marco de // iPhone. Ahora fluye: el alto lo decide el contenido y el shell se encarga // del scroll. `pad` se conserva por compatibilidad con las llamadas actuales. void pad; return (
{children}
); } // ── 1. Splash ─────────────────────────────────────────────────── function Splash({ t, persona, waveStyle, onStart }) { const p = t.paleta; // El contenido se agrupa en una tarjeta centrada con ancho acotado. Antes // se estiraba a todo el ancho de la ventana y el botón quedaba pegado al // texto: en escritorio parecía un móvil deformado, no una pantalla web. return (
Conoce a

Vita

Tu médica de confianza, por voz. Te acompaña, recuerda tu historia y cuida tu longevidad — 24 horas, todos los días.

{[['mic', 'Hablas, no escribes'], ['doc', 'Historia clínica viva'], ['shield', 'Datos cifrados']].map(([ic, txt]) => ( {Icon[ic]({ s: 15, c: p.primary })} {txt} ))}
Empezar a conocernos
{Icon.shield({ s: 14, c: p.textMuted })} Tus datos son tuyos. VITA solo los custodia.
); } function BigButton({ t, onClick, children, secondary }) { return ( ); } // ── 2. Choose your Vita ───────────────────────────────────────── // Frase de muestra por personalidad. Cada una está escrita en el registro // que le corresponde, así el paciente oye la diferencia real de tono, no // sólo la de los diales de voz. const MUESTRA_PERSONA = { calida: 'Hola, soy Vita. Me alegra mucho conocerte. Cuéntame, ¿cómo te has sentido estos días?', serena: 'Hola, soy Vita. Estoy aquí contigo. Respira tranquilo, y cuando quieras me cuentas cómo te sientes.', coach: '¡Hola! Soy Vita. Vamos a cuidar tu salud juntos. Cuéntame cómo te has sentido y arrancamos.', profesional: 'Hola, soy Vita, tu asistente de salud. Voy a hacerte algunas preguntas para conocer tu estado de salud. ¿Cómo te has sentido?', cercano: 'Hola, soy Vita. Estoy aquí para acompañarte en el cuidado de tu salud. Cuéntame, ¿cómo te has sentido estos días?', clinica: 'Hola, soy Vita, tu asistente de salud. Empecemos con calma: cuéntame cómo te has sentido últimamente.', }; // Las personas de la voz principal se ofrecen siempre; las demás, sólo si el // servidor tiene su voz configurada (VitaCore las consulta a /api/tts). const PERSONAS_BASE = ['calida', 'serena', 'coach']; function usePersonasDisponibles() { const [ids, setIds] = React.useState(PERSONAS_BASE); React.useEffect(() => { const nucleo = window.VitaCore; if (!nucleo || !nucleo.voz.personasDisponibles) return; let vigente = true; nucleo.voz.personasDisponibles() .then((lista) => { if (vigente && Array.isArray(lista) && lista.length) setIds(lista); }) .catch(() => {}); return () => { vigente = false; }; }, []); return ids; } function ChooseVita({ t, current, waveStyle, onPick, onContinue, onBack, voice, tts }) { const p = t.paleta; const [sonando, setSonando] = React.useState(null); const disponibles = usePersonasDisponibles(); // La persona elegida se muestra siempre, aunque su voz ya no esté configurada. const personas = Object.values(VITA_PERSONAS).filter((x) => disponibles.includes(x.id) || x.id === current.id); // Al elegir una personalidad se reproduce su muestra: la diferencia entre // Cálida, Serena y Coach está en la VOZ, así que enseñarla sólo por escrito // deja al paciente eligiendo a ciegas. const elegir = (personaId) => { onPick(personaId); if (!voice || !tts) return; voice.stopSpeaking(); setSonando(personaId); voice.speak( MUESTRA_PERSONA[personaId] || MUESTRA_PERSONA.calida, VITA_PERSONAS[personaId], () => setSonando((actual) => (actual === personaId ? null : actual)), ); }; React.useEffect(() => () => { if (voice) voice.stopSpeaking(); }, []); const vozNoDisponible = voice && voice.voiceState === 'no_disponible'; return (

¿Cómo quieres que te acompañe?

Elige la voz y el estilo con el que prefieres que te acompañe. Puedes cambiarlo cuando quieras.

{Icon.volume({ s: 15, c: p.primary })} {vozNoDisponible ? 'La voz no está disponible ahora mismo.' : 'Toca una opción para escucharla.'}

{personas.map((persona) => { const sel = persona.id === current.id; const activa = sonando === persona.id; return ( ); })}
Atrás { if (voice) voice.stopSpeaking(); onContinue(); }}> Continuar {Icon.chevron({ s: 17, c: t.onAccent })}
); } // ── Modo llamada ──────────────────────────────────────────────── // Envuelve VitaCore.llamada en un hook de React. Toda la lógica de turnos // vive en el núcleo (src/domain/conversation/TurnTaking.ts); aquí sólo se // refleja su estado para pintarlo. function useModoLlamada(conv) { const [activa, setActiva] = React.useState(false); const [estado, setEstado] = React.useState('inactivo'); const [parcial, setParcial] = React.useState(''); const [errorEscucha, setErrorEscucha] = React.useState(''); const nucleo = typeof window !== 'undefined' ? window.VitaCore : null; const soportado = !!(nucleo && nucleo.llamada.soportado()); // La respuesta del turno se toma por ref: si se capturara en el closure, // cada turno usaría la transcripción de la primera llamada. const turnoRef = React.useRef(conv.turnoDeLlamada); turnoRef.current = conv.turnoDeLlamada; const colgar = React.useCallback(() => { if (nucleo) nucleo.llamada.terminar(); setActiva(false); setEstado('inactivo'); setParcial(''); }, []); const llamar = React.useCallback((personaId) => { if (!nucleo || !soportado) return false; const ok = nucleo.llamada.iniciar(personaId || 'calida', { alCambiarEstado: (nuevo) => setEstado(nuevo), alTranscribir: (texto) => setParcial(texto), alTurnoDelPaciente: async (texto) => { setParcial(''); return turnoRef.current(texto); }, alError: (e) => setErrorEscucha((e && e.message) || 'Fallo en el micrófono'), }); if (ok) { setActiva(true); setErrorEscucha(''); } return ok; }, [soportado]); const pausar = React.useCallback(() => { if (nucleo) nucleo.llamada.pausar(); }, []); const reanudar = React.useCallback(() => { if (nucleo) nucleo.llamada.reanudar(); }, []); // Colgar al desmontar: dejar el micrófono abierto tras salir de la // pantalla sería un fallo de privacidad, no sólo un descuido técnico. React.useEffect(() => colgar, []); const etiqueta = nucleo ? nucleo.llamada.etiqueta(estado) : ''; const turnoDelPaciente = nucleo ? nucleo.llamada.esTurnoDelPaciente(estado) : false; return { activa, estado, parcial, soportado, llamar, colgar, pausar, reanudar, etiqueta, turnoDelPaciente, errorEscucha, limpiarError: () => setErrorEscucha('') }; } // ── 3. Conversation ───────────────────────────────────────────── function Conversation({ t, persona, waveStyle, tts, conv, returning, onToggleTheme, onToggleTTS, onShowProfile, onBack }) { const p = t.paleta; const voice = conv.voice; const scrollRef = React.useRef(null); const [text, setText] = React.useState(''); const [showDocs, setShowDocs] = React.useState(false); const DOC_TYPES = [ { label: 'Exámenes de laboratorio', icon: 'pulse' }, { label: 'Fórmula médica', icon: 'doc' }, { label: 'Mi historia clínica', icon: 'doc' }, { label: 'Carné de vacunas', icon: 'shield' }, { label: 'Foto / imagen médica', icon: 'plus' }, ]; const llamada = useModoLlamada(conv); // En modo llamada la fase la manda la máquina de turnos; fuera de él, los // indicadores sueltos de siempre. const phase = llamada.activa ? ({ escuchando: 'listening', esperando: 'listening', procesando: 'thinking', hablando: 'speaking' }[llamada.estado] || 'idle') : (voice.listening ? 'listening' : conv.loading ? 'thinking' : voice.speaking ? 'speaking' : 'idle'); React.useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [conv.messages, conv.loading]); const submit = (val) => { const v = (val != null ? val : text).trim(); if (!v || conv.loading) return; setText(''); conv.send(v); }; const micTap = () => { if (voice.speaking) { voice.stopSpeaking(); return; } if (voice.listening) { voice.stopListening(); return; } const ok = voice.startListening((final) => submit(final)); if (!ok) { const el = document.getElementById('vita-input'); el && el.focus(); } }; const profileCount = Object.keys(conv.profile).length; return ( {/* header */}
{returning && onBack ? {Icon.chevron({ s: 18, c: t.textSoft, w: 2.5 })} : }
{returning ? 'Hablar con Vita' : 'Primer triage'}
{returning ? 'Tu asistente de salud' : 'Hablado, con Vita'}
{tts ? Icon.volume({ s: 17, c: t.accent }) : Icon.volumeOff({ s: 17, c: t.textSoft })} {t.dark ? Icon.sun({ s: 17 }) : Icon.moon({ s: 17 })}
{/* waveform presence */}
{/* transcript */}
{conv.messages.map((m, i) => ( ))} {conv.loading && (
{[0,1,2].map(i => )}
)} {conv.done && !conv.loading && !returning && ( )} {!conv.done && !conv.loading && !returning && profileCount >= 6 && ( )}
{/* live profile peek */} {profileCount > 0 && (
{Object.entries(conv.profile).slice(0, 8).map(([k, v]) => ( ))}
)} {/* ── Barra de llamada ───────────────────────────────────────── Sustituye al pulsar-para-hablar. El estado es SIEMPRE visible: en una conversación por voz, no saber si te están oyendo es peor que un fallo técnico. */} {llamada.activa && (
{llamada.estado === 'hablando' ? Icon.volume({ s: 19, c: p.textMuted }) : Icon.mic({ s: 19, c: llamada.turnoDelPaciente ? '#fff' : p.textMuted })} {llamada.estado === 'escuchando' && ( )}
{llamada.estado === 'pausado' ? 'Llamada en pausa' : llamada.etiqueta}
{llamada.parcial || (llamada.turnoDelPaciente ? 'Habla cuando quieras. Me callo sola cuando termines.' : '')}
)} {/* Fallo del reconocedor: se muestra en pantalla, no sólo en consola */} {llamada.errorEscucha && (
{Icon.alert({ s: 16, c: p.danger })}
{llamada.errorEscucha}
)} {/* Invitación a la llamada, cuando no está activa */} {!llamada.activa && llamada.soportado && (
)} {/* input bar */}
{/* attach menu */} {showDocs && ( <>
setShowDocs(false)} style={{ position: 'absolute', inset: 0, zIndex: 1 }} />
Comparte tus documentos
{DOC_TYPES.map((d, i) => ( ))}
)}
setText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') submit(); }} placeholder={voice.listening ? 'Escuchando…' : 'Escribe o toca el micrófono…'} readOnly={voice.listening} style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 15.5, color: t.text, fontFamily: "'Inter', system-ui, sans-serif", minWidth: 0, }} /> {text.trim() && !voice.listening && ( )}
); } Object.assign(window, { TWEAK_DEFAULTS, Stage, Splash, BigButton, ChooseVita, Conversation });