feat: move to Socket.IO

This commit is contained in:
Marco Beretta 2024-12-30 01:47:21 +01:00 committed by Danny Avila
parent 7717d3a514
commit 9c0c341dee
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
9 changed files with 413 additions and 134 deletions

View file

@ -97,6 +97,7 @@
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"remark-supersub": "^1.0.0",
"socket.io-client": "^4.8.1",
"sse.js": "^2.5.0",
"tailwind-merge": "^1.9.1",
"tailwindcss-animate": "^1.0.5",

View file

@ -1,15 +1,51 @@
import React from 'react';
import { useRecoilState } from 'recoil';
import { Mic, Phone, PhoneOff } from 'lucide-react';
import { Phone, PhoneOff } from 'lucide-react';
import { OGDialog, OGDialogContent, Button } from '~/components';
import { useWebRTC, useWebSocket, useCall } from '~/hooks';
import { useWebSocket, useCall } from '~/hooks';
import store from '~/store';
export const Call: React.FC = () => {
const { isConnected } = useWebSocket();
const { isCalling, startCall, hangUp } = useCall();
const { isConnected, sendMessage } = useWebSocket();
const { isCalling, isProcessing, startCall, hangUp } = useCall();
const [open, setOpen] = useRecoilState(store.callDialogOpen(0));
const [eventLog, setEventLog] = React.useState<string[]>([]);
const logEvent = (message: string) => {
console.log(message);
setEventLog((prev) => [...prev, message]);
};
React.useEffect(() => {
if (isConnected) {
logEvent('Connected to server.');
} else {
logEvent('Disconnected from server.');
}
}, [isConnected]);
React.useEffect(() => {
if (isCalling) {
logEvent('Call started.');
} else if (isProcessing) {
logEvent('Processing audio...');
} else {
logEvent('Call ended.');
}
}, [isCalling, isProcessing]);
const handleStartCall = () => {
logEvent('Attempting to start call...');
startCall();
};
const handleHangUp = () => {
logEvent('Attempting to hang up call...');
hangUp();
};
return (
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogContent className="w-96 p-8">
@ -27,24 +63,34 @@ export const Call: React.FC = () => {
</span>
</div>
{!isCalling ? (
{isCalling ? (
<Button
onClick={startCall}
onClick={handleHangUp}
className="flex items-center gap-2 rounded-full bg-red-500 px-6 py-3 text-white hover:bg-red-600"
>
<PhoneOff size={20} />
<span>End Call</span>
</Button>
) : (
<Button
onClick={handleStartCall}
disabled={!isConnected}
className="flex items-center gap-2 rounded-full bg-green-500 px-6 py-3 text-white hover:bg-green-600 disabled:opacity-50"
>
<Phone size={20} />
<span>Start Call</span>
</Button>
) : (
<Button
onClick={hangUp}
className="flex items-center gap-2 rounded-full bg-red-500 px-6 py-3 text-white hover:bg-red-600"
>
<PhoneOff size={20} />
<span>End Call</span>
</Button>
)}
{/* Debugging Information */}
<div className="mt-4 w-full rounded-md bg-gray-100 p-4 shadow-sm">
<h3 className="mb-2 text-lg font-medium">Event Log</h3>
<ul className="h-32 overflow-y-auto text-xs text-gray-600">
{eventLog.map((log, index) => (
<li key={index}>{log}</li>
))}
</ul>
</div>
</div>
</OGDialogContent>
</OGDialog>

View file

@ -7,7 +7,7 @@ const SILENCE_THRESHOLD = -50;
const SILENCE_DURATION = 1000;
const useCall = () => {
const { sendMessage: wsMessage } = useWebSocket();
const { sendMessage: wsMessage, isConnected } = useWebSocket();
const [isCalling, setIsCalling] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const audioContextRef = useRef<AudioContext | null>(null);
@ -23,9 +23,7 @@ const useCall = () => {
}
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
// Send audio through WebRTC data channel
webrtcServiceRef.current?.sendAudioChunk(audioBlob);
// Signal processing start via WebSocket
wsMessage({ type: 'processing-start' });
audioChunksRef.current = [];
@ -34,17 +32,18 @@ const useCall = () => {
const handleRTCMessage = useCallback((message: RTCMessage) => {
if (message.type === 'audio-received') {
// Backend confirmed audio receipt
setIsProcessing(true);
}
}, []);
const startCall = useCallback(async () => {
// Initialize WebRTC with message handler
if (!isConnected) {
return;
}
webrtcServiceRef.current = new WebRTCService(handleRTCMessage);
await webrtcServiceRef.current.initializeCall();
// Signal call start via WebSocket
wsMessage({ type: 'call-start' });
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
@ -53,7 +52,6 @@ const useCall = () => {
analyserRef.current = audioContextRef.current.createAnalyser();
source.connect(analyserRef.current);
// Start VAD monitoring
intervalRef.current = window.setInterval(() => {
if (!analyserRef.current || !isCalling) {
return;
@ -76,7 +74,7 @@ const useCall = () => {
}, 100);
setIsCalling(true);
}, [handleRTCMessage, wsMessage, sendAudioChunk]);
}, [handleRTCMessage, isConnected, wsMessage, sendAudioChunk, isCalling]);
const hangUp = useCallback(async () => {
if (intervalRef.current) {

View file

@ -1,49 +1,58 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useGetWebsocketUrlQuery } from 'librechat-data-provider/react-query';
import { io, Socket } from 'socket.io-client';
import type { RTCMessage } from '~/common';
const useWebSocket = () => {
const { data: data } = useGetWebsocketUrlQuery();
const { data } = useGetWebsocketUrlQuery();
const [isConnected, setIsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const socketRef = useRef<Socket | null>(null);
const connect = useCallback(() => {
if (!data || !data.url) {
return;
}
wsRef.current = new WebSocket(data.url);
wsRef.current.onopen = () => setIsConnected(true);
wsRef.current.onclose = () => setIsConnected(false);
wsRef.current.onerror = (err) => console.error('WebSocket error:', err);
socketRef.current = io(data.url, { transports: ['websocket'] });
wsRef.current.onmessage = (event) => {
const msg: RTCMessage = JSON.parse(event.data);
switch (msg.type) {
case 'transcription':
// TODO: Handle transcription update
break;
case 'llm-response':
// TODO: Handle LLM streaming response
break;
case 'tts-chunk':
if (typeof msg.data === 'string') {
const audio = new Audio(`data:audio/mp3;base64,${msg.data}`);
audio.play().catch(console.error);
}
break;
socketRef.current.on('connect', () => {
setIsConnected(true);
});
socketRef.current.on('disconnect', () => {
setIsConnected(false);
});
socketRef.current.on('error', (err) => {
console.error('Socket.IO error:', err);
});
socketRef.current.on('transcription', (msg: RTCMessage) => {
// TODO: Handle transcription update
});
socketRef.current.on('llm-response', (msg: RTCMessage) => {
// TODO: Handle LLM streaming response
});
socketRef.current.on('tts-chunk', (msg: RTCMessage) => {
if (typeof msg.data === 'string') {
const audio = new Audio(`data:audio/mp3;base64,${msg.data}`);
audio.play().catch(console.error);
}
};
});
}, [data?.url]);
useEffect(() => {
connect();
return () => wsRef.current?.close();
return () => {
socketRef.current?.disconnect();
};
}, [connect]);
const sendMessage = useCallback((message: Record<string, unknown>) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
if (socketRef.current?.connected) {
socketRef.current.emit('message', message);
}
}, []);