এই টিউটোরিয়ালে, আমি উপস্থাপন করব কীভাবে একটি সাধারণ পপআপ এআই চ্যাট তৈরি করা যায় যা যেকোনো ওয়েবসাইটে যোগ করা যেতে পারে। ক্লায়েন্ট বট টাইপ করে এবং কথা বলে চ্যাটের উত্তর দিতে সক্ষম হবে।
আমরা AI কার্যকারিতার জন্য OpenAI থেকে টুল ব্যবহার করব। চ্যাটের জন্য, আমরা ChatGPT, STT (স্পিচ-টু-টেক্সট), হুইস্পার এবং TTS (টেক্সট-টু-স্পিচ) এর জন্য তাদের TTS ব্যবহার করব।
আমি অ্যাপটি বাস্তবায়নের জন্য একাধিক পদ্ধতি দেখাব, একটি সরল, বা মৌলিক পদ্ধতি থেকে শুরু করে একটি ভাল কিন্তু আরও জটিল পদ্ধতিতে।
অ্যাপ্লিকেশনটি জাভাস্ক্রিপ্ট (ECMAScript) এ প্রয়োগ করা হবে। যাইহোক, আপনি যদি অন্যান্য ভাষায় বাস্তবায়নে আগ্রহী হন তবে চূড়ান্ত অধ্যায়টি পড়ুন।
সমস্ত কোড আমার GitHub সংগ্রহস্থলে উপলব্ধ।
এই অধ্যায়ে, আমরা অ্যাপ্লিকেশনের মূল বিষয়গুলিতে যাব: আমরা যে প্রকল্পের কাঠামো এবং প্যাকেজগুলি ব্যবহার করেছি।
প্রকল্পটি নিম্নলিখিত প্যাকেজগুলি ব্যবহার করবে:
প্যাকেজের নাম | বর্ণনা |
---|---|
| HTTP সার্ভার এবং রাউটিং জন্য |
| সমস্ত OpenAI জিনিসপত্রের জন্য |
| SASS শৈলী ফাইলগুলিকে CSS ফাইলে রূপান্তর করতে |
| WebSockets জন্য |
প্রকল্পের কাঠামো নিম্নরূপ:
পথ | বর্ণনা |
---|---|
| |
| সর্বজনীন অডিও ফাইল ধারণকারী ডিরেক্টরি |
| সর্বজনীন ছবি ধারণকারী ডিরেক্টরি |
| প্রবেশপথ |
| পৃষ্ঠার শৈলী ধারণকারী ডিরেক্টরি |
| নিষ্পাপ বাস্তবায়ন সোর্স কোড ডিরেক্টরি |
| ভাল বাস্তবায়ন সোর্স কোড ডিরেক্টরি |
প্রকল্পটি এখানে দেখা যাবে, এবং যেখানে একটি কোড তালিকাভুক্ত করা হবে সেখানে সেই কোডটি কোথায় পাওয়া যাবে তার আপেক্ষিক পথও থাকবে।
SASS ফাইলটিকে CSS-এ রূপান্তর করতে npm install
এর পরে একটি npm run build
চালান এবং আপনি যেতে প্রস্তুত।
নিষ্পাপ বাস্তবায়ন শুরু করতে, npm run start-v1
চালান, অথবা আরও ভাল বাস্তবায়ন চালাতে, npm run start-v2
চালান। পরিবেশ পরিবর্তনশীল OPENAI_API_KEY
সংজ্ঞায়িত করতে ভুলবেন না।
ইউনিক্স সিস্টেমে আপনি চালাতে পারেন:
OPENAI_API_KEY=YOU_API_KEY npm run start-v1`
এবং উইন্ডোজে:
set OPENAI_API_KEY=YOU_API_KEY npm run start-v1
আপনি যখন পৃষ্ঠাটি অ্যাক্সেস করবেন তখন এই পৃষ্ঠাটি আপনাকে দেখতে হবে:
নিষ্পাপ বাস্তবায়ন সার্ভার থেকে ডেটা পাঠানো এবং গ্রহণ করার জন্য HTTP অনুরোধ এবং প্রতিক্রিয়া ব্যবহার করে।
আমরা প্রতিটি গুরুত্বপূর্ণ ফাংশন দেখব। সমস্ত কোড উপরে উল্লিখিত লিঙ্কে পাওয়া যাবে.
অ্যাপটি কীভাবে কাজ করবে তার একটি অ্যাক্টিভিটি ডায়াগ্রাম এখানে রয়েছে:
ইউজার টেক্সট ইনপুট এলিমেন্টে এন্টার চাপলে কী হয় তা দেখা যাক:
/* version-1/client.js */ inputTextElement.addEventListener('keydown', async (event) => { if (event.code !== 'Enter') return; if (!inputTextElement.value) return; const message = inputTextElement.value; inputTextElement.value = ""; inputTextElement.disabled = true; inputSpeechElement.disabled = true; await addMessage('user', message); inputTextElement.disabled = false; inputSpeechElement.disabled = false; });
ব্যবহারকারী একবার এন্টার কী টিপে এবং ইনপুট খালি না হলে, আমরা টেক্সট ইনপুট এবং অডিও বোতাম উভয়ই নিষ্ক্রিয় করে দেব যাতে আমরা আগের বার্তার প্রতিক্রিয়া পাওয়ার সময় ব্যবহারকারী অন্য বার্তা পাঠাতে না পারে। আমরা উত্তর পেয়ে গেলে, আমরা কার্যকারিতা পুনরুদ্ধার করি।
আমরা ইনপুট নিষ্ক্রিয় করার পরে, আমরা প্রধান ফাংশনকে কল করি, addMessage
, যা জাদু করে। চলুন এটি তাকান:
/* version-1/client.js */ /** * Add a new message to the chat. * @async * * @param {MessageType} type the type of message * @param {String|Audio} message the data of the message * @param {Object} [settings] additional settings * @param {Number} [settings.audioLength] the length of the audio in seconds * @returns {Promise} the promise resolved when all is done */ async function addMessage(type, message, settings = {}) { const newMsg = document.createElement('div'); newMsg.classList.add('message'); if (type === MessageType.User) { newMsg.classList.add('user'); newMsg.innerHTML = message; } else if (type === MessageType.UserAudio) { newMsg.classList.add('user', 'audio'); newMsg.innerHTML = 'Audio message'; } else { newMsg.classList.add(MessageType.Bot); } const msgsCnt = document.getElementById('friendly-bot-container-msgs'); msgsCnt.appendChild(newMsg); // Keeping own history log if (type === MessageType.User || type === MessageType.Bot) { messageHistory.push({ role: type === MessageType.User ? 'user' : 'assistant', content: message }); } if (type === MessageType.Bot) { if (Settings.UseWriteEffect) { // Create a write effect when the bot responds let speed = Settings.DefaultTypingSpeed; if (settings.audioLength) { const ms = settings.audioLength * 1000 + ((message.match(/,/g) || []).length * 40) + ((message.match(/\./g) || []).length * 70); speed = ms / message.length; } for (let i=0, length=message.length; i < length; i += 1) { newMsg.innerHTML += message.charAt(i); await sleep(speed); } } else { newMsg.innerHTML = message; } } else if (type === MessageType.User || type === MessageType.UserAudio) { let response; if (type === MessageType.User) { response = await sendMessage({ message }); } else if (type === MessageType.UserAudio) { response = await sendMessage({ audio: message }); } if (response.audio) { const audio = convertBase64ToAudio(response.audio); playAudio(audio); } return addMessage(MessageType.Bot, response.answer); } }
ফাংশনটি নতুন বার্তার জন্য একটি নতুন HTMLDivElement
তৈরি করবে এবং বার্তার ধরনের উপর ভিত্তি করে CSS ক্লাস যোগ করবে।
এটি হয়ে গেলে, আমরা আমাদের ক্লায়েন্ট-সাইড চ্যাট ইতিহাসে বার্তাটি সংরক্ষণ করি।
এরপরে, যদি যোগ করা বার্তাটি বট থেকে হয়, তাহলে আমরা এটি "লেখার প্রভাব" ব্যবহার করে প্রদর্শন করি। আমরা বার্তার অক্ষরের সংখ্যা দ্বারা অডিওর দৈর্ঘ্যকে ভাগ করে টাইপিং গতিতে অডিওটিকে, যদি এটি বিদ্যমান থাকে, সিঙ্ক্রোনাইজ করার চেষ্টা করি।
যদি যোগ করা বার্তাটি ব্যবহারকারীর কাছ থেকে হয়, তাহলে আমরা sendMessage
ফাংশনটিতে কল করে AI থেকে উত্তর পেতে সার্ভারে পাঠাই।
sendMessage
ফাংশনটি আমাদের সার্ভারে fetch
ব্যবহার করে একটি HTTP অনুরোধ করে।
উল্লেখ করার মতো একটি বিষয়: আমরা প্রতিটি ক্লায়েন্টের জন্য একটি র্যান্ডম আইডি তৈরি করি যা আমরা প্রতিটি বার্তার সাথে পাঠাই যাতে সার্ভার জানতে পারে কোথা থেকে চ্যাট ইতিহাস পেতে হবে।
সার্ভারে একটি শনাক্তকারী আইডি পাঠানোর বিকল্প হল প্রতিবার পুরো ইতিহাস পাঠাতে হবে, কিন্তু প্রতিটি বার্তার সাথে, যে ডেটা পাঠাতে হবে তা বৃদ্ধি পায়।
/* 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
অবজেক্টে এবং ব্লবটিকে একটি বেস64 এনকোডেড স্ট্রিং-এ রূপান্তর করি এবং এটি ফেরত দিই।
এছাড়াও আমরা প্রতিটি অডিও ট্র্যাকের মধ্য দিয়ে যাই এবং সেগুলি বন্ধ করি, তারপর সেগুলি সরিয়ে ফেলি৷ এটি প্রয়োজনীয় কারণ, অন্তত 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 }); });
ক্লায়েন্ট প্রয়োজনীয় ডেটা পাঠিয়েছে কিনা তা পরীক্ষা করার পরে, আমরা প্রদত্ত আইডির জন্য চ্যাট ইতিহাস পাই (বা একটি নতুন চ্যাট ইতিহাস তৈরি করুন):
/* version-1/server.js */ /** * Get the chat history, or create a new one or the given ID. * * @param {Object} chatHistory the global chat history object containing all the chats * @param {String} id the ID of the chat to retrieve * @returns {Object} the chat history for the given `id` */ function getChatMessages(chatHistory, id) { if (!chatHistory[id]) { chatHistory[id] = [ { role: "system", content: Settings.AISystemContent }, { role: "assistant", content: Settings.WelcomeMessage } ]; } return chatHistory[id]; }
তারপর, যদি প্রাপ্ত বার্তাটি একটি পাঠ্য হয়, একটি অডিও নয়, আমরা চ্যাট ইতিহাসে বার্তাটি যুক্ত করি৷ যদি প্রাপ্ত বার্তাটি একটি অডিও হয়, তাহলে আমরা ফাংশনটিকে stt
বলি যেটি OpenAI-এর হুইস্পার ব্যবহার করে স্পিচ-টু-টেক্সট অ্যাকশন সম্পাদন করবে।
ফাংশনটি openai.audio.transcriptions.create
পদ্ধতি ব্যবহার করবে। এই পদ্ধতির প্রধান পরামিতি হল file
, যা অবশ্যই আমাদের অডিও ডেটা উপস্থাপন করবে। আমরা আমাদের base64 এনকোড করা অডিও ফাইলকে OpenAI পড়তে পারে এমন ফাইলে রূপান্তর করতে প্যাকেজ 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; }
এখন যেহেতু আমাদের কাছে বার্তা আছে, আমরা চ্যাটটি 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
রূপান্তরিত করি এবং সেখান থেকে ক্লায়েন্টকে ফেরত পাঠানোর জন্য একটি বেস64 এনকোড করা অডিও স্ট্রিং-এ রূপান্তর করি।
/* 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 অনুরোধগুলি ব্যবহার করার পরিবর্তে, আমরা ক্লায়েন্ট এবং সার্ভারের মধ্যে যোগাযোগের জন্য WebSockets ব্যবহার করতে পারি এবং ChatGPT-কে স্ট্রীম হিসাবে ফলাফলগুলি ফেরত দিতে বলতে পারি। এইভাবে, আমরা একটি রিয়েল-টাইম লেখার প্রভাব তৈরি করতে পারি কারণ আমরা ফলাফলটি ChatGPT থেকে ক্লায়েন্টের কাছে রিয়েল-টাইমে স্ট্রিম করব।
এই বাস্তবায়নের একটি ত্রুটি রয়েছে, কিন্তু শুধুমাত্র কারণ আমরা OpenAI এর TTS ব্যবহার করছি, যা প্রতি মিনিটে সর্বোচ্চ 3টি অনুরোধ গ্রহণ করে। অতএব, এই বাস্তবায়নের জন্য, আমরা টেক্সট-টু-স্পিচ বৈশিষ্ট্যটি বাদ দেব, তবে আমি কীভাবে এটি পুনরায় প্রয়োগ করতে হবে এবং এটি করার সময় কী সন্ধান করতে হবে সে সম্পর্কে টিপস দেব।
তাই এর কিছু কোড তাকান. আমরা পূর্ববর্তী বাস্তবায়ন থেকে শুরু করেছি এবং WebSockets সমর্থন করার জন্য যা প্রয়োজন ছিল তা পরিবর্তন করেছি।
/* version-2/client.js */ const ws = new WebSocket(Settings.WSAddress); const ChatID = makeID(10); // When the connection to the server is made send the chat ID ws.addEventListener('open', () => { const idMessage = new Uint8Array([ClientMessageID.SetClientID, ...new TextEncoder().encode(ChatID)]); ws.send(idMessage); });
ক্লায়েন্ট কোডের এই বিভাগে, আমরা WebSocket সার্ভারের সাথে সংযোগ করি। যখন সংযোগ খোলা হয়, আমরা প্রথম বার্তা হিসাবে চ্যাট আইডি পাঠাই যাতে সার্ভার জানতে পারে আমরা কে।
ক্লায়েন্ট এবং সার্ভারের মধ্যে পাঠানো ডেটা/বার্তার গঠন এই বিন্যাস অনুসরণ করে:
প্রথম বাইটটি আমরা যে ধরণের বার্তা পাঠাচ্ছি তা উপস্থাপন করে, সার্ভারকে পাঠানো ডেটাতে নিম্নলিখিত বাইটগুলি দ্বারা প্রতিনিধিত্ব করা পেলোড কীভাবে পরিচালনা করতে হয় তা জানতে দেয়।
মনে রাখবেন যে আমরা WebSocket সার্ভারটিকে শুধুমাত্র বাইনারি ডেটা গ্রহণ এবং পাঠাতে কনফিগার করেছি। এই কারণেই আমরা সর্বদা ক্লায়েন্টের দিক থেকে একটি Uint8Array
এবং সার্ভারের দিক থেকে একটি Buffer
পাঠাব। আমরা iy শুধুমাত্র বাইনারিতে পাঠাচ্ছি কারণ এটি আরও কার্যকরী, শুধুমাত্র আমাদের যা প্রয়োজন তা টেক্সটে রূপান্তর করা হয় এবং বাইনারিতে থাকতে বাকি থাকে (যেমন অডিও অংশগুলি, যা বাইনারিতে থাকতে হবে)।
নিম্নলিখিত কোডে, আমরা সার্ভারের দিক থেকে প্রাপ্ত বার্তাগুলি পরিচালনা করি:
/* version-2/client.js */ const subscriptionsToWSMessages = []; ws.addEventListener('message', async (event) => { const data = new Uint8Array(await event.data.arrayBuffer()); const messageType = data[0]; // Because we know all the possible messages are all strings we can convert all the payloads to string const content = new TextDecoder().decode(data.slice(1)); if (!ws.allGood && messageType !== ServerMessageID.OK) { if (messageType === ServerMessageID.Error) { console.error('Something wrong sending the chat ID:', content); } } else if (messageType === ServerMessageID.OK) { ws.allGood = true; } else { let done; for (let i=0, length=subscriptionsToWSMessages.length; i < length; i += 1) { done = await subscriptionsToWSMessages[i](messageType, content); if (done === true) return; } if (!done) { if (messageType === ServerMessageID.Error) { console.error('Unhandled error received from server:', content); } else { console.log(`Unknown message type "${messageType}" received.`); } } } });
যেহেতু আমরা জানি যে সার্ভারের দিক থেকে আমরা যে সমস্ত বার্তা পাই তা পাঠ্য, String
আমরা TextDecoder
: new TextDecoder().decode(data.slice(1));
.
প্রথমত, আমরা সার্ভার থেকে প্রথম 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); }
এর পরে, আমরা ws
অবজেক্টটিকে আরও 3টি পদ্ধতির সাথে প্রসারিত করি:
sendTextMessage
;
sendAudioChunk
;
sendAudioEnd
।
/* version-2/client.js */ /** * Send a text message to the server. * @async * * @param {String} message the message to send * @param {Function} onNewMessageContent the function to be called with the new answer from bot as it sent from the server */ ws.sendTextMessage = async (message, onNewMessageContent) => { ws.createSubscriptionForBotResponse(onNewMessageContent); const wsMessage = new Uint8Array([ClientMessageID.UserTextMessage, ...new TextEncoder().encode(message)]); ws.send(wsMessage); };
sendTextMessage
পদ্ধতিটি সার্ভারে পাঠানো প্রয়োজন এমন বার্তা গ্রহণ করে এবং একটি ফাংশন যা ChatGPT থেকে প্রাপ্ত ডেটার প্রবাহের সাথে একাধিকবার কল করা হবে।
এই পদ্ধতিতে, সার্ভারে বার্তা পাঠানোর আগে, আমরা createSubscriptionForBotResponse
পদ্ধতিকে কল করি, যা বট থেকে প্রতিক্রিয়া পরিচালনা করার জন্য নতুন বার্তা শোনার জন্য একটি সাবস্ক্রিপশন তৈরি এবং যোগ করা পরিচালনা করে।
/* version-2/client.js */ /** * Create and add a subscription to listen for the response of the bot to our sent message * * @param {Function} onNewMessageContent the function to be called with the new answer from bot as it sent from the server */ ws.createSubscriptionForBotResponse = (onNewMessageContent) => { const wsMessagesHandler = (messageType, content) => { if (messageType === ServerMessageID.TextChunk) { onNewMessageContent(content); return true; } else if (messageType === ServerMessageID.TextEnd) { ws.unsubscribeToWSMessage(wsMessagesHandler); return true; } return false; } ws.subscribeToWSMessage(wsMessagesHandler); }
সাবস্ক্রাইব করা ফাংশন সার্ভার থেকে প্রাপ্ত বার্তাটিতে বটের প্রতিক্রিয়ার জন্য প্রয়োজনীয় বার্তার ধরণ আছে কিনা তা পরীক্ষা করবে ( ServerMessageID.TextChunk
)। যদি এটি হয়ে থাকে, আমরা টেক্সট খণ্ড সহ প্রাপ্ত ফাংশনকে কল করি, যা চ্যাটে বর্তমান বট প্রতিক্রিয়াতে খণ্ডটি যোগ করবে।
যখন বট প্রতিক্রিয়ার সাথে সম্পন্ন হয়, সার্ভার আমাদেরকে ServerMessageID.TextEnd
টাইপ সহ একটি বার্তা পাঠাবে, যা নির্দেশ করে যে আমরা শোনা বন্ধ করতে পারি, এই সময়ে, আমরা নতুন বার্তা শোনা থেকে সদস্যতা ত্যাগ করব।
/* version-2/client.js */ /** * Send an audio chunk to the server. * @async * * @param {Blob} blobChunk the audio blob chunk */ ws.sendAudioChunk = async (blobChunk) => { const wsMessage = new Uint8Array([ClientMessageID.UserAudioChunk, ...new Uint8Array(await blobChunk.arrayBuffer())]); ws.send(wsMessage); }; /** * Tell the server that the audio is done. * * @param {Function} onNewMessageContent the function to be called with the new answer from bot as it sent from the server */ ws.sendAudioEnd = (onNewMessageContent) => { ws.createSubscriptionForBotResponse(onNewMessageContent); ws.send(new Uint8Array([ClientMessageID.UserAudioEnd])); };
পরবর্তী 2টি পদ্ধতি, sendAudioChunk
এবং sendAudioEnd
হল ব্যবহারকারীর রেকর্ড করা ভয়েস সার্ভারে পাঠানোর জন্য। প্রথমটি, sendAudioChunk
, প্রাপ্ত বাইটগুলি সার্ভারে পাঠাবে, যখন অন্যটি, sendAudioEnd
, সার্ভারে একটি বার্তা পাঠাবে যা নির্দেশ করে যে অডিওটি হয়ে গেছে এবং sendTextMessage
পদ্ধতির মতো, প্রতিক্রিয়া শোনার জন্য createSubscriptionForBotResponse
কল করবে। বট থেকে
এরপর, আমরা দেখব কিভাবে sendTextMessage
এবং sendAudioEnd
পদ্ধতি থেকে onNewMessageContent
প্যারামিটার পাঠানো হয়।
আমরা addMessage
ফাংশনটিকে addUserMessage
এবং addBotMessage
এ বিভক্ত করে সামান্য পরিবর্তন করেছি। আমরা শুধু addUserMessage
দেখব:
/* version-2/client.js */ /** * Add a new message to the chat. * @async * * @param {WebSocket} ws the WebSocket * @param {MessageType} type the type of message * @param {String|Audio} message the data of the message * @returns {Promise} the promise resolved when all is done */ async function addUserMessage(ws, type, message) { createMessageHTMLElement(type, type === MessageType.User ? message : 'Audio message'); // Keeping own history log if (type === MessageType.User) { messageHistory.push({ role: type === MessageType.User ? 'user' : 'assistant', content: message }); } if (type === MessageType.User) { await ws.sendTextMessage(message, addBotMessageInChunks()); } else { await ws.sendAudioEnd(addBotMessageInChunks()); } } /** * Add bot message in chunks. The functions returns another function that when called with * the argument will add that argument to the bot message. * * @returns {Function} the function accept a parameter `content`; when called the `content` is added to the message */ function addBotMessageInChunks() { const newMsg = createMessageHTMLElement(MessageType.Bot); let nextContentIndex = 0; let currentContentIndex = 0; let currentContentPromise; const onNewMessageContent = async (content) => { const thisContentIndex = nextContentIndex; nextContentIndex += 1; while (thisContentIndex !== currentContentIndex) { await currentContentPromise; } currentContentPromise = new Promise(async resolve => { await addContentToMessage(newMsg, content); currentContentIndex += 1; resolve(); }); } return onNewMessageContent; }
addBotMessageInChunks
ফাংশনটি বর্তমান বট বার্তায় প্রদত্ত পাঠ্য/কন্টেন্ট যুক্ত করবে এমন ফাংশন তৈরি এবং ফেরানোর জন্য দায়ী।
যেহেতু আমরা বট বার্তাটি আসার সাথে সাথে লেখার প্রভাব রাখতে চাই, আমাদের সবকিছু সিঙ্ক্রোনাইজ করার জন্য একটি পদ্ধতি থাকা দরকার। সার্ভার পাঠ্যটি আসার সাথে সাথে পাঠাবে এবং addContentToMessage
ফাংশন, যা লেখার প্রভাব তৈরি করার জন্য দায়ী, পরবর্তী প্রাপ্ত পাঠ্যটি পরিচালনা করার জন্য সময়মতো প্রস্তুত নাও হতে পারে।
সুতরাং, আমরা একটি সহজ সিঙ্ক্রোনাইজেশন প্রক্রিয়া নিয়ে এসেছি: আমরা 2টি কাউন্টার এবং একটি ভেরিয়েবল তৈরি করি যা একটি প্রতিশ্রুতি ধারণ করবে। প্রতিবার ফিরে আসা ফাংশনটি কল করা হলে, আমরা সেই কলটিকে পরবর্তী সূচক (লাইন 39) বরাদ্দ করি এবং তারপর কাউন্টারটি বাড়াই। ফাংশনটি প্রতিশ্রুতি সমাধানের জন্য অপেক্ষা করে তার পালাটির জন্য অপেক্ষা করবে এবং যখন এটির পালা হবে, এটি একটি নতুন প্রতিশ্রুতি দিয়ে প্রতিশ্রুতি পরিবর্তনশীলটিকে ওভাররাইট করবে যা কেবল লেখার প্রভাবটি সম্পন্ন হওয়ার জন্য অপেক্ষা করবে (লাইন 47) এবং তারপর কাউন্টার বাড়ান।
/* version-2/client.js */ /** * Record the user and send the chunks to the server and on end wait for all the chunks to be sent. * @async * * @param {WebSocket} ws the WebSocket * @param {HTMLElement} stopRecordButtonElement the stop button element * @returns {Promise} */ async function recordUserAudio(ws, stopRecordButtonElement) { let stream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (error) { console.error(`The following getUserMedia error occurred: ${error}`); return; } const mediaRecorder = new MediaRecorder(stream, { mimeType: Settings.ClientAudioMimeType }); return new Promise((resolve, _reject) => { const onStopClick = () => { mediaRecorder.stop(); stopRecordButtonElement.classList.remove('show'); }; // Create an array to store the promises of the sent audio chunks so we can make sure that // when the user hit the stop button all the audio chunks are sent const sentAudioChunksPromises = []; mediaRecorder.addEventListener('dataavailable', (event) => { sentAudioChunksPromises.push(ws.sendAudioChunk(event.data)); }); mediaRecorder.addEventListener('stop', async (_event) => { await Promise.all(sentAudioChunksPromises); // Stop the audio listening stream.getAudioTracks().forEach((track) => { track.stop() stream.removeTrack(track); }); resolve(); }); stopRecordButtonElement.classList.add('show'); // The parameter of `start` is called `timeslice` and define how often, in milliseconds, // to fire the `dataavailable` event with the audio chunk mediaRecorder.start(1000); stopRecordButtonElement.addEventListener('click', onStopClick); }) }
recordUserAudio
ফাংশনটিও সামান্য পরিবর্তন করা হয়েছে:
mediaRecorder.start()
কে আর্গুমেন্ট 1000
সহ কল করলে ব্যবহারকারীর অডিও 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.')])); } } // ...
প্রথমত, আমরা প্রাপ্ত বার্তাটি বাইনারিতে আছে কিনা তা পরীক্ষা করি। এর পরে, আমরা বাকি ডেটা ( payload
) থেকে বার্তার ধরন ( messageType
) আলাদা করি। যদি ক্লায়েন্ট এখনও চ্যাট আইডি না পাঠিয়ে থাকে এবং বার্তার ধরন এটির জন্য না হয়, তাহলে একটি ত্রুটি ফেরত দিন। অন্যথায়, ক্লায়েন্ট সকেটের ভিতরে সঠিক হলে আমরা চ্যাট আইডি সংরক্ষণ করি।
/* version-2/server.js */ // ... } else if (messageType === ClientMessageID.UserTextMessage || messageType === ClientMessageID.UserAudioEnd) { const messages = getChatMessages(chatHistory, clientWS.chatID); let messageContent; if (messageType === ClientMessageID.UserTextMessage) { messageContent = payload.toString('utf8'); } else if (messageType === ClientMessageID.UserAudioEnd) { // When the client send the `ClientMessageID.UserAudioEnd` message type it means it clicked the STOP button // Concat all the buffers into a single one const buffer = Buffer.concat(clientWS.audioChunks); // Reset the chunks array clientWS.audioChunks = []; // Send audio to OpenAI to perform the speech-to-text messageContent = await stt(openai, buffer); } messages.push({ role: "user", content: messageContent }); try { await getAnswer(openai, messages, clientWS); } catch (error) { console.error(`(ChatID: ${clientWS.chatID}) Error when trying to get an answer from ChatGPT:`); console.error(error); clientWS.send(Buffer.from([ServerMessageID.Error, ...Buffer.from('Error!')])); return; } } // ...
একবার ক্লায়েন্ট ClientMessageID.UserTextMessage
বা ClientMessageID.UserAudioEnd
টাইপের একটি বার্তা পাঠালে, আমরা আগের মতোই চ্যাটের বার্তাগুলি পুনরুদ্ধার করি। যদি বার্তাটি ClientMessageID.UserTextMessage
ধরনের হয়, তাহলে আমরা প্রাপ্ত ডেটা ( payload
) একটি String
এ রূপান্তর করব।
যদি বার্তাটি ClientMessageID.UserAudioEnd
টাইপের হয়, তাহলে আমরা সমস্ত অডিও বাফার খণ্ডগুলিকে একটি একক খণ্ডে একত্রিত করব, খণ্ডগুলির অ্যারে রিসেট করব এবং অডিওতে স্পিচ-টু-টেক্সট অ্যাকশন সঞ্চালন করব, যা পাঠ্যটি ফিরিয়ে দেবে।
পরবর্তী ধাপ হল নতুন বার্তাটি ChatGPT দ্বারা গৃহীত বিন্যাসে তৈরি করা এবং প্রতিক্রিয়ার জন্য ChatGPT-কে জিজ্ঞাসা করা।
/* version-2/server.js */ // ... } else if (messageType === ClientMessageID.UserAudioChunk) { clientWS.audioChunks.push(payload); } // ...
শেষ বার্তার ধরনটি আমরা হ্যান্ডেল করি অডিও খণ্ডগুলির জন্য, যা ক্লায়েন্ট সকেটের অডিও খণ্ড অ্যারেতে প্রাপ্ত ডেটা যোগ করে।
এখন, আসুন দেখি কিভাবে getAnswer
ফাংশনটি স্ট্রীম সমর্থন করার জন্য পরিবর্তিত হয়েছে:
/* version-2/server.js */ /** * Get the next message in the conversation * @async * * @param {OpenAI} openai the OpenAI instance * @param {String[]} messages the messages in the OpenAI format * @returns {String} the response from ChatGPT */ async function getAnswer(openai, messages, clientWS) { // Documentation https://platform.openai.com/docs/api-reference/chat/create const stream = await openai.chat.completions.create({ model: Settings.ChatGPTModel, messages, stream: true, }); for await (const chunk of stream) { const content = chunk.choices[0]?.delta?.content; if (!content) continue; clientWS.send(Buffer.from([ServerMessageID.TextChunk, ...Buffer.from(content || "")])); } clientWS.send(Buffer.from([ServerMessageID.TextEnd])); }
কেবল stream: true
, এটি একটি স্ট্রিম অবজেক্ট ফিরিয়ে দেবে যা আমরা লুপ করতে পারি। ডেটার প্রতিটি অ-খালি খণ্ডের জন্য, আমরা এটি ক্লায়েন্টের কাছে ফেরত পাঠাব। স্ট্রীম সম্পন্ন হওয়ার পর, আমাদের ক্লায়েন্টকে জানাতে হবে যে প্রতিক্রিয়া সম্পূর্ণ হয়েছে।
ঠিক আছে, ঠিক আছে, কিন্তু যদি আমাদের একটি টিটিএস পরিষেবা থাকে যা স্ট্রিমিং সমর্থন করে বা দ্রুত প্রক্রিয়া করা হয় এমন অনেক অনুরোধ গ্রহণ করে?
কোন সমস্যা নেই: আমাদের শুধু সার্ভার এবং ক্লায়েন্ট সাইডে কিছু জিনিস সামঞ্জস্য করতে হবে।
সার্ভারের দিকে, একবার আমরা AI থেকে উত্তরের একটি অংশ পাই ( getAnswer
ফাংশনে), আমাদের TTS পরিষেবাতে কল করতে হবে এবং ক্লায়েন্ট পক্ষের প্রতিক্রিয়া হিসাবে প্রাপ্ত অডিও ডেটা পাঠাতে হবে।
ক্লায়েন্টের দিকে, আরও পরিবর্তন প্রয়োজন:
এই পোস্ট/বাস্তবায়নে বাদ দেওয়া হয়েছে এমন কিছু দিক আছে, যেমন কোডের বিভিন্ন অংশের জন্য ত্রুটি পরিচালনা।
আপনি যদি Go বা Rust-এ সার্ভারের বাস্তবায়ন দেখতে চান তাহলে অনুগ্রহ করে আমাকে [email protected] এ লিখুন।
Draw.io ব্যবহার করে ডায়াগ্রাম তৈরি করা হয়েছে এবং ব্যাকরণ পরীক্ষা ChatGPT দ্বারা করা হয়েছে।
সম্পদ: