paint-brush
Как создать простого всплывающего чат-бота с помощью OpenAIк@alexxanderx
1,234 чтения
1,234 чтения

Как создать простого всплывающего чат-бота с помощью OpenAI

к Alexandru Prisacariu31m2024/06/30
Read on Terminal Reader

Слишком долго; Читать

В этом уроке я расскажу, как создать простой и более сложный всплывающий чат с искусственным интеллектом, который можно добавить на любой веб-сайт. Клиент сможет ответить в чате, набрав и поговорив с ботом. Приложение будет реализовано на JavaScript, а для сложной версии мы будем использовать WebSockets.
featured image - Как создать простого всплывающего чат-бота с помощью OpenAI
Alexandru Prisacariu HackerNoon profile picture
0-item

В этом уроке я расскажу, как создать простой всплывающий чат с искусственным интеллектом, который можно добавить на любой веб-сайт. Клиент сможет ответить в чате, набрав и поговорив с ботом.


Демонстрация чат-бота, созданного в этом посте

Мы будем использовать инструменты OpenAI для функций искусственного интеллекта. Для чата мы будем использовать ChatGPT, для STT (преобразование речи в текст) — Whisper, а для TTS (преобразование текста в речь) — их TTS.


Я покажу несколько методов реализации приложения, начиная с простого или базового метода и заканчивая лучшим, но в то же время и более сложным методом.

Приложение будет реализовано на JavaScript (ECMAScript). Однако прочитайте последнюю главу, если вас интересуют реализации на других языках.


Весь код доступен в моем репозитории на GitHub .

Основы

В этой главе мы рассмотрим основы приложения: структуру проекта и используемые нами пакеты.


В проекте будут использоваться следующие пакеты:

Имя пакета

Описание

express

Для HTTP-сервера и маршрутизации

openai

Обо всем, что связано с OpenAI

sass

Чтобы преобразовать файлы стилей SASS в файлы CSS

ws

Для веб-сокетов


Структура проекта следующая:

Путь

Описание

public

Открытый каталог в Интернете под static именем.

public/audio

Каталог, содержащий общедоступные аудиофайлы

public/img

Каталог, содержащий общедоступные изображения

public/index.html

Точка входа

style

Каталог, содержащий стиль страницы

version-1

Каталог исходного кода наивной реализации

version-2

Каталог исходного кода лучшей реализации


Проект можно увидеть здесь , и там, где будет указан код, также будет указан относительный путь к тому месту, где этот код можно найти.


Запустите npm install а затем npm run build , чтобы преобразовать файл SASS в CSS, и все готово.


Чтобы запустить простую реализацию, запустите npm run start-v1 или, чтобы запустить лучшую реализацию, запустите npm run start-v2 . Не забудьте определить переменную среды OPENAI_API_KEY .


В системах UNIX вы можете запустить:

 OPENAI_API_KEY=YOU_API_KEY npm run start-v1`

И в Windows:

 set OPENAI_API_KEY=YOU_API_KEY npm run start-v1


Это страница, которую вы должны увидеть при доступе к ней:

Страница, которую вы видите при входе на сайт


Наивная/простая реализация

Простая реализация использует HTTP-запросы и ответы для отправки и получения данных с сервера.


Мы рассмотрим каждую важную функцию. Весь код можно найти по вышеупомянутой ссылке.


Вот диаграмма действий того, как приложение будет работать:

UML-диаграмма того, как работает чат-бот


Давайте посмотрим, что происходит, когда пользователь нажимает Enter на элементе ввода текста:


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


Как только пользователь нажимает клавишу ввода и ввод не пуст, мы отключим как текстовый ввод, так и кнопку звука, чтобы пользователь не отправлял другое сообщение, пока мы получаем ответ на предыдущее сообщение. Как только получим ответ, восстанавливаем работоспособность.


После отключения ввода мы вызываем основную функцию addMessage , которая творит чудеса. Давайте посмотрим на это:


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


Функция создаст новый HTMLDivElement для нового сообщения и добавит класс CSS в зависимости от типа сообщения.


Как только это будет сделано, мы сохраним сообщение в истории чата на стороне клиента.


Далее, если добавляемое сообщение исходит от бота, мы отображаем его с помощью «эффекта письма». Мы пытаемся синхронизировать звук, если он есть, со скоростью набора текста, разделив длину звука на количество символов в сообщении.


Если добавленное сообщение исходит от пользователя, мы отправляем его на сервер, чтобы получить ответ от ИИ, вызывая функцию sendMessage .


Функция sendMessage просто отправляет HTTP-запрос с помощью fetch на наш сервер.


Следует упомянуть одну вещь: мы генерируем случайный идентификатор для каждого клиента, который отправляем с каждым сообщением, чтобы сервер знал, откуда получить историю чата.


Альтернативой отправке идентификационного идентификатора на сервер была бы отправка всей истории каждый раз, но с каждым сообщением объем данных, которые необходимо отправить, увеличивается.


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


Прежде чем мы перейдем к стороне сервера и посмотрим, как он обрабатывает запрос, давайте посмотрим, что происходит, когда пользователь нажимает кнопку звука:


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


Это очень похоже на обработку ввода текста. Функция recordUserAudio вернет аудио в кодировке Base64, и мы просто вырезаем его заголовок перед отправкой в addMessage .


Функция recordUserAudio попытается получить от пользователя разрешение на запись звука и, если оно предоставлено, создаст MediaRecorder и начнет запись. Мы также покажем некоторые элементы пользовательского интерфейса, чтобы сообщить пользователю, что мы записываем его голос, и кнопку, позволяющую остановить запись после завершения.


После нажатия кнопки «Стоп» мы конвертируем аудиофрагменты в объект Blob , а blob — в строку в кодировке Base64 и возвращаем ее.


Мы также просматриваем каждую звуковую дорожку и останавливаем их, затем удаляем. Это необходимо, потому что, по крайней мере в Chrome, вызов mediaRecorder.stop() не остановит состояние «прослушивания» микрофона.


Кодирование звука в base64 — не очень эффективный метод отправки звука на сервер, но очень простой метод. Мы рассмотрим другой метод отправки звука на сервер в разделе «Лучшая реализация» .


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


Давайте теперь посмотрим, как запрос обрабатывается на сервере:


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


После того, как мы проверим, что клиент отправил необходимые данные, мы получаем историю чата для данного идентификатора (или создаем новую историю чата):


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


Затем, если полученное сообщение представляет собой текст, а не аудио, мы добавляем сообщение в историю чата. Если полученное сообщение является аудио, мы вызываем функцию stt , которая выполнит преобразование речи в текст с помощью OpenAI Whisper .


Функция будет использовать метод openai.audio.transcriptions.create . Основным параметром этого метода является file , который должен представлять наши аудиоданные. Мы используем функцию toFile из пакета openai/uploads для преобразования нашего аудиофайла в кодировке Base64 в файл, который может прочитать OpenAI. Функция вернет транскрипцию данного аудио.


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


Теперь, когда у нас есть сообщение, мы отправляем чат в ChatGPT и ждем ответа, вызывая функцию 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; }


Последняя часть посвящена преобразованию ответа AI в аудио с помощью функции tts , которая использует метод openai.audio.speech.create для создания аудиофайла. TTS OpenAI поддерживает несколько форматов, но для этого урока мы выбрали mp3 .


Как только аудиоданные получены, мы преобразуем их в Buffer , а оттуда в аудиостроку в кодировке Base64 для отправки обратно клиенту.


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


Лучшая реализация

Но можем ли мы сделать это лучше? Ну да. Вместо использования HTTP-запросов мы можем использовать WebSockets для связи между клиентом и сервером и указать ChatGPT возвращать результаты в виде потока. Таким образом, мы можем создать эффект записи в реальном времени, поскольку мы будем передавать результат из ChatGPT клиенту в режиме реального времени.


У этой реализации есть недостаток, но только потому, что мы используем TTS OpenAI, который принимает максимум 3 запроса в минуту. Поэтому в этой реализации мы откажемся от функции преобразования текста в речь, но я дам советы, как ее реализовать заново и на что при этом обратить внимание.


Итак, давайте посмотрим на код. Мы начали с предыдущей реализации и изменили то, что было необходимо для поддержки 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); });


В этом разделе клиентского кода мы подключаемся к серверу WebSocket. Когда соединение открывается, мы отправляем идентификатор чата в качестве первого сообщения, чтобы сервер знал, кто мы.


Структура данных/сообщений, отправляемых между клиентом и сервером, соответствует следующему формату:


Структура сообщений


Первый байт представляет тип сообщения, которое мы отправляем, позволяя серверу знать, как обрабатывать полезную нагрузку, представленную следующими байтами в отправленных данных.


Обратите внимание, что мы настроили сервер WebSocket на прием и отправку только двоичных данных. Вот почему мы всегда будем отправлять Uint8Array со стороны клиента и Buffer со стороны сервера. Мы отправляем iy только в двоичном формате, потому что это более эффективно: преобразуем в текст только то, что нам нужно, и остаемся в двоичном формате (например, аудиофрагменты, которые должны оставаться в двоичном формате).


В следующем коде мы обрабатываем сообщения, полученные со стороны сервера:


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


Поскольку мы знаем, что все сообщения, которые мы получаем со стороны сервера, являются текстами, мы можем безопасно преобразовать всю полезную нагрузку в String с помощью TextDecoder : new TextDecoder().decode(data.slice(1)); .


Сначала мы дождемся первого ServerMessageID.OK от сервера, который означает, что отправленный идентификатор чата действителен.


Чтобы быть гибкими, мы используем массив функций, которые представляют прослушиватели сообщений, полученных от сервера. Это позволяет нам использовать модульный подход. Каждая функция должна возвращать true или false : true означает, что сообщение было обработано, и нет необходимости вызывать остальные подписанные функции.


Чтобы упростить добавление и удаление подписчиков, мы расширим наш объект ws следующим образом:


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


Далее мы снова расширяем объект ws еще тремя методами:

  • sendTextMessage для отправки текстового сообщения пользователя;


  • sendAudioChunk для отправки аудиофрагмента из голосовой записи пользователя;


  • sendAudioEnd для сообщения серверу о завершении аудио.


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


Метод sendTextMessage принимает сообщение, которое необходимо отправить на сервер, и функцию, которая будет вызываться несколько раз с потоком данных, полученных от ChatGPT.


В этом методе перед отправкой сообщения на сервер мы вызываем метод createSubscriptionForBotResponse , который обрабатывает создание и добавление подписки для прослушивания новых сообщений для обработки ответа от бота.


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


Подписная функция проверит, имеет ли полученное от сервера сообщение требуемый тип сообщения для ответа бота ( ServerMessageID.TextChunk ). Если да, то мы вызываем полученную функцию с текстовым чаном, которая добавит этот чанк к текущему ответу бота в чате.


Когда бот завершит ответ, сервер отправит нам сообщение типа ServerMessageID.TextEnd , указывающее, что мы можем прекратить прослушивание, и в этот момент мы откажемся от прослушивания новых сообщений.


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


Следующие два метода, sendAudioChunk и sendAudioEnd , предназначены для отправки записанного голоса пользователя на сервер. Первый, sendAudioChunk , отправит полученные байты на сервер, а другой, sendAudioEnd , отправит сообщение на сервер, указывающее, что аудио завершено, и, как и метод sendTextMessage , вызовет createSubscriptionForBotResponse для прослушивания ответа. от бота.


Далее мы рассмотрим, как отправляется параметр onNewMessageContent из методов sendTextMessage и sendAudioEnd .


Мы немного модифицировали функцию addMessage , разделив ее на addUserMessage и addBotMessage . Мы просто посмотрим на 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; }


Функция addBotMessageInChunks отвечает за создание и возврат функции, которая добавит заданный текст/содержимое к текущему сообщению бота.


Поскольку мы хотим иметь эффект записи на сообщение бота по мере его поступления, нам нужен метод для синхронизации всего. Сервер отправит текст по мере поступления, а функция addContentToMessage , отвечающая за создание эффекта записи, может быть не готова вовремя обработать следующий полученный текст.


Итак, мы придумали простой механизм синхронизации: создаем 2 счетчика и переменную, которая будет содержать промис. Каждый раз, когда вызывается возвращаемая функция, мы присваиваем этому вызову следующий индекс (строка 39), а затем увеличиваем счетчик. Функция будет ждать своей очереди, ожидая разрешения обещания, и когда придет ее очередь, она перезапишет переменную обещания новым обещанием, которое просто будет ждать завершения эффекта записи (строка 47), а затем увеличить счетчик.


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


Функция recordUserAudio также была немного изменена:

  • Вызов mediaRecorder.start() с аргументом 1000 разделит аудио пользователя на куски по 1 секунде , которые будут получены в обработчике события dataavailable ;


  • Обработчик события dataavailable добавит обещание, возвращаемое вызовом ws.sendAudioChunk в массив, чтобы мы могли дождаться завершения всех из них в обработчике события stop нашего экземпляра MediaRecorder .


Это в значительной степени все для клиентской стороны.


Теперь давайте переключимся на серверную часть и посмотрим, что было добавлено:


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


Мы создаем сервер WebSocket (используя пакет ws ) с определенным портом. Как только у нас есть соединение, мы добавляем пустой массив с именем audioChunks в клиентский сокет, который будет содержать фрагменты аудиобуфера.


Когда пользователь отправляет сообщение, мы делаем следующее:


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


Сначала мы проверяем, является ли полученное сообщение двоичным. После этого мы отделяем тип сообщения ( messageType ) от остальных данных ( payload ). Если клиент еще не отправил идентификатор чата и тип сообщения не для этого, то верните ошибку. В противном случае мы сохраняем идентификатор чата, если он правильный, внутри клиентского сокета.


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


Как только клиент отправляет сообщение типа ClientMessageID.UserTextMessage или ClientMessageID.UserAudioEnd , мы, как и раньше, получаем сообщения чата. Если сообщение имеет тип ClientMessageID.UserTextMessage , мы преобразуем полученные данные ( payload ) в String .


Если сообщение имеет тип ClientMessageID.UserAudioEnd , мы объединим все фрагменты аудиобуфера в один фрагмент, сбросим массив фрагментов и выполним действие преобразования речи в текст над аудио, которое вернет текст.


Следующий шаг — создать новое сообщение в формате, принятом ChatGPT, и запросить ответ у ChatGPT.


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


Последний тип сообщений, который мы обрабатываем, относится к аудиофрагментам, которые просто добавляют полученные данные в массив аудиофрагментов клиентского сокета.


Теперь давайте посмотрим, как была изменена функция getAnswer для поддержки потоков:


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


Просто добавив stream: true к объекту, отправленному в качестве аргумента ChatGPT, он вернет объект потока, который мы можем пройти в цикле. Для каждого непустого фрагмента данных мы отправим его обратно клиенту. После завершения потока нам нужно уведомить клиента о том, что ответ завершен.

Советы по возвращению функциональности TTS

Хорошо, хорошо, но что, если у нас есть служба TTS, которая поддерживает потоковую передачу или принимает множество запросов, которые быстро обрабатываются?


Нет проблем: нам просто нужно настроить кое-что на стороне сервера и клиента.


На стороне сервера, как только мы получим часть ответа от ИИ (в функции getAnswer ), нам нужно вызвать нашу службу TTS и отправить аудиоданные, полученные в качестве ответа, на клиентскую сторону.


На стороне клиента необходимы дополнительные изменения:

  • Мы больше не можем преобразовывать полученные данные в текст, потому что теперь мы можем получать аудиоданные;


  • Поскольку мы можем получить следующие аудиоданные до того, как предыдущий звук будет воспроизведен, нам необходимо ввести метод синхронизации, чтобы отслеживать, какой звук необходимо воспроизвести следующим.

Заключительные слова

Есть аспекты, которые были опущены в этой публикации/реализации, например, обработка ошибок в различных частях кода.


Если вы хотите увидеть реализацию сервера на Go или Rust, напишите мне на [email protected] .


Диаграммы были созданы с помощью Draw.io, а проверка грамматики выполнялась ChatGPT.


Ресурсы: