در این مقاله، توسعه چت بات هوش مصنوعی در React را گامبهگام بررسی میکنیم. این چت بات بلادرنگ، با ترکیب ورودی صوتی و قابلیت چت، تجربهای تعاملی و چندوجهی را برای کاربران فراهم میسازد. توسعهدهندگان برای افزودن ورودی صوتی در اپلیکیشنهای چت مدرن از ابزارهایی مانند Stream Chat بهره میبرند؛ زیرا این قابلیت نهتنها تجربه کاربری را جذابتر میکند، بلکه دسترسیپذیری را نیز بهبود میدهد و کاربران با نیازهای گوناگون را بهتر پشتیبانی میکند.
در این آموزش، از Stream Chat در React برای مدیریت پیامرسانی و از Web Speech API برای تبدیل گفتار به متن استفاده میکنیم تا یک چت بات هوش مصنوعی توسعه دهیم که امکان تعامل همزمان صوتی و متنی را داشته باشد.
همچنین اگر به توسعه چت باتهای هوش مصنوعی علاقهمند هستید، پیشنهاد میکنیم ویدیوی آموزشی ما درباره ساخت یک چت بات تخصصی برنامهنویسی با Next.js را نیز در کانال یوتیوب مسعود صدری مشاهده کنید.
پیش از شروع، باید اطمینان حاصل کنیم که موارد زیر را در اختیار داریم:
ما این اپلیکیشن را با تکیه بر سه ابزار اصلی توسعه میدهیم: Stream Chat، Web Speech API، و یک بکاند مبتنی بر Node.js + Express.
برای شروع، کار خود را با بخش بکاند در فرآیند توسعه چت بات در React آغاز میکنیم؛ بخشی که ورودی کاربر را به مدلهای هوش مصنوعی مانند OpenAI و Anthropic ارسال کرده و پاسخ پردازش شده را return میکند.
.env.example را باز کرده و کلیدهای موردنیاز را وارد میکنیم. (در این مرحله فقط به کلید OpenAI یا Anthropic نیاز داریم، کلید Open Weather اختیاری است)env.example را به .env تغییر میدهیم. npm install، وابستگیها را نصب میکنیم. npm start راهاندازی میکنیم.اگر همه چیز درست انجام شده باشد، بکاند ما باید بهدرستی روی localhost:3000 اجرا شود.
در این بخش، به دو مؤلفه اصلی و مرتبط با یکدیگر میپردازیم: ساختار چت و قابلیت تشخیص گفتار.
در این مرحله از توسعه چت بات در React، پروژه را با استفاده از Stream Chat React SDK ایجاد و پیکربندی میکنیم. برای ساخت این پروژه، از Vite با قالب تایپ اسکریپت بهره میگیریم.
برای شروع، به پوشه My-Chat-Application میرویم، ترمینال را باز کرده و دستور زیر را وارد میکنیم:
npm create vite frontend -- --template react-ts cd chat-example npm i stream-chat stream-chat-react
پس از راهاندازی پروژه فرانتاند، میتوانیم اپلیکیشن را اجرا کنیم:
npm run dev
تمرکز اصلی در این بخش بر روی راهاندازی کلاینت چت، اتصال کاربر، ایجاد کانال و رندر رابط کاربری چت است. تمامی این مراحل را بهصورت گامبهگام بررسی میکنیم تا درک بهتری از نحوه عملکرد آنها بهدست آوریم:
در ابتدا باید برخی اطلاعات مهم را برای ایجاد کاربر و راهاندازی کلاینت چت وارد کنیم. این مقادیر از داشبورد Stream ما قابل دریافت هستند.
const apiKey = "xxxxxxxxxxxxx"; const userId = "111111111"; const userName = "John Doe"; const userToken = "xxxxxxxxxx.xxxxxxxxxxxx.xx_xxxxxxx-xxxxx_xxxxxxxx"; //your stream secret key
توجه داشته باشید که مقادیر موجود در کد مثال صرفاً جنبه نمایشی دارند. حتماً باید از اطلاعات واقعی حساب کاربری خود استفاده کنیم.
در گام بعدی، آبجکت کاربر را ایجاد میکنیم. این آبجکت شامل ID، نام و یک آدرس تصویر آواتار است:
const user: User = {
id: userId,
name: userName,
image: `https://getstream.io/random_png/?name=${userName}`,
};
برای پشتیبانی از پیامرسانی بلادرنگ در چت بات توسعه یافته با Stream Chat در React، باید وضعیت کانال فعال را با استفاده از هوک useState مدیریت کنیم.. در این پروژه، از یک هوک سفارشی با نام useCreateChatClient برای راهاندازی کلاینت چت استفاده میکنیم که شامل کلید API، توکن کاربر و اطلاعات کاربر خواهد بود:
const [channel, setChannel] = useState<StreamChannel>();
const client = useCreateChatClient({
apiKey,
tokenOrProvider: userToken,
userData: user,
});
در این مرحله، یک کانال پیامرسانی را مقداردهی اولیه میکنیم تا ارتباط بلادرنگ در اپلیکیشن فعال شود. زمانی که کلاینت چت آماده باشد، هوک useEffect اجرای کدی را آغاز میکند که کانال پیامرسانیای با نام my_channel ایجاد کرده و کاربر را بهعنوان عضو به آن اضافه میکند. سپس اپلیکیشن این کانال را در state مربوطه ذخیره میکند تا بتواند گفتگوهای پویا را نمایش دهد.
useEffect(() => {
if (!client) return;
const channel = client.channel("messaging", "my_channel", {
members: [userId],
});
setChannel(channel);
}, [client]);
با راهاندازی تمام بخشهای کلیدی اپلیکیشن چت، حالا نوبت به بازگرداندن JSX برای تعریف ساختار رابط کاربری چت میرسد:
if (!client) return <div>Setting up client & connection...</div>;
return (
<Chat client={client}>
<Channel channel={channel}>
<Window>
<MessageList />
<MessageInput />
</Window>
<Thread />
</Channel>
</Chat>
);
در ساختار JSX فوق:
<Chat>: کانتکست Stream Chat را با کلاینت راهاندازی شده دربر میگیرد.<Channel>: کانال فعال را تنظیم میکند.<Window>: کامپوننتهای اصلی رابط چت را شامل میشود:
<MessageList>: فهرست پیامها را نمایش میدهد.<MessageInput>: از یک کامپوننت سفارشی با نام CustomMessageInput برای ارسال پیام استفاده میکند.<Thread>: پاسخهای تودرتو را رندر میکند.با این تنظیمات، رابط کاربری و کانال چت ما راهاندازی شده و کلاینت آماده به کار است.
این اپلیکیشن چت برای تعامل با یک هوش مصنوعی طراحی شده، بنابراین لازم است بتوانیم AI را به کانال اضافه کنیم یا در صورت نیاز آن را حذف کنیم. برای این منظور، در رابط کاربری یک دکمه در هدر کانال اضافه میکنیم تا کاربران بتوانند AI را فعال یا غیرفعال کنند. اما ابتدا باید بررسی کنیم که آیا AI هماکنون در کانال حضور دارد یا نه، تا گزینه مناسب را نمایش دهیم.
برای انجام این کار، یک هوک سفارشی به نام useWatchers تعریف میکنیم که با استفاده از مفهومی به نام watchers، حضور AI را در کانال پایش میکند:
import { useCallback, useEffect, useState } from 'react';
import { Channel } from 'stream-chat';
export const useWatchers = ({ channel }: { channel: Channel }) => {
const [watchers, setWatchers] = useState<string[]>([]);
const [error, setError] = useState<Error | null>(null);
const queryWatchers = useCallback(async () => {
setError(null);
try {
const result = await channel.query({ watchers: { limit: 5, offset: 0 } });
setWatchers(result?.watchers?.map((watcher) => watcher.id).filter((id): id is string => id !== undefined) || [])
return;
} catch (err) {
setError(err as Error);
}
}, [channel]);
useEffect(() => {
queryWatchers();
}, [queryWatchers]);
useEffect(() => {
const watchingStartListener = channel.on('user.watching.start', (event) => {
const userId = event?.user?.id;
if (userId && userId.startsWith('ai-bot')) {
setWatchers((prevWatchers) => [
userId,
...(prevWatchers || []).filter((watcherId) => watcherId !== userId),
]);
}
});
const watchingStopListener = channel.on('user.watching.stop', (event) => {
const userId = event?.user?.id;
if (userId && userId.startsWith('ai-bot')) {
setWatchers((prevWatchers) =>
(prevWatchers || []).filter((watcherId) => watcherId !== userId)
);
}
});
return () => {
watchingStartListener.unsubscribe();
watchingStopListener.unsubscribe();
};
}, [channel]);
return { watchers, error };
};
اکنون میتوانیم یک کامپوننت جدید برای هدر کانال طراحی کنیم. با استفاده از هوک useChannelStateContext به کانال دسترسی پیدا کرده و هوک سفارشی useWatchers را مقداردهی اولیه میکنیم. از طریق اطلاعات بهدست آمده از Watchers، متغیری با نام aiInChannel تعریف میکنیم تا متن مرتبط را نمایش دهد.
براساس مقدار این متغیر، یکی از دو مسیر زیر را فراخوانی میکنیم:
start-ai-agent: برای فعالسازی هوش مصنوعیstop-ai-agent: برای غیرفعالسازی هوش مصنوعی.این درخواستها به بکاند Node.js ارسال میشوند.
import { useChannelStateContext } from 'stream-chat-react';
import { useWatchers } from './useWatchers';
export default function ChannelHeader() {
const { channel } = useChannelStateContext();
const { watchers } = useWatchers({ channel });
const aiInChannel =
(watchers ?? []).filter((watcher) => watcher.includes('ai-bot')).length > 0;
return (
<div className='my-channel-header'>
<h2>{(channel?.data as { name?: string })?.name ?? 'Voice-and-Text AI Chat'}</h2>
<button onClick={addOrRemoveAgent}>
{aiInChannel ? 'Remove AI' : 'Add AI'}
</button>
</div>
);
async function addOrRemoveAgent() {
if (!channel) return;
const endpoint = aiInChannel ? 'stop-ai-agent' : 'start-ai-agent';
await fetch(`http://127.0.0.1:3000/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: channel.id, platform: 'openai' }),
});
}
}
از آنجایی که پردازش اطلاعات توسط هوش مصنوعی ممکن است کمی زمانبر باشد، لازم است در حین پردازش، یک نشانگر برای نمایش state فعلی آن در رابط کاربری داشته باشیم. برای این منظور، یک کامپوننت با نام AIStateIndicator طراحی میکنیم که این وظیفه را برعهده دارد:
import { AIState } from 'stream-chat';
import { useAIState, useChannelStateContext } from 'stream-chat-react';
export default function MyAIStateIndicator() {
const { channel } = useChannelStateContext();
const { aiState } = useAIState(channel);
const text = textForState(aiState);
return text && <p className='my-ai-state-indicator'>{text}</p>;
function textForState(aiState: AIState): string {
switch (aiState) {
case 'AI_STATE_ERROR':
return 'Something went wrong...';
case 'AI_STATE_CHECKING_SOURCES':
return 'Checking external resources...';
case 'AI_STATE_THINKING':
return "I'm currently thinking...";
case 'AI_STATE_GENERATING':
return 'Generating an answer for you...';
default:
return '';
}
}
}
تا اینجای کار، اپلیکیشن چت ما قادر است پیامها را ارسال کرده و پاسخهایی از هوش مصنوعی دریافت کند. اکنون قصد داریم امکان تعامل صوتی را نیز فراهم کنیم تا کاربران بتوانند بهجای تایپ دستی، با صحبت کردن با AI ارتباط برقرار کنند.
برای رسیدن به این هدف، قابلیت تبدیل گفتار به متن را درون یک کامپوننت سفارشی به نام CustomMessageInput پیادهسازی خواهیم کرد. در ادامه تمام مراحل را گامبهگام مرور میکنیم تا بهصورت کامل با روند پیادهسازی آشنا شویم.
زمانی که کامپوننت CustomMessageInput برای اولین بار در DOM بارگذاری میشود، ابتدا ساختار اولیه stateها را پیکربندی میکند:
const [isRecording, setIsRecording] = useState<boolean>(false);
const [isRecognitionReady, setIsRecognitionReady] = useState<boolean>(false);
const recognitionRef = useRef<any>(null);
const isManualStopRef = useRef<boolean>(false);
const currentTranscriptRef = useRef<string>("");
این مرحله ابتدایی، نقش بسیار مهمی در عملکرد کلی کامپوننت دارد، زیرا باعث میشود امکان پیگیری همزمان چند state کلیدی فراهم شود؛ مانند:
در Stream Chat، کانتکست MessageInputContext درون کامپوننت MessageInput تعریف میشود و دادههایی را در اختیار کامپوننت ورودی و childهای آن قرار میدهد.
از آنجایی که ما قصد داریم از مقادیر موجود در MessageInputContext برای ساخت یک کامپوننت سفارشی ورودی استفاده کنیم، باید از هوک سفارشی useMessageInputContext استفاده کنیم:
// Access the MessageInput context
const { handleSubmit, textareaRef } = useMessageInputContext();
این مرحله تضمین میکند که قابلیت ورودی صوتی بهطور یکپارچه با زیرساخت موجود چت ادغام شده و از همان مرجع textarea و سازوکار ارسال پیام استفاده میکند که دیگر روشهای ورودی نیز از آن بهره میبرند.
از آنجایی که برخی مرورگرها Web Speech API را پشتیبانی نمیکنند، ابتدا باید بررسی کنیم آیا مرورگر فعلی این قابلیت را دارد یا نه. نخستین مرحله مهم در این کامپوننت، شناسایی و راهاندازی Web Speech API است:
const SpeechRecognition = (window as any).SpeechRecognition||(window as any).webkitSpeechRecognition;
بهمحض شناسایی API، اپلیکیشن سرویس تشخیص گفتار را با تنظیمات بهینه مقداردهی میکند تا تعامل صوتی با کاربر آغاز شود.
در این مرحله، دو دسته از Event Handlerها را تعریف خواهیم کرد:
هدف این بخش، پردازش نتایج خروجی تشخیص گفتار است. این پردازش در دو مرحله انجام میشود:
این رویکرد دوبخشی به ما این امکان را میدهد که تجربه کاربری همزمان سریع و دقیق داشته باشیم.
recognition.onresult = (event: any) => {
let finalTranscript = "";
let interimTranscript = "";
// Process all results from the last processed index
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcriptSegment = event.results[i][0].transcript;
if (event.results[i].isFinal) {
finalTranscript += transcriptSegment + " ";
} else {
interimTranscript += transcriptSegment;
}
}
// Update the current transcript
if (finalTranscript) {
currentTranscriptRef.current += finalTranscript;
}
// Combine stored final transcript with current interim results
const combinedTranscript = (currentTranscriptRef.current + interimTranscript).trim();
// Update the textarea
if (combinedTranscript) {
updateTextareaValue(combinedTranscript);
}
};
برای آنکه کامپوننت بتواند به درستی به مراحل مختلف چرخه عمر Web Speech API واکنش نشان دهد، یک Handler برای eventهای onstart، onend و onerror تعریف میکنیم. این eventها به ترتیب فعالسازی، پایان و خطا را در فرایند تشخیص گفتار مدیریت میکنند.
recognition.onstart = () => {
console.log("Speech recognition started");
setIsRecording(true);
currentTranscriptRef.current = ""; // Reset transcript on start
};
recognition.onend = () => {
console.log("Speech recognition ended");
setIsRecording(false);
// If it wasn't manually stopped and we're still supposed to be recording, restart
if (!isManualStopRef.current && isRecording) {
try {
recognition.start();
} catch (error) {
console.error("Error restarting recognition:", error);
}
}
isManualStopRef.current = false;
};
recognition.onerror = (event: any) => {
console.error("Speech recognition error:", event.error);
setIsRecording(false);
isManualStopRef.current = false;
switch (event.error) {
case "no-speech":
console.warn("No speech detected");
// Don't show alert for no-speech, just log it
break;
case "not-allowed":
alert(
"Microphone access denied. Please allow microphone permissions.",
);
break;
case "network":
alert("Network error occurred. Please check your connection.");
break;
case "aborted":
console.log("Speech recognition aborted");
break;
default:
console.error("Speech recognition error:", event.error);
}
};
recognitionRef.current = recognition;
setIsRecognitionReady(true);
} else {
console.warn("Web Speech API not supported in this browser.");
setIsRecognitionReady(false);
}
وقتی کاربر روی دکمه میکروفون کلیک میکند، کامپوننت وارد یک فرایند چندمرحلهای میشود:
این مراحل از نظر تجربه کاربری و رعایت مسائل امنیتی اهمیت زیادی دارند.
const toggleRecording = async (): Promise<void> => {
if (!recognitionRef.current) {
alert("Speech recognition not available");
return;
}
if (isRecording) {
// Stop recording
isManualStopRef.current = true;
recognitionRef.current.stop();
} else {
try {
// Request microphone permission
await navigator.mediaDevices.getUserMedia({ audio: true });
// Clear current text and reset transcript before starting
currentTranscriptRef.current = "";
updateTextareaValue("");
// Start recognition
recognitionRef.current.start();
} catch (error) {
console.error("Microphone access error:", error);
alert(
"Unable to access microphone. Please check permissions and try again.",
);
}
}
};
پیش از آغاز فرایند تشخیص گفتار، کامپوننت stateهای داخلی خود را ریست میکند تا از آغاز بدون خطا و بدون تداخل اطمینان حاصل شود.
این بازنشانی باعث میشود هر بار ضبط صدا در محیطی تمیز و بدون باقیمانده از session قبلی انجام شود.
currentTranscriptRef.current = "";
updateTextareaValue("");
recognitionRef.current.start();
در طول فرایند تشخیص گفتار، دو اقدام بهصورت همزمان انجام میشوند:
textarea را به صورت بلادرنگ بهروزرسانی میکند:const updateTextareaValue = (value: string) => {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
)?.set;
if (nativeInputValueSetter) {
nativeInputValueSetter.call(textareaRef.current, value);
const inputEvent = new Event('input', { bubbles: true });
textareaRef.current.dispatchEvent(inputEvent);
}
};
برای ایجاد تجربهای طبیعیتر و روانتر در تعاملات صوتی، چند ویژگی بصری مهم را پیادهسازی میکنیم:
این تغییر وضعیت، نمای واضحی از فعال یا غیرفعال بودن ضبط به کاربر میدهد.
<button
className={`voice-input-button ${isRecording ? 'recording' : 'idle'}`}
title={isRecording ? "Stop recording" : "Start voice input"}
>
{isRecording ? (
<Square size={20} className="voice-icon recording-icon" />
) : (
<Mic size={20} className="voice-icon idle-icon" />
)}
</button>
زمانی که ضبط صوت در حال انجام است، یک نوار اطلاعرسانی در بالای صفحه ظاهر میشود.
این اعلان به کاربران اطلاع میدهد که میکروفون فعال است و به این ترتیب هم از نظر حفظ حریم خصوصی و هم از نظر تجربه کاربری، شفافیت لازم فراهم میشود.
{isRecording && (
<div className="recording-notification show">
<span className="recording-icon">🎤</span>
Recording... Click stop when finished
</div>
)}
سیستم چت موجود، متن تبدیل شده را بهصورت یکپارچه در مکالمه ادغام میکند. این فرایند از طریق رفرنس مشترک textarea و هندلری که از طریق context برای ارسال پیام فراهم شده، صورت میگیرد:
<SendButton sendMessage={handleSubmit} />
در این یکپارچهسازی، پیامهای صوتی دقیقاً همان مسیر ارسال پیامهای تایپی را طی میکنند تا سیستم چت رفتاری یکپارچه و سازگار داشته باشد. پس از ارسال پیام، این کامپوننت state داخلی خود را بهدرستی پاکسازی کرده و برای session بعدی ورودی صوتی آماده میشود.
اکنون که کامپوننت ورودی پیام سفارشی خود را ساختیم، آن را بهعنوان مقدار prop با نام Input به کامپوننت MessageInput در فایل App.tsx ارسال خواهیم کرد:
<MessageInput Input={CustomMessageInput} />
روند کلی عملکرد این اپلیکیشن به شرح زیر است:
در این مقاله با روند توسعه یک چت بات در React آشنا شدیم که از ورودیهای متنی و صوتی بهصورت همزمان پشتیبانی میکند و تعامل روان و قدرتمندی را برای کاربر فراهم میسازد.
در ادامه مسیر، با بهرهگیری از قابلیتهای دیگر Stream مانند Chat و Video میتوانیم تجربههای مکالمهای پیشرفتهتری ایجاد کرده و کیفیت پروژهها را ارتقا دهیم.
کد کامل این پروژه از طریق این لینک در دسترس است.