paint-brush
Cách tạo Chatbot bật lên đơn giản bằng OpenAItừ tác giả@alexxanderx
1,222 lượt đọc
1,222 lượt đọc

Cách tạo Chatbot bật lên đơn giản bằng OpenAI

từ tác giả Alexandru Prisacariu31m2024/06/30
Read on Terminal Reader

dài quá đọc không nổi

Trong hướng dẫn này, tôi sẽ trình bày cách tạo một cuộc trò chuyện AI bật lên đơn giản và phức tạp hơn có thể được thêm vào bất kỳ trang web nào. Khách hàng sẽ có thể trả lời cuộc trò chuyện bằng cách nhập và nói chuyện với bot. Ứng dụng sẽ được triển khai bằng JavaScript và đối với phiên bản phức tạp, chúng tôi sẽ sử dụng WebSockets.
featured image - Cách tạo Chatbot bật lên đơn giản bằng OpenAI
Alexandru Prisacariu HackerNoon profile picture
0-item

Trong hướng dẫn này, tôi sẽ trình bày cách tạo một cuộc trò chuyện AI bật lên đơn giản có thể được thêm vào bất kỳ trang web nào. Khách hàng sẽ có thể trả lời cuộc trò chuyện bằng cách nhập và nói chuyện với bot.


Trình diễn chatbot được tạo trong bài đăng này

Chúng tôi sẽ sử dụng các công cụ từ OpenAI cho các chức năng AI. Để trò chuyện, chúng tôi sẽ sử dụng ChatGPT, cho STT (chuyển giọng nói thành văn bản), Whisper và cho TTS (chuyển văn bản thành giọng nói), TTS của họ.


Tôi sẽ chỉ ra nhiều phương pháp để triển khai ứng dụng, bắt đầu từ phương pháp đơn giản hoặc cơ bản đến phương pháp tốt hơn nhưng cũng phức tạp hơn.

Ứng dụng sẽ được triển khai bằng JavaScript (ECMAScript). Tuy nhiên, hãy đọc chương cuối nếu bạn quan tâm đến việc triển khai bằng các ngôn ngữ khác.


Tất cả mã đều có sẵn trong kho GitHub của tôi .

Những thứ cơ bản

Trong chương này, chúng ta sẽ đi đến những điều cơ bản của ứng dụng: cấu trúc dự án và các gói mà chúng ta đã sử dụng.


Dự án sẽ sử dụng các gói sau:

Tên gói hàng

Sự miêu tả

express

Đối với máy chủ HTTP và định tuyến

openai

Dành cho tất cả nội dung OpenAI

sass

Để chuyển đổi các tệp kiểu SASS thành tệp CSS

ws

Dành cho WebSockets


Cấu trúc dự án như sau:

Con đường

Sự miêu tả

public

Thư mục được hiển thị trên internet dưới tên static

public/audio

Thư mục chứa các tập tin âm thanh công cộng

public/img

Thư mục chứa hình ảnh công khai

public/index.html

Điểm vào

style

Thư mục chứa style của trang

version-1

Thư mục mã nguồn triển khai đơn giản

version-2

Thư mục mã nguồn triển khai tốt hơn


Bạn có thể xem dự án tại đây và nơi mã sẽ được liệt kê cũng sẽ chứa đường dẫn tương đối đến nơi có thể tìm thấy mã đó.


Chạy npm install theo sau là npm run build để chuyển đổi tệp SASS sang CSS và bạn đã sẵn sàng.


Để bắt đầu triển khai đơn giản, hãy chạy npm run start-v1 hoặc để chạy triển khai tốt hơn, hãy chạy npm run start-v2 . Đừng quên xác định biến môi trường OPENAI_API_KEY .


Trên hệ thống UNIX bạn có thể chạy:

 OPENAI_API_KEY=YOU_API_KEY npm run start-v1`

Và trên Windows:

 set OPENAI_API_KEY=YOU_API_KEY npm run start-v1


Đây là trang bạn sẽ thấy khi truy cập trang:

Trang bạn nhìn thấy khi vào trang web


Cách thực hiện đơn giản/ngây thơ

Việc triển khai đơn giản sử dụng các yêu cầu và phản hồi HTTP để gửi và nhận dữ liệu từ máy chủ.


Chúng ta sẽ xem xét từng chức năng quan trọng. Tất cả các mã có thể được tìm thấy trong liên kết nêu trên.


Đây là sơ đồ hoạt động về cách ứng dụng sẽ hoạt động:

Sơ đồ UML về cách thức hoạt động của chatbot


Hãy xem điều gì xảy ra khi người dùng nhấn enter trên phần tử nhập văn bản:


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


Sau khi người dùng nhấn phím enter và đầu vào không trống, chúng tôi sẽ tắt cả kiểu nhập văn bản và nút âm thanh để người dùng không gửi tin nhắn khác trong khi chúng tôi nhận được phản hồi cho tin nhắn trước đó. Sau khi nhận được câu trả lời, chúng tôi sẽ khôi phục chức năng.


Sau khi vô hiệu hóa đầu vào, chúng tôi gọi hàm chính, addMessage , chức năng này thực hiện điều kỳ diệu. Hãy nhìn vào nó:


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


Hàm sẽ tạo HTMLDivElement mới cho tin nhắn mới và thêm lớp CSS dựa trên loại tin nhắn.


Sau khi hoàn tất, chúng tôi sẽ lưu tin nhắn vào lịch sử trò chuyện phía khách hàng của mình.


Tiếp theo, nếu thông báo cần thêm là từ bot, chúng tôi sẽ hiển thị thông báo đó bằng "hiệu ứng viết". Chúng tôi cố gắng đồng bộ hóa âm thanh, nếu có, với tốc độ gõ bằng cách chia độ dài của âm thanh cho số ký tự trong tin nhắn.


Nếu tin nhắn được thêm là từ người dùng thì chúng tôi sẽ gửi nó đến máy chủ để nhận câu trả lời từ AI bằng cách gọi hàm sendMessage .


Hàm sendMessage chỉ thực hiện một yêu cầu HTTP bằng cách fetch tới máy chủ của chúng tôi.


Một điều cần đề cập: chúng tôi tạo một ID ngẫu nhiên cho mỗi khách hàng mà chúng tôi gửi cùng với mỗi tin nhắn để máy chủ biết lấy lịch sử trò chuyện từ đâu.


Giải pháp thay thế cho việc gửi ID nhận dạng đến máy chủ là gửi toàn bộ lịch sử mỗi lần, nhưng với mỗi tin nhắn, dữ liệu cần gửi sẽ tăng lên.


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


Trước khi chúng ta đến phía máy chủ để xem cách nó xử lý yêu cầu, hãy xem điều gì xảy ra khi người dùng nhấp vào nút âm thanh:


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


Nó rất giống với việc xử lý nhập văn bản. Hàm recordUserAudio sẽ trả về âm thanh được mã hóa base64 và chúng ta chỉ cần cắt tiêu đề của nó trước khi gửi đến addMessage .


Hàm recordUserAudio sẽ cố gắng xin phép người dùng để ghi âm và nếu được phép, nó sẽ tạo MediaRecorder và bắt đầu ghi. Chúng tôi cũng sẽ hiển thị một số thành phần giao diện người dùng để cho người dùng biết rằng chúng tôi đang ghi âm giọng nói của họ và một nút để dừng ghi khi hoàn tất.


Sau khi nhấn nút dừng, chúng tôi chuyển đổi các đoạn âm thanh thành đối tượng Blob và blob thành chuỗi được mã hóa base64 và trả về nó.


Chúng tôi cũng xem qua từng bản âm thanh và dừng chúng, sau đó xóa chúng. Điều này là cần thiết vì ít nhất là trên Chrome, việc gọi mediaRecorder.stop() sẽ không dừng trạng thái "nghe" của micrô.


Mã hóa âm thanh thành base64 không phải là phương pháp gửi âm thanh đến máy chủ rất hiệu quả nhưng là một phương pháp rất dễ dàng. Chúng ta sẽ xem xét một phương pháp khác để gửi âm thanh đến máy chủ trong phần Triển khai tốt hơn .


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


Bây giờ chúng ta hãy xem cách xử lý yêu cầu trên máy chủ:


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


Sau khi kiểm tra xem khách hàng đã gửi dữ liệu cần thiết hay chưa, chúng tôi sẽ nhận được lịch sử trò chuyện cho ID đã cho (hoặc tạo lịch sử trò chuyện mới):


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


Sau đó, nếu tin nhắn nhận được là văn bản chứ không phải âm thanh, chúng tôi sẽ thêm tin nhắn đó vào lịch sử trò chuyện. Nếu tin nhắn nhận được là âm thanh, chúng tôi gọi hàm stt sẽ thực hiện hành động chuyển giọng nói thành văn bản bằng Whisper của OpenAI.


Hàm sẽ sử dụng phương thức openai.audio.transcriptions.create . Tham số chính của phương thức này là file , phải thể hiện dữ liệu âm thanh của chúng ta. Chúng tôi sử dụng hàm toFile từ gói openai/uploads để chuyển đổi tệp âm thanh được mã hóa base64 thành tệp mà OpenAI có thể đọc được. Hàm sẽ trả về bản phiên âm của âm thanh đã cho.


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


Bây giờ chúng ta đã có tin nhắn, chúng ta gửi cuộc trò chuyện đến ChatGPT và chờ phản hồi bằng cách gọi hàm 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; }


Phần cuối cùng là về việc chuyển đổi phản hồi từ AI sang âm thanh bằng hàm tts sử dụng phương thức openai.audio.speech.create để tạo tệp âm thanh. TTS của OpenAI hỗ trợ nhiều định dạng nhưng chúng tôi đã chọn mp3 cho hướng dẫn này.


Sau khi nhận được dữ liệu âm thanh, chúng tôi chuyển đổi nó thành Buffer và từ đó thành chuỗi âm thanh được mã hóa base64 để gửi lại cho khách hàng.


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


Việc thực hiện tốt hơn

Nhưng liệu chúng ta có thể làm nó tốt hơn không? Vâng, vâng. Thay vì sử dụng các yêu cầu HTTP, chúng ta có thể sử dụng WebSockets để liên lạc giữa máy khách và máy chủ và yêu cầu ChatGPT trả về kết quả dưới dạng luồng. Bằng cách này, chúng tôi có thể tạo hiệu ứng viết theo thời gian thực vì chúng tôi sẽ truyền kết quả từ ChatGPT đến máy khách theo thời gian thực.


Việc triển khai này có một nhược điểm nhưng chỉ vì chúng tôi đang sử dụng TTS của OpenAI, chấp nhận tối đa 3 yêu cầu mỗi phút. Do đó, để triển khai tính năng này, chúng tôi sẽ loại bỏ tính năng chuyển văn bản thành giọng nói nhưng tôi sẽ đưa ra các mẹo về cách triển khai lại tính năng này và những điều cần lưu ý khi thực hiện việc đó.


Vì vậy, hãy xem xét một số mã. Chúng tôi đã bắt đầu từ quá trình triển khai trước đó và thay đổi những gì cần thiết để hỗ trợ 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); });


Trong phần mã máy khách này, chúng tôi kết nối với máy chủ WebSocket. Khi kết nối được mở, chúng tôi gửi ID trò chuyện dưới dạng tin nhắn đầu tiên để máy chủ biết chúng tôi là ai.


Cấu trúc của dữ liệu/tin nhắn được gửi giữa máy khách và máy chủ tuân theo định dạng sau:


Cấu trúc của tin nhắn


Byte đầu tiên thể hiện loại tin nhắn chúng ta đang gửi, cho phép máy chủ biết cách xử lý tải trọng được biểu thị bằng các byte sau trong dữ liệu được gửi.


Lưu ý rằng chúng tôi đã định cấu hình máy chủ WebSocket để chỉ chấp nhận và gửi dữ liệu nhị phân. Đây là lý do tại sao chúng tôi sẽ luôn gửi Uint8Array từ phía máy khách và Buffer từ phía máy chủ. Chúng tôi chỉ gửi iy ở dạng nhị phân vì nó hiệu quả hơn, chỉ chuyển đổi thành văn bản những gì chúng tôi cần và giữ nguyên ở dạng nhị phân (như đoạn âm thanh, những gì cần giữ nguyên ở dạng nhị phân).


Trong đoạn mã sau, chúng tôi xử lý các tin nhắn nhận được từ phía máy chủ:


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


Vì chúng tôi biết rằng tất cả tin nhắn chúng tôi nhận được từ phía máy chủ đều là văn bản, nên chúng tôi có thể chuyển đổi toàn bộ tải trọng thành String một cách an toàn bằng cách sử dụng TextDecoder : new TextDecoder().decode(data.slice(1)); .


Đầu tiên, chúng ta sẽ đợi ServerMessageID.OK đầu tiên từ máy chủ, điều này thể hiện rằng ID trò chuyện đã gửi là hợp lệ.


Để linh hoạt, chúng tôi sử dụng một loạt các hàm đại diện cho người nghe các tin nhắn nhận được từ máy chủ. Điều này cho phép chúng tôi có tính mô-đun hóa trong cách tiếp cận của mình. Mỗi hàm phải trả về true hoặc false : true có nghĩa là tin nhắn đã được xử lý và không cần thiết phải gọi phần còn lại của các hàm được đăng ký.


Để dễ dàng thêm và xóa người đăng ký, chúng tôi mở rộng đối tượng ws của mình như sau:


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


Tiếp theo, chúng ta mở rộng lại đối tượng ws với 3 phương thức nữa:

  • sendTextMessage để gửi tin nhắn văn bản của người dùng;


  • sendAudioChunk để gửi đoạn âm thanh từ bản ghi âm giọng nói của người dùng;


  • sendAudioEnd để thông báo cho máy chủ rằng âm thanh đã hoàn tất.


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


Phương thức sendTextMessage chấp nhận tin nhắn cần gửi đến máy chủ và một hàm sẽ được gọi nhiều lần với luồng dữ liệu nhận được từ ChatGPT.


Trong phương thức này, trước khi gửi tin nhắn đến máy chủ, chúng ta gọi phương thức createSubscriptionForBotResponse , phương thức này xử lý việc tạo và thêm đăng ký để nghe tin nhắn mới nhằm xử lý phản hồi từ 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); }


Chức năng đã đăng ký sẽ kiểm tra xem tin nhắn nhận được từ máy chủ có đúng loại tin nhắn cần thiết cho phản hồi của bot hay không ( ServerMessageID.TextChunk ). Nếu đúng như vậy, chúng tôi sẽ gọi hàm đã nhận bằng đoạn văn bản, đoạn văn bản này sẽ thêm đoạn này vào phản hồi hiện tại của bot trong cuộc trò chuyện.


Khi bot phản hồi xong, máy chủ sẽ gửi cho chúng ta một tin nhắn có kiểu ServerMessageID.TextEnd , cho biết rằng chúng ta có thể ngừng nghe, lúc này chúng ta sẽ hủy đăng ký nghe tin nhắn mới.


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


2 phương thức tiếp theo, sendAudioChunksendAudioEnd , dùng để gửi giọng nói đã ghi của người dùng đến máy chủ. Cái đầu tiên, sendAudioChunk , sẽ gửi các byte đã nhận đến máy chủ, trong khi cái còn lại, sendAudioEnd , sẽ gửi một tin nhắn đến máy chủ cho biết rằng âm thanh đã hoàn tất và, giống như phương thức sendTextMessage , sẽ gọi createSubscriptionForBotResponse để nghe phản hồi từ bot.


Tiếp theo, chúng ta sẽ xem xét cách gửi tham số onNewMessageContent từ các phương thức sendTextMessagesendAudioEnd .


Chúng tôi đã sửa đổi một chút hàm addMessage bằng cách tách nó thành addUserMessageaddBotMessage . Chúng ta sẽ chỉ xem xét 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; }


Hàm addBotMessageInChunks chịu trách nhiệm tạo và trả về hàm sẽ nối văn bản/nội dung đã cho vào thông báo bot hiện tại.


Bởi vì chúng ta muốn có hiệu ứng viết lên tin nhắn bot khi nó xuất hiện nên chúng ta cần có một phương pháp để đồng bộ hóa mọi thứ. Máy chủ sẽ gửi văn bản khi nó đến và hàm addContentToMessage , chịu trách nhiệm tạo hiệu ứng viết, có thể không sẵn sàng kịp thời để xử lý văn bản nhận được tiếp theo.


Vì vậy, chúng tôi đã nghĩ ra một cơ chế đồng bộ hóa đơn giản: chúng tôi tạo 2 bộ đếm và một biến sẽ giữ lời hứa. Mỗi lần hàm trả về được gọi, chúng ta gán cho lệnh gọi đó chỉ mục tiếp theo (dòng 39), sau đó tăng bộ đếm. Hàm sẽ chờ đến lượt của nó bằng cách đợi Promise được giải quyết và khi đến lượt nó sẽ ghi đè biến Promise bằng một Promise mới chỉ chờ hiệu ứng ghi được thực hiện (dòng 47) rồi tăng bộ đếm.


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


Hàm recordUserAudio cũng được thay đổi một chút:

  • Việc gọi mediaRecorder.start() với đối số 1000 sẽ cắt âm thanh của người dùng thành các đoạn có thời lượng 1 giây , sẽ được nhận trong trình xử lý cho sự kiện dataavailable ;


  • Trình xử lý cho sự kiện dataavailable sẽ thêm lời hứa được trả về bằng cách gọi ws.sendAudioChunk vào một mảng để chúng tôi có thể đợi tất cả chúng kết thúc trong trình xử lý cho sự kiện stop của phiên bản MediaRecorder của chúng tôi.


Điều này là khá nhiều cho phía khách hàng.


Bây giờ, hãy chuyển sang phía máy chủ để xem những gì đã được thêm vào:


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


Chúng tôi đang tạo máy chủ WebSocket (sử dụng gói ws ) với cổng được xác định của chúng tôi. Sau khi có kết nối, chúng tôi sẽ thêm một mảng trống có tên audioChunks vào ổ cắm máy khách, mảng này sẽ chứa các đoạn đệm âm thanh.


Khi người dùng gửi tin nhắn, chúng tôi thực hiện như sau:


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


Đầu tiên, chúng tôi kiểm tra xem tin nhắn nhận được có ở dạng nhị phân hay không. Sau đó, chúng tôi tách loại tin nhắn ( messageType ) khỏi phần còn lại của dữ liệu ( payload ). Nếu khách hàng chưa gửi ID trò chuyện và loại tin nhắn không dành cho việc này thì sẽ trả về lỗi. Nếu không, chúng tôi sẽ lưu trữ ID trò chuyện nếu đúng trong ổ cắm máy khách.


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


Sau khi khách hàng gửi tin nhắn thuộc loại ClientMessageID.UserTextMessage hoặc ClientMessageID.UserAudioEnd , chúng tôi sẽ truy xuất tin nhắn của cuộc trò chuyện như trước đây. Nếu tin nhắn thuộc loại ClientMessageID.UserTextMessage , chúng tôi sẽ chuyển đổi dữ liệu nhận được ( payload ) thành String .


Nếu tin nhắn thuộc loại ClientMessageID.UserAudioEnd , chúng tôi sẽ kết hợp tất cả các đoạn đệm âm thanh thành một đoạn duy nhất, đặt lại mảng các đoạn và thực hiện hành động chuyển giọng nói thành văn bản trên âm thanh, thao tác này sẽ trả về văn bản.


Bước tiếp theo là tạo tin nhắn mới ở định dạng được ChatGPT chấp nhận và truy vấn ChatGPT để tìm phản hồi.


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


Loại thông báo cuối cùng mà chúng tôi xử lý là dành cho các đoạn âm thanh, loại này chỉ thêm dữ liệu đã nhận vào mảng đoạn âm thanh của ổ cắm máy khách.


Bây giờ, hãy xem hàm getAnswer đã được thay đổi như thế nào để hỗ trợ các luồng:


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


Chỉ cần thêm stream: true vào đối tượng được gửi làm đối số cho ChatGPT, nó sẽ trả về một đối tượng luồng mà chúng ta có thể lặp qua. Đối với mỗi đoạn dữ liệu không trống, chúng tôi sẽ gửi lại cho khách hàng. Sau khi truyền xong, chúng tôi cần thông báo cho khách hàng rằng phản hồi đã hoàn tất.

Mẹo để thêm lại chức năng TTS

Ok, ok, nhưng nếu chúng ta có dịch vụ TTS hỗ trợ phát trực tuyến hoặc chấp nhận nhiều yêu cầu được xử lý nhanh thì sao?


Không có vấn đề gì: chúng tôi chỉ cần điều chỉnh một số thứ ở phía máy chủ và máy khách.


Về phía máy chủ, khi chúng tôi nhận được một đoạn câu trả lời từ AI (trong hàm getAnswer ), chúng tôi cần gọi dịch vụ TTS của mình và gửi dữ liệu âm thanh nhận được dưới dạng phản hồi cho phía máy khách.


Về phía khách hàng, cần có nhiều thay đổi hơn:

  • Chúng tôi không thể chuyển đổi dữ liệu đã nhận thành văn bản nữa vì giờ đây chúng tôi có thể nhận dữ liệu âm thanh;


  • Vì chúng tôi có thể nhận được dữ liệu âm thanh tiếp theo trước khi phát xong âm thanh trước đó nên chúng tôi cần giới thiệu phương pháp đồng bộ hóa để theo dõi âm thanh nào cần được phát tiếp theo.

Từ cuối cùng

Có những khía cạnh đã bị bỏ qua trong quá trình đăng/triển khai này, chẳng hạn như việc xử lý lỗi đối với các phần khác nhau của mã.


Nếu bạn muốn xem quá trình triển khai máy chủ trong Go hoặc Rust, vui lòng viết thư cho tôi theo địa chỉ [email protected] .


Các sơ đồ được tạo bằng Draw.io và việc kiểm tra ngữ pháp được thực hiện bởi ChatGPT.


Tài nguyên: