Expo React Native를 사용한 크로스 플랫폼 음성 에이전트
Expo React Native를 사용한 크로스 플랫폼 음성 에이전트
WebRTC를 지원하는 Expo 및 ElevenLabs React Native SDK로 iOS와 Android에서 작동하는 ElevenLabs 에이전트를 구축하세요.
튜토리얼 · ElevenAgents 빠른 시작을 완료했고 Expo 개발 환경을 설정했다고 가정합니다.
소개
이 튜토리얼에서는 WebRTC를 지원하는 Expo React Native와 ElevenLabs React Native SDK를 사용해 iOS와 Android에서 작동하는 음성 에이전트를 구축하는 방법을 알아봅니다.
요구 사항
- API 키가 있는 ElevenLabs 계정
- 컴퓨터에 설치된 Node.js v18 이상
설정
새 Expo 프로젝트 만들기
create-expo-app을 사용하여 새 빈 Expo 프로젝트를 만듭니다.
npx create-expo-app@latest --template blank-typescript
종속성 설치
ElevenLabs React Native SDK와 해당 종속성을 설치합니다.
npx expo install @elevenlabs/react-native @livekit/react-native @livekit/react-native-webrtc @config-plugins/react-native-webrtc @livekit/react-native-expo-plugin @livekit/react-native-expo-plugin livekit-client
피어 종속성 관련 문제가 발생하면 프로젝트 루트에 .npmrc 파일을 만들고 다음 내용을 추가하세요.
legacy-peer-deps=true
마이크 권한 활성화 및 Expo 플러그인 추가
app.json 파일에 다음 권한을 추가합니다.
{"expo": {"scheme": "elevenlabs",// ..."ios": {"infoPlist": {"NSMicrophoneUsageDescription": "This app uses the microphone to record audio."},"supportsTablet": true,"bundleIdentifier": "YOUR.BUNDLE.ID"},"android": {"permissions": ["android.permission.RECORD_AUDIO","android.permission.ACCESS_NETWORK_STATE","android.permission.CAMERA","android.permission.INTERNET","android.permission.MODIFY_AUDIO_SETTINGS","android.permission.SYSTEM_ALERT_WINDOW","android.permission.WAKE_LOCK","android.permission.BLUETOOTH"],"adaptiveIcon": {"foregroundImage": "./assets/adaptive-icon.png","backgroundColor": "#ffffff"},"package": "YOUR.PACKAGE.ID"},"plugins": ["@livekit/react-native-expo-plugin", "@config-plugins/react-native-webrtc"]// ...}}
이제 대화가 시작될 때 React Native에서 마이크 권한을 요청할 수 있습니다.
Android 에뮬레이터에서는 에뮬레이터 마이크 설정에서 “Virtual microphone uses host audio input”을 활성화해야 합니다.
앱에 ElevenLabs Agents 추가
./App.tsx 파일에 다음 코드를 추가하여 앱에 ElevenLabs Agents를 추가합니다.
import { ConversationProvider, useConversation } from "@elevenlabs/react-native";import type { ConversationStatus, ConversationEvent, Role } from "@elevenlabs/react-native";import React, { useState } from "react";import {View,Text,StyleSheet,TouchableOpacity,Keyboard,TouchableWithoutFeedback,Platform,} from "react-native";import { TextInput } from "react-native";import { getBatteryLevel, changeBrightness, flashScreen } from "./utils/tools";const ConversationScreen = () => {const conversation = useConversation({clientTools: {getBatteryLevel,changeBrightness,flashScreen,},onConnect: ({ conversationId }: { conversationId: string }) => {console.log("✅ Connected to conversation", conversationId);},onDisconnect: (details: string) => {console.log("❌ Disconnected from conversation", details);},onError: (message: string, context?: Record<string, unknown>) => {console.error("❌ Conversation error:", message, context);},onMessage: ({ message, source }: { message: ConversationEvent; source: Role }) => {console.log(`💬 Message from ${source}:`, message);},onModeChange: ({ mode }: { mode: "speaking" | "listening" }) => {console.log(`🔊 Mode: ${mode}`);},onStatusChange: ({ status }: { status: ConversationStatus }) => {console.log(`📡 Status: ${status}`);},onCanSendFeedbackChange: ({ canSendFeedback }: { canSendFeedback: boolean }) => {console.log(`🔊 Can send feedback: ${canSendFeedback}`);},});const [isStarting, setIsStarting] = useState(false);const [textInput, setTextInput] = useState("");const handleSubmitText = () => {if (textInput.trim()) {conversation.sendUserMessage(textInput.trim());setTextInput("");Keyboard.dismiss();}};const startConversation = async () => {if (isStarting) return;setIsStarting(true);try {await conversation.startSession({agentId: process.env.EXPO_PUBLIC_AGENT_ID,dynamicVariables: {platform: Platform.OS,},});} catch (error) {console.error("Failed to start conversation:", error);} finally {setIsStarting(false);}};const endConversation = async () => {try {await conversation.endSession();} catch (error) {console.error("Failed to end conversation:", error);}};const getStatusColor = (status: ConversationStatus): string => {switch (status) {case "connected":return "#10B981";case "connecting":return "#F59E0B";case "disconnected":return "#EF4444";default:return "#6B7280";}};const getStatusText = (status: ConversationStatus): string => {return status[0].toUpperCase() + status.slice(1);};const canStart = conversation.status === "disconnected" && !isStarting;const canEnd = conversation.status === "connected";return (<TouchableWithoutFeedback onPress={() => Keyboard.dismiss()}><View style={styles.container}><Text style={styles.title}>ElevenLabs React Native Example</Text><Text style={styles.subtitle}>Remember to set the agentId in the .env file!</Text><View style={styles.statusContainer}><Viewstyle={[styles.statusDot, { backgroundColor: getStatusColor(conversation.status) }]}/><Text style={styles.statusText}>{getStatusText(conversation.status)}</Text></View>{/* Speaking Indicator */}{conversation.status === "connected" && (<View style={styles.speakingContainer}><Viewstyle={[styles.speakingDot,{backgroundColor: conversation.isSpeaking ? "#8B5CF6" : "#D1D5DB",},]}/><Textstyle={[styles.speakingText,{ color: conversation.isSpeaking ? "#8B5CF6" : "#9CA3AF" },]}>{conversation.isSpeaking ? "🎤 AI Speaking" : "👂 AI Listening"}</Text></View>)}<View style={styles.buttonContainer}><TouchableOpacitystyle={[styles.button, styles.startButton, !canStart && styles.disabledButton]}onPress={startConversation}disabled={!canStart}><Text style={styles.buttonText}>{isStarting ? "Starting..." : "Start Conversation"}</Text></TouchableOpacity><TouchableOpacitystyle={[styles.button, styles.endButton, !canEnd && styles.disabledButton]}onPress={endConversation}disabled={!canEnd}><Text style={styles.buttonText}>End Conversation</Text></TouchableOpacity></View>{/* Feedback Buttons */}{conversation.status === "connected" && conversation.canSendFeedback && (<View style={styles.feedbackContainer}><Text style={styles.feedbackLabel}>How was that response?</Text><View style={styles.feedbackButtons}><TouchableOpacitystyle={[styles.button, styles.likeButton]}onPress={() => conversation.sendFeedback(true)}><Text style={styles.buttonText}>👍 Like</Text></TouchableOpacity><TouchableOpacitystyle={[styles.button, styles.dislikeButton]}onPress={() => conversation.sendFeedback(false)}><Text style={styles.buttonText}>👎 Dislike</Text></TouchableOpacity></View></View>)}{/* Text Input and Messaging */}{conversation.status === "connected" && (<View style={styles.messagingContainer}><Text style={styles.messagingLabel}>Send Text Message</Text><TextInputstyle={styles.textInput}value={textInput}onChangeText={(text) => {setTextInput(text);// Prevent agent from interrupting while user is typingif (text.length > 0) {conversation.sendUserActivity();}}}placeholder="Type your message or context... (Press Enter to send)"multilineonSubmitEditing={handleSubmitText}returnKeyType="send"blurOnSubmit={true}/><View style={styles.messageButtons}><TouchableOpacitystyle={[styles.button, styles.messageButton]}onPress={handleSubmitText}disabled={!textInput.trim()}><Text style={styles.buttonText}>💬 Send Message</Text></TouchableOpacity><TouchableOpacitystyle={[styles.button, styles.contextButton]}onPress={() => {if (textInput.trim()) {conversation.sendContextualUpdate(textInput.trim());setTextInput("");Keyboard.dismiss();}}}disabled={!textInput.trim()}><Text style={styles.buttonText}>📝 Send Context</Text></TouchableOpacity></View></View>)}</View></TouchableWithoutFeedback>);};export default function App() {return (<ConversationProvider><ConversationScreen /></ConversationProvider>);}const styles = StyleSheet.create({container: {flex: 1,justifyContent: "center",alignItems: "center",backgroundColor: "#F3F4F6",padding: 20,},title: {fontSize: 24,fontWeight: "bold",marginBottom: 8,color: "#1F2937",},subtitle: {fontSize: 16,color: "#6B7280",marginBottom: 32,},statusContainer: {flexDirection: "row",alignItems: "center",marginBottom: 24,},statusDot: {width: 12,height: 12,borderRadius: 6,marginRight: 8,},statusText: {fontSize: 16,fontWeight: "500",color: "#374151",},speakingContainer: {flexDirection: "row",alignItems: "center",marginBottom: 24,},speakingDot: {width: 12,height: 12,borderRadius: 6,marginRight: 8,},speakingText: {fontSize: 14,fontWeight: "500",},toolsContainer: {backgroundColor: "#E5E7EB",padding: 16,borderRadius: 8,marginBottom: 24,width: "100%",},toolsTitle: {fontSize: 14,fontWeight: "600",color: "#374151",marginBottom: 8,},toolItem: {fontSize: 12,color: "#6B7280",fontFamily: "monospace",marginBottom: 4,},buttonContainer: {width: "100%",gap: 16,},button: {backgroundColor: "#3B82F6",paddingVertical: 16,paddingHorizontal: 32,borderRadius: 8,alignItems: "center",},startButton: {backgroundColor: "#10B981",},endButton: {backgroundColor: "#EF4444",},disabledButton: {backgroundColor: "#9CA3AF",},buttonText: {color: "white",fontSize: 16,fontWeight: "600",},instructions: {marginTop: 24,fontSize: 14,color: "#6B7280",textAlign: "center",lineHeight: 20,},feedbackContainer: {marginTop: 24,alignItems: "center",},feedbackLabel: {fontSize: 16,fontWeight: "500",color: "#374151",marginBottom: 12,},feedbackButtons: {flexDirection: "row",gap: 16,},likeButton: {backgroundColor: "#10B981",},dislikeButton: {backgroundColor: "#EF4444",},messagingContainer: {marginTop: 24,width: "100%",},messagingLabel: {fontSize: 16,fontWeight: "500",color: "#374151",marginBottom: 8,},textInput: {backgroundColor: "#FFFFFF",borderRadius: 8,padding: 16,minHeight: 100,textAlignVertical: "top",borderWidth: 1,borderColor: "#D1D5DB",marginBottom: 16,},messageButtons: {flexDirection: "row",gap: 16,},messageButton: {backgroundColor: "#3B82F6",flex: 1,},contextButton: {backgroundColor: "#4F46E5",flex: 1,},activityContainer: {marginTop: 24,alignItems: "center",},activityLabel: {fontSize: 14,color: "#6B7280",marginBottom: 8,textAlign: "center",},activityButton: {backgroundColor: "#F59E0B",},});
네이티브 클라이언트 도구
ElevenLabs 에이전트 구축에서 중요한 부분은 에이전트가 기능에 동적으로 액세스하고 실행할 수 있도록 하는 것입니다. 이는 클라이언트 도구를 통해 구현할 수 있습니다.
클라이언트 도구를 저장할 새 파일 ./utils/tools.ts를 만들고 다음 코드를 추가합니다.
import * as Battery from "expo-battery";import * as Brightness from "expo-brightness";const getBatteryLevel = async () => {const batteryLevel = await Battery.getBatteryLevelAsync();console.log("batteryLevel", batteryLevel);if (batteryLevel === -1) {return "Error: Device does not support retrieving the battery level.";}return batteryLevel;};const changeBrightness = ({ brightness }: { brightness: number }) => {console.log("changeBrightness", brightness);Brightness.setSystemBrightnessAsync(brightness);return brightness;};const flashScreen = () => {Brightness.setSystemBrightnessAsync(1);setTimeout(() => {Brightness.setSystemBrightnessAsync(0);}, 200);return "Successfully flashed the screen.";};export { getBatteryLevel, changeBrightness, flashScreen };
동적 변수
클라이언트 도구 외에도 첫 번째 메시지와 프롬프트 모두에 플랫폼(웹, iOS, Android)을 동적 변수로 삽입합니다.
// ...const startConversation = async () => {if (isStarting) return;setIsStarting(true);try {await conversation.startSession({agentId: process.env.EXPO_PUBLIC_AGENT_ID,dynamicVariables: {platform: Platform.OS,},});} catch (error) {console.error("Failed to start conversation:", error);} finally {setIsStarting(false);}};// ...
에이전트 구성
첫 번째 메시지 설정
첫 번째 메시지를 설정하고 플랫폼의 동적 변수를 지정합니다.
Hi there, woah, so cool that I'm running on {{platform}}. What can I help you with?
시스템 프롬프트 설정
시스템 프롬프트를 설정합니다. 여기에 동적 변수를 포함할 수도 있습니다.
You are a helpful assistant running on {{platform}}. You have access to certain tools that allow you to check the user device battery level and change the display brightness. Use these tools if the user asks about them. Otherwise, just answer the question.
클라이언트 도구 설정
다음 클라이언트 도구를 설정합니다.
- 이름:
getBatteryLevel- 설명: 기기 배터리 잔량을 소수점 백분율로 가져옵니다.
- 응답 대기:
true - 응답 시간 제한(초): 3
- 이름:
changeBrightness- 설명: 기기 화면의 밝기를 변경합니다.
- 응답 대기:
true - 응답 시간 제한(초): 3
- 매개변수:
- 데이터 유형:
number - 식별자:
brightness - 필수:
true - 값 유형:
LLM Prompt - 설명: 원하는 화면 밝기를 나타내는 0에서 1 사이의 숫자(양 끝값 포함)입니다.
- 데이터 유형:
- 이름:
flashScreen- 설명: 화면을 빠르게 켰다 끕니다.
- 응답 대기:
true - 응답 시간 제한(초): 3
앱 실행
이 앱에는 Expo Go에서 지원되지 않는 일부 네이티브 종속성이 필요하므로, 앱을 사전 빌드한 후 네이티브 기기에서 실행해야 합니다.
- 터미널 1:
npx expo prebuild --clean을 실행합니다.
npx expo prebuild --clean
npx expo start --tunnel을 실행하여 https를 통해 Expo 개발 서버를 시작합니다.
npx expo start --tunnel
- 터미널 2:
npx expo run:ios --device를 실행하여 iOS 기기에서 앱을 실행합니다.
npx expo run:ios --device