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.
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.
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 |
---|---|
| Für den HTTP-Server und das Routing |
| Für alles rund um OpenAI |
| So konvertieren Sie die SASS-Style-Dateien in CSS-Dateien |
| Für die WebSockets |
Die Projektstruktur ist wie folgt:
Weg | Beschreibung |
---|---|
| Das im Internet unter dem |
| Das Verzeichnis mit den öffentlichen Audiodateien |
| Das Verzeichnis mit öffentlichen Bildern |
| Der Einstiegspunkt |
| Das Verzeichnis, das den Stil der Seite enthält |
| Das Quellcodeverzeichnis der naiven Implementierung |
| 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 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:
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'); }
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:
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:
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.
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.
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:
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: