├── .env.example ├── .eslintrc.json ├── .github └── workflows │ └── codeql.yml ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── app ├── api │ ├── openai │ │ ├── chat-title │ │ │ └── route.ts │ │ ├── chat │ │ │ └── route.ts │ │ ├── check │ │ │ └── route.ts │ │ └── embedding │ │ │ └── route.ts │ └── supabase │ │ ├── history │ │ └── route.ts │ │ └── message │ │ └── route.ts ├── chat │ ├── [id] │ │ └── page.tsx │ ├── layout.tsx │ └── page.tsx ├── favicon.ico ├── globals.css ├── layout.tsx ├── login │ ├── login-form.tsx │ └── page.tsx ├── opengraph-image.jpg └── page.tsx ├── atoms ├── chat.ts └── navigation.ts ├── components ├── brand │ └── logo.tsx ├── chat │ ├── chat-input.tsx │ ├── chat-settings-menu.tsx │ ├── chat.tsx │ ├── chatbox.tsx │ ├── chats.tsx │ ├── message.tsx │ ├── messages.tsx │ ├── new-chat-current.tsx │ └── new-chat.tsx ├── navigation │ ├── mobile-menu-button.tsx │ ├── profile-menu.tsx │ ├── sidebar-overlay.tsx │ ├── sidebar.tsx │ └── sidelink.tsx ├── providers │ ├── jotai-provider.tsx │ ├── openai-key-provider.tsx │ ├── openai-serverkey-provider.tsx │ └── theme-provider.tsx └── ui │ ├── avatar.tsx │ ├── button.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── label.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── seperator.tsx │ ├── spinner.tsx │ ├── textarea-default.tsx │ ├── textarea.tsx │ └── tooltip.tsx ├── hooks ├── useChat.ts └── useChats.ts ├── lib ├── openai-stream.ts ├── openai.ts └── supabase │ ├── supabase-auth-provider.tsx │ ├── supabase-browser.ts │ ├── supabase-provider.tsx │ └── supabase-server.ts ├── middleware.ts ├── next.config.js ├── package.json ├── postcss.config.js ├── public ├── favicon.png ├── login-gradient.jpg ├── makr-logo-dark.svg ├── makr-logo-light.svg ├── makr.-avatar.png ├── readme-hero.jpg ├── supabase_schema.png └── user-avatar.png ├── sql ├── create-index.sql ├── create-profile.sql ├── create-tables.sql └── search-messages.sql ├── supabase ├── .gitignore ├── config.toml └── seed.sql ├── tailwind.config.js ├── tsconfig.json ├── types ├── collections.ts ├── openai.ts └── supabase.ts ├── utils ├── cn.ts └── helpers.ts └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | # External APIs: 2 | OPENAI_API_KEY=changeme 3 | NEXT_PUBLIC_SUPABASE_URL=changeme 4 | NEXT_PUBLIC_SUPABASE_ANON_KEY=changeme 5 | NEXT_PUBLIC_AUTH_REDIRECT_URL=http://localhost:3000/chat 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '20 4 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Use only 'java' to analyze code written in Java, Kotlin or both 38 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 39 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v3 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v2 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | 54 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 55 | # queries: security-extended,security-and-quality 56 | 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@v2 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 65 | 66 | # If the Autobuild fails above, remove it and uncomment the following three lines. 67 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 68 | 69 | # - run: | 70 | # echo "Run, Build Application using script" 71 | # ./location_of_script_within_repo/buildscript.sh 72 | 73 | - name: Perform CodeQL Analysis 74 | uses: github/codeql-action/analyze@v2 75 | with: 76 | category: "/language:${{matrix.language}}" 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # makr.AI 2 | 3 | makr.AI is a ChatGPT clone with enhanced features for makers & indie hackers built on top of using Next.js, TypeScript, Supabase, Jotai and Tailwind CSS. 4 | 5 | ![makr.AI](./public/readme-hero.jpg) 6 | 7 | Check this [Youtube video](https://youtu.be/yrXLvCB0ByA) to learn more. 8 | 9 | ## Roadmap 10 | 11 | I'll be building new features over time. If you have any suggestions, feel free to open a discussion or reach out to me on [Twitter](https://twitter.com/makrdev). I listed the features I'm working on next below. 12 | 13 | **What to expect:** 14 | 15 | - [x] Long-term Memory for Conversations (Supabase's Vector Database) - 14.04.23 16 | - [ ] Propmt Library 17 | - [ ] Organising Conversations with Folders etc. 18 | - [ ] Collections Library for saving responses 19 | - [ ] Plugins ecosystem with GPT agents 20 | 21 | ## Deploy 22 | 23 | **Vercel** 24 | 25 | Host your own live version of makr.AI with Vercel. 26 | 27 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/batuhanbilginn/makr-ai) 28 | 29 | ## Running Locally 30 | 31 | **1. Create a Supabase Project** 32 | The application holds conversations in a Supabase database. You can create a free account [here](https://supabase.io/). 33 | 34 | makr.AI needs a Supabase URL and Anon Key to connect to your database. You can find these in your Supabase project settings. 35 | 36 | You must create 3 tables in your supabase project: 37 | 38 | ![makr.AI](./public/supabase_schema.png) 39 | 40 | You can create all the tables you need with the `sql function` that I cretead in the `sql` folder of the repo. You can use the `create-tables.sql` file in the `sql` folder of the repo to create the tables. Remember that you must enable the `pg_vector` extension before creating the tables. 41 | 42 | You can use `create-profile.sql` to automatically create user profiles on sign up. 43 | 44 | After you create the embedding column, you should create an `index` based on this column. You can use the `create-index.sql` file in the `sql` folder of the repo to create the index. 45 | 46 | Finally, you must create a sql function called `search-messages` in your Supabase project. You can use the `search-messages.sql` file in the `sql` folder of the repo to create the function. 47 | 48 | Project Setup [tutorial](https://youtu.be/yrXLvCB0ByA). 49 | Longterm Memory for ChatGPT [tutorial](https://youtu.be/trReGNOh2oM). 50 | 51 | **2. Clone The Repo** 52 | 53 | ```bash 54 | git clone https://github.com/batuhanbilginn/makr-ai.git 55 | ``` 56 | 57 | **3. Install Dependencies** 58 | 59 | ```bash 60 | yarn install 61 | ``` 62 | 63 | **4. Create Your Enviroment Variables** 64 | 65 | Create your .env.local file in the root of the repo with your Supabase URL, Supabase Anon Key, Auth Redirect URL and OpenAI API Key: 66 | 67 | ```bash 68 | NEXT_PUBLIC_SUPABASE_URL=YOUR_URL *required 69 | NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_KEY *required 70 | NEXT_PUBLIC_AUTH_REDIRECT_URL=YOUR_URL *required 71 | OPENAI_API_KEY=YOUR_KEY *optional 72 | ``` 73 | 74 | **4.1 Creating .env File** 75 | 76 | 1. Locate the file named `.env.example` in the main folder. 77 | 2. Create a copy of this file, called `.env` by removing the `template` extension. The easiest way is to do this in a command prompt/terminal window `cp .env.example .env`. 78 | 3. Open the `.env` file in a text editor. _Note: Files starting with a dot might be hidden by your Operating System._ 79 | 4. Change environment variables as needed. 80 | 5. Save and close the `.env` file. 81 | 82 | **4.2 OpenAI API Key** 83 | 84 | When you set your `OpenAI API Key` as an environment variable, the application will not prompt you to enter it again to initialize itself. BE CAREFUL if you set your `OpenAI API Key` as an environment variable and host it anybody who accesses your hosted version can use it. If you don't have an `OpenAI API Key`, you can get one [here](https://platform.openai.com/account/api-keys). 85 | 86 | **4.3 Auth Redirect URL** 87 | 88 | You must set your `Auth Redirect URL` on production as environment variable. If you don't set it, you will get an error when you try to sign in. Also, make sure you have the right `Auth Redirect URL` set in your Supabase project settings and in your environment variables that you created for Vercel. If you have different `Auth Redirect URL` for preview and production, you can set them as environment variables in Vercel and Supabase. 89 | 90 | **5. Run Development Server** 91 | 92 | ```bash 93 | yarn dev 94 | ``` 95 | 96 | ## Configuration 97 | 98 | When deploying the application, the following environment variables can be set: 99 | 100 | | Environment Variable | Required | Description | 101 | | ----------------------------- | ---------------- | -------------------------------------- | 102 | | NEXT_PUBLIC_SUPABASE_URL | Yes | The base url of your Supabase Project | 103 | | NEXT_PUBLIC_SUPABASE_ANON_KEY | Yes | The Anon Key for your Supabase Project | 104 | | NEXT_PUBLIC_AUTH_REDIRECT_URL | Yes (Production) | The base url of your Supabase Project | 105 | | OPENAI_API_KEY | Optional | The Anon Key for your Supabase Project | 106 | 107 | If you don't have an OpenAI API key, you can get one [here](https://platform.openai.com/account/api-keys). 108 | 109 | ## Contact 110 | 111 | If you have any questions, feel free to reach out to me on [Twitter](https://twitter.com/makrdev). 112 | -------------------------------------------------------------------------------- /app/api/openai/chat-title/route.ts: -------------------------------------------------------------------------------- 1 | import openai from "@/lib/openai"; 2 | import { createClient } from "@/lib/supabase/supabase-server"; 3 | import { ChatGPTMessage } from "@/types/openai"; 4 | import { NextResponse } from "next/server"; 5 | 6 | export async function POST(req: Request): Promise { 7 | const { messages, chatID, apiKey, model } = await req.json(); 8 | 9 | if (!messages) { 10 | return new Response("No messages!", { status: 400 }); 11 | } 12 | 13 | if (!chatID) { 14 | return new Response("No chatID!", { status: 400 }); 15 | } 16 | 17 | if (!apiKey) { 18 | return new Response("No key!", { status: 400 }); 19 | } 20 | 21 | // Create Supabase Server Client 22 | const supabase = createClient(); 23 | const { 24 | data: { session }, 25 | } = await supabase.auth.getSession(); 26 | 27 | // If no session, return 401 28 | if (!session) { 29 | return new Response("Not authorized!", { status: 401 }); 30 | } 31 | 32 | // Create OpenAI Client 33 | const openaiClient = openai(apiKey); 34 | 35 | const typeCorrectedMessages = messages as ChatGPTMessage[]; 36 | 37 | // Get Conversation Title 38 | const response = await openaiClient.createChatCompletion({ 39 | model: model ?? "gpt-3.5-turbo", 40 | messages: [ 41 | { 42 | content: 43 | "Based on the previous conversation, please write a short title for this conversation. RETURN ONLY THE TITLE.", 44 | role: "system", 45 | }, 46 | ...typeCorrectedMessages, 47 | ], 48 | }); 49 | 50 | const title = response.data.choices[0].message?.content; 51 | 52 | // If no title found, return 400 53 | if (!title) { 54 | return new Response("No response from OpenAI", { status: 401 }); 55 | } 56 | 57 | // Update Chat Title 58 | await supabase 59 | .from("chats") 60 | // @ts-ignore 61 | .update({ title }) 62 | .eq("id", chatID); 63 | 64 | // Finally return title 65 | return NextResponse.json({ 66 | title, 67 | }); 68 | } 69 | -------------------------------------------------------------------------------- /app/api/openai/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { OpenAIStream } from "@/lib/openai-stream"; 2 | import { createClient } from "@/lib/supabase/supabase-server"; 3 | 4 | export const runtime = "experimental-edge"; 5 | 6 | export async function POST(req: Request): Promise { 7 | const { payload } = await req.json(); 8 | 9 | // Create Supabase Server Client 10 | const supabase = createClient(); 11 | const { 12 | data: { session }, 13 | } = await supabase.auth.getSession(); 14 | 15 | // If no session, return 401 16 | if (!session) { 17 | return new Response("Not authorized!", { status: 401 }); 18 | } 19 | 20 | if (!payload) { 21 | return new Response("No payload!", { status: 400 }); 22 | } 23 | 24 | const stream = await OpenAIStream(payload); 25 | 26 | return new Response(stream); 27 | } 28 | -------------------------------------------------------------------------------- /app/api/openai/check/route.ts: -------------------------------------------------------------------------------- 1 | import openai from "@/lib/openai"; 2 | import { createClient } from "@/lib/supabase/supabase-server"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function POST(req: Request): Promise { 6 | const { key } = await req.json(); 7 | 8 | if (!key) { 9 | return NextResponse.json({ message: "Key is required!" }, { status: 401 }); 10 | } 11 | 12 | // Create Supabase Server Client 13 | const supabase = createClient(); 14 | const { 15 | data: { session }, 16 | } = await supabase.auth.getSession(); 17 | 18 | // If no session, return 401 19 | if (!session) { 20 | return NextResponse.json({ message: "Not authorized!" }, { status: 401 }); 21 | } 22 | 23 | // Create OpenAI Client 24 | const openaiClient = openai(key); 25 | 26 | try { 27 | // Get Model (this will throw an error if the key is invalid) 28 | await openaiClient.retrieveModel("text-davinci-003"); 29 | 30 | // Return 200 if there is no error 31 | return NextResponse.json("Key is valid!"); 32 | } catch (error: any) { 33 | console.log(error); 34 | if (error?.response.status === 401) { 35 | return NextResponse.json( 36 | { message: "Key is not valid." }, 37 | { status: 404 } 38 | ); 39 | } 40 | // If it's not a 401, return 500 41 | else { 42 | return NextResponse.json( 43 | { message: "Something went wrong." }, 44 | { status: 500 } 45 | ); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/api/openai/embedding/route.ts: -------------------------------------------------------------------------------- 1 | import openai from "@/lib/openai"; 2 | import { createClient } from "@/lib/supabase/supabase-server"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function POST(req: Request): Promise { 6 | const { messages, apiKey } = await req.json(); 7 | 8 | if (!messages) { 9 | return new Response("No messages!", { status: 400 }); 10 | } 11 | 12 | if (!apiKey) { 13 | return new Response("No key!", { status: 400 }); 14 | } 15 | 16 | // Create Supabase Server Client 17 | const supabase = createClient(); 18 | const { 19 | data: { session }, 20 | } = await supabase.auth.getSession(); 21 | 22 | // If no session, return 401 23 | if (!session) { 24 | return new Response("Not authorized!", { status: 401 }); 25 | } 26 | 27 | // Create OpenAI Client 28 | const openaiClient = openai(apiKey); 29 | 30 | try { 31 | // Create Embeddings 32 | const { data } = await openaiClient.createEmbedding({ 33 | model: "text-embedding-ada-002", 34 | input: messages 35 | .map((message: any) => message.content) 36 | .filter((filteredMessage: string) => filteredMessage !== ""), 37 | }); 38 | 39 | const embeddings = data.data; 40 | 41 | if (!embeddings) { 42 | return NextResponse.json( 43 | { message: "Something went wrong!" }, 44 | { status: 500 } 45 | ); 46 | } 47 | 48 | // Finally return title 49 | return NextResponse.json(embeddings); 50 | } catch (error) { 51 | console.log(error); 52 | return NextResponse.json( 53 | { message: "Something went wrong!" }, 54 | { status: 500 } 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/api/supabase/history/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/lib/supabase/supabase-server"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export async function POST(req: Request): Promise { 5 | const { 6 | query_embedding, 7 | similarity_threshold, 8 | match_count, 9 | chat_id, 10 | owner_id, 11 | } = await req.json(); 12 | 13 | // If no ID, return 400 14 | if (!query_embedding || !similarity_threshold || !match_count || !owner_id) { 15 | console.log({ 16 | query_embedding, 17 | similarity_threshold, 18 | match_count, 19 | chat_id, 20 | }); 21 | return NextResponse.json("Wrong payload!", { status: 400 }); 22 | } 23 | 24 | // Create Supabase Server Client 25 | const supabase = createClient(); 26 | const { 27 | data: { session }, 28 | } = await supabase.auth.getSession(); 29 | 30 | // If no session, return 401 31 | if (!session) { 32 | return new Response("Not authorized!", { status: 401 }); 33 | } 34 | 35 | try { 36 | const { data, error } = await supabase.rpc("search_messages", { 37 | query_embedding, 38 | similarity_threshold, 39 | match_count, 40 | chat_id, 41 | owner_id, 42 | }); 43 | 44 | if (error) { 45 | console.log(error); 46 | return NextResponse.json(error, { status: 400 }); 47 | } 48 | 49 | return NextResponse.json(data); 50 | } catch (error) { 51 | console.log(error); 52 | return NextResponse.json(error, { status: 400 }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/api/supabase/message/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/lib/supabase/supabase-server"; 2 | import { NextResponse } from "next/server"; 3 | 4 | // POST 5 | export async function POST(req: Request): Promise { 6 | const { messages } = await req.json(); 7 | // If no message, return 400 8 | if (!messages) { 9 | return new Response("No message!", { status: 400 }); 10 | } 11 | 12 | // Create Supabase Server Client 13 | const supabase = createClient(); 14 | const { 15 | data: { session }, 16 | } = await supabase.auth.getSession(); 17 | 18 | // If no session, return 401 19 | if (!session) { 20 | return new Response("Not authorized!", { status: 401 }); 21 | } 22 | 23 | // Insert Message 24 | const { data: messagesInserted, error } = await supabase 25 | .from("messages") 26 | .insert( 27 | messages.map((message: any) => { 28 | return { 29 | chat: message.chat, 30 | content: message.content, 31 | role: message.role, 32 | owner: session?.user?.id, 33 | embedding: message.embedding, 34 | token_size: message.token_size, 35 | }; 36 | }) 37 | ) 38 | .select("id,role,content"); 39 | 40 | if (error) { 41 | console.log(error); 42 | return new Response(error.message, { status: 400 }); 43 | } else { 44 | return NextResponse.json(messagesInserted); 45 | } 46 | } 47 | 48 | // DELETE (Currently there is bug with DELETE request - Next.js 13.3.0) 49 | export async function PATCH(req: Request): Promise { 50 | const { message } = await req.json(); 51 | 52 | // If no ID, return 400 53 | if (!message) { 54 | return new Response("No ID!", { status: 400 }); 55 | } 56 | 57 | // Create Supabase Server Client 58 | const supabase = createClient(); 59 | const { 60 | data: { session }, 61 | } = await supabase.auth.getSession(); 62 | 63 | // If no session, return 401 64 | if (!session) { 65 | return new Response("Not authorized!", { status: 401 }); 66 | } 67 | 68 | await supabase 69 | .from("messages") 70 | .delete() 71 | .match({ id: message.id, owner: session?.user?.id }); 72 | 73 | return new Response("Message deleted!", { status: 200 }); 74 | } 75 | -------------------------------------------------------------------------------- /app/chat/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import Chatbox from "@/components/chat/chatbox"; 2 | import { createClient } from "@/lib/supabase/supabase-server"; 3 | import { ChatWithMessageCountAndSettings, MessageT } from "@/types/collections"; 4 | import { notFound } from "next/navigation"; 5 | export const revalidate = 0; 6 | 7 | const ChatPage = async ({ 8 | params, 9 | }: { 10 | params: { 11 | id: string; 12 | }; 13 | }) => { 14 | // Get Initial Messages 15 | const supabase = createClient(); 16 | const id = params.id; 17 | const { data: messages } = await supabase 18 | .from("messages") 19 | .select("*") 20 | .eq("chat", id) 21 | .order("created_at", { ascending: true }) 22 | .order("role", { ascending: false }) 23 | .returns(); 24 | 25 | // Check if the chat exists, if not, return 404 26 | const { data: currentChat } = await supabase 27 | .from("chats") 28 | .select("*, messages(count)") 29 | .eq("id", id) 30 | .single(); 31 | if (!currentChat) { 32 | notFound(); 33 | } 34 | const parsedCurrentChat = { 35 | ...currentChat, 36 | advanced_settings: JSON.parse(currentChat.advanced_settings as string), 37 | } as ChatWithMessageCountAndSettings; 38 | 39 | return ( 40 | 41 | ); 42 | }; 43 | 44 | export default ChatPage; 45 | -------------------------------------------------------------------------------- /app/chat/layout.tsx: -------------------------------------------------------------------------------- 1 | import Sidebar from "@/components/navigation/sidebar"; 2 | import SiderbarOverlay from "@/components/navigation/sidebar-overlay"; 3 | import OpenAIServerKeyProvider from "@/components/providers/openai-serverkey-provider"; 4 | import React from "react"; 5 | 6 | const ChatLayout = ({ children }: { children: React.ReactNode }) => { 7 | return ( 8 | 9 |
10 | 11 | 12 | {children} 13 |
14 |
15 | ); 16 | }; 17 | 18 | export default ChatLayout; 19 | -------------------------------------------------------------------------------- /app/chat/page.tsx: -------------------------------------------------------------------------------- 1 | import ChatInput from "@/components/chat/chat-input"; 2 | import NewChat from "@/components/chat/new-chat"; 3 | import MobileMenuButton from "@/components/navigation/mobile-menu-button"; 4 | 5 | export default function Home() { 6 | return ( 7 |
8 |
9 | 10 | 11 | 12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/batuhanbilginn/makr-ai/f3faac67c93caf6cc8850c715ec070f5f41b5e92/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | html { 7 | @apply h-full scroll-smooth; 8 | } 9 | body { 10 | @apply h-full dark:bg-neutral-950 dark:text-neutral-50; 11 | } 12 | } 13 | 14 | @layer components { 15 | .markdown p { 16 | @apply mt-3 first:mt-0; 17 | } 18 | .markdown ol { 19 | @apply flex flex-col gap-5 pl-4 my-3 list-decimal; 20 | } 21 | .markdown ul { 22 | @apply flex flex-col gap-5 pl-4 my-3 list-disc; 23 | } 24 | .markdown h2 { 25 | @apply mt-4 mb-2 text-2xl font-semibold; 26 | } 27 | .markdown h3 { 28 | @apply mt-4 mb-2 text-xl font-semibold; 29 | } 30 | .markdown h4 { 31 | @apply mt-4 mb-2 text-lg font-medium; 32 | } 33 | .markdown h5 { 34 | @apply mt-4 mb-2 text-base font-medium; 35 | } 36 | .markdown table { 37 | @apply w-full my-6 overflow-hidden text-left rounded-md table-auto; 38 | } 39 | .markdown table thead { 40 | @apply text-sm font-medium uppercase border-b border-neutral-200 dark:border-neutral-400 bg-neutral-50 dark:bg-neutral-700; 41 | } 42 | .markdown table thead th { 43 | @apply px-4 py-3; 44 | } 45 | .markdown table tbody tr { 46 | @apply text-sm border-b dark:border-neutral-600 border-neutral-300 last:border-none even:bg-neutral-100 odd:bg-neutral-200 even:dark:bg-neutral-700 odd:dark:bg-neutral-800; 47 | } 48 | 49 | .markdown table tbody tr td { 50 | @apply px-4 py-3; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | import ".//globals.css"; 3 | 4 | export const metadata = { 5 | title: "makr.AI", 6 | description: "A ChatGPT clone with enhanced features for makers.", 7 | }; 8 | 9 | import JotaiProvider from "@/components/providers/jotai-provider"; 10 | import OpenAIKeyProvider from "@/components/providers/openai-key-provider"; 11 | import { ThemeProviderClient } from "@/components/providers/theme-provider"; 12 | import SupabaseAuthProvider from "@/lib/supabase/supabase-auth-provider"; 13 | import SupabaseProvider from "@/lib/supabase/supabase-provider"; 14 | import { createClient } from "@/lib/supabase/supabase-server"; 15 | import { Inter } from "next/font/google"; 16 | 17 | const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); 18 | 19 | export default async function RootLayout({ 20 | children, 21 | }: { 22 | children: React.ReactNode; 23 | }) { 24 | const supabase = createClient(); 25 | 26 | const { 27 | data: { session }, 28 | } = await supabase.auth.getSession(); 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | {children} 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/login/login-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { useAuth } from "@/lib/supabase/supabase-auth-provider"; 5 | import { Github } from "lucide-react"; 6 | import { useRouter } from "next/navigation"; 7 | import { useEffect } from "react"; 8 | 9 | const LoginForm = () => { 10 | const { signInWithGithub, user } = useAuth(); 11 | const router = useRouter(); 12 | 13 | // Check if there is a user 14 | useEffect(() => { 15 | if (user) { 16 | router.push("/"); 17 | } 18 | // eslint-disable-next-line react-hooks/exhaustive-deps 19 | }, [user]); 20 | 21 | return ( 22 |
23 | {/* Main Container */} 24 |
25 | {/* Text */} 26 |
27 |

Login

28 |

29 | Welcome to the{" "} 30 | 31 | makr.AI 32 | {" "} 33 | Please login with your Github account. 34 |

35 |
36 | {/* Github Button */} 37 | 43 |
44 |
45 | ); 46 | }; 47 | 48 | export default LoginForm; 49 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | import LoginForm from "./login-form"; 4 | 5 | const LoginPage = () => { 6 | return ( 7 |
8 | 9 | {/* Gradient */} 10 |
11 | {/* Overlay */} 12 |
13 | {/* Content */} 14 |
15 |
16 | makr-logo 23 |
24 |
25 | A ChatGPT clone with enhanced features for makers. 26 |
27 |
28 | ChatGPT is a product of OpenAI and makr.AI is{" "} 29 | 30 | 100% unaffiliated with OpenAI. 31 | {" "} 32 | In order to use this procut, you must get your OpenAI API key 33 | from their{" "} 34 | 38 | website 39 | 40 | . 41 |
42 |
43 |
44 |
45 | gradient 53 |
54 |
55 | ); 56 | }; 57 | 58 | export default LoginPage; 59 | -------------------------------------------------------------------------------- /app/opengraph-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/batuhanbilginn/makr-ai/f3faac67c93caf6cc8850c715ec070f5f41b5e92/app/opengraph-image.jpg -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | export default function Home() { 4 | return redirect("/chat"); 5 | } 6 | -------------------------------------------------------------------------------- /atoms/chat.ts: -------------------------------------------------------------------------------- 1 | import { ChatWithMessageCountAndSettings, MessageT } from "@/types/collections"; 2 | import { 3 | ChatGPTMessage, 4 | OpenAIKeyOptional, 5 | OpenAIKeyRequired, 6 | OpenAISettings, 7 | OpenAIStreamPayload, 8 | } from "@/types/openai"; 9 | import { encode } from "@nem035/gpt-3-encoder"; 10 | import { atom } from "jotai"; 11 | import { createRef } from "react"; 12 | import { v4 as uuidv4 } from "uuid"; 13 | 14 | // Current Prices of GPT Models per 1000 tokens 15 | const modelPrices = { 16 | "gpt-3.5-turbo": 0.002 / 1000, 17 | "gpt-4": 0.03 / 1000, 18 | }; 19 | export const defaultSystemPropmt = `You are makr.AI, a large language model trained by OpenAI.`; 20 | 21 | // To hold OpenAI API Key 22 | export const openAIAPIKeyAtom = atom(process.env.OPENAI_API_KEY || ""); 23 | 24 | // To control OpenAI API Key (Set and Delete) 25 | export const openAPIKeyHandlerAtom = atom( 26 | (get) => get(openAIAPIKeyAtom), 27 | (_get, set, payload: OpenAIKeyOptional | OpenAIKeyRequired) => { 28 | if (payload.action === "remove") { 29 | set(openAIAPIKeyAtom, ""); 30 | localStorage.removeItem("openai-api-key"); 31 | } else if (payload.action === "set") { 32 | set(openAIAPIKeyAtom, payload.key); 33 | localStorage.setItem("openai-api-key", payload.key); 34 | } else if (payload.action === "get") { 35 | // Check ENV first 36 | const localKey = localStorage.getItem("openai-api-key"); 37 | if (localKey) { 38 | set(openAIAPIKeyAtom, localKey); 39 | } 40 | } 41 | } 42 | ); 43 | 44 | // To control OpenAI Settings when starting new chat (New Chat Component) 45 | export const openAISettingsAtom = atom({ 46 | model: "gpt-3.5-turbo", 47 | history_type: "chat", 48 | system_prompt: defaultSystemPropmt, 49 | advanced_settings: { 50 | temperature: 0.7, 51 | top_p: 1, 52 | frequency_penalty: 0, 53 | presence_penalty: 0, 54 | max_tokens: 1000, 55 | stream: true, 56 | n: 1, 57 | }, 58 | }); 59 | 60 | // To combine all settings and messages in a state for sending new message (Read Only) 61 | const openAIPayload = atom((get) => { 62 | const currentChat = get(currentChatAtom); 63 | const tokenSizeLimitState = get(tokenSizeLimitAtom); 64 | // Check if global history is enabled 65 | const isContextNeeded = 66 | get(historyTypeAtom) === "global" || tokenSizeLimitState.isBeyondLimit; 67 | // Remove the empty assitant message before sending (There is a empty emssage that we push to state for UX purpose) 68 | const messages = [...get(messagesAtom)].filter( 69 | (message) => message.content !== "" 70 | ); 71 | let history = messages; 72 | 73 | // CONTEXT IS NEEDED 74 | if (isContextNeeded) { 75 | // Get the context based on search 76 | const context = get(previousContextAtom); 77 | // Remaining Token for Current Chat History 78 | let remainingTokenSizeForCurrentChat = 79 | tokenSizeLimitState.remainingTokenForCurerntChat; 80 | 81 | // Get old messages of current chat based on remaining token size 82 | for (let i = messages.length - 1; i >= 0; i--) { 83 | const message = messages[i]; 84 | const messageTokenSize = encode(message.content as string).length; 85 | if (messageTokenSize > remainingTokenSizeForCurrentChat) { 86 | messages.splice(i, 1); 87 | } else { 88 | remainingTokenSizeForCurrentChat -= messageTokenSize; 89 | } 90 | } 91 | history = messages.map((message, index, array) => { 92 | // Put Context in the last user's message 93 | if (index === array.length - 1) { 94 | return { 95 | content: `Previous Conversations Context:${JSON.stringify( 96 | context 97 | )} \n\n ${message.content}`, 98 | role: message.role, 99 | } as MessageT; 100 | } else { 101 | return message; 102 | } 103 | }); 104 | } 105 | 106 | return { 107 | apiKey: get(openAIAPIKeyAtom), 108 | model: currentChat?.model ?? "gpt-3.5-turbo", 109 | messages: [ 110 | { 111 | content: 112 | currentChat?.system_prompt!! + 113 | `Answer as concisely as possible and ALWAYS answer in MARKDOWN. Answer based on previous conversations if provided and if it's relevant. Current date: ${new Date()}`, 114 | role: "system", 115 | }, 116 | ...history.map( 117 | (m) => 118 | ({ 119 | content: m.content as string, 120 | role: m.role ?? "user", 121 | } as ChatGPTMessage) 122 | ), 123 | ], 124 | ...currentChat?.advanced_settings!!, 125 | }; 126 | }); 127 | 128 | // To control handling state of add message logic 129 | const handlingAtom = atom(false); 130 | // Chatbox Ref for controlling scroll 131 | export const chatboxRefAtom = atom(createRef()); 132 | // Chat Input 133 | export const inputAtom = atom(""); 134 | 135 | export const ownerIDAtom = atom(""); 136 | // Where we keep current chat ID - (Read Only) 137 | export const chatIDAtom = atom((get) => get(currentChatAtom)?.id ?? ""); 138 | // Where we keep current chat 139 | export const currentChatAtom = atom( 140 | null 141 | ); 142 | export const chatsAtom = atom([]); 143 | // Where we keep all the messages 144 | export const messagesAtom = atom([]); 145 | // To check if chat has messages (Read Only) 146 | export const currentChatHasMessagesAtom = atom( 147 | (get) => get(messagesAtom).length > 0 148 | ); 149 | 150 | // Token Calculations 151 | export const tokenCountAtom = atom((get) => { 152 | const currentModel = get(currentChatAtom)?.model ?? "gpt-3.5-turbo"; 153 | const currentMessage = get(inputAtom); 154 | 155 | const currentMessageToken = encode(currentMessage).length; 156 | const currentMessagePrice = 157 | currentMessageToken * modelPrices[currentModel] + "$"; 158 | const currentChatToken = 159 | get(messagesAtom).reduce((curr, arr) => { 160 | return curr + encode(arr.content as string).length; 161 | }, 0) + currentMessageToken; 162 | const currentChatPrice = currentChatToken * modelPrices[currentModel] + "$"; 163 | 164 | return { 165 | currentMessageToken, 166 | currentMessagePrice, 167 | currentChatToken, 168 | currentChatPrice, 169 | }; 170 | }); 171 | 172 | export const tokenSizeLimitAtom = atom((get) => { 173 | const limit = 4000; // TODO: Change this based on the model. 174 | const responseLimit = 175 | get(currentChatAtom)?.advanced_settings?.max_tokens ?? 1000; 176 | const systemPropmtTokenSize = 177 | encode(get(currentChatAtom)?.system_prompt ?? "").length + 90; // 90 is for static text we provided for the sake of this app. 178 | const buffer = 250; // Buffer TODO: Find a proper solution 179 | // Calcula the context token size 180 | const contextTokenSize = encode( 181 | JSON.stringify(get(previousContextAtom)) 182 | ).length; 183 | const total = 184 | limit - systemPropmtTokenSize - buffer - responseLimit - contextTokenSize; 185 | 186 | return { 187 | remainingToken: total - get(tokenCountAtom).currentChatToken, 188 | remainingTokenForCurerntChat: total, 189 | isBeyondLimit: total <= get(tokenCountAtom).currentChatToken, 190 | }; 191 | }); 192 | // Read Only atom for getting history type state 193 | export const historyTypeAtom = atom<"global" | "chat">( 194 | (get) => get(currentChatAtom)?.history_type ?? "chat" 195 | ); 196 | 197 | // To hold context that we get from similarity search 198 | const previousContextAtom = atom([]); 199 | 200 | // Abort Controller for OpenAI Stream 201 | const abortControllerAtom = atom(new AbortController()); 202 | export const cancelHandlerAtom = atom( 203 | (get) => get(handlingAtom), 204 | (get, set) => { 205 | const abortController = get(abortControllerAtom); 206 | abortController.abort(); 207 | set(handlingAtom, false); 208 | set(abortControllerAtom, new AbortController()); 209 | } 210 | ); 211 | 212 | // Add Message Handler 213 | export const addMessageAtom = atom( 214 | (get) => get(handlingAtom), 215 | async (get, set, action: "generate" | "regenerate" = "generate") => { 216 | const inputValue = get(inputAtom); 217 | const token_size = get(tokenCountAtom).currentMessageToken; 218 | const isHandlig = get(handlingAtom); 219 | const chatID = get(chatIDAtom); 220 | const currentChat = get(currentChatAtom); 221 | const apiKey = get(openAIAPIKeyAtom); 222 | // Early Returns 223 | if ( 224 | isHandlig || 225 | (inputValue.length < 2 && action !== "regenerate") || 226 | !apiKey 227 | ) { 228 | return; 229 | } 230 | 231 | // Build User's Message Object in Function Scope - We need to use it in multiple places 232 | const userMessage: MessageT = { 233 | content: inputValue, 234 | role: "user", 235 | chat: chatID!!, 236 | id: uuidv4(), 237 | created_at: String(new Date()), 238 | owner: "", 239 | token_size, 240 | }; 241 | 242 | // Add to Supabase Handler 243 | const addMessagetoSupabase = async ( 244 | messages: MessageT[], 245 | apiKey: string 246 | ) => { 247 | try { 248 | // Get Embeddings for the messages 249 | const embeddingResponse = await fetch("/api/openai/embedding", { 250 | method: "POST", 251 | headers: { 252 | "Content-Type": "application/json", 253 | }, 254 | body: JSON.stringify({ messages, apiKey }), 255 | }); 256 | 257 | const embeddings = await embeddingResponse.json(); 258 | 259 | // Add message to the Supabase 260 | const response = await fetch("/api/supabase/message", { 261 | method: "POST", 262 | headers: { 263 | "Content-Type": "application/json", 264 | }, 265 | body: JSON.stringify({ 266 | messages: messages.map((m, i) => { 267 | return { 268 | ...m, 269 | embedding: embeddings[i].embedding, 270 | }; 271 | }), 272 | }), 273 | }); 274 | if (!response.ok) throw new Error("Failed to add message to Supabase"); 275 | return await response.json(); 276 | } catch (error) { 277 | console.log(error); 278 | } 279 | }; 280 | 281 | // Scroll Down Handler 282 | const scrollDown = () => { 283 | const chatboxRef = get(chatboxRefAtom); 284 | if (chatboxRef.current) { 285 | chatboxRef.current.scrollTop = chatboxRef.current.scrollHeight; 286 | } 287 | }; 288 | 289 | // Start Handling 290 | set(handlingAtom, true); 291 | 292 | /* 1) Add User Message to the State */ 293 | if (action === "generate") { 294 | set(messagesAtom, (prev) => { 295 | return [...prev, userMessage]; 296 | }); 297 | 298 | // Clear Input 299 | set(inputAtom, ""); 300 | 301 | // Scroll down after insert 302 | scrollDown(); 303 | } 304 | 305 | /* 2) Send Messages to the API to get response from OpenAI */ 306 | const initialID = uuidv4(); 307 | // Set Initial Message to the State (We need show "thinking" message to the user before we get response") 308 | set(messagesAtom, (prev) => { 309 | return [ 310 | ...prev, 311 | { 312 | id: initialID, 313 | content: "", 314 | role: "assistant", 315 | created_at: String(new Date()), 316 | chat: chatID!!, 317 | token_size: 0, 318 | }, 319 | ]; 320 | }); 321 | 322 | // Scroll down after insert 323 | scrollDown(); 324 | 325 | // Check If Token Size is Exceeded 326 | const tokenSizeLimitExceeded = get(tokenSizeLimitAtom).isBeyondLimit; 327 | 328 | if (tokenSizeLimitExceeded || get(historyTypeAtom) === "global") { 329 | // Get User's Message 330 | const lastUsersMessage = 331 | action === "generate" 332 | ? userMessage 333 | : (get(messagesAtom).findLast( 334 | (message) => message.role === "user" 335 | ) as MessageT); 336 | 337 | let embedding = lastUsersMessage.embedding; 338 | 339 | // If we don't have embedding for the message, get it from OpenAI (When we regenerate, we already have embedding) 340 | if (!embedding) { 341 | // Get Embeddings for the User's Message 342 | const embeddingResponse = await fetch("/api/openai/embedding", { 343 | method: "POST", 344 | headers: { 345 | "Content-Type": "application/json", 346 | }, 347 | body: JSON.stringify({ messages: [lastUsersMessage], apiKey }), 348 | }); 349 | 350 | const embeddings = await embeddingResponse.json(); 351 | embedding = embeddings[0].embedding; 352 | } 353 | 354 | // Get history from Supabase 355 | const response = await fetch("/api/supabase/history", { 356 | method: "POST", 357 | headers: { 358 | "Content-Type": "application/json", 359 | }, 360 | body: JSON.stringify({ 361 | query_embedding: embedding, 362 | similarity_threshold: 0.79, 363 | match_count: 10, 364 | owner_id: get(ownerIDAtom), 365 | chat_id: get(historyTypeAtom) === "global" ? null : chatID, 366 | }), 367 | }); 368 | const history = await response.json(); 369 | 370 | if (history) { 371 | // Set the state 372 | set(previousContextAtom, history); 373 | } 374 | } 375 | 376 | // Response Fetcher and Stream Handler 377 | try { 378 | const response = await fetch("/api/openai/chat", { 379 | method: "POST", 380 | headers: { 381 | "Content-Type": "application/json", 382 | }, 383 | body: JSON.stringify({ 384 | payload: get(openAIPayload), 385 | }), 386 | signal: get(abortControllerAtom).signal, 387 | }); 388 | 389 | if (!response.ok) { 390 | console.log("Response not ok", response); 391 | throw new Error(response.statusText); 392 | } 393 | 394 | // This data is a ReadableStream 395 | const data = response.body; 396 | if (!data) { 397 | console.log("No data from response.", data); 398 | throw new Error("No data from response."); 399 | } 400 | 401 | const reader = data.getReader(); 402 | const decoder = new TextDecoder(); 403 | let done = false; 404 | 405 | while (!done) { 406 | const { value, done: doneReading } = await reader.read(); 407 | done = doneReading; 408 | const chunkValue = decoder.decode(value); 409 | set(messagesAtom, (prev) => { 410 | const responseMessage = prev.find((m) => m.id === initialID); 411 | if (!responseMessage) { 412 | console.log("No response message", responseMessage); 413 | return prev; 414 | } 415 | return [ 416 | ...prev.filter((m) => m.id !== initialID), 417 | { 418 | ...responseMessage, 419 | content: responseMessage?.content + chunkValue, 420 | token_size: 421 | (responseMessage?.token_size ?? 0) + encode(chunkValue).length, 422 | }, 423 | ]; 424 | }); 425 | 426 | /* Scroll to the bottom as we get chunk */ 427 | scrollDown(); 428 | } 429 | } catch (error) { 430 | console.log(error); 431 | // Set Error Message into the State If it's not aborted 432 | if (error !== "DOMException: The user aborted a request.") return; 433 | set(messagesAtom, (prev) => { 434 | const responseMessage = prev.find((m) => m.id === initialID); 435 | if (!responseMessage) { 436 | console.log("No response message", responseMessage); 437 | return prev; 438 | } 439 | return [ 440 | ...prev.filter((m) => m.id !== initialID), 441 | { 442 | ...responseMessage, 443 | content: "Oops! Something went wrong. Please try again.", 444 | }, 445 | ]; 446 | }); 447 | } finally { 448 | // Stop Handling 449 | set(handlingAtom, false); 450 | // Add messages to the Supabase if exists 451 | const finalAIMessage = get(messagesAtom).find( 452 | (m) => m.id === initialID 453 | ) as MessageT; 454 | if (action === "generate") { 455 | const instertedMessages = await addMessagetoSupabase( 456 | finalAIMessage ? [userMessage, finalAIMessage] : [userMessage], 457 | apiKey 458 | ); 459 | 460 | for (const message of instertedMessages) { 461 | if (message.role === "user") { 462 | set(messagesAtom, (prev) => { 463 | return prev.map((m) => { 464 | if (m.id === userMessage.id) { 465 | return { 466 | ...m, 467 | id: message.id, 468 | }; 469 | } 470 | return m; 471 | }); 472 | }); 473 | } else { 474 | set(messagesAtom, (prev) => { 475 | return prev.map((m) => { 476 | if (m.id === initialID) { 477 | return { 478 | ...m, 479 | id: message.id, 480 | }; 481 | } 482 | return m; 483 | }); 484 | }); 485 | } 486 | } 487 | } 488 | // Regenerate 489 | else { 490 | const instertedMessages = await addMessagetoSupabase( 491 | [finalAIMessage], 492 | apiKey 493 | ); 494 | // Change the dummy IDs with the real ones 495 | if (!instertedMessages) { 496 | console.log("No inserted messages found"); 497 | return; 498 | } 499 | 500 | set(messagesAtom, (prev) => { 501 | return prev.map((m) => { 502 | if (m.id === initialID) { 503 | return { 504 | ...m, 505 | id: instertedMessages[0].id, 506 | }; 507 | } 508 | return m; 509 | }); 510 | }); 511 | } 512 | } 513 | 514 | /* 3) Change Conversation Title */ 515 | if (action === "generate") { 516 | try { 517 | // If chat is new, update the chat title 518 | const isChatNew = get(messagesAtom).length === 2; 519 | if (isChatNew) { 520 | const response = await fetch("/api/openai/chat-title", { 521 | method: "POST", 522 | headers: { 523 | "Content-Type": "application/json", 524 | }, 525 | body: JSON.stringify({ 526 | messages: get(messagesAtom).map((message) => { 527 | return { 528 | content: message.content, 529 | role: message.role, 530 | }; 531 | }), 532 | chatID: get(chatIDAtom), 533 | apiKey: get(openAIAPIKeyAtom), 534 | model: currentChat?.model ?? "gpt-3.5-turbo", 535 | }), 536 | }); 537 | const { title } = await response.json(); 538 | if (title) { 539 | set(chatsAtom, (prev) => { 540 | return prev.map((c) => { 541 | if (c.id === get(chatIDAtom)) { 542 | return { 543 | ...c, 544 | title, 545 | }; 546 | } 547 | return c; 548 | }); 549 | }); 550 | } 551 | } 552 | } catch (error) { 553 | console.log(error); 554 | } 555 | } 556 | } 557 | ); 558 | 559 | // Re-generate Handler 560 | export const regenerateHandlerAtom = atom( 561 | (get) => { 562 | // Is there any message from the assistant? 563 | const assistantMessage = 564 | get(messagesAtom).filter((m) => m.role === "assistant")?.length > 0; 565 | const isHandling = get(handlingAtom); 566 | return Boolean(assistantMessage && !isHandling); 567 | }, 568 | async (get, set) => { 569 | // Remove last assistant message 570 | const allMessages = [...get(messagesAtom)]; 571 | const lastMessage = allMessages.pop(); 572 | 573 | if (lastMessage?.role === "assistant") { 574 | set(messagesAtom, allMessages); 575 | // Remove from Supabase 576 | await fetch("/api/supabase/message", { 577 | method: "PATCH", 578 | headers: { 579 | "Content-Type": "application/json", 580 | }, 581 | body: JSON.stringify({ 582 | message: lastMessage, 583 | }), 584 | }); 585 | 586 | await set(addMessageAtom, "regenerate"); 587 | } 588 | } 589 | ); 590 | -------------------------------------------------------------------------------- /atoms/navigation.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | 3 | export const mobileMenuAtom = atom(false); 4 | -------------------------------------------------------------------------------- /components/brand/logo.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useTheme } from "next-themes"; 3 | import Image from "next/image"; 4 | import { useEffect, useState } from "react"; 5 | 6 | const Logo = ({ className }: { className?: string }) => { 7 | const { theme } = useTheme(); 8 | const [mounted, setMounted] = useState(false); 9 | useEffect(() => { 10 | setMounted(true); 11 | }, []); 12 | 13 | if (!mounted) return null; 14 | return ( 15 | <> 16 | 23 | makr-logo 30 | 31 | ); 32 | }; 33 | 34 | export default Logo; 35 | -------------------------------------------------------------------------------- /components/chat/chat-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | addMessageAtom, 5 | cancelHandlerAtom, 6 | chatIDAtom, 7 | currentChatHasMessagesAtom, 8 | inputAtom, 9 | regenerateHandlerAtom, 10 | } from "@/atoms/chat"; 11 | import useChats from "@/hooks/useChats"; 12 | import { useAtom, useAtomValue, useSetAtom } from "jotai"; 13 | import { RefreshCw, Send, StopCircle } from "lucide-react"; 14 | import { useCallback, useEffect } from "react"; 15 | import { Button } from "../ui/button"; 16 | import { Textarea } from "../ui/textarea"; 17 | import ChatSettingsMenu from "./chat-settings-menu"; 18 | 19 | const ChatInput = () => { 20 | const { addChatHandler } = useChats(); 21 | const [inputValue, setInputValue] = useAtom(inputAtom); 22 | const [isHandling, addMessageHandler] = useAtom(addMessageAtom); 23 | const [isRegenerateSeen, regenerateHandler] = useAtom(regenerateHandlerAtom); 24 | const hasChatMessages = useAtomValue(currentChatHasMessagesAtom); 25 | const cancelHandler = useSetAtom(cancelHandlerAtom); 26 | const chatID = useAtomValue(chatIDAtom); 27 | 28 | // Handle Submit 29 | const handleSubmit = async (e: React.FormEvent) => { 30 | e.preventDefault(); 31 | if (!hasChatMessages && !chatID) { 32 | await addChatHandler(); 33 | } else { 34 | await addMessageHandler("generate"); 35 | } 36 | }; 37 | 38 | // Enter Key Handler 39 | const handleKeyDown = useCallback( 40 | async (e: KeyboardEvent) => { 41 | if (e.key === "Enter" && !e.shiftKey) { 42 | e.preventDefault(); 43 | if (!hasChatMessages && !chatID) { 44 | await addChatHandler(); 45 | } else { 46 | await addMessageHandler("generate"); 47 | } 48 | } 49 | }, 50 | [hasChatMessages, chatID, addMessageHandler, addChatHandler] 51 | ); 52 | 53 | // Subsribe to Key Down Event 54 | useEffect(() => { 55 | addEventListener("keydown", handleKeyDown); 56 | return () => removeEventListener("keydown", handleKeyDown); 57 | }, [handleKeyDown]); 58 | 59 | return ( 60 |
61 | {/* Container */} 62 |
63 | {/* Abort Controller */} 64 | {isHandling && ( 65 |
66 | 73 |
74 | )} 75 | {/* Regenerate Controller - Desktop */} 76 | {!isHandling && isRegenerateSeen && ( 77 |
78 | 85 |
86 | )} 87 | {/* Settings */} 88 | {hasChatMessages && } 89 | {/* Input Container */} 90 |
94 |