├── vite.config.js
├── .env.example
├── api
├── counter.js
└── completions.js
├── src
├── main.jsx
├── index.css
└── App.jsx
├── vite.env.d.ts
├── .eslintrc.cjs
├── index.html
├── README.md
├── public
└── images
│ └── chatgpt-logo.svg
├── LICENSE
├── package.json
└── server.js
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react-swc";
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | });
8 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Copy all variables to .env file and change the appropriate variables with your values
2 | OPENAI_API_KEY="moBeModified"
3 | GPT_MODEL_NAME="moBeModified"
4 | PORT=8000
5 | VITE_API_URL="http://localhost:8000"
--------------------------------------------------------------------------------
/api/counter.js:
--------------------------------------------------------------------------------
1 | import { Redis } from "@upstash/redis";
2 |
3 | const redis = Redis.fromEnv();
4 |
5 | export default async function handler(req, res) {
6 | const count = await redis.incr("counter");
7 | return res.status(200).json({ count });
8 | }
9 |
--------------------------------------------------------------------------------
/src/main.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App.jsx";
4 | import "./index.css";
5 |
6 | ReactDOM.createRoot(document.getElementById("root")).render(
7 |
User ${ip} sent ${req.body.message} prompt.
`, 37 | }); 38 | } 39 | 40 | const options = { 41 | method: "POST", 42 | headers: { 43 | Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, 44 | "Content-Type": "application/json", 45 | }, 46 | body: JSON.stringify({ 47 | model: process.env.GPT_MODEL_NAME, 48 | messages: [ 49 | { 50 | role: "user", 51 | content: req.body.message, 52 | }, 53 | ], 54 | }), 55 | }; 56 | 57 | try { 58 | const response = await fetch( 59 | "https://api.openai.com/v1/chat/completions", 60 | options 61 | ); 62 | 63 | const data = await response.json(); 64 | 65 | res.send(data); 66 | } catch (e) { 67 | console.error(e); 68 | res.status(500).send(e.message); 69 | } 70 | }); 71 | 72 | app.listen(process.env.PORT, () => { 73 | console.log( 74 | `Server is running on http://localhost:${process.env.PORT}/api/completions` 75 | ); 76 | }); 77 | -------------------------------------------------------------------------------- /api/completions.js: -------------------------------------------------------------------------------- 1 | import { Ratelimit } from '@upstash/ratelimit'; 2 | import { Redis } from '@upstash/redis'; 3 | import { Resend } from 'resend'; 4 | 5 | const resend = new Resend(process.env.RESEND_API_KEY); 6 | 7 | const ratelimit = new Ratelimit({ 8 | redis: Redis.fromEnv(), 9 | limiter: Ratelimit.slidingWindow(1, '1 d'), 10 | analytics: true, 11 | /** 12 | * Optional prefix for the keys used in redis. This is useful if you want to share a redis 13 | * instance with other applications and want to avoid key collisions. The default prefix is 14 | * "@upstash/ratelimit" 15 | */ 16 | prefix: '@gpt-clone/ratelimit', 17 | }); 18 | 19 | // export default async function handler(req, res) { 20 | // try { 21 | // if (req.headers.authorization !== process.env.AUTH_TOKEN) { 22 | // return res.status(401).send('Unauthorized'); 23 | // } 24 | 25 | // const ip = 26 | // req.ip || req.headers['x-forwarded-for'] || req.connection.remoteAddress; 27 | 28 | // const { success } = await ratelimit.limit(ip); 29 | 30 | // if (!success) { 31 | // return res 32 | // .status(429) 33 | // .send('You have reached the maximum number of requests per hour.'); 34 | // } 35 | 36 | // if (process.env.IS_RESEND_ENABLE === 'true') { 37 | // resend.emails.send({ 38 | // from: 'react-chatgpt-clone@resend.dev', 39 | // to: process.env.RESEND_EMAIL, 40 | // subject: 'User prompt', 41 | // html: `User ${ip} sent ${req.body.message} prompt.
`, 42 | // }); 43 | // } 44 | 45 | // const response = await fetch('https://api.openai.com/v1/chat/completions', { 46 | // method: 'POST', 47 | // headers: { 48 | // Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, 49 | // 'Content-Type': 'application/json', 50 | // }, 51 | // body: JSON.stringify({ 52 | // model: process.env.GPT_MODEL_NAME, 53 | // messages: [ 54 | // { 55 | // role: 'user', 56 | // content: req.body.message, 57 | // }, 58 | // ], 59 | // }), 60 | // }); 61 | 62 | // return res.send(await response.json()); 63 | // } catch (error) { 64 | // return res.status(500).send(error.message || error.toString()); 65 | // } 66 | // } 67 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | font-family: 6 | system-ui, 7 | -apple-system, 8 | BlinkMacSystemFont, 9 | 'Segoe UI', 10 | Roboto, 11 | Oxygen, 12 | Ubuntu, 13 | Cantarell, 14 | 'Open Sans', 15 | 'Helvetica Neue', 16 | sans-serif; 17 | color: #ececf1; 18 | } 19 | 20 | ul { 21 | list-style-type: none; 22 | } 23 | 24 | button { 25 | border: none; 26 | background-color: unset; 27 | cursor: pointer; 28 | } 29 | 30 | .container { 31 | height: 100vh; 32 | display: grid; 33 | grid-template-columns: 0fr 1fr; 34 | background-color: #343541; 35 | } 36 | 37 | .burger { 38 | position: absolute; 39 | top: 50%; 40 | left: 0; 41 | transform: translate(-25%, -50%); 42 | fill: #ececf1; 43 | cursor: pointer; 44 | } 45 | 46 | .sidebar { 47 | width: 16rem; 48 | padding: 1rem; 49 | gap: 1rem; 50 | display: flex; 51 | flex-direction: column; 52 | align-items: center; 53 | justify-content: space-between; 54 | background-color: rgb(32, 33, 35, 1); 55 | overflow: auto; 56 | transition: all 0.3s ease; 57 | } 58 | 59 | .sidebar.open { 60 | width: 0; 61 | padding: 0; 62 | opacity: 0; 63 | box-shadow: 0px 0px 14px #ececf1; 64 | filter: blur(14px); 65 | } 66 | 67 | .sidebar-header { 68 | width: 100%; 69 | display: flex; 70 | align-items: center; 71 | gap: 0.5rem; 72 | padding: 0.8rem 1rem; 73 | border-radius: 0.3rem; 74 | border: 0.05rem solid rgba(255, 255, 255, 0.5); 75 | cursor: pointer; 76 | } 77 | 78 | .sidebar-header:hover { 79 | background-color: #2b2c2f; 80 | } 81 | 82 | .sidebar-history { 83 | width: 100%; 84 | height: 100vh; 85 | overflow: auto; 86 | } 87 | 88 | .sidebar-history p { 89 | position: sticky; 90 | top: 0; 91 | background-color: rgb(32, 33, 35); 92 | padding: 0.4rem; 93 | color: #8e8fa1; 94 | font-size: 0.8rem; 95 | font-weight: 600; 96 | z-index: 1; 97 | } 98 | 99 | .sidebar li { 100 | position: relative; 101 | overflow: hidden; 102 | text-overflow: ellipsis; 103 | padding: 0.5rem; 104 | white-space: nowrap; 105 | } 106 | 107 | .sidebar .li-overflow-shadow:not(:hover)::after { 108 | content: ''; 109 | position: absolute; 110 | bottom: 0.5rem; 111 | right: -5px; 112 | padding: 0 5px; 113 | left: calc(100% - 50px); 114 | border-radius: 0.3rem; 115 | background: linear-gradient( 116 | to right, 117 | rgba(0, 0, 0, 0), 118 | #202123 100%, 119 | rgba(0, 0, 0, 0) 120 | ); 121 | pointer-events: none; 122 | z-index: 1; 123 | } 124 | 125 | .sidebar-info { 126 | width: 100%; 127 | padding-top: 1rem; 128 | border-top: 0.05rem solid rgba(255, 255, 255, 0.5); 129 | } 130 | 131 | .sidebar-info-upgrade, 132 | .sidebar-info-user { 133 | display: flex; 134 | align-items: center; 135 | gap: 0.5rem; 136 | padding: 0.5rem; 137 | } 138 | 139 | .sidebar li:hover, 140 | .sidebar-info-upgrade:hover, 141 | .sidebar-info-user:hover { 142 | background-color: #343541; 143 | border-radius: 0.3rem; 144 | cursor: pointer; 145 | } 146 | 147 | .sidebar-info-upgrade:hover, 148 | .sidebar-info-user:hover { 149 | cursor: not-allowed; 150 | } 151 | 152 | .main { 153 | display: flex; 154 | flex-direction: column; 155 | height: 100vh; 156 | justify-content: space-between; 157 | align-items: center; 158 | padding: 1rem; 159 | position: relative; 160 | overflow: hidden; 161 | } 162 | 163 | .main h1 { 164 | font-size: 2rem; 165 | } 166 | 167 | .main-header { 168 | width: 100%; 169 | display: flex; 170 | flex-direction: column; 171 | gap: 1rem; 172 | padding-bottom: 1rem; 173 | overflow: auto; 174 | } 175 | 176 | .main-header li { 177 | display: flex; 178 | align-items: center; 179 | gap: 1.5rem; 180 | background-color: rgb(68, 70, 85); 181 | padding: 1rem; 182 | margin: 1rem 0; 183 | border-radius: 0.3rem; 184 | } 185 | 186 | .main-header li:nth-child(odd) { 187 | background-color: unset; 188 | } 189 | 190 | .main-header li:nth-child(even) { 191 | background-color: #444655; 192 | } 193 | 194 | .main-header img:not(:nth-child(even)) { 195 | display: block; 196 | border-radius: 0.3rem; 197 | width: 1.8rem; 198 | height: 1.8rem; 199 | } 200 | 201 | .role-title { 202 | font-size: 1rem; 203 | font-weight: 600; 204 | margin-bottom: 0.5rem; 205 | } 206 | 207 | .main-bottom { 208 | display: flex; 209 | flex-direction: column; 210 | gap: 0.5rem; 211 | } 212 | 213 | .main-bottom p:first-child { 214 | padding-top: 0.5rem; 215 | } 216 | 217 | .main-bottom p { 218 | font-size: 0.8rem; 219 | text-align: center; 220 | color: #c3c3d1; 221 | } 222 | 223 | .empty-chat-container { 224 | display: flex; 225 | flex-direction: column; 226 | align-items: center; 227 | gap: 1rem; 228 | } 229 | 230 | .empty-chat-container h3 { 231 | font-weight: 500; 232 | } 233 | 234 | .errorText { 235 | margin: 0 auto; 236 | } 237 | 238 | #errorTextHint { 239 | margin: 0 auto; 240 | opacity: 0.6; 241 | } 242 | 243 | .form-container { 244 | width: 50rem; 245 | padding: 0.3rem 1.6rem; 246 | margin: 0 auto; 247 | border-radius: 0.3rem; 248 | display: flex; 249 | align-items: center; 250 | background-color: #404150; 251 | box-shadow: 252 | rgb(0, 0, 0, 0.05) 0 3.3rem 3.4rem, 253 | rgb(0, 0, 0, 0.05) 0 -0.7rem 1.8rem, 254 | rgb(0, 0, 0, 0.05) 0 0.2rem 0.3rem, 255 | rgb(0, 0, 0, 0.05) 0 0.7rem 0.2rem, 256 | rgb(0, 0, 0, 0.05) 0 0.2rem 0.3rem; 257 | } 258 | 259 | .form-container input { 260 | width: 100%; 261 | height: 3rem; 262 | font-size: 1rem; 263 | padding-right: 1rem; 264 | background-color: #404150; 265 | outline: none; 266 | border: none; 267 | } 268 | 269 | .form-container input::placeholder { 270 | color: #8e8fa1; 271 | } 272 | 273 | .form-container svg { 274 | fill: #8e8fa1; 275 | transform: rotate(-45deg); 276 | } 277 | 278 | .form-container svg:hover { 279 | fill: #ececf1; 280 | } 281 | 282 | @media screen and (min-width: 1280px) { 283 | .main-header li { 284 | margin: 1rem auto; 285 | width: 50rem; 286 | } 287 | } 288 | 289 | @media screen and (max-width: 1080px) { 290 | .form-container { 291 | width: auto; 292 | } 293 | } 294 | 295 | @media screen and (max-width: 640px) { 296 | .main-header li { 297 | gap: 1rem; 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | useState, 3 | useEffect, 4 | useRef, 5 | useCallback, 6 | useLayoutEffect, 7 | } from 'react'; 8 | import { BiPlus, BiUser, BiSend, BiSolidUserCircle } from 'react-icons/bi'; 9 | import { MdOutlineArrowLeft, MdOutlineArrowRight } from 'react-icons/md'; 10 | 11 | function App() { 12 | const [text, setText] = useState(''); 13 | const [message, setMessage] = useState(null); 14 | const [previousChats, setPreviousChats] = useState([]); 15 | const [localChats, setLocalChats] = useState([]); 16 | const [currentTitle, setCurrentTitle] = useState(null); 17 | const [isResponseLoading, setIsResponseLoading] = useState(false); 18 | const [errorText, setErrorText] = useState(''); 19 | const [isShowSidebar, setIsShowSidebar] = useState(false); 20 | const scrollToLastItem = useRef(null); 21 | 22 | const createNewChat = () => { 23 | setMessage(null); 24 | setText(''); 25 | setCurrentTitle(null); 26 | }; 27 | 28 | const backToHistoryPrompt = (uniqueTitle) => { 29 | setCurrentTitle(uniqueTitle); 30 | setMessage(null); 31 | setText(''); 32 | }; 33 | 34 | const toggleSidebar = useCallback(() => { 35 | setIsShowSidebar((prev) => !prev); 36 | }, []); 37 | 38 | const submitHandler = async (e) => { 39 | e.preventDefault(); 40 | return setErrorText('My billing plan is gone because of many requests.'); 41 | if (!text) return; 42 | 43 | setIsResponseLoading(true); 44 | setErrorText(''); 45 | 46 | const options = { 47 | method: 'POST', 48 | headers: { 49 | 'Content-Type': 'application/json', 50 | 'Authorization': import.meta.env.VITE_AUTH_TOKEN, 51 | }, 52 | body: JSON.stringify({ 53 | message: text, 54 | }), 55 | }; 56 | 57 | try { 58 | const response = await fetch( 59 | `${import.meta.env.VITE_API_URL}/api/completions`, 60 | options 61 | ); 62 | 63 | if (response.status === 429) { 64 | return setErrorText('Too many requests, please try again later.'); 65 | } 66 | 67 | const data = await response.json(); 68 | 69 | if (data.error) { 70 | setErrorText(data.error.message); 71 | setText(''); 72 | } else { 73 | setErrorText(false); 74 | } 75 | 76 | if (!data.error) { 77 | setErrorText(''); 78 | setMessage(data.choices[0].message); 79 | setTimeout(() => { 80 | scrollToLastItem.current?.lastElementChild?.scrollIntoView({ 81 | behavior: 'smooth', 82 | }); 83 | }, 1); 84 | setTimeout(() => { 85 | setText(''); 86 | }, 2); 87 | } 88 | } catch (e) { 89 | setErrorText(e.message); 90 | console.error(e); 91 | } finally { 92 | setIsResponseLoading(false); 93 | } 94 | }; 95 | 96 | useLayoutEffect(() => { 97 | const handleResize = () => { 98 | setIsShowSidebar(window.innerWidth <= 640); 99 | }; 100 | handleResize(); 101 | 102 | window.addEventListener('resize', handleResize); 103 | 104 | return () => { 105 | window.removeEventListener('resize', handleResize); 106 | }; 107 | }, []); 108 | 109 | useEffect(() => { 110 | const storedChats = localStorage.getItem('previousChats'); 111 | 112 | if (storedChats) { 113 | setLocalChats(JSON.parse(storedChats)); 114 | } 115 | }, []); 116 | 117 | useEffect(() => { 118 | if (!currentTitle && text && message) { 119 | setCurrentTitle(text); 120 | } 121 | 122 | if (currentTitle && text && message) { 123 | const newChat = { 124 | title: currentTitle, 125 | role: 'user', 126 | content: text, 127 | }; 128 | 129 | const responseMessage = { 130 | title: currentTitle, 131 | role: message.role, 132 | content: message.content, 133 | }; 134 | 135 | setPreviousChats((prevChats) => [...prevChats, newChat, responseMessage]); 136 | setLocalChats((prevChats) => [...prevChats, newChat, responseMessage]); 137 | 138 | const updatedChats = [...localChats, newChat, responseMessage]; 139 | localStorage.setItem('previousChats', JSON.stringify(updatedChats)); 140 | } 141 | }, [message, currentTitle]); 142 | 143 | const currentChat = (localChats || previousChats).filter( 144 | (prevChat) => prevChat.title === currentTitle 145 | ); 146 | 147 | const uniqueTitles = Array.from( 148 | new Set(previousChats.map((prevChat) => prevChat.title).reverse()) 149 | ); 150 | 151 | const localUniqueTitles = Array.from( 152 | new Set(localChats.map((prevChat) => prevChat.title).reverse()) 153 | ).filter((title) => !uniqueTitles.includes(title)); 154 | 155 | return ( 156 | <> 157 |Ongoing
167 |Previous
192 |Upgrade plan
219 |User
223 |You
271 |{chatMsg.content}
272 |ChatGPT
276 |{chatMsg.content}
277 |{errorText}
} 286 | {errorText && ( 287 |288 | *You can clone the repository and use your paid OpenAI API key 289 | to make this work. 290 |
291 | )} 292 | 307 |308 | ChatGPT can make mistakes. Consider checking important 309 | information. 310 |
311 |