├── .gitignore ├── .gitmodules ├── README.md ├── client ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── index.html │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.css │ ├── App.test.tsx │ ├── App.tsx │ ├── assets │ │ ├── arrow_ne.svg │ │ ├── arrow_right.svg │ │ └── loader.svg │ ├── components.d.ts │ ├── components │ │ ├── Button │ │ │ ├── Button.tsx │ │ │ ├── index.tsx │ │ │ ├── style.css │ │ │ └── types.tsx │ │ ├── ChatInput │ │ │ ├── index.tsx │ │ │ └── style.tsx │ │ ├── Chatbox │ │ │ ├── index.tsx │ │ │ └── style.tsx │ │ ├── Header │ │ │ ├── Header.tsx │ │ │ ├── index.tsx │ │ │ ├── style.css │ │ │ └── types.tsx │ │ ├── InlineNotification │ │ │ ├── InlineNotification.tsx │ │ │ ├── index.tsx │ │ │ ├── style.css │ │ │ └── types.tsx │ │ ├── LinkButton │ │ │ ├── LinkButton.tsx │ │ │ ├── index.tsx │ │ │ ├── style.css │ │ │ └── types.tsx │ │ └── Loader │ │ │ ├── Loader.tsx │ │ │ ├── index.tsx │ │ │ ├── style.css │ │ │ └── types.tsx │ ├── context │ │ └── session.tsx │ ├── index.css │ ├── index.tsx │ ├── middleware │ │ └── axios.tsx │ ├── pages │ │ ├── Chat.tsx │ │ ├── Error.tsx │ │ └── Home.tsx │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── setupTests.ts │ └── shared │ │ ├── layout.tsx │ │ ├── themes.tsx │ │ └── utilities.tsx └── tsconfig.json ├── docs ├── contents.md ├── freecodecamp.md ├── full-stack-chatbot-architecture.drawio.svg ├── full-stack-chatbot-architecture.png └── images │ ├── chat-session-with-token.png │ ├── chat-static.png │ ├── chat.mov │ ├── conversation-chat.png │ ├── conversation-live.png │ ├── postman-chat-test-token.png │ ├── postman-chat-test.png │ ├── redis-insight-channel.png │ ├── redis-insight-test.png │ ├── terminal-channel-messages-test.png │ ├── test-page.png │ ├── token-generator-postman.png │ └── token-generator-updated.png ├── server ├── .gitignore ├── main.py └── src │ ├── redis │ ├── cache.py │ ├── config.py │ ├── producer.py │ └── stream.py │ ├── routes │ └── chat.py │ ├── schema │ └── chat.py │ └── socket │ ├── connection.py │ └── utils.py └── worker ├── .gitignore ├── main.py └── src ├── model └── gptj.py ├── redis ├── cache.py ├── config.py ├── producer.py └── stream.py └── schema └── chat.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | env -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "fullstack-ai-chatbot.wiki"] 2 | path = fullstack-ai-chatbot.wiki 3 | url = https://github.com/stephensanwo/fullstack-ai-chatbot.wiki.git 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Build a Fullstack AI Chatbot with Redis, React, FastAPI and GPT 2 | 3 | - Featured on FreeCodeCamp: https://www.freecodecamp.org/news/how-to-build-an-ai-chatbot-with-redis-python-and-gpt/ 4 | - Article Wiki: https://github.com/stephensanwo/fullstack-ai-chatbot/wiki 5 | - Follow Full Series: https://blog.stephensanwo.dev/series/build-ai-chatbot 6 | - Subscribe to new technical tutorials: https://blog.stephensanwo.dev 7 | 8 | Created: July 02, 2022 9 | Author: Stephen Sanwo 10 | 11 |
12 | 13 | In order to build a working full-stack application, there are so many moving parts to think about. And you'll need to make many decisions that will be critical to the success of your app. 14 | 15 | For example, what language will you use and what platform will you deploy on? Are you going to deploy a containerised software on a server, or make use of serverless functions to handle the backend? Do you plan to use third-party APIs to handle complex parts of your application, like authentication or payments? Where do you store the data? 16 | 17 | In addition to all this, you'll also need to think about the user interface, design and usability of your application, and much more. 18 | 19 | This is why complex large applications require a multifunctional development team collaborating to build the app. 20 | 21 | One of the best ways to learn how to develop full stack applications is to build projects that cover the end-to-end development process. You'll go through designing the architecture, developing the API services, developing the user interface, and finally deploying your application. 22 | 23 | So this tutorial will take you through the process of building an AI chatbot to help you learn these concepts in depth. 24 | 25 | Some of the topics we will cover include: 26 | 27 | - How to build APIs with Python, FastAPI, and WebSockets 28 | - How to build real-time systems with Redis 29 | - How to build a chat User Interface with React 30 | 31 | **Important Note:** 32 | This is an intermediate full stack software development project that requires some basic Python and JavaScript knowledge. 33 | 34 | I've carefully divided the project into sections to ensure that you can easily select the phase that is important to you in case you do not wish to code the full application. 35 | 36 | You can download the full repository on [My Github here](https://github.com/stephensanwo/fullstack-ai-chatbot). 37 | 38 | ### Application Architecture 39 | 40 | Sketching out a solution architecture gives you a high-level overview of your application, the tools you intend to use, and how the components will communicate with each other. 41 | 42 | I have drawn up a simple architecture below using [draw.io](http://draw.io): 43 | 44 | ![full-stack-chatbot-architecture.svg](https://github.com/stephensanwo/fullstack-ai-chatbot/blob/master/docs/full-stack-chatbot-architecture.drawio.svg) 45 | 46 | Let's go over the various parts of the architecture in more detail: 47 | 48 | ### Client/User Interface 49 | 50 | We will use React version 18 to build the user interface. The Chat UI will communicate with the backend via WebSockets. 51 | 52 | ### GPT-J-6B and Huggingface Inference API 53 | 54 | GPT-J-6B is a generative language model which was trained with 6 Billion parameters and performs closely with OpenAI's GPT-3 on some tasks. 55 | 56 | I have chosen to use GPT-J-6B because it is an open-source model and doesn’t require paid tokens for simple use cases. 57 | 58 | Huggingface also provides us with an on-demand API to connect with this model pretty much free of charge. You can read more about [GPT-J-6B](https://huggingface.co/EleutherAI/gpt-j-6B?text=My+name+is+Teven+and+I+am) and [Hugging Face Inference API](https://huggingface.co/inference-api). 59 | 60 | ### Redis 61 | 62 | When we send prompts to GPT, we need a way to store the prompts and easily retrieve the response. We will use Redis JSON to store the chat data and also use Redis Streams for handling the real-time communication with the huggingface inference API. 63 | 64 | Redis is an in-memory key-value store that enables super-fast fetching and storing of JSON-like data. For this tutorial, we will use a managed free Redis storage provided by [Redis Enterprise](https://redis.info/3NBGJRT) for testing purposes. 65 | 66 | ### Web Sockets and the Chat API 67 | 68 | To send messages between the client and server in real-time, we need to open a socket connection. This is because an HTTP connection will not be sufficient to ensure real-time bi-directional communication between the client and the server. 69 | 70 | We will be using FastAPI for the chat server, as it provides a fast and modern Python server for our use. [Check out the FastAPI documentation](https://fastapi.tiangolo.com/advanced/websockets/?h=web)) to learn more about WebSockets. 71 | 72 | Follow the full series here: https://blog.stephensanwo.dev/series/build-ai-chatbot 73 | 74 | Created: July 02, 2022 75 | Author: Stephen Sanwo 76 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/client/README.md -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.4", 7 | "@testing-library/react": "^13.3.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "@types/jest": "^27.5.2", 10 | "@types/node": "^16.11.45", 11 | "@types/react": "^18.0.15", 12 | "@types/react-dom": "^18.0.6", 13 | "axios": "^0.27.2", 14 | "moment": "^2.29.4", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0", 17 | "react-router-dom": "^6.3.0", 18 | "react-scripts": "5.0.1", 19 | "styled-components": "^5.3.5", 20 | "typescript": "^4.7.4", 21 | "uuid": "^8.3.2", 22 | "web-vitals": "^2.1.4" 23 | }, 24 | "scripts": { 25 | "start": "react-scripts start", 26 | "build": "react-scripts build", 27 | "test": "react-scripts test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "eslintConfig": { 31 | "extends": [ 32 | "react-app", 33 | "react-app/jest" 34 | ] 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.2%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 1 chrome version", 44 | "last 1 firefox version", 45 | "last 1 safari version" 46 | ] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /client/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/client/public/favicon-16x16.png -------------------------------------------------------------------------------- /client/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/client/public/favicon-32x32.png -------------------------------------------------------------------------------- /client/public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/client/public/favicon-96x96.png -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 17 | 21 | 22 | Fullstack AI Chatbot 23 | 24 | 25 | 26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Fullstack AI Chatbot", 3 | "name": "Fullstack AI Chatbot", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/src/App.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/client/src/App.css -------------------------------------------------------------------------------- /client/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from "react"; 2 | import { Route, Routes } from "react-router-dom"; 3 | import "./App.css"; 4 | import Header from "./components/Header"; 5 | import Chat from "./pages/Chat"; 6 | import Error from "./pages/Error"; 7 | import Home from "./pages/Home"; 8 | import { AppContainer, PageContainer } from "./shared/layout"; 9 | 10 | function App() { 11 | return ( 12 | 13 |
17 | 18 | 19 | 20 | }> 21 | }> 22 | } /> 23 | 24 | 25 | 26 | 27 | ); 28 | } 29 | 30 | export default App; 31 | -------------------------------------------------------------------------------- /client/src/assets/arrow_ne.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/assets/arrow_right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/assets/loader.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/components.d.ts: -------------------------------------------------------------------------------- 1 | declare module "react-router-dom"; 2 | declare module "styled-components"; 3 | declare module "uuid"; 4 | declare module "stephensanwo-design-system"; 5 | declare let module: any; 6 | -------------------------------------------------------------------------------- /client/src/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Loader from "../Loader"; 3 | import "./style.css"; 4 | import { ButtonInterface } from "./types"; 5 | import SVG from "../../assets/arrow_right.svg"; 6 | 7 | const Button: React.FC = ({ kind, text, icon, ...props }) => { 8 | return ( 9 | 17 | ); 18 | }; 19 | 20 | export default Button; 21 | -------------------------------------------------------------------------------- /client/src/components/Button/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from "./Button"; 2 | -------------------------------------------------------------------------------- /client/src/components/Button/style.css: -------------------------------------------------------------------------------- 1 | /* Button */ 2 | .button { 3 | all: unset; 4 | cursor: pointer; 5 | height: 50px; 6 | min-width: 320px; 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | gap: 50px; 11 | cursor: pointer; 12 | border-radius: 2px; 13 | text-align: center; 14 | font-family: "IBM Plex Sans", sans-serif; 15 | text-align: left; 16 | color: #fff; 17 | width: fit-content; 18 | } 19 | 20 | .button-secondary { 21 | background-color: #000; 22 | } 23 | 24 | .button-secondary:hover { 25 | background-color: #2E2E2E; 26 | } 27 | 28 | .button-primary { 29 | background-color: #0053ff; 30 | } 31 | 32 | .button-primary:hover { 33 | background-color: #1f70ff; 34 | } 35 | .button-danger { 36 | background-color: rgb(255, 39, 39); 37 | } 38 | 39 | .button-danger:hover { 40 | background-color: #ff4545; 41 | } 42 | 43 | .button-tertiary { 44 | background-color: rgb(100, 100, 100); 45 | } 46 | 47 | .button-tertiary:hover { 48 | background-color: #2E2E2E; 49 | } 50 | 51 | .button h6 { 52 | color: #fff; 53 | font-size: 12px; 54 | } 55 | -------------------------------------------------------------------------------- /client/src/components/Button/types.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | export interface ButtonInterface 3 | extends React.ButtonHTMLAttributes { 4 | kind: "primary" | "secondary" | "tertiary" | "danger"; 5 | text: string; 6 | hasIcon: boolean; 7 | icon?: React.ReactNode; 8 | isLoading?: boolean; 9 | } 10 | -------------------------------------------------------------------------------- /client/src/components/ChatInput/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import React, { useContext, useState, useEffect, useRef } from "react"; 3 | import SessionContext, { MessageProps } from "../../context/session"; 4 | import { ChatMessage, ChatInputContainer, SendMessage } from "./style"; 5 | import { v4 as uuid4 } from "uuid"; 6 | 7 | interface ChatInputProps { 8 | chat: MessageProps; 9 | setChat: React.Dispatch>; 10 | } 11 | 12 | const ChatInput: React.FC = (props) => { 13 | const [chatInput, setChatInput] = useState(""); 14 | const { messages, setMessages, token, setSocketState } = 15 | useContext(SessionContext); 16 | const [isPaused, setPause] = useState(false); 17 | const handleChange = (event: any) => { 18 | setChatInput(event.target.value); 19 | }; 20 | 21 | const ws = useRef(); 22 | 23 | useEffect(() => { 24 | if (null !== ws) { 25 | ws.current = new WebSocket(`ws://127.0.0.1:3500/chat?token=${token}`); 26 | ws.current.onopen = () => setSocketState("active"); 27 | ws.current.onclose = () => setSocketState(""); 28 | 29 | const wsCurrent = ws.current; 30 | 31 | return () => { 32 | wsCurrent.close(); 33 | }; 34 | } 35 | }, []); 36 | 37 | useEffect(() => { 38 | if (!ws.current) return; 39 | 40 | ws.current.onmessage = (event: any) => { 41 | if (isPaused) return; 42 | const message = JSON.parse(event.data); 43 | setMessages(messages.concat(message)); 44 | console.log(messages); 45 | }; 46 | }, [isPaused]); 47 | 48 | const updateMessages = async (event: any) => { 49 | event.preventDefault(); 50 | setPause(!isPaused); 51 | if (chatInput.length > 0) { 52 | const chat: MessageProps = { 53 | id: uuid4(), 54 | msg: `Human: ${chatInput}`, 55 | timestamp: Date.now().toLocaleString(), 56 | }; 57 | setMessages(messages.concat(chat)); 58 | ws.current.send(chatInput); 59 | setChatInput(""); 60 | } 61 | }; 62 | 63 | return ( 64 | 65 | 71 | Send 72 | 73 | ); 74 | }; 75 | 76 | export default ChatInput; 77 | -------------------------------------------------------------------------------- /client/src/components/ChatInput/style.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const ChatMessage = styled.input` 4 | all: unset; 5 | height: 50px; 6 | min-width: 250px; 7 | width: 80%; 8 | background-color: #f4f4f4; 9 | border: 1px solid #e8e8e8; 10 | font-weight: 400; 11 | font-size: 16px; 12 | font-family: "IBM Plex Sans", sans-serif; 13 | padding-left: 10px; 14 | 15 | ::placeholder { 16 | font-weight: 400; 17 | font-size: 16px; 18 | font-family: "IBM Plex Sans", sans-serif; 19 | text-align: left; 20 | } 21 | `; 22 | 23 | export const ChatInputContainer = styled.form` 24 | display: flex; 25 | width: 100%; 26 | `; 27 | 28 | export const SendMessage = styled.button` 29 | all: unset; 30 | cursor: pointer; 31 | height: 50px; 32 | min-width: 80px; 33 | width: 20%; 34 | background-color: #000; 35 | display: flex; 36 | justify-content: center; 37 | align-items: center; 38 | cursor: pointer; 39 | border-radius: 2px; 40 | font-weight: 400; 41 | font-size: 14px; 42 | text-align: center; 43 | font-family: "IBM Plex Sans", sans-serif; 44 | text-align: left; 45 | color: #fff; 46 | 47 | :hover { 48 | background-color: rgba(0, 0, 0, 0.788); 49 | color: #fff; 50 | } 51 | `; 52 | -------------------------------------------------------------------------------- /client/src/components/Chatbox/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useContext, useEffect, useRef } from "react"; 2 | import SessionContext from "../../context/session"; 3 | import { Paragraph } from "../../shared/layout"; 4 | import moment from "moment"; 5 | import { 6 | BotMessage, 7 | ChatboxContainer, 8 | ChatTimeIndicator, 9 | HumanMessage, 10 | SessionStateIndicator, 11 | } from "./style"; 12 | 13 | const Chatbox = () => { 14 | const messageElement = useRef(null); 15 | const { messages, socketState } = useContext(SessionContext); 16 | 17 | useEffect(() => { 18 | if (null !== messageElement.current) { 19 | messageElement.current.addEventListener( 20 | "DOMNodeInserted", 21 | (event: any) => { 22 | const { currentTarget: target } = event; 23 | target.scroll({ top: target.scrollHeight, behavior: "smooth" }); 24 | } 25 | ); 26 | } 27 | }, [messages]); 28 | 29 | return ( 30 | 31 | 32 | 33 | {messages?.map((message, index) => 34 | message?.msg.substring(0, 5) === "Human" ? ( 35 | 36 | {message.msg} 37 | 38 | {moment(message.timestamp, "YYYYMMDD").fromNow()} 39 | 40 | 41 | ) : ( 42 | 43 | {message.msg} 44 | 45 | {" "} 46 | {moment(message.timestamp, "YYYYMMDD").fromNow()} 47 | 48 | 49 | ) 50 | )} 51 | 52 | 53 | ); 54 | }; 55 | 56 | export default Chatbox; 57 | -------------------------------------------------------------------------------- /client/src/components/Chatbox/style.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const ChatboxContainer = styled.div` 4 | width: 100%; 5 | min-height: 500px; 6 | max-height: 500px; 7 | padding: 10px; 8 | 9 | display: flex; 10 | flex-direction: column; 11 | overflow-y: scroll; 12 | overscroll-behavior-y: contain; 13 | scroll-snap-type: y proximity; 14 | border: 1px solid #e8e8e8; 15 | `; 16 | export const SessionStateIndicator = styled.div` 17 | height: 2px; 18 | width: 100%; 19 | background-color: ${(props: { state: string }) => 20 | props.state === "active" ? "#42be65" : "#fa4d56"}; ; 21 | `; 22 | 23 | export const Messagebox = styled.div` 24 | min-width: 40%; 25 | max-width: 40%; 26 | min-height: 50px; 27 | padding: 10px; 28 | border-radius: 5px; 29 | margin-bottom: 10px; 30 | display: table; 31 | `; 32 | 33 | export const BotMessage = styled(Messagebox)` 34 | background-color: #393939; 35 | align-self: flex-start; 36 | `; 37 | 38 | export const HumanMessage = styled(Messagebox)` 39 | background-color: #0f62fe; 40 | align-self: flex-end; 41 | `; 42 | 43 | export const ChatTimeIndicator = styled.p` 44 | font-weight: 400; 45 | font-size: 10px; 46 | letter-spacing: normal; 47 | font-family: "IBM Plex Sans", sans-serif; 48 | font-weight: 400; 49 | color: ${(props: any) => (props.light ? "#ffffff" : "#333333")}; 50 | -webkit-font-smoothing: antialiased; 51 | -moz-osx-font-smoothing: auto; 52 | text-align: left; 53 | padding: 0 0; 54 | margin: 0 0; 55 | line-height: 1.2; 56 | margin-top: 10px; 57 | `; 58 | -------------------------------------------------------------------------------- /client/src/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from "react"; 2 | import LinkButton from "../LinkButton"; 3 | import "./style.css"; 4 | import { HeaderInterface } from "./types"; 5 | 6 | const Header: React.FC = ({ 7 | mainTitle, 8 | productTitle, 9 | ...props 10 | }) => { 11 | return ( 12 | 13 | 18 | 19 |
20 |
21 |
{productTitle}
22 |
23 | 29 | 35 |
36 |
37 |
38 |
39 | ); 40 | }; 41 | 42 | export default Header; 43 | -------------------------------------------------------------------------------- /client/src/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from "./Header"; 2 | -------------------------------------------------------------------------------- /client/src/components/Header/style.css: -------------------------------------------------------------------------------- 1 | /* Navigation */ 2 | .app-nav-container { 3 | width: 100vw; 4 | top: 0; 5 | z-index: 1000; 6 | height: 50px; 7 | background-color: #000; 8 | } 9 | 10 | .app-nav { 11 | height: 100%; 12 | background: transparent; 13 | align-items: center; 14 | display: flex; 15 | justify-content: space-between; 16 | width: 90%; 17 | margin: auto; 18 | } 19 | 20 | .app-nav a { 21 | color: #fff; 22 | font-size: 12px; 23 | font-family: 'IBM Plex Sans'; 24 | font-style: normal; 25 | font-weight: 500; 26 | line-height: 16px; 27 | } 28 | 29 | .app-nav a:hover { 30 | color: #7BBAF1; 31 | } 32 | 33 | .nav-link-container { 34 | width: 100vw; 35 | display: flex; 36 | position: sticky; 37 | top: 0px; 38 | z-index: 1000; 39 | height: 50px; 40 | background-color: #fff; 41 | border-bottom: 1px solid #e8e8e8; 42 | } 43 | 44 | .nav-links { 45 | display: flex; 46 | align-items: center; 47 | gap: 35px 48 | } 49 | 50 | .nav-icons { 51 | display: flex; 52 | width: 150px; 53 | justify-content: space-between; 54 | align-items: center; 55 | } 56 | 57 | .nav-icon { 58 | cursor: pointer; 59 | 60 | } 61 | 62 | @media (max-width: 900px) { 63 | .nav-link-container .nav-icons { 64 | display: none; 65 | } 66 | .nav-links > a { 67 | display: none; 68 | } 69 | } -------------------------------------------------------------------------------- /client/src/components/Header/types.tsx: -------------------------------------------------------------------------------- 1 | export interface HeaderInterface { 2 | mainTitle: string; 3 | productTitle: string; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/components/InlineNotification/InlineNotification.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./style.css"; 3 | import { InlineNotificationInterface } from "./types"; 4 | 5 | const InlineNotification: React.FC = (props) => { 6 | return ( 7 |
8 | {props.children} 9 |
10 | ); 11 | }; 12 | 13 | export default InlineNotification; 14 | -------------------------------------------------------------------------------- /client/src/components/InlineNotification/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from "./InlineNotification"; 2 | -------------------------------------------------------------------------------- /client/src/components/InlineNotification/style.css: -------------------------------------------------------------------------------- 1 | .notification { 2 | min-height: 50px; 3 | display: flex; 4 | justify-content: flex-start; 5 | align-items: center; 6 | padding: 20px; 7 | margin-top: 20px; 8 | margin-bottom: 20px; 9 | font-size: 12px; 10 | } 11 | .notification-warning { 12 | background-color: #f2eedf; 13 | border-left: 5px solid #f1c21b; 14 | } 15 | 16 | .notification-error { 17 | background-color: #faf3f3; 18 | border-left: 5px solid #ee0713; 19 | } 20 | 21 | .notification-neutral { 22 | background-color: #e8e8e8; 23 | border-left: 5px solid #393939; 24 | } 25 | 26 | .notification-success { 27 | background-color: #eaf9ee; 28 | border-left: 5px solid #42be65; 29 | } 30 | -------------------------------------------------------------------------------- /client/src/components/InlineNotification/types.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | export interface InlineNotificationInterface 3 | extends React.ButtonHTMLAttributes { 4 | kind: "warning" | "error" | "neutral" | "success"; 5 | children: React.ReactNode | string; 6 | } 7 | -------------------------------------------------------------------------------- /client/src/components/LinkButton/LinkButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./style.css"; 3 | import { LinkButtonInterface } from "./types"; 4 | import SVG from "../../assets/arrow_ne.svg"; 5 | 6 | const LinkButton: React.FC = ({ 7 | kind, 8 | href, 9 | text, 10 | hasIcon, 11 | icon, 12 | ...rest 13 | }) => { 14 | return ( 15 | 21 |
{text}
22 | arrow ne 23 |
24 | ); 25 | }; 26 | 27 | export default LinkButton; 28 | -------------------------------------------------------------------------------- /client/src/components/LinkButton/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from "./LinkButton"; 2 | -------------------------------------------------------------------------------- /client/src/components/LinkButton/style.css: -------------------------------------------------------------------------------- 1 | /* Button */ 2 | .link-button { 3 | all: unset; 4 | height: 30px; 5 | padding: 0px 15px 0px 15px; 6 | border-radius: 15px 15px; 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | cursor: pointer; 11 | gap: 8px; 12 | min-width: 20px; 13 | width: fit-content; 14 | 15 | } 16 | 17 | /* .link-button > h6 { 18 | 19 | } */ 20 | 21 | .link-button-secondary { 22 | background-color: #000; 23 | } 24 | 25 | .link-button-secondary:hover { 26 | background-color: #2E2E2E; 27 | } 28 | 29 | .link-button-primary { 30 | background-color: #0053ff; 31 | } 32 | 33 | .link-button-primary:hover { 34 | background-color: #1f70ff; 35 | } 36 | .link-button-danger { 37 | background-color: rgb(255, 39, 39); 38 | } 39 | 40 | .link-button-danger:hover { 41 | background-color: #ff4545; 42 | } 43 | 44 | .link-button-tertiary { 45 | background-color: rgb(100, 100, 100); 46 | } 47 | 48 | .link-button-tertiary:hover { 49 | background-color: #2E2E2E; 50 | } 51 | 52 | .link-button h6 { 53 | color: #fff; 54 | font-size: 12px; 55 | } 56 | 57 | a:hover { 58 | text-decoration: none; 59 | color: #fff; 60 | } -------------------------------------------------------------------------------- /client/src/components/LinkButton/types.tsx: -------------------------------------------------------------------------------- 1 | export interface LinkButtonInterface { 2 | kind: "primary" | "secondary" | "tertiary" | "danger"; 3 | href?: string; 4 | text: string; 5 | hasIcon: boolean; 6 | icon?: React.ReactNode; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/components/Loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./style.css"; 3 | import { LoaderInterface } from "./types"; 4 | 5 | const Loader: React.FC = ({ size, ...rest }) => { 6 | return ( 7 |
14 | ); 15 | }; 16 | 17 | export default Loader; 18 | -------------------------------------------------------------------------------- /client/src/components/Loader/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from "./Loader"; 2 | -------------------------------------------------------------------------------- /client/src/components/Loader/style.css: -------------------------------------------------------------------------------- 1 | .loader { 2 | border: 2px solid #f3f3f3; 3 | border-radius: 50%; 4 | border-top: 2px solid #444444; 5 | animation: spin 1s linear infinite; 6 | z-index: 1000; 7 | } 8 | 9 | @keyframes spin { 10 | 100% { 11 | transform: rotate(360deg); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/src/components/Loader/types.tsx: -------------------------------------------------------------------------------- 1 | export interface LoaderInterface { 2 | size: number; 3 | } 4 | -------------------------------------------------------------------------------- /client/src/context/session.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useState } from "react"; 2 | 3 | interface SessionProviderProps { 4 | children: React.ReactNode; 5 | } 6 | 7 | export interface MessageProps { 8 | id: string; 9 | msg: string; 10 | timestamp: string; 11 | } 12 | 13 | interface SessionContextProps { 14 | token: string; 15 | setToken: React.Dispatch>; 16 | messages: Array; 17 | setMessages: 18 | | React.Dispatch>> 19 | | React.Dispatch> 20 | | any; 21 | name: string; 22 | setName: React.Dispatch>; 23 | session_start: string; 24 | setSessionStart: React.Dispatch>; 25 | socketState: string; 26 | setSocketState: React.Dispatch>; 27 | } 28 | 29 | const SessionContext = createContext({} as SessionContextProps); 30 | 31 | export const SessionProvider = ({ children }: SessionProviderProps) => { 32 | const [token, setToken] = useState(""); 33 | const [messages, setMessages] = useState([]); 34 | const [name, setName] = useState(""); 35 | const [session_start, setSessionStart] = useState(""); 36 | const [socketState, setSocketState] = useState(""); 37 | 38 | return ( 39 | 53 | {children} 54 | 55 | ); 56 | }; 57 | 58 | export default SessionContext; 59 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://1.www.s81c.com/common/carbon/plex/sans.css'); 2 | 3 | * { 4 | box-sizing: border-box; 5 | margin: 0; 6 | padding: 0; 7 | /* border: 1px dashed #007AFF; */ 8 | 9 | } 10 | 11 | body { 12 | background-color: #fff; 13 | font-family: IBM Plex Sans, Helvetica Neue, Arial, sans-serif; 14 | position: relative; 15 | } 16 | 17 | h1 { 18 | font-weight: 600; 19 | font-size: 36px; 20 | text-align: left; 21 | font-family: "IBM Plex Sans"; 22 | line-height: 1.4; 23 | } 24 | 25 | h2 { 26 | font-weight: 600; 27 | font-size: 28px; 28 | text-align: left; 29 | font-family: "IBM Plex Sans"; 30 | line-height: 1.4; 31 | } 32 | 33 | h4 { 34 | font-weight: 600; 35 | font-size: 18px; 36 | line-height: 1.5; 37 | color: #000; 38 | font-family: "IBM Plex Sans"; 39 | 40 | 41 | } 42 | 43 | h5 { 44 | font-size: 14px; 45 | font-family: "IBM Plex Sans"; 46 | font-style: normal; 47 | font-weight: 600; 48 | line-height: 1.5; 49 | } 50 | 51 | h6 { 52 | font-size: 12px; 53 | font-family: "IBM Plex Sans"; 54 | font-style: normal; 55 | font-weight: 500; 56 | line-height: 1.5; 57 | } 58 | 59 | p { 60 | font-weight: 400; 61 | font-size: 12px; 62 | font-family: "IBM Plex Sans"; 63 | text-align: left; 64 | color: #000; 65 | line-height: 1.5; 66 | 67 | } 68 | 69 | li { 70 | font-weight: 400; 71 | font-size: 12px; 72 | font-family: "IBM Plex Sans"; 73 | text-align: left; 74 | color: #000; 75 | line-height: 1.5; 76 | } 77 | 78 | ul { 79 | list-style-position: inside; 80 | } 81 | 82 | small { 83 | font-weight: 400; 84 | font-size: 12px; 85 | font-family: "IBM Plex Sans"; 86 | text-align: left; 87 | color: #000; 88 | line-height: 1.5; 89 | 90 | } 91 | 92 | a { 93 | font-weight: 500; 94 | font-size: 12px; 95 | text-align: center; 96 | font-family: "IBM Plex Sans"; 97 | text-align: left; 98 | color: #000; 99 | text-decoration: none; 100 | line-height: 16px; 101 | } 102 | 103 | a:hover { 104 | color: #007AFF; 105 | } 106 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | import reportWebVitals from "./reportWebVitals"; 6 | import { BrowserRouter, Route, Routes } from "react-router-dom"; 7 | import { SessionProvider } from "./context/session"; 8 | 9 | const root = ReactDOM.createRoot( 10 | document.getElementById("root") as HTMLElement 11 | ); 12 | root.render( 13 | 14 | 15 | 16 | 17 | } /> 18 | 19 | 20 | 21 | 22 | ); 23 | 24 | // If you want to start measuring performance in your app, pass a function 25 | // to log results (for example: reportWebVitals(console.log)) 26 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 27 | reportWebVitals(); 28 | -------------------------------------------------------------------------------- /client/src/middleware/axios.tsx: -------------------------------------------------------------------------------- 1 | import Axios from "axios"; 2 | 3 | export const axios = Axios.create({ 4 | baseURL: "http://127.0.0.1:3500", 5 | }); 6 | -------------------------------------------------------------------------------- /client/src/pages/Chat.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useContext, useEffect, useState } from "react"; 2 | import SessionContext, { MessageProps } from "../context/session"; 3 | import { 4 | ErrorIndicator, 5 | Heading4, 6 | Margin, 7 | MarginSmall, 8 | Paragraph, 9 | Small, 10 | } from "../shared/layout"; 11 | import { Loader } from "../shared/utilities"; 12 | import { axios } from "../middleware/axios"; 13 | import { useParams } from "react-router-dom"; 14 | import moment from "moment"; 15 | import Chatbox from "../components/Chatbox"; 16 | import loader from "../assets/loader.svg"; 17 | import ChatInput from "../components/ChatInput"; 18 | import InlineNotification from "../components/InlineNotification"; 19 | 20 | const Chat = () => { 21 | const { 22 | setToken, 23 | session_start, 24 | setName, 25 | name, 26 | setSessionStart, 27 | setMessages, 28 | } = useContext(SessionContext); 29 | const [loading, setLoading] = useState(false); 30 | const [error, setError] = useState(""); 31 | const [chat, setChat] = useState({ 32 | id: "", 33 | msg: "", 34 | timestamp: "", 35 | }); 36 | const { token_id } = useParams(); 37 | 38 | useEffect(() => { 39 | const REFRESH_SESSION = async () => { 40 | setLoading(true); 41 | try { 42 | const token = await axios.get(`/refresh_token?token=${token_id}`); 43 | setToken(token?.data.token); 44 | setName(token?.data.name); 45 | setSessionStart(token?.data.session_start); 46 | setMessages(token?.data.messages); 47 | setLoading(false); 48 | } catch (error: any) { 49 | setLoading(false); 50 | setError("An unknown error has occured, Please try again later"); 51 | } 52 | }; 53 | 54 | REFRESH_SESSION(); 55 | // eslint-disable-next-line react-hooks/exhaustive-deps 56 | }, [token_id]); 57 | 58 | return ( 59 | 60 | {" "} 61 | {loading ? ( 62 | 63 | UI loading 64 | 65 | ) : ( 66 | 67 | Welcome {name} 68 | 69 | Session Start: {moment(session_start, "YYYYMMDD").fromNow()} 70 | 71 | {error} 72 | 73 | 74 | 75 | 76 | 77 | )} 78 | 84 | 85 | ); 86 | }; 87 | 88 | export default Chat; 89 | -------------------------------------------------------------------------------- /client/src/pages/Error.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from "react"; 2 | import { Heading1, Margin } from "../shared/layout"; 3 | import { Button } from "../shared/utilities"; 4 | import { useNavigate } from "react-router-dom"; 5 | 6 | const Error = () => { 7 | const navigate = useNavigate(); 8 | const navHome = () => { 9 | navigate("/"); 10 | }; 11 | return ( 12 | 13 | 14 | 404 Error
Page Not Found 15 |
16 | 17 | 18 |
19 | ); 20 | }; 21 | 22 | export default Error; 23 | -------------------------------------------------------------------------------- /client/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState, useContext } from "react"; 2 | import { 3 | ErrorIndicator, 4 | Heading1, 5 | Margin, 6 | MarginSmall, 7 | Paragraph, 8 | Small, 9 | } from "../shared/layout"; 10 | import { Input, Loader } from "../shared/utilities"; 11 | import { useNavigate } from "react-router-dom"; 12 | import { axios } from "../middleware/axios"; 13 | import SessionContext from "../context/session"; 14 | import loader from "../assets/loader.svg"; 15 | import Button from "../components/Button"; 16 | import InlineNotification from "../components/InlineNotification"; 17 | 18 | const Home = () => { 19 | const { setToken, name, setName, setSessionStart } = 20 | useContext(SessionContext); 21 | const [loading, setLoading] = useState(false); 22 | const [error, setError] = useState(""); 23 | const navigate = useNavigate(); 24 | console.log(name); 25 | 26 | const handleInput = (event: any) => { 27 | setName(event.target.value); 28 | }; 29 | 30 | const CREATE_SESSION = async () => { 31 | try { 32 | setLoading(true); 33 | const token = await axios.post(`/token?name=${name}`); 34 | setToken(token?.data.token); 35 | setName(token?.data.name); 36 | setSessionStart(token?.data.session_start); 37 | setLoading(false); 38 | navigate(`chat/${token.data.token}`); 39 | } catch (error: any) { 40 | setLoading(false); 41 | if (error?.message === "timeout exceeded") { 42 | setError("An unknown error has occured, Please try again later"); 43 | } else if (error?.response.status === 400) { 44 | setError("Error! Provide Required Credentials"); 45 | } else { 46 | setError("An unknown error has occured, Please try again later"); 47 | } 48 | } 49 | }; 50 | 51 | const onSubmit = (event: any) => { 52 | event.preventDefault(); 53 | if (name.length > 0) { 54 | CREATE_SESSION(); 55 | } else { 56 | setError("Error! Provide Required Credentials"); 57 | } 58 | }; 59 | 60 | return ( 61 | 62 | Conversational AI Chatbot 63 | 64 | 65 | A conversational Artificial Intelligence based chatbot built with 66 | Python, Redis, React, FastAPI and GPT-J-6B language model on Huggingface 67 | 68 | 74 | 80 | 81 | {loading ? ( 82 | 83 |
92 | Loading Session 93 | UI loading 94 |
95 |
96 | ) : ( 97 |
98 | 104 | 105 | 106 | 107 |
114 |
117 | 118 | )} 119 | 120 |
121 | ); 122 | }; 123 | 124 | export default Home; 125 | -------------------------------------------------------------------------------- /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /client/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /client/src/shared/layout.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { ThemeColors } from "./themes"; 3 | 4 | export const AppContainer = styled.div` 5 | min-height: 100vh; 6 | max-width: 100vw; 7 | padding-right: 5%; 8 | padding-left: 5%; 9 | background-color: ${(props: any) => 10 | props.dark ? ThemeColors.bgDark : ThemeColors.bgLight}; 11 | /* @media (max-width: 1080px) { 12 | display: none; 13 | } */ 14 | `; 15 | 16 | export const PageContainer = styled.div` 17 | padding-top: 80px; 18 | display: flex; 19 | flex-direction: column; 20 | align-items: center; 21 | 22 | @media (min-width: 1080px) { 23 | max-width: 600px; 24 | margin: auto; 25 | } 26 | `; 27 | 28 | export const Margin = styled.div` 29 | margin-top: 20px; 30 | margin-bottom: 20px; 31 | `; 32 | 33 | export const MarginSmall = styled.div` 34 | margin-top: 10px; 35 | margin-bottom: 10px; 36 | `; 37 | 38 | export const Heading1 = styled.h1` 39 | font-weight: 500; 40 | font-size: 42px; 41 | line-height: 1.4; 42 | color: #000; 43 | letter-spacing: 0.5px; 44 | font-family: "IBM Plex Sans", sans-serif; 45 | /* color: #039874; */ 46 | -webkit-font-smoothing: antialiased; 47 | -moz-osx-font-smoothing: auto; 48 | text-align: center; 49 | padding: 0px; 50 | margin: 0px; 51 | `; 52 | 53 | export const Heading4 = styled.h4` 54 | letter-spacing: normal; 55 | font-family: "IBM Plex Sans", sans-serif; 56 | font-weight: 500; 57 | font-size: 28px; 58 | color: #000000; 59 | -webkit-font-smoothing: antialiased; 60 | -moz-osx-font-smoothing: auto; 61 | text-align: center; 62 | padding: 0 0; 63 | margin: 0 0; 64 | line-height: 1.4; 65 | `; 66 | 67 | export const Paragraph = styled.p` 68 | font-weight: 400; 69 | font-size: 14px; 70 | letter-spacing: normal; 71 | font-family: "IBM Plex Sans", sans-serif; 72 | font-weight: 400; 73 | color: ${(props: any) => (props.light ? "#ffffff" : "#000000")}; 74 | -webkit-font-smoothing: antialiased; 75 | -moz-osx-font-smoothing: auto; 76 | text-align: left; 77 | padding: 0 0; 78 | margin: 0 0; 79 | line-height: 1.4; 80 | `; 81 | 82 | export const Small = styled.p` 83 | font-weight: 400; 84 | font-size: 10px; 85 | letter-spacing: normal; 86 | font-family: "IBM Plex Sans", sans-serif; 87 | font-weight: 400; 88 | color: ${(props: any) => (props.light ? "#ffffff" : "#333333")}; 89 | -webkit-font-smoothing: antialiased; 90 | -moz-osx-font-smoothing: auto; 91 | text-align: center; 92 | padding: 0 0; 93 | margin: 0 0; 94 | line-height: 1.2; 95 | `; 96 | 97 | export const ErrorIndicator = styled(Small)` 98 | color: #f01c5c; 99 | margin-top: 20px; 100 | `; 101 | -------------------------------------------------------------------------------- /client/src/shared/themes.tsx: -------------------------------------------------------------------------------- 1 | export const ThemeColors = { 2 | bgDark: "rgb(19, 19, 19)", 3 | bgLight: "#ffffff", 4 | bgPrimary: "#f4f4f4", 5 | bgSecondary: "rgb(27, 27, 27)", 6 | primaryAction: "#42be65", 7 | dangerAction: "#fa4d56", 8 | bgHiglight1: "#202020", 9 | bgHighlight2: "rgb(43, 43, 43)", 10 | textDark: "#6f6f6f", 11 | }; 12 | -------------------------------------------------------------------------------- /client/src/shared/utilities.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Input = styled.input` 4 | all: unset; 5 | height: 50px; 6 | width: 310px; 7 | background-color: #f4f4f4; 8 | border: 1px solid #e8e8e8; 9 | margin-top: 15px; 10 | font-weight: 400; 11 | font-size: 16px; 12 | font-family: "IBM Plex Sans", sans-serif; 13 | padding-left: 10px; 14 | 15 | ::placeholder { 16 | font-weight: 400; 17 | font-size: 16px; 18 | font-family: "IBM Plex Sans", sans-serif; 19 | text-align: left; 20 | } 21 | `; 22 | 23 | export const Button = styled.button` 24 | all: unset; 25 | cursor: pointer; 26 | height: 50px; 27 | width: 320px; 28 | background-color: #000; 29 | display: flex; 30 | justify-content: center; 31 | align-items: center; 32 | cursor: pointer; 33 | border-radius: 2px; 34 | font-weight: 400; 35 | font-size: 14px; 36 | text-align: center; 37 | font-family: "IBM Plex Sans", sans-serif; 38 | text-align: left; 39 | color: #fff; 40 | 41 | :hover { 42 | background-color: rgba(0, 0, 0, 0.788); 43 | color: #fff; 44 | } 45 | `; 46 | 47 | export const Loader = styled.div` 48 | width: 100%; 49 | height: 100%; 50 | display: flex; 51 | justify-content: center; 52 | align-items: center; 53 | `; 54 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /docs/contents.md: -------------------------------------------------------------------------------- 1 | ### Table of Contents 2 | 3 | - **[Introduction - What we will be building](#introduction)** 4 | - **[Application Architecture](#application-architecture)** 5 | - **[Setting up the Development Environment](#application-architecture)** 6 | - **[Section 2 - How to build a Chat Server with Python, FastAPI and WebSockets](#how-to-build-a-chat-server-with-python-fastapi-and-websockets)** 7 | 8 | - **[Setting up the Python Environment](#setting-up-the-python-environment)** 9 | - **[FastAPI Server Setup](#fastapi-server-setup)** 10 | - **[Adding routes to the API](#fadding-routes-to-the-api)** 11 | - **[Generate a chat session token with UUID](#generate-a-chat-session-token-with-uuid)** 12 | - **[Testing the API with Postman](#testing-the-api-with-postman)** 13 | - **[Websockets and Connection Manager](#websockets-and-connection-manager)** 14 | - **[Dependency Injection in FastAPI](#dependency-injection-in-fastapi)** 15 | 16 | - **[Section 3 - How to build Real-Time Systems with Redis](#how-to-build-real-time-systems-with-redis)** 17 | 18 | - **[Redis and Distributed Messaging Queues](#redis-and-distributed-messaging-queues)** 19 | - **[Connecting to a Redis cluster in python with a Redis Client](#connecting-to-a-redis-cluster-in-python-with-a-redis-client)** 20 | - **[Working with Redis Streams](#working-with-redis-streams)** 21 | - **[Modelling the Chat data](#modelling-the-chat-data)** 22 | - **[Working with Redis JSON](#working-with-redis-json)** 23 | - **[Updating the Token Dependency](#updating-the-token-dependency)** 24 | 25 | - **[Section 4 - How to add Intelligence to Chatbots with AI models](#how-to-add-intelligence-to-chatbots-with-ai-models)** 26 | - **[Getting started with Huggingface](#getting-started-with-huggingface)** 27 | - **[Interacting with the language model](#interacting-with-the-language-model)** 28 | - **[Simulating short-term memory for the AI model](#simulating-short-term-memory-for-the-ai-model)** 29 | - **[Stream Consumer and real-time data pull from the message queue](#stream-consumer-and-real-time-data-pull-from-the-message-queue)** 30 | - **[Updating the Chat client with the AI response](#updating-the-chat-client-with-the-ai-response)** 31 | - **[Refresh Token](#refresh-token)** 32 | - **[Testing the Chat with multiple clients in Postman](#tersing-the-chat-with-multiple-clients-in-postman)** 33 | -------------------------------------------------------------------------------- /docs/freecodecamp.md: -------------------------------------------------------------------------------- 1 | In order to build a working full-stack application, there are so many moving parts to think about. And you'll need to make many decisions that will be critical to the success of your app. 2 | 3 | For example, what language will you use and what platform will you deploy on? Are you going to deploy a containerised software on a server, or make use of serverless functions to handle the backend? Do you plan to use third-party APIs to handle complex parts of your application, like authentication or payments? Where do you store the data? 4 | 5 | In addition to all this, you'll also need to think about the user interface, design and usability of your application, and much more. 6 | 7 | This is why complex large applications require a multifunctional development team collaborating to build the app. 8 | 9 | One of the best ways to learn how to develop full stack applications is to build projects that cover the end-to-end development process. You'll go through designing the architecture, developing the API services, developing the user interface, and finally deploying your application. 10 | 11 | So this tutorial will take you through the process of building an AI chatbot to help you learn these concepts in depth. 12 | 13 | Some of the topics we will cover include: 14 | 15 | - How to build APIs with Python, FastAPI, and WebSockets 16 | - How to build real-time systems with Redis 17 | - How to build a chat User Interface with React 18 | 19 | **Important Note:** 20 | This is an intermediate full stack software development project that requires some basic Python and JavaScript knowledge. 21 | 22 | I've carefully divided the project into sections to ensure that you can easily select the phase that is important to you in case you do not wish to code the full application. 23 | 24 | You can download the full repository on [My Github here](https://github.com/stephensanwo/fullstack-ai-chatbot). 25 | 26 | ## Table of Contents 27 | 28 | ### Section 1 29 | 30 | - [Application Architecture](#application-architecture) 31 | - [How to Set Up the Development Environment](#how-to-set-up-the-development-environment) 32 | 33 | ### Section 2 34 | 35 | - [How to Build a Chat Server with Python, FastAPI, and WebSockets](#how-to-build-a-chat-server-with-python-fastapi-and-websockets) 36 | - [How to Set Up the Python Environment](#how-to-set-up-the-python-environment) 37 | - [FastAPI Server Setup](#fastapi-server-setup) 38 | - [How to Add Routes to the API](#how-to-add-routes-to-the-api) 39 | - [How to Generate a Chat Session Token with UUID](#how-to-generate-a-chat-session-token-with-uuid) 40 | - [How to Test the API with Postman](#how-to-test-the-api-with-postman) 41 | - [Websockets and Connection Manager](#websockets-and-connection-manager) 42 | - [Dependency Injection in FastAPI](#dependency-injection-in-fastapi) 43 | 44 | ### Section 3 45 | 46 | - [How to build Real-Time Systems with Redis](#how-to-build-real-time-systems-with-redis) 47 | - [Redis and Distributed Messaging Queues](#redis-and-distributed-messaging-queues) 48 | - [How to Connect to a Redis Cluster in Python with a Redis Client](#how-to-connect-to-a-redis-cluster-in-python-with-a-redis-client) 49 | - [How to Work with Redis Streams](#how-to-work-with-redis-streams) 50 | - [How to Model the Chat Data](#how-to-model-the-chat-data) 51 | - [How to Work with Redis JSON](#how-to-work-with-redis-json) 52 | - [How to Update the Token Dependency](#how-to-update-the-token-dependency) 53 | 54 | ### Section 4 55 | 56 | - [How to Add Intelligence to Chatbots with AI models](#how-to-add-intelligence-to-chatbots-with-ai-models) 57 | - [How to Get Started with Huggingface](#how-to-get-started-with-huggingface) 58 | - [How to Interact with the Language Model](#how-to-interact-with-the-language-model) 59 | - [How to Simulate Short-term Memory for the AI Model](#how-to-simulate-short-term-memory-for-the-ai-model) 60 | - [Stream Consumer and Real-timeDdata Pull from the Message Queue](#stream-consumer-and-real-time-data-pull-from-the-message-queue) 61 | - [How to Update the Chat Client with the AI Response](#how-to-update-the-chat-client-with-the-ai-response) 62 | - [Refresh Token](#refresh-token) 63 | - [How to Test the Chat with Multiple Clients in Postman](#how-to-test-the-chat-with-multiple-clients-in-postman) 64 | 65 | ## Application Architecture 66 | 67 | Sketching out a solution architecture gives you a high-level overview of your application, the tools you intend to use, and how the components will communicate with each other. 68 | 69 | I have drawn up a simple architecture below using [draw.io](http://draw.io): 70 | 71 | Let's go over the various parts of the architecture in more detail: 72 | 73 | ### Client/User Interface 74 | 75 | We will use React version 18 to build the user interface. The Chat UI will communicate with the backend via WebSockets. 76 | 77 | ### GPT-J-6B and Huggingface Inference API 78 | 79 | GPT-J-6B is a generative language model which was trained with 6 Billion parameters and performs closely with OpenAI's GPT-3 on some tasks. 80 | 81 | I have chosen to use GPT-J-6B because it is an open-source model and doesn’t require paid tokens for simple use cases. 82 | 83 | Huggingface also provides us with an on-demand API to connect with this model pretty much free of charge. You can read more about [GPT-J-6B](https://huggingface.co/EleutherAI/gpt-j-6B?text=My+name+is+Teven+and+I+am) and [Hugging Face Inference API](https://huggingface.co/inference-api). 84 | 85 | ### Redis 86 | 87 | When we send prompts to GPT, we need a way to store the prompts and easily retrieve the response. We will use Redis JSON to store the chat data and also use Redis Streams for handling the real-time communication with the huggingface inference API. 88 | 89 | Redis is an in-memory key-value store that enables super-fast fetching and storing of JSON-like data. For this tutorial, we will use a managed free Redis storage provided by [Redis Enterprise](https://redis.info/3NBGJRT) for testing purposes. 90 | 91 | ### Web Sockets and the Chat API 92 | 93 | To send messages between the client and server in real-time, we need to open a socket connection. This is because an HTTP connection will not be sufficient to ensure real-time bi-directional communication between the client and the server. 94 | 95 | We will be using FastAPI for the chat server, as it provides a fast and modern Python server for our use. [Check out the FastAPI documentation](https://fastapi.tiangolo.com/advanced/websockets/?h=web)) to learn more about WebSockets. 96 | 97 | ## How to Set Up the Development Environment 98 | 99 | You can use your desired OS to build this app – I am currently using MacOS, and Visual Studio Code. Just make sure you have Python and NodeJs installed. 100 | 101 | To set up the project structure, create a folder named`fullstack-ai-chatbot`. Then create two folders within the project called `client` and `server`. The server will hold the code for the backend, while the client will hold the code for the frontend. 102 | 103 | Next within the project directory, initialize a Git repository within the root of the project folder using the "git init" command. Then create a .gitignore file by using "touch .gitignore": 104 | 105 | ```bash 106 | git init 107 | touch .gitignore 108 | ``` 109 | 110 | In the next section, we will build our chat web server using FastAPI and Python. 111 | 112 | ## How to Build a Chat Server with Python, FastAPI and WebSockets 113 | 114 | In this section, we will build the chat server using FastAPI to communicate with the user. We will use WebSockets to ensure bi-directional communication between the client and server so that we can send responses to the user in real-time. 115 | 116 | ### How to Set Up the Python Environment 117 | 118 | To start our server, we need to set up our Python environment. Open the project folder within VS Code, and open up the terminal. 119 | 120 | From the project root, cd into the server directory and run `python3.8 -m venv env`. This will create a [**virtual environment**](https://blog.stephensanwo.dev/virtual-environments-in-python) for our Python project, which will be named `env`. To activate the virtual environment, run `source env/bin/activate` 121 | 122 | Next, install a couple of libraries in your Python environment. 123 | 124 | ```bash 125 | pip install fastapi uuid uvicorn gunicorn WebSockets python-dotenv aioredis 126 | ``` 127 | 128 | Next create an environment file by running `touch .env` in the terminal. We will define our app variables and secret variables within the `.env` file. 129 | 130 | Add your app environment variable and set it to "development" like so: `export APP_ENV=development`. Next, we will set up a development server with a FastAPI server. 131 | 132 | ### FastAPI Server Setup 133 | 134 | At the root of the server directory, create a new file named `main.py` then paste the code below for the development sever: 135 | 136 | ```py 137 | from fastapi import FastAPI, Request 138 | import uvicorn 139 | import os 140 | from dotenv import load_dotenv 141 | 142 | load_dotenv() 143 | 144 | api = FastAPI() 145 | 146 | @api.get("/test") 147 | async def root(): 148 | return {"msg": "API is Online"} 149 | 150 | 151 | if __name__ == "__main__": 152 | if os.environ.get('APP_ENV') == "development": 153 | uvicorn.run("main:api", host="0.0.0.0", port=3500, 154 | workers=4, reload=True) 155 | else: 156 | pass 157 | 158 | ``` 159 | 160 | First we `import FastAPI` and initialize it as `api`. Then we `import load_dotenv` from the `python-dotenv` library, and initialize it to load the variables from the `.env` file, 161 | 162 | Then we create a simple test route to test the API. The test route will return a simple JSON response that tells us the API is online. 163 | 164 | Lastly, we set up the development server by using `uvicorn.run` and providing the required arguments. The API will run on port `3500`. 165 | 166 | Finally, run the server in the terminal with `python main.py`. Once you see `Application startup complete` in the terminal, navigate to the URL [http://localhost:3500/test](http://localhost:3500/test) on your browser, and you should get a web page like this: 167 | 168 | ### How to Add Routes to the API 169 | 170 | In this section, we will add routes to our API. Create a new folder named `src`. This is the directory where all our API code will live. 171 | 172 | Create a subfolder named `routes`, cd into the folder, create a new file named `chat.py` and then add the code below: 173 | 174 | ```py 175 | import os 176 | from fastapi import APIRouter, FastAPI, WebSocket, Request 177 | 178 | chat = APIRouter() 179 | 180 | # @route POST /token 181 | # @desc Route to generate chat token 182 | # @access Public 183 | 184 | @chat.post("/token") 185 | async def token_generator(request: Request): 186 | return None 187 | 188 | 189 | # @route POST /refresh_token 190 | # @desc Route to refresh token 191 | # @access Public 192 | 193 | @chat.post("/refresh_token") 194 | async def refresh_token(request: Request): 195 | return None 196 | 197 | 198 | # @route Websocket /chat 199 | # @desc Socket for chatbot 200 | # @access Public 201 | 202 | @chat.websocket("/chat") 203 | async def websocket_endpoint(websocket: WebSocket = WebSocket): 204 | return None 205 | 206 | ``` 207 | 208 | We created three endpoints: 209 | 210 | - `/token` will issue the user a session token for access to the chat session. Since the chat app will be open publicly, we do not want to worry about authentication and just keep it simple – but we still need a way to identify each unique user session. 211 | - `/refresh_token` will get the session history for the user if the connection is lost, as long as the token is still active and not expired. 212 | - `/chat` will open a WebSocket to send messages between the client and server. 213 | 214 | Next, connect the chat route to our main API. First we need to `import chat from src.chat` within our `main.py` file. Then we will include the router by literally calling an `include_router` method on the initialized `FastAPI` class and passing chat as the argument. 215 | 216 | Update your `api.py` code as shown below: 217 | 218 | ```python 219 | from fastapi import FastAPI, Request 220 | import uvicorn 221 | import os 222 | from dotenv import load_dotenv 223 | from routes.chat import chat 224 | 225 | load_dotenv() 226 | 227 | api = FastAPI() 228 | api.include_router(chat) 229 | 230 | 231 | @api.get("/test") 232 | async def root(): 233 | return {"msg": "API is Online"} 234 | 235 | 236 | if __name__ == "__main__": 237 | if os.environ.get('APP_ENV') == "development": 238 | uvicorn.run("main:api", host="0.0.0.0", port=3500, 239 | workers=4, reload=True) 240 | else: 241 | pass 242 | 243 | ``` 244 | 245 | ### How to Generate a Chat Session Token with UUID 246 | 247 | To generate a user token we will use `uuid4` to create dynamic routes for our chat endpoint. Since this is a publicly available endpoint, we won't need to go into details about JWTs and authentication. 248 | 249 | If you didn't install `uuid` initially, run `pip install uuid`. Next in chat.py, import UUID, and update the `/token` route with the code below: 250 | 251 | ```py 252 | 253 | from fastapi import APIRouter, FastAPI, WebSocket, Request, BackgroundTasks, HTTPException 254 | import uuid 255 | 256 | # @route POST /token 257 | # @desc Route generating chat token 258 | # @access Public 259 | 260 | @chat.post("/token") 261 | async def token_generator(name: str, request: Request): 262 | 263 | if name == "": 264 | raise HTTPException(status_code=400, detail={ 265 | "loc": "name", "msg": "Enter a valid name"}) 266 | 267 | token = str(uuid.uuid4()) 268 | 269 | data = {"name": name, "token": token} 270 | 271 | return data 272 | 273 | ``` 274 | 275 | In the code above, the client provides their name, which is required. We do a quick check to ensure that the name field is not empty, then generate a token using uuid4. 276 | 277 | The session data is a simple dictionary for the name and token. Ultimately we will need to persist this session data and set a timeout, but for now we just return it to the client. 278 | 279 | ### How to Test the API with Postman 280 | 281 | Because we will be testing a WebSocket endpoint, we need to use a tool like [Postman](https://www.postman.com) that allows this (as the default swagger docs on FastAPI does not support WebSockets). 282 | 283 | In Postman, create a collection for your development environment and send a POST request to `localhost:3500/token` specifying the name as a query parameter and passing it a value. You should get a response as shown below: 284 | 285 | ### Websockets and Connection Manager 286 | 287 | In the src root, create a new folder named `socket` and add a file named `connection.py`. In this file, we will define the class that controls the connections to our WebSockets, and all the helper methods to connect and disconnect. 288 | 289 | In `connection.py` add the code below: 290 | 291 | ```py 292 | 293 | from fastapi import WebSocket 294 | 295 | class ConnectionManager: 296 | def __init__(self): 297 | self.active_connections: List[WebSocket] = [] 298 | 299 | async def connect(self, websocket: WebSocket): 300 | await websocket.accept() 301 | self.active_connections.append(websocket) 302 | 303 | def disconnect(self, websocket: WebSocket): 304 | self.active_connections.remove(websocket) 305 | 306 | async def send_personal_message(self, message: str, websocket: WebSocket): 307 | await websocket.send_text(message) 308 | 309 | ``` 310 | 311 | The `ConnectionManager` class is initialized with an `active_connections` attribute that is a list of active connections. 312 | 313 | Then the asynchronous `connect` method will accept a `WebSocket` and add it to the list of active connections, while the `disconnect` method will remove the `Websocket` from the list of active connections. 314 | 315 | Lastly, the `send_personal_message` method will take in a message and the `Websocket` we want to send the message to and asynchronously send the message. 316 | 317 | WebSockets are a very broad topic and we only scraped the surface here. This should however be sufficient to create multiple connections and handle messages to those connections asynchronously. 318 | 319 | You can read more about [FastAPI Websockets](https://fastapi.tiangolo.com/advanced/websockets/?h=depends#using-depends-and-others) and [Sockets Programming](https://realpython.com/python-sockets/). 320 | 321 | To use the `ConnectionManager`, import and initialize it within the `src.routes.chat.py`, and update the `/chat` WebSocket route with the code below: 322 | 323 | ```py 324 | from ..socket.connection import ConnectionManager 325 | 326 | manager = ConnectionManager() 327 | 328 | @chat.websocket("/chat") 329 | async def websocket_endpoint(websocket: WebSocket): 330 | await manager.connect(websocket) 331 | try: 332 | while True: 333 | data = await websocket.receive_text() 334 | print(data) 335 | await manager.send_personal_message(f"Response: Simulating response from the GPT service", websocket) 336 | 337 | except WebSocketDisconnect: 338 | manager.disconnect(websocket) 339 | 340 | ``` 341 | 342 | In the `websocket_endpoint` function, which takes a WebSocket, we add the new websocket to the connection manager and run a `while True` loop, to ensure that the socket stays open. Except when the socket gets disconnected. 343 | 344 | While the connection is open, we receive any messages sent by the client with `websocket.receive_test()` and print them to the terminal for now. 345 | 346 | Then we send a hard-coded response back to the client for now. Ultimately the message received from the clients will be sent to the AI Model, and the response sent back to the client will be the response from the AI Model. 347 | 348 | In Postman, we can test this endpoint by creating a new WebSocket request, and connecting to the WebSocket endpoint `localhost:3500/chat`. 349 | 350 | When you click connect, the Messages pane will show that the API client is connected to the URL, and a socket is open. 351 | 352 | To test this, send a message "Hello Bot" to the chat server, and you should get an immediate test response "Response: Simulating response from the GPT service" as shown below: 353 | 354 | ### Dependency Injection in FastAPI 355 | 356 | To be able to distinguish between two different client sessions and limit the chat sessions, we will use a timed token, passed as a query parameter to the WebSocket connection. 357 | 358 | In the socket folder, create a file named `utils.py` then add the code below: 359 | 360 | ```py 361 | from fastapi import WebSocket, status, Query 362 | from typing import Optional 363 | 364 | async def get_token( 365 | websocket: WebSocket, 366 | token: Optional[str] = Query(None), 367 | ): 368 | if token is None or token == "": 369 | await websocket.close(code=status.WS_1008_POLICY_VIOLATION) 370 | 371 | return token 372 | 373 | 374 | ``` 375 | 376 | The get_token function receives a WebSocket and token, then checks if the token is None or null. 377 | 378 | If this is the case, the function returns a policy violation status and if available, the function just returns the token. We will ultimately extend this function later with additional token validation. 379 | 380 | To consume this function, we inject it into the `/chat` route. FastAPI provides a Depends class to easily inject dependencies, so we don't have to tinker with decorators. 381 | 382 | Update the `/chat` route to the following: 383 | 384 | ```py 385 | from ..socket.utils import get_token 386 | 387 | @chat.websocket("/chat") 388 | async def websocket_endpoint(websocket: WebSocket, token: str = Depends(get_token)): 389 | await manager.connect(websocket) 390 | try: 391 | while True: 392 | data = await websocket.receive_text() 393 | print(data) 394 | await manager.send_personal_message(f"Response: Simulating response from the GPT service", websocket) 395 | 396 | except WebSocketDisconnect: 397 | manager.disconnect(websocket) 398 | ``` 399 | 400 | Now when you try to connect to the `/chat` endpoint in Postman, you will get a 403 error. Provide a token as query parameter and provide any value to the token, for now. Then you should be able to connect like before, only now the connection requires a token. 401 | 402 | Congratulations on getting this far! Your `chat.py` file should now look like this: 403 | 404 | ```py 405 | import os 406 | from fastapi import APIRouter, FastAPI, WebSocket, WebSocketDisconnect, Request, Depends, HTTPException 407 | import uuid 408 | from ..socket.connection import ConnectionManager 409 | from ..socket.utils import get_token 410 | 411 | 412 | chat = APIRouter() 413 | 414 | manager = ConnectionManager() 415 | 416 | # @route POST /token 417 | # @desc Route to generate chat token 418 | # @access Public 419 | 420 | 421 | @chat.post("/token") 422 | async def token_generator(name: str, request: Request): 423 | token = str(uuid.uuid4()) 424 | 425 | if name == "": 426 | raise HTTPException(status_code=400, detail={ 427 | "loc": "name", "msg": "Enter a valid name"}) 428 | 429 | data = {"name": name, "token": token} 430 | 431 | return data 432 | 433 | 434 | # @route POST /refresh_token 435 | # @desc Route to refresh token 436 | # @access Public 437 | 438 | 439 | @chat.post("/refresh_token") 440 | async def refresh_token(request: Request): 441 | return None 442 | 443 | 444 | # @route Websocket /chat 445 | # @desc Socket for chatbot 446 | # @access Public 447 | 448 | @chat.websocket("/chat") 449 | async def websocket_endpoint(websocket: WebSocket, token: str = Depends(get_token)): 450 | await manager.connect(websocket) 451 | try: 452 | while True: 453 | data = await websocket.receive_text() 454 | print(data) 455 | await manager.send_personal_message(f"Response: Simulating response from the GPT service", websocket) 456 | 457 | except WebSocketDisconnect: 458 | manager.disconnect(websocket) 459 | ``` 460 | 461 | In the next part of this tutorial, we will focus on handling the state of our application and passing data between client and server. 462 | 463 | ## How to Build Real-Time Systems with Redis 464 | 465 | Our application currently does not store any state, and there is no way to identify users or store and retrieve chat data. We are also returning a hard-coded response to the client during chat sessions. 466 | 467 | In this part of the tutorial, we will cover the following: 468 | 469 | - How to connect to a **Redis Cluster** in Python and set up a **Redis Client** 470 | - How to store and retrieve data with **Redis JSON** 471 | - How to set up **Redis Streams** as message queues between a web server and worker environment 472 | 473 | ### Redis and Distributed Messaging Queues 474 | 475 | Redis is an open source in-memory data store that you can use as a database, cache, message broker, and streaming engine. It supports a number of data structures and is a perfect solution for distributed applications with real-time capabilities. 476 | 477 | **Redis Enterprise Cloud** is a fully managed cloud service provided by Redis that helps us deploy Redis clusters at an infinite scale without worrying about infrastructure. 478 | 479 | We will be using a free Redis Enterprise Cloud instance for this tutorial. You can [Get started with Redis Cloud for free here](https://redis.com/try-free/?utm_campaign=write_for_redis) and follow [This tutorial to set up a Redis database and Redis Insight, a GUI to interact with Redis](https://developer.redis.com/create/rediscloud/). 480 | 481 | Once you have set up your Redis database, create a new folder in the project root (outside the server folder) named `worker`. 482 | 483 | We will isolate our worker environment from the web server so that when the client sends a message to our WebSocket, the web server does not have to handle the request to the third-party service. Also, resources can be freed up for other users. 484 | 485 | The background communication with the inference API is handled by this worker service, through Redis. 486 | 487 | Requests from all the connected clients are appended to the message queue (producer), while the worker consumes the messages, sends off the requests to the inference API, and appends the response to a response queue. 488 | 489 | Once the API receives a response, it sends it back to the client. 490 | 491 | During the trip between the producer and the consumer, the client can send multiple messages, and these messages will be queued up and responded to in order. 492 | 493 | Ideally, we could have this worker running on a completely different server, in its own environment, but for now, we will create its own Python environment on our local machine. 494 | 495 | You might be wondering – **why do we need a worker?** Imagine a scenario where the web server also creates the request to the third-party service. This means that while waiting for the response from the third party service during a socket connection, the server is blocked and resources are tied up till the response is obtained from the API. 496 | 497 | You can try this out by creating a random sleep `time.sleep(10)` before sending the hard-coded response, and sending a new message. Then try to connect with a different token in a new postman session. 498 | 499 | You will notice that the chat session will not connect until the random sleep times out. 500 | 501 | While we can use asynchronous techniques and worker pools in a more production-focused server set-up, that also won't be enough as the number of simultaneous users grow. 502 | 503 | Ultimately, we want to avoid tying up the web server resources by using Redis to broker the communication between our chat API and the third-party API. 504 | 505 | Next open up a new terminal, cd into the worker folder, and create and activate a new Python virtual environment similar to what we did in part 1. 506 | 507 | Next, install the following dependencies: 508 | 509 | ```bash 510 | pip install aiohttp aioredis python-dotenv 511 | ``` 512 | 513 | ### How to Connect to a Redis Cluster in Python with a Redis Client 514 | 515 | We will use the aioredis client to connect with the Redis database. We'll also use the requests library to send requests to the Huggingface inference API. 516 | 517 | Create two files `.env`, and `main.py`. Then create a folder named `src`. Also, create a folder named `redis` and add a new file named `config.py`. 518 | 519 | In the `.env` file, add the following code – and make sure you update the fields with the credentials provided in your Redis Cluster. 520 | 521 | ```txt 522 | export REDIS_URL= 523 | export REDIS_USER= 524 | export REDIS_PASSWORD= 525 | export REDIS_HOST= 526 | export REDIS_PORT= 527 | ``` 528 | 529 | In config.py add the Redis Class below: 530 | 531 | ```py 532 | import os 533 | from dotenv import load_dotenv 534 | import aioredis 535 | 536 | load_dotenv() 537 | 538 | class Redis(): 539 | def __init__(self): 540 | """initialize connection """ 541 | self.REDIS_URL = os.environ['REDIS_URL'] 542 | self.REDIS_PASSWORD = os.environ['REDIS_PASSWORD'] 543 | self.REDIS_USER = os.environ['REDIS_USER'] 544 | self.connection_url = f"redis://{self.REDIS_USER}:{self.REDIS_PASSWORD}@{self.REDIS_URL}" 545 | 546 | async def create_connection(self): 547 | self.connection = aioredis.from_url( 548 | self.connection_url, db=0) 549 | 550 | return self.connection 551 | ``` 552 | 553 | We create a Redis object and initialize the required parameters from the environment variables. Then we create an asynchronous method `create_connection` to create a Redis connection and return the connection pool obtained from the `aioredis` method `from_url`. 554 | 555 | Next, we test the Redis connection in main.py by running the code below. This will create a new Redis connection pool, set a simple key "key", and assign a string "value" to it. 556 | 557 | ```py 558 | 559 | from src.redis.config import Redis 560 | import asyncio 561 | 562 | async def main(): 563 | redis = Redis() 564 | redis = await redis.create_connection() 565 | print(redis) 566 | await redis.set("key", "value") 567 | 568 | if __name__ == "__main__": 569 | asyncio.run(main()) 570 | 571 | ``` 572 | 573 | Now open Redis Insight (if you followed the tutorial to download and install it) You should see something like this: 574 | 575 | ### How to Work with Redis Streams 576 | 577 | Now that we have our worker environment setup, we can create a producer on the web server and a consumer on the worker. 578 | 579 | First, let's create our Redis class again on the server. In `server.src` create a folder named `redis` and add two files, `config.py` and `producer.py`. 580 | 581 | In `config.py`, add the code below as we did for the worker environment: 582 | 583 | ```py 584 | import os 585 | from dotenv import load_dotenv 586 | import aioredis 587 | 588 | load_dotenv() 589 | 590 | class Redis(): 591 | def __init__(self): 592 | """initialize connection """ 593 | self.REDIS_URL = os.environ['REDIS_URL'] 594 | self.REDIS_PASSWORD = os.environ['REDIS_PASSWORD'] 595 | self.REDIS_USER = os.environ['REDIS_USER'] 596 | self.connection_url = f"redis://{self.REDIS_USER}:{self.REDIS_PASSWORD}@{self.REDIS_URL}" 597 | 598 | async def create_connection(self): 599 | self.connection = aioredis.from_url( 600 | self.connection_url, db=0) 601 | 602 | return self.connection 603 | ``` 604 | 605 | In the .env file, also add the Redis credentials: 606 | 607 | ```txt 608 | export REDIS_URL= 609 | export REDIS_USER= 610 | export REDIS_PASSWORD= 611 | export REDIS_HOST= 612 | export REDIS_PORT= 613 | 614 | ``` 615 | 616 | Finally, in `server.src.redis.producer.py` add the following code: 617 | 618 | ```py 619 | 620 | from .config import Redis 621 | 622 | class Producer: 623 | def __init__(self, redis_client): 624 | self.redis_client = redis_client 625 | 626 | async def add_to_stream(self, data: dict, stream_channel): 627 | try: 628 | msg_id = await self.redis_client.xadd(name=stream_channel, id="*", fields=data) 629 | print(f"Message id {msg_id} added to {stream_channel} stream") 630 | return msg_id 631 | 632 | except Exception as e: 633 | print(f"Error sending msg to stream => {e}") 634 | 635 | ``` 636 | 637 | We created a Producer class that is initialized with a Redis client. We use this client to add data to the stream with the `add_to_stream` method, which takes the data and the Redis channel name. 638 | 639 | The Redis command for adding data to a stream channel is `xadd` and it has both high-level and low-level functions in aioredis. 640 | 641 | Next, to run our newly created Producer, update `chat.py` and the WebSocket `/chat` endpoint like below. Notice the updated channel name `message_channel`. 642 | 643 | ```py 644 | 645 | from ..redis.producer import Producer 646 | from ..redis.config import Redis 647 | 648 | chat = APIRouter() 649 | manager = ConnectionManager() 650 | redis = Redis() 651 | 652 | 653 | @chat.websocket("/chat") 654 | async def websocket_endpoint(websocket: WebSocket, token: str = Depends(get_token)): 655 | await manager.connect(websocket) 656 | redis_client = await redis.create_connection() 657 | producer = Producer(redis_client) 658 | 659 | try: 660 | while True: 661 | data = await websocket.receive_text() 662 | print(data) 663 | stream_data = {} 664 | stream_data[token] = data 665 | await producer.add_to_stream(stream_data, "message_channel") 666 | await manager.send_personal_message(f"Response: Simulating response from the GPT service", websocket) 667 | 668 | except WebSocketDisconnect: 669 | manager.disconnect(websocket) 670 | ``` 671 | 672 | Next, in Postman, create a connection and send any number of messages that say `Hello`. You should have the stream messages printed to the terminal like below: 673 | 674 | In Redis Insight, you will see a new `mesage_channel` created and a time-stamped queue filled with the messages sent from the client. This timestamped queue is important to preserve the order of the messages. 675 | 676 | ### How to Model the Chat Data 677 | 678 | Next, we'll create a model for our chat messages. Recall that we are sending text data over WebSockets, but our chat data needs to hold more information than just the text. We need to timestamp when the chat was sent, create an ID for each message, and collect data about the chat session, then store this data in a JSON format. 679 | 680 | We can store this JSON data in Redis so we don't lose the chat history once the connection is lost, because our WebSocket does not store state. 681 | 682 | In `server.src` create a new folder named `schema`. Then create a file named `chat.py` in `server.src.schema` add the following code: 683 | 684 | ```py 685 | from datetime import datetime 686 | from pydantic import BaseModel 687 | from typing import List, Optional 688 | import uuid 689 | 690 | 691 | class Message(BaseModel): 692 | id = uuid.uuid4() 693 | msg: str 694 | timestamp = str(datetime.now()) 695 | 696 | 697 | class Chat(BaseModel): 698 | token: str 699 | messages: List[Message] 700 | name: str 701 | session_start = str(datetime.now()) 702 | 703 | ``` 704 | 705 | We are using Pydantic's `BaseModel` class to model the chat data. The `Chat` class will hold data about a single Chat session. It will store the token, name of the user, and an automatically generated timestamp for the chat session start time using `datetime.now()`. 706 | 707 | The messages sent and received within this chat session are stored with a `Message` class which creates a chat id on the fly using `uuid4`. The only data we need to provide when initializing this `Message` class is the message text. 708 | 709 | ### How to Work with Redis JSON 710 | 711 | In order to use Redis JSON's ability to store our chat history, we need to install [rejson](https://github.com/RedisJSON/redisjson-py) provided by Redis labs. 712 | 713 | In the terminal, cd into `server` and install rejson with `pip install rejson`. Then update your `Redis` class in `server.src.redis.config.py` to include the `create_rejson_connection` method: 714 | 715 | ```py 716 | 717 | import os 718 | from dotenv import load_dotenv 719 | import aioredis 720 | from rejson import Client 721 | 722 | load_dotenv() 723 | 724 | class Redis(): 725 | def __init__(self): 726 | """initialize connection """ 727 | self.REDIS_URL = os.environ['REDIS_URL'] 728 | self.REDIS_PASSWORD = os.environ['REDIS_PASSWORD'] 729 | self.REDIS_USER = os.environ['REDIS_USER'] 730 | self.connection_url = f"redis://{self.REDIS_USER}:{self.REDIS_PASSWORD}@{self.REDIS_URL}" 731 | self.REDIS_HOST = os.environ['REDIS_HOST'] 732 | self.REDIS_PORT = os.environ['REDIS_PORT'] 733 | 734 | async def create_connection(self): 735 | self.connection = aioredis.from_url( 736 | self.connection_url, db=0) 737 | 738 | return self.connection 739 | 740 | def create_rejson_connection(self): 741 | self.redisJson = Client(host=self.REDIS_HOST, 742 | port=self.REDIS_PORT, decode_responses=True, username=self.REDIS_USER, password=self.REDIS_PASSWORD) 743 | 744 | return self.redisJson 745 | 746 | ``` 747 | 748 | We are adding the `create_rejson_connection` method to connect to Redis with the rejson `Client`. This gives us the methods to create and manipulate JSON data in Redis, which are not available with aioredis. 749 | 750 | Next, in `server.src.routes.chat.py` we can update the `/token` endpoint to create a new `Chat` instance and store the session data in Redis JSON like so: 751 | 752 | ```py 753 | @chat.post("/token") 754 | async def token_generator(name: str, request: Request): 755 | token = str(uuid.uuid4()) 756 | 757 | if name == "": 758 | raise HTTPException(status_code=400, detail={ 759 | "loc": "name", "msg": "Enter a valid name"}) 760 | 761 | # Create new chat session 762 | json_client = redis.create_rejson_connection() 763 | 764 | chat_session = Chat( 765 | token=token, 766 | messages=[], 767 | name=name 768 | ) 769 | 770 | # Store chat session in redis JSON with the token as key 771 | json_client.jsonset(str(token), Path.rootPath(), chat_session.dict()) 772 | 773 | # Set a timeout for redis data 774 | redis_client = await redis.create_connection() 775 | await redis_client.expire(str(token), 3600) 776 | 777 | 778 | return chat_session.dict() 779 | 780 | ``` 781 | 782 | NOTE: Because this is a demo app, I do not want to store the chat data in Redis for too long. So I have added a 60-minute time out on the token using the aioredis client (rejson does not implement timeouts). This means that after 60 minutes, the chat session data will be lost. 783 | 784 | This is necessary because we are not authenticating users, and we want to dump the chat data after a defined period. This step is optional, and you don't have to include it. 785 | 786 | Next, in Postman, when you send a POST request to create a new token, you will get a structured response like the one below. You can also check Redis Insight to see your chat data stored with the token as a JSON key and the data as a value. 787 | 788 | ### How to Update the Token Dependency 789 | 790 | Now that we have a token being generated and stored, this is a good time to update the `get_token` dependency in our `/chat` WebSocket. We do this to check for a valid token before starting the chat session. 791 | 792 | In `server.src.socket.utils.py` update the `get_token` function to check if the token exists in the Redis instance. If it does then we return the token, which means that the socket connection is valid. If it doesn't exist, we close the connection. 793 | 794 | The token created by `/token` will cease to exist after 60 minutes. So we can have some simple logic on the frontend to redirect the user to generate a new token if an error response is generated while trying to start a chat. 795 | 796 | ```py 797 | 798 | from ..redis.config import Redis 799 | 800 | async def get_token( 801 | websocket: WebSocket, 802 | token: Optional[str] = Query(None), 803 | ): 804 | 805 | if token is None or token == "": 806 | await websocket.close(code=status.WS_1008_POLICY_VIOLATION) 807 | 808 | redis_client = await redis.create_connection() 809 | isexists = await redis_client.exists(token) 810 | 811 | if isexists == 1: 812 | return token 813 | else: 814 | await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Session not authenticated or expired token") 815 | 816 | ``` 817 | 818 | To test the dependency, connect to the chat session with the random token we have been using, and you should get a 403 error. (Note that you have to manually delete the token in Redis Insight.) 819 | 820 | Now copy the token generated when you sent the post request to the `/token` endpoint (or create a new request) and paste it as the value to the token query parameter required by the `/chat` WebSocket. Then connect. You should get a successful connection. 821 | 822 | Bringing it all together, your chat.py should look like the below. 823 | 824 | ```py 825 | 826 | import os 827 | from fastapi import APIRouter, FastAPI, WebSocket, WebSocketDisconnect, Request, Depends 828 | import uuid 829 | from ..socket.connection import ConnectionManager 830 | from ..socket.utils import get_token 831 | import time 832 | from ..redis.producer import Producer 833 | from ..redis.config import Redis 834 | from ..schema.chat import Chat 835 | from rejson import Path 836 | 837 | chat = APIRouter() 838 | manager = ConnectionManager() 839 | redis = Redis() 840 | 841 | 842 | # @route POST /token 843 | # @desc Route to generate chat token 844 | # @access Public 845 | 846 | 847 | @chat.post("/token") 848 | async def token_generator(name: str, request: Request): 849 | token = str(uuid.uuid4()) 850 | 851 | if name == "": 852 | raise HTTPException(status_code=400, detail={ 853 | "loc": "name", "msg": "Enter a valid name"}) 854 | 855 | # Create nee chat session 856 | json_client = redis.create_rejson_connection() 857 | chat_session = Chat( 858 | token=token, 859 | messages=[], 860 | name=name 861 | ) 862 | 863 | print(chat_session.dict()) 864 | 865 | # Store chat session in redis JSON with the token as key 866 | json_client.jsonset(str(token), Path.rootPath(), chat_session.dict()) 867 | 868 | # Set a timeout for redis data 869 | redis_client = await redis.create_connection() 870 | await redis_client.expire(str(token), 3600) 871 | 872 | return chat_session.dict() 873 | 874 | 875 | # @route POST /refresh_token 876 | # @desc Route to refresh token 877 | # @access Public 878 | 879 | 880 | @chat.post("/refresh_token") 881 | async def refresh_token(request: Request): 882 | return None 883 | 884 | 885 | # @route Websocket /chat 886 | # @desc Socket for chat bot 887 | # @access Public 888 | 889 | @chat.websocket("/chat") 890 | async def websocket_endpoint(websocket: WebSocket, token: str = Depends(get_token)): 891 | await manager.connect(websocket) 892 | redis_client = await redis.create_connection() 893 | producer = Producer(redis_client) 894 | json_client = redis.create_rejson_connection() 895 | 896 | try: 897 | while True: 898 | data = await websocket.receive_text() 899 | stream_data = {} 900 | stream_data[token] = data 901 | await producer.add_to_stream(stream_data, "message_channel") 902 | await manager.send_personal_message(f"Response: Simulating response from the GPT service", websocket) 903 | 904 | except WebSocketDisconnect: 905 | manager.disconnect(websocket) 906 | 907 | 908 | ``` 909 | 910 | Well done on reaching it this far! In the next section, we will focus on communicating with the AI model and handling the data transfer between client, server, worker, and the external API. 911 | 912 | ## How to Add Intelligence to Chatbots with AI Models 913 | 914 | In this section, we will focus on building a wrapper to communicate with the transformer model, send prompts from a user to the API in a conversational format, and receive and transform responses for our chat application. 915 | 916 | ### How to Get Started with Huggingface 917 | 918 | We will not be building or deploying any language models on Hugginface. Instead, we'll focus on using Huggingface's accelerated inference API to connect to pre-trained models. 919 | 920 | The model we will be using is the [GPT-J-6B Model provided by EleutherAI](https://huggingface.co/EleutherAI/gpt-j-6B). It's a generative language model which was trained with 6 Billion parameters. 921 | 922 | Huggingface provides us with an on-demand limited API to connect with this model pretty much free of charge. 923 | 924 | To get started with Huggingface, [Create a free account](https://huggingface.co/pricing). In your settings, [generate a new access token](https://huggingface.co/settings/tokens). For up to 30k tokens, Huggingface provides access to the inference API for free. 925 | 926 | You can [Monitor your API usage here](https://api-inference.huggingface.co/dashboard/usage). Make sure you keep this token safe and don't expose it publicly. 927 | 928 | Note: We will use HTTP connections to communicate with the API because we are using a free account. But the PRO Huggingface account supports streaming with WebSockets [see parallelism and batch jobs](https://huggingface.co/docs/api-inference/parallelism). 929 | 930 | This can help significantly improve response times between the model and our chat application, and I'll hopefully cover this method in a follow-up article. 931 | 932 | ### How to Interact with the Language Model 933 | 934 | First, we add the Huggingface connection credentials to the .env file within our worker directory. 935 | 936 | ```txt 937 | export HUGGINFACE_INFERENCE_TOKEN= 938 | export MODEL_URL=https://api-inference.huggingface.co/models/EleutherAI/gpt-j-6B 939 | 940 | ``` 941 | 942 | Next, in `worker.src` create a folder named `model` then add a file `gptj.py`. Then add the GPT class below: 943 | 944 | ```py 945 | import os 946 | from dotenv import load_dotenv 947 | import requests 948 | import json 949 | 950 | load_dotenv() 951 | 952 | class GPT: 953 | def __init__(self): 954 | self.url = os.environ.get('MODEL_URL') 955 | self.headers = { 956 | "Authorization": f"Bearer {os.environ.get('HUGGINFACE_INFERENCE_TOKEN')}"} 957 | self.payload = { 958 | "inputs": "", 959 | "parameters": { 960 | "return_full_text": False, 961 | "use_cache": True, 962 | "max_new_tokens": 25 963 | } 964 | 965 | } 966 | 967 | def query(self, input: str) -> list: 968 | self.payload["inputs"] = input 969 | data = json.dumps(self.payload) 970 | response = requests.request( 971 | "POST", self.url, headers=self.headers, data=data) 972 | print(json.loads(response.content.decode("utf-8"))) 973 | return json.loads(response.content.decode("utf-8")) 974 | 975 | if __name__ == "__main__": 976 | GPT().query("Will artificial intelligence help humanity conquer the universe?") 977 | 978 | ``` 979 | 980 | The `GPT` class is initialized with the Huggingface model `url`, authentication `header`, and predefined `payload`. But the payload input is a dynamic field that is provided by the `query` method and updated before we send a request to the Huggingface endpoint. 981 | 982 | Finally, we test this by running the query method on an instance of the GPT class directly. In the terminal, run `python src/model/gptj.py`, and you should get a response like this (just keep in mind that your response will certainly be different from this): 983 | 984 | ```bash 985 | [{'generated_text': ' (AI) could solve all the problems on this planet? I am of the opinion that in the short term artificial intelligence is much better than human beings, but in the long and distant future human beings will surpass artificial intelligence.\n\nIn the distant'}] 986 | ``` 987 | 988 | Next, we add some tweaking to the input to make the interaction with the model more conversational by changing the format of the input. 989 | 990 | Update the `GPT` class like so: 991 | 992 | ```py 993 | 994 | class GPT: 995 | def __init__(self): 996 | self.url = os.environ.get('MODEL_URL') 997 | self.headers = { 998 | "Authorization": f"Bearer {os.environ.get('HUGGINFACE_INFERENCE_TOKEN')}"} 999 | self.payload = { 1000 | "inputs": "", 1001 | "parameters": { 1002 | "return_full_text": False, 1003 | "use_cache": False, 1004 | "max_new_tokens": 25 1005 | } 1006 | 1007 | } 1008 | 1009 | def query(self, input: str) -> list: 1010 | self.payload["inputs"] = f"Human: {input} Bot:" 1011 | data = json.dumps(self.payload) 1012 | response = requests.request( 1013 | "POST", self.url, headers=self.headers, data=data) 1014 | data = json.loads(response.content.decode("utf-8")) 1015 | text = data[0]['generated_text'] 1016 | res = str(text.split("Human:")[0]).strip("\n").strip() 1017 | return res 1018 | 1019 | 1020 | if __name__ == "__main__": 1021 | GPT().query("Will artificial intelligence help humanity conquer the universe?") 1022 | 1023 | ``` 1024 | 1025 | We updated the input with a string literal `f"Human: {input} Bot:"`. The human input is placed in the string and the Bot provides a response. This input format turns the GPT-J6B into a conversational model. Other changes you may notice include 1026 | 1027 | - use_cache: you can make this False if you want the model to create a new response when the input is the same. I suggest leaving this as True in production to prevent exhausting your free tokens if a user just keeps spamming the bot with the same message. Using cache does not actually load a new response from the model. 1028 | - return_full_text: is False, as we do not need to return the input – we already have it. When we get a response, we strip the "Bot:" and leading/trailing spaces from the response and return just the response text. 1029 | 1030 | ### How to Simulate Short-term Memory for the AI Model 1031 | 1032 | For every new input we send to the model, there is no way for the model to remember the conversation history. This is important if we want to hold context in the conversation. 1033 | 1034 | But remember that as the number of tokens we send to the model increases, the processing gets more expensive, and the response time is also longer. 1035 | 1036 | So we will need to find a way to retrieve short-term history and send it to the model. We will also need to figure out a sweet spot - how much historical data do we want to retrieve and send to the model? 1037 | 1038 | To handle chat history, we need to fall back to our JSON database. We'll use the `token` to get the last chat data, and then when we get the response, append the response to the JSON database. 1039 | 1040 | Update `worker.src.redis.config.py` to include the `create_rejson_connection` method. Also, update the .env file with the authentication data, and ensure rejson is installed. 1041 | 1042 | Your `worker.src.redis.config.py` should look like this: 1043 | 1044 | ```py 1045 | 1046 | import os 1047 | from dotenv import load_dotenv 1048 | import aioredis 1049 | from rejson import Client 1050 | 1051 | 1052 | load_dotenv() 1053 | 1054 | 1055 | class Redis(): 1056 | def __init__(self): 1057 | """initialize connection """ 1058 | self.REDIS_URL = os.environ['REDIS_URL'] 1059 | self.REDIS_PASSWORD = os.environ['REDIS_PASSWORD'] 1060 | self.REDIS_USER = os.environ['REDIS_USER'] 1061 | self.connection_url = f"redis://{self.REDIS_USER}:{self.REDIS_PASSWORD}@{self.REDIS_URL}" 1062 | self.REDIS_HOST = os.environ['REDIS_HOST'] 1063 | self.REDIS_PORT = os.environ['REDIS_PORT'] 1064 | 1065 | async def create_connection(self): 1066 | self.connection = aioredis.from_url( 1067 | self.connection_url, db=0) 1068 | 1069 | return self.connection 1070 | 1071 | def create_rejson_connection(self): 1072 | self.redisJson = Client(host=self.REDIS_HOST, 1073 | port=self.REDIS_PORT, decode_responses=True, username=self.REDIS_USER, password=self.REDIS_PASSWORD) 1074 | 1075 | return self.redisJson 1076 | 1077 | ``` 1078 | 1079 | While your .env file should look like this: 1080 | 1081 | ```txt 1082 | export REDIS_URL= 1083 | export REDIS_USER= 1084 | export REDIS_PASSWORD= 1085 | export REDIS_HOST= 1086 | export REDIS_PORT= 1087 | export HUGGINFACE_INFERENCE_TOKEN= 1088 | export MODEL_URL=https://api-inference.huggingface.co/models/EleutherAI/gpt-j-6B 1089 | ``` 1090 | 1091 | Next, in `worker.src.redis` create a new file named `cache.py` and add the code below: 1092 | 1093 | ```py 1094 | from .config import Redis 1095 | from rejson import Path 1096 | 1097 | class Cache: 1098 | def __init__(self, json_client): 1099 | self.json_client = json_client 1100 | 1101 | async def get_chat_history(self, token: str): 1102 | data = self.json_client.jsonget( 1103 | str(token), Path.rootPath()) 1104 | 1105 | return data 1106 | 1107 | ``` 1108 | 1109 | The cache is initialized with a rejson client, and the method `get_chat_history` takes in a token to get the chat history for that token, from Redis. Make sure you import the Path object from rejson. 1110 | 1111 | Next, update the `worker.main.py` with the code below: 1112 | 1113 | ```py 1114 | from src.redis.config import Redis 1115 | import asyncio 1116 | from src.model.gptj import GPT 1117 | from src.redis.cache import Cache 1118 | 1119 | redis = Redis() 1120 | 1121 | async def main(): 1122 | json_client = redis.create_rejson_connection() 1123 | data = await Cache(json_client).get_chat_history(token="18196e23-763b-4808-ae84-064348a0daff") 1124 | print(data) 1125 | 1126 | if __name__ == "__main__": 1127 | asyncio.run(main()) 1128 | 1129 | 1130 | ``` 1131 | 1132 | I have hard-coded a sample token created from previous tests in Postman. If you don't have a token created, just send a new request to `/token` and copy the token, then run `python main.py` in the terminal. You should see the data in the terminal like so: 1133 | 1134 | ```bash 1135 | {'token': '18196e23-763b-4808-ae84-064348a0daff', 'messages': [], 'name': 'Stephen', 'session_start': '2022-07-16 13:20:01.092109'} 1136 | ``` 1137 | 1138 | Next, we need to add an `add_message_to_cache` method to our `Cache` class that adds messages to Redis for a specific token. 1139 | 1140 | ```py 1141 | 1142 | async def add_message_to_cache(self, token: str, message_data: dict): 1143 | self.json_client.jsonarrappend( 1144 | str(token), Path('.messages'), message_data) 1145 | 1146 | ``` 1147 | 1148 | The `jsonarrappend` method provided by rejson appends the new message to the message array. 1149 | 1150 | Note that to access the message array, we need to provide `.messages` as an argument to the Path. If your message data has a different/nested structure, just provide the path to the array you want to append the new data to. 1151 | 1152 | To test this method, update the main function in the main.py file with the code below: 1153 | 1154 | ```py 1155 | async def main(): 1156 | json_client = redis.create_rejson_connection() 1157 | 1158 | await Cache(json_client).add_message_to_cache(token="18196e23-763b-4808-ae84-064348a0daff", message_data={ 1159 | "id": "1", 1160 | "msg": "Hello", 1161 | "timestamp": "2022-07-16 13:20:01.092109" 1162 | }) 1163 | 1164 | data = await Cache(json_client).get_chat_history(token="18196e23-763b-4808-ae84-064348a0daff") 1165 | print(data) 1166 | 1167 | ``` 1168 | 1169 | We are sending a hard-coded message to the cache, and getting the chat history from the cache. When you run `python main.py` in the terminal within the worker directory, you should get something like this printed in the terminal, with the message added to the message array. 1170 | 1171 | ```bash 1172 | {'token': '18196e23-763b-4808-ae84-064348a0daff', 'messages': [{'id': '1', 'msg': 'Hello', 'timestamp': '2022-07-16 13:20:01.092109'}], 'name': 'Stephen', 'session_start': '2022-07-16 13:20:01.092109'} 1173 | ``` 1174 | 1175 | Finally, we need to update the main function to send the message data to the GPT model, and update the input with the **last 4** messages sent between the client and the model. 1176 | 1177 | First let's update our `add_message_to_cache` function with a new argument "source" that will tell us if the message is a human or bot. We can then use this arg to add the "Human:" or "Bot:" tags to the data before storing it in the cache. 1178 | 1179 | Update the `add_message_to_cache` method in the Cache class like so: 1180 | 1181 | ```py 1182 | async def add_message_to_cache(self, token: str, source: str, message_data: dict): 1183 | if source == "human": 1184 | message_data['msg'] = "Human: " + (message_data['msg']) 1185 | elif source == "bot": 1186 | message_data['msg'] = "Bot: " + (message_data['msg']) 1187 | 1188 | self.json_client.jsonarrappend( 1189 | str(token), Path('.messages'), message_data) 1190 | 1191 | ``` 1192 | 1193 | Then update the main function in main.py in the worker directory, and run `python main.py` to see the new results in the Redis database. 1194 | 1195 | ```py 1196 | async def main(): 1197 | json_client = redis.create_rejson_connection() 1198 | 1199 | await Cache(json_client).add_message_to_cache(token="18196e23-763b-4808-ae84-064348a0daff", source="human", message_data={ 1200 | "id": "1", 1201 | "msg": "Hello", 1202 | "timestamp": "2022-07-16 13:20:01.092109" 1203 | }) 1204 | 1205 | data = await Cache(json_client).get_chat_history(token="18196e23-763b-4808-ae84-064348a0daff") 1206 | print(data) 1207 | 1208 | ``` 1209 | 1210 | Next, we need to update the main function to add new messages to the cache, read the previous 4 messages from the cache, and then make an API call to the model using the query method. It'll have a payload consisting of a composite string of the last 4 messages. 1211 | 1212 | You can always tune the number of messages in the history you want to extract, but I think 4 messages is a pretty good number for a demo. 1213 | 1214 | In `worker.src`, create a new folder schema. Then create a new file named `chat.py` and paste our message schema in chat.py like so: 1215 | 1216 | ```py 1217 | from datetime import datetime 1218 | from pydantic import BaseModel 1219 | from typing import List, Optional 1220 | import uuid 1221 | 1222 | 1223 | class Message(BaseModel): 1224 | id = str(uuid.uuid4()) 1225 | msg: str 1226 | timestamp = str(datetime.now()) 1227 | 1228 | ``` 1229 | 1230 | Next, update the main.py file like below: 1231 | 1232 | ```py 1233 | async def main(): 1234 | 1235 | json_client = redis.create_rejson_connection() 1236 | 1237 | await Cache(json_client).add_message_to_cache(token="18196e23-763b-4808-ae84-064348a0daff", source="human", message_data={ 1238 | "id": "3", 1239 | "msg": "I would like to go to the moon to, would you take me?", 1240 | "timestamp": "2022-07-16 13:20:01.092109" 1241 | }) 1242 | 1243 | data = await Cache(json_client).get_chat_history(token="18196e23-763b-4808-ae84-064348a0daff") 1244 | 1245 | print(data) 1246 | 1247 | message_data = data['messages'][-4:] 1248 | 1249 | input = ["" + i['msg'] for i in message_data] 1250 | input = " ".join(input) 1251 | 1252 | res = GPT().query(input=input) 1253 | 1254 | msg = Message( 1255 | msg=res 1256 | ) 1257 | 1258 | print(msg) 1259 | await Cache(json_client).add_message_to_cache(token="18196e23-763b-4808-ae84-064348a0daff", source="bot", message_data=msg.dict()) 1260 | 1261 | ``` 1262 | 1263 | In the code above, we add new message data to the cache. This message will ultimately come from the message queue. Next we get the chat history from the cache, which will now include the most recent data we added. 1264 | 1265 | Note that we are using the same hard-coded token to add to the cache and get from the cache, temporarily just to test this out. 1266 | 1267 | Next, we trim off the cache data and extract only the last 4 items. Then we consolidate the input data by extracting the msg in a list and join it to an empty string. 1268 | 1269 | Finally, we create a new Message instance for the bot response and add the response to the cache specifying the source as "bot" 1270 | 1271 | Next, run `python main.py` a couple of times, changing the human message and id as desired with each run. You should have a full conversation input and output with the model. 1272 | 1273 | Open Redis Insight and you should have something similar to the below: 1274 | 1275 | ### Stream Consumer and Real-time Data Pull from the Message Queue 1276 | 1277 | Next, we want to create a consumer and update our `worker.main.py` to connect to the message queue. We want it to pull the token data in real-time, as we are currently hard-coding the tokens and message inputs. 1278 | 1279 | In `worker.src.redis` create a new file named `stream.py`. Add a `StreamConsumer` class with the code below: 1280 | 1281 | ```py 1282 | class StreamConsumer: 1283 | def __init__(self, redis_client): 1284 | self.redis_client = redis_client 1285 | 1286 | async def consume_stream(self, count: int, block: int, stream_channel): 1287 | 1288 | response = await self.redis_client.xread( 1289 | streams={stream_channel: '0-0'}, count=count, block=block) 1290 | 1291 | return response 1292 | 1293 | async def delete_message(self, stream_channel, message_id): 1294 | await self.redis_client.xdel(stream_channel, message_id) 1295 | 1296 | 1297 | ``` 1298 | 1299 | The `StreamConsumer` class is initialized with a Redis client. The `consume_stream` method pulls a new message from the queue from the message channel, using the `xread` method provided by aioredis. 1300 | 1301 | Next, update the `worker.main.py` file with a while loop to keep the connection to the message channel alive, like so: 1302 | 1303 | ```py 1304 | 1305 | from src.redis.config import Redis 1306 | import asyncio 1307 | from src.model.gptj import GPT 1308 | from src.redis.cache import Cache 1309 | from src.redis.config import Redis 1310 | from src.redis.stream import StreamConsumer 1311 | import os 1312 | from src.schema.chat import Message 1313 | 1314 | 1315 | redis = Redis() 1316 | 1317 | 1318 | async def main(): 1319 | json_client = redis.create_rejson_connection() 1320 | redis_client = await redis.create_connection() 1321 | consumer = StreamConsumer(redis_client) 1322 | cache = Cache(json_client) 1323 | 1324 | print("Stream consumer started") 1325 | print("Stream waiting for new messages") 1326 | 1327 | while True: 1328 | response = await consumer.consume_stream(stream_channel="message_channel", count=1, block=0) 1329 | 1330 | if response: 1331 | for stream, messages in response: 1332 | # Get message from stream, and extract token, message data and message id 1333 | for message in messages: 1334 | message_id = message[0] 1335 | token = [k.decode('utf-8') 1336 | for k, v in message[1].items()][0] 1337 | message = [v.decode('utf-8') 1338 | for k, v in message[1].items()][0] 1339 | print(token) 1340 | 1341 | # Create a new message instance and add to cache, specifying the source as human 1342 | msg = Message(msg=message) 1343 | 1344 | await cache.add_message_to_cache(token=token, source="human", message_data=msg.dict()) 1345 | 1346 | # Get chat history from cache 1347 | data = await cache.get_chat_history(token=token) 1348 | 1349 | # Clean message input and send to query 1350 | message_data = data['messages'][-4:] 1351 | 1352 | input = ["" + i['msg'] for i in message_data] 1353 | input = " ".join(input) 1354 | 1355 | res = GPT().query(input=input) 1356 | 1357 | msg = Message( 1358 | msg=res 1359 | ) 1360 | 1361 | print(msg) 1362 | 1363 | await cache.add_message_to_cache(token=token, source="bot", message_data=msg.dict()) 1364 | 1365 | # Delete messaage from queue after it has been processed 1366 | 1367 | await consumer.delete_message(stream_channel="message_channel", message_id=message_id) 1368 | 1369 | 1370 | if __name__ == "__main__": 1371 | asyncio.run(main()) 1372 | 1373 | ``` 1374 | 1375 | This is quite the update, so let's take it step by step: 1376 | 1377 | We use a `while True` loop so that the worker can be online listening to messages from the queue. 1378 | 1379 | Next, we await new messages from the message_channel by calling our `consume_stream` method. If we have a message in the queue, we extract the message_id, token, and message. Then we create a new instance of the Message class, add the message to the cache, and then get the last 4 messages. We set it as input to the GPT model `query` method. 1380 | 1381 | Once we get a response, we then add the response to the cache using the `add_message_to_cache` method, then delete the message from the queue. 1382 | 1383 | ### How to Update the Chat Client with the AI Response 1384 | 1385 | So far, we are sending a chat message from the client to the message_channel (which is received by the worker that queries the AI model) to get a response. 1386 | 1387 | Next, we need to send this response to the client. As long as the socket connection is still open, the client should be able to receive the response. 1388 | 1389 | If the connection is closed, the client can always get a response from the chat history using the `refresh_token` endpoint. 1390 | 1391 | In `worker.src.redis` create a new file named `producer.py`, and add a `Producer` class similar to what we had on the chat web server: 1392 | 1393 | ```py 1394 | 1395 | class Producer: 1396 | def __init__(self, redis_client): 1397 | self.redis_client = redis_client 1398 | 1399 | async def add_to_stream(self, data: dict, stream_channel) -> bool: 1400 | msg_id = await self.redis_client.xadd(name=stream_channel, id="*", fields=data) 1401 | print(f"Message id {msg_id} added to {stream_channel} stream") 1402 | return msg_id 1403 | 1404 | ``` 1405 | 1406 | Next, in the `main.py` file, update the main function to initialize the producer, create a stream data, and send the response to a `response_channel` using the `add_to_stream` method: 1407 | 1408 | ```py 1409 | from src.redis.config import Redis 1410 | import asyncio 1411 | from src.model.gptj import GPT 1412 | from src.redis.cache import Cache 1413 | from src.redis.config import Redis 1414 | from src.redis.stream import StreamConsumer 1415 | import os 1416 | from src.schema.chat import Message 1417 | from src.redis.producer import Producer 1418 | 1419 | 1420 | redis = Redis() 1421 | 1422 | 1423 | async def main(): 1424 | json_client = redis.create_rejson_connection() 1425 | redis_client = await redis.create_connection() 1426 | consumer = StreamConsumer(redis_client) 1427 | cache = Cache(json_client) 1428 | producer = Producer(redis_client) 1429 | 1430 | print("Stream consumer started") 1431 | print("Stream waiting for new messages") 1432 | 1433 | while True: 1434 | response = await consumer.consume_stream(stream_channel="message_channel", count=1, block=0) 1435 | 1436 | if response: 1437 | for stream, messages in response: 1438 | # Get message from stream, and extract token, message data and message id 1439 | for message in messages: 1440 | message_id = message[0] 1441 | token = [k.decode('utf-8') 1442 | for k, v in message[1].items()][0] 1443 | message = [v.decode('utf-8') 1444 | for k, v in message[1].items()][0] 1445 | 1446 | # Create a new message instance and add to cache, specifying the source as human 1447 | msg = Message(msg=message) 1448 | 1449 | await cache.add_message_to_cache(token=token, source="human", message_data=msg.dict()) 1450 | 1451 | # Get chat history from cache 1452 | data = await cache.get_chat_history(token=token) 1453 | 1454 | # Clean message input and send to query 1455 | message_data = data['messages'][-4:] 1456 | 1457 | input = ["" + i['msg'] for i in message_data] 1458 | input = " ".join(input) 1459 | 1460 | res = GPT().query(input=input) 1461 | 1462 | msg = Message( 1463 | msg=res 1464 | ) 1465 | 1466 | stream_data = {} 1467 | stream_data[str(token)] = str(msg.dict()) 1468 | 1469 | await producer.add_to_stream(stream_data, "response_channel") 1470 | 1471 | await cache.add_message_to_cache(token=token, source="bot", message_data=msg.dict()) 1472 | 1473 | # Delete messaage from queue after it has been processed 1474 | await consumer.delete_message(stream_channel="message_channel", message_id=message_id) 1475 | 1476 | 1477 | if __name__ == "__main__": 1478 | asyncio.run(main()) 1479 | 1480 | ``` 1481 | 1482 | Next, we need to let the client know when we receive responses from the worker in the `/chat` socket endpoint. We do this by listening to the response stream. We do not need to include a while loop here as the socket will be listening as long as the connection is open. 1483 | 1484 | Note that we also need to check which client the response is for by adding logic to check if the token connected is equal to the token in the response. Then we delete the message in the response queue once it's been read. 1485 | 1486 | In `server.src.redis` create a new file named stream.py and add our `StreamConsumer` class like this: 1487 | 1488 | ```py 1489 | from .config import Redis 1490 | 1491 | class StreamConsumer: 1492 | def __init__(self, redis_client): 1493 | self.redis_client = redis_client 1494 | 1495 | async def consume_stream(self, count: int, block: int, stream_channel): 1496 | response = await self.redis_client.xread( 1497 | streams={stream_channel: '0-0'}, count=count, block=block) 1498 | 1499 | return response 1500 | 1501 | async def delete_message(self, stream_channel, message_id): 1502 | await self.redis_client.xdel(stream_channel, message_id) 1503 | 1504 | ``` 1505 | 1506 | Next, update the `/chat` socket endpoint like so: 1507 | 1508 | ```py 1509 | from ..redis.stream import StreamConsumer 1510 | 1511 | @chat.websocket("/chat") 1512 | async def websocket_endpoint(websocket: WebSocket, token: str = Depends(get_token)): 1513 | await manager.connect(websocket) 1514 | redis_client = await redis.create_connection() 1515 | producer = Producer(redis_client) 1516 | json_client = redis.create_rejson_connection() 1517 | consumer = StreamConsumer(redis_client) 1518 | 1519 | try: 1520 | while True: 1521 | data = await websocket.receive_text() 1522 | stream_data = {} 1523 | stream_data[str(token)] = str(data) 1524 | await producer.add_to_stream(stream_data, "message_channel") 1525 | response = await consumer.consume_stream(stream_channel="response_channel", block=0) 1526 | 1527 | print(response) 1528 | for stream, messages in response: 1529 | for message in messages: 1530 | response_token = [k.decode('utf-8') 1531 | for k, v in message[1].items()][0] 1532 | 1533 | if token == response_token: 1534 | response_message = [v.decode('utf-8') 1535 | for k, v in message[1].items()][0] 1536 | 1537 | print(message[0].decode('utf-8')) 1538 | print(token) 1539 | print(response_token) 1540 | 1541 | await manager.send_personal_message(response_message, websocket) 1542 | 1543 | await consumer.delete_message(stream_channel="response_channel", message_id=message[0].decode('utf-8')) 1544 | 1545 | except WebSocketDisconnect: 1546 | manager.disconnect(websocket) 1547 | 1548 | ``` 1549 | 1550 | ### Refresh Token 1551 | 1552 | Finally, we need to update the `/refresh_token` endpoint to get the chat history from the Redis database using our `Cache` class. 1553 | 1554 | In `server.src.redis`, add a `cache.py` file and add the code below: 1555 | 1556 | ```py 1557 | 1558 | from rejson import Path 1559 | 1560 | class Cache: 1561 | def __init__(self, json_client): 1562 | self.json_client = json_client 1563 | 1564 | async def get_chat_history(self, token: str): 1565 | data = self.json_client.jsonget( 1566 | str(token), Path.rootPath()) 1567 | 1568 | return data 1569 | 1570 | ``` 1571 | 1572 | Next, in `server.src.routes.chat.py` import the `Cache` class and update the `/token` endpoint to the below: 1573 | 1574 | ```py 1575 | 1576 | from ..redis.cache import Cache 1577 | 1578 | @chat.get("/refresh_token") 1579 | async def refresh_token(request: Request, token: str): 1580 | json_client = redis.create_rejson_connection() 1581 | cache = Cache(json_client) 1582 | data = await cache.get_chat_history(token) 1583 | 1584 | if data == None: 1585 | raise HTTPException( 1586 | status_code=400, detail="Session expired or does not exist") 1587 | else: 1588 | return data 1589 | ``` 1590 | 1591 | Now, when we send a GET request to the `/refresh_token` endpoint with any token, the endpoint will fetch the data from the Redis database. 1592 | 1593 | If the token has not timed out, the data will be sent to the user. Or it'll send a 400 response if the token is not found. 1594 | 1595 | ### How to Test the Chat with multiple Clients in Postman 1596 | 1597 | Finally, we will test the chat system by creating multiple chat sessions in Postman, connecting multiple clients in Postman, and chatting with the bot on the clients. 1598 | 1599 | Lastly, we will try to get the chat history for the clients and hopefully get a proper response. 1600 | 1601 | ## Recap 1602 | 1603 | Let's have a quick recap as to what we have achieved with our chat system. The chat client creates a token for each chat session with a client. This token is used to identify each client, and each message sent by clients connected to or web server is queued in a Redis channel (message_chanel), identified by the token. 1604 | 1605 | Our worker environment reads from this channel. It does not have any clue who the client is (except that it's a unique token) and uses the message in the queue to send requests to the Huggingface inference API. 1606 | 1607 | When it gets a response, the response is added to a response channel and the chat history is updated. The client listening to the response_channel immediately sends the response to the client once it receives a response with its token. 1608 | 1609 | If the socket is still open, this response is sent. If the socket is closed, we are certain that the response is preserved because the response is added to the chat history. The client can get the history, even if a page refresh happens or in the event of a lost connection. 1610 | 1611 | Congratulations on getting this far! You have been able to build a working chat system. 1612 | 1613 | In follow-up articles, I will focus on building a chat user interface for the client, creating unit and functional tests, fine-tuning our worker environment for faster response time with WebSockets and asynchronous requests, and ultimately deploying the chat application on AWS. 1614 | 1615 | This Article is part of a series on building full-stack intelligent chatbots with tools like Python, React, Huggingface, Redis, and so on. You can follow the full series on my blog: [blog.stephensanwo.dev - AI ChatBot Series](https://blog.stephensanwo.dev/series/build-ai-chatbot)\*\* 1616 | 1617 | **You can download the full repository on [My Github Repository](https://github.com/stephensanwo/fullstack-ai-chatbot)** 1618 | 1619 | I wrote this tutorial in collaboration with Redis. Need help getting started with Redis? Try the following resources: 1620 | 1621 | - [Try Redis Cloud free of charge](https://redis.info/3NBGJRT) 1622 | - [Watch this video on the benefits of Redis Cloud over other Redis providers](https://redis.info/3Ga9YII) 1623 | - [Redis Developer Hub - tools, guides, and tutorials about Redis](https://redis.info/3LC4GqB) 1624 | - [RedisInsight Desktop GUI](https://redis.info/3wMR7PR) 1625 | -------------------------------------------------------------------------------- /docs/full-stack-chatbot-architecture.drawio.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
32 |
33 | Client/User 34 |
35 |
36 |
37 |
38 | 39 | Client/U... 40 | 41 |
42 |
43 | 44 | 45 | 46 | 47 | 48 |
49 |
50 |
51 | API/Producer (FastAPI) 52 |
53 |
54 |
55 |
56 | 57 | API/Produ... 58 | 59 |
60 |
61 | 62 | 63 | 64 | 65 | 66 |
67 |
68 |
69 | Web Sockets 70 |
71 |
72 |
73 |
74 | 75 | Web Sockets 76 | 77 |
78 |
79 | 80 | 81 | Hugg 82 | 83 | 84 | 85 | 86 | 87 | 88 |
89 |
90 |
91 | Hugging Face 92 |
93 | Inference 94 |
95 | External API 96 |
97 |
98 |
99 |
100 | 101 | Hugging Face... 102 | 103 |
104 |
105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 |
114 |
115 |
116 | Redis/ Redis JSON/ 117 |
118 | Messaging Service 119 |
120 |
121 |
122 |
123 | 124 | Redis/ R... 125 | 126 |
127 |
128 | 129 | 130 | 131 | 132 | 133 | 134 |
135 |
136 |
137 | Queue 138 |
139 |
140 |
141 |
142 | 143 | Queue 144 | 145 |
146 |
147 | 148 | 149 | 150 | 151 | 152 |
153 |
154 |
155 | AWS EBS/EC2/AppRunner 156 |
157 |
158 |
159 |
160 | 161 | AWS EBS/... 162 | 163 |
164 |
165 | 166 | 167 | 168 | 169 | 170 |
171 |
172 |
173 | Worker/Consumer 174 |
175 |
176 |
177 |
178 | 179 | Worker/Co... 180 | 181 |
182 |
183 | 184 | 185 | 186 |
187 | 188 | 189 | 190 | 191 | Viewer does not support full SVG 1.1 192 | 193 | 194 | 195 |
-------------------------------------------------------------------------------- /docs/full-stack-chatbot-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/docs/full-stack-chatbot-architecture.png -------------------------------------------------------------------------------- /docs/images/chat-session-with-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/docs/images/chat-session-with-token.png -------------------------------------------------------------------------------- /docs/images/chat-static.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/docs/images/chat-static.png -------------------------------------------------------------------------------- /docs/images/chat.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/docs/images/chat.mov -------------------------------------------------------------------------------- /docs/images/conversation-chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/docs/images/conversation-chat.png -------------------------------------------------------------------------------- /docs/images/conversation-live.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/docs/images/conversation-live.png -------------------------------------------------------------------------------- /docs/images/postman-chat-test-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/docs/images/postman-chat-test-token.png -------------------------------------------------------------------------------- /docs/images/postman-chat-test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/docs/images/postman-chat-test.png -------------------------------------------------------------------------------- /docs/images/redis-insight-channel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/docs/images/redis-insight-channel.png -------------------------------------------------------------------------------- /docs/images/redis-insight-test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/docs/images/redis-insight-test.png -------------------------------------------------------------------------------- /docs/images/terminal-channel-messages-test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/docs/images/terminal-channel-messages-test.png -------------------------------------------------------------------------------- /docs/images/test-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/docs/images/test-page.png -------------------------------------------------------------------------------- /docs/images/token-generator-postman.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/docs/images/token-generator-postman.png -------------------------------------------------------------------------------- /docs/images/token-generator-updated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephensanwo/fullstack-ai-chatbot/256471e51d69c30034ac2c57b197c41c548a4a0b/docs/images/token-generator-updated.png -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | env 2 | .env 3 | __pycache__ -------------------------------------------------------------------------------- /server/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Request 2 | import uvicorn 3 | import os 4 | from dotenv import load_dotenv 5 | import multiprocessing 6 | from src.routes.chat import chat 7 | from fastapi.middleware.cors import CORSMiddleware 8 | 9 | 10 | load_dotenv() 11 | 12 | api = FastAPI() 13 | api.include_router(chat) 14 | 15 | origins = ["http://localhost:3000"] 16 | api.add_middleware( 17 | CORSMiddleware, 18 | allow_origins=origins, 19 | allow_credentials=True, 20 | allow_methods=["*"], 21 | allow_headers=["Content-Type"] 22 | ) 23 | 24 | 25 | @api.get("/test") 26 | async def root(): 27 | return {'msg": "API is Online'} 28 | 29 | 30 | if __name__ == "__main__": 31 | if os.environ.get('APP_ENV') == "development": 32 | uvicorn.run("main:api", host="0.0.0.0", port=3500, 33 | workers=4, reload=True) 34 | else: 35 | pass 36 | -------------------------------------------------------------------------------- /server/src/redis/cache.py: -------------------------------------------------------------------------------- 1 | from rejson import Path 2 | 3 | 4 | class Cache: 5 | def __init__(self, json_client): 6 | self.json_client = json_client 7 | 8 | async def get_chat_history(self, token: str): 9 | data = self.json_client.jsonget( 10 | str(token), Path.rootPath()) 11 | 12 | return data 13 | -------------------------------------------------------------------------------- /server/src/redis/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | import aioredis 4 | from rejson import Client 5 | 6 | 7 | load_dotenv() 8 | 9 | 10 | class Redis(): 11 | def __init__(self): 12 | """initialize connection """ 13 | self.REDIS_URL = os.environ['REDIS_URL'] 14 | self.REDIS_PASSWORD = os.environ['REDIS_PASSWORD'] 15 | self.REDIS_USER = os.environ['REDIS_USER'] 16 | self.connection_url = f"redis://{self.REDIS_USER}:{self.REDIS_PASSWORD}@{self.REDIS_URL}" 17 | self.REDIS_HOST = os.environ['REDIS_HOST'] 18 | self.REDIS_PORT = os.environ['REDIS_PORT'] 19 | 20 | async def create_connection(self): 21 | self.connection = aioredis.from_url( 22 | self.connection_url, db=0) 23 | 24 | return self.connection 25 | 26 | def create_rejson_connection(self): 27 | self.redisJson = Client(host=self.REDIS_HOST, 28 | port=self.REDIS_PORT, decode_responses=True, username=self.REDIS_USER, password=self.REDIS_PASSWORD) 29 | 30 | return self.redisJson 31 | -------------------------------------------------------------------------------- /server/src/redis/producer.py: -------------------------------------------------------------------------------- 1 | from .config import Redis 2 | 3 | 4 | class Producer: 5 | def __init__(self, redis_client): 6 | self.redis_client = redis_client 7 | 8 | async def add_to_stream(self, data: dict, stream_channel) -> bool: 9 | try: 10 | msg_id = await self.redis_client.xadd(name=stream_channel, id="*", fields=data) 11 | print(f"Message id {msg_id} added to {stream_channel} stream") 12 | return msg_id 13 | 14 | except Exception as e: 15 | print(f"Error sending msg to stream => {e}") 16 | -------------------------------------------------------------------------------- /server/src/redis/stream.py: -------------------------------------------------------------------------------- 1 | 2 | class StreamConsumer: 3 | def __init__(self, redis_client): 4 | self.redis_client = redis_client 5 | 6 | async def consume_stream(self, block: int, stream_channel): 7 | response = await self.redis_client.xread( 8 | streams={stream_channel: '0-0'}, block=block) 9 | 10 | return response 11 | 12 | async def delete_message(self, stream_channel, message_id): 13 | await self.redis_client.xdel(stream_channel, message_id) 14 | -------------------------------------------------------------------------------- /server/src/routes/chat.py: -------------------------------------------------------------------------------- 1 | import os 2 | from fastapi import APIRouter, FastAPI, WebSocket, WebSocketDisconnect, Request, Depends, HTTPException 3 | import uuid 4 | from ..socket.connection import ConnectionManager 5 | from ..socket.utils import get_token 6 | import time 7 | from ..redis.producer import Producer 8 | from ..redis.config import Redis 9 | from ..schema.chat import Chat 10 | from rejson import Path 11 | from ..redis.stream import StreamConsumer 12 | from ..redis.cache import Cache 13 | 14 | chat = APIRouter() 15 | manager = ConnectionManager() 16 | redis = Redis() 17 | 18 | 19 | # @route POST /token 20 | # @desc Route to generate chat token 21 | # @access Public 22 | 23 | 24 | @chat.post("/token") 25 | async def token_generator(name: str, request: Request): 26 | token = str(uuid.uuid4()) 27 | 28 | if name == "": 29 | raise HTTPException(status_code=400, detail={ 30 | "loc": "name", "msg": "Enter a valid name"}) 31 | 32 | # Create nee chat session 33 | json_client = redis.create_rejson_connection() 34 | chat_session = Chat( 35 | token=token, 36 | messages=[], 37 | name=name 38 | ) 39 | 40 | print(chat_session.dict()) 41 | 42 | # Store chat session in redis JSON with the token as key 43 | json_client.jsonset(str(token), Path.rootPath(), chat_session.dict()) 44 | 45 | # Set a timeout for redis data 46 | redis_client = await redis.create_connection() 47 | await redis_client.expire(str(token), 3600) 48 | 49 | return chat_session.dict() 50 | 51 | 52 | # @route POST /refresh_token 53 | # @desc Route to refresh token 54 | # @access Public 55 | 56 | 57 | @chat.get("/refresh_token") 58 | async def refresh_token(request: Request, token: str): 59 | json_client = redis.create_rejson_connection() 60 | cache = Cache(json_client) 61 | data = await cache.get_chat_history(token) 62 | 63 | if data == None: 64 | raise HTTPException( 65 | status_code=400, detail="Session expired or does not exist") 66 | else: 67 | return data 68 | 69 | 70 | # @route Websocket /chat 71 | # @desc Socket for chat bot 72 | # @access Public 73 | 74 | @chat.websocket("/chat") 75 | async def websocket_endpoint(websocket: WebSocket, token: str = Depends(get_token)): 76 | await manager.connect(websocket) 77 | redis_client = await redis.create_connection() 78 | producer = Producer(redis_client) 79 | json_client = redis.create_rejson_connection() 80 | consumer = StreamConsumer(redis_client) 81 | 82 | try: 83 | while True: 84 | data = await websocket.receive_text() 85 | stream_data = {} 86 | stream_data[str(token)] = str(data) 87 | await producer.add_to_stream(stream_data, "message_channel") 88 | response = await consumer.consume_stream(stream_channel="response_channel", block=0) 89 | 90 | print(response) 91 | # if response: 92 | for stream, messages in response: 93 | for message in messages: 94 | response_token = [k.decode('utf-8') 95 | for k, v in message[1].items()][0] 96 | 97 | if token == response_token: 98 | response_message = [v.decode('utf-8') 99 | for k, v in message[1].items()][0] 100 | 101 | print(message[0].decode('utf-8')) 102 | print(token) 103 | print(response_token) 104 | 105 | await manager.send_personal_message(response_message, websocket) 106 | 107 | await consumer.delete_message(stream_channel="response_channel", message_id=message[0].decode('utf-8')) 108 | 109 | except WebSocketDisconnect: 110 | manager.disconnect(websocket) 111 | -------------------------------------------------------------------------------- /server/src/schema/chat.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from pydantic import BaseModel 3 | from typing import List, Optional 4 | import uuid 5 | 6 | 7 | class Message(BaseModel): 8 | id = uuid.uuid4() 9 | msg: str 10 | timestamp = str(datetime.now()) 11 | 12 | 13 | class Chat(BaseModel): 14 | token: str 15 | messages: List[Message] 16 | name: str 17 | session_start = str(datetime.now()) 18 | -------------------------------------------------------------------------------- /server/src/socket/connection.py: -------------------------------------------------------------------------------- 1 | from fastapi import WebSocket 2 | 3 | 4 | class ConnectionManager: 5 | def __init__(self): 6 | self.active_connections: List[WebSocket] = [] 7 | 8 | async def connect(self, websocket: WebSocket): 9 | await websocket.accept() 10 | self.active_connections.append(websocket) 11 | 12 | def disconnect(self, websocket: WebSocket): 13 | self.active_connections.remove(websocket) 14 | 15 | async def send_personal_message(self, message: str, websocket: WebSocket): 16 | await websocket.send_text(message) 17 | -------------------------------------------------------------------------------- /server/src/socket/utils.py: -------------------------------------------------------------------------------- 1 | from fastapi import WebSocket, status, Query 2 | from typing import Optional 3 | from ..redis.config import Redis 4 | 5 | redis = Redis() 6 | 7 | 8 | async def get_token( 9 | websocket: WebSocket, 10 | token: Optional[str] = Query(None), 11 | ): 12 | 13 | if token is None or token == "": 14 | await websocket.close(code=status.WS_1008_POLICY_VIOLATION) 15 | 16 | redis_client = await redis.create_connection() 17 | isexists = await redis_client.exists(token) 18 | 19 | if isexists == 1: 20 | return token 21 | else: 22 | await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Session not authenticated or expired token") 23 | -------------------------------------------------------------------------------- /worker/.gitignore: -------------------------------------------------------------------------------- 1 | env 2 | .env 3 | __pycache__ -------------------------------------------------------------------------------- /worker/main.py: -------------------------------------------------------------------------------- 1 | from src.redis.config import Redis 2 | import asyncio 3 | from src.model.gptj import GPT 4 | from src.redis.cache import Cache 5 | from src.redis.config import Redis 6 | from src.redis.stream import StreamConsumer 7 | import os 8 | from src.schema.chat import Message 9 | from src.redis.producer import Producer 10 | 11 | 12 | redis = Redis() 13 | 14 | 15 | def number_of_workers(): 16 | return (multiprocessing.cpu_count() * 2) + 1 17 | 18 | 19 | async def main(): 20 | json_client = redis.create_rejson_connection() 21 | redis_client = await redis.create_connection() 22 | consumer = StreamConsumer(redis_client) 23 | cache = Cache(json_client) 24 | producer = Producer(redis_client) 25 | 26 | print("Stream consumer started") 27 | print("Stream waiting for new messages") 28 | 29 | while True: 30 | response = await consumer.consume_stream(stream_channel="message_channel", count=1, block=0) 31 | 32 | if response: 33 | for stream, messages in response: 34 | # Get message from stream, and extract token, message data and message id 35 | for message in messages: 36 | message_id = message[0] 37 | token = [k.decode('utf-8') 38 | for k, v in message[1].items()][0] 39 | message = [v.decode('utf-8') 40 | for k, v in message[1].items()][0] 41 | 42 | # Create a new message instance and add to cache, specifying the source as human 43 | msg = Message(msg=message) 44 | 45 | await cache.add_message_to_cache(token=token, source="human", message_data=msg.dict()) 46 | 47 | # Get chat history from cache 48 | data = await cache.get_chat_history(token=token) 49 | 50 | # Clean message input and send to query 51 | message_data = data['messages'][-4:] 52 | 53 | input = ["" + i['msg'] for i in message_data] 54 | input = " ".join(input) 55 | 56 | res = GPT().query(input=input) 57 | 58 | msg = Message( 59 | msg=res 60 | ) 61 | 62 | stream_data = {} 63 | stream_data[str(token)] = str(msg.dict()) 64 | 65 | await producer.add_to_stream(stream_data, "response_channel") 66 | 67 | await cache.add_message_to_cache(token=token, source="bot", message_data=msg.dict()) 68 | 69 | # Delete messaage from queue after it has been processed 70 | 71 | await consumer.delete_message(stream_channel="message_channel", message_id=message_id) 72 | 73 | 74 | if __name__ == "__main__": 75 | asyncio.run(main()) 76 | -------------------------------------------------------------------------------- /worker/src/model/gptj.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | import requests 4 | import json 5 | from ..redis.config import Redis 6 | from ..schema.chat import Message 7 | 8 | 9 | load_dotenv() 10 | redis = Redis() 11 | 12 | 13 | class GPT: 14 | def __init__(self): 15 | self.url = os.environ.get('MODEL_URL') 16 | self.headers = { 17 | "Authorization": f"Bearer {os.environ.get('HUGGINFACE_INFERENCE_TOKEN')}"} 18 | self.payload = { 19 | "inputs": "", 20 | "parameters": { 21 | "return_full_text": False, 22 | "use_cache": False, 23 | "max_new_tokens": 25 24 | } 25 | 26 | } 27 | self.json_client = redis.create_rejson_connection() 28 | redis_client = redis.create_connection() 29 | self.producer = Producer(redis_client) 30 | 31 | def query(self, input: str) -> list: 32 | self.payload["inputs"] = f"{input} Bot:" 33 | data = json.dumps(self.payload) 34 | response = requests.request( 35 | "POST", self.url, headers=self.headers, data=data) 36 | data = json.loads(response.content.decode("utf-8")) 37 | print(data) 38 | 39 | text = data[0]['generated_text'] 40 | 41 | res = str(text.split("Human:")[0]).strip("\n").strip() 42 | print(res) 43 | 44 | return res 45 | -------------------------------------------------------------------------------- /worker/src/redis/cache.py: -------------------------------------------------------------------------------- 1 | from rejson import Path 2 | 3 | 4 | class Cache: 5 | def __init__(self, json_client): 6 | self.json_client = json_client 7 | 8 | async def get_chat_history(self, token: str): 9 | data = self.json_client.jsonget( 10 | str(token), Path.rootPath()) 11 | 12 | return data 13 | 14 | async def add_message_to_cache(self, token: str, source: str, message_data: dict): 15 | if source == "human": 16 | message_data['msg'] = "Human: " + (message_data['msg']) 17 | elif source == "bot": 18 | message_data['msg'] = "Bot: " + (message_data['msg']) 19 | 20 | self.json_client.jsonarrappend( 21 | str(token), Path('.messages'), message_data) 22 | -------------------------------------------------------------------------------- /worker/src/redis/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | import aioredis 4 | from rejson import Client 5 | 6 | 7 | load_dotenv() 8 | 9 | 10 | class Redis(): 11 | def __init__(self): 12 | """initialize connection """ 13 | self.REDIS_URL = os.environ['REDIS_URL'] 14 | self.REDIS_PASSWORD = os.environ['REDIS_PASSWORD'] 15 | self.REDIS_USER = os.environ['REDIS_USER'] 16 | self.connection_url = f"redis://{self.REDIS_USER}:{self.REDIS_PASSWORD}@{self.REDIS_URL}" 17 | self.REDIS_HOST = os.environ['REDIS_HOST'] 18 | self.REDIS_PORT = os.environ['REDIS_PORT'] 19 | 20 | def create_connection(self): 21 | self.connection = aioredis.from_url( 22 | self.connection_url, db=0) 23 | 24 | return self.connection 25 | 26 | def create_rejson_connection(self): 27 | self.redisJson = Client(host=self.REDIS_HOST, 28 | port=self.REDIS_PORT, decode_responses=True, username=self.REDIS_USER, password=self.REDIS_PASSWORD) 29 | 30 | return self.redisJson 31 | -------------------------------------------------------------------------------- /worker/src/redis/producer.py: -------------------------------------------------------------------------------- 1 | 2 | class Producer: 3 | def __init__(self, redis_client): 4 | self.redis_client = redis_client 5 | 6 | async def add_to_stream(self, data: dict, stream_channel) -> bool: 7 | msg_id = await self.redis_client.xadd(name=stream_channel, id="*", fields=data) 8 | print(f"Message id {msg_id} added to {stream_channel} stream") 9 | return msg_id 10 | -------------------------------------------------------------------------------- /worker/src/redis/stream.py: -------------------------------------------------------------------------------- 1 | 2 | class StreamConsumer: 3 | def __init__(self, redis_client): 4 | self.redis_client = redis_client 5 | 6 | async def consume_stream(self, count: int, block: int, stream_channel): 7 | 8 | response = await self.redis_client.xread( 9 | streams={stream_channel: '0-0'}, count=count, block=block) 10 | 11 | return response 12 | 13 | async def delete_message(self, stream_channel, message_id): 14 | await self.redis_client.xdel(stream_channel, message_id) 15 | -------------------------------------------------------------------------------- /worker/src/schema/chat.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from pydantic import BaseModel 3 | from typing import List, Optional 4 | import uuid 5 | 6 | 7 | class Message(BaseModel): 8 | id = str(uuid.uuid4()) 9 | msg: str 10 | timestamp = str(datetime.now()) 11 | --------------------------------------------------------------------------------