paint-brush
Como criar um chatbot pop-up simples usando OpenAIpor@alexxanderx
1,234 leituras
1,234 leituras

Como criar um chatbot pop-up simples usando OpenAI

por Alexandru Prisacariu31m2024/06/30
Read on Terminal Reader

Muito longo; Para ler

Neste tutorial, apresentarei como criar um chat popup AI simples e um mais complexo que pode ser adicionado a qualquer site. O cliente poderá responder ao chat digitando e falando com o bot. A aplicação será implementada em JavaScript e para a versão complexa utilizaremos WebSockets.
featured image - Como criar um chatbot pop-up simples usando OpenAI
Alexandru Prisacariu HackerNoon profile picture
0-item

Neste tutorial, apresentarei como criar um chat pop-up simples de IA que pode ser adicionado a qualquer site. O cliente poderá responder ao chat digitando e falando com o bot.


Demonstração do chatbot criado neste post

Estaremos usando ferramentas da OpenAI para as funcionalidades de IA. Para o chat usaremos o ChatGPT, para o STT (speech-to-text), Whisper, e para o TTS (text-to-speech), o seu TTS.


Mostrarei vários métodos para implementar o aplicativo, começando com um método ingênuo ou básico até um método melhor, mas também mais complexo.

A aplicação será implementada em JavaScript (ECMAScript). Entretanto, leia o capítulo final se estiver interessado em implementações em outras linguagens.


Todo o código está disponível em meu repositório GitHub .

O básico

Neste capítulo, iremos ao básico da aplicação: a estrutura do projeto e os pacotes que utilizamos.


O projeto usará os seguintes pacotes:

Nome do pacote

Descrição

express

Para o servidor HTTP e roteamento

openai

Para todas as coisas do OpenAI

sass

Para converter os arquivos de estilo SASS em arquivos CSS

ws

Para os WebSockets


A estrutura do projeto é a seguinte:

Caminho

Descrição

public

O diretório exposto na Internet sob o nome static

public/audio

O diretório que contém arquivos de áudio públicos

public/img

O diretório que contém imagens públicas

public/index.html

O ponto de entrada

style

O diretório que contém o estilo da página

version-1

O diretório do código-fonte da implementação ingênua

version-2

O melhor diretório de código-fonte de implementação


O projeto pode ser visto aqui , e onde um código será listado também conterá o caminho relativo para onde esse código pode ser encontrado.


Execute npm install seguido de npm run build para converter o arquivo SASS em CSS e você estará pronto para começar.


Para iniciar a implementação ingênua, execute npm run start-v1 ou, para executar a implementação melhor, execute npm run start-v2 . Não esqueça de definir a variável de ambiente OPENAI_API_KEY .


Em sistemas UNIX você pode executar:

 OPENAI_API_KEY=YOU_API_KEY npm run start-v1`

E no Windows:

 set OPENAI_API_KEY=YOU_API_KEY npm run start-v1


Esta é a página que você deve ver ao acessar a página:

A página que você vê quando entra no site


A implementação ingênua/simples

A implementação ingênua usa solicitações e respostas HTTP para enviar e receber dados do servidor.


Examinaremos cada função importante. Todo o código pode ser encontrado no link mencionado acima.


Aqui está um diagrama de atividades de como o aplicativo funcionará:

Diagrama UML de como funciona o chatbot


Vejamos o que acontece quando o usuário pressiona Enter no elemento de entrada de texto:


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


Assim que o usuário pressionar a tecla Enter e a entrada não estiver vazia, desabilitaremos a entrada de texto e o botão de áudio para que o usuário não envie outra mensagem enquanto obtivermos uma resposta à mensagem anterior. Assim que obtivermos a resposta, restauramos a funcionalidade.


Depois de desabilitarmos a entrada, chamamos a função principal addMessage , que faz a mágica. Vejamos:


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


A função criará um novo HTMLDivElement para a nova mensagem e adicionará a classe CSS com base no tipo da mensagem.


Feito isso, armazenamos a mensagem em nosso histórico de chat do cliente.


A seguir, se a mensagem a ser adicionada for do bot, nós a exibimos usando um “efeito de escrita”. Tentamos sincronizar o áudio, se existir, com a velocidade de digitação dividindo a duração do áudio pelo número de caracteres da mensagem.


Se a mensagem adicionada for do usuário, então a enviamos ao servidor para obter a resposta da IA chamando a função sendMessage .


A função sendMessage apenas faz uma solicitação HTTP usando fetch para nosso servidor.


Uma coisa a mencionar: geramos um ID aleatório para cada cliente que enviamos com cada mensagem para que o servidor saiba de onde obter o histórico do chat.


A alternativa ao envio de um ID de identificação ao servidor seria enviar todo o histórico a cada vez, mas a cada mensagem os dados que precisam ser enviados aumentam.


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


Antes de irmos para o lado do servidor para ver como ele trata a solicitação, vamos ver o que acontece quando o usuário clica no botão de áudio:


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


É muito semelhante ao tratamento de entrada de texto. A função recordUserAudio retornará um áudio codificado em base64, e apenas cortamos o cabeçalho dele antes de enviá-lo para addMessage .


A função recordUserAudio tentará obter permissão do usuário para gravar áudio e, se concedida, criará um MediaRecorder e iniciará a gravação. Também mostraremos alguns elementos da interface do usuário para informar ao usuário que estamos gravando sua voz e um botão para interromper a gravação quando terminar.


Assim que o botão parar é pressionado, convertemos os pedaços de áudio em um objeto Blob e o blob em uma string codificada em base64 e o retornamos.


Também examinamos cada faixa de áudio, interrompemos-as e depois as removemos. Isso é necessário porque, pelo menos no Chrome, chamar mediaRecorder.stop() não interromperá o estado de “escuta” do microfone.


Codificar o áudio para base64 não é um método muito eficiente de enviar o áudio para o servidor, mas é um método muito fácil. Veremos outro método para enviar o áudio ao servidor na seção A melhor implementação .


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


Vejamos agora como a solicitação é tratada no servidor:


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


Depois de verificarmos se o cliente enviou os dados necessários, obtemos o histórico de chat para o ID fornecido (ou criamos um novo histórico de chat):


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


Então, se a mensagem recebida for um texto e não um áudio, adicionamos a mensagem ao histórico do chat. Se a mensagem recebida for um áudio, chamamos a função stt que realizará a ação de fala para texto usando o Whisper da OpenAI.


A função usará o método openai.audio.transcriptions.create . O principal parâmetro deste método é file , que deve representar nossos dados de áudio. Usamos a função toFile do pacote openai/uploads para converter nosso arquivo de áudio codificado em base64 em um arquivo que o OpenAI possa ler. A função retornará a transcrição do áudio fornecido.


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


Agora que temos a mensagem, enviamos o chat para ChatGPT e aguardamos resposta chamando a função 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; }


A última parte é sobre como converter a resposta da IA em áudio usando a função tts que utiliza o método openai.audio.speech.create para criar um arquivo de áudio. O TTS do OpenAI suporta vários formatos, mas escolhemos mp3 para este tutorial.


Uma vez obtidos os dados de áudio, nós os convertemos em um Buffer e, a partir daí, em uma string de áudio codificada em base64 para enviar de volta ao cliente.


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


A melhor implementação

Mas podemos melhorar isso? Bem, sim. Em vez de usar solicitações HTTP, podemos usar WebSockets para comunicação entre o cliente e o servidor e dizer ao ChatGPT para retornar os resultados como um fluxo. Desta forma, podemos criar um efeito de escrita em tempo real, pois transmitiremos o resultado do ChatGPT para o cliente em tempo real.


Esta implementação tem uma desvantagem, mas apenas porque estamos utilizando o TTS da OpenAI, que aceita no máximo 3 solicitações por minuto. Portanto, para esta implementação, abandonaremos o recurso de conversão de texto em fala, mas darei dicas de como reimplementá-lo e o que procurar ao fazê-lo.


Então, vamos dar uma olhada em algum código. Partimos da implementação anterior e alteramos o que era necessário para oferecer suporte a 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); });


Nesta seção do código do cliente, nos conectamos ao servidor WebSocket. Quando a conexão é aberta, enviamos o ID do chat como primeira mensagem para que o servidor saiba quem somos.


A estrutura dos dados/mensagens enviadas entre o cliente e o servidor segue este formato:


A estrutura das mensagens


O primeiro byte representa o tipo de mensagem que estamos enviando, permitindo ao servidor saber como lidar com o payload representado pelos bytes seguintes nos dados enviados.


Observe que configuramos o servidor WebSocket para aceitar e enviar apenas dados binários. É por isso que sempre enviaremos um Uint8Array do lado do cliente e um Buffer do lado do servidor. Estamos enviando iy apenas em binário porque é mais eficiente, convertendo para texto apenas o que precisamos e permanecendo em binário (como os pedaços de áudio, o que precisa permanecer em binário).


No código a seguir, tratamos das mensagens recebidas do lado do servidor:


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


Como sabemos que todas as mensagens que recebemos do lado do servidor são textos, podemos converter com segurança toda a carga útil em uma String usando TextDecoder : new TextDecoder().decode(data.slice(1)); .


Primeiramente, aguardaremos o primeiro ServerMessageID.OK do servidor, que representa que o ID do chat enviado é válido.


Para sermos flexíveis, usamos um conjunto de funções que representam ouvintes das mensagens recebidas do servidor. Isso nos permite ser modulares em nossa abordagem. Cada função deve retornar true ou false : true significa que a mensagem foi processada e não é necessário chamar o restante das funções inscritas.


Para facilitar a adição e remoção de assinantes, estendemos nosso objeto ws com o seguinte:


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


A seguir, estendemos novamente o objeto ws com mais 3 métodos:

  • sendTextMessage para enviar a mensagem de texto do usuário;


  • sendAudioChunk para enviar um trecho de áudio da gravação de voz do usuário;


  • sendAudioEnd para informar ao servidor que o áudio foi concluído.


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


O método sendTextMessage aceita a mensagem que precisa ser enviada ao servidor e uma função que será chamada diversas vezes com o fluxo de dados recebidos do ChatGPT.


Neste método, antes de enviar a mensagem ao servidor, chamamos o método createSubscriptionForBotResponse , que trata da criação e adição de uma assinatura para escutar novas mensagens para lidar com a resposta do 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); }


A função inscrita verificará se a mensagem recebida do servidor possui o tipo de mensagem necessário para a resposta do bot ( ServerMessageID.TextChunk ). Se isso acontecer, chamamos a função recebida com o pedaço de texto, que adicionará o pedaço à resposta atual do bot no chat.


Quando o bot terminar a resposta, o servidor nos enviará uma mensagem do tipo ServerMessageID.TextEnd , indicando que podemos parar de ouvir, momento em que cancelaremos a assinatura de ouvir novas mensagens.


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


Os próximos 2 métodos, sendAudioChunk e sendAudioEnd , servem para enviar a voz gravada do usuário ao servidor. O primeiro, sendAudioChunk , enviará os bytes recebidos ao servidor, enquanto o outro, sendAudioEnd , enviará uma mensagem ao servidor indicando que o áudio foi finalizado e, assim como o método sendTextMessage , chamará createSubscriptionForBotResponse para ouvir a resposta do bot.


A seguir, veremos como o parâmetro onNewMessageContent dos métodos sendTextMessage e sendAudioEnd é enviado.


Modificamos ligeiramente a função addMessage dividindo-a em addUserMessage e addBotMessage . Veremos apenas 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; }


A função addBotMessageInChunks é responsável por criar e retornar a função que anexará o texto/conteúdo fornecido à mensagem do bot atual.


Como queremos ter um efeito de escrita na mensagem do bot à medida que ela chega, precisamos de um método para sincronizar tudo. O servidor enviará o texto conforme ele chegar, e a função addContentToMessage , responsável por criar o efeito de escrita, pode não estar pronta a tempo de lidar com o próximo texto recebido.


Então, criamos um mecanismo de sincronização simples: criamos 2 contadores e uma variável que conterá uma promessa. Cada vez que a função retornada é chamada, atribuímos a essa chamada o próximo índice (linha 39) e então aumentamos o contador. A função irá aguardar a sua vez aguardando a resolução da promessa, e quando chegar a sua vez, irá sobrescrever a variável da promessa por uma nova promessa que irá apenas esperar que o efeito de escrita seja feito (linha 47) e então aumentar o contador.


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


A função recordUserAudio também foi ligeiramente alterada:

  • Chamar mediaRecorder.start() com o argumento 1000 dividirá o áudio do usuário em pedaços de 1 segundo , que serão recebidos no manipulador para o evento dataavailable ;


  • O manipulador para o evento dataavailable adicionará a promessa retornada pela chamada de ws.sendAudioChunk em um array para que possamos esperar que todos eles terminem no manipulador para o evento stop de nossa instância MediaRecorder .


Isso é basicamente tudo para o lado do cliente.


Agora, vamos mudar para o lado do servidor para ver o que foi adicionado:


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


Estamos criando o servidor WebSocket (usando o pacote ws ) com nossa porta definida. Assim que tivermos uma conexão, adicionamos um array vazio chamado audioChunks ao soquete do cliente, que conterá os pedaços do buffer de áudio.


Quando o usuário envia uma mensagem, fazemos o seguinte:


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


Primeiro, verificamos se a mensagem recebida está em binário. Depois disso, separamos o tipo de mensagem ( messageType ) do restante dos dados ( payload ). Caso o cliente ainda não tenha enviado o ID do chat e o tipo de mensagem não seja para isso, retorne um erro. Caso contrário, armazenamos o ID do chat se estiver correto dentro do soquete do cliente.


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


Assim que o cliente envia uma mensagem do tipo ClientMessageID.UserTextMessage ou ClientMessageID.UserAudioEnd , recuperamos, como antes, as mensagens do chat. Se a mensagem for do tipo ClientMessageID.UserTextMessage , converteremos os dados recebidos ( payload ) em String .


Se a mensagem for do tipo ClientMessageID.UserAudioEnd , combinaremos todos os pedaços do buffer de áudio em um único pedaço, redefiniremos a matriz de pedaços e executaremos a ação de conversão de fala em texto no áudio, que retornará o texto.


A próxima etapa é criar a nova mensagem no formato aceito pelo ChatGPT e consultar o ChatGPT para obter uma resposta.


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


O último tipo de mensagem que tratamos é para os pedaços de áudio, que apenas adiciona os dados recebidos ao array de pedaços de áudio do soquete do cliente.


Agora, vamos ver como a função getAnswer foi alterada para oferecer suporte a streams:


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


Simplesmente adicionando stream: true ao objeto enviado como argumento para ChatGPT, ele retornará um objeto stream pelo qual podemos percorrer. Para cada pedaço de dados não vazio, nós o enviaremos de volta ao cliente. Após a conclusão do stream, precisamos notificar o cliente de que a resposta foi concluída.

Dicas para adicionar a funcionalidade TTS de volta

Ok, ok, mas e se tivermos um serviço TTS que suporte streaming ou aceite muitas solicitações que são processadas rapidamente?


Não tem problema: só precisamos ajustar algumas coisas no lado do servidor e do cliente.


No lado do servidor, assim que recebermos uma parte da resposta da IA (na função getAnswer ), precisamos chamar nosso serviço TTS e enviar os dados de áudio recebidos como resposta ao lado do cliente.


Do lado do cliente, são necessárias mais mudanças:

  • Não podemos mais transformar os dados recebidos em texto porque agora podemos receber dados de áudio;


  • Como podemos receber os próximos dados de áudio antes que o áudio anterior termine de ser reproduzido, precisamos introduzir um método de sincronização para controlar qual áudio precisa ser reproduzido em seguida.

Palavras Finais

Existem aspectos que foram omitidos nesta postagem/implementação, como tratamento de erros para diferentes partes do código.


Se você gostaria de ver uma implementação do servidor em Go ou Rust, escreva para mim em [email protected] .


Os diagramas foram gerados no Draw.io e a verificação gramatical foi feita no ChatGPT.


Recursos: