├── next.config.js ├── public ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── mstile-70x70.png ├── mstile-144x144.png ├── mstile-150x150.png ├── mstile-310x150.png ├── mstile-310x310.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── menu.svg ├── browserconfig.xml ├── site.webmanifest ├── safari-pinned-tab.svg └── logo.svg ├── .prettierrc ├── postcss.config.cjs ├── prettier.config.cjs ├── src ├── styles │ ├── globals.css │ ├── drawerStyles.module.css │ ├── login.module.css │ ├── uploadSquare.module.css │ └── drawerContent.module.css ├── utils │ ├── consts.tsx │ ├── getTextForURL.tsx │ ├── handleUpload.ts │ ├── useWindowSize.tsx │ ├── embeddings.ts │ └── openAIStream.ts ├── pages │ ├── _app.tsx │ ├── api │ │ ├── chats │ │ │ ├── rename.ts │ │ │ ├── save.ts │ │ │ ├── deletefile.ts │ │ │ ├── getConversation.ts │ │ │ ├── create.ts │ │ │ ├── delete.ts │ │ │ └── get.ts │ │ ├── theme │ │ │ ├── setTheme.ts │ │ │ └── getAndUpdateTheme.ts │ │ ├── stream.ts │ │ └── upload │ │ │ ├── getEmbeddingsForText.ts │ │ │ ├── handleUrlUpload.ts │ │ │ └── handleFileUpload.ts │ ├── chat.tsx │ ├── chat │ │ └── [chatId].tsx │ └── index.tsx ├── types │ └── types.ts ├── components │ ├── drawer │ │ ├── uploadSquare.tsx │ │ ├── account.tsx │ │ ├── fileComponent.tsx │ │ ├── profile.tsx │ │ ├── chatSettings.tsx │ │ ├── addMedia.tsx │ │ └── drawerContent.tsx │ ├── utils │ │ └── login.tsx │ └── chat │ │ ├── introModal.tsx │ │ ├── message.tsx │ │ └── chat.tsx └── env.mjs ├── tailwind.config.cjs ├── .stylelintrc.json ├── next.config.mjs ├── .gitignore ├── tsconfig.json ├── .eslintrc.cjs ├── README.md └── package.json /next.config.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsomer/Document-Chat/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsomer/Document-Chat/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsomer/Document-Chat/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsomer/Document-Chat/HEAD/public/mstile-70x70.png -------------------------------------------------------------------------------- /public/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsomer/Document-Chat/HEAD/public/mstile-144x144.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsomer/Document-Chat/HEAD/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsomer/Document-Chat/HEAD/public/mstile-310x150.png -------------------------------------------------------------------------------- /public/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsomer/Document-Chat/HEAD/public/mstile-310x310.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsomer/Document-Chat/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "printWidth": 80 6 | } -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsomer/Document-Chat/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobsomer/Document-Chat/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/menu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | 8 | module.exports = config; 9 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | const config = { 3 | plugins: [require.resolve("prettier-plugin-tailwindcss")], 4 | }; 5 | 6 | module.exports = config; 7 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* @import url("../../node_modules/highlight.js/styles/github-dark-dimmed.css") */ 6 | 7 | /* get current theme */ 8 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const config = { 3 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [require("daisyui")], 8 | }; 9 | 10 | module.exports = config; 11 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "rules": { 4 | "selector-class-pattern": null, 5 | "declaration-block-no-redundant-longhand-properties":null, 6 | "font-family-no-missing-generic-family-keyword":null, 7 | "declaration-block-single-line-max-declarations":null, 8 | "at-rule-no-unknown": null, 9 | "no-invalid-position-at-import-rule": null 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/consts.tsx: -------------------------------------------------------------------------------- 1 | export const supportedExtensions = [ 2 | 'txt', 3 | 'pdf', 4 | 'doc', 5 | 'docx', 6 | 'ppt', 7 | 'pptx', 8 | 'csv', 9 | 'md', 10 | 'py', 11 | 'js', 12 | 'html', 13 | 'css', 14 | 'java', 15 | 'c', 16 | 'cpp', 17 | 'ts', 18 | 'tsx', 19 | 'jsx', 20 | 'json', 21 | 'xml', 22 | 'yaml', 23 | 'yml', 24 | 'sql', 25 | 'php', 26 | 'rb', 27 | 'go', 28 | 'env', 29 | 'sh', 30 | 'swift', 31 | 'kt' 32 | ]; 33 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. 5 | * This is especially useful for Docker builds. 6 | */ 7 | !process.env.SKIP_ENV_VALIDATION && (await import('./src/env.mjs')); 8 | 9 | /** @type {import("next").NextConfig} */ 10 | const config = { 11 | reactStrictMode: true, 12 | 13 | /** 14 | * If you have the "experimental: { appDir: true }" setting enabled, then you 15 | * must comment the below `i18n` config out. 16 | * 17 | * @see https://github.com/vercel/next.js/issues/41980 18 | */ 19 | i18n: { 20 | locales: ['en'], 21 | defaultLocale: 'en' 22 | } 23 | }; 24 | module.exports = config; 25 | -------------------------------------------------------------------------------- /.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 | # database 12 | /prisma/db.sqlite 13 | /prisma/db.sqlite-journal 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | next-env.d.ts 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # local env files 34 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 35 | .env 36 | .env*.local 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | -------------------------------------------------------------------------------- /src/utils/getTextForURL.tsx: -------------------------------------------------------------------------------- 1 | export type textResponse = 2 | | { pdfText: Array<[number, string]> } 3 | | { text: string }; 4 | type FetchOptions = { 5 | method: 'POST'; 6 | headers: { 'Content-Type': 'application/json' }; 7 | body: string; 8 | }; 9 | 10 | export async function fetchTextForUrl(url: string): Promise { 11 | const isPdf = url.endsWith('.pdf'); 12 | const endpoint = isPdf ? 'getTextForPDF' : 'getTextForURL'; 13 | const options: FetchOptions = { 14 | method: 'POST', 15 | headers: { 'Content-Type': 'application/json' }, 16 | body: JSON.stringify({ url }) 17 | }; 18 | const response = await fetch( 19 | `https://chat-boba-extract-fhpwesohfa-ue.a.run.app/${endpoint}`, 20 | options 21 | ); 22 | const data = (await response.json()) as textResponse; 23 | return data; 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "checkJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "incremental": true, 18 | "noUncheckedIndexedAccess": true, 19 | "baseUrl": ".", 20 | "paths": { 21 | "~/*": ["./src/*"] 22 | } 23 | }, 24 | "include": [ 25 | ".eslintrc.cjs", 26 | "next-env.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx", 29 | "**/*.cjs", 30 | "**/*.mjs" 31 | ], 32 | "exclude": ["node_modules"] 33 | } 34 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 3 | import '~/styles/globals.css'; 4 | import type { AppProps, AppType } from 'next/app'; 5 | import { createBrowserSupabaseClient } from '@supabase/auth-helpers-nextjs'; 6 | import React, { useState } from 'react'; 7 | import { SessionContextProvider } from '@supabase/auth-helpers-react'; 8 | 9 | const MyApp: AppType = ({ Component, pageProps }: AppProps) => { 10 | // Create a new supabase browser client on every first render. 11 | const [supabaseClient] = useState(() => createBrowserSupabaseClient()); 12 | 13 | return ( 14 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default MyApp; 24 | -------------------------------------------------------------------------------- /src/utils/handleUpload.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 3 | 4 | type response = { 5 | docId: string; 6 | error: string; 7 | } 8 | 9 | export const handleObjectUpload = async (url: string, docId: string): Promise => { 10 | const options: RequestInit= { 11 | method: "POST", 12 | headers: { "Content-Type": "application/json" }, 13 | body: JSON.stringify({ 14 | url, 15 | docId, 16 | }), 17 | }; 18 | try { 19 | const response = await fetch( 20 | "https://docuchat-extract-fhpwesohfa-ue.a.run.app/createEmbeddingForObject", 21 | options 22 | ); 23 | const resp = await response.json(); 24 | return resp 25 | } catch (err ) { 26 | let message = 'Unknown Error' 27 | if (err instanceof Error) message = err.message 28 | return { error: message } as response; 29 | } 30 | }; -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | const config = { 3 | overrides: [ 4 | { 5 | extends: [ 6 | 'plugin:@typescript-eslint/recommended-requiring-type-checking' 7 | ], 8 | files: ['*.ts', '*.tsx'], 9 | parserOptions: { 10 | project: 'tsconfig.json' 11 | } 12 | } 13 | ], 14 | parser: '@typescript-eslint/parser', 15 | parserOptions: { 16 | project: './tsconfig.json' 17 | }, 18 | plugins: ['@typescript-eslint'], 19 | extends: ['next/core-web-vitals', 'plugin:@typescript-eslint/recommended'], 20 | rules: { 21 | '@typescript-eslint/consistent-type-imports': [ 22 | 'warn', 23 | { 24 | prefer: 'type-imports', 25 | fixStyle: 'inline-type-imports' 26 | } 27 | ], 28 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }] 29 | // 'prettier/prettier': 2 30 | } 31 | }; 32 | 33 | module.exports = config; 34 | -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/pages/api/chats/rename.ts: -------------------------------------------------------------------------------- 1 | import { type NextApiRequest, type NextApiResponse } from 'next'; 2 | import { createClient } from '@supabase/supabase-js'; 3 | 4 | type Query = { 5 | newName: string; 6 | chatId: string; 7 | }; 8 | 9 | export default async function handler( 10 | req: NextApiRequest, 11 | res: NextApiResponse 12 | ) { 13 | // rename 14 | const { newName, chatId } = req.body as Query; 15 | const supabase = req.headers.host?.includes('localhost') 16 | ? createClient( 17 | process.env.NEXT_PUBLIC_SUPABASE_URL_DEV || '', 18 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY_DEV || '' 19 | ) 20 | : createClient( 21 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 22 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' 23 | ); 24 | 25 | const { error } = await supabase 26 | .from('userChats') 27 | .update({ chatName: newName }) 28 | .eq('chatId', chatId); 29 | 30 | if (error) { 31 | console.log(error.message); 32 | return; 33 | } 34 | 35 | return res.status(200).json({ message: 'Chat deleted successfully' }); 36 | } 37 | -------------------------------------------------------------------------------- /src/styles/drawerStyles.module.css: -------------------------------------------------------------------------------- 1 | .filesContainer { 2 | background-color: red; 3 | width: 250px; 4 | height: 80vh; 5 | position: absolute; 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | overflow-y: scroll; 10 | -ms-overflow-style: none; 11 | scrollbar-width: none; 12 | color: hsl(var(--bc)); 13 | font-size: large; 14 | } 15 | 16 | /* Hide scrollbar for Chrome, Safari and Opera */ 17 | .filesContainer::-webkit-scrollbar { 18 | display: none; 19 | } 20 | 21 | .fileItem { 22 | position: relative; 23 | width: 80%; 24 | color: hsl(var(--pc)); 25 | background-color: hsl(var(--b1)); 26 | margin: 8px; 27 | border-radius: 8px; 28 | padding: 8px; 29 | display: flex; 30 | align-items: center; 31 | } 32 | 33 | .fileIcon { 34 | position: relative; 35 | width: 20px; 36 | height: 20px; 37 | } 38 | 39 | .deleteIconContainer { 40 | position: absolute; 41 | top: 50%; 42 | transform: translateY(-50%); 43 | right: 4px; 44 | z-index: 100; 45 | display: flex; 46 | align-items: center; 47 | justify-content: center; 48 | } 49 | 50 | .delete-icon { 51 | position: relative; 52 | width: 28px; 53 | height: 28px; 54 | } 55 | -------------------------------------------------------------------------------- /src/pages/api/theme/setTheme.ts: -------------------------------------------------------------------------------- 1 | import { type NextApiRequest, type NextApiResponse } from 'next'; 2 | import { createClient } from '@supabase/supabase-js'; 3 | 4 | type Query = { 5 | userId: string; 6 | theme: string; 7 | }; 8 | 9 | export default async function handler( 10 | req: NextApiRequest, 11 | res: NextApiResponse 12 | ) { 13 | const { userId, theme } = req.body as Query; 14 | const supabase = req.headers.host?.includes('localhost') 15 | ? createClient( 16 | process.env.NEXT_PUBLIC_SUPABASE_URL_DEV || '', 17 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY_DEV || '' 18 | ) 19 | : createClient( 20 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 21 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' 22 | ); 23 | if (!userId || !theme) { 24 | res.status(400).json({ message: 'Missing user or theme' }); 25 | return; 26 | } 27 | 28 | const { error } = await supabase 29 | .from('userTheme') 30 | .update({ theme: theme }) 31 | .eq('userId', userId); 32 | 33 | if (error) { 34 | res.status(500).json({ message: 'Error updating user theme' }); 35 | return; 36 | } 37 | 38 | res.status(200).json({ message: 'User theme updated' }); 39 | } 40 | -------------------------------------------------------------------------------- /src/styles/login.module.css: -------------------------------------------------------------------------------- 1 | .loginContainer { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | height: 100vh; 6 | background-color: var(--background); 7 | } 8 | 9 | .loginPanel { 10 | width: 50%; 11 | display: flex; 12 | flex-direction: column; 13 | justify-content: center; 14 | align-items: center; 15 | padding: 2rem; 16 | margin: 0 2rem; 17 | background-color: var(--background); 18 | height: 100%; 19 | } 20 | 21 | .loginPanel1 { 22 | position: relative; 23 | left: 0; 24 | width: 50%; 25 | background-image: url("https://gsaywynqkowtwhnyrehr.supabase.co/storage/v1/object/public/media/signup.png"); 26 | height: 100%; 27 | top: 0; 28 | } 29 | 30 | @media only screen and (max-width: 768px) { 31 | .loginPanel { 32 | width: 100%; 33 | margin: 2rem 0; 34 | } 35 | 36 | .loginPanel1 { 37 | width: 100%; 38 | margin: 2rem 0; 39 | } 40 | } 41 | 42 | .loginPanelLogo { 43 | /* display: flex; */ 44 | justify-content: center; 45 | align-items: center; 46 | margin-bottom: 2rem; 47 | top: 0; 48 | } 49 | 50 | .loginPanelLogo img { 51 | width: 5rem; 52 | height: 5rem; 53 | } 54 | 55 | .loginPanelTitle { 56 | text-align: center; 57 | margin-bottom: 1rem; 58 | } 59 | -------------------------------------------------------------------------------- /src/pages/api/chats/save.ts: -------------------------------------------------------------------------------- 1 | // pages/api/chats/saveChat.js 2 | 3 | import { type NextApiRequest, type NextApiResponse } from 'next'; 4 | import { createClient } from '@supabase/supabase-js'; 5 | 6 | type SaveChatBody = { 7 | userId: string; 8 | chatId: string; 9 | conversation: string; 10 | }; 11 | 12 | export default async function handler( 13 | req: NextApiRequest, 14 | res: NextApiResponse 15 | ) { 16 | const { userId, chatId, conversation } = req.body as SaveChatBody; 17 | const supabase = req.headers.host?.includes('localhost') 18 | ? createClient( 19 | process.env.NEXT_PUBLIC_SUPABASE_URL_DEV || '', 20 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY_DEV || '' 21 | ) 22 | : createClient( 23 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 24 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' 25 | ); 26 | try { 27 | await supabase 28 | .from('userChats') 29 | .update({ conversation }) 30 | .eq('userId', userId) 31 | .eq('chatId', chatId); 32 | 33 | return res.status(200).json({ message: 'Chat saved successfully.' }); 34 | } catch (error) { 35 | console.error(error); 36 | return res 37 | .status(500) 38 | .json({ message: 'An error occurred while saving chat.' }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/useWindowSize.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-argument */ 2 | import { useState, useEffect } from 'react'; 3 | import { any, number, ZodErrorMap, ZodNumber } from 'zod'; 4 | 5 | // Hook 6 | export default function useWindowSize() { 7 | // Initialize state with undefined width/height so server and client renders match 8 | // Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/ 9 | 10 | const [width, setWidth] = useState(undefined); 11 | const [height, setHeight] = useState(undefined); 12 | 13 | const windowSize = { 14 | width, 15 | height 16 | }; 17 | 18 | useEffect(() => { 19 | // only execute all the code below in client side 20 | // Handler to call on window resize 21 | function handleResize() { 22 | const width = window.innerWidth; 23 | const height = window.innerHeight; 24 | // Set window width/height to state 25 | setWidth(width); 26 | setHeight(height); 27 | } 28 | 29 | // Add event listener 30 | window.addEventListener('resize', handleResize); 31 | 32 | // Call handler right away so state gets updated with initial window size 33 | handleResize(); 34 | 35 | // Remove event listener on cleanup 36 | return () => window.removeEventListener('resize', handleResize); 37 | }, []); // Empty array ensures that effect is only run on mount 38 | return windowSize; 39 | } 40 | -------------------------------------------------------------------------------- /src/styles/uploadSquare.module.css: -------------------------------------------------------------------------------- 1 | .uploadsquare { 2 | position: relative; 3 | font-family: "Open Sans", sans-serif; 4 | color: hsl(var(--bc)); 5 | border: 2px dotted hsl(var(--bc)); 6 | background: transparent; 7 | } 8 | 9 | .uploadlabel { 10 | width: 100%; 11 | height: 100%; 12 | display: block; 13 | text-align: center; 14 | border-radius: 4px; 15 | box-sizing: border-box; 16 | padding-top: 25px; 17 | transition: 0.2s; 18 | } 19 | 20 | .uploadtextcontainer { 21 | display: flex; 22 | flex-direction: row; 23 | align-items: center; 24 | justify-content: center; 25 | height: 100%; 26 | overflow: hidden; 27 | width: 100%; 28 | padding-bottom: 25px; 29 | } 30 | 31 | .uploadtext { 32 | text-align: center; 33 | } 34 | 35 | .uploadlabelheader { 36 | font-size: 16px; 37 | font-weight: 600; 38 | vertical-align: top; 39 | width: 100%; 40 | } 41 | 42 | .upload-label-subheader { 43 | font-size: 12px; 44 | font-weight: 500; 45 | line-height: 1.5; 46 | margin-top: 0; 47 | display: inline-block; 48 | vertical-align: top; 49 | width: 100%; 50 | } 51 | 52 | .uploadicon { 53 | margin-top: -3px; 54 | margin-left: 10px; 55 | font-size: 18px; 56 | opacity: 0.8; 57 | left: 50%; 58 | position: relative; 59 | transform: translateX(-50%); 60 | } 61 | 62 | .uploadbutton { 63 | position: absolute; 64 | top: 0; 65 | right: 0; 66 | margin: 0; 67 | padding: 0; 68 | font-size: 100px; 69 | cursor: pointer; 70 | opacity: 0; 71 | } 72 | 73 | .uploadbutton:focus { 74 | outline: none; 75 | } 76 | -------------------------------------------------------------------------------- /src/pages/api/stream.ts: -------------------------------------------------------------------------------- 1 | import type { ChatCompletionRequestMessage } from 'openai'; 2 | import { OpenAIStream, type OpenAIStreamPayload } from "~/utils/openAIStream"; 3 | 4 | export type OaiModel = 'gpt-3.5-turbo' | 'gpt-4'; 5 | 6 | export type CompletionRequest = { 7 | messages: ChatCompletionRequestMessage[]; 8 | dataSources: string[]; 9 | model: OaiModel; 10 | }; 11 | 12 | function addDataSources( 13 | messages: ChatCompletionRequestMessage[], 14 | dataSources: string[] 15 | ) { 16 | if (messages[0] === undefined) { 17 | throw new Error('No messages'); 18 | } 19 | if (messages[0].role !== 'system') { 20 | throw new Error('First message must be a system message'); 21 | } 22 | 23 | messages[0].content = `You are a helpful assistant named ChatBoba powered by GPT-4, the newest model by OpenAI. 24 | Here are your data sources: ${dataSources.join(', ')}\n\n`; 25 | console.log(messages); 26 | } 27 | 28 | 29 | export const config = { 30 | runtime: "edge", 31 | }; 32 | 33 | 34 | export default async function POST(req: Request): Promise { 35 | const { messages, dataSources, model } = (await req.json()) as CompletionRequest; 36 | 37 | addDataSources(messages, dataSources); 38 | 39 | const payload: OpenAIStreamPayload = { 40 | model: model, 41 | messages: messages, 42 | temperature: 0.7, 43 | top_p: 1, 44 | frequency_penalty: 0, 45 | presence_penalty: 0, 46 | max_tokens: 1000, 47 | stream: true, 48 | n: 1, 49 | }; 50 | 51 | const stream = await OpenAIStream(payload); 52 | return new Response(stream); 53 | } 54 | -------------------------------------------------------------------------------- /src/types/types.ts: -------------------------------------------------------------------------------- 1 | import { type SupabaseClient } from '@supabase/supabase-js'; 2 | import { type Dispatch, type SetStateAction } from 'react'; 3 | 4 | export type File = { 5 | url: string; 6 | docId: string; 7 | docName: string; 8 | }; 9 | 10 | export type SearchResponse = { 11 | index: number[]; 12 | body: string[]; 13 | docName: string[]; 14 | }; 15 | 16 | export type UserChat = { 17 | chatId: string; 18 | chatName: string; 19 | }; 20 | 21 | export type ChatFile = { 22 | chatId: string; 23 | docId: string; 24 | docName: string; 25 | }; 26 | 27 | export type AddMediaProps = { 28 | chatId: string; 29 | updateFiles: (chatId: string) => Promise; 30 | setToolTipString: Dispatch>; 31 | } 32 | export type DrawerProps = { 33 | currentChat: UserChat; 34 | userChats: UserChat[] | undefined; 35 | files: File[]; 36 | handleClearSubmit: (e: React.MouseEvent) => void; 37 | deleteFile: (docId: string) => Promise; 38 | updateFiles: (chatId: string) => Promise; 39 | createNewChat: () => Promise; 40 | deleteChat: () => Promise; 41 | renameChat: (newName: string) => Promise; 42 | }; 43 | 44 | export type ChatProps = { 45 | currentChat: UserChat; 46 | userChats: UserChat[] | undefined; 47 | userId: string | undefined; 48 | files: File[]; 49 | deleteFile: (docId: string) => Promise; 50 | updateFiles: (chatId: string) => Promise; 51 | createNewChat: () => Promise; 52 | deleteChat: () => Promise; 53 | renameChat: (newName: string) => Promise; 54 | }; 55 | -------------------------------------------------------------------------------- /src/pages/api/theme/getAndUpdateTheme.ts: -------------------------------------------------------------------------------- 1 | import { type NextApiRequest, type NextApiResponse } from 'next'; 2 | import { createClient } from '@supabase/supabase-js'; 3 | 4 | type Query = { 5 | userId: string; 6 | }; 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) { 11 | const { userId } = req.body as Query; 12 | const supabase = req.headers.host?.includes('localhost') 13 | ? createClient( 14 | process.env.NEXT_PUBLIC_SUPABASE_URL_DEV || '', 15 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY_DEV || '' 16 | ) 17 | : createClient( 18 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 19 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' 20 | ); 21 | try { 22 | const { data, error } = await supabase 23 | .from('userTheme') 24 | .select('*') 25 | .eq('userId', userId); 26 | 27 | if (error) { 28 | console.error(error); 29 | return res 30 | .status(500) 31 | .json({ message: 'An error occurred while retrieving user theme.' }); 32 | } 33 | 34 | if (data.length === 0) { 35 | await supabase.from('userTheme').insert({ 36 | userId: userId, 37 | theme: 'light' 38 | }); 39 | return res.status(200).json({ theme: 'light' }); 40 | } else if (data.length === 1) { 41 | const theme = data[0] as { theme: 'light' | 'dark' }; 42 | return res.status(200).json({ theme: theme.theme }); 43 | } 44 | } catch (error) { 45 | console.error(error); 46 | return res 47 | .status(500) 48 | .json({ message: 'An error occurred while updating user theme.' }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/styles/drawerContent.module.css: -------------------------------------------------------------------------------- 1 | .drawerContainer { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: space-between; 5 | height: 100%; 6 | padding: 20px; 7 | background-color: hsl(var(--b1)); 8 | box-shadow: 0 0 10px rgb(0 0 0 / 20%); 9 | } 10 | 11 | .topSection { 12 | flex: 1; 13 | display: flex; 14 | flex-direction: column; 15 | height: 80vh; 16 | } 17 | 18 | .bottomSection { 19 | margin-top: 20px; 20 | position: relative; 21 | } 22 | 23 | .title { 24 | margin-bottom: 10px; 25 | } 26 | 27 | .title h1 { 28 | font-size: 24px; 29 | font-weight: bold; 30 | color: hsl(var(--p)); 31 | } 32 | 33 | .filesList { 34 | margin-bottom: 20px; 35 | } 36 | 37 | .filesList h2 { 38 | font-size: 18px; 39 | font-weight: bold; 40 | color: hsl(var(--p)); 41 | } 42 | 43 | .filesListContainer { 44 | margin-top: 10px; 45 | } 46 | 47 | .file { 48 | display: flex; 49 | justify-content: space-between; 50 | align-items: center; 51 | background-color: hsl(var(--b2)); 52 | padding: 10px; 53 | margin-bottom: 10px; 54 | border: 1px solid hsl(var(--b1)); 55 | border-radius: 5px; 56 | } 57 | 58 | .file p { 59 | margin: 0; 60 | } 61 | 62 | .deleteButton { 63 | background-color: hsl(var(--er)); 64 | color: #fff; 65 | border: none; 66 | padding: 5px 10px; 67 | border-radius: 3px; 68 | cursor: pointer; 69 | transition: all 0.2s ease-in-out; 70 | } 71 | 72 | .deleteButton:hover { 73 | background-color: hsl(var(--wac)); 74 | } 75 | 76 | .addDataButtonContainer { 77 | position: relative; 78 | color: #fff; 79 | width: 100%; 80 | height: 100%; 81 | display: flex; 82 | justify-content: center; 83 | align-items: center; 84 | } 85 | 86 | .uploadIcon { 87 | margin-right: 4px; 88 | } 89 | -------------------------------------------------------------------------------- /src/components/drawer/uploadSquare.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-misused-promises */ 2 | import React from 'react'; 3 | import { FiUpload } from 'react-icons/fi'; 4 | import styles from '~/styles/uploadSquare.module.css'; 5 | 6 | const isMobileDevice = () => { 7 | return false; 8 | }; 9 | 10 | interface UploadSquareProps { 11 | handleFileUpload: ( 12 | event: React.ChangeEvent 13 | ) => Promise; 14 | } 15 | 16 | const UploadSquare: React.FC = ({ handleFileUpload }) => { 17 | const renderUploadText = () => { 18 | const isMobile = isMobileDevice(); // replace with your own logic to detect if the user is on a mobile device 19 | if (isMobile) { 20 | return 'Press to upload'; 21 | } 22 | return 'Click to upload'; 23 | }; 24 | 25 | return ( 26 |
27 | 47 |
48 | ); 49 | }; 50 | 51 | export default UploadSquare; 52 | -------------------------------------------------------------------------------- /src/pages/api/chats/deletefile.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js'; 2 | import { type NextApiRequest, type NextApiResponse } from 'next'; 3 | 4 | type Query = { 5 | docId: string; 6 | }; 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) { 11 | if (req.method === 'POST') { 12 | const supabase = req.headers.host?.includes('localhost') 13 | ? createClient( 14 | process.env.NEXT_PUBLIC_SUPABASE_URL_DEV || '', 15 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY_DEV || '' 16 | ) 17 | : createClient( 18 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 19 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' 20 | ); 21 | 22 | const { docId } = req.body as Query; 23 | 24 | const { error: error1 } = await supabase 25 | .from('chats') 26 | .delete() 27 | .eq('docId', docId); 28 | 29 | if (error1) { 30 | console.log(error1); 31 | return res.status(500).json({ message: 'Error deleting chat file' }); 32 | } 33 | 34 | const { data, error } = await supabase 35 | .from('chats') 36 | .select('*') 37 | .eq('docId', docId); 38 | 39 | if (data?.length == 0) { 40 | const { error } = await supabase 41 | .from('userdocuments') 42 | .delete() 43 | .eq('docId', docId); 44 | 45 | if (error) { 46 | console.log(error); 47 | return res 48 | .status(500) 49 | .json({ message: 'Error deleting user document' }); 50 | } 51 | } 52 | 53 | if (error) { 54 | console.log(error); 55 | return res.status(500).json({ message: 'Error retrieving chat file' }); 56 | } 57 | 58 | return res.status(200).json({ message: 'Chat file deleted successfully' }); 59 | } 60 | 61 | return res.status(405).json({ message: 'Method not allowed' }); 62 | } 63 | -------------------------------------------------------------------------------- /src/pages/api/chats/getConversation.ts: -------------------------------------------------------------------------------- 1 | // api/getChat.ts 2 | import { type NextApiRequest, type NextApiResponse } from 'next'; 3 | import { type ChatCompletionRequestMessage } from 'openai'; 4 | import { createClient } from '@supabase/supabase-js'; 5 | 6 | type Query = { 7 | userId: string; 8 | chatId: string; 9 | }; 10 | 11 | export default async function handler( 12 | req: NextApiRequest, 13 | res: NextApiResponse 14 | ) { 15 | const { userId, chatId } = req.body as Query; 16 | const supabase = req.headers.host?.includes('localhost') 17 | ? createClient( 18 | process.env.NEXT_PUBLIC_SUPABASE_URL_DEV || '', 19 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY_DEV || '' 20 | ) 21 | : createClient( 22 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 23 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' 24 | ); 25 | try { 26 | const { data, error } = await supabase 27 | .from('userChats') 28 | .select('conversation') 29 | .eq('userId', userId) 30 | .eq('chatId', chatId); 31 | 32 | if (error) { 33 | console.error(error); 34 | return res 35 | .status(500) 36 | .json({ message: 'An error occurred while retrieving chat.' }); 37 | } 38 | 39 | if (data.length === 0) { 40 | return res.status(404).json({ message: 'Chat not found.' }); 41 | } else if (data.length === 1) { 42 | try { 43 | const new_chat = JSON.parse( 44 | data[0]?.conversation as string 45 | ) as ChatCompletionRequestMessage[]; 46 | if (new_chat === null) { 47 | return res.status(200).json([]); 48 | } 49 | return res.status(200).json(new_chat); 50 | } catch (err) { 51 | console.error(err); 52 | return res 53 | .status(500) 54 | .json({ message: 'An error occurred while parsing chat data.' }); 55 | } 56 | } 57 | } catch (error) { 58 | console.error(error); 59 | return res 60 | .status(500) 61 | .json({ message: 'An error occurred while retrieving chat.' }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/components/drawer/account.tsx: -------------------------------------------------------------------------------- 1 | // Account Model, On Hover it should display the Account Component 2 | 3 | import { useRouter } from 'next/router'; 4 | import { useSupabaseClient } from '@supabase/auth-helpers-react'; 5 | import Profile from './profile'; 6 | import { isMobile } from 'react-device-detect'; 7 | 8 | export default function Account() { 9 | const router = useRouter(); 10 | const supabaseClient = useSupabaseClient(); 11 | 12 | const handleLogout = async () => { 13 | await supabaseClient.auth.signOut(); 14 | void router.push('/'); 15 | }; 16 | 17 | return ( 18 |
24 | 25 |
26 | {isMobile ? ( 27 | 33 | ) : ( 34 | 40 | )} 41 | 42 |
    46 |
  • 47 |
    48 | API coming soon 49 |
    50 |
  • 51 |
  • 52 | 58 |
  • 59 |
  • 60 | 66 |
  • 67 |
68 |
69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/components/utils/login.tsx: -------------------------------------------------------------------------------- 1 | import { Auth } from '@supabase/auth-ui-react'; 2 | import { useUser } from '@supabase/auth-helpers-react'; 3 | import { ThemeSupa } from '@supabase/auth-ui-shared'; 4 | import { isMobile } from 'react-device-detect'; 5 | import { createClient } from '@supabase/supabase-js'; 6 | 7 | export default function Login(props: { chatURL: string }) { 8 | const origin = 9 | typeof window !== 'undefined' && window.location.origin 10 | ? window.location.origin 11 | : ''; 12 | 13 | const supabaseClient = createClient( 14 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 15 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' 16 | ); 17 | 18 | const user = useUser(); 19 | 20 | if (user) { 21 | return null; 22 | } 23 | return ( 24 | <> 25 | {isMobile ? ( 26 | 32 | ) : ( 33 | 39 | )} 40 | 41 | 42 | 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/pages/api/chats/create.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js'; 2 | import { 3 | type NextApiRequest, 4 | type NextApiHandler, 5 | type NextApiResponse 6 | } from 'next'; 7 | import { v4 } from 'uuid'; 8 | 9 | type Query = { 10 | userId: string; 11 | chatId: string; 12 | }; 13 | 14 | const handler: NextApiHandler = async ( 15 | req: NextApiRequest, 16 | res: NextApiResponse 17 | ) => { 18 | const supabase = req.headers.host?.includes('localhost') 19 | ? createClient( 20 | process.env.NEXT_PUBLIC_SUPABASE_URL_DEV || '', 21 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY_DEV || '' 22 | ) 23 | : createClient( 24 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 25 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' 26 | ); 27 | 28 | const { userId, chatId } = req.body as Query; 29 | 30 | // if the current chat is empty, do nothing 31 | const { data: data1, error: error2 } = await supabase 32 | .from('chats') 33 | .select('*') 34 | .eq('chatId', chatId); 35 | if (error2) { 36 | console.log(error2); 37 | res.status(500).json({ message: 'Failed to check current chat' }); 38 | return; 39 | } 40 | if (data1?.length == 0) { 41 | res.status(400).json({ message: 'Current chat is empty' }); 42 | return; 43 | } 44 | 45 | const { data, error } = await supabase 46 | .from('userChats') 47 | .select('*') 48 | .eq('userId', userId); 49 | if (error) { 50 | console.log(error); 51 | res.status(500).json({ message: 'Failed to fetch user chats' }); 52 | return; 53 | } 54 | const names = data.map((chat) => chat.chatName as string); 55 | let chatName = 'New Chat'; 56 | let i = 1; 57 | while (names.includes(chatName)) { 58 | chatName = 'New Chat ' + String(i); 59 | i++; 60 | } 61 | const newChatID = v4(); 62 | 63 | const { error: error1 } = await supabase 64 | .from('userChats') 65 | .insert({ userId: userId, chatId: newChatID, chatName: chatName }); 66 | 67 | if (error1) { 68 | console.log(error1); 69 | res.status(500).json({ message: 'Failed to create new chat' }); 70 | return; 71 | } 72 | res.status(200).json({ newChatID: newChatID }); 73 | }; 74 | 75 | export default handler; 76 | -------------------------------------------------------------------------------- /src/components/drawer/fileComponent.tsx: -------------------------------------------------------------------------------- 1 | import { AiFillFileAdd } from 'react-icons/ai'; 2 | import { BsFillCloudDownloadFill, BsFillTrashFill } from 'react-icons/bs'; 3 | import styles from '~/styles/drawerStyles.module.css'; 4 | import { useState } from 'react'; 5 | 6 | const FileComponent = (props: { 7 | name: string; 8 | url: string; 9 | deleteFile: (url: string) => Promise; 10 | }) => { 11 | const [isHovered, setIsHovered] = useState(false); 12 | return ( 13 |
setIsHovered(true)} 15 | onMouseLeave={() => setIsHovered(false)} 16 | key={props.name} 17 | className={styles.fileItem} 18 | > 19 | 20 |
21 |   {props.name} 22 |
23 | 24 | {isHovered && ( 25 | <> 26 |
27 | {props.url.includes('supabase') ? ( 28 |
29 | 30 | 34 | 35 |
36 | ) : ( 37 |
38 | 39 | 43 | 44 |
45 | )} 46 |
47 |
48 |
49 | { 51 | void props.deleteFile(props.url); 52 | }} 53 | color="hsl(var(--s))" 54 | className="w-10 cursor-pointer bg-base-100" 55 | /> 56 |
57 |
58 | 59 | )} 60 |
61 | ); 62 | }; 63 | 64 | export default FileComponent; 65 | -------------------------------------------------------------------------------- /src/pages/chat.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | import { useEffect, useCallback, useState } from 'react'; 3 | import { useUser } from '@supabase/auth-helpers-react'; 4 | import { v4 } from 'uuid'; 5 | import { useRouter } from 'next/router'; 6 | import { createClient } from '@supabase/supabase-js'; 7 | 8 | const MainChat = () => { 9 | const user = useUser(); 10 | const router = useRouter(); 11 | const [status, setStatus] = useState('loading'); 12 | const origin = 13 | typeof window !== 'undefined' && window.location.origin 14 | ? window.location.origin 15 | : ''; 16 | // Create a single supabase client for interacting with your database 17 | const supabase = origin.includes('localhost') 18 | ? createClient( 19 | process.env.NEXT_PUBLIC_SUPABASE_URL_DEV || '', 20 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY_DEV || '' 21 | ) 22 | : createClient( 23 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 24 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' 25 | ); 26 | 27 | const createNewChat = useCallback(async () => { 28 | const chatID = v4(); 29 | if (user) { 30 | const { data, error } = await supabase 31 | .from('userChats') 32 | .insert({ userId: user.id, chatId: chatID, chatName: 'New Chat' }); 33 | if (error) { 34 | setStatus(error.message); 35 | return; 36 | } 37 | if (data) { 38 | setStatus('success'); 39 | void router.push(`/chat/${chatID}`); 40 | } 41 | } else { 42 | void router.push(`/chat/${chatID}`); 43 | } 44 | }, [router, user]); 45 | 46 | const getUserChats = useCallback(async () => { 47 | if (user) { 48 | const userId = user?.id; 49 | const { data, error } = await supabase 50 | .from('userChats') 51 | .select('*') 52 | .eq('userId', userId); 53 | if (error) { 54 | setStatus(error.message); 55 | } else if (data && data.length > 0) { 56 | const chat_id: string = data[0]?.chatId; 57 | if (chat_id) { 58 | void router.push(`/chat/${chat_id}`); 59 | } 60 | } else if (data && data.length == 0) { 61 | void createNewChat(); 62 | } 63 | } else { 64 | void createNewChat(); 65 | } 66 | }, [createNewChat, router, user]); 67 | 68 | useEffect(() => { 69 | void getUserChats(); 70 | }, [getUserChats]); 71 | 72 | return
{status}
; 73 | }; 74 | 75 | export default MainChat; 76 | -------------------------------------------------------------------------------- /src/components/chat/introModal.tsx: -------------------------------------------------------------------------------- 1 | import { isMobile } from 'react-device-detect'; 2 | 3 | const IntroModal = (props: { setToolTipString: (s: string) => void }) => { 4 | if (isMobile) { 5 | return ( 6 |
7 |
34 | ); 35 | } 36 | return ( 37 |
38 |
63 | ); 64 | }; 65 | 66 | export default IntroModal; 67 | -------------------------------------------------------------------------------- /src/utils/embeddings.ts: -------------------------------------------------------------------------------- 1 | import { OpenAIEmbeddings } from 'langchain/embeddings/openai'; 2 | import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter'; 3 | import { createClient } from '@supabase/supabase-js'; 4 | 5 | async function fetchEmbeddingForObject(url: string) { 6 | const options = { 7 | method: 'POST', 8 | headers: { 'Content-Type': 'application/json' }, 9 | body: JSON.stringify({ url }) 10 | }; 11 | 12 | try { 13 | const response = await fetch( 14 | 'https://docuchat-doc-to-txt-fhpwesohfa-uc.a.run.app/createEmbeddingForObject', 15 | options 16 | ); 17 | const data = (await response.json()) as { text: string }; 18 | return data.text; 19 | } catch (error) { 20 | console.error(error); 21 | return null; 22 | } 23 | } 24 | 25 | export async function processRequest( 26 | url: string, 27 | chatId: string, 28 | name: string, 29 | newDocId: string, 30 | isLocal: boolean 31 | ) { 32 | const embeddings = new OpenAIEmbeddings({ 33 | openAIApiKey: process.env.OPENAI_API_KEY 34 | }); 35 | 36 | const splitter = new RecursiveCharacterTextSplitter({ 37 | chunkSize: 4000, 38 | chunkOverlap: 200 39 | }); 40 | 41 | const supabase = isLocal 42 | ? createClient( 43 | process.env.NEXT_PUBLIC_SUPABASE_URL_DEV || '', 44 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY_DEV || '' 45 | ) 46 | : createClient( 47 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 48 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' 49 | ); 50 | 51 | const text = await fetchEmbeddingForObject(url); 52 | console.log(isLocal) 53 | if (text === null) { 54 | throw new Error('Error'); 55 | } 56 | 57 | const docOutput = await splitter.createDocuments([text]); 58 | const arr: string[] = []; 59 | for (let i = 0; i < docOutput.length; i++) { 60 | const doc = docOutput[i]; 61 | arr.push(`${doc?.pageContent ?? ''}`); 62 | } 63 | 64 | const docEmbeddings = await embeddings.embedDocuments(arr); 65 | console.log(docEmbeddings.length) 66 | const insertPromises = docEmbeddings.map(async (embedding, i) => { 67 | const { error } = await supabase.from('userdocuments').insert({ 68 | url: url, 69 | body: arr[i], 70 | embedding: embedding, 71 | docId: newDocId, 72 | docName: name 73 | }); 74 | // console.log(JSON.stringify({ 75 | // url: url, 76 | // body: arr[i], 77 | // embedding: embedding, 78 | // docId: newDocId, 79 | // docName: name 80 | // })) 81 | if (error) { 82 | console.log(error); 83 | throw new Error(error.message); 84 | } 85 | }); 86 | await Promise.all(insertPromises); 87 | await supabase.from('chats').insert({ 88 | chatId: chatId, 89 | docId: newDocId 90 | }); 91 | } -------------------------------------------------------------------------------- /src/pages/api/chats/delete.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js'; 2 | import { type NextApiRequest, type NextApiResponse } from 'next'; 3 | 4 | type Query = { 5 | chatId: string; 6 | }; 7 | 8 | export default async function deleteChatHandler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | if (req.method !== 'POST') { 13 | return res.status(405).json({ message: 'Method not allowed' }); 14 | } 15 | const supabase = req.headers.host?.includes('localhost') 16 | ? createClient( 17 | process.env.NEXT_PUBLIC_SUPABASE_URL_DEV || '', 18 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY_DEV || '' 19 | ) 20 | : createClient( 21 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 22 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' 23 | ); 24 | 25 | const { chatId } = req.body as Query; 26 | 27 | const { error } = await supabase 28 | .from('userChats') 29 | .delete() 30 | .eq('chatId', chatId); 31 | 32 | if (error) { 33 | console.log(error); 34 | return res.status(500).json({ message: 'Failed to delete chat' }); 35 | } 36 | 37 | // get the files in the chat 38 | const { data, error: error1 } = await supabase 39 | .from('chats') 40 | .select('*') 41 | .eq('chatId', chatId); 42 | 43 | if (error1) { 44 | console.log(error1); 45 | return res.status(500).json({ message: 'Failed to delete chat files' }); 46 | } 47 | 48 | // delete the files in the chat 49 | for (const file of data) { 50 | const docId = file.docId as string; 51 | const { error } = await supabase.from('chats').delete().eq('docId', docId); 52 | 53 | if (error) { 54 | console.log(error); 55 | return res.status(500).json({ message: 'Failed to delete chat files' }); 56 | } 57 | 58 | const { data, error: error2 } = await supabase 59 | .from('chats') 60 | .select('*') 61 | .eq('docId', docId); 62 | 63 | if (data?.length == 0) { 64 | const { error } = await supabase 65 | .from('userdocuments') 66 | .delete() 67 | .eq('docId', docId); 68 | 69 | if (error) { 70 | console.log(error); 71 | return res.status(500).json({ message: 'Failed to delete chat files' }); 72 | } 73 | } 74 | 75 | if (error2) { 76 | console.log(error2); 77 | return res.status(500).json({ message: 'Failed to delete chat files' }); 78 | } 79 | } 80 | 81 | // delete the chat 82 | const { error: error2 } = await supabase 83 | .from('chats') 84 | .delete() 85 | .eq('chatId', chatId); 86 | 87 | if (error2) { 88 | console.log(error2); 89 | return res.status(500).json({ message: 'Failed to delete chat' }); 90 | } 91 | 92 | return res.status(200).json({ message: 'Chat deleted successfully' }); 93 | } 94 | -------------------------------------------------------------------------------- /src/pages/api/upload/getEmbeddingsForText.ts: -------------------------------------------------------------------------------- 1 | import { type NextApiRequest, type NextApiResponse } from 'next'; 2 | import { OpenAIEmbeddings } from 'langchain/embeddings/openai'; 3 | import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter'; 4 | import { createClient } from '@supabase/supabase-js'; 5 | 6 | const embeddings = new OpenAIEmbeddings({ 7 | openAIApiKey: process.env.OPENAI_API_KEY // In Node.js defaults to process.env.OPENAI_API_KEY 8 | }); 9 | 10 | async function fetchEmbeddingForObject(url: string) { 11 | const options = { 12 | method: 'POST', 13 | headers: { 'Content-Type': 'application/json' }, 14 | body: JSON.stringify({ url }) 15 | }; 16 | 17 | try { 18 | const response = await fetch( 19 | 'https://docuchat-doc-to-txt-fhpwesohfa-uc.a.run.app/createEmbeddingForObject', 20 | options 21 | ); 22 | const data = (await response.json()) as { text: string }; 23 | return data.text; 24 | } catch (error) { 25 | console.error(error); 26 | return null; 27 | } 28 | } 29 | 30 | export default async function handler( 31 | req: NextApiRequest, 32 | res: NextApiResponse 33 | ) { 34 | const { url, name, chatId, newDocId, isLocal } = req.body as { 35 | url: string; 36 | name: string; 37 | chatId: string; 38 | newDocId: string; 39 | isLocal: boolean; 40 | }; 41 | 42 | const splitter = new RecursiveCharacterTextSplitter({ 43 | chunkSize: 4000, 44 | chunkOverlap: 200 45 | }); 46 | 47 | const supabase = isLocal 48 | ? createClient( 49 | process.env.NEXT_PUBLIC_SUPABASE_URL_DEV || '', 50 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY_DEV || '' 51 | ) 52 | : createClient( 53 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 54 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' 55 | ); 56 | 57 | const text = await fetchEmbeddingForObject(url); 58 | if (text === null) { 59 | res.status(400).json({ message: 'Error' }); 60 | return; 61 | } 62 | 63 | const docOutput = await splitter.createDocuments([text]); 64 | const arr: string[] = []; 65 | for (let i = 0; i < docOutput.length; i++) { 66 | const doc = docOutput[i]; 67 | arr.push(`${doc?.pageContent ?? ''}`); 68 | } 69 | 70 | const docEmbeddings = await embeddings.embedDocuments(arr); 71 | 72 | const insertPromises = docEmbeddings.map(async (embedding, i) => { 73 | const { error } = await supabase.from('userdocuments').insert({ 74 | url: url, 75 | body: arr[i], 76 | embedding: embedding, 77 | docId: newDocId, 78 | docName: name 79 | }); 80 | if (error) { 81 | console.log(error); 82 | res.status(500).json({ message: error.message }); 83 | } 84 | }); 85 | await Promise.all(insertPromises); 86 | await supabase.from('chats').insert({ 87 | chatId: chatId, 88 | docId: newDocId 89 | }); 90 | res.status(200).json({ message: 'success' }); 91 | } 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Document Chat 2 | 3 | Document Chat is an open source project that enables users to engage in chat discussions centered around documents. Built using React, Next.js, and Supabase, this platform integrates real-time chat functionalities with document management, providing a seamless user experience. 4 | 5 | ![](https://frdnoxefcxhbqneflzqj.supabase.co/storage/v1/object/sign/images/document-chat.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJpbWFnZXMvZG9jdW1lbnQtY2hhdC5wbmciLCJpYXQiOjE3MTM4ODIwOTYsImV4cCI6MTgwMDI4MjA5Nn0.VsiT34VubGzthjCx1RIkg5y1hL8MjIdzurxlFQ0AtCw&t=2024-04-23T14%3A21%3A36.977Z) 6 | 7 | ## Features 8 | 9 | - **Real-Time Chat**: Users can instantly send and receive messages. 10 | - **Document-Centric Discussions**: Each chat can be associated with specific documents to enable focused discussions. 11 | - **Authentication**: Integrated with Supabase Auth to ensure secure access to user-specific chats. 12 | - **Responsive Design**: A fully responsive web interface compatible with various devices. 13 | 14 | ## Technology Stack 15 | 16 | - **Frontend**: React, Next.js 17 | - **Backend**: Supabase (for database and authentication) 18 | - **State Management**: React Hooks 19 | - **Routing**: Next Router 20 | 21 | ## Getting Started 22 | 23 | ### Prerequisites 24 | 25 | - Node.js 26 | - A Supabase account 27 | 28 | ### Installation 29 | 30 | 1. **Clone the repository** 31 | 32 | ```bash 33 | git clone https://github.com/jacobsomer/Document-Chat.git 34 | cd Document-Chat 35 | ``` 36 | 37 | 2. **Install dependencies** 38 | 39 | ```bash 40 | npm install 41 | ``` 42 | 43 | 3. **Set up environment variables** 44 | 45 | Create a `.env.local` file at the root of your project and fill in the following variables according to your Supabase setup: 46 | ``` 47 | NEXT_PUBLIC_SUPABASE_URL=your_supabase_url 48 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key 49 | NEXT_PUBLIC_SUPABASE_URL_DEV=your_supabase_url_for_dev 50 | NEXT_PUBLIC_SUPABASE_ANON_KEY_DEV=your_supabase_anon_key_for_dev 51 | ``` 52 | 53 | 4. **Run the development server** 54 | ```bash 55 | npm run dev 56 | ``` 57 | 58 | Visit `http://localhost:3000` in your browser to see the application in action. 59 | 60 | ## How to Contribute 61 | 62 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. 63 | 64 | 1. Fork the Project 65 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) 66 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) 67 | 4. Push to the Branch (`git push origin feature/AmazingFeature`) 68 | 5. Open a Pull Request 69 | 70 | ## License 71 | 72 | Distributed under the MIT License. See `LICENSE` for more information. 73 | 74 | ## Contact 75 | 76 | Jacob Somer - [@jacob_somer_](https://twitter.com/jacob_somer_) - jsomer@cmu.edu 77 | 78 | [Project Link](https://github.com/jacobsomer/Document-Chat.git) 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatboba", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "postinstall": "prisma generate", 9 | "lint-fix": "npx stylelint '**/*.css' --fix && prettier --config .prettierrc 'src/components/**/*.tsx' --write && prettier --config .prettierrc 'src/**/**/*.tsx' --write && prettier --config .prettierrc 'src/pages/**/*.ts' --write", 10 | "lint": "eslint . --ext .tsx", 11 | "start": "next start" 12 | }, 13 | "dependencies": { 14 | "@prisma/client": "^4.14.0", 15 | "@supabase/auth-helpers-nextjs": "^0.5.6", 16 | "@supabase/auth-helpers-react": "^0.3.1", 17 | "@supabase/auth-ui-react": "^0.4.2", 18 | "@supabase/auth-ui-shared": "^0.1.3", 19 | "@supabase/supabase-js": "^2.10.0", 20 | "@tanstack/react-query": "^4.20.2", 21 | "@trpc/client": "^10.9.0", 22 | "@trpc/next": "^10.9.0", 23 | "@trpc/react-query": "^10.9.0", 24 | "@trpc/server": "^10.9.0", 25 | "@types/marked": "^4.0.8", 26 | "@types/react-highlight": "^0.12.5", 27 | "@types/uuid": "^9.0.1", 28 | "@web-std/file": "^3.0.2", 29 | "async-get-file": "^1.0.4", 30 | "axios": "^1.3.4", 31 | "cheerio": "^1.0.0-rc.12", 32 | "d3-dsv": "2", 33 | "daisyui": "^2.51.3", 34 | "eventsource-parser": "^1.0.0", 35 | "formidable": "v3", 36 | "highlight.js": "^11.7.0", 37 | "langchain": "^0.0.75", 38 | "mammoth": "^1.5.1", 39 | "marked": "^4.2.12", 40 | "next": "^13.2.1", 41 | "openai": "^3.2.1", 42 | "path": "^0.12.7", 43 | "pdf-parse": "^1.1.1", 44 | "puppeteer": "^19.7.2", 45 | "react": "18.2.0", 46 | "react-device-detect": "^2.2.3", 47 | "react-dom": "18.2.0", 48 | "react-highlight": "^0.15.0", 49 | "react-hook-form": "^7.43.5", 50 | "react-icons": "^4.8.0", 51 | "remark": "^14.0.2", 52 | "remark-html": "^15.0.2", 53 | "superjson": "1.9.1", 54 | "uuid": "^9.0.0", 55 | "youtube-transcript": "^1.0.6", 56 | "zod": "^3.20.6" 57 | }, 58 | "devDependencies": { 59 | "@types/eslint": "^8.21.1", 60 | "@types/formidable": "^2.0.6", 61 | "@types/node": "^18.14.0", 62 | "@types/prettier": "^2.7.2", 63 | "@types/react": "^18.0.28", 64 | "@types/react-dom": "^18.0.11", 65 | "@typescript-eslint/eslint-plugin": "^5.53.0", 66 | "@typescript-eslint/parser": "^5.53.0", 67 | "autoprefixer": "^10.4.7", 68 | "compute-cosine-similarity": "^1.0.0", 69 | "eslint": "^8.34.0", 70 | "eslint-config-next": "^13.2.1", 71 | "postcss": "^8.4.14", 72 | "prettier": "^2.8.4", 73 | "prettier-eslint": "^15.0.1", 74 | "prettier-eslint-cli": "^7.1.0", 75 | "prettier-plugin-tailwindcss": "^0.2.1", 76 | "prisma": "^4.14.0", 77 | "stylelint": "^14.14.1", 78 | "stylelint-config-standard": "^29.0.0", 79 | "tailwindcss": "^3.2.0", 80 | "typescript": "^4.9.5" 81 | }, 82 | "ct3aMetadata": { 83 | "initVersion": "7.7.0" 84 | }, 85 | "browser": { 86 | "fs": false, 87 | "path": false, 88 | "os": false 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/components/chat/message.tsx: -------------------------------------------------------------------------------- 1 | // import Image from "next/image" 2 | // import avatar from "../../public/avatar.webp" 3 | import { useEffect } from 'react'; 4 | import { useState } from 'react'; 5 | import { marked } from 'marked'; 6 | import Highlight from 'react-highlight'; 7 | 8 | function CodeSnippet({ value }: { value: string }) { 9 | const [html, setHtml] = useState(''); 10 | 11 | useEffect(() => { 12 | let updatedHtml = ''; 13 | let currCodeBlock = ''; 14 | let openBlock = false; 15 | let language = ''; 16 | 17 | const lines = value.split('\n'); 18 | 19 | const createCodeBlock = (code: string, language: string) => { 20 | // Escape the code block < and > 21 | code = code.replace(//g, '>'); 22 | return `
23 |
${language}
24 |
${code}
25 |
`; 26 | }; 27 | 28 | lines.forEach((line) => { 29 | if (openBlock) { 30 | // If we're in a code block, keep adding to it until we hit the end 31 | if (line.trim().startsWith('```')) { 32 | // If we hit the end of the code block, add it to the html 33 | updatedHtml += createCodeBlock(currCodeBlock, language); 34 | currCodeBlock = ''; 35 | openBlock = false; 36 | } else { 37 | // Otherwise, keep adding to the code block, trim the line 38 | currCodeBlock += line + '\n'; 39 | } 40 | } else { 41 | if (line.trim().startsWith('```')) { 42 | const languageSubstring = line.split('```')[1]; 43 | if (languageSubstring) { 44 | language = languageSubstring.split(' ')[0] || ''; 45 | } 46 | 47 | openBlock = true; 48 | } else { 49 | updatedHtml += `
${marked( 50 | line.trim() 51 | )}
`; 52 | } 53 | } 54 | }); 55 | 56 | if (currCodeBlock.length) { 57 | updatedHtml += createCodeBlock(currCodeBlock, language); 58 | } 59 | 60 | setHtml(updatedHtml); 61 | }, [value]); 62 | 63 | return {html}; 64 | } 65 | 66 | export const BotMessage = ({ msg }: { msg: { content: string } }) => { 67 | return ( 68 |
69 |
70 | 71 |
72 |
73 | ); 74 | }; 75 | 76 | export const UserMessage = ({ msg }: { msg: { content: string } }) => { 77 | return ( 78 |
79 | {/*
80 |
81 | avatar 82 |
83 |
*/} 84 |
85 |

{msg.content}

86 |
87 |
88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /src/env.mjs: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | /** 4 | * Specify your server-side environment variables schema here. This way you can ensure the app isn't 5 | * built with invalid env vars. 6 | */ 7 | const server = z.object({ 8 | DATABASE_URL: z.string().url(), 9 | OPENAI_API_KEY: z.string(), 10 | NODE_ENV: z.enum(["development", "test", "production"]), 11 | NEXT_PUBLIC_SUPABASE_URL: z.string().url(), 12 | NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string(), 13 | SUPABASE_SERVICE_ROLE_KEY: z.string(), 14 | }); 15 | 16 | /** 17 | * Specify your client-side environment variables schema here. This way you can ensure the app isn't 18 | * built with invalid env vars. To expose them to the client, prefix them with `NEXT_PUBLIC_`. 19 | */ 20 | const client = z.object({ 21 | // NEXT_PUBLIC_CLIENTVAR: z.string().min(1), 22 | }); 23 | 24 | /** 25 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. 26 | * middlewares) or client-side so we need to destruct manually. 27 | * 28 | * @type {Record | keyof z.infer, string | undefined>} 29 | */ 30 | const processEnv = { 31 | DATABASE_URL: process.env.DATABASE_URL, 32 | OPENAI_API_KEY: process.env.OPENAI_API_KEY, 33 | NODE_ENV: process.env.NODE_ENV, 34 | NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL, 35 | NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, 36 | SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY, 37 | // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, 38 | }; 39 | 40 | // Don't touch the part below 41 | // -------------------------- 42 | 43 | const merged = server.merge(client); 44 | 45 | /** @typedef {z.input} MergedInput */ 46 | /** @typedef {z.infer} MergedOutput */ 47 | /** @typedef {z.SafeParseReturnType} MergedSafeParseReturn */ 48 | 49 | let env = /** @type {MergedOutput} */ (process.env); 50 | 51 | if (!!process.env.SKIP_ENV_VALIDATION == false) { 52 | const isServer = typeof window === "undefined"; 53 | 54 | const parsed = /** @type {MergedSafeParseReturn} */ ( 55 | isServer 56 | ? merged.safeParse(processEnv) // on server we can validate all env vars 57 | : client.safeParse(processEnv) // on client we can only validate the ones that are exposed 58 | ); 59 | 60 | if (parsed.success === false) { 61 | console.error( 62 | "❌ Invalid environment variables:", 63 | parsed.error.flatten().fieldErrors, 64 | ); 65 | throw new Error("Invalid environment variables"); 66 | } 67 | 68 | env = new Proxy(parsed.data, { 69 | get(target, prop) { 70 | if (typeof prop !== "string") return undefined; 71 | // Throw a descriptive error if a server-side env var is accessed on the client 72 | // Otherwise it would just be returning `undefined` and be annoying to debug 73 | if (!isServer && !prop.startsWith("NEXT_PUBLIC_")) 74 | throw new Error( 75 | process.env.NODE_ENV === "production" 76 | ? "❌ Attempted to access a server-side environment variable on the client" 77 | : `❌ Attempted to access server-side environment variable '${prop}' on the client`, 78 | ); 79 | return target[/** @type {keyof typeof target} */ (prop)]; 80 | }, 81 | }); 82 | } 83 | 84 | export { env }; 85 | -------------------------------------------------------------------------------- /src/utils/openAIStream.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-argument */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 4 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 5 | import { 6 | createParser, 7 | type ParsedEvent, 8 | type ReconnectInterval, 9 | } from "eventsource-parser"; 10 | import { ChatCompletionRequestMessage } from "openai"; 11 | 12 | export type ChatGPTAgent = "user" | "system"; 13 | 14 | export interface ChatGPTMessage { 15 | role: ChatGPTAgent; 16 | content: string; 17 | } 18 | 19 | export interface OpenAIStreamPayload { 20 | model: string; 21 | messages: ChatCompletionRequestMessage[]; 22 | temperature: number; 23 | top_p: number; 24 | frequency_penalty: number; 25 | presence_penalty: number; 26 | max_tokens: number; 27 | stream: boolean; 28 | n: number; 29 | } 30 | 31 | export async function OpenAIStream(payload: OpenAIStreamPayload) { 32 | const encoder = new TextEncoder(); 33 | const decoder = new TextDecoder(); 34 | 35 | let counter = 0; 36 | 37 | const res = await fetch("https://api.openai.com/v1/chat/completions", { 38 | headers: { 39 | "Content-Type": "application/json", 40 | Authorization: `Bearer ${process.env.OPENAI_API_KEY ?? ""}`, 41 | }, 42 | method: "POST", 43 | body: JSON.stringify(payload), 44 | }); 45 | 46 | const stream = new ReadableStream({ 47 | async start(controller) { 48 | // callback 49 | function onParse(event: ParsedEvent | ReconnectInterval) { 50 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 51 | if (event.type === "event") { 52 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access 53 | const data = event.data; 54 | // https://beta.openai.com/docs/api-reference/completions/create#completions/create-stream 55 | if (data === "[DONE]") { 56 | controller.close(); 57 | return; 58 | } 59 | try { 60 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 61 | const json = JSON.parse(data); 62 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 63 | const text = json.choices[0].delta?.content || ""; 64 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 65 | if (counter < 2 && (text.match(/\n/) || []).length) { 66 | // this is a prefix character (i.e., "\n\n"), do nothing 67 | return; 68 | } 69 | const queue = encoder.encode(text); 70 | controller.enqueue(queue); 71 | counter++; 72 | } catch (e) { 73 | // maybe parse error 74 | controller.error(e); 75 | } 76 | } 77 | } 78 | 79 | // stream response (SSE) from OpenAI may be fragmented into multiple chunks 80 | // this ensures we properly read chunks and invoke an event for each SSE event stream 81 | const parser = createParser(onParse); 82 | // https://web.dev/streams/#asynchronous-iteration 83 | for await (const chunk of res.body as any) { 84 | parser.feed(decoder.decode(chunk)); 85 | } 86 | }, 87 | }); 88 | 89 | return stream; 90 | } -------------------------------------------------------------------------------- /src/pages/api/upload/handleUrlUpload.ts: -------------------------------------------------------------------------------- 1 | import { type NextApiRequest, type NextApiResponse } from 'next'; 2 | import { CheerioWebBaseLoader } from 'langchain/document_loaders/web/cheerio'; 3 | import { PuppeteerWebBaseLoader } from 'langchain/document_loaders/web/puppeteer'; 4 | import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter'; 5 | import { OpenAIEmbeddings } from 'langchain/embeddings/openai'; 6 | import { v4 } from 'uuid'; 7 | import { createClient } from '@supabase/supabase-js'; 8 | import { YoutubeTranscript } from 'youtube-transcript'; 9 | 10 | const embeddings = new OpenAIEmbeddings({ 11 | openAIApiKey: process.env.OPENAI_API_KEY // In Node.js defaults to process.env.OPENAI_API_KEY 12 | }); 13 | 14 | type UrlUploadBody = { 15 | chatId: string; 16 | url: string; 17 | }; 18 | 19 | export default async function handler( 20 | req: NextApiRequest, 21 | res: NextApiResponse 22 | ) { 23 | try { 24 | const { chatId, url } = req.body as UrlUploadBody; 25 | 26 | const supabase = req.headers.host?.includes('localhost') 27 | ? createClient( 28 | process.env.NEXT_PUBLIC_SUPABASE_URL_DEV || '', 29 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY_DEV || '' 30 | ) 31 | : createClient( 32 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 33 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' 34 | ); 35 | 36 | const { data: userDocs, error: userDocsError } = await supabase 37 | .from('userdocuments') 38 | .select('*') 39 | .eq('url', url); 40 | 41 | if (!userDocsError && userDocs && userDocs.length > 0 && userDocs[0]) { 42 | const docId = userDocs[0].docId as string; 43 | const { error: insertError } = await supabase.from('chats').insert({ 44 | chatId: chatId, 45 | docId: docId 46 | }); 47 | if (insertError) { 48 | res.status(500).json({ message: insertError.message }); 49 | } else { 50 | res.status(200).json({ message: 'Success' }); 51 | } 52 | } else { 53 | const docArr: string[] = []; 54 | const splitter = new RecursiveCharacterTextSplitter({ 55 | chunkSize: 4000, 56 | chunkOverlap: 200 57 | }); 58 | let docOutput: any[] = []; 59 | 60 | if (url.includes('youtu')) { 61 | const transcript = await YoutubeTranscript.fetchTranscript(url); 62 | let text = ''; 63 | transcript.forEach((item) => { 64 | text += item.text + ' '; 65 | }); 66 | docOutput = await splitter.splitText(text) 67 | 68 | } 69 | else { 70 | const cheerLoader = new CheerioWebBaseLoader(url); 71 | const docs = await cheerLoader.load(); 72 | docOutput = await splitter.splitDocuments(docs); 73 | } 74 | 75 | if (docOutput.length == 0) { 76 | const puppeteerLoader = new PuppeteerWebBaseLoader(url); 77 | const puppeteer_docs = await puppeteerLoader.load(); 78 | const puppeteer_splitter = new RecursiveCharacterTextSplitter({ 79 | chunkSize: 4000, 80 | chunkOverlap: 200 81 | }); 82 | const puppeteer_docOutput = await puppeteer_splitter.splitDocuments( 83 | puppeteer_docs 84 | ); 85 | if (puppeteer_docOutput.length == 0) { 86 | res.status(500).json({ error: 'Nothing Found For URL: ' + url }); 87 | return; 88 | } 89 | for (let i = 0; i < puppeteer_docOutput.length; i++) { 90 | const puppeteer_doc = puppeteer_docOutput[i]; 91 | docArr.push(`${puppeteer_doc?.pageContent ?? ''}`); 92 | } 93 | } else { 94 | for (let i = 0; i < docOutput.length; i++) { 95 | const doc = docOutput[i] as string | { pageContent: string } 96 | const docString = typeof doc === 'string' ? doc : doc?.pageContent; 97 | docArr.push(`${docString ?? ''}`); 98 | } 99 | const docEmbeddings = await embeddings.embedDocuments(docArr); 100 | const newDocId = v4(); 101 | const insertPromises = docEmbeddings.map(async (embedding, i) => { 102 | await supabase.from('userdocuments').insert({ 103 | url: url, 104 | body: docArr[i], 105 | embedding: embedding, 106 | docId: newDocId, 107 | docName: url 108 | }); 109 | }); 110 | await Promise.all(insertPromises); 111 | await supabase.from('chats').insert({ 112 | chatId: chatId, 113 | docId: newDocId 114 | }); 115 | 116 | res.status(200).json({ message: 'File uploaded successfully' }); 117 | } 118 | } 119 | } catch (err) { 120 | res.status(400).json({ 121 | message: JSON.stringify((err as { message: string }).message) 122 | }); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/pages/api/chats/get.ts: -------------------------------------------------------------------------------- 1 | import { type NextApiRequest, type NextApiResponse } from 'next'; 2 | import { type ChatFile, type File, type UserChat } from '~/types/types'; 3 | import { createClient } from '@supabase/supabase-js'; 4 | 5 | type Query = { 6 | chatId: string; 7 | userId: string | undefined; 8 | }; 9 | 10 | export default async function handler( 11 | req: NextApiRequest, 12 | res: NextApiResponse 13 | ) { 14 | const supabase = req.headers.host?.includes('localhost') 15 | ? createClient( 16 | process.env.NEXT_PUBLIC_SUPABASE_URL_DEV || '', 17 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY_DEV || '' 18 | ) 19 | : createClient( 20 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 21 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' 22 | ); 23 | const { chatId, userId } = req.body as Query; 24 | if (!chatId || chatId.length !== 36) { 25 | res.status(400).json({ message: 'Invalid chatId' }); 26 | return; 27 | } 28 | 29 | try { 30 | if (userId && userId.length !== 36) { 31 | res.status(400).json({ message: 'Invalid userId' }); 32 | return; 33 | } 34 | 35 | // if chatID belongs to user, make sure userID matches the one in the database 36 | const { data: chatUsersData, error: chatUsersError } = await supabase 37 | .from('userChats') 38 | .select('*') 39 | .eq('chatId', chatId); 40 | 41 | if (chatUsersError) { 42 | res 43 | .status(500) 44 | .json({ message: 'An error occurred while retrieving chat.' }); 45 | return; 46 | } 47 | 48 | if (chatUsersData.length > 0 && !userId) { 49 | res.status(200).json({ message: 'No Chats Available' }); 50 | return; 51 | } 52 | 53 | if (chatUsersData.length > 0 && userId) { 54 | const chatUser = chatUsersData.find((chat) => chat.userId === userId); 55 | if (!chatUser) { 56 | res.status(400).json({ message: 'Invalid userId3' + userId }); 57 | return; 58 | } 59 | } 60 | 61 | const { data: chatData, error: chatError } = await supabase 62 | .from('chats') 63 | .select('*') 64 | .eq('chatId', chatId); 65 | 66 | if (chatError) { 67 | res 68 | .status(500) 69 | .json({ message: 'An error occurred while retrieving chat.' }); 70 | return; 71 | } 72 | 73 | const files: File[] = []; 74 | let name = ''; 75 | for (const file of chatData) { 76 | const chat = file as ChatFile; 77 | if (!name) { 78 | name = chat.docName; 79 | } 80 | const docId = chat.docId; 81 | const { data: docData, error: docError } = await supabase 82 | .from('userdocuments') 83 | .select('url, docId, docName, body, embedding') 84 | .eq('docId', docId); 85 | 86 | if (docError) { 87 | throw docError; 88 | } 89 | 90 | for (const file1 of docData) { 91 | const file2 = file1 as File; 92 | if (files.find((file) => file.docId === file2.docId)) { 93 | continue; 94 | } 95 | files.push(file2); 96 | } 97 | } 98 | 99 | let currentChat: UserChat; 100 | let userChats: UserChat[]; 101 | if (userId) { 102 | const { data: userChatsData, error: userChatsError } = await supabase 103 | .from('userChats') 104 | .select('*') 105 | .eq('userId', userId); 106 | 107 | if (userChatsError) { 108 | throw userChatsError; 109 | } 110 | 111 | const chat = userChatsData.find((chat) => chat.chatId === chatId); 112 | 113 | if (chat) { 114 | currentChat = chat as UserChat; 115 | } else { 116 | const names = userChatsData.map((chat) => chat.chatName as string); 117 | let chatName = 'New Chat'; 118 | let i = 1; 119 | while (names.includes(chatName)) { 120 | chatName = `New Chat ${i}`; 121 | i++; 122 | } 123 | 124 | currentChat = { 125 | chatId: chatId, 126 | chatName: chatName 127 | }; 128 | 129 | const { error: upsertError } = await supabase.from('userChats').upsert({ 130 | chatId: chatId, 131 | userId: userId, 132 | chatName: chatName 133 | }); 134 | 135 | if (upsertError) { 136 | throw upsertError; 137 | } 138 | } 139 | userChats = userChatsData.map((chat) => chat as UserChat); 140 | } else { 141 | currentChat = { 142 | chatId: chatId, 143 | chatName: 'New Chat' 144 | }; 145 | userChats = [currentChat]; 146 | } 147 | 148 | res.status(200).json({ 149 | currentChat: currentChat, 150 | files: files, 151 | userChats: userChats 152 | }); 153 | } catch (error) { 154 | console.error(error); 155 | res.status(500).json({ message: 'Internal server error' }); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/pages/api/upload/handleFileUpload.ts: -------------------------------------------------------------------------------- 1 | import { type NextApiRequest, type NextApiResponse } from 'next'; 2 | // you might want to use regular 'fs' and not a promise one 3 | import { CSVLoader } from 'langchain/document_loaders/fs/csv'; 4 | import { TextLoader } from 'langchain/document_loaders/fs/text'; 5 | import { PDFLoader } from 'langchain/document_loaders/fs/pdf'; 6 | import { DocxLoader } from 'langchain/document_loaders/fs/docx'; 7 | import { v4 } from 'uuid'; 8 | import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter'; 9 | import { UnstructuredLoader } from 'langchain/document_loaders/fs/unstructured'; 10 | import { JSONLoader } from 'langchain/document_loaders/fs/json'; 11 | import { OpenAIEmbeddings } from 'langchain/embeddings/openai'; 12 | import { createClient } from '@supabase/supabase-js'; 13 | import fs from 'fs'; 14 | import { supportedExtensions } from '~/utils/consts'; 15 | import { processRequest } from '~/utils/embeddings'; 16 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires 17 | const get = require('async-get-file'); 18 | 19 | const embeddings = new OpenAIEmbeddings({ 20 | openAIApiKey: process.env.OPENAI_API_KEY // In Node.js defaults to process.env.OPENAI_API_KEY 21 | }); 22 | 23 | type FileUploadBody = { 24 | chatId: string; 25 | name: string; 26 | extension: string; 27 | url: string; 28 | }; 29 | 30 | export default async function handler( 31 | req: NextApiRequest, 32 | res: NextApiResponse 33 | ) { 34 | if (req.method !== 'POST') { 35 | res.status(400).json({ message: 'Invalid method' }); 36 | return; 37 | } 38 | 39 | const { url, chatId, name, extension } = req.body as FileUploadBody; 40 | if (!supportedExtensions.includes(extension)) { 41 | res.status(400).json({ message: 'Invalid file extension' }); 42 | return; 43 | } 44 | 45 | const isLocal = req.headers.host?.includes('localhost'); 46 | 47 | const supabase = isLocal 48 | ? createClient( 49 | process.env.NEXT_PUBLIC_SUPABASE_URL_DEV || '', 50 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY_DEV || '' 51 | ) 52 | : createClient( 53 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 54 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' 55 | ); 56 | 57 | if ( 58 | extension === 'pptx' || 59 | extension === 'ppt' || 60 | extension === 'xls' || 61 | extension === 'xlsx' || 62 | extension === 'docx' 63 | ) { 64 | const newDocId = v4(); 65 | try { 66 | await processRequest(url, chatId, name, newDocId, isLocal || false); 67 | console.log('Hello'); 68 | res.status(200).json({ message: 'File uploaded successfully' }); 69 | } catch (err) { 70 | res.status(400).json({ message: 'File upload failed' }); 71 | } 72 | return; 73 | } 74 | 75 | // download file from url 76 | const response = await fetch(url); 77 | if (!response.ok) { 78 | res.status(400).json({ message: 'File upload failed1' }); 79 | return; 80 | } 81 | 82 | const options = { 83 | directory: './tmp/', 84 | filename: name + '.' + extension 85 | }; 86 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 87 | await get(url, options); 88 | 89 | const filePath = `./tmp/${name}.${extension}`; 90 | 91 | const newDocId = v4(); 92 | let loader; 93 | try { 94 | if (extension === 'csv') { 95 | loader = new CSVLoader(filePath); 96 | } else if (extension === 'docx') { 97 | loader = new DocxLoader(filePath); 98 | } else if (extension === 'pdf') { 99 | loader = new PDFLoader(filePath); 100 | } else if (extension === 'txt') { 101 | loader = new TextLoader(filePath); 102 | } else if (extension === 'json') { 103 | loader = new JSONLoader(filePath); 104 | } else if (supportedExtensions.includes(extension)) { 105 | loader = new UnstructuredLoader(filePath); 106 | } 107 | } catch (err) { 108 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 109 | // do nothing and test for loader 110 | // delete tmp 111 | fs.unlinkSync(filePath); 112 | res.status(400).json({ message: 'Loader Error' }); 113 | } 114 | 115 | if (!loader) { 116 | // delete file 117 | fs.unlinkSync(filePath); 118 | res.status(400).json({ message: 'Invalid file extension' }); 119 | } else { 120 | try { 121 | const docs = await loader.load(); 122 | const splitter = new RecursiveCharacterTextSplitter({ 123 | chunkSize: 4000, 124 | chunkOverlap: 200 125 | }); 126 | 127 | const docOutput = await splitter.splitDocuments(docs); 128 | const arr: string[] = []; 129 | for (let i = 0; i < docOutput.length; i++) { 130 | const doc = docOutput[i]; 131 | arr.push(`${doc?.pageContent ?? ''}`); 132 | } 133 | 134 | const docEmbeddings = await embeddings.embedDocuments(arr); 135 | 136 | const insertPromises = docEmbeddings.map(async (embedding, i) => { 137 | await supabase.from('userdocuments').insert({ 138 | url: url, 139 | body: arr[i], 140 | embedding: embedding, 141 | docId: newDocId, 142 | docName: name 143 | }); 144 | }); 145 | await Promise.all(insertPromises); 146 | await supabase.from('chats').insert({ 147 | chatId: chatId, 148 | docId: newDocId 149 | }); 150 | fs.unlinkSync(filePath); 151 | res.status(200).json({ message: 'File uploaded successfully' }); 152 | } catch (err) { 153 | res.status(400).json({ 154 | message: JSON.stringify((err as { message: string }).message) 155 | }); 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/components/drawer/profile.tsx: -------------------------------------------------------------------------------- 1 | import { type SetStateAction, useState } from 'react'; 2 | import { useSupabaseClient, useUser } from '@supabase/auth-helpers-react'; 3 | import { MdModeEdit } from 'react-icons/md'; 4 | 5 | const Profile = () => { 6 | const user = useUser(); 7 | const [isEditing, setIsEditing] = useState(false); 8 | const [newFullName, setNewFullName] = useState(''); 9 | const [isEditingEmail, setIsEditingEmail] = useState(false); 10 | const [newEmail, setNewEmail] = useState(''); 11 | 12 | const supabaseClient = useSupabaseClient(); 13 | 14 | const handleEditClick = () => { 15 | setIsEditing(true); 16 | setNewFullName((user?.user_metadata.full_name as string) || ''); 17 | }; 18 | 19 | const handleSaveClick = async () => { 20 | // TODO: Call API to update full name 21 | setIsEditing(false); 22 | const { data, error } = await supabaseClient.auth.updateUser({ 23 | data: { full_name: newFullName } 24 | }); 25 | if (error) { 26 | console.log(error); 27 | return; 28 | } 29 | console.log(data); 30 | }; 31 | 32 | const handleCancelClick = () => { 33 | setIsEditing(false); 34 | }; 35 | 36 | const handleFullNameChange = (event: { 37 | target: { value: SetStateAction }; 38 | }) => { 39 | setNewFullName(event.target.value); 40 | }; 41 | 42 | const handleEditEmailClick = () => { 43 | setIsEditingEmail(true); 44 | setNewEmail(user?.email as string); 45 | }; 46 | 47 | const handleSaveEmailClick = async () => { 48 | const { data, error } = await supabaseClient.auth.updateUser({ 49 | email: newEmail 50 | }); 51 | if (error) { 52 | console.log(error); 53 | return; 54 | } 55 | console.log(data); 56 | setIsEditingEmail(false); 57 | }; 58 | 59 | const handleCancelEmailClick = () => { 60 | setIsEditingEmail(false); 61 | }; 62 | 63 | const handleEmailChange = (event: { 64 | target: { value: SetStateAction }; 65 | }) => { 66 | setNewEmail(event.target.value); 67 | }; 68 | 69 | return ( 70 | <> 71 | {user && ( 72 | <> 73 | 74 |
75 |
76 | 82 |
83 |
84 | Full Name: 85 |
86 | {isEditing ? ( 87 | <> 88 | 94 | 100 | 106 | 107 | ) : ( 108 | <> 109 |
110 | {user.user_metadata.full_name || 'N/A'} 111 |
112 | 116 | 117 | )} 118 |
119 |
120 | Email: 121 |
122 | {isEditingEmail ? ( 123 | <> 124 | 130 | 136 | 142 | 143 | ) : ( 144 | <> 145 |
146 | {user.email} 147 |
148 | 152 | 153 | )} 154 |
155 |
156 |
157 |
158 |
159 | 160 | )} 161 | 162 | ); 163 | }; 164 | 165 | export default Profile; 166 | -------------------------------------------------------------------------------- /src/pages/chat/[chatId].tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import Chat from '~/components/chat/chat'; 3 | import { useUser } from '@supabase/auth-helpers-react'; 4 | import { useRouter } from 'next/router'; 5 | import { type UserChat, type File } from '~/types/types'; 6 | import { v4 } from 'uuid'; 7 | 8 | const ChatRoom = () => { 9 | const user = useUser(); 10 | const [currentChat, setCurrentChat] = useState(null); 11 | const router = useRouter(); 12 | const [files, setFiles] = useState([]); 13 | const [userChats, setUserChats] = useState(undefined); 14 | const [finishedLoading, setFinishedLoading] = useState(false); 15 | 16 | type ChatRoomProps = { 17 | currentChat: UserChat; 18 | files: File[]; 19 | userChats: UserChat[]; 20 | }; 21 | 22 | const updateFiles = useCallback( 23 | async (chatId: string) => { 24 | const url = '/api/chats/get'; 25 | try { 26 | const res = await fetch(url, { 27 | method: 'POST', 28 | body: JSON.stringify({ 29 | chatId: chatId, 30 | userId: user ? user.id : undefined 31 | }), 32 | headers: { 33 | 'Content-Type': 'application/json' 34 | } 35 | }); 36 | if (res.status == 200) { 37 | const data = (await res.json()) as ChatRoomProps; 38 | setFiles(data.files); 39 | } else { 40 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 41 | const data = (await res.json()) as { message: string }; 42 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 43 | if (data.message == 'Invalid userId') { 44 | // void router.push('/chat/' + v4()); 45 | } 46 | } 47 | } catch (err) { 48 | console.log(err); 49 | } 50 | }, 51 | [user] 52 | ); 53 | 54 | const updateChat = useCallback( 55 | async (chatId: string) => { 56 | setFinishedLoading(false); 57 | const url = '/api/chats/get'; 58 | try { 59 | const res = await fetch(url, { 60 | method: 'POST', 61 | body: JSON.stringify({ 62 | chatId: chatId, 63 | userId: user ? user.id : undefined 64 | }), 65 | headers: { 66 | 'Content-Type': 'application/json' 67 | } 68 | }); 69 | if (res.status == 200) { 70 | const data = (await res.json()) as ChatRoomProps; 71 | setCurrentChat(data.currentChat); 72 | setFiles(data.files); 73 | setUserChats(data.userChats); 74 | setFinishedLoading(true); 75 | } else { 76 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 77 | const data = (await res.json()) as { message: string }; 78 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 79 | if (data.message == 'Invalid userId') { 80 | // void router.push('/chat/' + v4()); 81 | } 82 | } 83 | } catch (err) { 84 | console.log(err); 85 | } 86 | }, 87 | [user] 88 | ); 89 | 90 | useEffect(() => { 91 | const chat_id = window.location.pathname.split('/')[2]; 92 | console.log('useEffect Running'); 93 | if (!chat_id) { 94 | if (!user) { 95 | void router.push('/chat/' + v4()); 96 | } else { 97 | void router.push('/chat/' + v4()); 98 | } 99 | } else { 100 | void updateChat(chat_id); 101 | } 102 | }, [router, updateChat, user]); 103 | 104 | const deleteFile = async (docId: string) => { 105 | if (!currentChat) { 106 | return; 107 | } 108 | 109 | const response = await fetch(`/api/chats/deletefile`, { 110 | method: 'POST', 111 | headers: { 112 | 'Content-Type': 'application/json' 113 | }, 114 | body: JSON.stringify({ docId: docId }) 115 | }); 116 | 117 | if (!response.ok) { 118 | const error = (await response.json()) as { message: string }; 119 | console.log(error.message); 120 | return; 121 | } 122 | 123 | setFiles(files.filter((file) => file.docId != docId)); 124 | }; 125 | 126 | const createNewChat = async () => { 127 | if (!user || !currentChat) { 128 | return; 129 | } 130 | 131 | const userId = user.id; 132 | const chatId = currentChat.chatId; 133 | 134 | const res = await fetch('/api/chats/create', { 135 | method: 'POST', 136 | headers: { 137 | 'Content-Type': 'application/json' 138 | }, 139 | body: JSON.stringify({ userId, chatId }) 140 | }); 141 | if (!res.ok) { 142 | const error = (await res.json()) as { message: string }; 143 | console.log(error.message); 144 | return; 145 | } 146 | const { newChatID } = (await res.json()) as { newChatID: string }; 147 | await router.push('/chat/' + newChatID); 148 | }; 149 | 150 | const deleteChat = async () => { 151 | if (!user || !userChats || !currentChat) { 152 | return; 153 | } 154 | if (userChats.length <= 1) { 155 | alert('You cannot delete your last chat'); 156 | return; 157 | } 158 | 159 | const res = await fetch('/api/chats/delete', { 160 | method: 'POST', 161 | headers: { 162 | 'Content-Type': 'application/json' 163 | }, 164 | body: JSON.stringify({ chatId: currentChat.chatId }) 165 | }); 166 | 167 | if (!res.ok) { 168 | const error = (await res.json()) as { message: string }; 169 | console.log(error.message); 170 | return; 171 | } 172 | 173 | // redirect to another chat that is not the one being deleted 174 | const newChat = userChats.find((chat) => chat.chatId != currentChat.chatId); 175 | if (newChat) { 176 | void router.push('/chat/' + newChat.chatId); 177 | } 178 | }; 179 | 180 | const renameChat = async (newName: string) => { 181 | if (!user || !currentChat) { 182 | return; 183 | } 184 | 185 | const res = await fetch('/api/chats/rename', { 186 | method: 'PUT', 187 | headers: { 188 | 'Content-Type': 'application/json' 189 | }, 190 | body: JSON.stringify({ chatId: currentChat.chatId, newName }) 191 | }); 192 | 193 | if (!res.ok) { 194 | setCurrentChat({ 195 | chatId: currentChat.chatId, 196 | chatName: newName 197 | }); 198 | } 199 | void updateChat(currentChat.chatId); 200 | }; 201 | 202 | return ( 203 | <> 204 | {currentChat && files && finishedLoading ? ( 205 | 216 | ) : ( 217 | // is loading 218 |

219 | Loading... 220 |

221 | )} 222 | 223 | ); 224 | }; 225 | 226 | export default ChatRoom; 227 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/drawer/chatSettings.tsx: -------------------------------------------------------------------------------- 1 | import { useUser } from '@supabase/auth-helpers-react'; 2 | import { useState } from 'react'; 3 | import { AiOutlineSetting, AiOutlineDelete } from 'react-icons/ai'; 4 | import { MdDriveFileRenameOutline } from 'react-icons/md'; 5 | import { RxReset } from 'react-icons/rx'; 6 | import { BiMessageSquareAdd } from 'react-icons/bi'; 7 | import { isMobile } from 'react-device-detect'; 8 | 9 | type AccountProps = { 10 | handleClearSubmit: (e: React.MouseEvent) => void; 11 | createNewChat: () => Promise; 12 | deleteChat: () => Promise; 13 | renameChat: (newName: string) => Promise; 14 | }; 15 | 16 | export default function Account(props: AccountProps) { 17 | const user = useUser(); 18 | const [newChatName, setNewChatName] = useState(''); 19 | 20 | if (isMobile) { 21 | return ( 22 | <> 23 | {/* Rename chat modal */} 24 | 25 | 61 | 62 | 63 | 82 |
88 |
89 | 95 |
    99 | {user && ( 100 | <> 101 | 108 | 114 | 121 | 122 | )} 123 |
  • 124 | 131 |
  • 132 |
133 |
134 |
135 | 136 | ); 137 | } 138 | 139 | return ( 140 | <> 141 | {/* Rename chat modal */} 142 | 143 | 170 | 171 | 172 | 191 |
197 |
198 | 204 |
    208 | {user && ( 209 | <> 210 | 217 | 223 | 230 | 231 | )} 232 |
  • 233 | 240 |
  • 241 |
242 |
243 |
244 | 245 | ); 246 | } 247 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { type NextPage } from 'next'; 2 | import { useEffect } from 'react'; 3 | import Head from 'next/head'; 4 | import { useUser } from '@supabase/auth-helpers-react'; 5 | import Login from '~/components/utils/login'; 6 | import Image from 'next/image'; 7 | import { Roboto } from 'next/font/google'; 8 | import Router from 'next/router'; 9 | import { useRef } from 'react'; 10 | import { isMobile } from 'react-device-detect'; 11 | import { HiChevronRight } from 'react-icons/hi'; 12 | 13 | const mukta = Roboto({ 14 | weight: '500', 15 | style: 'normal', 16 | subsets: ['latin-ext'] 17 | }); 18 | 19 | const Home: NextPage = () => { 20 | const user = useUser(); 21 | const myRef = useRef(null); 22 | const executeScroll = () => { 23 | if (myRef.current) { 24 | myRef.current.scrollIntoView(); 25 | } 26 | }; 27 | 28 | return ( 29 | <> 30 | 31 | ChatBoba 32 | 36 | 41 | 47 | 53 | 54 | 55 | 56 | 57 | 58 |
59 |
60 |
61 |
72 | About 73 |
74 |
85 | Docs 86 |
87 | {!user && ( 88 |
89 | 90 |
91 | )} 92 |
93 | 94 |
void Router.push('/')} 97 | > 98 | Chat Boba Logo 104 | {isMobile ? ( 105 |

ChatBoba

106 | ) : ( 107 |

ChatBoba

108 | )} 109 |
110 |
111 |

112 | Chat with{' '} 113 | 114 | Docs,Youtube Videos, and More 115 | 116 |

117 |

118 | Create a chat room with any data source. Connect your data sources 119 | and 10x your writing productivity. 120 |

121 |
122 | {isMobile ? ( 123 | 132 | ) : ( 133 | 146 | )} 147 |
148 |
149 |
150 |
151 |

152 | How it Works 153 |

154 |

1. Add Media

155 | Chat Boba Logo 161 |

2. Chat

162 | Chat Boba Logo 168 |
169 |
170 | 243 |
244 | 245 | ); 246 | }; 247 | 248 | export default Home; 249 | -------------------------------------------------------------------------------- /src/components/drawer/addMedia.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import UploadSquare from './uploadSquare'; 3 | import { supportedExtensions } from '~/utils/consts'; 4 | import { FiUpload } from 'react-icons/fi'; 5 | import { type AddMediaProps } from '~/types/types'; 6 | import { isMobile } from 'react-device-detect'; 7 | import { createClient } from '@supabase/supabase-js'; 8 | 9 | const cleanFileName = (fileName: string) => { 10 | // replace any characters that are not letters, numbers, dashes, spaces, or underscores with an underscore 11 | return fileName.replace(/[^a-zA-Z0-9-_]/g, '_'); 12 | }; 13 | 14 | const AddMedia = (props: AddMediaProps) => { 15 | const [loading, setLoading] = useState(false); 16 | const [errorMessage, setErrorMessage] = useState(''); 17 | const [input, setInput] = useState(''); 18 | const [loadingForAWhile, setLoadingForAWhile] = useState(false); 19 | 20 | const origin = 21 | typeof window !== 'undefined' && window.location.origin 22 | ? window.location.origin 23 | : ''; 24 | const isLocal = origin.includes('localhost'); 25 | // Create a single supabase client for interacting with your database 26 | const supabase = isLocal 27 | ? createClient( 28 | process.env.NEXT_PUBLIC_SUPABASE_URL_DEV || '', 29 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY_DEV || '' 30 | ) 31 | : createClient( 32 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 33 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' 34 | ); 35 | 36 | const removeErrorMessageAfter4Seconds = () => { 37 | setLoading(false); 38 | setTimeout(() => { 39 | setErrorMessage(''); 40 | setLoadingForAWhile(false); 41 | setLoading(false); 42 | const closeModal = document.getElementById('closeModal'); 43 | if (closeModal) { 44 | closeModal.click(); 45 | } 46 | }, 4000); 47 | }; 48 | 49 | const uploadFile = async ( 50 | file: File, 51 | name: string, 52 | extension: string 53 | ): Promise => { 54 | // upload file to supabase storage 55 | const { data, error } = await supabase.storage 56 | .from('media') 57 | .upload(`userFiles/${props.chatId}/${name}.${extension}`, file, { 58 | cacheControl: '3600', 59 | upsert: true 60 | }); 61 | if (error && !error.message.includes('The resource already exists')) { 62 | console.log(error.message); 63 | return 'Error'; 64 | } 65 | let url = ''; 66 | if (data) { 67 | url = data.path; 68 | } else { 69 | url = `userFiles/${props.chatId}/${name}.${extension}`; 70 | } 71 | const baseStorageUrl = 72 | (isLocal 73 | ? 'https://eyoguhfgkfmjnjpcwblg.supabase.co' 74 | : 'https://gsaywynqkowtwhnyrehr.supabase.co') + 75 | '/storage/v1/object/public/media/'; 76 | url = baseStorageUrl + url; 77 | return url; 78 | }; 79 | 80 | const handleFileUpload = async ( 81 | event: React.ChangeEvent 82 | ) => { 83 | setLoading(true); 84 | setLoadingForAWhile(false); 85 | setTimeout(() => { 86 | setLoadingForAWhile(true); 87 | }, 10000); 88 | const file = event.target.files?.[0]; 89 | 90 | if (file) { 91 | const extension = file.name.split('.').pop(); 92 | 93 | if (!extension || !supportedExtensions.includes(extension)) { 94 | setErrorMessage( 95 | 'FileType is not one of: ' + supportedExtensions.toString() 96 | ); 97 | removeErrorMessageAfter4Seconds(); 98 | return; 99 | } 100 | 101 | // get file name 102 | const name = file.name.split('.').slice(0, -1).join('.'); 103 | 104 | const cleaned_name = cleanFileName(name); 105 | 106 | const url = await uploadFile(file, cleaned_name, extension); 107 | if (url === 'Error') { 108 | setErrorMessage('Error uploading file'); 109 | removeErrorMessageAfter4Seconds(); 110 | return; 111 | } 112 | 113 | const enpointURL = `/api/upload/handleFileUpload`; 114 | let resp = null; 115 | try { 116 | const res = await fetch(enpointURL, { 117 | method: 'POST', 118 | body: JSON.stringify({ 119 | url: url, 120 | chatId: props.chatId, 121 | name: cleaned_name, 122 | extension: extension 123 | }), 124 | headers: { 125 | 'Content-Type': 'application/json' 126 | } 127 | }); 128 | 129 | resp = (await res.json()) as { message: string }; 130 | 131 | if (resp.message === 'File uploaded successfully') { 132 | void props.updateFiles(props.chatId); 133 | const closeModal = document.getElementById('closeModal'); 134 | if (closeModal) { 135 | closeModal.click(); 136 | } 137 | setLoading(false); 138 | setLoadingForAWhile(false); 139 | } 140 | } catch (e) { 141 | console.log(e); 142 | setErrorMessage('Error with API'); 143 | removeErrorMessageAfter4Seconds(); 144 | setLoading(false); 145 | setLoadingForAWhile(false); 146 | return; 147 | } 148 | } 149 | }; 150 | 151 | const handleUrlUpload = async ( 152 | event: React.MouseEvent 153 | ): Promise => { 154 | event.preventDefault(); 155 | setLoading(true); 156 | setLoadingForAWhile(false); 157 | setTimeout(() => { 158 | setLoadingForAWhile(true); 159 | }, 10000); 160 | const enpointURL = '/api/upload/handleUrlUpload'; 161 | const response = await fetch(enpointURL, { 162 | method: 'POST', 163 | headers: { 164 | 'Content-Type': 'application/json' 165 | }, 166 | body: JSON.stringify({ url: input, chatId: props.chatId }) 167 | }); 168 | if (!response.ok) { 169 | setErrorMessage('Error with API'); 170 | removeErrorMessageAfter4Seconds(); 171 | setLoading(false); 172 | setLoadingForAWhile(false); 173 | return; 174 | } 175 | const resp = (await response.json()) as { message: string }; 176 | if (resp.message === 'File uploaded successfully') { 177 | await props.updateFiles(props.chatId); 178 | const closeModal = document.getElementById('closeModal'); 179 | if (closeModal) { 180 | closeModal.click(); 181 | } 182 | setLoading(false); 183 | setLoadingForAWhile(false); 184 | return; 185 | } 186 | }; 187 | 188 | return ( 189 | <> 190 | {isMobile ? ( 191 | 201 | ) : ( 202 | 212 | )} 213 | 214 | 215 |
216 |
217 | {isMobile ? ( 218 | <> 219 | 226 | 227 |

Press To Add Data

228 | 229 | ) : ( 230 | <> 231 | 238 |

Add Data

239 | 240 | )} 241 | 242 |
OR
243 |
244 |
245 | {isMobile ? ( 246 | <> 247 |

Enter URL:

248 |
249 | 255 | setInput((e.target as HTMLTextAreaElement).value) 256 | } 257 | /> 258 | 265 |
266 |

267 | Supported URLs include, youtube videos, wikipedia articles, 268 | news, and more. 269 |

270 | 271 | ) : ( 272 | <> 273 |

Enter URL

274 |
275 | 281 | setInput((e.target as HTMLTextAreaElement).value) 282 | } 283 | /> 284 | 291 |
292 |

293 | Supported URLs include, youtube videos, wikipedia articles, 294 | news, and more. 295 |

296 | 297 | )} 298 | 299 | {loading && } 300 | {errorMessage && ( 301 |

302 | {errorMessage} 303 |

304 | )} 305 | {loading && !loadingForAWhile && ( 306 |

Loading...

307 | )} 308 | {loadingForAWhile && loading && ( 309 |

310 | Please wait as this may take a few moments... 311 |

312 | )} 313 |
314 |
315 |
316 |
317 |
318 | 319 | ); 320 | }; 321 | 322 | export default AddMedia; 323 | -------------------------------------------------------------------------------- /src/components/drawer/drawerContent.tsx: -------------------------------------------------------------------------------- 1 | import { useUser } from '@supabase/auth-helpers-react'; 2 | import Account from './account'; 3 | import AddMedia from '~/components/drawer/addMedia'; 4 | import Login from '../utils/login'; 5 | import { type MouseEvent, useEffect, useState } from 'react'; 6 | import { type DrawerProps } from '~/types/types'; 7 | import { useRouter } from 'next/router'; 8 | import ChatSettings from './chatSettings'; 9 | import { HiChevronRight, HiSelector } from 'react-icons/hi'; 10 | import FileComponent from './fileComponent'; 11 | import IntroModal from '../chat/introModal'; 12 | import { isMobile } from 'react-device-detect'; 13 | 14 | export const DrawerContent = (props: DrawerProps) => { 15 | const user = useUser(); 16 | const router = useRouter(); 17 | const [toolTipString, setToolTipString] = useState(''); 18 | const [width, setWidth] = useState(250); 19 | const [isDragging, setIsDragging] = useState(false); 20 | 21 | const alternateChatLength = props.userChats?.map( 22 | (chat) => chat.chatId !== props.currentChat.chatId 23 | ).length; 24 | 25 | useEffect(() => { 26 | const handleMouseMove = (event: { clientX: number; clientY: number }) => { 27 | if (isDragging) { 28 | setWidth(Math.min(Math.max(event.clientX, 0), 800)); 29 | } 30 | }; 31 | 32 | const handleMouseUp = () => { 33 | setIsDragging(false); 34 | }; 35 | 36 | window.addEventListener('mousemove', handleMouseMove); 37 | window.addEventListener('mouseup', handleMouseUp); 38 | 39 | return () => { 40 | window.removeEventListener('mousemove', handleMouseMove); 41 | window.removeEventListener('mouseup', handleMouseUp); 42 | }; 43 | }, [isDragging, props.files.length, props.userChats, user, width]); 44 | 45 | if (isMobile) { 46 | return ( 47 |
48 |
49 | {user ? ( 50 |
51 |
56 | {alternateChatLength && alternateChatLength > 1 ? ( 57 |
58 | 64 | 86 |
87 | ) : ( 88 |
89 | {/* {props.currentChat.chatName} */} 90 |
91 | )} 92 |
93 |
94 | ) : ( 95 |
96 | {props.currentChat.chatName} 97 |
98 | )} 99 |
100 | 101 | { 102 | // If user is not logged in, display intro modal 103 | !user && 104 | } 105 |
3 ? 'scroll' : undefined, 114 | msOverflowStyle: 'none', 115 | scrollbarWidth: 'none', 116 | color: 'hsl(var(--bc))', 117 | fontSize: 'large' 118 | }} 119 | > 120 |
121 | 122 | {props.files.length === 0 ? ( 123 | <> 124 |
No files yet!
125 | 126 | ) : ( 127 | props.files.map((file) => ( 128 | props.deleteFile(file.docId)} 133 | /> 134 | )) 135 | )} 136 |
137 | 138 |
149 |
161 | {user ? ( 162 | <> 163 | 169 | 174 | 175 | 176 | 177 | ) : ( 178 | <> 179 |
184 | 189 |
190 |
195 | 196 |
197 |
198 | 199 | )} 200 |
201 |
202 |
203 | ); 204 | } 205 | 206 | const handleDrag = (e: MouseEvent) => { 207 | const newWidth = width + e.movementX; 208 | if (newWidth >= 0 && newWidth <= 800) { 209 | setWidth(newWidth); 210 | setIsDragging(true); 211 | } 212 | }; 213 | 214 | return ( 215 |
223 |
{ 233 | if (e.buttons === 1) { 234 | handleDrag(e); 235 | } 236 | }} 237 | className={`absolute h-[80vh] w-[24px] bg-[${ 238 | isDragging ? 'hsl(var(--b3))' : 'hsl(var(--b1))' 239 | }] rounded-full hover:bg-[hsl(var(--b3))]`} 240 | > 241 |
246 | 247 |
248 |
249 |
256 | {user ? ( 257 |
258 |
261 | {alternateChatLength && alternateChatLength > 1 ? ( 262 |
263 | 266 | 288 |
289 | ) : ( 290 |
291 | {props.currentChat.chatName} 292 |
293 | )} 294 |
295 |
296 | ) : ( 297 |
298 | {props.currentChat.chatName} 299 |
300 | )} 301 |
302 | 303 | { 304 | // If user is not logged in, display intro modal 305 | !user && 306 | } 307 |
3 ? 'scroll' : undefined, 316 | msOverflowStyle: 'none', 317 | scrollbarWidth: 'none', 318 | color: 'hsl(var(--bc))', 319 | fontSize: 'large', 320 | maxWidth: width.toString() + 'px', 321 | overflow: 'hidden' 322 | }} 323 | > 324 |
My Files
325 | {props.files.length === 0 ? ( 326 | <> 327 |
No files yet!
328 | 329 | ) : ( 330 | props.files.map((file) => ( 331 | props.deleteFile(file.docId)} 336 | /> 337 | )) 338 | )} 339 |
340 | 341 |
352 |
363 | {user ? ( 364 | <> 365 | 371 | 376 | 377 | 378 | 379 | ) : ( 380 | <> 381 |
386 | 391 |
392 |
397 | 398 |
399 |
400 | 401 | )} 402 |
403 |
404 |
405 | ); 406 | }; 407 | 408 | export default DrawerContent; 409 | -------------------------------------------------------------------------------- /src/components/chat/chat.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react'; 2 | import type { 3 | ChatCompletionRequestMessage, 4 | ChatCompletionRequestMessageRoleEnum 5 | } from 'openai'; 6 | import type { CompletionRequest, OaiModel } from '~/pages/api/stream'; 7 | import { BotMessage, UserMessage } from '~/components/chat/message'; 8 | import DrawerContent from '~/components/drawer/drawerContent'; 9 | import { type SearchResponse, type ChatProps } from '~/types/types'; 10 | import { MdOutlineDarkMode } from 'react-icons/md'; 11 | import { useRouter } from 'next/router'; 12 | import { useUser } from '@supabase/auth-helpers-react'; 13 | import { GiHamburgerMenu } from 'react-icons/gi'; 14 | import { isMobile } from 'react-device-detect'; 15 | import { Mukta } from 'next/font/google'; 16 | import Image from 'next/image'; 17 | import Head from 'next/head'; 18 | 19 | const mukta = Mukta({ 20 | weight: '500', 21 | style: 'normal', 22 | subsets: ['latin'] 23 | }); 24 | 25 | const model: OaiModel = 'gpt-3.5-turbo'; 26 | 27 | const initMessages: ChatCompletionRequestMessage[] = [ 28 | { 29 | role: 'system', 30 | content: `You are a helpful assistant named ChatBoba powered by GPT-4, the newest model by OpenAI.` 31 | } 32 | ]; 33 | 34 | const scrollToBottom = (element: HTMLElement) => { 35 | element.scroll({ 36 | behavior: 'auto', 37 | top: element.scrollHeight 38 | }); 39 | }; 40 | 41 | const createMessage = ( 42 | content: string, 43 | role: ChatCompletionRequestMessageRoleEnum 44 | ): ChatCompletionRequestMessage => { 45 | return { 46 | content, 47 | role 48 | }; 49 | }; 50 | 51 | const Chat = (props: ChatProps) => { 52 | const ref = useRef(null); 53 | const [messages, setMessages] = 54 | useState(initMessages); 55 | const [input, setInput] = useState(''); 56 | const [theme, setTheme] = useState<'light' | 'dark' | 'none'>('none'); 57 | const [themeButtonIsHovered, setThemeButtonIsHovered] = useState(false); 58 | const [loadingText, setLoadingText] = useState(''); 59 | const [drawerIsOpened, setDrawerIsOpened] = useState(true); 60 | const [drawerOpenedOneTime, setDrawerOpenedOneTime] = useState(false); 61 | const router = useRouter(); 62 | const user = useUser(); 63 | const [count, setCount] = useState(0); 64 | 65 | const handleScroll = useCallback(() => { 66 | if (ref.current) { 67 | scrollToBottom(ref.current); 68 | } 69 | }, []); 70 | 71 | const getChat = useCallback(async () => { 72 | if (!user) { 73 | return; 74 | } 75 | 76 | if (count >= 1) { 77 | return; 78 | } 79 | setCount(count + 1); 80 | 81 | try { 82 | const res = await fetch(`/api/chats/getConversation`, { 83 | method: 'POST', 84 | headers: { 85 | 'Content-Type': 'application/json' 86 | }, 87 | body: JSON.stringify({ 88 | userId: user.id, 89 | chatId: props.currentChat.chatId 90 | }) 91 | }); 92 | 93 | if (!res.ok) { 94 | return; 95 | } 96 | const new_chat = (await res.json()) as ChatCompletionRequestMessage[]; 97 | if (new_chat === null) { 98 | return; 99 | } 100 | setMessages(new_chat); 101 | } catch (error) { 102 | console.error(error); 103 | } 104 | }, [count, props.currentChat.chatId, user]); 105 | 106 | const saveChat = useCallback( 107 | async (ret: ChatCompletionRequestMessage[]) => { 108 | if (!user) { 109 | return; 110 | } 111 | 112 | const conversation = JSON.stringify(ret); 113 | 114 | try { 115 | await fetch('/api/chats/save', { 116 | method: 'POST', 117 | body: JSON.stringify({ 118 | userId: user.id, 119 | chatId: props.currentChat.chatId, 120 | conversation 121 | }), 122 | headers: { 'Content-Type': 'application/json' } 123 | }); 124 | } catch (error) { 125 | console.error(error); 126 | } 127 | }, 128 | [user, props.currentChat.chatId] 129 | ); 130 | 131 | const getAndUpdateTheme = useCallback(async () => { 132 | if (!user) { 133 | if (theme === 'none') { 134 | setTheme('light'); 135 | } 136 | return; 137 | } 138 | 139 | const apiUrl = '/api/theme/getAndUpdateTheme'; 140 | const response = await fetch(apiUrl, { 141 | method: 'POST', 142 | headers: { 143 | 'Content-Type': 'application/json' 144 | }, 145 | body: JSON.stringify({ 146 | userId: user.id 147 | }) 148 | }); 149 | const resp = (await response.json()) as { 150 | message: string | undefined; 151 | theme: string | undefined; 152 | }; 153 | 154 | if (resp.message) { 155 | console.log(resp.message); 156 | return; 157 | } 158 | if (resp.theme) { 159 | setTheme(resp.theme as 'light' | 'dark'); 160 | } 161 | }, [user, theme]); 162 | 163 | useEffect(() => { 164 | handleScroll(); 165 | void getAndUpdateTheme(); 166 | void getChat(); 167 | 168 | if (isMobile && !drawerOpenedOneTime) { 169 | setDrawerIsOpened(false); 170 | setDrawerOpenedOneTime(true); 171 | } 172 | }, [ 173 | handleScroll, 174 | getChat, 175 | saveChat, 176 | getAndUpdateTheme, 177 | drawerIsOpened, 178 | drawerOpenedOneTime 179 | ]); 180 | 181 | const getDataSources = async (prompt: string): Promise => { 182 | // set chat to repeated loading state ... 183 | setLoadingText('Loading ...'); 184 | 185 | const url = 186 | 'https://docuchat-embeddings-search-fhpwesohfa-ue.a.run.app/searchChatRoom'; 187 | const response = await fetch(url, { 188 | method: 'POST', 189 | headers: { 190 | 'Content-Type': 'application/json' 191 | }, 192 | body: JSON.stringify({ 193 | query: prompt, 194 | chatId: props.currentChat.chatId 195 | }) 196 | }); 197 | if (!response.ok) { 198 | console.error(response.statusText); 199 | } 200 | const data = (await response.json()) as SearchResponse; 201 | return data; 202 | }; 203 | 204 | 205 | const stream = async (input: string) => { 206 | const newUserMessage: ChatCompletionRequestMessage = { 207 | content: input, 208 | role: 'user' 209 | }; 210 | 211 | let dataSources: string[] = []; 212 | if (props.files.length != 0) { 213 | dataSources = (await getDataSources(input)).body.slice(0, 3); 214 | } 215 | 216 | const completionRequestBody: CompletionRequest = { 217 | messages: messages.concat([newUserMessage]), 218 | dataSources: dataSources, 219 | model: model 220 | }; 221 | 222 | const response = await fetch('/api/stream', { 223 | method: 'POST', 224 | headers: { 225 | 'Content-Type': 'application/json' 226 | }, 227 | body: JSON.stringify(completionRequestBody) 228 | }); 229 | 230 | if (!response.ok) { 231 | console.error(response.statusText); 232 | } 233 | setLoadingText(''); 234 | 235 | // This data is a ReadableStream 236 | const data = response.body; 237 | if (!data) { 238 | return; 239 | } 240 | 241 | const reader = data.getReader(); 242 | const decoder = new TextDecoder(); 243 | let done = false; 244 | 245 | while (!done) { 246 | const { value, done: doneReading } = await reader.read(); 247 | done = doneReading; 248 | 249 | const chunkValue = decoder.decode(value); 250 | 251 | try { 252 | const jsns = chunkValue 253 | for (const jsn of jsns) { 254 | const text: string = jsn 255 | 256 | setMessages((prevMessages) => { 257 | const last = 258 | prevMessages[prevMessages.length - 1] || 259 | createMessage('', 'assistant'); 260 | 261 | const ret = [ 262 | ...prevMessages.slice(0, -1), 263 | { ...last, content: last.content + text } 264 | ]; 265 | 266 | if (done) { 267 | void saveChat(ret); 268 | } 269 | return ret; 270 | }); 271 | } 272 | handleScroll(); 273 | } catch (e) { 274 | console.log(e); 275 | } 276 | void saveChat(messages); 277 | } 278 | }; 279 | 280 | async function submit() { 281 | setMessages((prevMessages) => { 282 | const newMessages = [ 283 | ...prevMessages, 284 | createMessage(input, 'user'), 285 | createMessage('', 'assistant') 286 | ]; 287 | 288 | return newMessages; 289 | }); 290 | 291 | // const textInput = input; 292 | setInput(''); 293 | await stream(input); 294 | // if user, update messages for user chat room 295 | } 296 | const handleKeyDown = (e: React.KeyboardEvent) => { 297 | if (e.key === 'Enter' && !e.shiftKey) { 298 | e.preventDefault(); 299 | void submit(); 300 | } 301 | }; 302 | 303 | function handleSubmit(e: React.MouseEvent) { 304 | e.preventDefault(); 305 | void submit(); 306 | } 307 | 308 | function handleClearSubmit(e: React.MouseEvent) { 309 | e.preventDefault(); 310 | setMessages(initMessages); 311 | void saveChat(initMessages); 312 | } 313 | 314 | const setUserTheme = async (theme: 'light' | 'dark') => { 315 | if (!user) { 316 | setTheme(theme); 317 | return; 318 | } 319 | 320 | const response = await fetch('/api/theme/setTheme', { 321 | method: 'POST', 322 | headers: { 323 | 'Content-Type': 'application/json' 324 | }, 325 | body: JSON.stringify({ 326 | userId: user.id, 327 | theme: theme 328 | }) 329 | }); 330 | if (!response.ok) { 331 | console.error(response.statusText); 332 | } 333 | 334 | setTheme(theme); 335 | }; 336 | 337 | if (isMobile) { 338 | return ( 339 | <> 340 | 341 | 342 | 347 | 353 | 359 | 360 | 365 | 366 | 367 | 368 | 369 | {theme !== 'none' && ( 370 |
371 |
372 | {drawerIsOpened && ( 373 | 384 | )} 385 | 386 |
387 | 396 |
397 |
void router.push('/')} 400 | > 401 | Chat Boba Logo 407 |

408 | ChatBoba as 409 |

410 |
411 | Beta 0.0.13 412 |
413 |
414 |
415 |
416 | {theme === 'dark' ? ( 417 |
418 | {themeButtonIsHovered ? ( 419 | setThemeButtonIsHovered(true)} 423 | onMouseLeave={() => setThemeButtonIsHovered(false)} 424 | onClick={() => void setUserTheme('light')} 425 | /> 426 | ) : ( 427 | setThemeButtonIsHovered(true)} 431 | onMouseLeave={() => setThemeButtonIsHovered(false)} 432 | onClick={() => void setUserTheme('light')} 433 | /> 434 | )} 435 |
436 | ) : ( 437 |
438 | {themeButtonIsHovered ? ( 439 | setThemeButtonIsHovered(true)} 443 | onMouseLeave={() => setThemeButtonIsHovered(false)} 444 | onClick={() => void setUserTheme('dark')} 445 | /> 446 | ) : ( 447 | setThemeButtonIsHovered(true)} 451 | onMouseLeave={() => setThemeButtonIsHovered(false)} 452 | onClick={() => void setUserTheme('dark')} 453 | /> 454 | )} 455 |
456 | )} 457 |
458 |
467 |
    468 | {messages.map((msg, i) => { 469 | switch (msg.role) { 470 | case 'assistant': 471 | return ( 472 |
  • 473 | 474 |
  • 475 | ); 476 | case 'user': 477 | return ( 478 |
  • 479 | 480 |
  • 481 | ); 482 | case 'system': 483 | return; 484 | } 485 | })} 486 | {loadingText} 487 |
488 |
489 |
490 | setInput(e.target.value)} 496 | onKeyDown={handleKeyDown} 497 | /> 498 | 501 |
502 |
503 |
504 |
505 | )} 506 | 507 | ); 508 | } 509 | 510 | return ( 511 | <> 512 | {theme !== 'none' && ( 513 |
514 |
515 | 526 |
527 |
528 |
void router.push('/')} 531 | > 532 | Chat Boba Logo 538 |

539 | ChatBoba 540 |

541 |
Beta 0.0.13
542 |
543 |
544 |
545 | {theme === 'dark' ? ( 546 |
547 | {themeButtonIsHovered ? ( 548 | setThemeButtonIsHovered(true)} 552 | onMouseLeave={() => setThemeButtonIsHovered(false)} 553 | onClick={() => void setUserTheme('light')} 554 | /> 555 | ) : ( 556 | setThemeButtonIsHovered(true)} 560 | onMouseLeave={() => setThemeButtonIsHovered(false)} 561 | onClick={() => void setUserTheme('light')} 562 | /> 563 | )} 564 |
565 | ) : ( 566 |
567 | {themeButtonIsHovered ? ( 568 | setThemeButtonIsHovered(true)} 572 | onMouseLeave={() => setThemeButtonIsHovered(false)} 573 | onClick={() => void setUserTheme('dark')} 574 | /> 575 | ) : ( 576 | setThemeButtonIsHovered(true)} 580 | onMouseLeave={() => setThemeButtonIsHovered(false)} 581 | onClick={() => void setUserTheme('dark')} 582 | /> 583 | )} 584 |
585 | )} 586 |
587 |
596 |
    597 | {messages.map((msg, i) => { 598 | switch (msg.role) { 599 | case 'assistant': 600 | return ( 601 |
  • 602 | 603 |
  • 604 | ); 605 | case 'user': 606 | return ( 607 |
  • 608 | 609 |
  • 610 | ); 611 | case 'system': 612 | return; 613 | } 614 | })} 615 | {loadingText} 616 |
617 |
618 |
619 | setInput(e.target.value)} 625 | onKeyDown={handleKeyDown} 626 | /> 627 | 628 | 631 |
632 |
633 |
634 |
635 | )} 636 | 637 | ); 638 | }; 639 | 640 | export default Chat; 641 | --------------------------------------------------------------------------------