Hook sin interfaz
Crea una interfaz de voz totalmente personalizada con el hook de React useThunderPhone
El hook useThunderPhone te brinda control total sobre la interfaz de usuario mientras ThunderPhone administra la sesión de voz, el enrutamiento de audio y el estado de conexión. Úsalo cuando quieras una interfaz totalmente personalizada -- tus propios botones, diseños, animaciones e identidad de marca -- mientras ThunderPhone se encarga de todo internamente.
Cuándo usar el hook sin interfaz
El componente predefinido ThunderPhoneWidget cubre la mayoría de los casos de uso, pero usa el hook sin interfaz cuando necesites:
- Una interfaz de llamadas completamente personalizada que coincida con el sistema de diseño de tu app
- Visualizaciones que reaccionen al audio (formas de onda, esferas, indicadores pulsantes) impulsadas por niveles de audio en tiempo real
- Flujos de llamadas personalizados, como formularios previos a la llamada, encuestas posteriores a la llamada o chat integrado junto con la voz
- Integración en una biblioteca de componentes existente (Material UI, Chakra, Radix, etc.)
Instalación
npm install @thunderphone/widgetUso básico
import { useThunderPhone } from '@thunderphone/widget'
function CustomCallButton() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
const handleClick = () => {
if (phone.state === 'connected') {
phone.disconnect()
} else {
phone.connect()
}
}
return (
<>
<button onClick={handleClick} disabled={phone.state === 'connecting'}>
{phone.state === 'connecting'
? 'Connecting...'
: phone.state === 'connected'
? 'End call'
: 'Start call'}
</button>
{phone.audio}
</>
)
}Opciones
Pasa estas opciones a useThunderPhone mediante UseThunderPhoneOptions:
| Opción | Tipo | Obligatorio | Predeterminado | Descripción |
|---|---|---|---|---|
publishableKey | string | Sí | -- | Clave de API publicable (pk_live_...). El agente se resuelve automáticamente a partir de la configuración del widget de la clave. |
apiBase | string | No | 'https://api.thunderphone.com/v1' | Anulación de la URL base de la API. |
language | string | No | -- | Anulación de idioma por sesión -- un código de idioma o configuración regional como en, es o fr-FR. Cuando no se configura, se aplica el idioma configurado del agente. |
voice | string | No | -- | Anulación de voz por sesión -- un nombre de voz como maria. Cuando no se configura, se aplica la voz configurada del agente. |
context | string | No | -- | Contexto factual de la página o el sitio por sesión que se pasa al agente. Se trunca del lado del servidor a 12,000 caracteres. |
onConnect | () => void | No | -- | Se llama cuando se conecta la sesión de voz. |
onDisconnect | () => void | No | -- | Se llama cuando finaliza la sesión. |
onError | (error) => void | No | -- | Se llama cuando ocurren errores. El error tiene los campos error (código) y message. |
ringtone | boolean | string | No | false | Reproduce un tono de llamada mientras se conecta. true para el tono de llamada predeterminado o una cadena de URL para audio personalizado. |
Valor de retorno
El hook devuelve un objeto UseThunderPhoneReturn:
| Propiedad | Tipo | Descripción |
|---|---|---|
state | 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error' | Estado actual de la conexión. |
connect | () => void | Inicia una sesión de voz. |
disconnect | () => void | Finaliza la sesión actual. |
toggleMute | () => void | Activa o desactiva el silencio del micrófono. |
isMuted | boolean | Indica si el micrófono está actualmente silenciado. |
error | string | undefined | Mensaje de error cuando el estado es 'error'. |
agentName | string | undefined | Nombre para mostrar del agente conectado. |
audioLevel | number | Obsoleto -- siempre es 0. Un marcador de posición estático que se conserva para mantener la compatibilidad con versiones anteriores; nunca se actualiza. Lee audioLevelRef.current en su lugar. |
audioLevelRef | React.RefObject<number> | Una ref mutable que contiene el nivel de audio en tiempo real (0--1) -- el más alto entre la voz del agente y el micrófono del visitante -- actualizada en cada cuadro de animación, fuera del ciclo de renderizado de React. Lee audioLevelRef.current dentro de bucles de requestAnimationFrame para obtener animaciones fluidas y sin interrupciones, o consúltala a intervalos cuando necesites el valor en el estado de React. |
audio | ReactNode | Elemento invisible que gestiona la conexión de audio -- debe renderizarse. |
IU reactiva al audio
La referencia audioLevelRef te proporciona niveles de audio a velocidad de fotogramas sin activar nuevas renderizaciones de React, por lo que es ideal para controlar visualizaciones fluidas de formas de onda, orbes pulsantes o cualquier animación vinculada a la conversación. El nivel refleja cuál es más fuerte: la voz del agente o el micrófono del visitante.
Ejemplo de forma de onda
import { useRef, useEffect } from 'react'
import { useThunderPhone } from '@thunderphone/widget'
function WaveformCall() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
if (phone.state !== 'connected') return
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')!
let animId: number
const draw = () => {
const level = phone.audioLevelRef.current ?? 0
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Draw bars that react to audio level
const barCount = 24
const barWidth = canvas.width / barCount
for (let i = 0; i < barCount; i++) {
const distance = Math.abs(i - barCount / 2) / (barCount / 2)
const height = level * canvas.height * (1 - distance * 0.6)
const y = (canvas.height - height) / 2
ctx.fillStyle = '#0ea5e9'
ctx.fillRect(i * barWidth + 1, y, barWidth - 2, height)
}
animId = requestAnimationFrame(draw)
}
animId = requestAnimationFrame(draw)
return () => cancelAnimationFrame(animId)
}, [phone.state, phone.audioLevelRef])
return (
<div>
{phone.state === 'connected' && (
<canvas ref={canvasRef} width={240} height={80} />
)}
<button
onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
disabled={phone.state === 'connecting'}
>
{phone.state === 'connected' ? 'End call' : 'Start call'}
</button>
{phone.audio}
</div>
)
}Ejemplo de orbe pulsante
import { useRef, useEffect } from 'react'
import { useThunderPhone } from '@thunderphone/widget'
function PulsingOrb() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
const orbRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (phone.state !== 'connected') return
let animId: number
const animate = () => {
const level = phone.audioLevelRef.current ?? 0
if (orbRef.current) {
const scale = 1 + level * 0.5
orbRef.current.style.transform = `scale(${scale})`
orbRef.current.style.opacity = `${0.6 + level * 0.4}`
}
animId = requestAnimationFrame(animate)
}
animId = requestAnimationFrame(animate)
return () => cancelAnimationFrame(animId)
}, [phone.state, phone.audioLevelRef])
return (
<div style={{ textAlign: 'center' }}>
<div
ref={orbRef}
style={{
width: 80,
height: 80,
borderRadius: '50%',
background: '#0ea5e9',
margin: '20px auto',
transition: 'transform 0.05s ease-out',
}}
/>
<button
onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
disabled={phone.state === 'connecting'}
>
{phone.state === 'connected' ? 'End call' : 'Call'}
</button>
{phone.audio}
</div>
)
}Ejemplo de indicador de habla
Para una IU renderizada por React que cambie con el volumen —como una insignia de "hablando" basada en un umbral—, consulta audioLevelRef.current en un intervalo y guarda el resultado en el estado:
import { useEffect, useState } from 'react'
import { useThunderPhone } from '@thunderphone/widget'
function SpeakingBadge() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
const [speaking, setSpeaking] = useState(false)
useEffect(() => {
if (phone.state !== 'connected') {
setSpeaking(false)
return
}
const interval = setInterval(() => {
setSpeaking((phone.audioLevelRef.current ?? 0) > 0.1)
}, 100)
return () => clearInterval(interval)
}, [phone.state, phone.audioLevelRef])
return (
<div>
{phone.state === 'connected' && (
<span>{speaking ? 'Speaking' : 'Listening'}</span>
)}
<button onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}>
{phone.state === 'connected' ? 'End call' : 'Start call'}
</button>
{phone.audio}
</div>
)
}Máquina de estados
La propiedad state sigue este ciclo de vida:
idle --> connecting --> connected --> disconnected --> idle (after 1.5s)
\
--> error (stays until connect() is called again)
| Estado | Descripción |
|---|---|
idle | No hay ninguna sesión activa. Listo para llamar a connect(). |
connecting | Se está estableciendo la sesión. Desactiva el botón de llamada durante este estado. |
connected | La sesión de voz está activa. El usuario está hablando con el agente. |
disconnected | La sesión finalizó correctamente. Vuelve automáticamente a idle después de 1.5 segundos. |
error | Algo salió mal. Consulta phone.error para ver el mensaje. El estado no se borra por sí solo -- llamar a connect() de nuevo inicia un intento nuevo y restablece el error. |
Ejemplos
Con control de silencio
import { useThunderPhone } from '@thunderphone/widget'
function CallWithMute() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
return (
<div>
{phone.state === 'connected' && (
<div>
<p>Talking to {phone.agentName ?? 'Agent'}</p>
<button onClick={phone.toggleMute}>
{phone.isMuted ? 'Unmute' : 'Mute'}
</button>
<button onClick={phone.disconnect}>End call</button>
</div>
)}
{phone.state !== 'connected' && (
<button
onClick={phone.connect}
disabled={phone.state === 'connecting'}
>
{phone.state === 'connecting' ? 'Connecting...' : 'Call support'}
</button>
)}
{phone.state === 'error' && (
<p style={{ color: 'red' }}>{phone.error}</p>
)}
{phone.audio}
</div>
)
}Con tono de llamada
Reproduce un tono mientras se conecta para simular una llamada telefónica:
import { useThunderPhone } from '@thunderphone/widget'
function PhoneCallButton() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
ringtone: true, // or a custom URL: 'https://example.com/ringtone.mp3'
})
return (
<>
<button
onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
disabled={phone.state === 'connecting'}
>
{phone.state === 'connecting'
? 'Ringing...'
: phone.state === 'connected'
? 'Hang up'
: 'Call'}
</button>
{phone.audio}
</>
)
}El tono de llamada se repite durante el estado connecting y se desvanece cuando el agente se conecta. Pasa true para usar el tono de llamada predeterminado integrado o una cadena de URL para usar tu propio archivo de audio.
Con callbacks de eventos
import { useThunderPhone } from '@thunderphone/widget'
function TrackedCallButton() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
onConnect: () => {
analytics.track('call_started')
},
onDisconnect: () => {
analytics.track('call_ended')
},
onError: (error) => {
analytics.track('call_error', { code: error.error, message: error.message })
},
})
return (
<>
<button
onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
disabled={phone.state === 'connecting'}
>
{phone.state === 'connected' ? 'Hang up' : 'Talk to AI'}
</button>
{phone.audio}
</>
)
}Interfaz de usuario totalmente personalizada
import { useThunderPhone } from '@thunderphone/widget'
function FullCustomUI() {
const phone = useThunderPhone({
publishableKey: 'pk_live_your_publishable_key',
})
return (
<div className="call-panel">
<div className="call-status">
{phone.state === 'idle' && <span>Ready</span>}
{phone.state === 'connecting' && <span className="pulse">Connecting...</span>}
{phone.state === 'connected' && (
<span>On call with {phone.agentName}</span>
)}
{phone.state === 'error' && <span className="error">{phone.error}</span>}
</div>
<div className="call-controls">
{phone.state === 'connected' ? (
<>
<button className="mute-btn" onClick={phone.toggleMute}>
{phone.isMuted ? 'Unmute' : 'Mute'}
</button>
<button className="end-btn" onClick={phone.disconnect}>
End
</button>
</>
) : (
<button
className="start-btn"
onClick={phone.connect}
disabled={phone.state === 'connecting'}
>
Start call
</button>
)}
</div>
{/* Required -- handles audio under the hood */}
{phone.audio}
</div>
)
}Consejos
Renderiza siempre phone.audio
El elemento phone.audio es invisible, pero obligatorio. Colócalo en cualquier lugar de tu JSX -- no renderiza ningún DOM visible, pero administra internamente la conexión de audio WebRTC.
Desactiva el botón mientras se conecta
El estado connecting puede durar entre 1 y 3 segundos. Desactiva el botón de llamada durante este estado para evitar intentos de conexión duplicados.
Maneja el estado de error correctamente
Cuando el estado sea error, muestra phone.error a la persona usuaria y mantén habilitado el botón de llamada. El hook no sale del estado error por sí solo -- volver a llamar a connect() inicia un intento nuevo y borra el error anterior.
Usa callbacks para efectos secundarios
Los callbacks onConnect, onDisconnect y onError son ideales para analítica, registros o para activar otra lógica de la aplicación sin consultar el estado constantemente.
Lee los niveles de audio desde audioLevelRef
audioLevelRef es la única fuente activa de niveles de audio. Lee audioLevelRef.current dentro de requestAnimationFrame para obtener animaciones fluidas, como formas de onda (leer una referencia no provoca nuevos renderizados), o toma muestras a intervalos y guarda el resultado en el estado para una interfaz renderizada por React. El número audioLevel está obsoleto y siempre es 0 -- no construyas lógica basándote en él.