이번 튜토리얼에서는 어떤 웹사이트에도 추가할 수 있는 간단한 팝업 AI 채팅을 만드는 방법을 소개하겠습니다. 클라이언트는 봇에 입력하고 말하여 채팅에 응답할 수 있습니다.
우리는 AI 기능을 위해 OpenAI의 도구를 사용할 것입니다. 채팅에는 STT(음성-텍스트), Whisper, TTS(텍스트-음성)에는 TTS인 ChatGPT를 사용합니다.
순진하거나 기본적인 방법부터 시작하여 더 우수하지만 더 복잡한 방법까지 앱을 구현하는 여러 가지 방법을 보여 드리겠습니다.
애플리케이션은 JavaScript(ECMAScript)로 구현됩니다. 그러나 다른 언어로 구현하는 데 관심이 있다면 마지막 장을 읽으십시오.
모든 코드는 내 GitHub 저장소에서 사용할 수 있습니다.
이번 장에서는 애플리케이션의 기본, 즉 우리가 사용한 프로젝트 구조와 패키지에 대해 살펴보겠습니다.
이 프로젝트는 다음 패키지를 사용합니다.
패키지 이름 | 설명 |
---|---|
| HTTP 서버 및 라우팅의 경우 |
| 모든 OpenAI 관련 항목 |
| SASS 스타일 파일을 CSS 파일로 변환하려면 |
| WebSocket의 경우 |
프로젝트 구조는 다음과 같습니다.
길 | 설명 |
---|---|
| |
| 공개 오디오 파일이 포함된 디렉토리 |
| 공개 이미지가 포함된 디렉터리 |
| 진입점 |
| 페이지 스타일이 포함된 디렉토리 |
| 순진한 구현 소스 코드 디렉토리 |
| 더 나은 구현 소스 코드 디렉토리 |
프로젝트는 여기에서 볼 수 있으며, 코드가 나열되는 위치에는 해당 코드를 찾을 수 있는 상대 경로도 포함됩니다.
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 요청과 응답을 사용합니다.
각각의 중요한 기능을 살펴보겠습니다. 모든 코드는 위 링크에서 확인하실 수 있습니다.
다음은 앱 작동 방식에 대한 활동 다이어그램입니다.
사용자가 텍스트 입력 요소에서 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; });
사용자가 Enter 키를 누르고 입력이 비어 있지 않으면 텍스트 입력과 오디오 버튼을 모두 비활성화하여 이전 메시지에 대한 응답을 받는 동안 사용자가 다른 메시지를 보내지 않도록 합니다. 답변을 얻으면 기능을 복원합니다.
입력을 비활성화한 후 마법을 수행하는 기본 함수 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]; }
그런 다음 수신된 메시지가 오디오가 아닌 텍스트인 경우 해당 메시지를 채팅 기록에 추가합니다. 수신된 메시지가 오디오인 경우 OpenAI의 Whisper 를 사용하여 음성-텍스트 작업을 수행하는 stt
함수를 호출합니다.
이 함수는 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; }
마지막 부분은 오디오 파일을 생성하기 위해 openai.audio.speech.create
메서드를 사용하는 tts
함수를 사용하여 AI의 응답을 오디오로 변환하는 방법에 관한 것입니다. 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'); }
하지만 더 좋게 만들 수 있을까요? 그렇죠. HTTP 요청을 사용하는 대신 WebSocket을 사용하여 클라이언트와 서버 간에 통신하고 ChatGPT에 결과를 스트림으로 반환하도록 지시할 수 있습니다. 이런 식으로 ChatGPT의 결과를 실시간으로 클라이언트에 스트리밍하기 때문에 실시간 글쓰기 효과를 만들 수 있습니다.
이 구현에는 단점이 있지만 분당 최대 3개의 요청을 허용하는 OpenAI의 TTS를 사용하고 있기 때문입니다. 따라서 이번 구현에서는 텍스트 음성 변환 기능을 삭제하지만 이를 다시 구현하는 방법과 이를 수행할 때 찾아야 할 사항에 대한 팁을 제공하겠습니다.
그럼 몇 가지 코드를 살펴보겠습니다. 우리는 이전 구현에서 시작하여 WebSocket을 지원하는 데 필요한 사항을 변경했습니다.
/* 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
이유입니다. 우리는 바이너리로만 전송하는 것이 더 효율적이기 때문에 필요한 것만 텍스트로 변환하고 바이너리로 유지하기 위해 남아 있습니다(오디오 청크, 바이너리로 남아 있어야 하는 것).
다음 코드에서는 서버 측에서 받은 메시지를 처리합니다.
/* 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));
.
먼저, 전송된 채팅 ID가 유효함을 나타내는 서버의 첫 번째 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); }
다음으로, 3가지 메소드를 더 사용하여 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])); };
다음 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])); }
간단히 ChatGPT에 인수로 전송된 개체에 stream: true
추가하면 반복할 수 있는 스트림 개체가 반환됩니다. 비어 있지 않은 각 데이터 청크에 대해 클라이언트에 다시 보냅니다. 스트림이 완료된 후에는 응답이 완료되었음을 클라이언트에 알려야 합니다.
좋아요, 하지만 스트리밍을 지원하거나 빠르게 처리되는 많은 요청을 수락하는 TTS 서비스가 있다면 어떨까요?
문제 없습니다. 서버와 클라이언트 측에서 몇 가지 사항을 조정하면 됩니다.
서버 측에서는 getAnswer
함수에서 AI로부터 응답 덩어리를 받으면 TTS 서비스를 호출하고 응답으로 수신된 오디오 데이터를 클라이언트 측에 보내야 합니다.
클라이언트 측에서는 더 많은 변경이 필요합니다.
코드의 여러 부분에 대한 오류 처리와 같이 이 사후/구현에서 생략된 측면이 있습니다.
Go 또는 Rust에서 서버 구현을 보고 싶으시면 [email protected] 로 저에게 메일을 보내주세요.
다이어그램은 Draw.io를 사용하여 생성되었으며 문법 검사는 ChatGPT에서 수행되었습니다.
자원: