From cf4b73b5e36698643616ddb15b2389dcecea0e14 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 21 Dec 2024 14:36:01 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20WebSocket=20functiona?= =?UTF-8?q?lity=20and=20integrate=20call=20features=20in=20the=20chat=20co?= =?UTF-8?q?mponent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/package.json | 1 + api/server/index.js | 18 +- api/server/routes/index.js | 6 +- api/server/routes/websocket.js | 18 ++ .../services/WebSocket/WebSocketServer.js | 70 +++++ client/src/components/Chat/Input/Call.tsx | 52 ++++ client/src/components/Chat/Input/ChatForm.tsx | 249 +++++++++--------- .../src/components/Chat/Input/SendButton.tsx | 34 +-- client/src/hooks/index.ts | 3 + client/src/hooks/useCall.ts | 67 +++++ client/src/hooks/useWebRTC.ts | 77 ++++++ client/src/hooks/useWebSocket.ts | 45 ++++ .../src/services/WebRTC/WebRTCService.test.ts | 0 client/src/services/WebRTC/WebRTCService.ts | 36 +++ client/src/store/families.ts | 6 + package-lock.json | 22 ++ packages/data-provider/src/api-endpoints.ts | 4 +- packages/data-provider/src/data-service.ts | 6 +- packages/data-provider/src/keys.ts | 1 + .../src/react-query/react-query-service.ts | 11 + packages/data-provider/src/types.ts | 4 + 21 files changed, 588 insertions(+), 142 deletions(-) create mode 100644 api/server/routes/websocket.js create mode 100644 api/server/services/WebSocket/WebSocketServer.js create mode 100644 client/src/components/Chat/Input/Call.tsx create mode 100644 client/src/hooks/useCall.ts create mode 100644 client/src/hooks/useWebRTC.ts create mode 100644 client/src/hooks/useWebSocket.ts create mode 100644 client/src/services/WebRTC/WebRTCService.test.ts create mode 100644 client/src/services/WebRTC/WebRTCService.ts diff --git a/api/package.json b/api/package.json index 2a2c8be6de..d6694f5403 100644 --- a/api/package.json +++ b/api/package.json @@ -111,6 +111,7 @@ "winston": "^3.11.0", "winston-daily-rotate-file": "^4.7.1", "youtube-transcript": "^1.2.1", + "ws": "^8.18.0", "zod": "^3.22.4" }, "devDependencies": { diff --git a/api/server/index.js b/api/server/index.js index 4a428789dd..a2c3c674ec 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -4,6 +4,7 @@ require('module-alias')({ base: path.resolve(__dirname, '..') }); const cors = require('cors'); const axios = require('axios'); const express = require('express'); +const { createServer } = require('http'); const compression = require('compression'); const passport = require('passport'); const mongoSanitize = require('express-mongo-sanitize'); @@ -14,6 +15,7 @@ const { connectDb, indexSync } = require('~/lib/db'); const { isEnabled } = require('~/server/utils'); const { ldapLogin } = require('~/strategies'); const { logger } = require('~/config'); +const { WebSocketService } = require('./services/WebSocket/WebSocketServer'); const validateImageRequest = require('./middleware/validateImageRequest'); const errorController = require('./controllers/ErrorController'); const configureSocialLogins = require('./socialLogins'); @@ -37,7 +39,18 @@ const startServer = async () => { await indexSync(); const app = express(); + const server = createServer(app); + app.disable('x-powered-by'); + app.use( + cors({ + origin: true, + credentials: true, + }), + ); + + new WebSocketService(server); + await AppService(app); const indexPath = path.join(app.locals.paths.dist, 'index.html'); @@ -110,6 +123,7 @@ const startServer = async () => { app.use('/api/agents', routes.agents); app.use('/api/banner', routes.banner); app.use('/api/bedrock', routes.bedrock); + app.use('/api/websocket', routes.websocket); app.use('/api/tags', routes.tags); @@ -127,7 +141,7 @@ const startServer = async () => { res.send(updatedIndexHtml); }); - app.listen(port, host, () => { + server.listen(port, host, () => { if (host == '0.0.0.0') { logger.info( `Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`, @@ -135,6 +149,8 @@ const startServer = async () => { } else { logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`); } + + logger.info(`WebSocket endpoint: ws://${host}:${port}`); }); }; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 4b34029c7b..fa52f87723 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -2,6 +2,7 @@ const assistants = require('./assistants'); const categories = require('./categories'); const tokenizer = require('./tokenizer'); const endpoints = require('./endpoints'); +const websocket = require('./websocket'); const staticRoute = require('./static'); const messages = require('./messages'); const presets = require('./presets'); @@ -15,6 +16,7 @@ const models = require('./models'); const convos = require('./convos'); const config = require('./config'); const agents = require('./agents'); +const banner = require('./banner'); const roles = require('./roles'); const oauth = require('./oauth'); const files = require('./files'); @@ -25,7 +27,6 @@ const edit = require('./edit'); const keys = require('./keys'); const user = require('./user'); const ask = require('./ask'); -const banner = require('./banner'); module.exports = { ask, @@ -39,6 +40,7 @@ module.exports = { files, share, agents, + banner, bedrock, convos, search, @@ -50,10 +52,10 @@ module.exports = { presets, balance, messages, + websocket, endpoints, tokenizer, assistants, categories, staticRoute, - banner, }; diff --git a/api/server/routes/websocket.js b/api/server/routes/websocket.js new file mode 100644 index 0000000000..82d487f593 --- /dev/null +++ b/api/server/routes/websocket.js @@ -0,0 +1,18 @@ +const express = require('express'); +const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth'); +const router = express.Router(); + +router.get('/', optionalJwtAuth, async (req, res) => { + const isProduction = process.env.NODE_ENV === 'production'; + const useSSL = isProduction && process.env.SERVER_DOMAIN?.startsWith('https'); + + const protocol = useSSL ? 'wss' : 'ws'; + const serverDomain = process.env.SERVER_DOMAIN + ? process.env.SERVER_DOMAIN.replace(/^https?:\/\//, '') + : req.headers.host; + const wsUrl = `${protocol}://${serverDomain}/ws`; + + res.json({ url: wsUrl }); +}); + +module.exports = router; diff --git a/api/server/services/WebSocket/WebSocketServer.js b/api/server/services/WebSocket/WebSocketServer.js new file mode 100644 index 0000000000..602e20851e --- /dev/null +++ b/api/server/services/WebSocket/WebSocketServer.js @@ -0,0 +1,70 @@ +const { WebSocketServer } = require('ws'); +const fs = require('fs'); +const path = require('path'); + +module.exports.WebSocketService = class { + constructor(server) { + this.wss = new WebSocketServer({ server, path: '/ws' }); + this.log('Server initialized'); + this.clientAudioBuffers = new Map(); + this.setupHandlers(); + } + + log(msg) { + console.log(`[WSS ${new Date().toISOString()}] ${msg}`); + } + + setupHandlers() { + this.wss.on('connection', (ws) => { + const clientId = Date.now().toString(); + this.clientAudioBuffers.set(clientId, []); + + this.log(`Client connected: ${clientId}`); + + ws.on('message', async (raw) => { + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } + + if (message.type === 'audio-chunk') { + if (!this.clientAudioBuffers.has(clientId)) { + this.clientAudioBuffers.set(clientId, []); + } + this.clientAudioBuffers.get(clientId).push(message.data); + } + + if (message.type === 'request-response') { + const filePath = path.join(__dirname, './assets/response.mp3'); + const audioFile = fs.readFileSync(filePath); + ws.send(JSON.stringify({ type: 'audio-response', data: audioFile.toString('base64') })); + } + + if (message.type === 'call-ended') { + const allChunks = this.clientAudioBuffers.get(clientId); + this.writeAudioFile(clientId, allChunks); + this.clientAudioBuffers.delete(clientId); + } + }); + + ws.on('close', () => { + this.log(`Client disconnected: ${clientId}`); + this.clientAudioBuffers.delete(clientId); + }); + }); + } + + writeAudioFile(clientId, base64Chunks) { + if (!base64Chunks || base64Chunks.length === 0) { + return; + } + const filePath = path.join(__dirname, `recorded_${clientId}.webm`); + const buffer = Buffer.concat( + base64Chunks.map((chunk) => Buffer.from(chunk.split(',')[1], 'base64')), + ); + fs.writeFileSync(filePath, buffer); + this.log(`Saved audio to ${filePath}`); + } +}; diff --git a/client/src/components/Chat/Input/Call.tsx b/client/src/components/Chat/Input/Call.tsx new file mode 100644 index 0000000000..c79877374f --- /dev/null +++ b/client/src/components/Chat/Input/Call.tsx @@ -0,0 +1,52 @@ +import { useRecoilState } from 'recoil'; +import { Mic, Phone, PhoneOff } from 'lucide-react'; +import { OGDialog, OGDialogContent, Button } from '~/components'; +import { useWebRTC, useWebSocket, useCall } from '~/hooks'; +import store from '~/store'; + +export const Call: React.FC = () => { + const { isConnected } = useWebSocket(); + const { isCalling, startCall, hangUp } = useCall(); + + const [open, setOpen] = useRecoilState(store.callDialogOpen(0)); + + return ( + + +
+
+
+ + {isConnected ? 'Connected' : 'Disconnected'} + +
+ + {!isCalling ? ( + + ) : ( + + )} +
+ + + ); +}; diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 4b14e9b606..975d32e42f 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -26,12 +26,12 @@ import PromptsCommand from './PromptsCommand'; import AudioRecorder from './AudioRecorder'; import CollapseChat from './CollapseChat'; import StreamAudio from './StreamAudio'; -import CallButton from './CallButton'; import StopButton from './StopButton'; import SendButton from './SendButton'; import { BadgeRow } from './BadgeRow'; import EditBadges from './EditBadges'; import Mention from './Mention'; +import { Call } from './Call'; import store from '~/store'; const ChatForm = memo(({ index = 0 }: { index?: number }) => { @@ -190,140 +190,145 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => { ); return ( -
-
-
- {showPlusPopover && !isAssistantsEndpoint(endpoint) && ( - - )} - {showMentionPopover && ( - - )} - -
+ +
+
+ {showPlusPopover && !isAssistantsEndpoint(endpoint) && ( + )} - > - - - - {endpoint && ( -
- { - ref(e); - (textAreaRef as React.MutableRefObject).current = e; - }} - disabled={disableInputs} - onPaste={handlePaste} - onKeyDown={handleKeyDown} - onKeyUp={handleKeyUp} - onCompositionStart={handleCompositionStart} - onCompositionEnd={handleCompositionEnd} - id={mainTextareaId} - tabIndex={0} - data-testid="text-input" - rows={1} - onFocus={() => { - handleFocusOrClick(); - setIsTextAreaFocused(true); - }} - onBlur={setIsTextAreaFocused.bind(null, false)} - onClick={handleFocusOrClick} - style={{ height: 44, overflowY: 'auto' }} - className={cn( - baseClasses, - removeFocusRings, - 'transition-[max-height] duration-200', - )} - /> -
- -
-
+ {showMentionPopover && ( + )} +
-
- -
- setBadges(newBadges)} - isInChat={ - Array.isArray(conversation?.messages) && conversation.messages.length >= 1 - } + + -
- {SpeechToText && ( - - )} -
- {(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? ( - - ) : ( - endpoint && ( - + {endpoint && ( +
+ { + ref(e); + (textAreaRef as React.MutableRefObject).current = + e; + }} + disabled={disableInputs} + onPaste={handlePaste} + onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} + id={mainTextareaId} + tabIndex={0} + data-testid="text-input" + rows={1} + onFocus={() => { + handleFocusOrClick(); + setIsTextAreaFocused(true); + }} + onBlur={setIsTextAreaFocused.bind(null, false)} + onClick={handleFocusOrClick} + style={{ height: 44, overflowY: 'auto' }} + className={cn( + baseClasses, + removeFocusRings, + 'transition-[max-height] duration-200', + )} + /> +
+ - ) +
+
+ )} +
+
+ +
+ setBadges(newBadges)} + isInChat={ + Array.isArray(conversation?.messages) && conversation.messages.length >= 1 + } + /> +
+ {SpeechToText && ( + + )} +
+ {(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? ( + + ) : ( + endpoint && ( + + ) + )} +
+ {TextToSpeech && automaticPlayback && }
- {TextToSpeech && automaticPlayback && }
-
- + + + ); }); diff --git a/client/src/components/Chat/Input/SendButton.tsx b/client/src/components/Chat/Input/SendButton.tsx index c93ae016e9..0589fc3d25 100644 --- a/client/src/components/Chat/Input/SendButton.tsx +++ b/client/src/components/Chat/Input/SendButton.tsx @@ -1,11 +1,13 @@ import React, { forwardRef } from 'react'; import { useWatch } from 'react-hook-form'; +import { useSetRecoilState } from 'recoil'; import type { TRealtimeEphemeralTokenResponse } from 'librechat-data-provider'; import type { Control } from 'react-hook-form'; import { useRealtimeEphemeralTokenMutation } from '~/data-provider'; import { TooltipAnchor, SendIcon, CallIcon } from '~/components'; import { useToastContext } from '~/Providers/ToastContext'; import { useLocalize } from '~/hooks'; +import store from '~/store'; import { cn } from '~/utils'; type ButtonProps = { @@ -56,25 +58,27 @@ const SendButton = forwardRef((props: ButtonProps, ref: React.ForwardedRef { - showToast({ - message: 'IT WORKS!!', - status: 'success', - }); - }, - onError: (error: unknown) => { - showToast({ - message: localize('com_nav_audio_process_error', (error as Error).message), - status: 'error', - }); - }, - }); + // const { mutate: startCall, isLoading: isProcessing } = useRealtimeEphemeralTokenMutation({ + // onSuccess: async (data: TRealtimeEphemeralTokenResponse) => { + // showToast({ + // message: 'IT WORKS!!', + // status: 'success', + // }); + // }, + // onError: (error: unknown) => { + // showToast({ + // message: localize('com_nav_audio_process_error', (error as Error).message), + // status: 'error', + // }); + // }, + // }); const handleClick = () => { if (text.trim() === '') { - startCall({ voice: 'verse' }); + setCallOpen(true); + // startCall({ voice: 'verse' }); } }; diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts index 83cdb7fbc4..987f8e6329 100644 --- a/client/src/hooks/index.ts +++ b/client/src/hooks/index.ts @@ -21,10 +21,13 @@ export * from './Endpoint'; export type { TranslationKeys } from './useLocalize'; +export { default as useCall } from './useCall'; export { default as useToast } from './useToast'; +export { default as useWebRTC } from './useWebRTC'; export { default as useTimeout } from './useTimeout'; export { default as useNewConvo } from './useNewConvo'; export { default as useLocalize } from './useLocalize'; +export { default as useWebSocket } from './useWebSocket'; export { default as useMediaQuery } from './useMediaQuery'; export { default as useChatBadges } from './useChatBadges'; export { default as useScrollToRef } from './useScrollToRef'; diff --git a/client/src/hooks/useCall.ts b/client/src/hooks/useCall.ts new file mode 100644 index 0000000000..91dc2effb0 --- /dev/null +++ b/client/src/hooks/useCall.ts @@ -0,0 +1,67 @@ +import { useState, useRef, useCallback } from 'react'; +import useWebSocket from './useWebSocket'; +import { WebRTCService } from '../services/WebRTC/WebRTCService'; + +const SILENCE_THRESHOLD = -50; +const SILENCE_DURATION = 1000; + +const useCall = () => { + const { sendMessage } = useWebSocket(); + const [isCalling, setIsCalling] = useState(false); + const audioContextRef = useRef(null); + const analyserRef = useRef(null); + const silenceStartRef = useRef(null); + const intervalRef = useRef(null); + const webrtcServiceRef = useRef(null); + + const checkSilence = useCallback(() => { + if (!analyserRef.current || !isCalling) { + return; + } + const data = new Float32Array(analyserRef.current.frequencyBinCount); + analyserRef.current.getFloatFrequencyData(data); + const avg = data.reduce((a, b) => a + b) / data.length; + if (avg < SILENCE_THRESHOLD) { + if (!silenceStartRef.current) { + silenceStartRef.current = Date.now(); + } else if (Date.now() - silenceStartRef.current > SILENCE_DURATION) { + sendMessage({ type: 'request-response' }); + silenceStartRef.current = null; + } + } else { + silenceStartRef.current = null; + } + }, [isCalling, sendMessage]); + + const startCall = useCallback(async () => { + webrtcServiceRef.current = new WebRTCService(sendMessage); + await webrtcServiceRef.current.initializeCall(); + + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + audioContextRef.current = new AudioContext(); + const source = audioContextRef.current.createMediaStreamSource(stream); + analyserRef.current = audioContextRef.current.createAnalyser(); + source.connect(analyserRef.current); + + intervalRef.current = window.setInterval(checkSilence, 100); + setIsCalling(true); + }, [checkSilence, sendMessage]); + + const hangUp = useCallback(async () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + analyserRef.current = null; + audioContextRef.current?.close(); + audioContextRef.current = null; + + await webrtcServiceRef.current?.endCall(); + webrtcServiceRef.current = null; + setIsCalling(false); + sendMessage({ type: 'call-ended' }); + }, [sendMessage]); + + return { isCalling, startCall, hangUp }; +}; + +export default useCall; diff --git a/client/src/hooks/useWebRTC.ts b/client/src/hooks/useWebRTC.ts new file mode 100644 index 0000000000..436b1da1ea --- /dev/null +++ b/client/src/hooks/useWebRTC.ts @@ -0,0 +1,77 @@ +import { useRef, useCallback } from 'react'; +import useWebSocket from './useWebSocket'; + +const SILENCE_THRESHOLD = -50; +const SILENCE_DURATION = 1000; + +const useWebRTC = () => { + const { sendMessage } = useWebSocket(); + const localStreamRef = useRef(null); + const audioContextRef = useRef(null); + const analyserRef = useRef(null); + const silenceStartTime = useRef(null); + const isProcessingRef = useRef(false); + + const log = (msg: string) => console.log(`[WebRTC ${new Date().toISOString()}] ${msg}`); + + const processAudioLevel = () => { + if (!analyserRef.current || !isProcessingRef.current) { + return; + } + + const dataArray = new Float32Array(analyserRef.current.frequencyBinCount); + analyserRef.current.getFloatFrequencyData(dataArray); + const average = dataArray.reduce((a, b) => a + b) / dataArray.length; + + if (average < SILENCE_THRESHOLD) { + if (!silenceStartTime.current) { + silenceStartTime.current = Date.now(); + log(`Silence started: ${average}dB`); + } else if (Date.now() - silenceStartTime.current > SILENCE_DURATION) { + log('Silence threshold reached - requesting response'); + sendMessage({ type: 'request-response' }); + silenceStartTime.current = null; + } + } else { + silenceStartTime.current = null; + } + + requestAnimationFrame(processAudioLevel); + }; + + const startLocalStream = async () => { + try { + log('Starting audio capture'); + localStreamRef.current = await navigator.mediaDevices.getUserMedia({ audio: true }); + + audioContextRef.current = new AudioContext(); + const source = audioContextRef.current.createMediaStreamSource(localStreamRef.current); + analyserRef.current = audioContextRef.current.createAnalyser(); + + source.connect(analyserRef.current); + isProcessingRef.current = true; + processAudioLevel(); + + log('Audio capture started'); + } catch (error) { + log(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw error; + } + }; + + const stopLocalStream = useCallback(() => { + log('Stopping audio capture'); + isProcessingRef.current = false; + audioContextRef.current?.close(); + localStreamRef.current?.getTracks().forEach((track) => track.stop()); + + localStreamRef.current = null; + audioContextRef.current = null; + analyserRef.current = null; + silenceStartTime.current = null; + }, []); + + return { startLocalStream, stopLocalStream }; +}; + +export default useWebRTC; diff --git a/client/src/hooks/useWebSocket.ts b/client/src/hooks/useWebSocket.ts new file mode 100644 index 0000000000..4d070f499f --- /dev/null +++ b/client/src/hooks/useWebSocket.ts @@ -0,0 +1,45 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { useGetWebsocketUrlQuery } from 'librechat-data-provider/react-query'; + +const useWebSocket = () => { + const { data: url } = useGetWebsocketUrlQuery(); + const [isConnected, setIsConnected] = useState(false); + const wsRef = useRef(null); + + console.log('wsConfig:', url?.url); + + const connect = useCallback(() => { + if (!url?.url) { + return; + } + + wsRef.current = new WebSocket(url?.url); + wsRef.current.onopen = () => setIsConnected(true); + wsRef.current.onclose = () => setIsConnected(false); + wsRef.current.onerror = (err) => console.error('WebSocket error:', err); + + wsRef.current.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.type === 'audio-response') { + const audioData = msg.data; + const audio = new Audio(`data:audio/mp3;base64,${audioData}`); + audio.play().catch(console.error); + } + }; + }, [url?.url]); + + useEffect(() => { + connect(); + return () => wsRef.current?.close(); + }, [connect]); + + const sendMessage = useCallback((message: any) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(message)); + } + }, []); + + return { isConnected, sendMessage }; +}; + +export default useWebSocket; diff --git a/client/src/services/WebRTC/WebRTCService.test.ts b/client/src/services/WebRTC/WebRTCService.test.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/client/src/services/WebRTC/WebRTCService.ts b/client/src/services/WebRTC/WebRTCService.ts new file mode 100644 index 0000000000..6b7ec2bfd8 --- /dev/null +++ b/client/src/services/WebRTC/WebRTCService.ts @@ -0,0 +1,36 @@ +export class WebRTCService { + private peerConnection: RTCPeerConnection | null = null; + private mediaRecorder: MediaRecorder | null = null; + private sendMessage: (msg: any) => void; + + constructor(sendMessage: (msg: any) => void) { + this.sendMessage = sendMessage; + } + + async initializeCall() { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + this.peerConnection = new RTCPeerConnection(); + stream.getTracks().forEach((track) => this.peerConnection?.addTrack(track, stream)); + + this.mediaRecorder = new MediaRecorder(stream); + this.mediaRecorder.ondataavailable = (e) => { + if (e.data.size > 0) { + const reader = new FileReader(); + reader.onload = () => { + this.sendMessage({ + type: 'audio-chunk', + data: reader.result, + }); + }; + reader.readAsDataURL(e.data); + } + }; + this.mediaRecorder.start(); + } + + async endCall() { + this.mediaRecorder?.stop(); + this.peerConnection?.close(); + this.peerConnection = null; + } +} diff --git a/client/src/store/families.ts b/client/src/store/families.ts index 786a415d46..404ece0d4c 100644 --- a/client/src/store/families.ts +++ b/client/src/store/families.ts @@ -368,6 +368,11 @@ const updateConversationSelector = selectorFamily({ }, }); +const callDialogOpen = atomFamily({ + key: 'callDialogOpen', + default: false, +}); + export default { conversationKeysAtom, conversationByIndex, @@ -399,4 +404,5 @@ export default { useClearLatestMessages, showPromptsPopoverFamily, updateConversationSelector, + callDialogOpen, }; diff --git a/package-lock.json b/package-lock.json index 911cd7f724..874753082a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -127,6 +127,7 @@ "winston": "^3.11.0", "winston-daily-rotate-file": "^4.7.1", "youtube-transcript": "^1.2.1", + "ws": "^8.18.0", "zod": "^3.22.4" }, "devDependencies": { @@ -1655,6 +1656,27 @@ "webidl-conversions": "^3.0.0" } }, + "api/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "client": { "name": "@librechat/frontend", "version": "v0.7.7", diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 0cd26a4da3..c262868ed1 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -246,4 +246,6 @@ export const verifyTwoFactor = () => '/api/auth/2fa/verify'; export const confirmTwoFactor = () => '/api/auth/2fa/confirm'; export const disableTwoFactor = () => '/api/auth/2fa/disable'; export const regenerateBackupCodes = () => '/api/auth/2fa/backup/regenerate'; -export const verifyTwoFactorTemp = () => '/api/auth/2fa/verify-temp'; \ No newline at end of file +export const verifyTwoFactorTemp = () => '/api/auth/2fa/verify-temp'; + +export const websocket = () => '/api/websocket'; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index b0b3372ab8..41b3b4db48 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -809,4 +809,8 @@ export function verifyTwoFactorTemp( payload: t.TVerify2FATempRequest, ): Promise { return request.post(endpoints.verifyTwoFactorTemp(), payload); -} \ No newline at end of file +} + +export function getWebsocketUrl(): Promise { + return request.get(endpoints.websocket()); +} diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index 76184d0ce3..8158ed7fbb 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -46,6 +46,7 @@ export enum QueryKeys { health = 'health', userTerms = 'userTerms', banner = 'banner', + websocketUrl = 'websocketUrl', } export enum MutationKeys { diff --git a/packages/data-provider/src/react-query/react-query-service.ts b/packages/data-provider/src/react-query/react-query-service.ts index 03a37d99a7..57e98c3825 100644 --- a/packages/data-provider/src/react-query/react-query-service.ts +++ b/packages/data-provider/src/react-query/react-query-service.ts @@ -376,3 +376,14 @@ export const useGetCustomConfigSpeechQuery = ( }, ); }; + +export const useGetWebsocketUrlQuery = ( + config?: UseQueryOptions, +): QueryObserverResult => { + return useQuery([QueryKeys.websocketUrl], () => dataService.getWebsocketUrl(), { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + ...config, + }); +}; diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 2354bb1afc..5febb233d9 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -541,3 +541,7 @@ export type TRealtimeEphemeralTokenResponse = { token: string; url: string; }; + +export type TWebsocketUrlResponse = { + url: string; +};