@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
JavaScript
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