├── .eslintrc.json
├── .gitignore
├── .prettierrc.json
├── README.md
├── jsconfig.json
├── next.config.js
├── package.json
├── postcss.config.js
├── public
├── favicon.ico
├── next.svg
├── thirteen.svg
└── vercel.svg
├── src
├── components
│ ├── Aside.jsx
│ ├── Avatar.jsx
│ ├── Banner.jsx
│ ├── CreatePrompt.jsx
│ ├── ErrorMessage.jsx
│ ├── Icons.jsx
│ ├── Layout.jsx
│ ├── Message.jsx
│ ├── ScrollToBottom.jsx
│ ├── Twitch.jsx
│ ├── TypingEffect.jsx
│ ├── UserAvatar.jsx
│ └── Welcome.jsx
├── pages
│ ├── _app.js
│ ├── _document.js
│ ├── api
│ │ └── chat.js
│ └── index.js
├── store
│ └── conversations.js
└── styles
│ ├── Home.module.css
│ └── globals.css
└── tailwind.config.js
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "next/core-web-vitals",
4 | "./node_modules/standard/eslintrc.json"
5 | ],
6 | "rules": {
7 | "space-before-function-paren": "off"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/.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 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true,
4 | "jsxSingleQuote": true,
5 | "trailingComma": "none"
6 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Clon de ChatGPT desde cero con React y Tailwind
2 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "@/*": ["./src/*"]
6 | }
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | }
5 |
6 | module.exports = nextConfig
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "midu-chat-gpt",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@formkit/auto-animate": "1.0.0-beta.6",
13 | "@next/font": "13.1.6",
14 | "eslint": "8.34.0",
15 | "eslint-config-next": "13.1.6",
16 | "highlight.js": "11.7.0",
17 | "lz-string": "1.4.4",
18 | "lz-ts": "1.1.2",
19 | "next": "13.1.6",
20 | "react": "18.2.0",
21 | "react-dom": "18.2.0",
22 | "snarkdown": "2.0.0",
23 | "zustand": "4.3.3"
24 | },
25 | "devDependencies": {
26 | "autoprefixer": "10.4.13",
27 | "postcss": "8.4.21",
28 | "prettier": "2.8.4",
29 | "simple-zustand-devtools": "1.1.0",
30 | "standard": "17.0.0",
31 | "tailwindcss": "3.2.6"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {}
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/midudev/midu-chatgpt-clone/dba228f54eeef74a515a5c822c11993a9b2f80a0/public/favicon.ico
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/thirteen.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/Aside.jsx:
--------------------------------------------------------------------------------
1 | import { useConversationsStore } from '@/store/conversations'
2 | import { MenuIcon, MessageIcon, PencilIcon, PlusIcon, TrashIcon } from './Icons'
3 | import { useAutoAnimate } from '@formkit/auto-animate/react'
4 | import { useState } from 'react'
5 |
6 | export function Aside() {
7 | const [editConversationId, setEditConversationId] = useState(null)
8 |
9 | const addNewConversation = useConversationsStore(
10 | (state) => state.addNewConversation
11 | )
12 | const conversationsInfo = useConversationsStore(
13 | (state) => state.conversationsInfo
14 | )
15 | const removeConversation = useConversationsStore(
16 | (state) => state.removeConversation
17 | )
18 | const clearConversations = useConversationsStore(
19 | (state) => state.clearConversations
20 | )
21 | const selectConversation = useConversationsStore(
22 | (state) => state.selectConversation
23 | )
24 |
25 | const [animationParent] = useAutoAnimate()
26 |
27 | return (
28 | <>
29 |
30 |
37 |
New chat
38 |
41 |
42 |
43 |
100 | >
101 | )
102 | }
103 |
--------------------------------------------------------------------------------
/src/components/Avatar.jsx:
--------------------------------------------------------------------------------
1 | export function Avatar({ children }) {
2 | return (
3 |
4 | {children}
5 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/src/components/Banner.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { useAutoAnimate } from '@formkit/auto-animate/react'
3 |
4 | import { CloseIcon } from './Icons'
5 |
6 | export function Banner () {
7 | const [showBanner, setShowBanner] = useState(false)
8 | const [animationParent] = useAutoAnimate()
9 |
10 | const handleClick = () => {
11 | setShowBanner(false)
12 | localStorage.setItem('bannerClosed', true)
13 | }
14 |
15 | useEffect(() => {
16 | // get from local storage is banner has been closed before
17 | const bannerClosed = localStorage.getItem('bannerClosed')
18 | if (bannerClosed) return
19 |
20 | // show banner after 5 seconds
21 | const timeoutId = setTimeout(() => {
22 | setShowBanner(true)
23 | }
24 | , 2000)
25 |
26 | return () => clearTimeout(timeoutId)
27 | }, [])
28 |
29 | return (
30 |
31 | {showBanner && (
32 |
33 |
34 | Esta página no es la oficial de ChatGPT. Es un clon creado con React y Tailwind para fines educativos.
35 |
38 |
39 |
40 | )}
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/CreatePrompt.jsx:
--------------------------------------------------------------------------------
1 | import { SendIcon } from '@/components/Icons.jsx'
2 | import { useEffect, useRef, useState } from 'react'
3 | import { useConversationsStore } from '@/store/conversations'
4 |
5 | const loadingStates = [
6 | [true, false, false],
7 | [true, true, false],
8 | [true, true, true]
9 | ]
10 |
11 | function LoadingButton () {
12 | const [index, setIndex] = useState(0)
13 |
14 | useEffect(() => {
15 | const intervalId = setInterval(() => {
16 | setIndex(prevIndex => {
17 | const newIndex = prevIndex + 1
18 | return newIndex > 2 ? 0 : newIndex
19 | })
20 | }, 400)
21 |
22 | return () => clearInterval(intervalId)
23 | }, [])
24 |
25 | const [, showSecond, showThird] = loadingStates[index]
26 |
27 | return (
28 |
29 | ·
30 | ·
31 | ·
32 |
33 | )
34 | }
35 |
36 | export function ChatForm() {
37 | const sendPrompt = useConversationsStore((state) => state.sendPrompt)
38 | const isLoading = useConversationsStore(state => state.loading)
39 | const textAreaRef = useRef()
40 |
41 | const handleSubmit = (event) => {
42 | event?.preventDefault()
43 | if (isLoading) return
44 |
45 | const { value } = textAreaRef.current
46 | sendPrompt({ prompt: value })
47 | textAreaRef.current.value = ''
48 | }
49 |
50 | const handleChange = () => {
51 | const el = textAreaRef.current
52 |
53 | el.style.height = '0px'
54 | const scrollHeight = el.scrollHeight
55 | el.style.height = scrollHeight + 'px'
56 | }
57 |
58 | const handleKeyDown = (e) => {
59 | if (e.key === 'Enter' && !e.shiftKey) {
60 | e.preventDefault()
61 | handleSubmit()
62 | }
63 | }
64 |
65 | useEffect(() => {
66 | textAreaRef.current.focus()
67 | }, [])
68 |
69 | return (
70 |
110 | )
111 | }
112 |
--------------------------------------------------------------------------------
/src/components/ErrorMessage.jsx:
--------------------------------------------------------------------------------
1 | export function ErrorMessage ({ message }) {
2 | return (
3 | An error occurred. Either the engine you requested does not exist or there was another issue processing your request. Please, try again later.
4 | )
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/Icons.jsx:
--------------------------------------------------------------------------------
1 | export function SendIcon() {
2 | return (
3 |
18 | )
19 | }
20 |
21 | export function TrashIcon() {
22 | return (
23 |
40 | )
41 | }
42 |
43 | export function SunIcon() {
44 | return (
45 |
67 | )
68 | }
69 |
70 | export function ZapIcon() {
71 | return (
72 |
87 | )
88 | }
89 |
90 | export function WarningIcon() {
91 | return (
92 |
108 | )
109 | }
110 |
111 | export function DiscordIcon() {
112 | return (
113 |
125 | )
126 | }
127 |
128 | export function ExternalLinkIcon() {
129 | return (
130 |
146 | )
147 | }
148 |
149 | export function LogOutIcon() {
150 | return (
151 |
167 | )
168 | }
169 |
170 | export function UserIcon() {
171 | return (
172 |
187 | )
188 | }
189 |
190 | export function ThumbsUpIcon() {
191 | return (
192 |
206 | )
207 | }
208 |
209 | export function ThumbsDownIcon() {
210 | return (
211 |
225 | )
226 | }
227 |
228 | export function MessageIcon() {
229 | return (
230 |
244 | )
245 | }
246 |
247 | export const GitHubIcon = ({ className = 'w-6 h-6' }) => (
248 |
257 | )
258 |
259 | export const TwitchIcon = ({ className }) => {
260 | return (
261 |
264 | )
265 | }
266 |
267 | export function ReactIcon({ className }) {
268 | return (
269 |
282 | )
283 | }
284 |
285 | export function CloseIcon() {
286 | return (
287 |
302 | )
303 | }
304 |
305 | export function MenuIcon() {
306 | return (
307 |
323 | )
324 | }
325 |
326 | export function PlusIcon() {
327 | return (
328 |
343 | )
344 | }
345 |
346 | export function PencilIcon() {
347 | return (
348 |
363 | )
364 | }
365 |
366 | export function PencilInSquareIcon() {
367 | return (
368 |
383 | )
384 | }
385 |
386 | export function CheckIcon() {
387 | return (
388 |
403 | )
404 | }
405 |
406 | export function ChatGPTLogo() {
407 | return (
408 |
422 | )
423 | }
424 |
--------------------------------------------------------------------------------
/src/components/Layout.jsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head'
2 | import { Aside } from '@/components/Aside'
3 |
4 | export function Layout({ children }) {
5 | return (
6 | <>
7 |
8 | midu Chat GPT
9 |
10 |
11 |
12 | {children}
13 |
14 | >
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/Message.jsx:
--------------------------------------------------------------------------------
1 | import snarkdown from 'snarkdown'
2 | import { Avatar } from '@/components/Avatar.jsx'
3 | import { TypingEffect } from '@/components/TypingEffect.jsx'
4 | import { UserAvatar } from '@/components/UserAvatar'
5 | import { ChatGPTLogo } from '@/components/Icons.jsx'
6 | import { ErrorMessage } from './ErrorMessage'
7 |
8 | export function Message({ ia, message, error }) {
9 | const avatar = ia ? :
10 | const textElement = ia
11 | ?
12 | : (
13 |
17 | )
18 |
19 | const renderContent = () => {
20 | if (error) return
21 | return textElement
22 | }
23 |
24 | return (
25 |
26 |
27 | {avatar}
28 |
29 |
30 | {renderContent()}
31 |
32 |
33 |
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/ScrollToBottom.jsx:
--------------------------------------------------------------------------------
1 | export function ScrollToBottom () {
2 | return (
3 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/src/components/Twitch.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | const useTwitchOnline = () => {
4 | const [online, setOnline] = useState(false)
5 |
6 | useEffect(() => {
7 | fetch('https://midudev-apis.midudev.workers.dev/uptime/midudev')
8 | .then(res => res.json())
9 | .then(({ online }) => {
10 | setOnline(online)
11 | })
12 | .catch(err => {
13 | console.error(err)
14 | })
15 | }, [])
16 |
17 | return online
18 | }
19 |
20 | export default function TwitchStream () {
21 | const [open, setOpen] = useState(true)
22 | const online = useTwitchOnline()
23 |
24 | if (!online) return
25 |
26 | const { hostname } = document.location
27 |
28 | if (!open) return null
29 |
30 | return (
31 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/TypingEffect.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import 'highlight.js/styles/atom-one-dark.css'
3 | import hljs from 'highlight.js'
4 |
5 | const useTypingEffect = ({ text }) => {
6 | const [displayText, setDisplayText] = useState('')
7 | const [currentIndex, setCurrentIndex] = useState(0) // podríamos usar un useRef
8 | const [showCursor, setShowCursor] = useState(true)
9 |
10 | useEffect(() => {
11 | hljs.highlightAll()
12 | }, [displayText])
13 |
14 | useEffect(() => {
15 | if (!text?.length) return
16 |
17 | const intervalId = setInterval(() => {
18 | if (currentIndex >= text.length) {
19 | clearInterval(intervalId)
20 | setShowCursor(false)
21 | return
22 | }
23 |
24 | const nextIndex = text.indexOf(' ', currentIndex + 1)
25 | if (nextIndex < 0) { // ha llegado al final
26 | setDisplayText(text)
27 | setCurrentIndex(text.length)
28 | return
29 | }
30 |
31 | setDisplayText(text.slice(0, nextIndex))
32 | setCurrentIndex(currentIndex + 1)
33 | }, 1)
34 |
35 | return () => clearInterval(intervalId)
36 | }, [text, currentIndex])
37 |
38 | return { displayText, showCursor }
39 | }
40 |
41 | export function TypingEffect ({ text }) {
42 | const { displayText, showCursor } = useTypingEffect({ text })
43 |
44 | return
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/UserAvatar.jsx:
--------------------------------------------------------------------------------
1 | export function UserAvatar() {
2 | return (
3 |
7 | )
8 | }
9 |
--------------------------------------------------------------------------------
/src/components/Welcome.jsx:
--------------------------------------------------------------------------------
1 | import { GitHubIcon, ReactIcon, SunIcon, TwitchIcon } from './Icons'
2 | import { useAutoAnimate } from '@formkit/auto-animate/react'
3 | import { useConversationsStore } from '@/store/conversations'
4 |
5 | const EXAMPLES = [
6 | '¿Cómo iterar un array en JavaScript?',
7 | 'Explícame cómo funciona un coche',
8 | '¿Por qué se dice que un gato tiene 7 vidas?'
9 | ]
10 |
11 | export function Welcome() {
12 | const [animationParent] = useAutoAnimate()
13 | const sendPrompt = useConversationsStore(state => state.sendPrompt)
14 |
15 | return (
16 |
20 |
21 |
22 | miduGPT
23 |
24 |
25 |
26 | Esta página no es la oficial de ChatGPT. Es un clon
27 | creado con React y
28 | Tailwind para fines educativos.
29 |
30 |
31 |
45 |
46 |
Ejemplos
47 |
48 |
49 | {
50 | EXAMPLES.map((example, index) => (
51 |
54 | ))
55 | }
56 |
57 |
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/src/pages/_app.js:
--------------------------------------------------------------------------------
1 | import '@/styles/globals.css'
2 |
3 | export default function App({ Component, pageProps }) {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/src/pages/_document.js:
--------------------------------------------------------------------------------
1 | import { Html, Head, Main, NextScript } from 'next/document'
2 |
3 | export default function Document() {
4 | return (
5 |
6 |
8 |
9 |
10 |
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/src/pages/api/chat.js:
--------------------------------------------------------------------------------
1 | import { decompress } from 'lz-ts'
2 |
3 | const { OPENAI_API_KEY } = process.env
4 |
5 | const date = new Date()
6 | const currentDate = `${date.getFullYear()}-${
7 | date.getMonth() + 1
8 | }-${date.getDate()}`
9 |
10 | const INITIAL_ROLE_MESSAGE = {
11 | role: 'system',
12 | content: `You are ChatGPT, a large language model trained by OpenAI. Answer as concisely as possible. Current date is: ${currentDate}.`
13 | }
14 |
15 | export default async function handler(req, res) {
16 | if (req.method !== 'GET') return res.status(405).end()
17 |
18 | const { prompt, conversation } = req.query
19 |
20 | if (!prompt) {
21 | return res.status(400).json({ error: 'Prompt is required' })
22 | }
23 |
24 | let previousConversation = []
25 | try {
26 | const decompressedConversation = decompress(conversation)
27 | let parsedConversation = {}
28 | try {
29 | parsedConversation = JSON.parse(decompressedConversation)
30 | } catch (e) {
31 | console.error('Problems parsing', decompressedConversation)
32 | return res.status(400).json({ error: 'Error parsing' })
33 | }
34 |
35 | previousConversation = parsedConversation
36 | .map((entry) => {
37 | const role = entry.ia ? 'assistant' : 'user'
38 |
39 | // ignore messages without content
40 | if (!entry.message) return null
41 |
42 | return {
43 | role,
44 | content: entry.message
45 | }
46 | })
47 | .filter(Boolean)
48 | } catch (error) {
49 | console.error(error)
50 | }
51 |
52 | try {
53 | const response = await fetch('https://api.openai.com/v1/chat/completions', {
54 | method: 'POST',
55 | headers: {
56 | 'Content-Type': 'application/json',
57 | Authorization: `Bearer ${OPENAI_API_KEY}`
58 | },
59 | body: JSON.stringify({
60 | model: 'gpt-3.5-turbo-0301',
61 | stream: true,
62 | messages: [
63 | INITIAL_ROLE_MESSAGE,
64 | ...previousConversation,
65 | {
66 | role: 'user',
67 | content: prompt
68 | }
69 | ]
70 | })
71 | })
72 |
73 | if (!response.ok) {
74 | console.error(response.statusText)
75 | return res.status(500).json({ error: 'OpenAI API error' })
76 | }
77 |
78 | res.writeHead(200, {
79 | 'Access-Control-Allow-Origin': '*',
80 | Connection: 'keep-alive',
81 | 'Content-Encoding': 'none',
82 | 'Cache-Control': 'no-cache, no-transform',
83 | 'Content-Type': 'text/event-stream;charset=utf-8'
84 | })
85 |
86 | const reader = response.body.getReader()
87 | const decoder = new TextDecoder('utf-8')
88 |
89 | while (true) {
90 | const { done, value } = await reader.read()
91 | if (done) {
92 | res.end('data: [DONE]\n\n')
93 | break
94 | }
95 |
96 | const text = decoder.decode(value)
97 | const data = text
98 | .split('\n')
99 | .filter(Boolean)
100 | .map((line) => line.trim().replace('data: ', '').trim())
101 |
102 | for (const line of data) {
103 | if (line === '[DONE]') {
104 | res.end('data: [DONE]\n\n')
105 | break
106 | }
107 |
108 | let content = ''
109 | try {
110 | const json = JSON.parse(line)
111 | content = json?.choices[0]?.delta?.content ?? ''
112 | } catch (e) {
113 | console.error('No se pudo parsear la línea', line)
114 | console.error(e)
115 | }
116 |
117 | console.log('----------------')
118 | console.log({ content })
119 | res.write(`data: ${JSON.stringify(content)}\n\n`)
120 | res.flush()
121 | }
122 | }
123 | } catch (e) {
124 | console.error(e)
125 | res.status(500).json({ error: e })
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/src/pages/index.js:
--------------------------------------------------------------------------------
1 | import { ChatForm } from '@/components/CreatePrompt'
2 | import { Layout } from '@/components/Layout'
3 | import { Message } from '@/components/Message'
4 | import { Welcome } from '@/components/Welcome'
5 | import { useConversationsStore } from '@/store/conversations.js'
6 | import { useAutoAnimate } from '@formkit/auto-animate/react'
7 |
8 | function Chat() {
9 | const selectedConversation = useConversationsStore(
10 | (state) => state.selectedConversation
11 | )
12 | const messages = useConversationsStore((state) => {
13 | const { selectedConversation } = state
14 | return state.conversationsMessages[selectedConversation]
15 | })
16 | const [animationParent] = useAutoAnimate()
17 |
18 | const renderContent = () => {
19 | if (!selectedConversation) return
20 | return (
21 |
22 |
23 | {messages?.map((entry) => (
24 |
25 | ))}
26 |
27 |
28 |
29 | )
30 | }
31 |
32 | return (
33 |
34 |
35 | {renderContent()}
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | export default function Home() {
43 | return (
44 |
45 |
46 |
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/src/store/conversations.js:
--------------------------------------------------------------------------------
1 | import { compress } from 'lz-ts'
2 | import { mountStoreDevtool } from 'simple-zustand-devtools'
3 | import { create } from 'zustand'
4 | import { createJSONStorage, persist } from 'zustand/middleware'
5 |
6 | export const useConversationsStore = create(
7 | persist(
8 | (set, get) => ({
9 | loading: false,
10 | conversationsMessages: {},
11 | conversationsInfo: {},
12 | selectedConversation: null,
13 | selectConversation: ({ id }) => {
14 | set(() => ({ selectedConversation: id }))
15 | },
16 | clearConversations: () => {
17 | set(() => ({
18 | loading: false,
19 | conversationsMessages: {},
20 | conversationsInfo: {},
21 | selectedConversation: null
22 | }))
23 | },
24 | removeConversation: async ({ id }) => {
25 | const newSelectedConversation =
26 | get().selectedConversation === id ? null : get().selectedConversation
27 |
28 | set((state) => {
29 | const { [id]: _, ...conversationsMessages } =
30 | state.conversationsMessages
31 | const { [id]: __, ...conversationsInfo } = state.conversationsInfo
32 |
33 | return {
34 | selectedConversation: newSelectedConversation,
35 | conversationsMessages,
36 | conversationsInfo
37 | }
38 | })
39 | },
40 | addNewConversation: async () => {
41 | const selectedConversation = crypto.randomUUID()
42 |
43 | set((state) => ({
44 | loading: false,
45 | selectedConversation,
46 | conversationsMessages: {
47 | ...state.conversationsMessages,
48 | [selectedConversation]: []
49 | },
50 | conversationsInfo: {
51 | ...state.conversationsInfo,
52 | [selectedConversation]: {
53 | id: selectedConversation,
54 | name: 'New conversation'
55 | }
56 | }
57 | }))
58 | },
59 | sendPrompt: async ({ prompt }) => {
60 | let selectedConversation = get().selectedConversation
61 | const userMessageID = crypto.randomUUID()
62 | const IAMessageID = crypto.randomUUID()
63 |
64 | const partialNewConversation = [
65 | {
66 | id: userMessageID,
67 | ia: false,
68 | message: prompt
69 | },
70 | {
71 | id: IAMessageID,
72 | ia: true,
73 | message: ''
74 | }
75 | ]
76 |
77 | let fullConversation = partialNewConversation
78 |
79 | if (!selectedConversation) {
80 | selectedConversation = crypto.randomUUID()
81 |
82 | set((state) => ({
83 | loading: true,
84 | selectedConversation,
85 | conversationsMessages: {
86 | ...state.conversationsMessages,
87 | [selectedConversation]: partialNewConversation
88 | },
89 | conversationsInfo: {
90 | ...state.conversationsInfo,
91 | [selectedConversation]: {
92 | id: selectedConversation,
93 | name: 'New conversation'
94 | }
95 | }
96 | }))
97 | } else {
98 | set((state) => {
99 | fullConversation = [
100 | ...state.conversationsMessages[selectedConversation],
101 | ...partialNewConversation
102 | ]
103 |
104 | return {
105 | loading: true,
106 | conversationsMessages: {
107 | ...state.conversationsMessages,
108 | [selectedConversation]: fullConversation
109 | }
110 | }
111 | })
112 | }
113 |
114 | const compressedConversation = compress(
115 | JSON.stringify(fullConversation)
116 | )
117 |
118 | try {
119 | const eventSource = new EventSource(
120 | '/api/chat?prompt=' +
121 | prompt +
122 | '&conversation=' +
123 | compressedConversation
124 | )
125 | let message = ''
126 |
127 | eventSource.onerror = () => {
128 | set((state) => {
129 | return {
130 | loading: false,
131 | conversationsMessages: {
132 | ...state.conversationsMessages,
133 | [selectedConversation]: state.conversationsMessages[
134 | selectedConversation
135 | ].map((entry) => {
136 | if (entry.id === IAMessageID) {
137 | return {
138 | ...entry,
139 | error: true,
140 | message
141 | }
142 | }
143 | return entry
144 | })
145 | }
146 | }
147 | })
148 | }
149 |
150 | eventSource.onmessage = (event) => {
151 | if (event.data === '[DONE]') {
152 | set(() => ({ loading: false }))
153 |
154 | eventSource.close()
155 | return
156 | }
157 |
158 | message += JSON.parse(event.data)
159 |
160 | // Actualizar el mensaje de la IA
161 | // que tenía el mensaje vacio,
162 | // con el texto completo
163 | set((state) => {
164 | return {
165 | conversationsMessages: {
166 | ...state.conversationsMessages,
167 | [selectedConversation]: state.conversationsMessages[
168 | selectedConversation
169 | ].map((entry) => {
170 | if (entry.id === IAMessageID) {
171 | return {
172 | ...entry,
173 | message
174 | }
175 | }
176 | return entry
177 | })
178 | }
179 | }
180 | })
181 | }
182 | } catch (error) {
183 | console.error(error)
184 | }
185 | }
186 | }),
187 | {
188 | name: 'conversations',
189 | storage: createJSONStorage(() => ({
190 | // Returning a promise from getItem is necessary to avoid issues with hydration
191 | getItem: async (name) =>
192 | new Promise((resolve) =>
193 | setTimeout(() => {
194 | const isServer = typeof window === 'undefined'
195 | if (isServer) return
196 |
197 | const value = localStorage?.getItem(name)
198 | resolve(value)
199 | }, 100)
200 | ),
201 | setItem: (name, value) => localStorage?.setItem(name, value),
202 | removeItem: (name) => localStorage?.removeItem(name)
203 | }))
204 | }
205 | )
206 | )
207 |
208 | if (process.env.NODE_ENV === 'development') {
209 | mountStoreDevtool('Store', useConversationsStore)
210 | }
211 |
--------------------------------------------------------------------------------
/src/styles/Home.module.css:
--------------------------------------------------------------------------------
1 | .main {
2 | display: flex;
3 | flex-direction: column;
4 | justify-content: space-between;
5 | align-items: center;
6 | padding: 6rem;
7 | min-height: 100vh;
8 | }
9 |
10 | .description {
11 | display: inherit;
12 | justify-content: inherit;
13 | align-items: inherit;
14 | font-size: 0.85rem;
15 | max-width: var(--max-width);
16 | width: 100%;
17 | z-index: 2;
18 | font-family: var(--font-mono);
19 | }
20 |
21 | .description a {
22 | display: flex;
23 | justify-content: center;
24 | align-items: center;
25 | gap: 0.5rem;
26 | }
27 |
28 | .description p {
29 | position: relative;
30 | margin: 0;
31 | padding: 1rem;
32 | background-color: rgba(var(--callout-rgb), 0.5);
33 | border: 1px solid rgba(var(--callout-border-rgb), 0.3);
34 | border-radius: var(--border-radius);
35 | }
36 |
37 | .code {
38 | font-weight: 700;
39 | font-family: var(--font-mono);
40 | }
41 |
42 | .grid {
43 | display: grid;
44 | grid-template-columns: repeat(4, minmax(25%, auto));
45 | width: var(--max-width);
46 | max-width: 100%;
47 | }
48 |
49 | .card {
50 | padding: 1rem 1.2rem;
51 | border-radius: var(--border-radius);
52 | background: rgba(var(--card-rgb), 0);
53 | border: 1px solid rgba(var(--card-border-rgb), 0);
54 | transition: background 200ms, border 200ms;
55 | }
56 |
57 | .card span {
58 | display: inline-block;
59 | transition: transform 200ms;
60 | }
61 |
62 | .card h2 {
63 | font-weight: 600;
64 | margin-bottom: 0.7rem;
65 | }
66 |
67 | .card p {
68 | margin: 0;
69 | opacity: 0.6;
70 | font-size: 0.9rem;
71 | line-height: 1.5;
72 | max-width: 30ch;
73 | }
74 |
75 | .center {
76 | display: flex;
77 | justify-content: center;
78 | align-items: center;
79 | position: relative;
80 | padding: 4rem 0;
81 | }
82 |
83 | .center::before {
84 | background: var(--secondary-glow);
85 | border-radius: 50%;
86 | width: 480px;
87 | height: 360px;
88 | margin-left: -400px;
89 | }
90 |
91 | .center::after {
92 | background: var(--primary-glow);
93 | width: 240px;
94 | height: 180px;
95 | z-index: -1;
96 | }
97 |
98 | .center::before,
99 | .center::after {
100 | content: '';
101 | left: 50%;
102 | position: absolute;
103 | filter: blur(45px);
104 | transform: translateZ(0);
105 | }
106 |
107 | .logo,
108 | .thirteen {
109 | position: relative;
110 | }
111 |
112 | .thirteen {
113 | display: flex;
114 | justify-content: center;
115 | align-items: center;
116 | width: 75px;
117 | height: 75px;
118 | padding: 25px 10px;
119 | margin-left: 16px;
120 | transform: translateZ(0);
121 | border-radius: var(--border-radius);
122 | overflow: hidden;
123 | box-shadow: 0px 2px 8px -1px #0000001a;
124 | }
125 |
126 | .thirteen::before,
127 | .thirteen::after {
128 | content: '';
129 | position: absolute;
130 | z-index: -1;
131 | }
132 |
133 | /* Conic Gradient Animation */
134 | .thirteen::before {
135 | animation: 6s rotate linear infinite;
136 | width: 200%;
137 | height: 200%;
138 | background: var(--tile-border);
139 | }
140 |
141 | /* Inner Square */
142 | .thirteen::after {
143 | inset: 0;
144 | padding: 1px;
145 | border-radius: var(--border-radius);
146 | background: linear-gradient(
147 | to bottom right,
148 | rgba(var(--tile-start-rgb), 1),
149 | rgba(var(--tile-end-rgb), 1)
150 | );
151 | background-clip: content-box;
152 | }
153 |
154 | /* Enable hover only on non-touch devices */
155 | @media (hover: hover) and (pointer: fine) {
156 | .card:hover {
157 | background: rgba(var(--card-rgb), 0.1);
158 | border: 1px solid rgba(var(--card-border-rgb), 0.15);
159 | }
160 |
161 | .card:hover span {
162 | transform: translateX(4px);
163 | }
164 | }
165 |
166 | @media (prefers-reduced-motion) {
167 | .thirteen::before {
168 | animation: none;
169 | }
170 |
171 | .card:hover span {
172 | transform: none;
173 | }
174 | }
175 |
176 | /* Mobile */
177 | @media (max-width: 700px) {
178 | .content {
179 | padding: 4rem;
180 | }
181 |
182 | .grid {
183 | grid-template-columns: 1fr;
184 | margin-bottom: 120px;
185 | max-width: 320px;
186 | text-align: center;
187 | }
188 |
189 | .card {
190 | padding: 1rem 2.5rem;
191 | }
192 |
193 | .card h2 {
194 | margin-bottom: 0.5rem;
195 | }
196 |
197 | .center {
198 | padding: 8rem 0 6rem;
199 | }
200 |
201 | .center::before {
202 | transform: none;
203 | height: 300px;
204 | }
205 |
206 | .description {
207 | font-size: 0.8rem;
208 | }
209 |
210 | .description a {
211 | padding: 1rem;
212 | }
213 |
214 | .description p,
215 | .description div {
216 | display: flex;
217 | justify-content: center;
218 | position: fixed;
219 | width: 100%;
220 | }
221 |
222 | .description p {
223 | align-items: center;
224 | inset: 0 0 auto;
225 | padding: 2rem 1rem 1.4rem;
226 | border-radius: 0;
227 | border: none;
228 | border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25);
229 | background: linear-gradient(
230 | to bottom,
231 | rgba(var(--background-start-rgb), 1),
232 | rgba(var(--callout-rgb), 0.5)
233 | );
234 | background-clip: padding-box;
235 | backdrop-filter: blur(24px);
236 | }
237 |
238 | .description div {
239 | align-items: flex-end;
240 | pointer-events: none;
241 | inset: auto 0 0;
242 | padding: 2rem;
243 | height: 200px;
244 | background: linear-gradient(
245 | to bottom,
246 | transparent 0%,
247 | rgb(var(--background-end-rgb)) 40%
248 | );
249 | z-index: 1;
250 | }
251 | }
252 |
253 | /* Tablet and Smaller Desktop */
254 | @media (min-width: 701px) and (max-width: 1120px) {
255 | .grid {
256 | grid-template-columns: repeat(2, 50%);
257 | }
258 | }
259 |
260 | @media (prefers-color-scheme: dark) {
261 | .vercelLogo {
262 | filter: invert(1);
263 | }
264 |
265 | .logo,
266 | .thirteen img {
267 | filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70);
268 | }
269 | }
270 |
271 | @keyframes rotate {
272 | from {
273 | transform: rotate(360deg);
274 | }
275 | to {
276 | transform: rotate(0deg);
277 | }
278 | }
279 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --max-width: 1100px;
7 | --border-radius: 12px;
8 | --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono',
9 | 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro',
10 | 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace;
11 |
12 | --foreground-rgb: 0, 0, 0;
13 | --background-start-rgb: 214, 219, 220;
14 | --background-end-rgb: 255, 255, 255;
15 |
16 | --primary-glow: conic-gradient(
17 | from 180deg at 50% 50%,
18 | #16abff33 0deg,
19 | #0885ff33 55deg,
20 | #54d6ff33 120deg,
21 | #0071ff33 160deg,
22 | transparent 360deg
23 | );
24 | --secondary-glow: radial-gradient(
25 | rgba(255, 255, 255, 1),
26 | rgba(255, 255, 255, 0)
27 | );
28 |
29 | --tile-start-rgb: 239, 245, 249;
30 | --tile-end-rgb: 228, 232, 233;
31 | --tile-border: conic-gradient(
32 | #00000080,
33 | #00000040,
34 | #00000030,
35 | #00000020,
36 | #00000010,
37 | #00000010,
38 | #00000080
39 | );
40 |
41 | --callout-rgb: 238, 240, 241;
42 | --callout-border-rgb: 172, 175, 176;
43 | --card-rgb: 180, 185, 188;
44 | --card-border-rgb: 131, 134, 135;
45 | }
46 |
47 | @media (prefers-color-scheme: dark) {
48 | :root {
49 | --foreground-rgb: 255, 255, 255;
50 | --background-start-rgb: 0, 0, 0;
51 | --background-end-rgb: 0, 0, 0;
52 |
53 | --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0));
54 | --secondary-glow: linear-gradient(
55 | to bottom right,
56 | rgba(1, 65, 255, 0),
57 | rgba(1, 65, 255, 0),
58 | rgba(1, 65, 255, 0.3)
59 | );
60 |
61 | --tile-start-rgb: 2, 13, 46;
62 | --tile-end-rgb: 2, 5, 19;
63 | --tile-border: conic-gradient(
64 | #ffffff80,
65 | #ffffff40,
66 | #ffffff30,
67 | #ffffff20,
68 | #ffffff10,
69 | #ffffff10,
70 | #ffffff80
71 | );
72 |
73 | --callout-rgb: 20, 20, 20;
74 | --callout-border-rgb: 108, 108, 108;
75 | --card-rgb: 100, 100, 100;
76 | --card-border-rgb: 200, 200, 200;
77 | }
78 | }
79 |
80 | * {
81 | box-sizing: border-box;
82 | padding: 0;
83 | margin: 0;
84 | }
85 |
86 | html,
87 | body {
88 | max-width: 100vw;
89 | overflow-x: hidden;
90 | scroll-behavior: smooth;
91 | }
92 |
93 | body {
94 | color: rgb(var(--foreground-rgb));
95 | background: linear-gradient(
96 | to bottom,
97 | transparent,
98 | rgb(var(--background-end-rgb))
99 | )
100 | rgb(var(--background-start-rgb));
101 | }
102 |
103 | a {
104 | color: inherit;
105 | text-decoration: none;
106 | }
107 |
108 | @media (prefers-color-scheme: dark) {
109 | html {
110 | color-scheme: dark;
111 | }
112 | }
113 |
114 | ::-webkit-scrollbar-thumb {
115 | background-color: rgba(86,88,105,1);
116 | border-color: rgba(255,255,255,1);
117 | border-radius: 9999px;
118 | border-width: 1px;
119 | }
120 |
121 | ::-webkit-scrollbar-track {
122 | background-color: transparent;
123 | border-radius: 9999px;
124 | }
125 |
126 | code {
127 | font-size: 14px;
128 | color: white;
129 | font-size: .875em;
130 | font-weight: 600;
131 | }
132 |
133 | .hljs {
134 | font-size: 14px;
135 | border-radius: 4px;
136 | }
137 |
138 | .hljs code {
139 | font-weight: 500;
140 | }
141 |
142 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ['./src/**/*.{js,ts,jsx,tsx}'],
4 | theme: {
5 | extend: {
6 | animation: {
7 | typing: 'blink 1s steps(5, start) infinite'
8 | },
9 | backgroundImage: {
10 | gradient: 'linear-gradient(180deg,rgba(53,55,64,0),#353740 58.85%)'
11 | },
12 | keyframes: {
13 | blink: {
14 | to: { visibility: 'hidden' }
15 | }
16 | },
17 | colors: {
18 | gptlogo: '#10a37f',
19 | gptdarkgray: '#202123',
20 | gptgray: '#343541',
21 | gptlightgray: '#444654'
22 | }
23 | }
24 | },
25 | plugins: []
26 | }
27 |
--------------------------------------------------------------------------------