paint-brush
Comment créer un chatbot contextuel simple à l'aide d'OpenAIpar@alexxanderx
Nouvelle histoire

Comment créer un chatbot contextuel simple à l'aide d'OpenAI

par Alexandru Prisacariu31m2024/06/30
Read on Terminal Reader

Trop long; Pour lire

Dans ce didacticiel, je présenterai comment créer un chat IA contextuel simple et plus complexe qui peut être ajouté à n'importe quel site Web. Le client pourra répondre au chat en tapant et en parlant au bot. L'application sera implémentée en JavaScript et pour la version complexe nous utiliserons WebSockets.
featured image - Comment créer un chatbot contextuel simple à l'aide d'OpenAI
Alexandru Prisacariu HackerNoon profile picture
0-item

Dans ce didacticiel, je présenterai comment créer un simple chat contextuel IA qui peut être ajouté à n'importe quel site Web. Le client pourra répondre au chat en tapant et en parlant au bot.


Démonstration du chatbot créé dans cet article

Nous utiliserons les outils d'OpenAI pour les fonctionnalités d'IA. Pour le chat, nous utiliserons ChatGPT, pour le STT (speech-to-text), Whisper, et pour le TTS (text-to-speech), leur TTS.


Je montrerai plusieurs méthodes pour implémenter l'application, en commençant par une méthode naïve ou basique jusqu'à une méthode meilleure mais aussi plus complexe.

L'application sera implémentée en JavaScript (ECMAScript). Cependant, lisez le dernier chapitre si vous êtes intéressé par des implémentations dans d'autres langages.


Tout le code est disponible dans mon dépôt GitHub .

Les bases

Dans ce chapitre, nous aborderons les bases de l'application : la structure du projet et les packages que nous avons utilisés.


Le projet utilisera les packages suivants :

Nom du paquet

Description

express

Pour le serveur HTTP et le routage

openai

Pour tout ce qui concerne OpenAI

sass

Pour convertir les fichiers de style SASS en fichiers CSS

ws

Pour les WebSockets


La structure du projet est la suivante :

Chemin

Description

public

Le répertoire exposé à Internet sous le nom static

public/audio

Le répertoire contenant les fichiers audio publics

public/img

Le répertoire contenant les images publiques

public/index.html

Le point d'entrée

style

Le répertoire contenant le style de la page

version-1

Le répertoire de code source d'implémentation naïve

version-2

Le meilleur répertoire de code source d'implémentation


Le projet peut être vu ici , et l'endroit où un code sera répertorié contiendra également le chemin relatif vers l'endroit où ce code peut être trouvé.


Exécutez npm install suivi d'un npm run build pour convertir le fichier SASS en CSS, et vous êtes prêt à partir.


Pour démarrer l'implémentation naïve, exécutez npm run start-v1 , ou pour exécuter la meilleure implémentation, exécutez npm run start-v2 . N'oubliez pas de définir la variable d'environnement OPENAI_API_KEY .


Sur les systèmes UNIX, vous pouvez exécuter :

 OPENAI_API_KEY=YOU_API_KEY npm run start-v1`

Et sous Windows :

 set OPENAI_API_KEY=YOU_API_KEY npm run start-v1


Voici la page que vous devriez voir lorsque vous accédez à la page :

La page que vous voyez lorsque vous entrez sur le site Web


La mise en œuvre naïve/simple

L'implémentation naïve utilise des requêtes et des réponses HTTP pour envoyer et recevoir des données du serveur.


Nous examinerons chaque fonction importante. Tout le code peut être trouvé dans le lien mentionné ci-dessus.


Voici un diagramme d'activité du fonctionnement de l'application :

Diagramme UML du fonctionnement du chatbot


Regardons ce qui se passe lorsque l'utilisateur appuie sur Entrée sur l'élément de saisie de texte :


 /* version-1/client.js */ inputTextElement.addEventListener('keydown', async (event) => { if (event.code !== 'Enter') return; if (!inputTextElement.value) return; const message = inputTextElement.value; inputTextElement.value = ""; inputTextElement.disabled = true; inputSpeechElement.disabled = true; await addMessage('user', message); inputTextElement.disabled = false; inputSpeechElement.disabled = false; });


Une fois que l'utilisateur appuie sur la touche Entrée et que l'entrée n'est pas vide, nous désactiverons à la fois la saisie de texte et le bouton audio afin que l'utilisateur n'envoie pas un autre message pendant que nous obtenons une réponse au message précédent. Une fois la réponse obtenue, nous restaurons la fonctionnalité.


Après avoir désactivé l'entrée, nous appelons la fonction principale, addMessage , qui fait la magie. Regardons-le :


 /* version-1/client.js */ /** * Add a new message to the chat. * @async * * @param {MessageType} type the type of message * @param {String|Audio} message the data of the message * @param {Object} [settings] additional settings * @param {Number} [settings.audioLength] the length of the audio in seconds * @returns {Promise} the promise resolved when all is done */ async function addMessage(type, message, settings = {}) { const newMsg = document.createElement('div'); newMsg.classList.add('message'); if (type === MessageType.User) { newMsg.classList.add('user'); newMsg.innerHTML = message; } else if (type === MessageType.UserAudio) { newMsg.classList.add('user', 'audio'); newMsg.innerHTML = 'Audio message'; } else { newMsg.classList.add(MessageType.Bot); } const msgsCnt = document.getElementById('friendly-bot-container-msgs'); msgsCnt.appendChild(newMsg); // Keeping own history log if (type === MessageType.User || type === MessageType.Bot) { messageHistory.push({ role: type === MessageType.User ? 'user' : 'assistant', content: message }); } if (type === MessageType.Bot) { if (Settings.UseWriteEffect) { // Create a write effect when the bot responds let speed = Settings.DefaultTypingSpeed; if (settings.audioLength) { const ms = settings.audioLength * 1000 + ((message.match(/,/g) || []).length * 40) + ((message.match(/\./g) || []).length * 70); speed = ms / message.length; } for (let i=0, length=message.length; i < length; i += 1) { newMsg.innerHTML += message.charAt(i); await sleep(speed); } } else { newMsg.innerHTML = message; } } else if (type === MessageType.User || type === MessageType.UserAudio) { let response; if (type === MessageType.User) { response = await sendMessage({ message }); } else if (type === MessageType.UserAudio) { response = await sendMessage({ audio: message }); } if (response.audio) { const audio = convertBase64ToAudio(response.audio); playAudio(audio); } return addMessage(MessageType.Bot, response.answer); } }


La fonction créera un nouveau HTMLDivElement pour le nouveau message et ajoutera la classe CSS en fonction du type du message.


Une fois cela fait, nous stockons le message dans notre historique de discussion côté client.


Ensuite, si le message à ajouter provient du bot, nous l'affichons à l'aide d'un « effet d'écriture ». Nous essayons de synchroniser l'audio, s'il existe, avec la vitesse de frappe en divisant la longueur de l'audio par le nombre de caractères du message.


Si le message ajouté provient de l'utilisateur, nous l'envoyons au serveur pour obtenir la réponse de l'IA en appelant la fonction sendMessage .


La fonction sendMessage effectue simplement une requête HTTP en utilisant fetch sur notre serveur.


Une chose à mentionner : nous générons un identifiant aléatoire pour chaque client que nous envoyons avec chaque message afin que le serveur sache d'où obtenir l'historique des discussions.


L'alternative à l'envoi d'un identifiant au serveur serait d'envoyer l'intégralité de l'historique à chaque fois, mais à chaque message, les données à envoyer augmentent.


 /* version-1/client.js */ /** * Create a random ID of given length. * Taken from https://stackoverflow.com/a/1349426 * * @param {Number} length the length of the generated ID * @returns {String} the generated ID */ function makeID(length) { let result = ''; const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const charactersLength = characters.length; let counter = 0; while (counter < length) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); counter += 1; } return result; } const ChatID = makeID(10); // ... /** * Send a message to the server and return the JSON back. * @async * * @param {Object} data the data to send * @returns {Promise<Object>} the result from the server */ async function sendMessage(data = {}) { try { const response = await fetch(Settings.APISendMessage, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ id: ChatID, ...data }), }); return response.json(); } catch (error) { console.error("Error:", error); } }


Avant de passer du côté du serveur pour voir comment il gère la requête, voyons ce qui se passe lorsque l'utilisateur clique sur le bouton audio :


 /* version-1/client.js */ inputSpeechElement.addEventListener('click', async (_event) => { inputTextElement.value = ""; inputTextElement.disabled = true; inputSpeechElement.disabled = true; const stopRecordButtonElement = document.getElementById('friendly-bot-container-stop-record'); const base64Audio = await recordUserAudio(stopRecordButtonElement); await addMessage(MessageType.UserAudio, base64Audio.substring(`data:${Settings.ClientAudioMimeType};base64,`.length)); inputTextElement.disabled = false; inputSpeechElement.disabled = false; });


C'est très similaire à la gestion de la saisie de texte. La fonction recordUserAudio renverra un audio encodé en base64, et nous en coupons simplement l'en-tête avant de l'envoyer à addMessage .


La fonction recordUserAudio tentera d'obtenir l'autorisation de l'utilisateur pour enregistrer de l'audio et, si elle est accordée, créera un MediaRecorder et commencera l'enregistrement. Nous afficherons également certains éléments de l'interface utilisateur pour informer l'utilisateur que nous enregistrons sa voix et un bouton pour arrêter l'enregistrement une fois terminé.


Une fois le bouton d'arrêt appuyé, nous convertissons les morceaux audio en un objet Blob et le blob en une chaîne codée en base64 et le renvoyons.


Nous parcourons également chaque piste audio et les arrêtons, puis les supprimons. Ceci est nécessaire car, au moins sur Chrome, l'appel mediaRecorder.stop() n'arrêtera pas l'état « d'écoute » du microphone.


L'encodage de l'audio en base64 n'est pas une méthode très efficace pour envoyer l'audio au serveur mais c'est une méthode très simple. Nous examinerons une autre méthode pour envoyer l'audio au serveur dans la section La meilleure implémentation .


 /* version-1/client.js */ /** * Record the user and return it as an base64 encoded audio. * @async * * @param {HTMLElement} stopRecordButtonElement the stop button element * @returns {Promise<String>} the base64 encoded audio */ async function recordUserAudio(stopRecordButtonElement) { let stream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (error) { console.error(`The following getUserMedia error occurred: ${error}`); return; } let chunks = []; const mediaRecorder = new MediaRecorder(stream, { mimeType: Settings.ClientAudioMimeType }); return new Promise((resolve, reject) => { const onStopClick = () => { mediaRecorder.stop(); stopRecordButtonElement.classList.remove('show'); }; mediaRecorder.addEventListener('dataavailable', (event) => { chunks.push(event.data); }); mediaRecorder.addEventListener('stop', (_event) => { const blob = new Blob(chunks, { type: Settings.ClientAudioMimeType }); chunks = []; const base64AudioPromise = blobToBase64(blob); stopRecordButtonElement.removeEventListener('click', onStopClick); // Stop the audio listening stream.getAudioTracks().forEach((track) => { track.stop() stream.removeTrack(track); }); base64AudioPromise.then(resolve).catch(reject); }); stopRecordButtonElement.classList.add('show'); mediaRecorder.start(); stopRecordButtonElement.addEventListener('click', onStopClick); }) }


Voyons maintenant comment la requête est gérée sur le serveur :


 /* version-1/server.js */ app.post('/api/message', async (req, res) => { if (!req.body.message && !req.body.audio) { res.status(400).send('Missing "message" or "audio"'); } if (req.body.message && req.body.audio) { res.status(400).send('Cannot be both "message" and "audio"'); } if (!req.body.id) { res.status(400).send('Missing "id"'); } const messages = getChatMessages(chatHistory, req.body.id); if (req.body.message) { messages.push({ role: "user", content: req.body.message }); } else { let content; try { content = await stt(openai, req.body.audio); } catch (error) { console.error(`(ChatID: ${req.body.id}) Error when trying to convert the user's audio to text:`); console.error(error); res.status(500).end(); return; } messages.push({ role: "user", content }); } let answer; try { answer = await getAnswer(openai, messages); } catch (error) { console.error(`(ChatID: ${req.body.id}) Error when trying to get an answer from ChatGPT:`); console.error(error); res.status(500).end(); return; } let audio; if (Settings.CreateAudioOfAnswer) { try { audio = await tts(openai, answer); } catch (error) { console.error(`(ChatID: ${req.body.id}) Error when trying to convert the ChatGPT's answer to audio:`); console.error(error); res.status(500).end(); return; } } messages.push({ role: "assistant", content: answer }); res.json({ answer, audio }); });


Après avoir vérifié que le client a envoyé les données requises, nous obtenons l'historique des discussions pour l'ID donné (ou créons un nouvel historique des discussions) :


 /* version-1/server.js */ /** * Get the chat history, or create a new one or the given ID. * * @param {Object} chatHistory the global chat history object containing all the chats * @param {String} id the ID of the chat to retrieve * @returns {Object} the chat history for the given `id` */ function getChatMessages(chatHistory, id) { if (!chatHistory[id]) { chatHistory[id] = [ { role: "system", content: Settings.AISystemContent }, { role: "assistant", content: Settings.WelcomeMessage } ]; } return chatHistory[id]; }


Ensuite, si le message reçu est un texte et non un audio, nous ajoutons le message à l'historique du chat. Si le message reçu est un audio, nous appelons la fonction stt qui effectuera l'action de synthèse vocale à l'aide de Whisper d'OpenAI.


La fonction utilisera la méthode openai.audio.transcriptions.create . Le paramètre principal de cette méthode est file , qui doit représenter nos données audio. Nous utilisons la fonction toFile du package openai/uploads pour convertir notre fichier audio encodé en base64 en un fichier qu'OpenAI peut lire. La fonction renverra la transcription de l'audio donné.


 /* version-1/server.js */ /** * Convert speech to text using OpenAI. * @async * * @param {OpenAI} openai the OpenAI instance * @param {String} audio the base64 encoded audio * @returns {Promise<String>} the text */ async function stt(openai, audio) { // Documentation https://platform.openai.com/docs/api-reference/audio/createTranscription const transcription = await openai.audio.transcriptions.create({ file: await toFile(Buffer.from(audio, 'base64'), `audio.${Settings.ClientAudioExtension}`), model: Settings.STTModel, language: Settings.ClientAudioLanguage, // this is optional but helps the model }); return transcription.text; }


Maintenant que nous avons le message, nous envoyons le chat à ChatGPT et attendons une réponse en appelant la fonction getAnswer .


 /* version-1/server.js */ /** * * @param {*} openai * @param {*} messages * @returns */ async function getAnswer(openai, messages) { // Documentation https://platform.openai.com/docs/api-reference/chat/create const completion = await openai.chat.completions.create({ messages, model: Settings.ChatGPTModel, }); return completion.choices[0].message.content; }


La dernière partie concerne la conversion de la réponse de l'IA en audio à l'aide de la fonction tts qui utilise la méthode openai.audio.speech.create pour créer un fichier audio. Le TTS d'OpenAI prend en charge plusieurs formats, mais nous avons choisi mp3 pour ce didacticiel.


Une fois les données audio obtenues, nous les convertissons en un Buffer , et de là, en une chaîne audio codée en base64 à renvoyer au client.


 /* version-1/server.js */ /** * Convert text to speech. * @async * * @param {*} openai * @param {*} input * @returns */ async function tts(openai, input) { // Documentation https://platform.openai.com/docs/api-reference/audio/createSpeech const mp3 = await openai.audio.speech.create({ model: Settings.TTSModel, voice: Settings.TTSVoice, input, response_format: Settings.TTSFormat }); return Buffer.from(await mp3.arrayBuffer()).toString('base64'); }


La meilleure mise en œuvre

Mais pouvons-nous l’améliorer ? Hé bien oui. Au lieu d'utiliser des requêtes HTTP, nous pouvons utiliser WebSockets pour communiquer entre le client et le serveur et demander à ChatGPT de renvoyer les résultats sous forme de flux. De cette façon, nous pouvons créer un effet d'écriture en temps réel car nous diffuserons le résultat de ChatGPT au client en temps réel.


Cette implémentation présente un inconvénient, mais uniquement parce que nous utilisons le TTS d'OpenAI, qui accepte un maximum de 3 requêtes par minute. Par conséquent, pour cette implémentation, nous supprimerons la fonctionnalité de synthèse vocale, mais je donnerai des conseils sur la façon de la réimplémenter et sur ce qu'il faut rechercher lors de cette implémentation.


Alors regardons du code. Nous sommes partis de l'implémentation précédente et avons modifié ce qui était nécessaire pour prendre en charge les WebSockets.


 /* version-2/client.js */ const ws = new WebSocket(Settings.WSAddress); const ChatID = makeID(10); // When the connection to the server is made send the chat ID ws.addEventListener('open', () => { const idMessage = new Uint8Array([ClientMessageID.SetClientID, ...new TextEncoder().encode(ChatID)]); ws.send(idMessage); });


Dans cette section du code client, nous nous connectons au serveur WebSocket. Lorsque la connexion est ouverte, nous envoyons l'ID de chat comme premier message afin que le serveur sache qui nous sommes.


La structure des données/messages envoyés entre le client et le serveur suit ce format :


La structure des messages


Le premier octet représente le type de message que nous envoyons, permettant au serveur de savoir comment gérer la charge utile représentée par les octets suivants dans les données envoyées.


Notez que nous avons configuré le serveur WebSocket pour accepter et envoyer uniquement des données binaires. C'est pourquoi nous enverrons toujours un Uint8Array du côté client et un Buffer du côté serveur. Nous envoyons iy uniquement en binaire car c'est plus efficace, convertissant en texte uniquement ce dont nous avons besoin et restant en binaire (comme les morceaux audio, ce qui doit rester en binaire).


Dans le code suivant, nous traitons les messages reçus du côté serveur :


 /* version-2/client.js */ const subscriptionsToWSMessages = []; ws.addEventListener('message', async (event) => { const data = new Uint8Array(await event.data.arrayBuffer()); const messageType = data[0]; // Because we know all the possible messages are all strings we can convert all the payloads to string const content = new TextDecoder().decode(data.slice(1)); if (!ws.allGood && messageType !== ServerMessageID.OK) { if (messageType === ServerMessageID.Error) { console.error('Something wrong sending the chat ID:', content); } } else if (messageType === ServerMessageID.OK) { ws.allGood = true; } else { let done; for (let i=0, length=subscriptionsToWSMessages.length; i < length; i += 1) { done = await subscriptionsToWSMessages[i](messageType, content); if (done === true) return; } if (!done) { if (messageType === ServerMessageID.Error) { console.error('Unhandled error received from server:', content); } else { console.log(`Unknown message type "${messageType}" received.`); } } } });


Puisque nous savons que tous les messages que nous recevons du côté serveur sont des textes, nous pouvons convertir en toute sécurité l'intégralité de la charge utile en String en utilisant TextDecoder : new TextDecoder().decode(data.slice(1)); .


Tout d’abord, nous attendrons le premier ServerMessageID.OK du serveur, ce qui signifie que l’ID de discussion envoyé est valide.


Pour être flexible, nous utilisons un ensemble de fonctions qui représentent les auditeurs des messages reçus du serveur. Cela nous permet d’être modulaire dans notre approche. Chaque fonction doit renvoyer true ou false : true signifie que le message a été traité, et il n'est pas nécessaire d'appeler le reste des fonctions souscrites.


Pour faciliter l'ajout et la suppression d'abonnés, nous étendons notre objet ws avec les éléments suivants :


 /* version-2/client.js */ /** * Add a function to the list of functions to be called when the socket receives * a new message. The function must return a boolean: if `true` is returned then * is considered that the message was handled and will stop the exection of the * rest of the subscribers in the list. * * @param {Function} fn the function to be added */ ws.subscribeToWSMessage = (fn) => { subscriptionsToWSMessages.push(fn); } /** * Remove an added function from the list of subscribers. * * @param {Function} fn the function to be removed */ ws.unsubscribeToWSMessage = (fn) => { subscriptionsToWSMessages.splice(subscriptionsToWSMessages.indexOf(fn), 1); }


Ensuite, nous étendons à nouveau l'objet ws avec 3 méthodes supplémentaires :

  • sendTextMessage pour envoyer le message texte de l'utilisateur ;


  • sendAudioChunk pour envoyer un morceau audio à partir de l'enregistrement vocal de l'utilisateur ;


  • sendAudioEnd pour indiquer au serveur que l'audio est terminé.


 /* version-2/client.js */ /** * Send a text message to the server. * @async * * @param {String} message the message to send * @param {Function} onNewMessageContent the function to be called with the new answer from bot as it sent from the server */ ws.sendTextMessage = async (message, onNewMessageContent) => { ws.createSubscriptionForBotResponse(onNewMessageContent); const wsMessage = new Uint8Array([ClientMessageID.UserTextMessage, ...new TextEncoder().encode(message)]); ws.send(wsMessage); };


La méthode sendTextMessage accepte le message qui doit être envoyé au serveur et une fonction qui sera appelée plusieurs fois avec le flux de données reçu de ChatGPT.


Dans cette méthode, avant d'envoyer le message au serveur, nous appelons la méthode createSubscriptionForBotResponse , qui gère la création et l'ajout d'un abonnement pour écouter les nouveaux messages afin de gérer la réponse du bot.


 /* version-2/client.js */ /** * Create and add a subscription to listen for the response of the bot to our sent message * * @param {Function} onNewMessageContent the function to be called with the new answer from bot as it sent from the server */ ws.createSubscriptionForBotResponse = (onNewMessageContent) => { const wsMessagesHandler = (messageType, content) => { if (messageType === ServerMessageID.TextChunk) { onNewMessageContent(content); return true; } else if (messageType === ServerMessageID.TextEnd) { ws.unsubscribeToWSMessage(wsMessagesHandler); return true; } return false; } ws.subscribeToWSMessage(wsMessagesHandler); }


La fonction souscrite vérifiera si le message reçu du serveur a le type de message requis pour la réponse du bot ( ServerMessageID.TextChunk ). Si c'est le cas, nous appelons la fonction reçue avec le fragment de texte, qui ajoutera le fragment à la réponse actuelle du bot dans le chat.


Lorsque le bot aura terminé la réponse, le serveur nous enverra un message de type ServerMessageID.TextEnd , indiquant que nous pouvons arrêter d'écouter, auquel cas nous nous désabonnerons de l'écoute des nouveaux messages.


 /* version-2/client.js */ /** * Send an audio chunk to the server. * @async * * @param {Blob} blobChunk the audio blob chunk */ ws.sendAudioChunk = async (blobChunk) => { const wsMessage = new Uint8Array([ClientMessageID.UserAudioChunk, ...new Uint8Array(await blobChunk.arrayBuffer())]); ws.send(wsMessage); }; /** * Tell the server that the audio is done. * * @param {Function} onNewMessageContent the function to be called with the new answer from bot as it sent from the server */ ws.sendAudioEnd = (onNewMessageContent) => { ws.createSubscriptionForBotResponse(onNewMessageContent); ws.send(new Uint8Array([ClientMessageID.UserAudioEnd])); };


Les 2 méthodes suivantes, sendAudioChunk et sendAudioEnd , permettent d'envoyer la voix enregistrée de l'utilisateur au serveur. Le premier, sendAudioChunk , enverra les octets reçus au serveur, tandis que l'autre, sendAudioEnd , enverra un message au serveur indiquant que l'audio est terminé et, comme la méthode sendTextMessage , appellera createSubscriptionForBotResponse pour écouter la réponse. du robot.


Ensuite, nous verrons comment le paramètre onNewMessageContent des méthodes sendTextMessage et sendAudioEnd est envoyé.


Nous avons légèrement modifié la fonction addMessage en la divisant en addUserMessage et addBotMessage . Nous allons simplement regarder addUserMessage :


 /* version-2/client.js */ /** * Add a new message to the chat. * @async * * @param {WebSocket} ws the WebSocket * @param {MessageType} type the type of message * @param {String|Audio} message the data of the message * @returns {Promise} the promise resolved when all is done */ async function addUserMessage(ws, type, message) { createMessageHTMLElement(type, type === MessageType.User ? message : 'Audio message'); // Keeping own history log if (type === MessageType.User) { messageHistory.push({ role: type === MessageType.User ? 'user' : 'assistant', content: message }); } if (type === MessageType.User) { await ws.sendTextMessage(message, addBotMessageInChunks()); } else { await ws.sendAudioEnd(addBotMessageInChunks()); } } /** * Add bot message in chunks. The functions returns another function that when called with * the argument will add that argument to the bot message. * * @returns {Function} the function accept a parameter `content`; when called the `content` is added to the message */ function addBotMessageInChunks() { const newMsg = createMessageHTMLElement(MessageType.Bot); let nextContentIndex = 0; let currentContentIndex = 0; let currentContentPromise; const onNewMessageContent = async (content) => { const thisContentIndex = nextContentIndex; nextContentIndex += 1; while (thisContentIndex !== currentContentIndex) { await currentContentPromise; } currentContentPromise = new Promise(async resolve => { await addContentToMessage(newMsg, content); currentContentIndex += 1; resolve(); }); } return onNewMessageContent; }


La fonction addBotMessageInChunks est responsable de la création et du renvoi de la fonction qui ajoutera le texte/contenu donné au message du bot actuel.


Parce que nous voulons avoir un effet d'écriture sur le message du bot au fur et à mesure de son arrivée, nous devons disposer d'une méthode pour tout synchroniser. Le serveur enverra le texte au fur et à mesure et la fonction addContentToMessage , responsable de la création de l'effet d'écriture, risque de ne pas être prête à temps pour traiter le prochain texte reçu.


Nous avons donc imaginé un mécanisme de synchronisation simple : nous créons 2 compteurs et une variable qui contiendra une promesse. Chaque fois que la fonction renvoyée est appelée, nous attribuons à cet appel l'index suivant (ligne 39), puis augmentons le compteur. La fonction attendra son tour en attendant que la promesse soit résolue, et quand ce sera son tour, elle écrasera la variable de promesse par une nouvelle promesse qui attendra juste que l'effet d'écriture soit fait (ligne 47) puis augmenter le compteur.


 /* version-2/client.js */ /** * Record the user and send the chunks to the server and on end wait for all the chunks to be sent. * @async * * @param {WebSocket} ws the WebSocket * @param {HTMLElement} stopRecordButtonElement the stop button element * @returns {Promise} */ async function recordUserAudio(ws, stopRecordButtonElement) { let stream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (error) { console.error(`The following getUserMedia error occurred: ${error}`); return; } const mediaRecorder = new MediaRecorder(stream, { mimeType: Settings.ClientAudioMimeType }); return new Promise((resolve, _reject) => { const onStopClick = () => { mediaRecorder.stop(); stopRecordButtonElement.classList.remove('show'); }; // Create an array to store the promises of the sent audio chunks so we can make sure that // when the user hit the stop button all the audio chunks are sent const sentAudioChunksPromises = []; mediaRecorder.addEventListener('dataavailable', (event) => { sentAudioChunksPromises.push(ws.sendAudioChunk(event.data)); }); mediaRecorder.addEventListener('stop', async (_event) => { await Promise.all(sentAudioChunksPromises); // Stop the audio listening stream.getAudioTracks().forEach((track) => { track.stop() stream.removeTrack(track); }); resolve(); }); stopRecordButtonElement.classList.add('show'); // The parameter of `start` is called `timeslice` and define how often, in milliseconds, // to fire the `dataavailable` event with the audio chunk mediaRecorder.start(1000); stopRecordButtonElement.addEventListener('click', onStopClick); }) }


La fonction recordUserAudio a également été légèrement modifiée :

  • L'appel mediaRecorder.start() avec l'argument 1000 découpera l'audio de l'utilisateur en morceaux de 1 seconde , qui seront reçus dans le gestionnaire de l'événement dataavailable ;


  • Le gestionnaire de l'événement dataavailable ajoutera la promesse renvoyée par l'appel de ws.sendAudioChunk dans un tableau afin que nous puissions attendre qu'ils se terminent tous dans le gestionnaire de l'événement stop de notre instance MediaRecorder .


C'est à peu près tout pour le côté client.


Passons maintenant du côté serveur pour voir ce qui a été ajouté :


 /* version-2/server.js */ const webSocketServer = new WebSocketServer({ port: Settings.WSPort }); webSocketServer.on('connection', function connection(clientWS) { // Array to keep all the audio chunks until the user is done talking clientWS.audioChunks = []; clientWS.on('error', console.error); clientWS.on('message', async (data, isBinary) => { // ... }); });


Nous créons le serveur WebSocket (à l'aide du package ws ) avec notre port défini. Une fois que nous avons une connexion, nous ajoutons un tableau vide appelé audioChunks au socket client, qui contiendra les morceaux de tampon audio.


Lorsque l'utilisateur envoie un message, nous procédons comme suit :


 /* version-2/server.js */ // ... // If the message is non-binary then reject it. // If the user did not already set the chatID then we close the socket. if (!isBinary) { const errorMsg = 'Only binary messages are supported.'; clientWS.send(Buffer.from([ServerMessageID.Error, errorMsg])); console.error(`(ChatID: ${clientWS.chatID}) Non-binary message received.`); if (!clientWS.chatID) { clientWS.close(1003, errorMsg); } return; } const messageType = data[0]; const payload = data.slice(1); if (!clientWS.chatID && messageType !== ClientMessageID.SetClientID) { clientWS.send(Buffer.from('Error! Please send first your ID')); } else if (messageType === ClientMessageID.SetClientID) { const id = payload.toString('utf8'); if (typeof id === 'string' && id.trim() !== '') { clientWS.chatID = id; clientWS.send(Buffer.from([ServerMessageID.OK])); } else { clientWS.send(Buffer.from([ServerMessageID.Error, ...Buffer.from('Error! Invalid ID. Please send a valid string ID.')])); } } // ...


Tout d'abord, nous vérifions si le message reçu est en binaire. Après cela, nous séparons le type de message ( messageType ) du reste des données ( payload ). Si le client n'a pas encore envoyé l'ID de chat et que le type de message n'est pas prévu pour cela, renvoyez une erreur. Sinon, nous stockons l'ID de discussion s'il est correct dans le socket client.


 /* version-2/server.js */ // ... } else if (messageType === ClientMessageID.UserTextMessage || messageType === ClientMessageID.UserAudioEnd) { const messages = getChatMessages(chatHistory, clientWS.chatID); let messageContent; if (messageType === ClientMessageID.UserTextMessage) { messageContent = payload.toString('utf8'); } else if (messageType === ClientMessageID.UserAudioEnd) { // When the client send the `ClientMessageID.UserAudioEnd` message type it means it clicked the STOP button // Concat all the buffers into a single one const buffer = Buffer.concat(clientWS.audioChunks); // Reset the chunks array clientWS.audioChunks = []; // Send audio to OpenAI to perform the speech-to-text messageContent = await stt(openai, buffer); } messages.push({ role: "user", content: messageContent }); try { await getAnswer(openai, messages, clientWS); } catch (error) { console.error(`(ChatID: ${clientWS.chatID}) Error when trying to get an answer from ChatGPT:`); console.error(error); clientWS.send(Buffer.from([ServerMessageID.Error, ...Buffer.from('Error!')])); return; } } // ...


Une fois que le client envoie un message de type ClientMessageID.UserTextMessage ou ClientMessageID.UserAudioEnd , on récupère, comme auparavant, les messages du chat. Si le message est de type ClientMessageID.UserTextMessage , nous convertirons les données reçues ( payload ) en String .


Si le message est de type ClientMessageID.UserAudioEnd , nous combinerons tous les morceaux de tampon audio en un seul morceau, réinitialiserons le tableau de morceaux et effectuerons l'action de synthèse vocale sur l'audio, qui renverra le texte.


L'étape suivante consiste à créer le nouveau message dans le format accepté par ChatGPT et à interroger ChatGPT pour obtenir une réponse.


 /* version-2/server.js */ // ... } else if (messageType === ClientMessageID.UserAudioChunk) { clientWS.audioChunks.push(payload); } // ...


Le dernier type de message que nous traitons concerne les morceaux audio, qui ajoutent simplement les données reçues au tableau de morceaux audio du socket client.


Voyons maintenant comment la fonction getAnswer a été modifiée afin de prendre en charge les flux :


 /* version-2/server.js */ /** * Get the next message in the conversation * @async * * @param {OpenAI} openai the OpenAI instance * @param {String[]} messages the messages in the OpenAI format * @returns {String} the response from ChatGPT */ async function getAnswer(openai, messages, clientWS) { // Documentation https://platform.openai.com/docs/api-reference/chat/create const stream = await openai.chat.completions.create({ model: Settings.ChatGPTModel, messages, stream: true, }); for await (const chunk of stream) { const content = chunk.choices[0]?.delta?.content; if (!content) continue; clientWS.send(Buffer.from([ServerMessageID.TextChunk, ...Buffer.from(content || "")])); } clientWS.send(Buffer.from([ServerMessageID.TextEnd])); }


En ajoutant simplement stream: true à l'objet envoyé en argument à ChatGPT, il renverra un objet stream que nous pourrons parcourir. Pour chaque bloc de données non vide, nous le renverrons au client. Une fois le flux terminé, nous devons informer le client que la réponse est complète.

Conseils pour ajouter la fonctionnalité TTS

D'accord, d'accord, mais que se passe-t-il si nous disposons d'un service TTS qui prend en charge le streaming ou accepte de nombreuses demandes traitées rapidement ?


Pas de problème : il nous reste juste à ajuster certaines choses côté serveur et client.


Côté serveur, une fois que nous recevons une partie de la réponse de l'IA (dans la fonction getAnswer ), nous devons appeler notre service TTS et envoyer les données audio reçues en réponse au côté client.


Côté client, d'autres changements sont nécessaires :

  • Nous ne pouvons plus transformer les données reçues en texte car nous pouvons désormais recevoir des données audio ;


  • Étant donné que nous pourrions recevoir les données audio suivantes avant la fin de la lecture de l'audio précédent, nous devons introduire une méthode de synchronisation pour savoir quel audio doit être lu ensuite.

Derniers mots

Certains aspects ont été omis dans cet article/implémentation, tels que la gestion des erreurs pour différentes parties du code.


Si vous souhaitez voir une implémentation du serveur dans Go ou Rust, écrivez-moi à [email protected] .


Les diagrammes ont été générés à l'aide de Draw.io et la vérification grammaticale a été effectuée par ChatGPT.


Ressources: