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ı mevcut. GitHub depomda Temeller 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 express HTTP sunucusu ve yönlendirme için openai Tüm OpenAI özellikleri için sass SASS stil dosyalarını CSS dosyalarına dönüştürmek için ws WebSocket'ler için Proje yapısı aşağıdaki gibidir: Yol Tanım public ad altında internete sunulan dizin static public/audio Herkese açık ses dosyalarını içeren dizin public/img Genel görselleri içeren dizin public/index.html Giriş noktası style Sayfanın stilini içeren dizin version-1 Saf uygulama kaynak kodu dizini version-2 Daha iyi uygulama kaynak kodu dizini Proje görülebilir ve bir kodun listeleneceği yer aynı zamanda bu kodun bulunabileceği ilgili yolu da içerecektir. burada SASS dosyasını CSS'ye dönüştürmek için ve ardından çalıştırın; artık hazırsınız. npm install npm run build Saf uygulamayı başlatmak için çalıştırın veya daha iyi uygulamayı çalıştırmak için çalıştırın. ortam değişkenini tanımlamayı unutmayın. npm run start-v1 npm run start-v2 OPENAI_API_KEY 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: Naif/Basit Uygulama 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, çağırıyoruz. Şuna bir bakalım: 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); } } İşlev, yeni mesaj için yeni bir oluşturacak ve mesajın türüne göre CSS sınıfını ekleyecektir. HTMLDivElement 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, işlevini çağırarak yapay zekadan yanıt almak için bu mesajı sunucuya göndeririz. sendMessage işlevi, sunucumuza kullanarak yalnızca bir HTTP isteğinde bulunur. sendMessage fetch 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. işlevi base64 kodlu bir ses döndürecektir ve bunu göndermeden önce yalnızca başlığını keseriz. recordUserAudio addMessage işlevi, ses kaydetmek için kullanıcıdan izin almaya çalışacak ve eğer izin verilirse bir 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. recordUserAudio MediaRecorder Durdurma düğmesine basıldığında, ses parçalarını bir nesnesine ve blob'u da base64 kodlu bir dizeye dönüştürüp geri döndürüyoruz. Blob Ayrıca her ses parçasını inceliyoruz, durduruyoruz ve ardından kaldırıyoruz. Bu gereklidir, çünkü en azından Chrome'da işlevinin çağrılması mikrofonun "dinleme" durumunu durdurmayacaktır. mediaRecorder.stop() Sesi kodlamak, sesi sunucuya göndermenin çok etkili bir yöntemi olmasa da çok kolay bir yöntemdir. bölümünde, sesi sunucuya göndermenin başka bir yöntemine bakacağız. base64 Daha iyi uygulama /* 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 kullanarak konuşmayı metne dönüştürme eylemini gerçekleştirecek fonksiyonunu çağırırız. Whisper'ını stt İşlev yöntemini kullanacaktır. Bu yöntemin ana parametresi, ses verilerimizi temsil etmesi gereken . Base64 kodlu ses dosyamızı OpenAI'nin okuyabileceği bir dosyaya dönüştürmek için paketindeki fonksiyonunu kullanıyoruz. İşlev, verilen sesin transkripsiyonunu döndürecektir. openai.audio.transcriptions.create file openai/uploads toFile /* 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 işlevini çağırarak yanıt bekliyoruz. 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; } Son bölüm, bir ses dosyası oluşturmak için yöntemini kullanan 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 seçtik. openai.audio.speech.create tts mp3 Ses verileri elde edildikten sonra, onu bir ve oradan da istemciye geri göndermek üzere base64 kodlu bir ses dizisine dönüştürürüz. Buffer /* 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'); } Daha İyi Uygulama 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 ve sunucu tarafından bir 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). Uint8Array Buffer 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, kullanarak veri yükünün tamamını güvenli bir şekilde dönüştürebiliriz: . TextDecoder String new TextDecoder().decode(data.slice(1)); Öncelikle sunucudan gönderilen sohbet kimliğinin geçerli olduğunu gösteren ilk bekleyeceğiz. ServerMessageID.OK 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 veya döndürmesi gerekir: mesajın işlendiği anlamına gelir ve abone olunan diğer işlevlerin çağrılmasına gerek yoktur. true false true Abone eklemeyi ve kaldırmayı kolaylaştırmak için nesnemizi aşağıdakilerle genişletiyoruz: 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); } Daha sonra nesnesini 3 yöntemle daha genişletiyoruz: ws kullanıcının kısa mesajını göndermek için ; sendTextMessage kullanıcının ses kaydından bir ses parçası göndermek için ; sendAudioChunk sunucuya sesin tamamlandığını bildirmek için . 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); }; 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. sendTextMessage 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 yöntemini çağırıyoruz. 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); } Abone olunan işlev, sunucudan alınan mesajın botun yanıtı için gerekli mesaj türüne ( ) 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. ServerMessageID.TextChunk Botun yanıtı bittiğinde, sunucu bize türünde bir mesaj göndererek dinlemeyi bırakabileceğimizi, bu noktada yeni mesajları dinleme aboneliğinden çıkacağımızı belirtir. 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])); }; Sonraki 2 yöntem, ve , kullanıcının kayıtlı sesini sunucuya göndermek içindir. Bunlardan ilki, , alınan baytları sunucuya gönderirken, diğeri, , sunucuya sesin tamamlandığını belirten bir mesaj gönderecek ve yöntemi gibi, yanıtı dinlemek için öğesini çağıracaktır. bottan. sendAudioChunk sendAudioEnd sendAudioChunk sendAudioEnd sendTextMessage createSubscriptionForBotResponse Daha sonra ve yöntemlerinden parametresinin nasıl gönderildiğine bakacağız. sendTextMessage sendAudioEnd onNewMessageContent işlevini ve olarak bölerek biraz değiştirdik. Sadece bakacağız: 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; } işlevi, verilen metni/içeriği geçerli bot mesajına ekleyecek işlevin oluşturulmasından ve döndürülmesinden sorumludur. addBotMessageInChunks 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 işlevi, alınan bir sonraki metni işlemek için zamanında hazır olmayabilir. addContentToMessage 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); }) } işlevi de biraz değiştirildi: recordUserAudio öğesinin argümanıyla çağrılması, kullanıcının sesini parçalara böler ve bunlar olayının işleyicisinde alınır; mediaRecorder.start() 1000 1 saniyelik dataavailable olayının işleyicisi, çağrısıyla döndürülen vaadi bir diziye ekleyecektir, böylece örneğimizin olayı için hepsinin işleyicide bitmesini bekleyebiliriz. dataavailable ws.sendAudioChunk MediaRecorder stop 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 ( paketini kullanarak) oluşturuyoruz. Bağlantı kurduktan sonra, istemci soketine, ses arabellek parçalarını tutacak, adı verilen boş bir dizi ekliyoruz. ws audioChunks 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ü ( ) verilerin geri kalanından ( ) 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. messageType payload /* version-2/server.js */ // ... } else if (messageType === ClientMessageID.UserTextMessage || messageType === ClientMessageID.UserAudioEnd) { const messages = getChatMessages(chatHistory, clientWS.chatID); let messageContent; if (messageType === ClientMessageID.UserTextMessage) { messageContent = payload.toString('utf8'); } else if (messageType === ClientMessageID.UserAudioEnd) { // When the client send the `ClientMessageID.UserAudioEnd` message type it means it clicked the STOP button // Concat all the buffers into a single one const buffer = Buffer.concat(clientWS.audioChunks); // Reset the chunks array clientWS.audioChunks = []; // Send audio to OpenAI to perform the speech-to-text messageContent = await stt(openai, buffer); } messages.push({ role: "user", content: messageContent }); try { await getAnswer(openai, messages, clientWS); } catch (error) { console.error(`(ChatID: ${clientWS.chatID}) Error when trying to get an answer from ChatGPT:`); console.error(error); clientWS.send(Buffer.from([ServerMessageID.Error, ...Buffer.from('Error!')])); return; } } // ... İstemci veya türünde bir mesaj gönderdiğinde, daha önce olduğu gibi sohbet mesajlarını alırız. Mesajın tipi ise, alınan veriyi ( ) dönüştüreceğiz. ClientMessageID.UserTextMessage ClientMessageID.UserAudioEnd ClientMessageID.UserTextMessage payload String Mesaj 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. ClientMessageID.UserAudioEnd 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 fonksiyonunun nasıl değiştirildiğine bakalım: 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'ye argüman olarak gönderilen nesneye basitçe 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. stream: true TTS İşlevselliğini Geri Eklemeye İlişkin İpuçları 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 ( işlevinde), TTS hizmetimizi çağırmamız ve yanıt olarak alınan ses verilerini istemci tarafına göndermemiz gerekir. getAnswer İstemci tarafında daha fazla değişikliğe ihtiyaç var: Artık alınan verileri metne dönüştüremiyoruz çünkü artık ses verilerini alabiliyoruz; Bir sonraki ses verisini önceki sesin oynatılması tamamlanmadan önce alabileceğimiz için, bir sonraki sesin çalınması gerektiğini takip etmek için bir senkronizasyon yöntemi uygulamamız gerekir. Son sözler 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 adresinden yazın. alex@alexprisacariu.dev Diyagramlar Draw.io kullanılarak oluşturuldu ve dilbilgisi kontrolü ChatGPT tarafından yapıldı. Kaynaklar: ; OpenAI Belgeleri . MDN Web Belgeleri