useThunderPhone Hook 让您能够完全掌控用户界面,同时由 ThunderPhone 管理语音会话、音频路由和连接状态。当您需要完全自定义的 UI——包括自己的按钮、布局、动画和品牌风格——同时希望 ThunderPhone 在底层处理一切时,请使用它。
何时使用无头 Hook
预构建的ThunderPhoneWidget 组件可满足大多数使用场景,但在您需要以下功能时,应使用无头 Hook:
- 与您的应用设计系统匹配的完全自定义通话 UI
- 由实时音频电平驱动的音频响应式可视化效果(波形、光球、脉冲指示器)
- 自定义通话流程,例如通话前表单、通话后问卷,或与语音并列的内嵌聊天
- 集成到现有组件库中(Material UI、Chakra、Radix 等)
安装
npm install @thunderphone/widget
由于您需要提供自己的 UI,无头 Hook 不要求导入
@thunderphone/widget/style.css。不过,您仍必须安装相同的 @thunderphone/widget 包。基本用法
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}
</>
)
}
**您必须在组件树中的某处渲染
phone.audio。**它是一个不可见的 React 元素,用于管理底层音频连接。如果省略它,将不会播放音频,且会话无法正常工作。选项
通过UseThunderPhoneOptions 将以下选项传递给 useThunderPhone:
| 选项 | 类型 | 必填 | 默认值 | 描述 |
|---|---|---|---|---|
publishableKey | string | 是 | — | 可发布 API 密钥(pk_live_...)。系统会根据该密钥的组件配置自动解析智能体。 |
apiBase | string | 否 | 'https://api.thunderphone.com/v1' | 覆盖 API 基础 URL。 |
language | string | 否 | — | 按会话覆盖语言——语言代码或区域设置,例如 en、es 或 fr-FR。未设置时,将应用智能体配置的语言。 |
voice | string | 否 | — | 按会话覆盖语音——语音名称,例如 maria。未设置时,将应用智能体配置的语音。 |
context | string | 否 | — | 按会话传递给智能体的事实性页面或网站上下文。服务端会将其截断为 12,000 个字符。 |
onConnect | () => void | 否 | — | 语音会话连接时调用。 |
onDisconnect | () => void | 否 | — | 会话结束时调用。 |
onError | (error) => void | 否 | — | 发生错误时调用。错误包含 error(代码)和 message 字段。 |
ringtone | boolean | string | 否 | false | 连接期间播放铃声。使用 true 播放默认铃声,或使用 URL 字符串指定自定义音频。 |
此 Hook 为无头 Hook:它不接受
ThunderPhoneWidget 的外观属性(theme、primaryColor、title、position、className)。传入这些属性会导致 TypeScript 错误——界面呈现完全由您构建。返回值
该 Hook 返回一个UseThunderPhoneReturn 对象:
| 属性 | 类型 | 描述 |
|---|---|---|
state | 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error' | 当前连接状态。 |
connect | () => void | 开始语音会话。 |
disconnect | () => void | 结束当前会话。 |
toggleMute | () => void | 切换麦克风静音状态。 |
isMuted | boolean | 麦克风当前是否处于静音状态。 |
error | string | undefined | 当状态为 'error' 时的错误消息。 |
agentName | string | undefined | 已连接智能体的显示名称。 |
audioLevel | number | 已弃用——始终为 0。 为保持向后兼容而保留的静态占位符;它不会更新。请改为读取 audioLevelRef.current。 |
audioLevelRef | React.RefObject<number> | 包含实时音频级别(0—1)的可变 ref——取智能体语音和访客麦克风中较响的一方——在每个动画帧更新,位于 React 渲染周期之外。为实现流畅、无卡顿的动画,请在 requestAnimationFrame 循环内读取 audioLevelRef.current;当您需要在 React 状态中使用该值时,请按固定时间间隔采样。 |
audio | ReactNode | 处理音频连接的不可见元素——必须渲染。 |
音频响应式 UI
audioLevelRef ref 可为您提供帧率级别的音频音量,而不会触发 React 重新渲染,因此非常适合驱动流畅的波形可视化、脉冲光球或任何与对话关联的动画。该音量反映语音智能体声音和访客麦克风中音量较高的一方。
波形示例
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>
)
}
脉冲光球示例
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>
)
}
说话状态指示器示例
对于会随音量变化的 React 渲染 UI——例如基于阈值的“说话中”徽章——请按固定间隔读取audioLevelRef.current,并将结果存储在状态中:
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>
)
}
始终从
audioLevelRef.current 读取音量。返回对象上的 audioLevel 数值已弃用且始终为 0——任何基于它构建的逻辑都会在无提示的情况下读取到零。状态机
state 属性遵循以下生命周期:
idle --> connecting --> connected --> disconnected --> idle (after 1.5s)
\
--> error (stays until connect() is called again)
| 状态 | 描述 |
|---|---|
idle | 没有活动会话。可以调用 connect()。 |
connecting | 正在建立会话。在此状态期间禁用呼叫按钮。 |
connected | 语音会话处于活动状态。用户正在与智能体交谈。 |
disconnected | 会话已正常结束。1.5 秒后自动转换回 idle。 |
error | 出现问题。请查看 phone.error 获取错误消息。该状态不会自行清除——再次调用 connect() 会发起新的尝试并重置错误。 |
示例
使用静音控制
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>
)
}
使用铃声
在连接期间播放响铃声,以模拟电话呼叫: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}
</>
)
}
connecting 状态期间循环播放,并在智能体接通时淡出。传入 true 可使用内置默认铃声,或传入 URL 字符串以使用您自己的音频文件。
使用事件回调
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}
</>
)
}
完全自定义 UI
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>
)
}
提示
始终渲染 phone.audio
始终渲染 phone.audio
phone.audio 元素不可见,但必不可少。将其放在 JSX 中的任意位置——它不会渲染可见的 DOM,但会在内部管理 WebRTC 音频连接。连接时禁用按钮
连接时禁用按钮
connecting 状态可能持续 1-3 秒。在此状态期间禁用通话按钮,以防止重复连接尝试。妥善处理错误状态
妥善处理错误状态
当状态为
error 时,向用户显示 phone.error,并保持通话按钮可用。该 Hook 不会自行离开 error 状态——再次调用 connect() 会发起新的尝试并清除之前的错误。使用回调处理副作用
使用回调处理副作用
onConnect、onDisconnect 和 onError 回调非常适合用于分析、日志记录,或触发其他应用逻辑,而无需轮询状态。从 audioLevelRef 读取音频级别
从 audioLevelRef 读取音频级别
audioLevelRef 是唯一的实时音频级别来源。对于波形等流畅动画,请在 requestAnimationFrame 中读取 audioLevelRef.current(读取 ref 不会导致重新渲染);或者按固定间隔采样并将结果存储在状态中,以用于由 React 渲染的 UI。audioLevel 数值已弃用,且始终为 0——请勿基于它构建逻辑。