UNPKG

@airsurfer09/web-handsfree

Version:

A React package for integrating Convai's AI-powered voice assistants with LiveKit for real-time audio/video conversations

419 lines 27.3 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import { useState, useRef, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { FiMoreVertical, FiSend, FiMic } from "react-icons/fi"; import { IoVolumeMute, IoVolumeHigh } from "react-icons/io5"; import { PiWaveform } from "react-icons/pi"; import { RoomContext, RoomAudioRenderer, } from "@livekit/components-react"; import { useCharacterInfo } from "../hooks/useCharacterInfo"; // Import styled components and theme from rtc-widget import { ConvaiContainer, MorphingContainer, ButtonContent, Logo, ChatContent, Header, Title, CloseButton, SettingsButton, Content, Footer, InputContainer, Input, ActionButton, aeroTheme, iosTransitions, ConvaiLogo, ChevronDown, } from "./rtc-widget"; // Import message components import { MessageList, SettingsTray, VoiceModeOverlay, FloatingVideo, } from "./rtc-widget"; /** * RTCWidget - A consolidated chat widget component for real-time conversations * * This component combines all chat UI functionality into a single, easy-to-use widget * with an expandable/collapsible interface similar to modern chat widgets. * * Features: * - Collapsed circular button with status indicator * - Expandable/collapsible chat interface * - Voice mode support * - Real-time message streaming * - Audio controls (mute/unmute) * - Settings tray with session reset * - Auto-connect on first click * * @param {RTCWidgetProps} props - Component props * @param {ConvaiClient} props.convaiClient - Convai client instance * @param {string} props.apiKey - Convai API key * @param {string} props.characterId - Character ID to connect to * @param {boolean} props.enableVideo - Enable video capability (default: false) * @param {boolean} props.enableAudio - Enable audio (default: true) * @param {boolean} props.startWithVideoOn - Start with video camera on when connecting (default: false) * @param {string} props.url - Custom Convai URL * * @example * ```tsx * function App() { * const convaiClient = useConvaiClient(); * * return ( * <RTCWidget * convaiClient={convaiClient} * apiKey="your-api-key" * characterId="360f1c86-c98a-11ef-82c5-42010a7be016" * enableVideo={true} * startWithVideoOn={false} * /> * ); * } * ``` */ export const RTCWidget = ({ convaiClient, apiKey, characterId = "360f1c86-c98a-11ef-82c5-42010a7be016", enableVideo = false, enableAudio = true, startWithVideoOn = false, url = 'http://34.122.233.149:8099', }) => { // Fetch character information dynamically const { name: characterName, image: characterImage, isLoading: isCharacterLoading } = useCharacterInfo(characterId, apiKey); // UI State const [isOpen, setIsOpen] = useState(false); const [inputValue, setInputValue] = useState(""); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isVoiceMode, setIsVoiceMode] = useState(false); const [isMuted, setIsMuted] = useState(false); // Character audio starts unmuted const [hasConnected, setHasConnected] = useState(false); const [isReadyToConnect, setIsReadyToConnect] = useState(true); const [hasAutoOpened, setHasAutoOpened] = useState(false); const [isVideoVisible, setIsVideoVisible] = useState(false); const settingsButtonRef = useRef(null); // Character info from API const selectedCharacter = { id: characterId, name: characterName, image: characterImage, }; // Format chat messages for UI const formatMessages = () => { // Filter to only show relevant message types and exclude empty messages const filteredMessages = convaiClient.chatMessages.filter((msg) => (msg.type === "user-transcription" || msg.type === "user-llm-text" || msg.type === "bot-llm-text" || msg.type === "bot-emotion") && msg.content && // Ensure content exists msg.content.trim().length > 0 // Ensure content is not empty or just whitespace ); return filteredMessages.map((msg) => ({ id: msg.id, text: msg.content, isUser: msg.type.includes('user'), timestamp: new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }), sender: msg.type.includes('user') ? 'You' : selectedCharacter.name, showLogo: msg.type.includes('bot'), })); }; // Event Handlers const handleToggle = async () => { // If not connected yet, connect on first click if (!hasConnected && !convaiClient.state.isConnected && !convaiClient.state.isConnecting) { try { await convaiClient.connect({ apiKey, characterId: selectedCharacter.id, enableVideo: startWithVideoOn, // Only enable video on connect if startWithVideoOn is true enableAudio, url, }); setHasConnected(true); setIsOpen(true); } catch (error) { console.error("Failed to connect:", error); } } else { // Just toggle open/close setIsOpen(!isOpen); } }; const handleClose = () => { console.log("Close button clicked!"); setIsOpen(false); }; const handleInputChange = (e) => { setInputValue(e.target.value); }; const handleSend = () => { if (inputValue.trim() && convaiClient.state.isConnected) { convaiClient.sendUserTextMessage(inputValue); setInputValue(""); } }; const handleKeyPress = (e) => { if (e.key === "Enter") { handleSend(); } }; const handleMicToggle = async () => { // Toggle user's microphone on/off await convaiClient.audioControls.toggleAudio(); }; const handleToggleMute = () => { // Toggle character audio output (client-side muting of remote audio track) const newMutedState = !isMuted; // Get remote participants (character) and mute/unmute their audio tracks if (convaiClient.room) { const remoteParticipants = Array.from(convaiClient.room.remoteParticipants.values()); remoteParticipants.forEach((participant) => { participant.audioTrackPublications.forEach((publication) => { if (publication.track) { // Client-side mute/unmute of the audio track publication.track.setMuted(newMutedState); } }); }); } setIsMuted(newMutedState); }; const handleReset = async () => { setIsSettingsOpen(false); try { // Disconnect current session await convaiClient.disconnect(); // Reset session ID so next connection creates a new session convaiClient.resetSession(); // Reconnect with new session await convaiClient.connect({ apiKey, characterId: selectedCharacter.id, enableVideo: startWithVideoOn, // Only enable video on connect if startWithVideoOn is true enableAudio, url, }); // Auto-open the widget after reconnection setIsOpen(true); } catch (error) { console.error("Failed to reset and reconnect:", error); } }; const handleDisconnect = async () => { setIsSettingsOpen(false); await convaiClient.disconnect(); }; const handleToggleVideo = async () => { if (!enableVideo) { console.warn("Video capability is not enabled for this widget"); return; } if (convaiClient.videoControls.isVideoEnabled) { await convaiClient.videoControls.disableVideo(); } else { await convaiClient.videoControls.enableVideo(); } }; const handleToggleScreenShare = async () => { await convaiClient.screenShareControls.toggleScreenShare(); }; const handleSettingsToggle = () => { setIsSettingsOpen(!isSettingsOpen); }; // Auto-open when connected (only first time after connection) useEffect(() => { if (convaiClient.state.isConnected && hasConnected && !hasAutoOpened) { setIsOpen(true); setHasAutoOpened(true); } }, [convaiClient.state.isConnected, hasConnected, hasAutoOpened]); // Auto-collapse when disconnected and reset connection state useEffect(() => { if (!convaiClient.state.isConnected && !convaiClient.state.isConnecting) { if (isOpen) { setIsOpen(false); } // Reset hasConnected so user can reconnect by clicking the button if (hasConnected) { setHasConnected(false); setHasAutoOpened(false); // Also reset auto-open flag for next connection } } }, [convaiClient.state.isConnected, convaiClient.state.isConnecting, isOpen, hasConnected]); // Sync video visibility with video controls state useEffect(() => { setIsVideoVisible(convaiClient.videoControls.isVideoEnabled); }, [convaiClient.videoControls.isVideoEnabled]); // Close settings when clicking outside useEffect(() => { const handleClickOutside = (event) => { if (isSettingsOpen && settingsButtonRef.current) { const target = event.target; const settingsButton = settingsButtonRef.current; if (!settingsButton.contains(target)) { const settingsTray = document.querySelector("[data-settings-tray]"); if (settingsTray && !settingsTray.contains(target)) { setIsSettingsOpen(false); } } } }; if (isSettingsOpen) { document.addEventListener("mousedown", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [isSettingsOpen]); // Determine logo state and color const getLogoState = () => { if (convaiClient.state.isConnecting) return "connecting"; if (convaiClient.state.isConnected) return "connected"; return "idle"; }; const getLogoColor = () => { if (convaiClient.state.isConnected) return aeroTheme.colors.primary[500]; // Green when connected if (isReadyToConnect && !hasConnected) return aeroTheme.colors.primary[500]; // Green when ready if (convaiClient.state.isConnecting) return aeroTheme.colors.primary[300]; // Lighter green when connecting return "#94a3b8"; // Grey when not ready }; return (_jsxs(RoomContext.Provider, { value: convaiClient.room, children: [_jsx(RoomAudioRenderer, {}), _jsxs(ConvaiContainer, { initial: { opacity: 0, scale: 0.8 }, animate: { opacity: 1, scale: 1 }, transition: { duration: 0.4, ease: "easeOut" }, children: [_jsxs(MorphingContainer, { onClick: !isOpen ? handleToggle : undefined, whileHover: !isOpen ? { scale: 1.05 } : undefined, whileTap: !isOpen ? { scale: 0.95 } : undefined, animate: { width: isOpen ? "400px" : "4rem", height: isOpen ? "600px" : "4rem", borderRadius: isOpen ? aeroTheme.borderRadius.xl : "50%", }, transition: { duration: 0.3, ease: "easeInOut" }, style: { cursor: !isOpen ? "pointer" : "default" }, children: [_jsx(ButtonContent, { animate: { opacity: isOpen ? 0 : 1, scale: isOpen ? 0.8 : 1, }, transition: { duration: 0.3 }, children: _jsx(Logo, { children: _jsx(ConvaiLogo, { size: "xl", color: getLogoColor(), animate: convaiClient.state.isConnecting, state: getLogoState() }) }) }), convaiClient.state.isConnected && (_jsxs(ChatContent, { animate: { opacity: isOpen ? 1 : 0, scale: isOpen ? 1 : 0.8, }, transition: { duration: 0.2, delay: isOpen ? 0.2 : 0, }, style: { pointerEvents: isOpen ? "auto" : "none", }, children: [_jsxs(Header, { initial: { opacity: 0, y: -20 }, animate: { opacity: isOpen ? 1 : 0, y: isOpen ? 0 : -20, }, transition: { delay: isOpen ? 0.4 : 0, ...iosTransitions.modalSlide, }, style: { pointerEvents: isOpen ? "auto" : "none" }, children: [_jsx(CloseButton, { onClick: handleClose, whileHover: { scale: 1.1 }, whileTap: { scale: 0.9 }, transition: iosTransitions.snappy, style: { cursor: "pointer", pointerEvents: "auto" }, children: _jsx(ChevronDown, { size: "md" }) }), _jsxs(Title, { children: [selectedCharacter.image && (_jsx("img", { src: selectedCharacter.image, alt: selectedCharacter.name, style: { width: "1.5rem", height: "1.5rem", borderRadius: "50%", objectFit: "cover", marginRight: "0.5rem", } })), selectedCharacter.name, _jsx(motion.button, { onClick: handleToggleMute, whileHover: { scale: 1.1 }, whileTap: { scale: 0.9 }, transition: iosTransitions.snappy, style: { cursor: "pointer", pointerEvents: "auto", background: "transparent", border: "none", boxShadow: "none", width: "auto", height: "auto", padding: "0", display: "inline-flex", alignItems: "center", marginLeft: "0.5rem", outline: "none", }, "aria-label": isMuted ? "Unmute" : "Mute", title: isMuted ? "Unmute" : "Mute", children: isMuted ? (_jsx(IoVolumeMute, { size: 16, color: "#919EABA6" })) : (_jsx(IoVolumeHigh, { size: 16, color: "#0E7360" })) }), isVoiceMode && (_jsx("span", { style: { fontSize: "10px", color: aeroTheme.colors.convai.light, fontWeight: 500, marginLeft: "8px", padding: "2px 6px", borderRadius: "4px", backgroundColor: aeroTheme.colors.convai.light + "20", }, children: "VOICE" }))] }), _jsx("div", { style: { display: "flex", alignItems: "center", gap: "0.5rem", position: "relative", }, children: _jsx(SettingsButton, { ref: settingsButtonRef, onClick: handleSettingsToggle, whileHover: { scale: 1.1 }, whileTap: { scale: 0.9 }, transition: iosTransitions.snappy, style: { cursor: "pointer" }, children: _jsx(FiMoreVertical, { size: 20 }) }) })] }), _jsxs(Content, { style: { background: isVoiceMode ? "rgba(255, 255, 255, 0.98)" : "transparent", filter: isSettingsOpen ? "blur(3px)" : "none", }, children: [isVoiceMode && (_jsx(VoiceModeOverlay, { isActive: !convaiClient.audioControls.isAudioMuted, isTalking: convaiClient.state.isSpeaking, convaiClient: null })), !isVoiceMode && (_jsx(MessageList, { messages: formatMessages(), isStreaming: convaiClient.state.isSpeaking, forceToBottomToken: `${isOpen}-${convaiClient.chatMessages.length}` }))] }), _jsx(Footer, { initial: { y: 20, opacity: 0 }, animate: { y: 0, opacity: 1 }, transition: { delay: 0.7, ...iosTransitions.modalSlide, }, style: { filter: isSettingsOpen ? "blur(3px)" : "none", transition: "filter 0.2s ease", display: "flex", gap: "0.5rem", alignItems: "center", position: "relative", justifyContent: isVoiceMode ? "center" : "flex-start", }, children: isVoiceMode ? (_jsx(motion.button, { initial: { scale: 0.8, opacity: 0 }, animate: { scale: 1, opacity: 1 }, exit: { scale: 0.8, opacity: 0 }, transition: { duration: 0.3, ease: "easeOut", }, onClick: () => setIsVoiceMode(false), whileHover: { scale: 1.1 }, whileTap: { scale: 0.9 }, style: { cursor: "pointer", backgroundColor: "#ef4444", color: "#ffffff", border: "none", borderRadius: "50%", width: "2.25rem", height: "2.25rem", minWidth: "2.25rem", minHeight: "2.25rem", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, padding: 0, outline: "none", }, "aria-label": "Exit Voice Mode", title: "Exit voice mode", children: _jsx(PiWaveform, { size: 20 }) }, "voice-mode-exit")) : (_jsxs(_Fragment, { children: [_jsx(ActionButton, { onClick: handleMicToggle, animate: { backgroundColor: convaiClient.audioControls.isAudioMuted ? "#ef4444" // Solid red when muted : aeroTheme.colors.text.primary, // Dark when active color: "#ffffff", }, transition: { backgroundColor: { duration: 0.15, ease: "easeOut" }, color: { duration: 0.15, ease: "easeOut" }, }, whileHover: convaiClient.audioControls.isAudioMuted ? { scale: 1.05 } // Only scale on hover when muted, no color change : { scale: 1.05 }, whileTap: { scale: 0.9 }, style: { cursor: "pointer", borderRadius: "50%", border: "none", width: "2.25rem", height: "2.25rem", minWidth: "2.25rem", minHeight: "2.25rem", flexShrink: 0, }, "aria-label": convaiClient.audioControls.isAudioMuted ? "Unmute Microphone" : "Mute Microphone", title: convaiClient.audioControls.isAudioMuted ? "Unmute Microphone" : "Mute Microphone", children: _jsx(FiMic, { size: 20 }) }), _jsxs(InputContainer, { style: { flex: 1, position: "relative" }, children: [_jsx(Input, { type: "text", placeholder: "Conversation", value: inputValue, onChange: handleInputChange, onKeyPress: handleKeyPress }), _jsx("div", { style: { position: "absolute", right: "0.375rem", top: "50%", transform: "translateY(-50%)", width: "2.25rem", height: "2.25rem", minWidth: "2.25rem", minHeight: "2.25rem", flexShrink: 0, overflow: "visible", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 2, }, children: _jsx(AnimatePresence, { mode: "wait", children: inputValue.length > 0 || convaiClient.state.isSpeaking ? (_jsx(ActionButton, { initial: { y: "100%" }, animate: { y: "0%" }, exit: { y: "-100%" }, transition: { duration: 0.1, ease: "linear", }, onClick: handleSend, whileTap: { scale: 0.9 }, style: { position: "absolute", top: 0, left: 0, cursor: "pointer", }, children: _jsx(FiSend, { size: 20 }) }, "send")) : (_jsx(motion.button, { initial: { y: "100%" }, animate: { y: "0%" }, exit: { y: "-100%" }, transition: { duration: 0.1, ease: "linear", }, onClick: () => setIsVoiceMode(true), whileHover: { scale: 1.1 }, whileTap: { scale: 0.9 }, style: { position: "absolute", top: 0, left: 0, cursor: "pointer", backgroundColor: "transparent", color: aeroTheme.colors.text.primary, border: `1px solid ${aeroTheme.colors.neutral[300]}`, borderRadius: "50%", width: "2.25rem", height: "2.25rem", minWidth: "2.25rem", minHeight: "2.25rem", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, padding: 0, outline: "none", }, "aria-label": "Enter Voice Mode", title: "Use voice mode", children: _jsx(PiWaveform, { size: 20 }) }, "voice")) }) })] })] })) })] }))] }), convaiClient.state.isConnected && (_jsx(SettingsTray, { isOpen: isSettingsOpen, onReset: handleReset, onDisconnect: handleDisconnect, onToggleVideo: enableVideo ? handleToggleVideo : undefined, isVideoVisible: isVideoVisible, onToggleScreenShare: handleToggleScreenShare, isScreenShareActive: convaiClient.screenShareControls.isScreenShareActive }))] }), _jsx(FloatingVideo, { isVisible: isVideoVisible, onClose: () => { convaiClient.videoControls.disableVideo(); }, mirror: true })] })); }; //# sourceMappingURL=RTCWidget.js.map