在本教程中,我将介绍如何创建一个简单的弹出式 AI 聊天,该聊天可以添加到任何网站。客户端将能够通过键入和与机器人对话来回复聊天。
我们将使用 OpenAI 的工具来实现 AI 功能。对于聊天,我们将使用 ChatGPT;对于 STT(语音转文本),我们将使用 Whisper;对于 TTS(文本转语音),我们将使用他们的 TTS。
我将展示实现该应用程序的多种方法,从简单的或基本的方法到更好但更复杂的方法。
该应用程序将使用 JavaScript (ECMAScript) 实现。但是,如果您对使用其他语言的实现感兴趣,请阅读最后一章。
所有代码都可以在我的 GitHub 存储库中找到。
在本章中,我们将介绍应用程序的基础知识:项目结构和我们使用的包。
该项目将使用以下软件包:
包裹名字 | 描述 |
---|---|
| 对于 HTTP 服务器和路由 |
| 对于所有 OpenAI 内容 |
| 将 SASS 样式文件转换为 CSS 文件 |
| 对于 WebSockets |
项目结构如下:
小路 | 描述 |
---|---|
| 以 |
| 包含公共音频文件的目录 |
| 包含公共镜像的目录 |
| 入口点 |
| 包含页面样式的目录 |
| 原始实现源代码目录 |
| 更好的实现源代码目录 |
该项目可以在这里看到,并且列出的代码还将包含可以找到该代码的相对路径。
运行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 请求和响应来从服务器发送和接收数据。
我们将介绍每个重要函数。所有代码都可以在上述链接中找到。
以下是该应用程序如何运行的活动图:
让我们看看当用户在文本输入元素上按下回车键时会发生什么:
/* 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
将其发送到服务器以从 AI 获取答案。
sendMessage
函数只是使用fetch
向我们的服务器发出 HTTP 请求。
需要提一下的是:我们为随每条消息发送的每个客户端生成一个随机 ID,以便服务器知道从哪里获取聊天历史记录。
向服务器发送识别 ID 的替代方法是每次发送整个历史记录,但随着每条消息的发送,需要发送的数据都会增加。
/* 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
并开始录制。我们还将显示一些 UI 元素,让用户知道我们正在录制他们的声音,并显示一个按钮,以便在录制完成后停止录制。
一旦按下停止按钮,我们就将音频块转换为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 }); });
在我们检查客户端发送了所需的数据后,我们获取给定 ID 的聊天历史记录(或创建新的聊天历史记录):
/* 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
,它必须代表我们的音频数据。我们使用openai/uploads
包中的toFile
函数将我们的 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; }
最后一部分是使用tts
函数将 AI 的响应转换为音频,该函数使用openai.audio.speech.create
方法创建音频文件。OpenAI 的 TTS 支持多种格式,但我们在本教程中选择了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'); }
但是我们可以做得更好吗?当然可以。我们可以使用 WebSockets 在客户端和服务器之间进行通信,而不是使用 HTTP 请求,并告诉 ChatGPT 将结果以流的形式返回。这样,我们可以创建实时书写效果,因为我们会将结果从 ChatGPT 实时流式传输到客户端。
此实现有一个缺点,但这只是因为我们使用的是 OpenAI 的 TTS,它每分钟最多接受 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 服务器。当连接打开时,我们将聊天 ID 作为第一条消息发送,以便服务器知道我们是谁。
客户端和服务器之间发送的数据/消息的结构遵循以下格式:
第一个字节代表我们发送的消息类型,让服务器知道如何处理发送的数据中后面的字节所代表的有效负载。
请注意,我们将 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.`); } } } });
因为我们知道从服务器端收到的所有消息都是文本,所以我们可以使用TextDecoder
安全地将整个有效负载转换为String
: new TextDecoder().decode(data.slice(1));
。
首先我们会等待服务器端第一个ServerMessageID.OK
,这代表发送的聊天ID是有效的。
为了提高灵活性,我们使用一个函数数组来表示从服务器接收的消息的侦听器。这使我们能够采用模块化方法。每个函数必须返回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
对象并添加另外 3 种方法:
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])); };
接下来的 2 个方法sendAudioChunk
和sendAudioEnd
用于将录制的用户语音发送到服务器。第一个方法sendAudioChunk
会将收到的字节发送到服务器,而另一个方法sendAudioEnd
会向服务器发送一条消息,表示音频已完成,并且像sendTextMessage
方法一样,会调用createSubscriptionForBotResponse
来监听来自机器人的响应。
接下来,我们来看看sendTextMessage
和sendAudioEnd
方法中的onNewMessageContent
参数是如何发送的。
我们稍微修改了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
也做了些许改动:
1000
调用mediaRecorder.start()
将会把用户的音频切分成1 秒的块,这些块将在dataavailable
事件的处理程序中接收;
dataavailable
事件的处理程序将把调用ws.sendAudioChunk
返回的承诺添加到数组中,以便我们可以等待它们全部在MediaRecorder
实例的stop
事件的处理程序中完成。
对于客户端来说这基本上就是全部了。
现在,我们切换到服务器端看看添加了什么:
/* 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
) 分开。如果客户端尚未发送聊天 ID,并且消息类型不适用于此,则返回错误。否则,如果聊天 ID 正确,我们会将其存储在客户端套接字中。
/* 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 服务怎么办?
没问题:我们只需要在服务器和客户端调整一些东西。
在服务器端,一旦我们从 AI 收到一部分答案(在getAnswer
函数中),我们需要调用我们的 TTS 服务并将收到的音频数据作为响应发送到客户端。
在客户端,需要进行更多更改:
这篇文章/实现中省略了一些方面,例如代码不同部分的错误处理。
如果您想查看使用 Go 或 Rust 实现的服务器,请写信给我[email protected] 。
图表是使用 Draw.io 生成的,语法检查由 ChatGPT 完成。
资源: