paint-brush
So erstellen Sie mit OpenAI einen einfachen Popup-Chatbotvon@alexxanderx
Neue Geschichte

So erstellen Sie mit OpenAI einen einfachen Popup-Chatbot

von Alexandru Prisacariu31m2024/06/30
Read on Terminal Reader

Zu lang; Lesen

In diesem Tutorial zeige ich, wie man einen einfachen und einen komplexeren Popup-KI-Chat erstellt, der zu jeder Website hinzugefügt werden kann. Der Client kann auf den Chat antworten, indem er tippt und mit dem Bot spricht. Die Anwendung wird in JavaScript implementiert und für die komplexe Version verwenden wir WebSockets.
featured image - So erstellen Sie mit OpenAI einen einfachen Popup-Chatbot
Alexandru Prisacariu HackerNoon profile picture
0-item

In diesem Tutorial zeige ich, wie man einen einfachen Popup-KI-Chat erstellt, der zu jeder Website hinzugefügt werden kann. Der Kunde kann auf den Chat antworten, indem er tippt und mit dem Bot spricht.


Demonstration des in diesem Beitrag erstellten Chatbots

Für die KI-Funktionen verwenden wir Tools von OpenAI. Für den Chat verwenden wir ChatGPT, für STT (Speech-to-Text) Whisper und für TTS (Text-to-Speech) deren TTS.


Ich zeige mehrere Methoden zur Implementierung der App, angefangen von einer naiven oder einfachen Methode bis hin zu einer besseren, aber auch komplexeren Methode.

Die Anwendung wird in JavaScript (ECMAScript) implementiert. Lesen Sie jedoch das letzte Kapitel, wenn Sie an Implementierungen in anderen Sprachen interessiert sind.


Der gesamte Code ist in meinem GitHub-Repository verfügbar.

Die Grundlagen

In diesem Kapitel gehen wir auf die Grundlagen der Anwendung ein: die Projektstruktur und die Pakete, die wir verwendet haben.


Das Projekt wird die folgenden Pakete verwenden:

Paketnamen

Beschreibung

express

Für den HTTP-Server und das Routing

openai

Für alles rund um OpenAI

sass

So konvertieren Sie die SASS-Style-Dateien in CSS-Dateien

ws

Für die WebSockets


Die Projektstruktur ist wie folgt:

Weg

Beschreibung

public

Das im Internet unter dem static Namen freigegebene Verzeichnis

public/audio

Das Verzeichnis mit den öffentlichen Audiodateien

public/img

Das Verzeichnis mit öffentlichen Bildern

public/index.html

Der Einstiegspunkt

style

Das Verzeichnis, das den Stil der Seite enthält

version-1

Das Quellcodeverzeichnis der naiven Implementierung

version-2

Das bessere Implementierungsquellcodeverzeichnis


Das Projekt kann hier angezeigt werden. Wo ein Code aufgeführt wird, ist auch der relative Pfad angegeben, unter dem der Code zu finden ist.


Führen Sie npm install und anschließend npm run build um die SASS-Datei in CSS zu konvertieren. Dann können Sie loslegen.


Um die naive Implementierung zu starten, führen Sie npm run start-v1 aus, oder um die bessere Implementierung auszuführen, führen Sie npm run start-v2 . Vergessen Sie nicht, die Umgebungsvariable OPENAI_API_KEY zu definieren.


Auf UNIX-Systemen können Sie Folgendes ausführen:

 OPENAI_API_KEY=YOU_API_KEY npm run start-v1`

Und unter Windows:

 set OPENAI_API_KEY=YOU_API_KEY npm run start-v1


Dies ist die Seite, die Sie sehen sollten, wenn Sie auf die Seite zugreifen:

Die Seite, die Sie sehen, wenn Sie die Website aufrufen


Die naive/einfache Implementierung

Die naive Implementierung verwendet HTTP-Anfragen und -Antworten zum Senden und Empfangen von Daten vom Server.


Wir werden uns jede wichtige Funktion ansehen. Den gesamten Code finden Sie unter dem oben genannten Link.


Hier ist ein Aktivitätsdiagramm zur Funktionsweise der App:

UML-Diagramm der Funktionsweise des Chatbots


Schauen wir uns an, was passiert, wenn der Benutzer beim Texteingabeelement die Eingabetaste drückt:


 /* 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; });


Sobald der Benutzer die Eingabetaste drückt und die Eingabe nicht leer ist, deaktivieren wir sowohl die Texteingabe als auch die Audiotaste, damit der Benutzer keine weitere Nachricht sendet, während wir eine Antwort auf die vorherige Nachricht erhalten. Sobald wir die Antwort erhalten, stellen wir die Funktionalität wieder her.


Nachdem wir die Eingabe deaktiviert haben, rufen wir die Hauptfunktion addMessage auf, die den Rest erledigt. Schauen wir sie uns an:


 /* 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); } }


Die Funktion erstellt ein neues HTMLDivElement für die neue Nachricht und fügt die CSS-Klasse basierend auf dem Nachrichtentyp hinzu.


Sobald dies erledigt ist, speichern wir die Nachricht in unserem clientseitigen Chatverlauf.


Als nächstes zeigen wir die hinzuzufügende Nachricht, wenn sie vom Bot stammt, mit einem „Schreibeffekt“ an. Wir versuchen, den Ton, sofern vorhanden, mit der Schreibgeschwindigkeit zu synchronisieren, indem wir die Länge des Tons durch die Anzahl der Zeichen in der Nachricht teilen.


Wenn die hinzugefügte Nachricht vom Benutzer stammt, senden wir sie an den Server, um die Antwort von der KI zu erhalten, indem wir die Funktion sendMessage aufrufen.


Die Funktion sendMessage stellt einfach eine HTTP-Anfrage per fetch an unseren Server.


Eine Sache ist noch zu erwähnen: Wir generieren für jeden Client eine zufällige ID, die wir mit jeder Nachricht senden, damit der Server weiß, woher er den Chatverlauf beziehen muss.


Die Alternative zum Senden einer identifizierenden ID an den Server wäre, jedes Mal den gesamten Verlauf zu senden. Mit jeder Nachricht erhöht sich jedoch die Datenmenge, die gesendet werden muss.


 /* 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); } }


Bevor wir uns auf der Serverseite ansehen, wie die Anfrage verarbeitet wird, schauen wir uns erst an, was passiert, wenn der Benutzer auf die Audio-Schaltfläche klickt:


 /* 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; });


Es ist der Verarbeitung von Texteingaben sehr ähnlich. Die Funktion recordUserAudio gibt ein base64-codiertes Audio zurück und wir schneiden einfach den Header davon ab, bevor wir es an addMessage senden.


Die Funktion recordUserAudio versucht, vom Benutzer die Erlaubnis zur Audioaufzeichnung zu erhalten. Wenn diese erteilt wird, wird ein MediaRecorder erstellt und die Aufzeichnung gestartet. Wir zeigen auch einige UI-Elemente an, um den Benutzer darüber zu informieren, dass wir seine Stimme aufzeichnen, und eine Schaltfläche, um die Aufzeichnung zu stoppen, wenn sie abgeschlossen ist.


Sobald die Stopptaste gedrückt wird, konvertieren wir die Audioblöcke in ein Blob Objekt und den Blob in eine Base64-codierte Zeichenfolge und geben sie zurück.


Wir gehen auch jede Audiospur durch, stoppen sie und entfernen sie dann. Dies ist notwendig, da zumindest bei Chrome der Aufruf von mediaRecorder.stop() den „Abhör“-Zustand des Mikrofons nicht stoppt.


Die Codierung des Audios in base64 ist keine sehr effiziente Methode, um das Audio an den Server zu senden, aber eine sehr einfache Methode. Im Abschnitt „Die bessere Implementierung“ werden wir uns eine andere Methode ansehen, um das Audio an den Server zu senden.


 /* 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); }) }


Schauen wir uns nun an, wie die Anfrage auf dem Server behandelt wird:


 /* 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 }); });


Nachdem wir überprüft haben, ob der Client die erforderlichen Daten gesendet hat, erhalten wir den Chatverlauf für die angegebene ID (oder erstellen einen neuen Chatverlauf):


 /* 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]; }


Wenn die empfangene Nachricht dann ein Text und kein Audio ist, fügen wir die Nachricht dem Chatverlauf hinzu. Wenn die empfangene Nachricht ein Audio ist, rufen wir die Funktion stt auf, die die Spracherkennungsaktion mithilfe von Whisper von OpenAI ausführt.


Die Funktion verwendet die Methode openai.audio.transcriptions.create . Der Hauptparameter dieser Methode ist file , das unsere Audiodaten darstellen muss. Wir verwenden die Funktion toFile aus dem Paket openai/uploads um unsere base64-codierte Audiodatei in eine Datei zu konvertieren, die OpenAI lesen kann. Die Funktion gibt die Transkription des angegebenen Audios zurück.


 /* 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; }


Nachdem wir nun die Nachricht haben, senden wir den Chat an ChatGPT und warten auf eine Antwort, indem wir die Funktion getAnswer aufrufen.


 /* 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; }


Im letzten Teil geht es darum, die Antwort der KI mithilfe der Funktion tts in Audio umzuwandeln. Diese verwendet die Methode openai.audio.speech.create , um eine Audiodatei zu erstellen. Das TTS von OpenAI unterstützt mehrere Formate, wir haben uns für dieses Tutorial jedoch für mp3 entschieden.


Sobald die Audiodaten vorliegen, konvertieren wir sie in einen Buffer und von dort in eine Base64-codierte Audiozeichenfolge, um sie an den Client zurückzusenden.


 /* 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'); }


Die bessere Umsetzung

Aber können wir es besser machen? Nun, ja. Anstatt HTTP-Anfragen zu verwenden, können wir stattdessen WebSockets zur Kommunikation zwischen Client und Server verwenden und ChatGPT anweisen, die Ergebnisse als Stream zurückzugeben. Auf diese Weise können wir einen Echtzeit-Schreibeffekt erzeugen, da wir das Ergebnis in Echtzeit von ChatGPT an den Client streamen.


Diese Implementierung hat einen Nachteil, aber nur, weil wir OpenAIs TTS verwenden, das maximal 3 Anfragen pro Minute akzeptiert. Daher werden wir für diese Implementierung die Text-to-Speech-Funktion weglassen, aber ich werde Tipps geben, wie man sie neu implementiert und worauf man dabei achten muss.


Schauen wir uns also etwas Code an. Wir sind von der vorherigen Implementierung ausgegangen und haben das geändert, was zur Unterstützung von WebSockets erforderlich war.


 /* 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); });


In diesem Abschnitt des Client-Codes stellen wir eine Verbindung zum WebSocket-Server her. Wenn die Verbindung geöffnet wird, senden wir die Chat-ID als erste Nachricht, damit der Server weiß, wer wir sind.


Die Struktur der zwischen Client und Server gesendeten Daten/Nachrichten folgt diesem Format:


Die Struktur der Nachrichten


Das erste Byte stellt den Typ der von uns gesendeten Nachricht dar und ermöglicht dem Server, mit der Nutzlast umzugehen, die durch die folgenden Bytes in den gesendeten Daten dargestellt wird.


Beachten Sie, dass wir den WebSocket-Server so konfiguriert haben, dass er nur binäre Daten akzeptiert und sendet. Aus diesem Grund senden wir immer ein Uint8Array von der Clientseite und einen Buffer von der Serverseite. Wir senden iy nur in Binärform, weil das effizienter ist. Wir konvertieren nur das in Text, was wir brauchen, und der Rest bleibt in Binärform (wie die Audioblöcke, die in Binärform bleiben müssen).


Im folgenden Code verarbeiten wir die von der Serverseite empfangenen Nachrichten:


 /* 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.`); } } } });


Da wir wissen, dass alle Nachrichten, die wir von der Serverseite erhalten, Texte sind, können wir die gesamte Nutzlast mit TextDecoder sicher in einen String umwandeln: new TextDecoder().decode(data.slice(1)); .


Zuerst warten wir auf die erste ServerMessageID.OK vom Server, die anzeigt, dass die gesendete Chat-ID gültig ist.


Um flexibel zu sein, verwenden wir ein Array von Funktionen, die Listener für die vom Server empfangenen Nachrichten darstellen. Dies ermöglicht uns einen modularen Ansatz. Jede Funktion muss true oder false zurückgeben: true bedeutet, dass die Nachricht verarbeitet wurde und es nicht notwendig ist, die restlichen abonnierten Funktionen aufzurufen.


Um das Hinzufügen und Entfernen von Abonnenten zu vereinfachen, erweitern wir unser ws Objekt um Folgendes:


 /* 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); }


Als nächstes erweitern wir das ws Objekt erneut um 3 weitere Methoden:

  • sendTextMessage zum Senden der Textnachricht des Benutzers;


  • sendAudioChunk zum Senden eines Audio-Blocks aus der Sprachaufzeichnung des Benutzers;


  • sendAudioEnd um dem Server mitzuteilen, dass die Audiowiedergabe fertig ist.


 /* 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); };


Die Methode sendTextMessage akzeptiert die Nachricht, die an den Server gesendet werden muss, und eine Funktion, die mit dem von ChatGPT empfangenen Datenstrom mehrmals aufgerufen wird.


Bei dieser Methode rufen wir vor dem Senden der Nachricht an den Server die Methode createSubscriptionForBotResponse auf, die das Erstellen und Hinzufügen eines Abonnements übernimmt, um auf neue Nachrichten zu warten und die Antwort vom Bot zu verarbeiten.


 /* 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); }


Die abonnierte Funktion prüft, ob die empfangene Nachricht vom Server den erforderlichen Nachrichtentyp für die Antwort des Bots hat ( ServerMessageID.TextChunk ). Wenn dies der Fall ist, rufen wir die empfangene Funktion mit dem Textblock auf, die den Block zur aktuellen Bot-Antwort im Chat hinzufügt.


Wenn der Bot mit der Antwort fertig ist, sendet uns der Server eine Nachricht vom Typ ServerMessageID.TextEnd , die angibt, dass wir mit dem Abhören aufhören können. An diesem Punkt melden wir uns vom Abhören neuer Nachrichten ab.


 /* 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])); };


Die nächsten beiden Methoden, sendAudioChunk und sendAudioEnd , dienen zum Senden der aufgezeichneten Stimme des Benutzers an den Server. Die erste Methode, sendAudioChunk , sendet die empfangenen Bytes an den Server, während die andere, sendAudioEnd , eine Nachricht an den Server sendet, die angibt, dass die Audioaufzeichnung abgeschlossen ist, und wie die Methode sendTextMessage createSubscriptionForBotResponse aufruft, um auf die Antwort des Bots zu warten.


Als Nächstes schauen wir uns an, wie der Parameter onNewMessageContent der Methoden sendTextMessage und sendAudioEnd gesendet wird.


Wir haben die Funktion addMessage leicht modifiziert, indem wir sie in addUserMessage und addBotMessage aufgeteilt haben. Wir werden uns nur addUserMessage ansehen:


 /* 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; }


Die Funktion addBotMessageInChunks ist für das Erstellen und Zurückgeben der Funktion verantwortlich, die den angegebenen Text/Inhalt an die aktuelle Bot-Nachricht anhängt.


Da wir einen Schreibeffekt auf die Bot-Nachricht haben möchten, sobald sie eingeht, benötigen wir eine Methode, um alles zu synchronisieren. Der Server sendet den Text, sobald er eingeht, und die Funktion addContentToMessage , die für die Erstellung des Schreibeffekts verantwortlich ist, ist möglicherweise nicht rechtzeitig bereit, den nächsten empfangenen Text zu verarbeiten.


Also haben wir uns einen einfachen Synchronisierungsmechanismus ausgedacht: Wir erstellen zwei Zähler und eine Variable, die ein Versprechen enthält. Jedes Mal, wenn die zurückgegebene Funktion aufgerufen wird, weisen wir diesem Aufruf den nächsten Index zu (Zeile 39) und erhöhen dann den Zähler. Die Funktion wartet, bis sie an der Reihe ist, indem sie darauf wartet, dass das Versprechen eingelöst wird, und wenn sie an der Reihe ist, überschreibt sie die Versprechensvariable mit einem neuen Versprechen, das nur darauf wartet, dass der Schreibeffekt abgeschlossen ist (Zeile 47), und erhöht dann den Zähler.


 /* 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); }) }


Auch die Funktion recordUserAudio wurde leicht verändert:

  • Durch den Aufruf mediaRecorder.start() mit dem Argument 1000 wird das Audio des Benutzers in Abschnitte von 1 Sekunde unterteilt, die im Handler für das Ereignis dataavailable empfangen werden.


  • Der Handler für das dataavailable -Ereignis fügt das durch den Aufruf von ws.sendAudioChunk zurückgegebene Versprechen einem Array hinzu, sodass wir warten können, bis sie alle im Handler für das stop unserer MediaRecorder Instanz abgeschlossen sind.


Dies ist im Wesentlichen alles, was die Client-Seite betrifft.


Wechseln wir nun zur Serverseite, um zu sehen, was hinzugefügt wurde:


 /* 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) => { // ... }); });


Wir erstellen den WebSocket-Server (mit dem ws Paket) mit unserem definierten Port. Sobald wir eine Verbindung haben, fügen wir dem Client-Socket ein leeres Array namens audioChunks hinzu, das die Audiopuffer-Chunks enthält.


Wenn der Benutzer eine Nachricht sendet, tun wir Folgendes:


 /* 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.')])); } } // ...


Zuerst prüfen wir, ob die empfangene Nachricht binär ist. Danach trennen wir den Nachrichtentyp ( messageType ) vom Rest der Daten ( payload ). Wenn der Client die Chat-ID noch nicht gesendet hat und der Nachrichtentyp nicht dafür geeignet ist, geben wir einen Fehler zurück. Andernfalls speichern wir die Chat-ID, wenn sie korrekt ist, im Client-Socket.


 /* 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; } } // ...


Sobald der Client eine Nachricht vom Typ ClientMessageID.UserTextMessage oder ClientMessageID.UserAudioEnd sendet, rufen wir wie zuvor die Nachrichten des Chats ab. Wenn die Nachricht vom Typ ClientMessageID.UserTextMessage ist, konvertieren wir die empfangenen Daten ( payload ) in einen String .


Wenn die Nachricht vom Typ ClientMessageID.UserAudioEnd ist, kombinieren wir alle Audiopufferblöcke zu einem einzigen Block, setzen das Block-Array zurück und führen die Spracherkennungsaktion für das Audio aus, wodurch der Text zurückgegeben wird.


Der nächste Schritt besteht darin, die neue Nachricht im von ChatGPT akzeptierten Format zu erstellen und ChatGPT nach einer Antwort abzufragen.


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


Der letzte Nachrichtentyp, den wir behandeln, ist für die Audio-Chunks, der einfach die empfangenen Daten zum Audio-Chunk-Array des Client-Sockets hinzufügt.


Sehen wir uns nun an, wie die getAnswer Funktion geändert wurde, um Streams zu unterstützen:


 /* 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])); }


Indem wir einfach stream: true zu dem als Argument an ChatGPT gesendeten Objekt hinzufügen, wird ein Stream-Objekt zurückgegeben, das wir durchlaufen können. Für jeden nicht leeren Datenblock senden wir ihn an den Client zurück. Nachdem der Stream fertig ist, müssen wir den Client benachrichtigen, dass die Antwort abgeschlossen ist.

Tipps zum erneuten Hinzufügen der TTS-Funktionalität

Ok, ok, aber was ist, wenn wir einen TTS-Dienst haben, der Streaming unterstützt oder viele Anfragen akzeptiert, die schnell verarbeitet werden?


Kein Problem: Wir müssen lediglich einige Dinge auf der Server- und Clientseite anpassen.


Sobald wir auf der Serverseite einen Teil der Antwort von der KI erhalten haben (in der Funktion getAnswer ), müssen wir unseren TTS-Dienst aufrufen und die empfangenen Audiodaten als Antwort an die Clientseite senden.


Auf der Clientseite sind weitere Änderungen erforderlich:

  • Wir können die empfangenen Daten nicht mehr in Text umwandeln, da wir jetzt Audiodaten empfangen können.


  • Da wir die nächsten Audiodaten möglicherweise erhalten, bevor die Wiedergabe des vorherigen Audios abgeschlossen ist, müssen wir eine Synchronisierungsmethode einführen, um zu verfolgen, welches Audio als nächstes abgespielt werden muss.

Letzte Worte

Es gibt Aspekte, die in diesem Beitrag/dieser Implementierung ausgelassen wurden, wie z. B. die Fehlerbehandlung für verschiedene Teile des Codes.


Wenn Sie eine Implementierung des Servers in Go oder Rust sehen möchten, schreiben Sie mir bitte an [email protected] .


Diagramme wurden mit Draw.io erstellt und die Grammatikprüfung wurde von ChatGPT durchgeführt.


Ressourcen: