├── .env.example ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── app ├── actions.ts ├── api │ ├── auth │ │ └── callback │ │ │ └── route.ts │ ├── chat │ │ └── route.ts │ └── web3auth │ │ ├── login │ │ └── route.ts │ │ └── nonce │ │ └── route.ts ├── chat │ └── [id] │ │ └── page.tsx ├── globals.css ├── layout.tsx ├── opengraph-image.png ├── page.tsx ├── share │ └── [id] │ │ └── page.tsx ├── sign-in │ └── page.tsx └── twitter-image.png ├── assets └── fonts │ ├── Inter-Bold.woff │ └── Inter-Regular.woff ├── auth.ts ├── codegen.yml ├── components ├── button-scroll-to-bottom.tsx ├── chat-list.tsx ├── chat-message-actions.tsx ├── chat-message.tsx ├── chat-panel.tsx ├── chat-scroll-anchor.tsx ├── chat.tsx ├── clear-history.tsx ├── empty-screen.tsx ├── external-link.tsx ├── footer.tsx ├── header.tsx ├── login-button-metamask.tsx ├── markdown.tsx ├── profile.tsx ├── prompt-form.tsx ├── providers.tsx ├── sidebar-actions.tsx ├── sidebar-footer.tsx ├── sidebar-item.tsx ├── sidebar-list.tsx ├── sidebar.tsx ├── tailwind-indicator.tsx ├── theme-toggle.tsx ├── toaster.tsx ├── ui │ ├── alert-dialog.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── codeblock.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── icons.tsx │ ├── input.tsx │ ├── label.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── switch.tsx │ ├── textarea.tsx │ └── tooltip.tsx └── user-menu.tsx ├── docs ├── README_improvements_ideas.md ├── README_web3_auth_supabase.md ├── README_web3_tokens_and_networks.md └── assets │ ├── metamask_add_network.png │ ├── ocean_publish_datatoken.png │ ├── supabase_jwt_policy.png │ ├── supabase_user_table.png │ ├── table_users_linked_id.png │ ├── tokengated_chatbot.png │ └── your_datatoken.png ├── graphql ├── graphqlClient.ts ├── operations │ └── queries │ │ ├── getOrder.graphql │ │ ├── getOrders.graphql │ │ └── getUser.graphql ├── schema.graphql └── sdk.ts ├── lib ├── analytics.ts ├── db_types.ts ├── fonts.ts ├── hooks │ ├── use-at-bottom.tsx │ ├── use-copy-to-clipboard.tsx │ ├── use-enter-submit.tsx │ └── use-local-storage.ts ├── types.ts └── utils.ts ├── middleware.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.cjs ├── public ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon.ico ├── next.svg ├── thirteen.svg └── vercel.svg ├── supabase ├── .gitignore ├── config.toml ├── functions │ └── client.ts ├── migrations │ └── 20230707053030_init.sql └── seed.sql ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | ## You must first activate a Billing Account here: https://platform.openai.com/account/billing/overview 2 | ## Then get your OpenAI API Key here: https://platform.openai.com/account/api-keys 3 | OPENAI_API_KEY=your-open-ai-key 4 | 5 | # Update these with your Supabase details from your project settings > API 6 | # https://app.supabase.com/project/_/settings/api 7 | # In local dev you can get these by running `supabase status`. 8 | NEXT_PUBLIC_SUPABASE_URL=your-supabase-project-url 9 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key 10 | NEXT_PUBLIC_SUPABASE_SERVICE_KEY=your-supabase-service-key 11 | NEXT_PUBLIC_SUPABASE_JWT_SECRET=your-supabase-jwt-key 12 | 13 | ## Wallet Connect 14 | NEXT_PUBLIC_WC2_PROJECT_ID=your-wallet-connect-project-id 15 | 16 | ## Infura API Key 17 | NEXT_PUBLIC_INFURA_API_KEY=your-infura-api-key 18 | 19 | ## Auth message to validate the web3 signature 20 | NEXT_PUBLIC_WEB3AUTH_MESSAGE=Please sign this message to confirm your identity. Nonce: 21 | 22 | ## Ocean Protocol Token Configuration 23 | NEXT_PUBLIC_WEB3AUTH_TTL=3600 24 | NEXT_PUBLIC_DATATOKEN_ADDRESS=0x2eaa179769d1Db4678Ce5FCD93E29F81aD0C5146 25 | NEXT_PUBLIC_SUBGRAPH_URL=https://v4.subgraph.mumbai.oceanprotocol.com/subgraphs/name/oceanprotocol/ocean-subgraph 26 | 27 | ## App Configuration 28 | NEXT_PUBLIC_APP_DOMAN=@yourdomain.com 29 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "root": true, 4 | "extends": [ 5 | "next/core-web-vitals", 6 | "prettier", 7 | "plugin:tailwindcss/recommended" 8 | ], 9 | "plugins": ["tailwindcss"], 10 | "rules": { 11 | "tailwindcss/no-custom-classname": "off" 12 | }, 13 | "settings": { 14 | "tailwindcss": { 15 | "callees": ["cn", "cva"], 16 | "config": "tailwind.config.js" 17 | } 18 | }, 19 | "overrides": [ 20 | { 21 | "files": ["*.ts", "*.tsx"], 22 | "parser": "@typescript-eslint/parser" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # graphql sdk 4 | graphql/sdk.ts 5 | 6 | # dependencies 7 | node_modules 8 | .pnp 9 | .pnp.js 10 | 11 | # testing 12 | coverage 13 | 14 | # next.js 15 | .next/ 16 | out/ 17 | build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | .pnpm-debug.log* 28 | 29 | # local env files 30 | .env.local 31 | .env.development.local 32 | .env.test.local 33 | .env.production.local 34 | 35 | # turbo 36 | .turbo 37 | 38 | .contentlayer 39 | .env 40 | .vercel 41 | .vscode 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Vercel, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Ocean Protocol Tokengated Chatbot

4 |
5 | 6 |

7 | A Web3 powered, Ocean Protocol tokengated, open-source AI chatbot app template built with Next.js, the Vercel AI SDK, OpenAI, and Supabase. 8 |

9 | 10 |

11 | Features · 12 | Web3 Powered · 13 | Model Providers · 14 | Security Warning · 15 | Deploy Vercel App · 16 | Publish Datatoken · 17 | Running locally · 18 | Authors 19 |

20 |
21 | 22 | ## Features 23 | 24 | - Create a Web3 Tokengated app where users gain access by [purchasing a DataNFT](https://market.oceanprotocol.com/asset/did:op:6897592e137286774c718ddcb5c3e5177aba5575d868a2f997ebfa3ffaa5213a) on the [Mumbai Network](#publish-datatoken). 25 | - Sign a Web3 transaction to identify yourself and login via a custom [Supabase Web3Auth](#supabase-web3auth). 26 | - Prompt OpenAI (default), Anthropic, Hugging Face, or custom AI chat models and/or LangChain 27 | - Chat History with [Supabase Postgres DB](https://supabase.com) 28 | - [Next.js](https://nextjs.org) App Router 29 | - React Server Components (RSCs), Suspense, and Server Actions 30 | - [Vercel AI SDK](https://sdk.vercel.ai/docs) for streaming chat UI 31 | - [shadcn/ui](https://ui.shadcn.com) 32 | - Styling with [Tailwind CSS](https://tailwindcss.com) 33 | - [Radix UI](https://radix-ui.com) for headless component primitives 34 | - Icons from [Phosphor Icons](https://phosphoricons.com) 35 | - [Rainbowkit](https://www.rainbowkit.com/) and [Wagmi](https://wagmi.sh/) as wallet providers and React hooks 36 | - [Ethers.js](https://docs.ethers.org/v5/) and [Infura](https://app.infura.io/) for the low-level work 37 | 38 | ## Web3 Powered 39 | 40 | With a few clicks, you can deploy a Web3 enabled, tokengated, AI dApp that uses Ocean Protocol's DataNFT to prove ownership and access of the application. 41 | 42 | What does this do? 43 | 1. Provides users with access by purchasing a DataNFT from the [Ocean Marketplace](https://market.oceanprotocol.com/). 44 | 2. Allows users to login and authorize with an off-chain Web3 signature. 45 | 46 | ## Model Providers 47 | 48 | This template ships with OpenAI `gpt-3.5-turbo` as the default. However, thanks to the [Vercel AI SDK](https://sdk.vercel.ai/docs), you can switch LLM providers to [Anthropic](https://anthropic.com), [Hugging Face](https://huggingface.co), or using [LangChain](https://js.langchain.com) with just a few lines of code. 49 | 50 | ## Security Warning 51 | 52 | For the Web3 implementation to work, we need to implement **Supabase's service_key** in the app. You can learn more about this by reading the [Supabase Web3Auth](#supabase-web3auth) for more intuition. 53 | 54 | If you are not careful with this as a developer you can easily expose your Supabase's admin role to the user. Please be careful to not expose getServiceSupabase() or NEXT_PUBLIC_SUPABASE_SERVICE_KEY. 55 | 56 | What does this mean? 57 | 58 | **[Never use a service key on the client](https://supabase.com/docs/guides/auth/row-level-security#never-use-a-service-key-on-the-client)** 59 | 60 | ## Deploy Vercel App 61 | 62 | Before hopping into code, let's launch the app and play with it. 63 | 1. Fork this repository: [tokengated-next-chatgpt](https://github.com/oceanprotocol/tokengated-next-chatgpt/) via Github. 64 | 1. Get a new [OpenAI API key](https://platform.openai.com/apps) 65 | 1. Deploy a new [DB in Supabase](https://supabase.com/dashboard/sign-in) 66 | 1. Setup your `public.users` table inside Supabase. We have provided you a screenshot of what ours looks like so you can configure it in the exact same way.
Create a public.users table
. 67 | 1. Please note that your `public.users.id` should link to your `auth.users.id` record
Link your public.users.id to auth.users.id
. **Important:** Make sure you make the `id` column nullable. 68 | 1. Setup your Supabase Row-Level Security (RLS) by executing the [scripts located below](#configure-supabase) inside the Supabase SQL Editor. 69 | 1. Get an [infura API key](https://www.infura.io/) 70 | 1. Get a [Wallet Connect Project ID](https://cloud.walletconnect.com/sign-in) 71 | 1. Hop onto Vercel and [Deploy your forked repository](https://vercel.com/new/) as a new Vercel project and configure your environment variables. 72 | 1. Configure your Vercel->project->settings to rebuild `sdk.ts` by overriding the build command with: `yarn generate && yarn build` 73 | 1. You should now have all the initial ENV_VARS required to deploy the initial version of the app. 74 | 1. Finally, after Vercel is deployed, update your Supabase's Project: [Authentication / URL Configuration / Site URL](https://supabase.com/dashboard/project/) to be your Vercel's app URL. 75 | 76 | ``` 77 | OPENAI_API_KEY=your-open-ai-key 78 | NEXT_PUBLIC_SUPABASE_URL=your-supabase-project-url 79 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key 80 | NEXT_PUBLIC_SUPABASE_SERVICE_KEY=your-supabase-service-key 81 | NEXT_PUBLIC_SUPABASE_JWT_SECRET=your-supabase-jwt-key 82 | NEXT_PUBLIC_WC2_PROJECT_ID=your-wallet-connect-project-id 83 | NEXT_PUBLIC_INFURA_API_KEY=your-infura-api-key 84 | NEXT_PUBLIC_WEB3AUTH_MESSAGE=Please sign this message to confirm your identity. Nonce: 85 | NEXT_PUBLIC_APP_DOMAN=@yourdomain.com 86 | ``` 87 | _Initial Environment Variables required for Vercel app to work_ 88 | 89 | ## Configure Supabase 90 | In the SQL Editor, we're going to create the remainder of the role-level security policies we'll need to keep the DB secure. 91 | 92 | You should already have a `public.users` table from the work you did in [Deploy Vercel App](#deploy-vercel-app) 93 | 94 | ``` 95 | -- Create view of auth.users and set strict access. 96 | create view public.auth_users as select * from auth.users; 97 | revoke all on public.auth_users from anon, authenticated; 98 | 99 | -- service-role policy 100 | CREATE POLICY service_role_access ON public.users 101 | AS PERMISSIVE FOR ALL 102 | TO service_role 103 | USING (auth.role() = 'service_role') 104 | WITH CHECK (auth.role() = 'service_role'); 105 | 106 | -- authenticated user policy 107 | CREATE POLICY authenticated_users_can_write ON public.users 108 | AS PERMISSIVE FOR UPDATE 109 | TO authenticated 110 | USING (auth.role() = 'authenticated') 111 | WITH CHECK (auth.role() = 'authenticated'); 112 | 113 | -- web3 auth policy 114 | CREATE POLICY web3_auth ON public.users 115 | AS PERMISSIVE FOR UPDATE 116 | TO authenticated 117 | USING ((current_setting('request.jwt.claims', true))::json ->> 'address' = address) 118 | WITH CHECK ((current_setting('request.jwt.claims', true))::json ->> 'address' = address); 119 | ``` 120 | 121 | ## Publish Datatoken 122 | 123 | We recommend using the [Mumbai Testnet](https://www.alchemy.com/overviews/mumbai-testnet) to deploy your datatoken. It will be fast and free. 124 | ``` 125 | Network Name: Mumbai Testnet 126 | New RPC URL: https://polygon-mumbai.g.alchemy.com/v2/your-api-key 127 | Chain ID: 80001 128 | Currency Symbol: MATIC 129 | Block Explorer URL: https://mumbai.polygonscan.com/ 130 | ``` 131 | _The Mumbai network_ 132 | 133 | 1. Let's begin by adding the Mumbai network to your wallet. 134 | 1. Now connect your wallet to the Mumbai network.
Metamask Add Network
135 | 1. Now get your wallet `0x address` for later. 136 | 1. We need some tokens to make transactions, [collect MATIC from this faucet](https://mumbaifaucet.com/) so we can create the Data token. 137 | 1. Make sure to also [collect OCEAN from this faucet](https://faucet.mumbai.oceanprotocol.com/) so you can also buy some tokens. 138 | 1. Deploy a Datatoken (DT) inside the [OCEAN marketplace](https://market.oceanprotocol.com/). On Step-2, select File-type "URL" and use the Vercel url as the address so you can complete the wizard (this architecture doesn't use it). You can now see your datatoken, copy the `0x address`.
Publish your URL Datatoken
139 | 1. You have now published a Datatoken. When a user purchases this, they will gain access to our application. So, let's make sure to buy one so we can obtain access to the app after we deploy it. 140 |
Your Datatoken
141 | 142 | ### Complete Vercel Configuration 143 | 144 | You can now complete configuring the Vercel app. 145 | 146 | Go back to your Vercel->project->settings->Environment Variables and add the rest of them. 147 | ``` 148 | NEXT_PUBLIC_WEB3AUTH_TTL=3600 149 | NEXT_PUBLIC_DATATOKEN_ADDRESS=0x2eaa179769d1Db4678Ce5FCD93E29F81aD0C5146 150 | NEXT_PUBLIC_SUBGRAPH_URL=https://v4.subgraph.mumbai.oceanprotocol.com/subgraphs/name/oceanprotocol/ocean-subgraph 151 | ``` 152 | _Ocean Protocol and Datatoken Environment Variables_ 153 | 154 | User subscriptions are verified at login based on when the Datatoken was purchased + TTL. Users are only authorized to prompt until the subscription expires. 155 | 156 | ## Running locally 157 | 158 | **Before you start,** make sure you have followed every step from [Deploy Vercel App](#deploy-vercel-app) so your application can be configured correctly. 159 | 160 | You will need to use the environment variables [defined in `.env.example`](.env.example) to run OP's Tokengated AI Chatbot. 161 | 162 | Copy the `.env.example` file and populate the required env vars: 163 | 164 | ```bash 165 | cp .env.example .env 166 | ``` 167 | 168 | [Install the Supabase CLI](https://supabase.com/docs/guides/cli) and start the local Supabase stack: 169 | 170 | ```bash 171 | npm install supabase --save-dev 172 | npx supabase start 173 | ``` 174 | 175 | Install the local dependencies and start dev mode: 176 | 177 | ```bash 178 | pnpm install 179 | pnpm dev 180 | ``` 181 | 182 | Your app template should now be running on [localhost:3000](http://localhost:3000/). 183 | 184 | ## Building GQL SDK 185 | 186 | Vercel currently does not support `graphql-generate` as part of the build, so you'll have to do this ahead of time. 187 | 188 | As you write more GQL, please run the `yarn generate` command to update your local GQL library and SDK. This will help you maintain good code and avoid type safety issues. 189 | 190 | You can then add the newly built SDK before deploying a new Vercel Build. 191 | ``` 192 | yarn generate 193 | git add . 194 | git commit -m "updating gql" 195 | git push 196 | ``` 197 | 198 | ## Additional Readmes 199 | 200 | The following READMEs have been created to provide guidance to the reader. 201 | 1. [Improvements and Ideas](/docs/README_improvements_ideas.md) 202 | 1. [Supabase Web3Auth](/docs/README_web3_auth_supabase.md) 203 | 1. [Web3 Tokens and Networks](/docs/README_web3_tokens_and_networks.md) 204 | 205 | ## Authors 206 | 207 | This scaffolding is an extension of the fantastic [Vercel AI-Chatbot](https://github.com/supabase-community/vercel-ai-chatbot) project. 208 | 209 | Ocean Protocol ([@oceanprotocol](https://twitter.com/oceanprotocol)) has provided the work to build a custom Web3 Auth on top of Supabase, token-gated access with a DataNFT, and to provide a Web3 scaffolding to create AI dApps. 210 | 211 | Special thanks to [@kdetry](https://github.com/kdetry) and [@idiom-bytes](https://github.com/idiom-bytes) for assembling this. 212 | 213 | No sealife was harmed in the making of this repository. 214 | -------------------------------------------------------------------------------- /app/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { createServerActionClient } from '@supabase/auth-helpers-nextjs' 4 | import { cookies } from 'next/headers' 5 | import { Database } from '@/lib/db_types' 6 | import { revalidatePath } from 'next/cache' 7 | import { redirect } from 'next/navigation' 8 | 9 | import { type Chat } from '@/lib/types' 10 | import { auth } from '@/auth' 11 | 12 | const supabase = createServerActionClient({ cookies }) 13 | 14 | export async function getChats(userId?: string | null) { 15 | if (!userId) { 16 | return [] 17 | } 18 | 19 | try { 20 | const { data } = await supabase 21 | .from('chats') 22 | .select('payload') 23 | .order('payload->createdAt', { ascending: false }) 24 | .throwOnError() 25 | 26 | return (data?.map(entry => entry.payload) as Chat[]) ?? [] 27 | } catch (error) { 28 | return [] 29 | } 30 | } 31 | 32 | export async function getChat(id: string) { 33 | const { data } = await supabase 34 | .from('chats') 35 | .select('payload') 36 | .eq('id', id) 37 | .maybeSingle() 38 | 39 | return (data?.payload as Chat) ?? null 40 | } 41 | 42 | export async function removeChat({ id, path }: { id: string; path: string }) { 43 | try { 44 | await supabase.from('chats').delete().eq('id', id).throwOnError() 45 | 46 | revalidatePath('/') 47 | return revalidatePath(path) 48 | } catch (error) { 49 | return { 50 | error: 'Unauthorized' 51 | } 52 | } 53 | } 54 | 55 | export async function clearChats() { 56 | try { 57 | const user = await auth() 58 | await supabase 59 | .from('chats') 60 | .delete() 61 | .eq('user_id', user?.id) 62 | .throwOnError() 63 | revalidatePath('/') 64 | return redirect('/') 65 | } catch (error) { 66 | console.log('clear chats error', error) 67 | return { 68 | error: 'Unauthorized' 69 | } 70 | } 71 | } 72 | 73 | export async function getSharedChat(id: string) { 74 | const { data } = await supabase 75 | .from('chats') 76 | .select('payload') 77 | .eq('id', id) 78 | .not('payload->sharePath', 'is', null) 79 | .maybeSingle() 80 | 81 | return (data?.payload as Chat) ?? null 82 | } 83 | 84 | export async function shareChat(chat: Chat) { 85 | const payload = { 86 | ...chat, 87 | sharePath: `/share/${chat.id}` 88 | } 89 | 90 | await supabase 91 | .from('chats') 92 | .update({ payload: payload as any }) 93 | .eq('id', chat.id) 94 | .throwOnError() 95 | 96 | return payload 97 | } 98 | -------------------------------------------------------------------------------- /app/api/auth/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs' 2 | import { cookies } from 'next/headers' 3 | import { NextResponse } from 'next/server' 4 | 5 | export async function GET(request: Request) { 6 | // The `/auth/callback` route is required for the server-side auth flow implemented 7 | // by the Auth Helpers package. It exchanges an auth code for the user's session. 8 | // https://supabase.com/docs/guides/auth/auth-helpers/nextjs#managing-sign-in-with-code-exchange 9 | const requestUrl = new URL(request.url) 10 | const code = requestUrl.searchParams.get('code') 11 | 12 | if (code) { 13 | const supabase = createRouteHandlerClient({ cookies }) 14 | await supabase.auth.exchangeCodeForSession(code) 15 | } 16 | 17 | // URL to redirect to after sign in process completes 18 | return NextResponse.redirect(requestUrl.origin) 19 | } 20 | 21 | -------------------------------------------------------------------------------- /app/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { OpenAIStream, StreamingTextResponse } from 'ai' 2 | import { Configuration, OpenAIApi } from 'openai-edge' 3 | import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs' 4 | import { cookies } from 'next/headers' 5 | import { Database } from '@/lib/db_types' 6 | 7 | import { auth } from '@/auth' 8 | import { nanoid } from '@/lib/utils' 9 | 10 | export const runtime = 'nodejs' 11 | 12 | const configuration = new Configuration({ 13 | apiKey: process.env.OPENAI_API_KEY 14 | }) 15 | 16 | const openai = new OpenAIApi(configuration) 17 | 18 | export async function POST(req: Request) { 19 | const supabase = createRouteHandlerClient({ cookies }) 20 | const json = await req.json() 21 | const { messages, previewToken } = json 22 | const user = await auth() 23 | const userId = user?.id 24 | 25 | if (!userId) { 26 | return new Response('Unauthorized', { 27 | status: 401 28 | }) 29 | } 30 | 31 | if (previewToken) { 32 | configuration.apiKey = previewToken 33 | } 34 | 35 | const res = await openai.createChatCompletion({ 36 | model: 'gpt-3.5-turbo', 37 | messages, 38 | temperature: 0.7, 39 | stream: true 40 | }) 41 | 42 | const stream = OpenAIStream(res, { 43 | async onCompletion(completion) { 44 | const title = json.messages[0].content.substring(0, 100) 45 | const id = json.id ?? nanoid() 46 | const createdAt = Date.now() 47 | const path = `/chat/${id}` 48 | const payload = { 49 | id, 50 | title, 51 | userId, 52 | createdAt, 53 | path, 54 | messages: [ 55 | ...messages, 56 | { 57 | content: completion, 58 | role: 'assistant' 59 | } 60 | ] 61 | } 62 | // Insert chat into database. 63 | await supabase.from('chats').upsert({ id, payload }).throwOnError() 64 | } 65 | }) 66 | 67 | return new StreamingTextResponse(stream) 68 | } 69 | -------------------------------------------------------------------------------- /app/api/web3auth/login/route.ts: -------------------------------------------------------------------------------- 1 | import { OrderDirection, Order_OrderBy } from '@/graphql/sdk' 2 | 3 | import { NextResponse } from 'next/server' 4 | import { ethers } from 'ethers' 5 | import { getServiceRoleServerSupabaseClient } from '@/supabase/functions/client' 6 | import { signToken } from '@/lib/utils' 7 | import { subgraphSDK } from '@/graphql/graphqlClient' 8 | 9 | export async function POST(req: Request) { 10 | const srSupabase = getServiceRoleServerSupabaseClient() 11 | const json = await req.json() 12 | const { address, signedMessage, nonce } = json 13 | 14 | // 1. Verify the signed message matches the requested address 15 | const message = process.env.NEXT_PUBLIC_WEB3AUTH_MESSAGE + nonce 16 | const recoveredAddress = ethers.utils.verifyMessage(message, signedMessage) 17 | 18 | if (recoveredAddress !== address) { 19 | return NextResponse.json( 20 | { error: 'Signature verification failed' }, 21 | { status: 401 } 22 | ) 23 | } 24 | 25 | try { 26 | // 2. Select * from public.user table to get nonce 27 | const { data: user, error: userError } = await srSupabase 28 | .from('users') 29 | .select('*') 30 | .eq('address', address) 31 | .single() 32 | 33 | if (user && !userError) { 34 | // 3. Verify the nonce included in the request matches what's already in public.users table for that address 35 | if (user?.auth.genNonce !== nonce) { 36 | return NextResponse.json( 37 | { error: 'Nonce verification failed' }, 38 | { status: 401 } 39 | ) 40 | } 41 | 42 | let finalAuthUser = null 43 | // 2. Select * from public.auth_users view where address matches 44 | const { data: authUser, error: authUserError } = await srSupabase 45 | .from('auth_users') 46 | .select('*') 47 | .eq('raw_user_meta_data->>address', address) 48 | .single() 49 | 50 | if (!authUser || authUserError) { 51 | // 4. If there's no auth.users.id for that address 52 | const { data: newUser, error: newUserError } = 53 | await srSupabase.auth.admin.createUser({ 54 | email: address + process.env.NEXT_PUBLIC_APP_DOMAN, 55 | user_metadata: { address: address }, 56 | email_confirm: true 57 | }) 58 | 59 | if (newUserError || !newUser) { 60 | return NextResponse.json( 61 | { error: 'Failed to create auth user' }, 62 | { status: 500 } 63 | ) 64 | } 65 | 66 | // response object is different from auth.users view 67 | finalAuthUser = newUser.user 68 | } else { 69 | // selection from auth.users view is the user, assign 70 | finalAuthUser = authUser 71 | } 72 | 73 | // 5. Update public.users.id with auth.users.id 74 | await srSupabase 75 | .from('users') 76 | .update([ 77 | { 78 | id: finalAuthUser?.id, 79 | auth: { 80 | genNonce: nonce, 81 | lastAuth: new Date().toISOString(), 82 | lastAuthStatus: 'success' 83 | } 84 | } 85 | ]) 86 | .eq('address', address) 87 | .select() 88 | 89 | const ttl = await getTTLofUser(address) 90 | 91 | if (!ttl) { 92 | return NextResponse.json({ error: 'Token expired' }, { status: 401 }) 93 | } 94 | 95 | // 6. We sign the token and return it to client 96 | const token = await signToken( 97 | { 98 | address: address, 99 | sub: user.id, 100 | aud: 'authenticated' 101 | }, 102 | { expiresIn: `${ttl}s` } 103 | ) 104 | 105 | const response = NextResponse.json('success', { status: 200 }) 106 | response.cookies.set('address', address) 107 | response.cookies.set('web3jwt', token) 108 | return response 109 | } 110 | 111 | return NextResponse.json( 112 | { error: userError?.message || 'Internal Server Error' }, 113 | { status: 500 } 114 | ) 115 | } catch (error: any) { 116 | return NextResponse.json( 117 | { error: error?.message || 'Internal Server Error' }, 118 | { status: 500 } 119 | ) 120 | } 121 | } 122 | 123 | const getTTLofUser = async (address: string) => { 124 | const ttl = process.env.NEXT_PUBLIC_WEB3AUTH_TTL 125 | if (!ttl) { 126 | throw new Error('NEXT_PUBLIC_WEB3AUTH_TTL is not set') 127 | } 128 | 129 | const userOnChain = await subgraphSDK.GetUser({ 130 | id: address.toLowerCase() 131 | }) 132 | 133 | if (userOnChain?.user?.id) { 134 | const datatoken = 135 | process.env.NEXT_PUBLIC_DATATOKEN_ADDRESS?.toLowerCase() || '' 136 | const orders = await subgraphSDK.GetOrders({ 137 | where: { 138 | consumer: userOnChain.user.id, 139 | datatoken: datatoken 140 | }, 141 | orderBy: Order_OrderBy.CreatedTimestamp, 142 | orderDirection: OrderDirection.Desc 143 | }) 144 | 145 | if (orders?.orders?.length > 0) { 146 | const createdTimestamp = orders?.orders[0].createdTimestamp 147 | //add 1 day to the timestamp 148 | const expiryTimestamp = createdTimestamp + 86400 149 | const now = Math.floor(Date.now() / 1000) 150 | if (now > expiryTimestamp) { 151 | return 0 152 | } 153 | 154 | return expiryTimestamp - now 155 | } 156 | return 0 157 | } 158 | return 0 159 | } 160 | -------------------------------------------------------------------------------- /app/api/web3auth/nonce/route.ts: -------------------------------------------------------------------------------- 1 | import { getServiceRoleServerSupabaseClient } from '@/supabase/functions/client' 2 | import { NextResponse } from 'next/server' 3 | import { auth } from '@/auth' 4 | 5 | export async function POST(req: Request) { 6 | const srSupabase = getServiceRoleServerSupabaseClient() 7 | const json = await req.json() 8 | const { address } = json 9 | const userId = (await auth())?.id 10 | 11 | if (userId) { 12 | return new Response('Unauthorized', {status: 401}) 13 | } 14 | 15 | try { 16 | const nonce = Math.floor(Math.random() * 1000000); 17 | let { data, error } = await srSupabase 18 | .from('users') 19 | .select('*') 20 | .eq('address', address) 21 | .single() 22 | 23 | if (!data || error) { 24 | const { data: user, error: upsertError } = await srSupabase 25 | .from('users') 26 | .upsert([ 27 | { 28 | address: address, 29 | auth: { 30 | genNonce: nonce, 31 | lastAuth: new Date().toISOString(), 32 | lastAuthStatus: "pending" 33 | }, 34 | } 35 | ]) 36 | .select() 37 | 38 | if (data || !upsertError) { 39 | return NextResponse.json({ user }, { status: 200 }) 40 | } 41 | throw new Error("Failed to create user") 42 | } else { 43 | const { data: user, error: updateError } = await srSupabase 44 | .from('users') 45 | .update([ 46 | { 47 | auth: { 48 | genNonce: nonce, 49 | lastAuth: new Date().toISOString(), 50 | lastAuthStatus: "pending" 51 | }, 52 | } 53 | ]) 54 | .eq('address', address) 55 | .select() 56 | 57 | if (data || !updateError) { 58 | return NextResponse.json({ user }, { status: 200 }) 59 | } 60 | throw new Error("Failed to update user") 61 | } 62 | } catch (error) { 63 | return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }) 64 | } 65 | } -------------------------------------------------------------------------------- /app/chat/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { type Metadata } from 'next' 2 | import { notFound, redirect } from 'next/navigation' 3 | 4 | import { auth } from '@/auth' 5 | import { getChat } from '@/app/actions' 6 | import { Chat } from '@/components/chat' 7 | 8 | export const runtime = 'nodejs' 9 | export const preferredRegion = 'home' 10 | 11 | export interface ChatPageProps { 12 | params: { 13 | id: string 14 | } 15 | } 16 | 17 | export async function generateMetadata({ 18 | params 19 | }: ChatPageProps): Promise { 20 | const user = await auth() 21 | 22 | if (!user) { 23 | return {} 24 | } 25 | 26 | const chat = await getChat(params.id) 27 | return { 28 | title: chat?.title.toString().slice(0, 50) ?? 'Chat' 29 | } 30 | } 31 | 32 | export default async function ChatPage({ params }: ChatPageProps) { 33 | const user = await auth() 34 | 35 | if (!user) { 36 | redirect(`/sign-in?next=/chat/${params.id}`) 37 | } 38 | 39 | const chat = await getChat(params.id) 40 | 41 | if (!chat) { 42 | notFound() 43 | } 44 | 45 | if (chat?.userId !== user?.id) { 46 | notFound() 47 | } 48 | 49 | return 50 | } 51 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | 10 | --muted: 240 4.8% 95.9%; 11 | --muted-foreground: 240 3.8% 46.1%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 240 10% 3.9%; 15 | 16 | --card: 0 0% 100%; 17 | --card-foreground: 240 10% 3.9%; 18 | 19 | --border: 240 5.9% 90%; 20 | --input: 240 5.9% 90%; 21 | 22 | --primary: 240 5.9% 10%; 23 | --primary-foreground: 0 0% 98%; 24 | 25 | --secondary: 240 4.8% 95.9%; 26 | --secondary-foreground: 240 5.9% 10%; 27 | 28 | --accent: 240 4.8% 95.9%; 29 | --accent-foreground: ; 30 | 31 | --destructive: 0 84.2% 60.2%; 32 | --destructive-foreground: 0 0% 98%; 33 | 34 | --ring: 240 5% 64.9%; 35 | 36 | --radius: 0.5rem; 37 | } 38 | 39 | .dark { 40 | --background: 240 10% 3.9%; 41 | --foreground: 0 0% 98%; 42 | 43 | --muted: 240 3.7% 15.9%; 44 | --muted-foreground: 240 5% 64.9%; 45 | 46 | --popover: 240 10% 3.9%; 47 | --popover-foreground: 0 0% 98%; 48 | 49 | --card: 240 10% 3.9%; 50 | --card-foreground: 0 0% 98%; 51 | 52 | --border: 240 3.7% 15.9%; 53 | --input: 240 3.7% 15.9%; 54 | 55 | --primary: 0 0% 98%; 56 | --primary-foreground: 240 5.9% 10%; 57 | 58 | --secondary: 240 3.7% 15.9%; 59 | --secondary-foreground: 0 0% 98%; 60 | 61 | --accent: 240 3.7% 15.9%; 62 | --accent-foreground: ; 63 | 64 | --destructive: 0 62.8% 30.6%; 65 | --destructive-foreground: 0 85.7% 97.3%; 66 | 67 | --ring: 240 3.7% 15.9%; 68 | } 69 | } 70 | 71 | @layer base { 72 | * { 73 | @apply border-border; 74 | } 75 | body { 76 | @apply bg-background text-foreground; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next' 2 | 3 | import { Toaster } from 'react-hot-toast' 4 | 5 | import '@/app/globals.css' 6 | import '@rainbow-me/rainbowkit/styles.css'; 7 | import { fontMono, fontSans } from '@/lib/fonts' 8 | import { cn } from '@/lib/utils' 9 | import { TailwindIndicator } from '@/components/tailwind-indicator' 10 | import { Providers } from '@/components/providers' 11 | import { Header } from '@/components/header' 12 | 13 | export const metadata: Metadata = { 14 | title: { 15 | default: 'Next.js AI Chatbot', 16 | template: `%s - Next.js AI Chatbot` 17 | }, 18 | description: 'An AI-powered chatbot template built with Next.js and Vercel.', 19 | themeColor: [ 20 | { media: '(prefers-color-scheme: light)', color: 'white' }, 21 | { media: '(prefers-color-scheme: dark)', color: 'black' } 22 | ], 23 | icons: { 24 | icon: '/favicon.ico', 25 | shortcut: '/favicon-16x16.png', 26 | apple: '/apple-touch-icon.png' 27 | } 28 | } 29 | 30 | interface RootLayoutProps { 31 | children: React.ReactNode 32 | } 33 | 34 | export default function RootLayout({ children }: RootLayoutProps) { 35 | return ( 36 | 37 | 38 | 45 | 46 | 47 |
48 | {/* @ts-ignore */} 49 |
50 |
{children}
51 |
52 | 53 |
54 | 55 | 56 | ) 57 | } -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oceanprotocol/tokengated-next-chatgpt/215b3776ef8055a461792f05b38a89bd345a7759/app/opengraph-image.png -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { nanoid } from '@/lib/utils' 2 | import { Chat } from '@/components/chat' 3 | 4 | export const runtime = 'nodejs' 5 | 6 | export default function IndexPage() { 7 | const id = nanoid() 8 | 9 | return 10 | } 11 | -------------------------------------------------------------------------------- /app/share/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { type Metadata } from 'next' 2 | import { notFound } from 'next/navigation' 3 | 4 | import { formatDate } from '@/lib/utils' 5 | import { getSharedChat } from '@/app/actions' 6 | import { ChatList } from '@/components/chat-list' 7 | import { FooterText } from '@/components/footer' 8 | 9 | export const runtime = 'nodejs' 10 | export const preferredRegion = 'home' 11 | 12 | interface SharePageProps { 13 | params: { 14 | id: string 15 | } 16 | } 17 | 18 | export async function generateMetadata({ 19 | params 20 | }: SharePageProps): Promise { 21 | const chat = await getSharedChat(params.id) 22 | 23 | return { 24 | title: chat?.title.slice(0, 50) ?? 'Chat' 25 | } 26 | } 27 | 28 | export default async function SharePage({ params }: SharePageProps) { 29 | const chat = await getSharedChat(params.id) 30 | 31 | if (!chat || !chat?.sharePath) { 32 | notFound() 33 | } 34 | 35 | return ( 36 | <> 37 |
38 |
39 |
40 |
41 |

{chat.title}

42 |
43 | {formatDate(chat.createdAt)} · {chat.messages.length} messages 44 |
45 |
46 |
47 |
48 | 49 |
50 | 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /app/sign-in/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from '@/auth' 2 | import { LoginButtonMetamask } from '@/components/login-button-metamask' 3 | import { redirect } from 'next/navigation' 4 | 5 | export default async function SignInPage() { 6 | const user = await auth() 7 | 8 | // redirect to home if user is already logged in 9 | if (user) { 10 | redirect('/') 11 | } 12 | return ( 13 |
14 | 15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /app/twitter-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oceanprotocol/tokengated-next-chatgpt/215b3776ef8055a461792f05b38a89bd345a7759/app/twitter-image.png -------------------------------------------------------------------------------- /assets/fonts/Inter-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oceanprotocol/tokengated-next-chatgpt/215b3776ef8055a461792f05b38a89bd345a7759/assets/fonts/Inter-Bold.woff -------------------------------------------------------------------------------- /assets/fonts/Inter-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oceanprotocol/tokengated-next-chatgpt/215b3776ef8055a461792f05b38a89bd345a7759/assets/fonts/Inter-Regular.woff -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | import { jsonResponse, verifyToken } from '@/lib/utils' 2 | 3 | import { cookies } from 'next/headers' 4 | import { createClient } from '@supabase/supabase-js' 5 | import { createServerActionClient } from '@supabase/auth-helpers-nextjs' 6 | 7 | export const auth = async () => { 8 | const address = cookies().get('address')?.value || '' 9 | const web3jwt = cookies().get('web3jwt')?.value || '' 10 | 11 | const validToken = await verifyToken(web3jwt, address) 12 | if (web3jwt && validToken) { 13 | const url = process.env.NEXT_PUBLIC_SUPABASE_URL || '' 14 | const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' 15 | const headers = { 16 | global: { 17 | headers: { Authorization: `Bearer ${web3jwt}` } 18 | }, 19 | auth: { persistSession: false } 20 | } 21 | 22 | const supabase = createClient(url, anonKey, headers) 23 | 24 | const { 25 | data: { user } 26 | } = await supabase.auth.getUser() 27 | console.log('user: ', user) 28 | 29 | if (user) { 30 | return user 31 | } 32 | } else { 33 | if (address) cookies().delete('address') 34 | if (web3jwt) cookies().delete('web3jwt') 35 | } 36 | 37 | return null 38 | } 39 | -------------------------------------------------------------------------------- /codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: 'https://v4.subgraph.mumbai.oceanprotocol.com/subgraphs/name/oceanprotocol/ocean-subgraph' 3 | documents: 'graphql/operations/**/*.graphql' 4 | generates: 5 | graphql/schema.graphql: 6 | plugins: 7 | - 'schema-ast' 8 | graphql/sdk.ts: 9 | plugins: 10 | - typescript 11 | - typescript-operations 12 | - typescript-graphql-request 13 | config: 14 | add: 15 | content: '/* eslint-disable */' -------------------------------------------------------------------------------- /components/button-scroll-to-bottom.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | 5 | import { cn } from '@/lib/utils' 6 | import { useAtBottom } from '@/lib/hooks/use-at-bottom' 7 | import { Button, type ButtonProps } from '@/components/ui/button' 8 | import { IconArrowDown } from '@/components/ui/icons' 9 | 10 | export function ButtonScrollToBottom({ className, ...props }: ButtonProps) { 11 | const isAtBottom = useAtBottom() 12 | 13 | return ( 14 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /components/chat-list.tsx: -------------------------------------------------------------------------------- 1 | import { type Message } from 'ai' 2 | 3 | import { Separator } from '@/components/ui/separator' 4 | import { ChatMessage } from '@/components/chat-message' 5 | 6 | export interface ChatList { 7 | messages: Message[] 8 | } 9 | 10 | export function ChatList({ messages }: ChatList) { 11 | if (!messages.length) { 12 | return null 13 | } 14 | 15 | return ( 16 |
17 | {messages.map((message, index) => ( 18 |
19 | 20 | {index < messages.length - 1 && ( 21 | 22 | )} 23 |
24 | ))} 25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /components/chat-message-actions.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { type Message } from 'ai' 4 | 5 | import { Button } from '@/components/ui/button' 6 | import { IconCheck, IconCopy } from '@/components/ui/icons' 7 | import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' 8 | import { cn } from '@/lib/utils' 9 | 10 | interface ChatMessageActionsProps extends React.ComponentProps<'div'> { 11 | message: Message 12 | } 13 | 14 | export function ChatMessageActions({ 15 | message, 16 | className, 17 | ...props 18 | }: ChatMessageActionsProps) { 19 | const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }) 20 | 21 | const onCopy = () => { 22 | if (isCopied) return 23 | copyToClipboard(message.content) 24 | } 25 | 26 | return ( 27 |
34 | 38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /components/chat-message.tsx: -------------------------------------------------------------------------------- 1 | import { Message } from 'ai' 2 | import remarkGfm from 'remark-gfm' 3 | import remarkMath from 'remark-math' 4 | 5 | import { cn } from '@/lib/utils' 6 | import { CodeBlock } from '@/components/ui/codeblock' 7 | import { MemoizedReactMarkdown } from '@/components/markdown' 8 | import { IconOpenAI, IconUser } from '@/components/ui/icons' 9 | import { ChatMessageActions } from '@/components/chat-message-actions' 10 | 11 | export interface ChatMessageProps { 12 | message: Message 13 | } 14 | 15 | export function ChatMessage({ message, ...props }: ChatMessageProps) { 16 | return ( 17 |
21 |
29 | {message.role === 'user' ? : } 30 |
31 |
32 | {children}

38 | }, 39 | code({ node, inline, className, children, ...props }) { 40 | if (children.length) { 41 | if (children[0] == '▍') { 42 | return ( 43 | 44 | ) 45 | } 46 | 47 | children[0] = (children[0] as string).replace('`▍`', '▍') 48 | } 49 | 50 | const match = /language-(\w+)/.exec(className || '') 51 | 52 | if (inline) { 53 | return ( 54 | 55 | {children} 56 | 57 | ) 58 | } 59 | 60 | return ( 61 | 67 | ) 68 | } 69 | }} 70 | > 71 | {message.content} 72 |
73 | 74 |
75 |
76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /components/chat-panel.tsx: -------------------------------------------------------------------------------- 1 | import { type UseChatHelpers } from 'ai/react' 2 | 3 | import { Button } from '@/components/ui/button' 4 | import { PromptForm } from '@/components/prompt-form' 5 | import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom' 6 | import { IconRefresh, IconStop } from '@/components/ui/icons' 7 | import { FooterText } from '@/components/footer' 8 | 9 | export interface ChatPanelProps 10 | extends Pick< 11 | UseChatHelpers, 12 | | 'append' 13 | | 'isLoading' 14 | | 'reload' 15 | | 'messages' 16 | | 'stop' 17 | | 'input' 18 | | 'setInput' 19 | > { 20 | id?: string 21 | } 22 | 23 | export function ChatPanel({ 24 | id, 25 | isLoading, 26 | stop, 27 | append, 28 | reload, 29 | input, 30 | setInput, 31 | messages 32 | }: ChatPanelProps) { 33 | return ( 34 |
35 | 36 |
37 |
38 | {isLoading ? ( 39 | 47 | ) : ( 48 | messages?.length > 0 && ( 49 | 57 | ) 58 | )} 59 |
60 |
61 | { 63 | await append({ 64 | id, 65 | content: value, 66 | role: 'user' 67 | }) 68 | }} 69 | input={input} 70 | setInput={setInput} 71 | isLoading={isLoading} 72 | /> 73 | 74 |
75 |
76 |
77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /components/chat-scroll-anchor.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { useInView } from 'react-intersection-observer' 5 | 6 | import { useAtBottom } from '@/lib/hooks/use-at-bottom' 7 | 8 | interface ChatScrollAnchorProps { 9 | trackVisibility?: boolean 10 | } 11 | 12 | export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) { 13 | const isAtBottom = useAtBottom() 14 | const { ref, entry, inView } = useInView({ 15 | trackVisibility, 16 | delay: 100, 17 | rootMargin: '0px 0px -150px 0px' 18 | }) 19 | 20 | React.useEffect(() => { 21 | if (isAtBottom && trackVisibility && !inView) { 22 | entry?.target.scrollIntoView({ 23 | block: 'start' 24 | }) 25 | } 26 | }, [inView, entry, isAtBottom, trackVisibility]) 27 | 28 | return
29 | } 30 | -------------------------------------------------------------------------------- /components/chat.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useChat, type Message } from 'ai/react' 4 | 5 | import { cn } from '@/lib/utils' 6 | import { ChatList } from '@/components/chat-list' 7 | import { ChatPanel } from '@/components/chat-panel' 8 | import { EmptyScreen } from '@/components/empty-screen' 9 | import { ChatScrollAnchor } from '@/components/chat-scroll-anchor' 10 | import { useLocalStorage } from '@/lib/hooks/use-local-storage' 11 | import { 12 | Dialog, 13 | DialogContent, 14 | DialogDescription, 15 | DialogFooter, 16 | DialogHeader, 17 | DialogTitle 18 | } from '@/components/ui/dialog' 19 | import { useState } from 'react' 20 | import { Button } from './ui/button' 21 | import { Input } from './ui/input' 22 | import { toast } from 'react-hot-toast' 23 | 24 | const IS_PREVIEW = process.env.VERCEL_ENV === 'preview' 25 | export interface ChatProps extends React.ComponentProps<'div'> { 26 | initialMessages?: Message[] 27 | id?: string 28 | } 29 | 30 | export function Chat({ id, initialMessages, className }: ChatProps) { 31 | const [previewToken, setPreviewToken] = useLocalStorage( 32 | 'ai-token', 33 | null 34 | ) 35 | const [previewTokenDialog, setPreviewTokenDialog] = useState(IS_PREVIEW) 36 | const [previewTokenInput, setPreviewTokenInput] = useState(previewToken ?? '') 37 | const { messages, append, reload, stop, isLoading, input, setInput } = 38 | useChat({ 39 | initialMessages, 40 | id, 41 | body: { 42 | id, 43 | previewToken 44 | }, 45 | onResponse(response) { 46 | if (response.status === 401) { 47 | toast.error(response.statusText) 48 | } 49 | } 50 | }) 51 | return ( 52 | <> 53 |
54 | {messages.length ? ( 55 | <> 56 | 57 | 58 | 59 | ) : ( 60 | 61 | )} 62 |
63 | 73 | 74 | 75 | 76 | 77 | Enter your OpenAI Key 78 | 79 | If you have not obtained your OpenAI API key, you can do so by{' '} 80 | 84 | signing up 85 | {' '} 86 | on the OpenAI website. This is only necessary for preview 87 | environments so that the open source community can test the app. 88 | The token will be saved to your browser's local storage under 89 | the name ai-token. 90 | 91 | 92 | setPreviewTokenInput(e.target.value)} 96 | /> 97 | 98 | 106 | 107 | 108 | 109 | 110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /components/clear-history.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { useRouter } from 'next/navigation' 5 | import { toast } from 'react-hot-toast' 6 | 7 | import { ServerActionResult } from '@/lib/types' 8 | import { Button } from '@/components/ui/button' 9 | import { 10 | AlertDialog, 11 | AlertDialogAction, 12 | AlertDialogCancel, 13 | AlertDialogContent, 14 | AlertDialogDescription, 15 | AlertDialogFooter, 16 | AlertDialogHeader, 17 | AlertDialogTitle, 18 | AlertDialogTrigger 19 | } from '@/components/ui/alert-dialog' 20 | import { IconSpinner } from '@/components/ui/icons' 21 | 22 | interface ClearHistoryProps { 23 | clearChats: () => ServerActionResult 24 | } 25 | 26 | export function ClearHistory({ clearChats }: ClearHistoryProps) { 27 | const [open, setOpen] = React.useState(false) 28 | const [isPending, startTransition] = React.useTransition() 29 | const router = useRouter() 30 | 31 | return ( 32 | 33 | 34 | 38 | 39 | 40 | 41 | Are you absolutely sure? 42 | 43 | This will permanently delete your chat history and remove your data 44 | from our servers. 45 | 46 | 47 | 48 | Cancel 49 | { 52 | event.preventDefault() 53 | startTransition(async () => { 54 | const result = await clearChats() 55 | 56 | if (result && 'error' in result) { 57 | toast.error(result.error) 58 | return 59 | } 60 | 61 | setOpen(false) 62 | router.push('/') 63 | }) 64 | }} 65 | > 66 | {isPending && } 67 | Delete 68 | 69 | 70 | 71 | 72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /components/empty-screen.tsx: -------------------------------------------------------------------------------- 1 | import { UseChatHelpers } from 'ai/react' 2 | 3 | import { Button } from '@/components/ui/button' 4 | import { ExternalLink } from '@/components/external-link' 5 | import { IconArrowRight } from '@/components/ui/icons' 6 | 7 | const exampleMessages = [ 8 | { 9 | heading: 'Explain technical concepts', 10 | message: `What is a "serverless function"?` 11 | }, 12 | { 13 | heading: 'Summarize an article', 14 | message: 'Summarize the following article for a 2nd grader: \n' 15 | }, 16 | { 17 | heading: 'Draft an email', 18 | message: `Draft an email to my boss about the following: \n` 19 | } 20 | ] 21 | 22 | export function EmptyScreen({ setInput }: Pick) { 23 | return ( 24 |
25 |
26 |

27 | Welcome to the Supabaseified Next.js AI Chatbot! 28 |

29 |

30 | This is an open source AI chatbot app template built with{' '} 31 | Next.js and{' '} 32 | Supabase. 33 |

34 |

35 | You can start a conversation here or try the following examples: 36 |

37 |
38 | {exampleMessages.map((message, index) => ( 39 | 48 | ))} 49 |
50 |
51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /components/external-link.tsx: -------------------------------------------------------------------------------- 1 | export function ExternalLink({ 2 | href, 3 | children 4 | }: { 5 | href: string 6 | children: React.ReactNode 7 | }) { 8 | return ( 9 | 14 | {children} 15 | 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /components/footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { cn } from '@/lib/utils' 4 | import { ExternalLink } from '@/components/external-link' 5 | 6 | export function FooterText({ className, ...props }: React.ComponentProps<'p'>) { 7 | return ( 8 |

15 | Open source AI chatbot built with{' '} 16 | Next.js and{' '} 17 | Supabase. 18 |

19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /components/header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { Button, buttonVariants } from '@/components/ui/button' 4 | import { 5 | IconNextChat, 6 | IconSeparator, 7 | } from '@/components/ui/icons' 8 | 9 | import { ClearHistory } from '@/components/clear-history' 10 | import Link from 'next/link' 11 | import { Profile } from '@/components/profile' 12 | import { Sidebar } from '@/components/sidebar' 13 | import { SidebarFooter } from '@/components/sidebar-footer' 14 | import { SidebarList } from '@/components/sidebar-list' 15 | import { ThemeToggle } from '@/components/theme-toggle' 16 | import { UserMenu } from '@/components/user-menu' 17 | import { auth } from '@/auth' 18 | import { clearChats } from '@/app/actions' 19 | 20 | export async function Header() { 21 | const user = await auth() 22 | 23 | return ( 24 |
25 |
26 | {user ? ( 27 | 28 | }> 29 | {/* @ts-ignore */} 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ) : ( 38 | 39 | 40 | 41 | 42 | )} 43 |
44 | 45 | {user ? ( 46 | 47 | ) : ( 48 | 51 | )} 52 |
53 |
54 |
55 | 56 |
57 |
58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /components/login-button-metamask.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState, useEffect } from 'react' 4 | 5 | import { cn } from '@/lib/utils' 6 | import { Button, type ButtonProps } from '@/components/ui/button' 7 | import { IconMetamask, IconSpinner } from '@/components/ui/icons' 8 | 9 | import { useRouter } from 'next/navigation' 10 | 11 | import { useAccount } from 'wagmi' 12 | 13 | import { signMessage } from '@wagmi/core' 14 | 15 | interface LoginButtonProps extends ButtonProps { 16 | showIcon?: boolean 17 | text?: string 18 | } 19 | 20 | export function LoginButtonMetamask({ 21 | text = 'Login with Metamask', 22 | showIcon = true, 23 | className, 24 | ...props 25 | }: LoginButtonProps) { 26 | const [isLoading, setIsLoading] = useState(false) 27 | const [isAuthenticated, setIsAuthenticated] = useState(false) 28 | const router = useRouter() 29 | 30 | const { address, isConnected } = useAccount() 31 | // const { chains } = useNetwork(); 32 | 33 | useEffect(() => { 34 | if (isAuthenticated) { 35 | console.log('User is authenticated, redirecting to home page') 36 | router.refresh() 37 | router.push('/') 38 | } 39 | }, [isAuthenticated, router]) 40 | 41 | return ( 42 | 113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /components/markdown.tsx: -------------------------------------------------------------------------------- 1 | import { FC, memo } from 'react' 2 | import ReactMarkdown, { Options } from 'react-markdown' 3 | 4 | export const MemoizedReactMarkdown: FC = memo( 5 | ReactMarkdown, 6 | (prevProps, nextProps) => 7 | prevProps.children === nextProps.children && 8 | prevProps.className === nextProps.className 9 | ) 10 | -------------------------------------------------------------------------------- /components/profile.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ConnectButton } from '@rainbow-me/rainbowkit'; 4 | 5 | export function Profile() { 6 | return ( 7 |
8 | 9 |
10 | ) 11 | } -------------------------------------------------------------------------------- /components/prompt-form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Link from 'next/link' 3 | import Textarea from 'react-textarea-autosize' 4 | import { UseChatHelpers } from 'ai/react' 5 | 6 | import { useEnterSubmit } from '@/lib/hooks/use-enter-submit' 7 | import { cn } from '@/lib/utils' 8 | import { Button, buttonVariants } from '@/components/ui/button' 9 | import { 10 | Tooltip, 11 | TooltipContent, 12 | TooltipTrigger 13 | } from '@/components/ui/tooltip' 14 | import { IconArrowElbow, IconPlus } from '@/components/ui/icons' 15 | 16 | export interface PromptProps 17 | extends Pick { 18 | onSubmit: (value: string) => Promise 19 | isLoading: boolean 20 | } 21 | 22 | export function PromptForm({ 23 | onSubmit, 24 | input, 25 | setInput, 26 | isLoading 27 | }: PromptProps) { 28 | const { formRef, onKeyDown } = useEnterSubmit() 29 | const inputRef = React.useRef(null) 30 | 31 | React.useEffect(() => { 32 | if (inputRef.current) { 33 | inputRef.current.focus() 34 | } 35 | }, []) 36 | 37 | return ( 38 |
{ 40 | e.preventDefault() 41 | if (!input?.trim()) { 42 | return 43 | } 44 | setInput('') 45 | await onSubmit(input) 46 | }} 47 | ref={formRef} 48 | > 49 |
50 | 51 | 52 | 59 | 60 | New Chat 61 | 62 | 63 | New Chat 64 | 65 |