├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── README.md ├── app ├── actions.ts ├── chat │ ├── [id] │ │ └── page.tsx │ ├── layout.tsx │ └── page.tsx ├── favicon.ico ├── globals.css ├── layout.tsx ├── opengraph-image.tsx ├── page.tsx ├── providers.tsx ├── shared-metadata.ts └── sign-in │ └── [[...sign-in]] │ └── page.tsx ├── assets └── fonts │ └── LabilGrotesk-Medium.ttf ├── components.json ├── components ├── AssistantDisplay.tsx ├── Chat.tsx ├── ChatHistory.tsx ├── ChatMessage.tsx ├── ChatPanel.tsx ├── ClearAllChats.tsx ├── LoadingSpinner.tsx ├── Markdown.tsx ├── Navigation.tsx ├── OgImage.tsx ├── PromptForm.tsx ├── RateLimited.tsx ├── SideBarActions.tsx ├── Sidebar.tsx ├── SidebarDesktop.tsx ├── SidebarItem.tsx ├── SidebarItems.tsx ├── SidebarList.tsx ├── SidebarMobile.tsx ├── SidebarToggle.tsx ├── ThemeToggle.tsx ├── UserBadge.tsx ├── assistant │ ├── Directory.tsx │ ├── Message.tsx │ ├── Profile.tsx │ ├── ProfileList.tsx │ ├── ProfileSkeleton.tsx │ ├── Readme.tsx │ ├── ReadmeSkeleton.tsx │ ├── Repositories.tsx │ ├── RepositorySkeleton.tsx │ └── Spinner.tsx ├── icons │ └── License.tsx └── ui │ ├── accordion.tsx │ ├── alert-dialog.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── code-block.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── navigation-menu.tsx │ ├── scroll-area.tsx │ ├── seperator.tsx │ ├── sheet.tsx │ ├── skeleton.tsx │ ├── textarea.tsx │ ├── toast.tsx │ ├── toaster.tsx │ ├── tooltip.tsx │ └── use-toast.ts ├── db ├── index.ts ├── migrate.ts ├── migrations │ ├── 0000_premium_morlocks.sql │ └── meta │ │ ├── 0000_snapshot.json │ │ └── _journal.json └── schema.ts ├── drizzle.config.ts ├── lib ├── chat │ ├── actions.tsx │ ├── github │ │ └── github.ts │ ├── submit-user-action.tsx │ ├── submit-user-message.tsx │ └── system-prompt.ts ├── constants.ts ├── hooks │ ├── use-copy-to-clipboard.ts │ ├── use-decoder.ts │ ├── use-enter-submit.ts │ ├── use-get-directory-content.ts │ ├── use-local-storage.ts │ ├── use-scroll-anchor.ts │ ├── use-sidebar.tsx │ └── use-streamable-text.ts ├── store.ts ├── types.ts └── utils.ts ├── middleware.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "plugins": ["tailwindcss"], 4 | "rules": { 5 | "tailwindcss/enforces-shorthand": "warn" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.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 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "jsxSingleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Generative UI: GitHub Assistant

3 | 4 | 5 | 6 |

7 | Github Assistant is a chatbot that utilises GPT and Vercel AI SDK to generate UI elements on the fly based on user input by calling functions and APIs. It queries the user input and responds with interactive UI elements within the AI stream to make the experience of a developer a lot smoother. 8 |
9 | 10 | View LIVE 11 | 12 |

13 |
14 |
15 |

16 | NOTE: Actions related to GitHub without tight rate limits require authentication. Testing the app with a session is recommended. 17 |

18 | 19 | ## 🔍 Overview 20 | 21 | - 📖 [Walk-through](#walk-through) 22 | - ⚙️ [Run on local](#run-on-local) 23 | - 🧱 [Tech Stack & Features](#tech-stack-&-features) 24 | 25 | ## 📖 Walk-through 26 |

NOTE: Please beware that the search queries are conducted by the LLM (GPT-3-turbo) so sometimes it might bring out unexpected results.

27 | 28 | Username search ~ Brings out the related profile UI component with related information and actions. 29 | > Example: `Search for the username 'kayaayberk'` 30 | 31 | Search list of users ~ Brings out the people who appeared in the search conducted by the LLM presented in a UI component with different actions. 32 | > Example: `Search for the people named John with more than 5 repositories located in London.` 33 | 34 | Repository search ~ Brings out the first 4 repositories that appeared in the search with related actions presented in a UI component. 35 | > Example: `Search for the repositories that have 'state management' in description with more than 5.000 stars.` 36 | 37 | Directory content search ~ Brings out the searched repository directory with its content on each file. 38 | > Example: `Search for ReduxJs/redux content directory.` 39 | 40 | Readme search ~ Brings out the full README.md of a searched repository in an organised format. 41 | > Example: `Search for the readme of Shadcn/ui` 42 | 43 |

44 | You can also interact with these actions within the AI stream through presented UI components. Each component includes related actions. 45 |

46 | 47 | ### Actions & Function calling 48 | Actions and functions related to generative UI elements as well as the AI provider can be found in: 49 | - `/lib/chat/actions.tsx` 50 | - `/lib/chat/submit-user-action.tsx` 51 | - `/lib/chat/submit-user-message.tsx` 52 | 53 | ## ⚙️ Run on your local 54 | 55 | #### 1. Clone the repository 56 | ```bash 57 | git clone https://github.com/kayaayberk/generative-ui-github-assistant.git 58 | ``` 59 | 60 | #### 2. Install dependencies & run on localhost 61 | ```bash 62 | cd generative-ui-github-assistant 63 | npm install 64 | npm run dev 65 | ``` 66 | 67 | #### 3. Set up .env.local 68 | 69 | ```bash 70 | OPENAI_API_KEY=[YOUR_OPENAI_API_KEY] 71 | 72 | ## Clerk Auth 73 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=[YOUR_NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY] 74 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=[PREFERRED_URL] 75 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=[PREFERRED_URL] 76 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=[PREFERRED_URL] 77 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=[PREFERRED_URL] 78 | 79 | ## Neon Database 80 | DATABASE_URL=[YOUR_NEON_DATABASE_URL] 81 | 82 | ## GitHub OAuth app 83 | GITHUB_CLIENT_ID=[YOUR_GITHUB_CLIENT_ID] 84 | GITHUB_CLIENT_SECRET=[YOUR_GITHUB_CLIENT_SECRET] 85 | ``` 86 | 87 | ## 🧱 Stack & Features 88 | 89 | #### Frameworks & Libraries & Languages 90 | 91 | - [React](https://react.dev/) 92 | - [Next.js](https://nextjs.org/) 93 | - [Vercel AI SDK](https://sdk.vercel.ai/docs) 94 | - [Typescript](https://www.typescriptlang.org/docs/) 95 | - [Zod](https://github.com/colinhacks/zod) 96 | - [Clerk Auth](https://clerk.com/docs) 97 | 98 | #### Platforms 99 | 100 | - [OpenAI](https://platform.openai.com/docs/introduction) 101 | - [Neon Database](https://neon.tech/) 102 | - [Drizzle ORM](https://orm.drizzle.team/docs/overview) 103 | - [Vercel](https://www.contentful.com/) 104 | - [GitHub API](https://docs.github.com/en/rest?apiVersion=2022-11-28) 105 | 106 | #### UI 107 | 108 | - [Tailwind CSS](https://tailwindcss.com/) 109 | - [Shadcn/ui](https://ui.shadcn.com/) 110 | - [Phosphor Icons](https://phosphoricons.com/) 111 | -------------------------------------------------------------------------------- /app/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { db } from '@/db' 4 | import { eq } from 'drizzle-orm' 5 | import { type Chat } from '@/lib/types' 6 | import { InsertChat, SelectChat, chat } from '@/db/schema' 7 | import { revalidatePath } from 'next/cache' 8 | import { QueryCache } from '@tanstack/react-query' 9 | import { clerkClient, currentUser } from '@clerk/nextjs' 10 | 11 | type GetChatResult = SelectChat[] | null 12 | type SetChetResults = InsertChat[] 13 | 14 | /** 15 | * `getChat` function retrieves a chat based on its ID and the logged-in user's ID, ensuring that 16 | * the user is the author of the chat. 17 | * @param {string} id - The `id` parameter is a string that represents the unique identifier of the 18 | * chat you want to retrieve. 19 | * @param {string} loggedInUserId - The `loggedInUserId` parameter represents 20 | * the ID of the user who is currently logged in and trying to access the chat. This parameter is used 21 | * to ensure that only the author of the chat can access and view the chat details. If the 22 | * `loggedInUserId` does not 23 | * @returns The `getChat` function is returning a Promise that resolves to a `GetChatResult`. If the 24 | * `receivedChat` array is empty or the author of the chat is not the same as the `loggedInUserId`, the 25 | * function returns `null`. Otherwise, it returns the `receivedChat` array. 26 | */ 27 | export async function getChat(id: string, loggedInUserId: string) { 28 | const receivedChat = await db 29 | .select({ 30 | id: chat.id, 31 | title: chat.title, 32 | author: chat.author, 33 | path: chat.path, 34 | messages: chat.messages, 35 | createdAt: chat.createdAt, 36 | }) 37 | .from(chat) 38 | .where(eq(chat.id, id)) 39 | 40 | if (!receivedChat.length || receivedChat[0].author !== loggedInUserId) { 41 | return null 42 | } 43 | return receivedChat 44 | } 45 | 46 | /** 47 | * `getChats` function retrieves chats for a specific user ID from a database and returns them as 48 | * an array of Chat objects. 49 | * @param {string | null} userId - The `userId` parameter is a string that represents the user for whom 50 | * we want to retrieve chats. It can also be `null` if the user is not authenticated. 51 | * @returns The function `getChats` returns a Promise that resolves to a `GetChatResult`, which is an 52 | * array of `Chat` objects. If the `userId` parameter is null, an empty array is returned. If there is 53 | * an error during the database query, an empty array is also returned. 54 | */ 55 | export async function getChats(userId?: string | null) { 56 | if (!userId) { 57 | return [] 58 | } 59 | 60 | try { 61 | const receivedChats: SelectChat[] = await db 62 | .select() 63 | .from(chat) 64 | .where(eq(chat.author, userId)) 65 | 66 | return receivedChats as Chat[] 67 | } catch (e) { 68 | console.error(`getChats error: ${e}`) 69 | return [] 70 | } 71 | } 72 | 73 | /** 74 | * The function `saveChat` saves a chat object to a database with conflict resolution logic. 75 | * @param {Chat} savedChat - The `savedChat` parameter is an object of type `Chat` that contains the 76 | * data to be saved in the database. 77 | */ 78 | export async function saveChat(savedChat: Chat) { 79 | const user = await currentUser() 80 | 81 | if (user) { 82 | await db 83 | .insert(chat) 84 | .values(savedChat) 85 | .onConflictDoUpdate({ 86 | target: chat.id, 87 | set: { id: savedChat.id, messages: savedChat.messages }, 88 | }) 89 | } 90 | } 91 | 92 | /** 93 | * The function `getMissingKeys` returns an array of environment variable keys that are required but 94 | * not present in the current environment. 95 | * @returns The `getMissingKeys` function is returning an array of keys that are missing from the 96 | * `process.env` object. The keys that are missing are those that are listed in the `keysRequired` 97 | * array but are not found in the `process.env` object. 98 | * From Vercel, see here: https://github.com/vercel/ai-chatbot/blob/main/app/actions.ts 99 | */ 100 | export async function getMissingKeys() { 101 | const keysRequired = ['OPENAI_API_KEY'] 102 | return keysRequired 103 | .map((key) => (process.env[key] ? '' : key)) 104 | .filter((key) => key !== '') 105 | } 106 | 107 | export async function removeChat({ id, path }: { id: string; path: string }) { 108 | const user = await currentUser() 109 | 110 | if (!user) { 111 | return { 112 | error: 'Unauthorized', 113 | } 114 | } 115 | 116 | if (user) { 117 | const uid = String( 118 | await db 119 | .select({ author: chat.author }) 120 | .from(chat) 121 | .where(eq(chat.author, user.id)), 122 | ) 123 | } 124 | 125 | await db.delete(chat).where(eq(chat.id, id)) 126 | await db.delete(chat).where(eq(chat.path, path)) 127 | const queryCache = new QueryCache({ 128 | onError: (error) => { 129 | console.log(error) 130 | }, 131 | onSuccess: (data) => { 132 | console.log(data) 133 | }, 134 | onSettled: (data, error) => { 135 | console.log(data, error) 136 | }, 137 | }) 138 | const query = queryCache.find({ queryKey: ['profiles'] }) 139 | 140 | revalidatePath('/') 141 | return revalidatePath(path) 142 | } 143 | 144 | export const getGithubAccessToken = async (userId: string) => { 145 | if (!userId) { 146 | throw new Error('userId not found') 147 | } 148 | const provider = 'oauth_github' 149 | 150 | const clerkResponse = await clerkClient.users.getUserOauthAccessToken( 151 | userId, 152 | provider, 153 | ) 154 | 155 | const accessToken = clerkResponse[0].token 156 | return accessToken as string 157 | } 158 | 159 | export const clearAllChats = async (userId: string) => { 160 | const user = await currentUser() 161 | if (!user || !userId) { 162 | return { 163 | error: 'Unauthorized', 164 | } 165 | } 166 | 167 | if (user) { 168 | const allChats: Chat[] = await db 169 | .select() 170 | .from(chat) 171 | .where(eq(chat.author, user.id)) 172 | 173 | if (allChats.length) { 174 | await db.delete(chat).where(eq(chat.author, userId)) 175 | } 176 | return revalidatePath(allChats.map((chat) => chat.path).join(', ')) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /app/chat/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import Chat from '@/components/Chat' 2 | import { AI } from '@/lib/chat/actions' 3 | import { currentUser } from '@clerk/nextjs' 4 | import { notFound, redirect } from 'next/navigation' 5 | import { getChat, getMissingKeys } from '@/app/actions' 6 | 7 | export interface ChatPageProps { 8 | params: { 9 | id: string 10 | } 11 | } 12 | export default async function ChatPage({ params }: ChatPageProps) { 13 | const user = await currentUser() 14 | const missingKeys = await getMissingKeys() 15 | 16 | if (!user) { 17 | redirect(`/api/auth/login?post_login_redirect_url=/chat/${params.id}`) 18 | } 19 | 20 | const chat = await getChat(params.id, user.id) 21 | 22 | if (!chat) { 23 | redirect('/chat') 24 | } 25 | 26 | if (chat[0].author !== user?.id) { 27 | notFound() 28 | } 29 | 30 | return ( 31 | 32 | 36 | 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /app/chat/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata, Viewport } from 'next' 2 | import { ChatHistory } from '@/components/ChatHistory' 3 | import SidebarMobile from '@/components/SidebarMobile' 4 | import SidebarToggle from '@/components/SidebarToggle' 5 | import SidebarDesktop from '@/components/SidebarDesktop' 6 | 7 | interface ChatLayoutProps { 8 | children: React.ReactNode 9 | } 10 | 11 | const title = 'GitHub Assistant' 12 | const description = 13 | "An experimental AI Chatbot utilising generative UI, serving data from GitHub's API through interactive UI components." 14 | 15 | export const metadata: Metadata = { 16 | metadataBase: new URL('https://githubassistant.vercel.app/chat'), 17 | title, 18 | description, 19 | openGraph: { 20 | title, 21 | description, 22 | }, 23 | twitter: { 24 | title, 25 | description, 26 | card: 'summary_large_image', 27 | creator: '@kayaayberkk', 28 | }, 29 | } 30 | 31 | export const viewport: Viewport = { 32 | width: 'device-width', 33 | initialScale: 1, 34 | minimumScale: 1, 35 | maximumScale: 1, 36 | } 37 | 38 | export default async function ChatLayout({ children }: ChatLayoutProps) { 39 | return ( 40 |
41 | 42 | 43 | 44 | 45 | 46 | {children} 47 |
48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /app/chat/page.tsx: -------------------------------------------------------------------------------- 1 | import Chat from '@/components/Chat' 2 | import { nanoid } from '@/lib/utils' 3 | import { AI } from '@/lib/chat/actions' 4 | import { getMissingKeys } from '../actions' 5 | 6 | export default async function IndexPage() { 7 | const missingKeys = await getMissingKeys() 8 | const id = nanoid() 9 | 10 | return ( 11 | 12 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kayaayberk/generative-ui-github-assistant/7242e9857fda79bc13f8b9f046c498b57029020d/app/favicon.ico -------------------------------------------------------------------------------- /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 './globals.css' 2 | import { Metadata, Viewport } from 'next' 3 | import { Providers } from './providers' 4 | import Navigation from '@/components/Navigation' 5 | import { Toaster } from '@/components/ui/toaster' 6 | import { ClerkProvider, auth } from '@clerk/nextjs' 7 | import { Analytics } from '@vercel/analytics/react' 8 | import { checkRateLimit } from '@/lib/chat/github/github' 9 | import SidebarMobile from '@/components/SidebarMobile' 10 | import { ChatHistory } from '@/components/ChatHistory' 11 | import SidebarToggle from '@/components/SidebarToggle' 12 | import SidebarDesktop from '@/components/SidebarDesktop' 13 | import { sharedTitle } from './shared-metadata' 14 | interface RootLayoutProps { 15 | children: React.ReactNode 16 | } 17 | 18 | const title = 'GitHub Assistant' 19 | const description = 20 | "An experimental AI Chatbot utilising generative UI, serving data from GitHub's API through interactive UI components." 21 | 22 | export const metadata: Metadata = { 23 | metadataBase: new URL('https://githubassistant.vercel.app/'), 24 | title, 25 | description, 26 | openGraph: { 27 | title: { 28 | template: `%s — ${sharedTitle}`, 29 | default: sharedTitle, 30 | }, 31 | }, 32 | twitter: { 33 | title, 34 | description, 35 | card: 'summary_large_image', 36 | creator: '@kayaayberkk', 37 | }, 38 | } 39 | 40 | export const viewport: Viewport = { 41 | width: 'device-width', 42 | initialScale: 1, 43 | minimumScale: 1, 44 | maximumScale: 1, 45 | } 46 | 47 | const fetchedData = async () => { 48 | const rateLimitRemaining = await checkRateLimit() 49 | return rateLimitRemaining 50 | } 51 | 52 | export default async function RootLayout({ children }: RootLayoutProps) { 53 | const rateLimitRemaining = await fetchedData() 54 | const { userId } = auth() 55 | return ( 56 | 57 | 58 | 62 | 68 | {!userId && ( 69 |
70 |

71 | Rate limit remaining: 72 | {rateLimitRemaining} 73 |

74 | 75 |
76 | )} 77 | {userId && ( 78 | <> 79 | 80 | 81 | 82 | 83 | 84 | 85 | )} 86 | {children} 87 | 88 | 89 |
90 | 91 | 92 |
93 | ) 94 | } 95 | -------------------------------------------------------------------------------- /app/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | sharedTitle, 3 | sharedImage, 4 | sharedDescription, 5 | } from '@/app/shared-metadata' 6 | import { ImageResponse } from 'next/og' 7 | import { getMediumFont } from '@/lib/utils' 8 | import { OgImage } from '@/components/OgImage' 9 | 10 | export const runtime = 'edge' 11 | 12 | export const alt = sharedTitle 13 | 14 | export const size = { 15 | width: sharedImage.width, 16 | height: sharedImage.height, 17 | } 18 | export const contentType = sharedImage.type 19 | 20 | export default async function Image() { 21 | return new ImageResponse( 22 | , 23 | { 24 | ...size, 25 | fonts: [ 26 | { 27 | name: 'Space Grotesk', 28 | data: await getMediumFont(), 29 | style: 'normal', 30 | weight: 500, 31 | }, 32 | { 33 | name: 'Space Grotesk', 34 | data: await getMediumFont(), 35 | style: 'normal', 36 | weight: 600, 37 | }, 38 | ], 39 | }, 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { nanoid } from '@/lib/utils' 2 | import Chat from '@/components/Chat' 3 | import { AI } from '@/lib/chat/actions' 4 | import { getMissingKeys } from './actions' 5 | 6 | async function HomePage() { 7 | const missingKeys = await getMissingKeys() 8 | const id = nanoid() 9 | return ( 10 | 11 | 15 | 16 | ) 17 | } 18 | 19 | export default HomePage 20 | -------------------------------------------------------------------------------- /app/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { TooltipProvider } from '@/components/ui/tooltip' 4 | import { SidebarProvider } from '@/lib/hooks/use-sidebar' 5 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 6 | import { ThemeProvider } from 'next-themes' 7 | import { ThemeProviderProps } from 'next-themes/dist/types' 8 | 9 | const queryClient = new QueryClient() 10 | 11 | export function Providers({ children, ...props }: ThemeProviderProps) { 12 | return ( 13 | 14 | 15 | 16 | {children} 17 | 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /app/shared-metadata.ts: -------------------------------------------------------------------------------- 1 | export const sharedTitle = 'GitHub Assistant' 2 | export const sharedDescription = 3 | "An experimental AI Chatbot utilising generative UI, serving data from GitHub's API through interactive UI components." 4 | export const sharedImage = { 5 | width: 1200, 6 | height: 630, 7 | type: 'image/png', 8 | } 9 | -------------------------------------------------------------------------------- /app/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from '@clerk/nextjs' 2 | import React from 'react' 3 | 4 | function SignInPage() { 5 | return ( 6 |
7 | 8 |
9 | ) 10 | } 11 | 12 | export default SignInPage 13 | -------------------------------------------------------------------------------- /assets/fonts/LabilGrotesk-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kayaayberk/generative-ui-github-assistant/7242e9857fda79bc13f8b9f046c498b57029020d/assets/fonts/LabilGrotesk-Medium.ttf -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /components/AssistantDisplay.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/lib/utils' 4 | 5 | type AssistantDisplayProps = { 6 | children: React.ReactNode 7 | } 8 | 9 | function AssistantDisplay({ children }: AssistantDisplayProps) { 10 | return ( 11 |
12 |
13 | GitHub Assistant 14 | {children} 15 |
16 |
17 | ) 18 | } 19 | 20 | export default AssistantDisplay 21 | -------------------------------------------------------------------------------- /components/Chat.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Message } from 'ai' 4 | import ChatPanel from './ChatPanel' 5 | import { sleep } from '@/lib/utils' 6 | import { useUser } from '@clerk/nextjs' 7 | import { useToast } from './ui/use-toast' 8 | import { PromptForm } from './PromptForm' 9 | import { ChatMessage } from './ChatMessage' 10 | import { ScrollArea } from './ui/scroll-area' 11 | import { useUIState, useAIState } from 'ai/rsc' 12 | import { useEffect, useRef, useState } from 'react' 13 | import { useSidebar } from '@/lib/hooks/use-sidebar' 14 | import { usePathname, useRouter } from 'next/navigation' 15 | import { useLocalStorage } from '@/lib/hooks/use-local-storage' 16 | 17 | export interface ChatProps extends React.ComponentProps<'div'> { 18 | initialMessages?: Message[] 19 | id: string 20 | missingKeys: string[] 21 | } 22 | 23 | function Chat({ id, missingKeys }: ChatProps) { 24 | const ref = useRef(null) 25 | const { isSignedIn } = useUser() 26 | 27 | const [input, setInput] = useState('') 28 | const [messages] = useUIState() 29 | const [aiState] = useAIState() 30 | 31 | const router = useRouter() 32 | const pathname = usePathname() 33 | 34 | const { toast } = useToast() 35 | 36 | const [_, setNewChatId] = useLocalStorage('newChatId', id) 37 | const { isSidebarOpen, isLoading, toggleSidebar } = useSidebar() 38 | 39 | useEffect(() => { 40 | const messagesLength = aiState.messages?.length 41 | if (messagesLength === 3) { 42 | sleep(500).then(() => { 43 | ref.current?.scrollTo(0, ref.current.scrollHeight) 44 | router.refresh() 45 | }) 46 | } 47 | }, [aiState.messages, router]) 48 | 49 | useEffect(() => { 50 | setNewChatId(id) 51 | }, []) 52 | 53 | useEffect(() => { 54 | missingKeys.map((key) => { 55 | toast({ 56 | title: 'Error', 57 | description: `Missing ${key} environment variable!`, 58 | variant: 'destructive', 59 | }) 60 | }) 61 | }, [missingKeys]) 62 | 63 | return ( 64 |
65 | 66 |
70 | 71 |
72 | 73 |
74 |
77 | 78 |

79 | GitHub assistant is a personal and experimental project. Please do not 80 | abuse it in term of token usage. 81 |

82 |
83 |
84 | ) 85 | } 86 | 87 | export default Chat 88 | -------------------------------------------------------------------------------- /components/ChatHistory.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { SidebarList } from './SidebarList' 3 | import { db } from '@/db' 4 | import { eq } from 'drizzle-orm' 5 | import UserBadge from './UserBadge' 6 | import ThemeToggle from './ThemeToggle' 7 | import { currentUser } from '@clerk/nextjs' 8 | import { InsertUser, user } from '@/db/schema' 9 | import { Plus } from '@phosphor-icons/react/dist/ssr' 10 | import ClearAllChats from './ClearAllChats' 11 | 12 | export async function ChatHistory() { 13 | const loggedInUser = await currentUser() 14 | if (!loggedInUser) { 15 | console.log('No user logged in from ChatHistory component.') 16 | return null 17 | } 18 | 19 | if (loggedInUser) { 20 | const existingUser = await db 21 | .select({ id: user.id }) 22 | .from(user) 23 | .where(eq(user.id, loggedInUser.id)) 24 | // console.log('user.id: ', user.id, 'loggedInUser.id:', loggedInUser.id) 25 | 26 | if (!existingUser.length) { 27 | const newUser: InsertUser = { 28 | id: loggedInUser.id, 29 | email: loggedInUser.emailAddresses[0].emailAddress ?? '', 30 | name: loggedInUser.firstName ?? '', 31 | surname: loggedInUser.lastName ?? '', 32 | createdAt: new Date(), 33 | } 34 | await db 35 | .insert(user) 36 | .values(newUser) 37 | .onConflictDoUpdate({ 38 | target: user.id, 39 | set: { 40 | email: newUser.email, 41 | id: newUser.id, 42 | name: newUser.name, 43 | surname: newUser.surname, 44 | }, 45 | }) 46 | } 47 | } else { 48 | console.log('No user logged in from ChatHistory component.') 49 | throw new Error('No user logged in from ChatHistory component.') 50 | } 51 | return ( 52 |
53 |
Chat History
54 | 59 | New Chat 60 | 61 | 62 |
63 | 64 |
65 | 66 |
67 | 68 | 69 | 70 |
71 |
72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /components/ChatMessage.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect } from 'react' 4 | import { useUser } from '@clerk/nextjs' 5 | import { UIState } from '@/lib/chat/actions' 6 | import { usePathname } from 'next/navigation' 7 | 8 | export interface ChatList { 9 | messages: UIState 10 | id: string 11 | } 12 | 13 | export function ChatMessage({ messages, id }: ChatList) { 14 | const pathname = usePathname() 15 | const { isSignedIn } = useUser() 16 | 17 | useEffect(() => { 18 | if (isSignedIn) { 19 | if (!pathname.includes(id) && messages.length === 1) { 20 | window.history.replaceState({}, '', `/chat/${id}`) 21 | } 22 | } 23 | }, [pathname, isSignedIn, messages]) 24 | 25 | if (!messages.length) { 26 | return null 27 | } 28 | return ( 29 |
30 | {messages.map((message, index) => ( 31 |
32 | {message.display} 33 |
34 | ))} 35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /components/ChatPanel.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { nanoid } from 'nanoid' 4 | import { useUser } from '@clerk/nextjs' 5 | import { AI } from '@/lib/chat/actions' 6 | import { UserMessage } from './assistant/Message' 7 | import { useSidebar } from '@/lib/hooks/use-sidebar' 8 | import { useAIState, useActions, useUIState } from 'ai/rsc' 9 | import { ArrowRight, GithubLogo } from '@phosphor-icons/react' 10 | 11 | function ChatPanel() { 12 | const exampleMessages = [ 13 | { 14 | heading: 'Search for the username', 15 | subheading: 'Jaredpalmer', 16 | message: `Search for the username jaredpalmer`, 17 | }, 18 | { 19 | heading: 'Search for repositories', 20 | subheading: 'with "state management" in description.', 21 | message: 22 | 'Search for repositories with "state management" in description.', 23 | }, 24 | { 25 | heading: 'Search for repository content', 26 | subheading: 'of shadcn/ui', 27 | message: `Search for repository content of shadcn-ui/ui.`, 28 | }, 29 | { 30 | heading: 'Show README.md of', 31 | subheading: 'Reduxjs/redux?', 32 | message: `Show README.md of Reduxjs/redux`, 33 | }, 34 | ] 35 | const [aiState] = useAIState() 36 | const { submitUserMessage } = useActions() 37 | const [messages, setMessages] = useUIState() 38 | const { isSignedIn, user } = useUser() 39 | const { isSidebarOpen, isLoading, toggleSidebar } = useSidebar() 40 | 41 | return ( 42 |
45 |
0 ? 'hidden' : 'block'} mx-auto w-full flex flex-col gap-4 z-10 justify-center items-center`} 47 | > 48 |

49 | 50 | 51 | GitHub Assistant 52 | 53 |

54 |

57 | Actions related to GitHub are tightly rate-limited. Please sign in 58 | with GitHub not to get rate-limited. 59 |

60 |
61 |
0 ? 'hidden' : 'block'} mx-auto w-full`} 63 | > 64 |
65 | {exampleMessages.map((example, index) => ( 66 |
{ 72 | setMessages((currentMessages) => [ 73 | ...currentMessages, 74 | { 75 | id: nanoid(), 76 | display: {example.message}, 77 | }, 78 | ]) 79 | 80 | const responseMessage = await submitUserMessage(example.message) 81 | 82 | setMessages((currentMessages) => [ 83 | ...currentMessages, 84 | responseMessage, 85 | ]) 86 | }} 87 | > 88 |
89 | 90 | 91 | {example.heading} 92 | {example.subheading} 93 | 94 |
95 |
96 | ))} 97 |
98 |
99 |
100 | ) 101 | } 102 | 103 | export default ChatPanel 104 | -------------------------------------------------------------------------------- /components/ClearAllChats.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { 4 | AlertDialog, 5 | AlertDialogTitle, 6 | AlertDialogAction, 7 | AlertDialogCancel, 8 | AlertDialogFooter, 9 | AlertDialogHeader, 10 | AlertDialogContent, 11 | AlertDialogTrigger, 12 | AlertDialogDescription, 13 | } from '@/components/ui/alert-dialog' 14 | import React from 'react' 15 | import { Button } from './ui/button' 16 | import { useToast } from './ui/use-toast' 17 | import { useRouter } from 'next/navigation' 18 | import { clearAllChats } from '@/app/actions' 19 | import LoadingSpinner from './LoadingSpinner' 20 | import { Trash } from '@phosphor-icons/react' 21 | 22 | function ClearAllChats({ userId }: { userId: string }) { 23 | const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) 24 | const [isRemovePending, startRemoveTransition] = React.useTransition() 25 | const { toast } = useToast() 26 | const router = useRouter() 27 | return ( 28 | <> 29 | 40 | 41 | 42 | 43 | Are you absolutely sure? 44 | 45 | This action cannot be undone. This will permanently delete all of 46 | your chat history and remove the chat data from our servers. 47 | 48 | 49 | 50 | 51 | Cancel 52 | 53 | { 56 | e.preventDefault() 57 | startRemoveTransition(async () => { 58 | const result = await clearAllChats(userId) 59 | if (result && 'error' in result) { 60 | toast({ 61 | title: 'Error', 62 | description: result.error, 63 | variant: 'destructive', 64 | }) 65 | return 66 | } 67 | setDeleteDialogOpen(false) 68 | router.refresh() 69 | router.push('/chat') 70 | toast({ 71 | title: 'Chat deleted', 72 | description: 'Your chat has been successfully deleted.', 73 | className: 'bg-green-500 text-white', 74 | }) 75 | }) 76 | }} 77 | className='flex items-center gap-2 bg-red-500 hover:bg-red-600 text-white' 78 | > 79 | {isRemovePending && } 80 | Delete 81 | 82 | 83 | 84 | 85 | 86 | ) 87 | } 88 | 89 | export default ClearAllChats 90 | -------------------------------------------------------------------------------- /components/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | function LoadingSpinner() { 4 | return ( 5 |
6 |
11 | Loading... 12 |
13 |
14 | ) 15 | } 16 | 17 | export default LoadingSpinner 18 | -------------------------------------------------------------------------------- /components/Markdown.tsx: -------------------------------------------------------------------------------- 1 | import { FC, memo } from 'react' 2 | import ReactMarkDown, { Options } from 'react-markdown' 3 | 4 | /** 5 | * This code snippet is creating a memoized version of the ReactMarkDown component using the memo function from React. 6 | * https://github.com/vercel/ai-chatbot/blob/main/components/markdown.tsx#L4 7 | */ 8 | export const MemoizedReactMarkdown: FC = memo( 9 | ReactMarkDown, 10 | (prevProps, nextProps) => 11 | prevProps.children === nextProps.children && 12 | prevProps.className === nextProps.className, 13 | ) 14 | -------------------------------------------------------------------------------- /components/Navigation.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Link from 'next/link' 4 | import * as React from 'react' 5 | import { cn } from '@/lib/utils' 6 | import { 7 | NavigationMenu, 8 | NavigationMenuItem, 9 | NavigationMenuLink, 10 | NavigationMenuList, 11 | NavigationMenuTrigger, 12 | NavigationMenuContent, 13 | navigationMenuTriggerStyle, 14 | } from '@/components/ui/navigation-menu' 15 | import { 16 | Globe, 17 | SignIn, 18 | GithubLogo, 19 | LinkSimple, 20 | LinkedinLogo, 21 | } from '@phosphor-icons/react' 22 | 23 | const components: { title: string; href: string; icon: any }[] = [ 24 | { 25 | title: 'Aybrk.dev', 26 | href: 'https://aybrk.dev', 27 | icon: , 28 | }, 29 | { 30 | title: 'GitHub', 31 | href: 'https://github.com/kayaayberk', 32 | icon: , 33 | }, 34 | { 35 | title: 'LinkedIn', 36 | href: 'https://www.linkedin.com/in/kayaayberk/', 37 | icon: , 38 | }, 39 | ] 40 | 41 | export default function Navigation() { 42 | return ( 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | Login 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | Links 65 | 66 | 67 | 68 |
    69 | {components.map((component) => ( 70 | 77 | {component.icon} 78 | 79 | ))} 80 |
81 |
82 |
83 |
84 |
85 | ) 86 | } 87 | 88 | const ListItem = React.forwardRef< 89 | React.ElementRef<'a'>, 90 | React.ComponentPropsWithoutRef<'a'> 91 | >(({ className, title, children, ...props }, ref) => { 92 | return ( 93 |
  • 94 | 95 | 103 |

    {children}

    104 |
    {title}
    105 |
    106 |
    107 |
  • 108 | ) 109 | }) 110 | ListItem.displayName = 'ListItem' 111 | -------------------------------------------------------------------------------- /components/OgImage.tsx: -------------------------------------------------------------------------------- 1 | import { GithubLogo } from '@phosphor-icons/react/dist/ssr' 2 | 3 | export function OgImage({ title, description, url, image }: any) { 4 | return ( 5 | <> 6 |
    15 |
    23 |
    38 | {`githubassistant.vercel.app`} 39 |
    40 |
    49 | 56 | 57 | 58 |
    59 | 67 |
    78 | 88 | 95 | 96 | 97 | {title} 98 | 99 |
    100 | {description && ( 101 | 110 | {description} 111 | 112 | )} 113 |
    114 |
    115 | 116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /components/PromptForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { 4 | User, 5 | Code, 6 | Sparkle, 7 | BookBookmark, 8 | ArrowElbowDownLeft, 9 | Plus, 10 | } from '@phosphor-icons/react' 11 | import { 12 | DropdownMenu, 13 | DropdownMenuLabel, 14 | DropdownMenuContent, 15 | DropdownMenuTrigger, 16 | DropdownMenuRadioItem, 17 | DropdownMenuSeparator, 18 | DropdownMenuRadioGroup, 19 | } from '@/components/ui/dropdown-menu' 20 | import * as React from 'react' 21 | import { nanoid } from 'nanoid' 22 | import { Button } from './ui/button' 23 | import { AI } from '@/lib/chat/actions' 24 | import { Textarea } from './ui/textarea' 25 | import { AttributeTypes } from '@/lib/types' 26 | import { UserMessage } from './assistant/Message' 27 | import { useAIState, useActions, useUIState } from 'ai/rsc' 28 | import { useEnterSubmit } from '@/lib/hooks/use-enter-submit' 29 | import { usePathname } from 'next/navigation' 30 | import Link from 'next/link' 31 | 32 | const ChatFilters = [ 33 | { 34 | name: 'General', 35 | value: 'general', 36 | role: 'assistant', 37 | icon: , 38 | status: 'active', 39 | }, 40 | { 41 | name: 'User Search', 42 | value: 'user-search', 43 | role: 'function', 44 | icon: , 45 | status: 'active', 46 | }, 47 | { 48 | name: 'Repository Search', 49 | value: 'repository-search', 50 | role: 'function', 51 | icon: , 52 | status: 'active', 53 | }, 54 | { 55 | name: 'Code Search', 56 | value: 'code-search', 57 | role: 'function', 58 | icon: , 59 | status: 'disabled', 60 | }, 61 | ] 62 | 63 | export function PromptForm({ 64 | input, 65 | setInput, 66 | }: { 67 | input: string 68 | setInput: (value: string) => void 69 | }) { 70 | const [_, setMessages] = useUIState() 71 | const [aiState, setAIState] = useAIState() 72 | const { submitUserMessage } = useActions() 73 | const [attribute, setAttribute] = React.useState('general') 74 | const [newAttribute, setNewAttribute] = React.useState(null) 75 | 76 | // Unique identifier for this UI component. 77 | const id = React.useId() 78 | const { formRef, onKeyDown } = useEnterSubmit() 79 | const inputRef = React.useRef(null) 80 | const pathname = usePathname() 81 | 82 | // Set the initial attribute to general 83 | const message = { 84 | role: 'system' as const, 85 | content: `[User has changed the attribute to general]`, 86 | 87 | // Identifier of this UI component, so we don't insert it many times. 88 | id, 89 | } 90 | if (!aiState.messages.length) { 91 | setAIState({ ...aiState, messages: [...aiState.messages, message] }) 92 | } 93 | 94 | // Whenever the attribute changes, we need to update the local value state and the history 95 | // so LLM also knows what's going on. 96 | function onAttributeChange(e: any) { 97 | const newValue = e 98 | 99 | if (newAttribute === null) return // if newAttribute is null, don't run the effect 100 | 101 | // Insert a hidden history info to the list. 102 | const message = { 103 | role: 'system' as const, 104 | content: `[User has changed the attribute to ${newValue}]`, 105 | id, 106 | } 107 | 108 | // If last history state is already this info, update it. This is to avoid 109 | // adding every attribute change to the history. 110 | if (aiState.messages[aiState.messages.length - 1]?.id === id) { 111 | setAIState({ 112 | ...aiState, 113 | messages: [...aiState.messages.slice(0, -1), message], 114 | }) 115 | return 116 | } 117 | 118 | // If it doesn't exist, append it to history. 119 | setAIState({ ...aiState, messages: [...aiState.messages, message] }) 120 | } 121 | 122 | React.useEffect(() => { 123 | if (inputRef.current) { 124 | inputRef.current.focus() 125 | } 126 | }, []) 127 | 128 | return ( 129 |
    { 132 | e.preventDefault() 133 | 134 | // Blur focus on mobile 135 | if (window.innerWidth < 600) { 136 | e.target['message']?.blur() 137 | } 138 | 139 | const value = input.trim() 140 | setInput('') 141 | if (!value) return 142 | 143 | // Optimistically add user message UI 144 | setMessages((currentMessages) => [ 145 | ...currentMessages, 146 | { 147 | id: nanoid(), 148 | display: {value}, 149 | }, 150 | ]) 151 | 152 | // Force attributes 153 | // Submit and get response message 154 | const responseMessage = await submitUserMessage(value) 155 | setMessages((currentMessages) => [...currentMessages, responseMessage]) 156 | }} 157 | className='w-full max-w-2xl mx-auto flex items-center' 158 | > 159 |
    160 | 161 | 162 | 169 | 170 | 171 | Choose Attribute 172 | 173 | 177 | {ChatFilters.map((attribute: AttributeTypes) => { 178 | return ( 179 | onAttributeChange(attribute.value)} 185 | > 186 | 187 | {attribute.icon && attribute.icon} 188 | 189 | {attribute.name} 190 | 191 | ) 192 | })} 193 | 194 | 195 | 196 |