├── .env.example ├── .eslintrc.cjs ├── LICENSE ├── README.md ├── api ├── completions.js └── counter.js ├── index.html ├── package.json ├── public └── images │ └── chatgpt-logo.svg ├── server.js ├── src ├── App.jsx ├── index.css └── main.jsx ├── vite.config.js └── vite.env.d.ts /.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" -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | "eslint:recommended", 5 | "plugin:react/recommended", 6 | "plugin:react/jsx-runtime", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | parserOptions: { ecmaVersion: "latest", sourceType: "module" }, 10 | settings: { react: { version: "18.2" } }, 11 | plugins: ["react-refresh", "dotenv"], 12 | rules: { 13 | "react-refresh/only-export-components": "warn", 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Volodymyr Gerun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React ChatGPT Clone 2 | 3 | The ChatGPT clone uses chat completion v1 API with your gpt model and express.js to run a server requesting client-side requests. 4 | 5 | ## Screenshots 6 | 7 | react-chatgpt-clone 8 | 9 | ## Installation 10 | 11 | Copy all variables from `.env.example` to new created `.env` file and change the appropriate variables with your values. You can get all necessary data at [Platform OpenAI](https://platform.openai.com/api-keys). 12 | 13 | Install dependencies. 14 | 15 | ```bash 16 | npm i 17 | ``` 18 | 19 | Start backend server with nodemon. 20 | 21 | ```bash 22 | npm run dev:back 23 | ``` 24 | 25 | Start frontend server with vite. 26 | 27 | ```bash 28 | npm run dev:front 29 | ``` 30 | 31 | ## License 32 | 33 | [MIT License](LICENSE) 34 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ChatGPT Clone 9 | 10 | 11 | 12 |
13 | 14 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-chatgpt-clone", 3 | "private": "false", 4 | "version": "1.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev:front": "vite", 8 | "dev:back": "nodemon server.js", 9 | "build": "vite build", 10 | "lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0", 11 | "preview": "vite preview", 12 | "prettier": "prettier --c .", 13 | "prettier:fix": "prettier --w ." 14 | }, 15 | "dependencies": { 16 | "@upstash/ratelimit": "^1.0.1", 17 | "@upstash/redis": "^1.28.3", 18 | "cors": "^2.8.5", 19 | "dotenv": "^16.4.2", 20 | "express": "^4.18.2", 21 | "express-rate-limit": "^7.1.5", 22 | "nodemon": "^3.0.3", 23 | "prettier": "^3.2.5", 24 | "react": "^18.2.0", 25 | "react-dom": "^18.2.0", 26 | "react-icons": "^5.0.1", 27 | "resend": "^3.2.0" 28 | }, 29 | "devDependencies": { 30 | "@types/react": "^18.0.28", 31 | "@types/react-dom": "^18.0.11", 32 | "@vitejs/plugin-react-swc": "^3.0.0", 33 | "eslint": "^8.38.0", 34 | "eslint-plugin-react": "^7.32.2", 35 | "eslint-plugin-react-hooks": "^4.6.0", 36 | "eslint-plugin-react-refresh": "^0.4.5", 37 | "vite": "^5.1.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /public/images/chatgpt-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import cors from "cors"; 3 | import rateLimit from "express-rate-limit"; 4 | import { Resend } from "resend"; 5 | import dotenv from "dotenv"; 6 | dotenv.config(); 7 | 8 | const resend = new Resend(process.env.RESEND_API_KEY); 9 | 10 | const app = express(); 11 | app.use(express.json()); 12 | app.use(cors()); 13 | 14 | const limiter = rateLimit({ 15 | windowMs: 60 * 10000, // 10 minute 16 | max: 10, // limit each IP to 100 requests per minute defined in windowMs 17 | message: "Too many requests from this IP, please try again later.", 18 | }); 19 | 20 | const auth = (req, res, next) => { 21 | if (req.headers.authorization !== process.env.VITE_AUTH_TOKEN) { 22 | return res.status(401).send("Unauthorized"); 23 | } 24 | next(); 25 | }; 26 | 27 | app.post("/api/completions", auth, limiter, async (req, res) => { 28 | const ip = 29 | req.ip || req.headers["x-forwarded-for"] || req.connection.remoteAddress; 30 | 31 | if (process.env.IS_RESEND_ENABLE === "true") { 32 | resend.emails.send({ 33 | from: "react-chatgpt-clone@resend.dev", 34 | to: process.env.RESEND_EMAIL, 35 | subject: "User prompt", 36 | html: `

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 | -------------------------------------------------------------------------------- /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 |
158 |
159 |
160 | 161 | 162 |
163 |
164 | {uniqueTitles.length > 0 && previousChats.length !== 0 && ( 165 | <> 166 |

Ongoing

167 |
    168 | {uniqueTitles?.map((uniqueTitle, idx) => { 169 | const listItems = document.querySelectorAll('li'); 170 | 171 | listItems.forEach((item) => { 172 | if (item.scrollWidth > item.clientWidth) { 173 | item.classList.add('li-overflow-shadow'); 174 | } 175 | }); 176 | 177 | return ( 178 |
  • backToHistoryPrompt(uniqueTitle)} 181 | > 182 | {uniqueTitle} 183 |
  • 184 | ); 185 | })} 186 |
187 | 188 | )} 189 | {localUniqueTitles.length > 0 && localChats.length !== 0 && ( 190 | <> 191 |

Previous

192 |
    193 | {localUniqueTitles?.map((uniqueTitle, idx) => { 194 | const listItems = document.querySelectorAll('li'); 195 | 196 | listItems.forEach((item) => { 197 | if (item.scrollWidth > item.clientWidth) { 198 | item.classList.add('li-overflow-shadow'); 199 | } 200 | }); 201 | 202 | return ( 203 |
  • backToHistoryPrompt(uniqueTitle)} 206 | > 207 | {uniqueTitle} 208 |
  • 209 | ); 210 | })} 211 |
212 | 213 | )} 214 |
215 |
216 |
217 | 218 |

Upgrade plan

219 |
220 |
221 | 222 |

User

223 |
224 |
225 |
226 | 227 |
228 | {!currentTitle && ( 229 |
230 | ChatGPT 236 |

Chat GPT Clone

237 |

How can I help you today?

238 |
239 | )} 240 | 241 | {isShowSidebar ? ( 242 | 247 | ) : ( 248 | 253 | )} 254 |
255 |
    256 | {currentChat?.map((chatMsg, idx) => { 257 | const isUser = chatMsg.role === 'user'; 258 | 259 | return ( 260 |
  • 261 | {isUser ? ( 262 |
    263 | 264 |
    265 | ) : ( 266 | ChatGPT 267 | )} 268 | {isUser ? ( 269 |
    270 |

    You

    271 |

    {chatMsg.content}

    272 |
    273 | ) : ( 274 |
    275 |

    ChatGPT

    276 |

    {chatMsg.content}

    277 |
    278 | )} 279 |
  • 280 | ); 281 | })} 282 |
283 |
284 |
285 | {errorText &&

{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 |
293 | setText(e.target.value)} 299 | readOnly={isResponseLoading} 300 | /> 301 | {!isResponseLoading && ( 302 | 305 | )} 306 |
307 |

308 | ChatGPT can make mistakes. Consider checking important 309 | information. 310 |

311 |
312 |
313 |
314 | 315 | ); 316 | } 317 | 318 | export default App; 319 | -------------------------------------------------------------------------------- /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/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 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /vite.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly OPENAI_API_KEY: string; 5 | readonly GPT_MODEL_NAME: string; 6 | readonly PORT: number; 7 | readonly VITE_API_URL: string; 8 | } 9 | 10 | interface ImportMeta { 11 | readonly env: ImportMetaEnv; 12 | } 13 | --------------------------------------------------------------------------------