Hook Headless
Crie uma interface de voz totalmente personalizada com o hook React useThunderPhone
O hook useThunderPhone oferece controle total sobre a interface do usuário enquanto o ThunderPhone gerencia a sessão de voz, o roteamento de áudio e o estado da conexão. Use-o quando quiser uma UI totalmente personalizada -- com seus próprios botões, layouts, animações e identidade visual -- enquanto o ThunderPhone cuida de tudo nos bastidores.
Quando usar o hook headless
O componente pré-criado ThunderPhoneWidget cobre a maioria dos casos de uso, mas use o hook headless quando precisar de:
- Uma UI de chamada totalmente personalizada que corresponda ao sistema de design do seu app
- Visualizações reativas ao áudio (formas de onda, orbes, indicadores pulsantes) orientadas por níveis de áudio em tempo real
- Fluxos de chamada personalizados, como formulários antes da chamada, pesquisas após a chamada ou chat integrado ao lado da voz
- Integração com uma biblioteca de componentes existente (Material UI, Chakra, Radix etc.)
Instalação
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}
</>
)
}Opções
Passe estas opções para useThunderPhone por meio de UseThunderPhoneOptions:
| Opção | Tipo | Obrigatória | Padrão | Descrição |
|---|---|---|---|---|
publishableKey | string | Sim | -- | Chave de API publicável (pk_live_...). O agente é determinado automaticamente pela configuração do widget da chave. |
apiBase | string | Não | 'https://api.thunderphone.com/v1' | Substituição da URL base da API. |
language | string | Não | -- | Substituição de idioma por sessão -- um código de idioma ou localidade, como en, es ou fr-FR. Quando não definido, aplica-se o idioma configurado do agente. |
voice | string | Não | -- | Substituição de voz por sessão -- um nome de voz, como maria. Quando não definido, aplica-se a voz configurada do agente. |
context | string | Não | -- | Contexto factual da página ou do site por sessão enviado ao agente. Truncado no servidor para 12.000 caracteres. |
onConnect | () => void | Não | -- | Chamado quando a sessão de voz é conectada. |
onDisconnect | () => void | Não | -- | Chamado quando a sessão termina. |
onError | (error) => void | Não | -- | Chamado em caso de erros. O erro tem os campos error (código) e message. |
ringtone | boolean | string | Não | false | Reproduz um toque enquanto conecta. Use true para o toque padrão ou uma string de URL para áudio personalizado. |
Valor de retorno
O hook retorna um objeto UseThunderPhoneReturn:
| Propriedade | Tipo | Descrição |
|---|---|---|
state | 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error' | Estado atual da conexão. |
connect | () => void | Inicia uma sessão de voz. |
disconnect | () => void | Encerra a sessão atual. |
toggleMute | () => void | Ativa ou desativa o silenciamento do microfone. |
isMuted | boolean | Indica se o microfone está silenciado no momento. |
error | string | undefined | Mensagem de erro quando o estado é 'error'. |
agentName | string | undefined | Nome de exibição do agente conectado. |
audioLevel | number | Obsoleto -- sempre 0. Um placeholder estático mantido para compatibilidade retroativa; ele nunca é atualizado. Leia audioLevelRef.current em vez disso. |
audioLevelRef | React.RefObject<number> | Uma ref mutável que contém o nível de áudio em tempo real (0--1) -- o mais alto entre a voz do agente e o microfone do visitante -- atualizada em cada frame de animação, fora do ciclo de renderização do React. Leia audioLevelRef.current dentro de loops requestAnimationFrame para animações fluidas, sem travamentos, ou faça uma amostragem em um intervalo quando precisar do valor no estado do React. |
audio | ReactNode | Elemento invisível que gerencia a conexão de áudio -- deve ser renderizado. |
IU reativa a áudio
A ref audioLevelRef fornece níveis de áudio em taxa de quadros sem acionar novas renderizações do React, o que a torna ideal para controlar visualizações suaves de forma de onda, orbes pulsantes ou qualquer animação vinculada à conversa. O nível reflete o que estiver mais alto: a voz do agente ou o microfone do visitante.
Exemplo 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>
)
}Exemplo 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>
)
}Exemplo de indicador de fala
Para uma IU renderizada pelo React que muda conforme o volume -- como um selo de "falando" baseado em limite -- faça amostragens de audioLevelRef.current em um intervalo e armazene o resultado no 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
A propriedade state segue este ciclo de vida:
idle --> connecting --> connected --> disconnected --> idle (after 1.5s)
\
--> error (stays until connect() is called again)
| Estado | Descrição |
|---|---|
idle | Nenhuma sessão ativa. Pronto para chamar connect(). |
connecting | A sessão está sendo estabelecida. Desative o botão de chamada durante este estado. |
connected | A sessão de voz está ativa. O usuário está falando com o agente. |
disconnected | A sessão foi encerrada corretamente. Faz a transição de volta para idle automaticamente após 1,5 segundos. |
error | Algo deu errado. Verifique phone.error para ver a mensagem. O estado não é limpo sozinho -- chamar connect() novamente inicia uma nova tentativa e redefine o erro. |
Exemplos
Com controle de silenciamento
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>
)
}Com toque
Reproduza um som de toque durante a conexão para simular uma chamada 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}
</>
)
}O toque é repetido durante o estado connecting e diminui gradualmente quando o agente se conecta. Passe true para usar o toque padrão integrado ou uma string de URL para usar seu próprio arquivo de áudio.
Com 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}
</>
)
}Interface personalizada completa
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>
)
}Dicas
Sempre renderize phone.audio
O elemento phone.audio é invisível, mas obrigatório. Coloque-o em qualquer lugar do seu JSX -- ele não renderiza nenhum DOM visível, mas gerencia internamente a conexão de áudio WebRTC.
Desabilite o botão durante a conexão
O estado connecting pode durar de 1 a 3 segundos. Desabilite o botão de chamada durante esse estado para evitar tentativas de conexão duplicadas.
Trate o estado de erro adequadamente
Quando o estado for error, exiba phone.error para a pessoa usuária e mantenha o botão de chamada habilitado. O hook não sai do estado error por conta própria -- chamar connect() novamente inicia uma nova tentativa e limpa o erro anterior.
Use callbacks para efeitos colaterais
Os callbacks onConnect, onDisconnect e onError são ideais para analytics, logs ou para acionar outra lógica da aplicação sem consultar o estado continuamente.
Leia os níveis de áudio de audioLevelRef
audioLevelRef é a única fonte ativa de nível de áudio. Leia audioLevelRef.current dentro de requestAnimationFrame para animações suaves, como formas de onda (ler uma ref não causa novas renderizações), ou faça amostragens em intervalos e armazene o resultado no estado para uma UI renderizada pelo React. O número audioLevel está obsoleto e é sempre 0 -- não crie lógica baseada nele.