ThunderPhone 2.0 đã chính thức ra mắt.Tự thiết lập, từ 2 xu/phút.Xem thông báo ra mắt

Widget

Headless Hook

Xây dựng giao diện giọng nói tùy chỉnh hoàn toàn với React hook useThunderPhone

Hook useThunderPhone cho phép bạn kiểm soát hoàn toàn giao diện người dùng, trong khi ThunderPhone quản lý phiên thoại, định tuyến âm thanh và trạng thái kết nối. Sử dụng hook này khi bạn muốn có UI tùy chỉnh hoàn toàn -- với các nút, bố cục, hiệu ứng động và thương hiệu riêng -- trong khi ThunderPhone xử lý mọi thứ phía sau.

Khi nào nên sử dụng Hook không giao diện

Component ThunderPhoneWidget dựng sẵn đáp ứng hầu hết trường hợp sử dụng, nhưng hãy dùng hook không giao diện khi bạn cần:

  • UI cuộc gọi tùy chỉnh hoàn toàn, phù hợp với hệ thống thiết kế của ứng dụng
  • Hình ảnh trực quan phản ứng theo âm thanh (dạng sóng, quả cầu, chỉ báo nhấp nháy) được điều khiển bởi mức âm thanh theo thời gian thực
  • Luồng cuộc gọi tùy chỉnh như biểu mẫu trước cuộc gọi, khảo sát sau cuộc gọi hoặc chat nội tuyến cùng với thoại
  • Tích hợp vào thư viện component hiện có (Material UI, Chakra, Radix, v.v.)

Cài đặt

npm install @thunderphone/widget

Cách sử dụng cơ bản

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}
    </>
  )
}

Tùy chọn

Truyền các tùy chọn này vào useThunderPhone thông qua UseThunderPhoneOptions:

Tùy chọnKiểuBắt buộcMặc địnhMô tả
publishableKeystring--API key có thể công khai (pk_live_...). Tác nhân AI được xác định tự động từ cấu hình widget của key.
apiBasestringKhông'https://api.thunderphone.com/v1'Ghi đè URL cơ sở API.
languagestringKhông--Ghi đè ngôn ngữ theo từng phiên -- mã ngôn ngữ hoặc locale như en, es hoặc fr-FR. Khi không đặt, ngôn ngữ đã cấu hình của tác nhân AI sẽ được áp dụng.
voicestringKhông--Ghi đè giọng nói theo từng phiên -- tên giọng nói như maria. Khi không đặt, giọng nói đã cấu hình của tác nhân AI sẽ được áp dụng.
contextstringKhông--Ngữ cảnh thực tế của trang hoặc website theo từng phiên được truyền đến tác nhân AI. Được cắt ngắn phía máy chủ xuống còn 12.000 ký tự.
onConnect() => voidKhông--Được gọi khi phiên thoại kết nối.
onDisconnect() => voidKhông--Được gọi khi phiên kết thúc.
onError(error) => voidKhông--Được gọi khi xảy ra lỗi. Lỗi có các trường error (mã) và message.
ringtoneboolean | stringKhôngfalsePhát nhạc chuông trong khi kết nối. true dùng nhạc chuông mặc định, hoặc chuỗi URL cho âm thanh tùy chỉnh.

Giá trị trả về

Hook trả về một đối tượng UseThunderPhoneReturn:

Thuộc tínhLoạiMô tả
state'idle' | 'connecting' | 'connected' | 'disconnected' | 'error'Trạng thái kết nối hiện tại.
connect() => voidBắt đầu phiên thoại.
disconnect() => voidKết thúc phiên hiện tại.
toggleMute() => voidBật/tắt chế độ tắt tiếng microphone.
isMutedbooleanCho biết microphone hiện đang bị tắt tiếng hay không.
errorstring | undefinedThông báo lỗi khi trạng thái là 'error'.
agentNamestring | undefinedTên hiển thị của tác nhân AI đã kết nối.
audioLevelnumberĐã ngừng hỗ trợ -- luôn là 0. Đây là giá trị giữ chỗ tĩnh để tương thích ngược; giá trị này không bao giờ cập nhật. Thay vào đó, hãy đọc audioLevelRef.current.
audioLevelRefReact.RefObject<number>Một ref có thể thay đổi chứa mức âm thanh theo thời gian thực (0--1) -- mức lớn hơn giữa giọng nói của tác nhân AI và microphone của khách truy cập -- được cập nhật ở mỗi khung hình hoạt ảnh, bên ngoài chu kỳ render của React. Đọc audioLevelRef.current trong các vòng lặp requestAnimationFrame để có hoạt ảnh mượt mà, không giật, hoặc lấy mẫu theo khoảng thời gian khi bạn cần giá trị trong state của React.
audioReactNodePhần tử vô hình xử lý kết nối âm thanh -- bắt buộc phải render.

Giao diện phản hồi theo âm thanh

Ref audioLevelRef cung cấp mức âm thanh theo tốc độ khung hình mà không kích hoạt React render lại, nên rất phù hợp để điều khiển trực quan hóa dạng sóng mượt mà, các quả cầu nhấp nhô hoặc bất kỳ hoạt ảnh nào gắn với cuộc trò chuyện. Mức này phản ánh âm thanh nào lớn hơn: giọng nói của tác nhân AI hoặc microphone của khách truy cập.

Ví dụ dạng sóng

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>
  )
}

Ví dụ quả cầu nhấp nhô

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>
  )
}

Ví dụ chỉ báo đang nói

Đối với giao diện được React render thay đổi theo âm lượng -- chẳng hạn như nhãn "đang nói" dựa trên ngưỡng -- hãy lấy mẫu audioLevelRef.current theo một khoảng thời gian và lưu kết quả vào state:

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áy trạng thái

Thuộc tính state tuân theo vòng đời sau:

idle --> connecting --> connected --> disconnected --> idle (after 1.5s)
                  \
                   --> error (stays until connect() is called again)
Trạng tháiMô tả
idleKhông có phiên hoạt động. Sẵn sàng gọi connect().
connectingPhiên đang được thiết lập. Vô hiệu hóa nút gọi trong trạng thái này.
connectedPhiên thoại đang hoạt động. Người dùng đang nói chuyện với tác nhân AI.
disconnectedPhiên đã kết thúc bình thường. Tự động chuyển lại idle sau 1,5 giây.
errorĐã xảy ra lỗi. Kiểm tra phone.error để xem thông báo. Trạng thái này không tự xóa -- việc gọi lại connect() sẽ bắt đầu một lần thử mới và đặt lại lỗi.

Ví dụ

Với điều khiển tắt tiếng

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>
  )
}

Với nhạc chuông

Phát âm thanh đổ chuông trong khi kết nối để mô phỏng cuộc gọi điện thoại:

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}
    </>
  )
}

Nhạc chuông sẽ lặp lại trong trạng thái connecting và nhỏ dần khi tác nhân AI kết nối. Truyền true để dùng nhạc chuông mặc định tích hợp sẵn hoặc truyền chuỗi URL để dùng tệp âm thanh của riêng bạn.

Với callback sự kiện

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}
    </>
  )
}

Giao diện tùy chỉnh hoàn toàn

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>
  )
}

Mẹo

Luôn render phone.audio

Phần tử phone.audio không hiển thị nhưng bắt buộc. Đặt phần tử này ở bất kỳ đâu trong JSX của bạn -- phần tử không render DOM hiển thị nhưng quản lý kết nối âm thanh WebRTC nội bộ.

Vô hiệu hóa nút khi đang kết nối

Trạng thái connecting có thể kéo dài 1-3 giây. Vô hiệu hóa nút gọi trong trạng thái này để ngăn các lần thử kết nối trùng lặp.

Xử lý trạng thái lỗi một cách hợp lý

Khi trạng thái là error, hãy hiển thị phone.error cho người dùng và giữ nút gọi của bạn ở trạng thái bật. Hook không tự thoát khỏi trạng thái error -- việc gọi lại connect() sẽ bắt đầu một lần thử mới và xóa lỗi trước đó.

Dùng callback cho các tác dụng phụ

Các callback onConnect, onDisconnectonError phù hợp cho phân tích, ghi log hoặc kích hoạt logic ứng dụng khác mà không cần thăm dò trạng thái.

Đọc mức âm thanh từ audioLevelRef

audioLevelRef là nguồn mức âm thanh trực tiếp duy nhất. Đọc audioLevelRef.current bên trong requestAnimationFrame để tạo hiệu ứng mượt mà như dạng sóng (đọc ref không gây render lại), hoặc lấy mẫu theo khoảng thời gian và lưu kết quả vào state cho UI do React render. Số audioLevel đã bị ngừng sử dụng và luôn là 0 -- không xây dựng logic dựa trên nó.