Bu eğitimde, herhangi bir web sitesine eklenebilecek basit bir açılır yapay zeka sohbetinin nasıl oluşturulacağını anlatacağım. Müşteri, bota yazıp konuşarak sohbete yanıt verebilecektir.
Yapay zeka işlevleri için OpenAI'nin araçlarını kullanacağız. Sohbet için ChatGPT'yi, STT (konuşmadan metne) için Whisper'ı ve TTS (metinden konuşmaya) için bunların TTS'sini kullanacağız.
Uygulamayı uygulamak için saf veya basit bir yöntemden başlayarak daha iyi ama aynı zamanda daha karmaşık bir yönteme kadar birden fazla yöntem göstereceğim.
Uygulama JavaScript'te (ECMAScript) uygulanacaktır. Ancak diğer dillerdeki uygulamalarla ilgileniyorsanız son bölümü okuyun.
Kodun tamamı GitHub depomda mevcut.
Bu bölümde uygulamanın temellerine gideceğiz: kullandığımız proje yapısı ve paketler.
Proje aşağıdaki paketleri kullanacak:
Paket ismi | Tanım |
---|---|
| HTTP sunucusu ve yönlendirme için |
| Tüm OpenAI özellikleri için |
| SASS stil dosyalarını CSS dosyalarına dönüştürmek için |
| WebSocket'ler için |
Proje yapısı aşağıdaki gibidir:
Yol | Tanım |
---|---|
| |
| Herkese açık ses dosyalarını içeren dizin |
| Genel görselleri içeren dizin |
| Giriş noktası |
| Sayfanın stilini içeren dizin |
| Saf uygulama kaynak kodu dizini |
| Daha iyi uygulama kaynak kodu dizini |
Proje burada görülebilir ve bir kodun listeleneceği yer aynı zamanda bu kodun bulunabileceği ilgili yolu da içerecektir.
SASS dosyasını CSS'ye dönüştürmek için npm install
ve ardından npm run build
çalıştırın; artık hazırsınız.
Saf uygulamayı başlatmak için npm run start-v1
çalıştırın veya daha iyi uygulamayı çalıştırmak için npm run start-v2
çalıştırın. OPENAI_API_KEY
ortam değişkenini tanımlamayı unutmayın.
UNIX sistemlerinde şunları çalıştırabilirsiniz:
OPENAI_API_KEY=YOU_API_KEY npm run start-v1`
Ve Windows'ta:
set OPENAI_API_KEY=YOU_API_KEY npm run start-v1
Sayfaya eriştiğinizde görmeniz gereken sayfa budur:
Saf uygulama, sunucudan veri göndermek ve almak için HTTP isteklerini ve yanıtlarını kullanır.
Her önemli fonksiyona bakacağız. Kodun tamamı yukarıda belirtilen bağlantıda bulunabilir.
Uygulamanın nasıl çalışacağına ilişkin bir etkinlik şeması aşağıda verilmiştir:
Kullanıcı metin giriş öğesinde enter tuşuna bastığında ne olacağına bakalım:
/* 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; });
Kullanıcı enter tuşuna bastığında ve giriş boş olmadığında, hem metin girişini hem de ses düğmesini devre dışı bırakacağız, böylece kullanıcı bir önceki mesaja yanıt alırken başka bir mesaj göndermeyecektir. Cevabı aldıktan sonra işlevselliği geri yükleriz.
Girişi devre dışı bıraktıktan sonra, sihri gerçekleştiren ana işlevi, addMessage
çağırıyoruz. Şuna bir bakalım:
/* 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); } }
İşlev, yeni mesaj için yeni bir HTMLDivElement
oluşturacak ve mesajın türüne göre CSS sınıfını ekleyecektir.
Bu yapıldıktan sonra mesajı istemci tarafındaki sohbet geçmişimizde saklarız.
Daha sonra eklenecek mesaj bottan geliyorsa bunu bir "yazma efekti" kullanarak görüntüleriz. Sesin uzunluğunu mesajdaki karakter sayısına bölerek, varsa sesi yazma hızıyla senkronize etmeye çalışıyoruz.
Eklenen mesaj kullanıcıdan geliyorsa, sendMessage
işlevini çağırarak yapay zekadan yanıt almak için bu mesajı sunucuya göndeririz.
sendMessage
işlevi, sunucumuza fetch
kullanarak yalnızca bir HTTP isteğinde bulunur.
Bahsetmemiz gereken bir şey var: Her mesajla birlikte gönderdiğimiz her müşteri için rastgele bir kimlik oluşturuyoruz, böylece sunucu sohbet geçmişini nereden alacağını biliyor.
Sunucuya tanımlayıcı bir kimlik göndermenin alternatifi, her seferinde tüm geçmişi göndermek olabilir, ancak her mesajla birlikte gönderilmesi gereken veriler artar.
/* 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); } }
İsteği nasıl işlediğini görmek için sunucu tarafına gitmeden önce, kullanıcı ses düğmesini tıkladığında ne olacağını görelim:
/* 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; });
Metin girişi işlemeye çok benzer. recordUserAudio
işlevi base64 kodlu bir ses döndürecektir ve bunu addMessage
göndermeden önce yalnızca başlığını keseriz.
recordUserAudio
işlevi, ses kaydetmek için kullanıcıdan izin almaya çalışacak ve eğer izin verilirse bir MediaRecorder
oluşturup kayda başlayacaktır. Ayrıca kullanıcıya sesini kaydettiğimizi bildiren bazı kullanıcı arayüzü öğeleri ve bittiğinde kaydı durduracak bir düğme göstereceğiz.
Durdurma düğmesine basıldığında, ses parçalarını bir Blob
nesnesine ve blob'u da base64 kodlu bir dizeye dönüştürüp geri döndürüyoruz.
Ayrıca her ses parçasını inceliyoruz, durduruyoruz ve ardından kaldırıyoruz. Bu gereklidir, çünkü en azından Chrome'da mediaRecorder.stop()
işlevinin çağrılması mikrofonun "dinleme" durumunu durdurmayacaktır.
Sesi base64
kodlamak, sesi sunucuya göndermenin çok etkili bir yöntemi olmasa da çok kolay bir yöntemdir. Daha iyi uygulama bölümünde, sesi sunucuya göndermenin başka bir yöntemine bakacağız.
/* 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); }) }
Şimdi isteğin sunucuda nasıl işlendiğine bakalım:
/* 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 }); });
İstemcinin gerekli verileri gönderip göndermediğini kontrol ettikten sonra, verilen kimliğe ilişkin sohbet geçmişini alırız (veya yeni bir sohbet geçmişi oluştururuz):
/* 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]; }
Daha sonra alınan mesaj ses değil de metin ise mesajı sohbet geçmişine ekliyoruz. Alınan mesaj bir ses ise, OpenAI'nin Whisper'ını kullanarak konuşmayı metne dönüştürme eylemini gerçekleştirecek stt
fonksiyonunu çağırırız.
İşlev openai.audio.transcriptions.create
yöntemini kullanacaktır. Bu yöntemin ana parametresi, ses verilerimizi temsil etmesi gereken file
. Base64 kodlu ses dosyamızı OpenAI'nin okuyabileceği bir dosyaya dönüştürmek için openai/uploads
paketindeki toFile
fonksiyonunu kullanıyoruz. İşlev, verilen sesin transkripsiyonunu döndürecektir.
/* 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; }
Artık mesajı aldığımıza göre sohbeti ChatGPT'ye gönderiyoruz ve getAnswer
işlevini çağırarak yanıt bekliyoruz.
/* 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; }
Son bölüm, bir ses dosyası oluşturmak için openai.audio.speech.create
yöntemini kullanan tts
işlevini kullanarak yapay zekadan gelen yanıtı sese dönüştürmekle ilgilidir. OpenAI'nin TTS'si birden fazla formatı destekler, ancak bu eğitim için mp3
seçtik.
Ses verileri elde edildikten sonra, onu bir Buffer
ve oradan da istemciye geri göndermek üzere base64 kodlu bir ses dizisine dönüştürürüz.
/* 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'); }
Ama bunu daha iyi hale getirebilir miyiz? İyi evet. HTTP isteklerini kullanmak yerine, istemci ile sunucu arasında iletişim kurmak ve ChatGPT'ye sonuçları bir akış olarak döndürmesini söylemek için WebSockets'i kullanabiliriz. Bu şekilde gerçek zamanlı bir yazma efekti oluşturabiliriz çünkü sonucu ChatGPT'den istemciye gerçek zamanlı olarak aktaracağız.
Bu uygulamanın bir dezavantajı var, ancak bunun nedeni OpenAI'nin dakikada maksimum 3 isteği kabul eden TTS'sini kullanıyor olmamız. Bu nedenle, bu uygulama için metinden konuşmaya özelliğini bırakacağız ancak bunun nasıl yeniden uygulanacağı ve bunu yaparken nelere dikkat edilmesi gerektiği konusunda ipuçları vereceğim.
Şimdi biraz koda bakalım. Önceki uygulamadan başladık ve WebSockets'i desteklemek için gerekenleri değiştirdik.
/* 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); });
İstemci kodunun bu bölümünde WebSocket sunucusuna bağlanıyoruz. Bağlantı açıldığında, sunucunun kim olduğumuzu bilmesi için ilk mesaj olarak sohbet kimliğini göndeririz.
İstemci ile sunucu arasında gönderilen verilerin/mesajların yapısı şu formatı takip eder:
İlk bayt, gönderdiğimiz mesajın türünü temsil eder ve sunucunun, gönderilen verilerde aşağıdaki baytlarla temsil edilen yükü nasıl işleyeceğini bilmesini sağlar.
WebSocket sunucusunu yalnızca ikili verileri kabul edecek ve gönderecek şekilde yapılandırdığımızı unutmayın. Bu nedenle her zaman istemci tarafından bir Uint8Array
ve sunucu tarafından bir Buffer
göndereceğiz. iy'yi yalnızca ikili olarak gönderiyoruz çünkü bu daha verimlidir, yalnızca ihtiyacımız olanı metne dönüştürür ve ikili olarak kalır (ikili olarak kalması gereken ses parçaları gibi).
Aşağıdaki kodda sunucu tarafından alınan mesajları ele alıyoruz:
/* 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.`); } } } });
Sunucu tarafından aldığımız tüm mesajların metin olduğunu bildiğimiz için, TextDecoder
kullanarak veri yükünün tamamını güvenli bir şekilde String
dönüştürebiliriz: new TextDecoder().decode(data.slice(1));
.
Öncelikle sunucudan gönderilen sohbet kimliğinin geçerli olduğunu gösteren ilk ServerMessageID.OK
bekleyeceğiz.
Esnek olmak için, sunucudan alınan mesajların dinleyicilerini temsil eden bir dizi işlev kullanırız. Bu, yaklaşımımızda modüler olmamızı sağlar. Her işlevin true
veya false
döndürmesi gerekir: true
mesajın işlendiği anlamına gelir ve abone olunan diğer işlevlerin çağrılmasına gerek yoktur.
Abone eklemeyi ve kaldırmayı kolaylaştırmak için ws
nesnemizi aşağıdakilerle genişletiyoruz:
/* 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); }
Daha sonra ws
nesnesini 3 yöntemle daha genişletiyoruz:
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
yöntemi, sunucuya gönderilmesi gereken mesajı ve ChatGPT'den alınan veri akışıyla birden çok kez çağrılacak bir işlevi kabul eder.
Bu yöntemde, mesajı sunucuya göndermeden önce, bottan gelen yanıtı işlemek için yeni mesajları dinlemek üzere bir abonelik oluşturmayı ve eklemeyi işleyen createSubscriptionForBotResponse
yöntemini çağırıyoruz.
/* 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); }
Abone olunan işlev, sunucudan alınan mesajın botun yanıtı için gerekli mesaj türüne ( ServerMessageID.TextChunk
) sahip olup olmadığını kontrol edecektir. Eğer öyleyse, alınan işlevi metin öbeğiyle birlikte çağırırız; bu öbeği sohbetteki mevcut bot yanıtına ekleyecektir.
Botun yanıtı bittiğinde, sunucu bize ServerMessageID.TextEnd
türünde bir mesaj göndererek dinlemeyi bırakabileceğimizi, bu noktada yeni mesajları dinleme aboneliğinden çıkacağımızı belirtir.
/* 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])); };
Sonraki 2 yöntem, sendAudioChunk
ve sendAudioEnd
, kullanıcının kayıtlı sesini sunucuya göndermek içindir. Bunlardan ilki, sendAudioChunk
, alınan baytları sunucuya gönderirken, diğeri, sendAudioEnd
, sunucuya sesin tamamlandığını belirten bir mesaj gönderecek ve sendTextMessage
yöntemi gibi, yanıtı dinlemek için createSubscriptionForBotResponse
öğesini çağıracaktır. bottan.
Daha sonra sendTextMessage
ve sendAudioEnd
yöntemlerinden onNewMessageContent
parametresinin nasıl gönderildiğine bakacağız.
addMessage
işlevini addUserMessage
ve addBotMessage
olarak bölerek biraz değiştirdik. Sadece addUserMessage
bakacağız:
/* 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
işlevi, verilen metni/içeriği geçerli bot mesajına ekleyecek işlevin oluşturulmasından ve döndürülmesinden sorumludur.
Bot mesajı geldiğinde üzerinde yazma efekti olmasını istediğimiz için her şeyi senkronize edecek bir yönteme ihtiyacımız var. Sunucu, metni geldiği gibi gönderecektir ve yazma efektini oluşturmaktan sorumlu olan addContentToMessage
işlevi, alınan bir sonraki metni işlemek için zamanında hazır olmayabilir.
Böylece basit bir senkronizasyon mekanizması geliştirdik: 2 sayaç ve bir söz tutacak bir değişken oluşturuyoruz. Döndürülen fonksiyon her çağrıldığında, bu çağrıya bir sonraki dizini (satır 39) atarız ve ardından sayacı artırırız. İşlev, söz vermenin çözümlenmesini bekleyerek sırasını bekleyecek ve sıra kendisine geldiğinde, yazma efektinin yapılmasını bekleyecek (satır 47) yeni bir söz ile söz değişkeninin üzerine yazacak ve ardından sayacı artırın.
/* 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
işlevi de biraz değiştirildi:
mediaRecorder.start()
öğesinin 1000
argümanıyla çağrılması, kullanıcının sesini 1 saniyelik parçalara böler ve bunlar dataavailable
olayının işleyicisinde alınır;
dataavailable
olayının işleyicisi, ws.sendAudioChunk
çağrısıyla döndürülen vaadi bir diziye ekleyecektir, böylece MediaRecorder
örneğimizin stop
olayı için hepsinin işleyicide bitmesini bekleyebiliriz.
Bu, istemci tarafı için hemen hemen budur.
Şimdi nelerin eklendiğini görmek için sunucu tarafına geçelim:
/* 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) => { // ... }); });
Tanımladığımız port ile WebSocket sunucusunu ( ws
paketini kullanarak) oluşturuyoruz. Bağlantı kurduktan sonra, istemci soketine, ses arabellek parçalarını tutacak, audioChunks
adı verilen boş bir dizi ekliyoruz.
Kullanıcı bir mesaj gönderdiğinde aşağıdakileri yaparız:
/* 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.')])); } } // ...
Öncelikle alınan mesajın ikili dosya formatında olup olmadığını kontrol ediyoruz. Bundan sonra mesaj türünü ( messageType
) verilerin geri kalanından ( payload
) ayırıyoruz. İstemci henüz sohbet kimliğini göndermediyse ve mesaj türü bunun için değilse bir hata döndürün. Aksi takdirde, doğruysa sohbet kimliğini istemci soketinde saklarız.
/* 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; } } // ...
İstemci ClientMessageID.UserTextMessage
veya ClientMessageID.UserAudioEnd
türünde bir mesaj gönderdiğinde, daha önce olduğu gibi sohbet mesajlarını alırız. Mesajın tipi ClientMessageID.UserTextMessage
ise, alınan veriyi ( payload
) String
dönüştüreceğiz.
Mesaj ClientMessageID.UserAudioEnd
türündeyse, tüm ses arabellek parçalarını tek bir parça halinde birleştireceğiz, parça dizisini sıfırlayacağız ve ses üzerinde metni döndürecek konuşmayı metne dönüştürme eylemini gerçekleştireceğiz.
Bir sonraki adım, yeni mesajı ChatGPT tarafından kabul edilen formatta oluşturmak ve bir yanıt için ChatGPT'yi sorgulamaktır.
/* version-2/server.js */ // ... } else if (messageType === ClientMessageID.UserAudioChunk) { clientWS.audioChunks.push(payload); } // ...
Ele aldığımız son mesaj türü, alınan verileri istemci soketinin ses parçaları dizisine ekleyen ses parçaları içindir.
Şimdi akışları desteklemek için getAnswer
fonksiyonunun nasıl değiştirildiğine bakalım:
/* 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'ye argüman olarak gönderilen nesneye basitçe stream: true
eklendiğinde, döngü yapabileceğimiz bir akış nesnesi döndürülür. Boş olmayan her veri yığınını müşteriye geri göndereceğiz. Akış tamamlandıktan sonra istemciye yanıtın tamamlandığını bildirmemiz gerekir.
Tamam, peki ya akışı destekleyen veya hızlı işlenen birçok isteği kabul eden bir TTS hizmetimiz varsa?
Sorun değil: Sadece sunucu ve istemci tarafında bazı şeyleri ayarlamamız gerekiyor.
Sunucu tarafında, yapay zekadan yanıtın bir kısmını aldığımızda ( getAnswer
işlevinde), TTS hizmetimizi çağırmamız ve yanıt olarak alınan ses verilerini istemci tarafına göndermemiz gerekir.
İstemci tarafında daha fazla değişikliğe ihtiyaç var:
Kodun farklı bölümleri için hata işleme gibi bu gönderide/uygulamada atlanan hususlar vardır.
Sunucunun Go veya Rust'ta uygulanmasını görmek isterseniz lütfen bana [email protected] adresinden yazın.
Diyagramlar Draw.io kullanılarak oluşturuldu ve dilbilgisi kontrolü ChatGPT tarafından yapıldı.
Kaynaklar: