در این مقاله، توسعه چت بات هوش مصنوعی در React را گام‌به‌گام بررسی می‌کنیم. این چت بات بلادرنگ، با ترکیب ورودی صوتی و قابلیت چت، تجربه‌ای تعاملی و چندوجهی را برای کاربران فراهم می‌سازد. توسعه‌دهندگان برای افزودن ورودی صوتی در اپلیکیشن‌های چت مدرن از ابزارهایی مانند Stream Chat بهره می‌برند؛ زیرا این قابلیت نه‌تنها تجربه کاربری را جذاب‌تر می‌کند، بلکه دسترسی‌پذیری را نیز بهبود می‌دهد و کاربران با نیازهای گوناگون را بهتر پشتیبانی می‌کند.

در این آموزش، از Stream Chat در React برای مدیریت پیام‌رسانی و از Web Speech API برای تبدیل گفتار به متن استفاده می‌کنیم تا یک چت بات هوش مصنوعی توسعه دهیم که امکان تعامل همزمان صوتی و متنی را داشته باشد.

همچنین اگر به توسعه چت بات‌های هوش مصنوعی علاقه‌مند هستید، پیشنهاد می‌کنیم ویدیوی آموزشی ما درباره ساخت یک چت بات تخصصی برنامه‌نویسی با Next.js را نیز در کانال یوتیوب مسعود صدری مشاهده کنید.

پیش‌نیازها

پیش از شروع، باید اطمینان حاصل کنیم که موارد زیر را در اختیار داریم:

فناوری‌های مورد استفاده در پروژه

ما این اپلیکیشن را با تکیه بر سه ابزار اصلی توسعه می‌دهیم: Stream Chat، Web Speech API، و یک بک‌اند مبتنی بر Node.js + Express.

راهنمای پیاده‌سازی بک‌اند

برای شروع، کار خود را با بخش بک‌اند در فرآیند توسعه چت بات در React آغاز می‌کنیم؛ بخشی که ورودی کاربر را به مدل‌های هوش مصنوعی مانند OpenAI و Anthropic ارسال کرده و پاسخ پردازش شده را return می‌کند.

راه‌اندازی پروژه Node.js + Express

  1. یک پوشه با نام «My-Chat-Application» ایجاد می‌کنیم.
  2. این ریپازیتوری گیت‌هاب را کلون می‌کنیم.
  3. پس از کلون کردن، نام پوشه را به «backend» تغییر می‌دهیم.
  4. فایل .env.example را باز کرده و کلیدهای موردنیاز را وارد می‌کنیم. (در این مرحله فقط به کلید OpenAI یا Anthropic نیاز داریم، کلید Open Weather اختیاری است)
  5. نام فایل env.example را به .env تغییر می‌دهیم.
  6. با اجرای دستور npm install، وابستگی‌ها را نصب می‌کنیم.
  7. پروژه را با اجرای دستور npm start راه‌اندازی می‌کنیم.

اگر همه چیز درست انجام شده باشد، بک‌اند ما باید به‌درستی روی localhost:3000 اجرا شود.

راهنمای پیاده‌سازی فرانت‌اند با React

در این بخش، به دو مؤلفه اصلی و مرتبط با یکدیگر می‌پردازیم: ساختار چت و قابلیت تشخیص گفتار.

راه‌اندازی پروژه React

در این مرحله از توسعه چت بات در 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

آشنایی با ساختار کامپوننت App در Stream Chat در React

تمرکز اصلی در این بخش بر روی راه‌اندازی کلاینت چت، اتصال کاربر، ایجاد کانال و رندر رابط کاربری چت است. تمامی این مراحل را به‌صورت گام‌به‌گام بررسی می‌کنیم تا درک بهتری از نحوه عملکرد آن‌ها به‌دست آوریم:

تعریف مقادیر ثابت موردنیاز

در ابتدا باید برخی اطلاعات مهم را برای ایجاد کاربر و راه‌اندازی کلاینت چت وارد کنیم. این مقادیر از داشبورد 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

برای پشتیبانی از پیام‌رسانی بلادرنگ در چت بات توسعه یافته با 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 فوق:

با این تنظیمات، رابط کاربری و کانال چت ما راه‌اندازی شده و کلاینت آماده به کار است.

افزودن هوش مصنوعی به کانال چت Stream

این اپلیکیشن چت برای تعامل با یک هوش مصنوعی طراحی شده، بنابراین لازم است بتوانیم 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 };
};

پیکربندی ChannelHeader

اکنون می‌توانیم یک کامپوننت جدید برای هدر کانال طراحی کنیم. با استفاده از هوک useChannelStateContext به کانال دسترسی پیدا کرده و هوک سفارشی useWatchers را مقداردهی اولیه می‌کنیم. از طریق اطلاعات به‌دست آمده از Watchers، متغیری با نام aiInChannel تعریف می‌کنیم تا متن مرتبط را نمایش دهد.

براساس مقدار این متغیر، یکی از دو مسیر زیر را فراخوانی می‌کنیم:

این درخواست‌ها به بک‌اند 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 AI

از آن‌جایی که پردازش اطلاعات توسط هوش مصنوعی ممکن است کمی زمان‌بر باشد، لازم است در حین پردازش، یک نشانگر برای نمایش 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 '';
    }
  }
}

پیاده‌سازی قابلیت تبدیل گفتار به متن در Stream Chat در React

تا اینجای کار، اپلیکیشن چت ما قادر است پیام‌ها را ارسال کرده و پاسخ‌هایی از هوش مصنوعی دریافت کند. اکنون قصد داریم امکان تعامل صوتی را نیز فراهم کنیم تا کاربران بتوانند به‌جای تایپ دستی، با صحبت کردن با AI ارتباط برقرار کنند.

برای رسیدن به این هدف، قابلیت تبدیل گفتار به متن را درون یک کامپوننت سفارشی به نام CustomMessageInput پیاده‌سازی خواهیم کرد. در ادامه تمام مراحل را گام‌به‌گام مرور می‌کنیم تا به‌صورت کامل با روند پیاده‌سازی آشنا شویم.

پیکربندی stateهای اولیه برای تشخیص صدا

زمانی که کامپوننت 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 کلیدی فراهم شود؛ مانند:

یکپارچه‌سازی سیستم تشخیص گفتار با Context

در Stream Chat، کانتکست MessageInputContext درون کامپوننت MessageInput تعریف می‌شود و داده‌هایی را در اختیار کامپوننت ورودی و childهای آن قرار می‌دهد.

از آن‌جایی که ما قصد داریم از مقادیر موجود در MessageInputContext برای ساخت یک کامپوننت سفارشی ورودی استفاده کنیم، باید از هوک سفارشی useMessageInputContext استفاده کنیم:

// Access the MessageInput context
const { handleSubmit, textareaRef } = useMessageInputContext();

این مرحله تضمین می‌کند که قابلیت ورودی صوتی به‌طور یکپارچه با زیرساخت موجود چت ادغام شده و از همان مرجع textarea و سازوکار ارسال پیام استفاده می‌کند که دیگر روش‌های ورودی نیز از آن بهره می‌برند.

شناسایی و مقداردهی اولیه Web Speech API

از آن‌جایی که برخی مرورگرها Web Speech API را پشتیبانی نمی‌کنند، ابتدا باید بررسی کنیم آیا مرورگر فعلی این قابلیت را دارد یا نه. نخستین مرحله مهم در این کامپوننت، شناسایی و راه‌اندازی Web Speech API است:

const SpeechRecognition = (window as any).SpeechRecognition||(window as any).webkitSpeechRecognition;

به‌محض شناسایی API، اپلیکیشن سرویس تشخیص گفتار را با تنظیمات بهینه مقداردهی می‌کند تا تعامل صوتی با کاربر آغاز شود.

پیکربندی Event Handlerهای مرتبط با تشخیص گفتار

در این مرحله، دو دسته از Event Handler‌ها را تعریف خواهیم کرد:

  1. مدیریت خروجی تشخیص گفتار
  2. مدیریت life cycle تشخیص گفتار

مدیریت خروجی تشخیص گفتار

هدف این بخش، پردازش نتایج خروجی تشخیص گفتار است. این پردازش در دو مرحله انجام می‌شود:

این رویکرد دوبخشی به ما این امکان را می‌دهد که تجربه کاربری همزمان سریع و دقیق داشته باشیم.

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);
        }
      };

مدیریت eventهای life cycle تشخیص گفتار

برای آنکه کامپوننت بتواند به درستی به مراحل مختلف چرخه عمر 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);
     }

آغاز فرآیند ضبط صوت کاربر

وقتی کاربر روی دکمه میکروفون کلیک می‌کند، کامپوننت وارد یک فرایند چندمرحله‌ای می‌شود:

  1. درخواست مجوز دسترسی به میکروفون از مرورگر
  2. بررسی وضعیت دسترسی
  3. در صورت رد دسترسی، نمایش پیام خطای واضح به کاربر

این مراحل از نظر تجربه کاربری و رعایت مسائل امنیتی اهمیت زیادی دارند.

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 و شروع تشخیص گفتار جدید

پیش از آغاز فرایند تشخیص گفتار، کامپوننت stateهای داخلی خود را ریست می‌کند تا از آغاز بدون خطا و بدون تداخل اطمینان حاصل شود.

این بازنشانی باعث می‌شود هر بار ضبط صدا در محیطی تمیز و بدون باقی‌مانده از session قبلی انجام شود.

currentTranscriptRef.current = "";
updateTextareaValue("");
recognitionRef.current.start();

پردازش بلادرنگ گفتار کاربر و تبدیل به متن

در طول فرایند تشخیص گفتار، دو اقدام به‌صورت همزمان انجام می‌شوند:

  1. پردازش پیوسته خروجی گفتار
  1. به‌روزرسانی لحظه‌ای 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);
   }
 };

نمایش بازخورد بصری در رابط کاربری

برای ایجاد تجربه‌ای طبیعی‌تر و روان‌تر در تعاملات صوتی، چند ویژگی بصری مهم را پیاده‌سازی می‌کنیم:

1. جابه‌جایی بین آیکون میکروفون و توقف

این تغییر وضعیت، نمای واضحی از فعال یا غیرفعال بودن ضبط به کاربر می‌دهد.

<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>

2. نمایش نوار اعلان فعال بودن ضبط صوت

زمانی که ضبط صوت در حال انجام است، یک نوار اطلاع‌رسانی در بالای صفحه ظاهر می‌شود.

این اعلان به کاربران اطلاع می‌دهد که میکروفون فعال است و به این ترتیب هم از نظر حفظ حریم خصوصی و هم از نظر تجربه کاربری، شفافیت لازم فراهم می‌شود.

{isRecording && (
  <div className="recording-notification show">
    <span className="recording-icon">🎤</span>
    Recording... Click stop when finished
  </div>
)}

یکپارچه‌سازی پیام صوتی و ارسال آن در چت

سیستم چت موجود، متن تبدیل شده را به‌صورت یکپارچه در مکالمه ادغام می‌کند. این فرایند از طریق رفرنس مشترک textarea و هندلری که از طریق context برای ارسال پیام فراهم شده، صورت می‌گیرد:

<SendButton sendMessage={handleSubmit} />

در این یکپارچه‌سازی، پیام‌های صوتی دقیقاً همان مسیر ارسال پیام‌های تایپی را طی می‌کنند تا سیستم چت رفتاری یکپارچه و سازگار داشته باشد. پس از ارسال پیام، این کامپوننت state داخلی خود را به‌درستی پاک‌سازی کرده و برای session بعدی ورودی صوتی آماده می‌شود.

ارسال پیام با استفاده از کامپوننت سفارشی CustomMessageInput

اکنون که کامپوننت ورودی پیام سفارشی خود را ساختیم، آن را به‌عنوان مقدار prop با نام Input به کامپوننت MessageInput در فایل App.tsx ارسال خواهیم کرد:

<MessageInput Input={CustomMessageInput} />

بررسی روند کامل اجرای اپلیکیشن چت بات در React

روند کلی عملکرد این اپلیکیشن به شرح زیر است:

  1. پس از رندر شدن کامپوننت، با کلیک روی دکمه Add AI، هوش مصنوعی را به چت اضافه می‌کنیم.
  2. برای شروع ضبط صدا، روی آیکون میکروفن کلیک می‌کنیم.
  3. مرورگر ما برای استفاده از میکروفن، درخواست مجوز می‌کند.
  4. اگر اجازه دسترسی به میکروفن را ندهیم، فرایند ضبط آغاز نخواهد شد.
  5. در صورت موافقت با دسترسی، ضبط صدا و تبدیل گفتار به متن به‌صورت همزمان آغاز می‌شوند.
  6. برای پایان ضبط، روی آیکون توقف (مربع) کلیک می‌کنیم.
  7. با کلیک روی دکمه ارسال، پیام خود را ارسال می‌کنیم.
  8. هوش مصنوعی پیام ما را پردازش کرده و پاسخ مناسبی تولید می‌کند.

جمع‌بندی

در این مقاله با روند توسعه یک چت بات در React آشنا شدیم که از ورودی‌های متنی و صوتی به‌صورت هم‌زمان پشتیبانی می‌کند و تعامل روان و قدرتمندی را برای کاربر فراهم می‌سازد.

در ادامه مسیر، با بهره‌گیری از قابلیت‌های دیگر Stream مانند Chat و Video می‌توانیم تجربه‌های مکالمه‌ای پیشرفته‌تری ایجاد کرده و کیفیت پروژه‌ها را ارتقا دهیم.

کد کامل این پروژه از طریق این لینک در دسترس است.