├── .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 |
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 | setDeleteDialogOpen(true)}
34 | >
35 | Clear Chat History
36 |
37 |
38 |
39 |
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 |
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 |
226 | )
227 | }
228 |
--------------------------------------------------------------------------------
/components/RateLimited.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Warning } from '@phosphor-icons/react'
4 | import Link from 'next/link'
5 |
6 | function RateLimited() {
7 | return (
8 |
9 |
10 | Too many actions...
11 |
12 |
13 | Your IP address has been rate-limted by{' '}
14 | GitHub . Please{' '}
15 |
19 | Sign-in
20 | {' '}
21 | to continue or come back later.
22 |
23 |
24 | )
25 | }
26 |
27 | export default RateLimited
28 |
--------------------------------------------------------------------------------
/components/SideBarActions.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | AlertDialog,
5 | AlertDialogTitle,
6 | AlertDialogAction,
7 | AlertDialogCancel,
8 | AlertDialogFooter,
9 | AlertDialogHeader,
10 | AlertDialogContent,
11 | AlertDialogDescription,
12 | } from '@/components/ui/alert-dialog'
13 | import {
14 | Tooltip,
15 | TooltipContent,
16 | TooltipTrigger,
17 | } from '@/components/ui/tooltip'
18 | import * as React from 'react'
19 | import { useToast } from './ui/use-toast'
20 | import { useRouter } from 'next/navigation'
21 | import LoadingSpinner from './LoadingSpinner'
22 | import { Trash } from '@phosphor-icons/react'
23 | import { Button } from '@/components/ui/button'
24 | import { Chat, ServerActionResult } from '@/lib/types'
25 |
26 | interface SidebarActionsProps {
27 | chat: Chat
28 | removeChat: (args: { id: string; path: string }) => ServerActionResult
29 | }
30 |
31 | function SideBarActions({ chat, removeChat }: SidebarActionsProps) {
32 | const { toast } = useToast()
33 | const router = useRouter()
34 | const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
35 | const [isRemovePending, startRemoveTransition] = React.useTransition()
36 |
37 | return (
38 | <>
39 |
40 |
41 |
42 | setDeleteDialogOpen(true)}
47 | >
48 |
49 | Delete
50 |
51 |
52 | Delete chat
53 |
54 |
55 |
56 |
57 |
58 | Are you absolutely sure?
59 |
60 | This action cannot be undone. This will permanently delete your
61 | chat and remove the chat data from our servers.
62 |
63 |
64 |
65 |
66 | Cancel
67 |
68 | {
71 | e.preventDefault()
72 | startRemoveTransition(async () => {
73 | const result = await removeChat({
74 | id: chat.id,
75 | path: chat.path,
76 | })
77 | if (result && 'error' in result) {
78 | toast({
79 | title: 'Error',
80 | description: result.error,
81 | variant: 'destructive',
82 | })
83 | return
84 | }
85 | setDeleteDialogOpen(false)
86 | router.refresh()
87 | router.push('/chat')
88 | toast({
89 | title: 'Chat deleted',
90 | description: 'Your chat has been successfully deleted.',
91 | className: 'bg-green-500 text-white',
92 | })
93 | })
94 | }}
95 | className='flex items-center gap-2 bg-red-500 hover:bg-red-600 text-white'
96 | >
97 | {isRemovePending && }
98 | Delete
99 |
100 |
101 |
102 |
103 | >
104 | )
105 | }
106 |
107 | export default SideBarActions
108 |
--------------------------------------------------------------------------------
/components/Sidebar.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { cn } from '@/lib/utils'
4 | import { useSidebar } from '@/lib/hooks/use-sidebar'
5 |
6 | export interface SidebarProps extends React.ComponentProps<'div'> {}
7 |
8 | function Sidebar({ className, children }: SidebarProps) {
9 | const { isSidebarOpen, isLoading } = useSidebar()
10 | return (
11 |
15 | {children}
16 |
17 | )
18 | }
19 |
20 | export default Sidebar
21 |
--------------------------------------------------------------------------------
/components/SidebarDesktop.tsx:
--------------------------------------------------------------------------------
1 | import Sidebar from './Sidebar'
2 | import { ChatHistory } from './ChatHistory'
3 |
4 | async function SidebarDesktop() {
5 | return (
6 |
7 |
8 |
9 | )
10 | }
11 |
12 | export default SidebarDesktop
13 |
--------------------------------------------------------------------------------
/components/SidebarItem.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import Link from 'next/link'
4 | import { motion } from 'framer-motion'
5 | import { type Chat } from '@/lib/types'
6 | import { usePathname } from 'next/navigation'
7 | import { Chat as ChatIcon } from '@phosphor-icons/react'
8 | import { useLocalStorage } from '@/lib/hooks/use-local-storage'
9 |
10 | interface SidebarItemProps {
11 | index: number
12 | chat: Chat
13 | children: React.ReactNode
14 | }
15 |
16 | function SidebarItem({ index, chat, children }: SidebarItemProps) {
17 | const pathname = usePathname()
18 | const isActive = pathname === chat.path
19 | const [newChatId, setNewChatId] = useLocalStorage('newChatId', null)
20 |
21 | const shouldAnimate = index === 0 && isActive && newChatId
22 |
23 | if (!chat?.id) return null
24 |
25 | return (
26 |
45 |
54 |
55 |
56 |
57 |
58 | {' '}
59 | {chat.title.split('').map((character, index) => {
60 | return (
61 | {
82 | if (index === chat.title.length - 1) {
83 | setNewChatId(null)
84 | }
85 | }}
86 | >
87 | {character}
88 |
89 | )
90 | })}
91 |
92 |
93 | {isActive && {children}
}
94 |
95 | )
96 | }
97 |
98 | export default SidebarItem
99 |
--------------------------------------------------------------------------------
/components/SidebarItems.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Chat } from '@/lib/types'
4 | import SidebarItem from './SidebarItem'
5 | import { removeChat } from '@/app/actions'
6 | import SideBarActions from './SideBarActions'
7 |
8 | interface SidebarItemsProps {
9 | chats?: Chat[]
10 | }
11 | function SidebarItems({ chats }: SidebarItemsProps) {
12 | if (!chats?.length) return null
13 |
14 | return (
15 |
16 | {chats
17 | .sort(
18 | (a, b) =>
19 | new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
20 | )
21 | .map(
22 | (chat, index) =>
23 | chat && (
24 |
25 |
26 |
31 |
32 |
33 | ),
34 | )
35 | .reverse()}
36 |
37 | )
38 | }
39 |
40 | export default SidebarItems
41 |
--------------------------------------------------------------------------------
/components/SidebarList.tsx:
--------------------------------------------------------------------------------
1 | import { cache } from 'react'
2 | import { getChats } from '@/app/actions'
3 | import SidebarItems from './SidebarItems'
4 |
5 | interface SidebarListProps {
6 | userId: string
7 | children?: React.ReactNode
8 | }
9 |
10 | const loadChats = cache(async (userId: string) => {
11 | return await getChats(userId)
12 | })
13 |
14 | export async function SidebarList({ userId }: SidebarListProps) {
15 | const chats = await loadChats(userId)
16 | return (
17 |
18 | {chats?.length ? (
19 |
20 | ) : (
21 |
22 |
No chat history
23 |
24 | )}
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/components/SidebarMobile.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import Sidebar from './Sidebar'
4 | import { Button } from './ui/button'
5 | import { useTheme } from 'next-themes'
6 | import { SidebarSimple } from '@phosphor-icons/react'
7 | import { Sheet, SheetContent, SheetTrigger } from './ui/sheet'
8 |
9 | interface SidebarMobileProps {
10 | children: React.ReactNode
11 | }
12 |
13 | function SidebarMobile({ children }: SidebarMobileProps) {
14 | const { theme } = useTheme()
15 |
16 | return (
17 |
18 |
19 |
23 |
27 |
28 |
29 |
33 | {children}
34 |
35 |
36 | )
37 | }
38 |
39 | export default SidebarMobile
40 |
--------------------------------------------------------------------------------
/components/SidebarToggle.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button } from './ui/button'
4 | import { useTheme } from 'next-themes'
5 | import { CaretLeft } from '@phosphor-icons/react'
6 | import { useSidebar } from '@/lib/hooks/use-sidebar'
7 |
8 | function SidebarToggle() {
9 | const { theme } = useTheme()
10 | const { isSidebarOpen, toggleSidebar } = useSidebar()
11 | return (
12 | toggleSidebar()}
16 | >
17 |
22 |
23 | )
24 | }
25 |
26 | export default SidebarToggle
27 |
--------------------------------------------------------------------------------
/components/ThemeToggle.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import { Button } from './ui/button'
5 | import { useTheme } from 'next-themes'
6 | import { Moon, Sun } from '@phosphor-icons/react'
7 |
8 | function ThemeToggle() {
9 | const { theme, setTheme, resolvedTheme } = useTheme()
10 | return (
11 | {
15 | setTheme(resolvedTheme === 'light' ? 'dark' : 'light')
16 | }}
17 | >
18 | {resolvedTheme === 'dark' ? (
19 |
20 | Theme
21 |
22 |
23 | ) : (
24 |
25 | Theme
26 |
27 |
28 | )}
29 |
30 | )
31 | }
32 |
33 | export default ThemeToggle
34 |
--------------------------------------------------------------------------------
/components/UserBadge.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | useUser,
5 | SignedIn,
6 | SignedOut,
7 | SignInButton,
8 | SignOutButton,
9 | } from '@clerk/nextjs'
10 | import {
11 | DropdownMenu,
12 | DropdownMenuLabel,
13 | DropdownMenuContent,
14 | DropdownMenuTrigger,
15 | DropdownMenuRadioItem,
16 | DropdownMenuSeparator,
17 | DropdownMenuRadioGroup,
18 | } from '@/components/ui/dropdown-menu'
19 | import React from 'react'
20 | import Image from 'next/image'
21 | import { Button } from './ui/button'
22 | import { Skeleton } from './ui/skeleton'
23 | import { useRouter } from 'next/navigation'
24 | import LoadingSpinner from './LoadingSpinner'
25 | import { Gear, SignOut, User } from '@phosphor-icons/react'
26 | import { DropdownMenuItem } from '@radix-ui/react-dropdown-menu'
27 |
28 | function UserBadge() {
29 | const [position, setPosition] = React.useState('bottom')
30 | const { isLoaded, user } = useUser()
31 | const router = useRouter()
32 |
33 | return (
34 | <>
35 |
36 |
37 |
38 |
39 |
40 |
41 |
45 |
46 | {isLoaded ? (
47 | <>
48 |
49 | {user ? (
50 |
56 | ) : (
57 |
58 | )}
59 |
60 |
61 |
62 |
63 | {user?.firstName}
64 |
65 |
66 | {user?.lastName}
67 |
68 |
69 |
70 | {user?.emailAddresses[0].emailAddress}
71 |
72 |
73 | >
74 | ) : (
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | )}
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | {user ? (
92 |
98 | ) : (
99 |
100 | )}
101 |
102 |
103 |
104 |
105 | {user?.firstName}
106 |
107 |
108 | {user?.lastName}
109 |
110 |
111 |
112 | {user?.emailAddresses[0].emailAddress}
113 |
114 |
115 |
116 |
117 |
118 |
123 |
124 |
125 |
126 |
127 |
128 | Usage
129 | In progress...
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 | Profile
138 | In progress...
139 |
140 |
141 | router.push('/')}>
142 |
143 |
144 |
145 |
146 | Sign Out
147 |
148 |
149 |
150 |
151 |
152 |
153 | >
154 | )
155 | }
156 |
157 | export default UserBadge
158 |
--------------------------------------------------------------------------------
/components/assistant/Directory.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | Accordion,
5 | AccordionItem,
6 | AccordionTrigger,
7 | AccordionContent,
8 | } from '@/components/ui/accordion'
9 | import React from 'react'
10 | import { Button } from '../ui/button'
11 | import { Directory as Dir } from '@/lib/types'
12 | import AssistantDisplay from '../AssistantDisplay'
13 | import { useDecoder } from '@/lib/hooks/use-decoder'
14 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
15 | import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard'
16 | import { Check, Copy, File, FolderSimple } from '@phosphor-icons/react'
17 | import { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism'
18 | import { useGetDirectoryContent } from '@/lib/hooks/use-get-directory-content'
19 |
20 | export default function Directory({ props: directory }: { props: Dir[] }) {
21 | return (
22 |
23 |
24 |
25 | {Array.isArray(directory) &&
26 | directory
27 | .sort((a, b) => {
28 | if (a.type === 'dir' && b.type === 'file') {
29 | return -1
30 | } else if (a.type === 'file' && b.type === 'dir') {
31 | return 1
32 | } else {
33 | return 0
34 | }
35 | })
36 | .map((dir, index) => {
37 | return dir.type === 'file' ? (
38 |
43 |
44 |
45 |
46 | {dir.name}
47 |
48 |
49 |
50 |
51 |
52 |
53 | ) : (
54 |
59 |
60 |
61 |
62 | {dir.name}
63 |
64 |
65 |
66 |
67 |
68 |
69 | )
70 | })}
71 |
72 |
73 |
74 | )
75 | }
76 |
77 | function DropdownContent({ url }: { url: string }) {
78 | const data = useGetDirectoryContent(url)
79 | if (!data) {
80 | return null
81 | }
82 | const { fetchedData, isLoading } = data
83 |
84 | return (
85 |
86 | {fetchedData &&
87 | Array.isArray(fetchedData) &&
88 | fetchedData
89 | .sort((a, b) => {
90 | if (a.type === 'dir' && b.type === 'file') {
91 | return -1
92 | } else if (a.type === 'file' && b.type === 'dir') {
93 | return 1
94 | } else {
95 | return 0
96 | }
97 | })
98 | .map((item, index) => {
99 | return item.type === 'file' ? (
100 |
105 |
110 |
111 |
112 | {item.name}
113 |
114 |
115 |
116 |
117 |
118 |
119 | ) : (
120 |
121 |
122 |
123 |
124 | {item.name}
125 |
126 |
127 |
128 |
129 |
130 |
131 | )
132 | })}
133 |
134 | )
135 | }
136 |
137 | function DropdownFileContent({
138 | url,
139 | className,
140 | }: {
141 | url: string
142 | className?: string
143 | }) {
144 | const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 })
145 | const data = useDecoder(url)
146 |
147 | if (!data) {
148 | return null
149 | }
150 |
151 | const { fetchedData } = data
152 |
153 | const match = /language-(\w+)/.exec(className || '')
154 |
155 | const onCopy = () => {
156 | if (isCopied) return
157 | copyToClipboard(fetchedData)
158 | }
159 |
160 | return (
161 |
162 |
163 |
{(match && match[1]) || ''}
164 |
165 |
171 | {isCopied ? : }
172 |
173 |
174 |
175 |
199 | {fetchedData}
200 |
201 |
202 | )
203 | }
204 |
--------------------------------------------------------------------------------
/components/assistant/Message.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import Image from 'next/image'
4 | import { cn } from '@/lib/utils'
5 | import remarkGfm from 'remark-gfm'
6 | import { Spinner } from './Spinner'
7 | import remarkMath from 'remark-math'
8 | import { useUser } from '@clerk/nextjs'
9 | import { StreamableValue } from 'ai/rsc'
10 | import { Skeleton } from '../ui/skeleton'
11 | import { CodeBlock } from '../ui/code-block'
12 | import { Sparkle, User } from '@phosphor-icons/react'
13 | import { MemoizedReactMarkdown } from '../Markdown'
14 | import { useStreamableText } from '@/lib/hooks/use-streamable-text'
15 | import { useTheme } from 'next-themes'
16 |
17 | export function SpinnerMessage() {
18 | return (
19 |
20 |
21 |
22 |
23 |
24 | {Spinner}
25 |
26 |
27 | )
28 | }
29 |
30 | export function BotMessage({
31 | content,
32 | className,
33 | }: {
34 | content: string | StreamableValue
35 | className?: string
36 | }) {
37 | const { rawContent, isLoading } = useStreamableText(content)
38 |
39 | return (
40 |
41 |
42 |
43 |
44 |
45 | GitHub Assistant
46 | {children}
52 | },
53 | code({ node, inline, className, children, ...props }) {
54 | if (children && children.length) {
55 | if (children[0] == '▍') {
56 | return (
57 | ▍
58 | )
59 | }
60 |
61 | children[0] = (children[0] as string).replace('`▍`', '▍')
62 | }
63 |
64 | const match = /language-(\w+)/.exec(className || '')
65 |
66 | if (inline) {
67 | return (
68 |
69 | {children}
70 |
71 | )
72 | }
73 |
74 | return (
75 |
81 | )
82 | },
83 | }}
84 | >
85 | {rawContent}
86 |
87 |
88 |
89 | )
90 | }
91 |
92 | export function UserMessage({ children }: { children: React.ReactNode }) {
93 | const { isLoaded, isSignedIn, user } = useUser()
94 | const { theme } = useTheme()
95 |
96 | return (
97 |
98 |
99 | {!user?.imageUrl ? (
100 | //
101 |
102 | ) : (
103 |
104 | )}
105 |
106 |
107 | You
108 | {children}
109 |
110 |
111 | )
112 | }
113 |
114 | export function BotCard({
115 | children,
116 | showAvatar = true,
117 | }: {
118 | children: React.ReactNode
119 | showAvatar?: boolean
120 | }) {
121 | return (
122 |
123 |
129 |
130 |
131 |
{children}
132 |
133 | )
134 | }
135 |
136 | export function SystemMessage({ children }: { children: React.ReactNode }) {
137 | return (
138 |
145 | )
146 | }
147 |
--------------------------------------------------------------------------------
/components/assistant/Profile.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import Link from 'next/link'
5 | import Image from 'next/image'
6 | import { cn } from '@/lib/utils'
7 | import { Button } from '../ui/button'
8 | import { AI } from '@/lib/chat/actions'
9 | import { GithubUser } from '@/lib/types'
10 | import { useActions, useUIState } from 'ai/rsc'
11 | import { BookBookmark, Sparkle, Users } from '@phosphor-icons/react'
12 | import AssistantDisplay from '../AssistantDisplay'
13 |
14 | export function Profile({
15 | props: username,
16 | isMultiple,
17 | }: {
18 | props: GithubUser
19 | isMultiple?: boolean
20 | }) {
21 | const [messages, setMessages] = useUIState()
22 | const { repoAction } = useActions()
23 | // Unique identifier for this UI component.
24 | const id = React.useId()
25 |
26 | if (!username) {
27 | return null
28 | }
29 |
30 | return !isMultiple ? (
31 |
32 |
33 |
34 |
35 |
45 |
46 |
47 |
52 | {username.name}
53 |
54 |
55 | {username.login}
56 |
57 |
58 |
59 | {username.bio ? username.bio : '(No bio found)'}
60 |
61 |
62 |
63 |
64 | {username.location}
65 |
66 | •
67 |
68 |
69 |
70 |
71 | {username.public_repos}
72 |
73 | •
74 |
75 |
76 |
77 |
78 | {username.followers}
79 |
80 |
81 |
82 |
83 |
{
88 | const response = await repoAction(username.login)
89 | setMessages((currentMessages) => [
90 | ...currentMessages,
91 | response.newMessage,
92 | ])
93 | }}
94 | >
95 |
96 |
97 |
98 | Show Repositories
99 |
100 |
101 |
102 |
103 | ) : (
104 |
105 |
106 |
107 |
117 |
118 |
119 |
124 | {username.name}
125 |
126 |
127 | {username.login}
128 |
129 |
130 |
131 | {username.bio ? username.bio : '(No bio found)'}
132 |
133 |
134 |
135 |
136 | {username.location}
137 |
138 | •
139 |
140 |
141 |
142 |
143 | {username.public_repos}
144 |
145 | •
146 |
147 |
148 |
149 |
150 | {username.followers}
151 |
152 |
153 |
154 |
155 |
{
160 | const response = await repoAction(username.login)
161 | setMessages((currentMessages) => [
162 | ...currentMessages,
163 | response.newMessage,
164 | ])
165 | }}
166 | >
167 |
168 |
169 |
170 | Show Repositories
171 |
172 |
173 |
174 | )
175 | }
176 |
--------------------------------------------------------------------------------
/components/assistant/ProfileList.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import AssistantDisplay from '../AssistantDisplay'
4 | import { Profile } from './Profile'
5 | import { GithubUser } from '@/lib/types'
6 |
7 | export function ProfileList({ props: profiles }: { props: GithubUser[] }) {
8 | if (!Array.isArray(profiles)) {
9 | return []
10 | }
11 | if (profiles.length === 0) {
12 | return []
13 | }
14 | return (
15 |
16 |
17 | {profiles.map((user: GithubUser, index) => {
18 | return
19 | })}
20 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/components/assistant/ProfileSkeleton.tsx:
--------------------------------------------------------------------------------
1 | import AssistantDisplay from '../AssistantDisplay'
2 | import { Skeleton } from '../ui/skeleton'
3 |
4 | export const ProfileSkeleton = () => {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | )
18 | }
19 |
20 | export const ProfileListSkeleton = () => {
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | )
61 | }
62 |
--------------------------------------------------------------------------------
/components/assistant/Readme.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { cn } from '@/lib/utils'
4 | import rehypeRaw from 'rehype-raw'
5 | import remarkGfm from 'remark-gfm'
6 | import remarkMath from 'remark-math'
7 | import { CodeBlock } from '../ui/code-block'
8 | import { MemoizedReactMarkdown } from '../Markdown'
9 |
10 | export function Readme({ props: readme }: { props: string }) {
11 | return (
12 |
13 |
14 |
GitHub Assistant
15 |
{children}
23 | },
24 | br() {
25 | return <>>
26 | },
27 | h1({ children }) {
28 | return {children}
29 | },
30 | h2({ children }) {
31 | return {children}
32 | },
33 | h3({ children }) {
34 | return {children}
35 | },
36 | a({ node, href, children, ...props }) {
37 | let target = ''
38 |
39 | if (href?.startsWith('http')) {
40 | target = '_blank'
41 | } else if (href?.startsWith('#')) {
42 | target = '_self'
43 | }
44 |
45 | return (
46 |
53 | {children}
54 |
55 | )
56 | },
57 | code({ node, inline, className, children, ...props }) {
58 | if (children && children.length) {
59 | if (children[0] == '▍') {
60 | return (
61 | ▍
62 | )
63 | }
64 |
65 | children[0] = (children[0] as string).replace('`▍`', '▍')
66 | }
67 |
68 | const match = /language-(\w+)/.exec(className || '')
69 |
70 | if (inline) {
71 | return (
72 |
79 | {children}
80 |
81 | )
82 | }
83 |
84 | return (
85 |
91 | )
92 | },
93 | }}
94 | >
95 | {typeof readme === 'string' ? readme : JSON.stringify(readme)}
96 |
97 |
98 |
99 | )
100 | }
101 |
--------------------------------------------------------------------------------
/components/assistant/ReadmeSkeleton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Skeleton } from '../ui/skeleton'
3 | import AssistantDisplay from '../AssistantDisplay'
4 | import { CaretDown, FolderSimple } from '@phosphor-icons/react/dist/ssr'
5 |
6 | function ReadmeSkeleton() {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | export default ReadmeSkeleton
43 |
44 | export function DirectorySkeleton() {
45 | return (
46 |
47 |
48 |
49 |
50 |
56 |
57 |
58 |
59 |
60 |
61 |
67 |
68 |
69 |
70 |
71 |
72 |
78 |
79 |
80 |
81 |
82 |
83 |
89 |
90 |
91 |
92 |
93 |
94 |
100 |
101 |
102 |
103 |
104 |
105 |
111 |
112 |
113 |
114 |
115 |
116 |
122 |
123 |
124 |
125 |
126 |
127 |
133 |
134 |
135 |
136 |
137 |
138 |
144 |
145 |
146 |
147 |
148 |
149 |
155 |
156 |
157 |
158 |
159 |
160 |
166 |
167 |
168 |
169 |
170 |
171 |
177 |
178 |
179 |
180 |
181 |
182 |
188 |
189 |
190 |
191 |
192 |
193 |
199 |
200 |
201 |
202 |
203 |
204 |
210 |
211 |
212 |
213 |
214 | )
215 | }
216 |
--------------------------------------------------------------------------------
/components/assistant/Repositories.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | DropdownMenu,
5 | DropdownMenuLabel,
6 | DropdownMenuContent,
7 | DropdownMenuTrigger,
8 | DropdownMenuRadioItem,
9 | DropdownMenuSeparator,
10 | DropdownMenuRadioGroup,
11 | } from '@/components/ui/dropdown-menu'
12 | import React from 'react'
13 | import Link from 'next/link'
14 | import Image from 'next/image'
15 | import { Badge } from '../ui/badge'
16 | import { Button } from '../ui/button'
17 | import License from '../icons/License'
18 | import { AI } from '@/lib/chat/actions'
19 | import { RepoProps } from '@/lib/types'
20 | import { COLOURS } from '@/lib/constants'
21 | import { useActions, useUIState } from 'ai/rsc'
22 | import AssistantDisplay from '../AssistantDisplay'
23 | import { Sparkle, Star } from '@phosphor-icons/react'
24 |
25 | function Repositories({ props: repos }: { props: RepoProps[] }) {
26 | const { readmeAction, dirAction } = useActions()
27 | const [action, setAction] = React.useState('')
28 | const [messages, setMessages] = useUIState()
29 |
30 | function findColour(language: string): string {
31 | const entry = Object.entries(COLOURS).find(([key]) => key === language)
32 | if (entry) {
33 | if (entry[0] !== undefined && entry[0] !== '' && entry[0] === language) {
34 | return entry[1].color
35 | }
36 | return '#000000'
37 | }
38 | return '#000000'
39 | }
40 |
41 | const RepoActions = [
42 | {
43 | name: 'Show Readme',
44 | value: 'show-readme',
45 | function: async (repo: string, owner: string) => {
46 | const response = await readmeAction(repo, owner)
47 | setMessages((currentMessages) => [
48 | ...currentMessages,
49 | response.newMessage,
50 | ])
51 | },
52 | status: 'active',
53 | },
54 | {
55 | name: 'Show Directory',
56 | value: 'show-directory',
57 | function: async (repo: string, owner: string) => {
58 | const response = await dirAction(repo, owner)
59 | setMessages((currentMessages) => [
60 | ...currentMessages,
61 | response.newMessage,
62 | ])
63 | },
64 | status: 'active',
65 | },
66 | {
67 | name: 'Code Search',
68 | value: 'code-search',
69 | function: async () => {},
70 | status: 'disabled',
71 | },
72 | ]
73 |
74 | return (
75 |
76 | {Array.isArray(repos) &&
77 | repos.map((r, index) => {
78 | return (
79 |
80 |
81 |
82 |
92 |
93 |
94 |
99 | {r.full_name}
100 |
101 |
102 |
103 | {r.description ? r.description : '(No description found)'}
104 |
105 |
106 | {r.topics.map((topic, index) => {
107 | return (
108 |
113 | {topic}
114 |
115 | )
116 | })}
117 |
118 |
119 |
120 |
121 |
127 |
128 | {r.language}
129 |
130 |
131 | •
132 |
133 |
134 |
135 |
136 | {r.stargazers_count.toString().split('').length > 3 ? (
137 | {Math.floor(r.stargazers_count / 1000)}k
138 | ) : (
139 | {r.stargazers_count}
140 | )}
141 |
142 | •
143 |
144 |
145 |
146 |
147 |
148 | {r?.license?.spdx_id !== null ||
149 | r?.license?.spdx_id !== undefined
150 | ? r?.license?.spdx_id
151 | : 'No License'}
152 |
153 |
154 |
155 |
156 |
157 |
158 |
162 |
167 |
168 |
169 |
170 | Actions
171 |
172 |
173 |
174 | Choose Action
175 |
176 |
180 | {RepoActions.map((action) => {
181 | return (
182 | {
188 | if (action.value === 'show-readme') {
189 | await action.function(r.name, r.owner.login)
190 | } else if (action.value === 'show-directory') {
191 | await action.function(r.name, r.owner.login)
192 | }
193 | }}
194 | >
195 | {action.name}
196 |
197 | )
198 | })}
199 |
200 |
201 |
202 |
203 |
204 | )
205 | })}
206 |
207 | )
208 | }
209 |
210 | export default Repositories
211 |
--------------------------------------------------------------------------------
/components/assistant/RepositorySkeleton.tsx:
--------------------------------------------------------------------------------
1 | import AssistantDisplay from '../AssistantDisplay'
2 | import { Skeleton } from '../ui/skeleton'
3 |
4 | function RepositorySkeleton() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 | )
93 | }
94 |
95 | export default RepositorySkeleton
96 |
--------------------------------------------------------------------------------
/components/assistant/Spinner.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | export const Spinner = (
4 |
14 |
15 |
16 | )
17 |
--------------------------------------------------------------------------------
/components/icons/License.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | function License() {
4 | return (
5 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | )
24 | }
25 |
26 | export default License
27 |
--------------------------------------------------------------------------------
/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as AccordionPrimitive from '@radix-ui/react-accordion'
5 | import { ChevronDownIcon } from '@radix-ui/react-icons'
6 |
7 | import { cn } from '@/lib/utils'
8 |
9 | const Accordion = AccordionPrimitive.Root
10 |
11 | const AccordionItem = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
20 | ))
21 | AccordionItem.displayName = 'AccordionItem'
22 |
23 | const AccordionTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, children, ...props }, ref) => (
27 |
28 | svg]:rotate-180',
32 | className,
33 | )}
34 | {...props}
35 | >
36 |
37 | {children}
38 |
39 |
40 | ))
41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
42 |
43 | const AccordionContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, children, ...props }, ref) => (
47 |
52 | {children}
53 |
54 | ))
55 | AccordionContent.displayName = AccordionPrimitive.Content.displayName
56 |
57 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
58 |
--------------------------------------------------------------------------------
/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { buttonVariants } from "@/components/ui/button"
8 |
9 | const AlertDialog = AlertDialogPrimitive.Root
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
12 |
13 | const AlertDialogPortal = AlertDialogPrimitive.Portal
14 |
15 | const AlertDialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ))
28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
29 |
30 | const AlertDialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
35 |
36 |
44 |
45 | ))
46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
47 |
48 | const AlertDialogHeader = ({
49 | className,
50 | ...props
51 | }: React.HTMLAttributes) => (
52 |
59 | )
60 | AlertDialogHeader.displayName = "AlertDialogHeader"
61 |
62 | const AlertDialogFooter = ({
63 | className,
64 | ...props
65 | }: React.HTMLAttributes) => (
66 |
73 | )
74 | AlertDialogFooter.displayName = "AlertDialogFooter"
75 |
76 | const AlertDialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ))
86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
87 |
88 | const AlertDialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ))
98 | AlertDialogDescription.displayName =
99 | AlertDialogPrimitive.Description.displayName
100 |
101 | const AlertDialogAction = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
112 |
113 | const AlertDialogCancel = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
126 | ))
127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
128 |
129 | export {
130 | AlertDialog,
131 | AlertDialogPortal,
132 | AlertDialogOverlay,
133 | AlertDialogTrigger,
134 | AlertDialogContent,
135 | AlertDialogHeader,
136 | AlertDialogFooter,
137 | AlertDialogTitle,
138 | AlertDialogDescription,
139 | AlertDialogAction,
140 | AlertDialogCancel,
141 | }
142 |
--------------------------------------------------------------------------------
/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Slot } from '@radix-ui/react-slot'
3 | import { cva, type VariantProps } from 'class-variance-authority'
4 |
5 | import { cn } from '@/lib/utils'
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
14 | destructive:
15 | 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
16 | outline:
17 | 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
18 | secondary:
19 | 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
20 | ghost: 'hover:bg-accent hover:text-accent-foreground',
21 | link: 'text-primary underline-offset-4 hover:underline',
22 | },
23 | size: {
24 | default: 'h-9 px-4 py-2',
25 | sm: 'h-8 rounded-md px-3 text-xs',
26 | lg: 'h-10 rounded-md px-8',
27 | icon: 'h-9 w-9',
28 | },
29 | },
30 | defaultVariants: {
31 | variant: 'default',
32 | size: 'default',
33 | },
34 | },
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : 'button'
46 | return (
47 |
52 | )
53 | },
54 | )
55 | Button.displayName = 'Button'
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/components/ui/code-block.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { FC, memo } from 'react'
4 | import { Button } from '@/components/ui/button'
5 | import { Check, Copy, Download } from '@phosphor-icons/react'
6 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
7 | import { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism'
8 | import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard'
9 |
10 | interface Props {
11 | language: string
12 | value: string
13 | }
14 |
15 | interface languageMap {
16 | [key: string]: string | undefined
17 | }
18 |
19 | export const programmingLanguages: languageMap = {
20 | javascript: '.js',
21 | python: '.py',
22 | java: '.java',
23 | c: '.c',
24 | cpp: '.cpp',
25 | 'c++': '.cpp',
26 | 'c#': '.cs',
27 | ruby: '.rb',
28 | php: '.php',
29 | swift: '.swift',
30 | 'objective-c': '.m',
31 | kotlin: '.kt',
32 | typescript: '.ts',
33 | go: '.go',
34 | perl: '.pl',
35 | rust: '.rs',
36 | scala: '.scala',
37 | haskell: '.hs',
38 | lua: '.lua',
39 | shell: '.sh',
40 | sql: '.sql',
41 | html: '.html',
42 | css: '.css',
43 | // add more file extensions here, make sure the key is same as language prop in CodeBlock.tsx component
44 | }
45 |
46 | export const generateRandomString = (length: number, lowercase = false) => {
47 | const chars = 'ABCDEFGHJKLMNPQRSTUVWXY3456789' // excluding similar looking characters like Z, 2, I, 1, O, 0
48 | let result = ''
49 | for (let i = 0; i < length; i++) {
50 | result += chars.charAt(Math.floor(Math.random() * chars.length))
51 | }
52 | return lowercase ? result.toLowerCase() : result
53 | }
54 |
55 | const CodeBlock: FC = memo(({ language, value }) => {
56 | const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 })
57 |
58 | const onCopy = () => {
59 | if (isCopied) return
60 | copyToClipboard(value)
61 | }
62 |
63 | return (
64 |
65 |
66 |
{language}
67 |
68 |
74 | {isCopied ? : }
75 |
76 |
77 |
78 |
102 | {value}
103 |
104 |
105 | )
106 | })
107 | CodeBlock.displayName = 'CodeBlock'
108 |
109 | export { CodeBlock }
110 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import {
6 | CheckIcon,
7 | ChevronRightIcon,
8 | DotFilledIcon,
9 | } from "@radix-ui/react-icons"
10 |
11 | import { cn } from "@/lib/utils"
12 |
13 | const DropdownMenu = DropdownMenuPrimitive.Root
14 |
15 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
16 |
17 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
18 |
19 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
20 |
21 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
22 |
23 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
24 |
25 | const DropdownMenuSubTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef & {
28 | inset?: boolean
29 | }
30 | >(({ className, inset, children, ...props }, ref) => (
31 |
40 | {children}
41 |
42 |
43 | ))
44 | DropdownMenuSubTrigger.displayName =
45 | DropdownMenuPrimitive.SubTrigger.displayName
46 |
47 | const DropdownMenuSubContent = React.forwardRef<
48 | React.ElementRef,
49 | React.ComponentPropsWithoutRef
50 | >(({ className, ...props }, ref) => (
51 |
59 | ))
60 | DropdownMenuSubContent.displayName =
61 | DropdownMenuPrimitive.SubContent.displayName
62 |
63 | const DropdownMenuContent = React.forwardRef<
64 | React.ElementRef,
65 | React.ComponentPropsWithoutRef
66 | >(({ className, sideOffset = 4, ...props }, ref) => (
67 |
68 |
78 |
79 | ))
80 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
81 |
82 | const DropdownMenuItem = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef & {
85 | inset?: boolean
86 | }
87 | >(({ className, inset, ...props }, ref) => (
88 |
97 | ))
98 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
99 |
100 | const DropdownMenuCheckboxItem = React.forwardRef<
101 | React.ElementRef,
102 | React.ComponentPropsWithoutRef
103 | >(({ className, children, checked, ...props }, ref) => (
104 |
113 |
114 |
115 |
116 |
117 |
118 | {children}
119 |
120 | ))
121 | DropdownMenuCheckboxItem.displayName =
122 | DropdownMenuPrimitive.CheckboxItem.displayName
123 |
124 | const DropdownMenuRadioItem = React.forwardRef<
125 | React.ElementRef,
126 | React.ComponentPropsWithoutRef
127 | >(({ className, children, ...props }, ref) => (
128 |
136 | {/*
137 |
138 |
139 |
140 | */}
141 | {children}
142 |
143 | ))
144 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
145 |
146 | const DropdownMenuLabel = React.forwardRef<
147 | React.ElementRef,
148 | React.ComponentPropsWithoutRef & {
149 | inset?: boolean
150 | }
151 | >(({ className, inset, ...props }, ref) => (
152 |
161 | ))
162 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
163 |
164 | const DropdownMenuSeparator = React.forwardRef<
165 | React.ElementRef,
166 | React.ComponentPropsWithoutRef
167 | >(({ className, ...props }, ref) => (
168 |
173 | ))
174 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
175 |
176 | const DropdownMenuShortcut = ({
177 | className,
178 | ...props
179 | }: React.HTMLAttributes) => {
180 | return (
181 |
185 | )
186 | }
187 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
188 |
189 | export {
190 | DropdownMenu,
191 | DropdownMenuTrigger,
192 | DropdownMenuContent,
193 | DropdownMenuItem,
194 | DropdownMenuCheckboxItem,
195 | DropdownMenuRadioItem,
196 | DropdownMenuLabel,
197 | DropdownMenuSeparator,
198 | DropdownMenuShortcut,
199 | DropdownMenuGroup,
200 | DropdownMenuPortal,
201 | DropdownMenuSub,
202 | DropdownMenuSubContent,
203 | DropdownMenuSubTrigger,
204 | DropdownMenuRadioGroup,
205 | }
206 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/lib/utils'
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | },
22 | )
23 | Input.displayName = 'Input'
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/components/ui/navigation-menu.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { ChevronDownIcon } from "@radix-ui/react-icons"
3 | import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
4 | import { cva } from "class-variance-authority"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const NavigationMenu = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
20 | {children}
21 |
22 |
23 | ))
24 | NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
25 |
26 | const NavigationMenuList = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, ...props }, ref) => (
30 |
38 | ))
39 | NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
40 |
41 | const NavigationMenuItem = NavigationMenuPrimitive.Item
42 |
43 | const navigationMenuTriggerStyle = cva(
44 | "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
45 | )
46 |
47 | const NavigationMenuTrigger = React.forwardRef<
48 | React.ElementRef,
49 | React.ComponentPropsWithoutRef
50 | >(({ className, children, ...props }, ref) => (
51 |
56 | {children}{" "}
57 |
61 |
62 | ))
63 | NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
64 |
65 | const NavigationMenuContent = React.forwardRef<
66 | React.ElementRef,
67 | React.ComponentPropsWithoutRef
68 | >(({ className, ...props }, ref) => (
69 |
77 | ))
78 | NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
79 |
80 | const NavigationMenuLink = NavigationMenuPrimitive.Link
81 |
82 | const NavigationMenuViewport = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => (
86 |
87 |
95 |
96 | ))
97 | NavigationMenuViewport.displayName =
98 | NavigationMenuPrimitive.Viewport.displayName
99 |
100 | const NavigationMenuIndicator = React.forwardRef<
101 | React.ElementRef,
102 | React.ComponentPropsWithoutRef
103 | >(({ className, ...props }, ref) => (
104 |
112 |
113 |
114 | ))
115 | NavigationMenuIndicator.displayName =
116 | NavigationMenuPrimitive.Indicator.displayName
117 |
118 | export {
119 | navigationMenuTriggerStyle,
120 | NavigationMenu,
121 | NavigationMenuList,
122 | NavigationMenuItem,
123 | NavigationMenuContent,
124 | NavigationMenuTrigger,
125 | NavigationMenuLink,
126 | NavigationMenuIndicator,
127 | NavigationMenuViewport,
128 | }
129 |
--------------------------------------------------------------------------------
/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
16 |
20 | {children}
21 |
22 |
23 |
24 |
25 | ))
26 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
27 |
28 | const ScrollBar = React.forwardRef<
29 | React.ElementRef,
30 | React.ComponentPropsWithoutRef
31 | >(({ className, orientation = 'vertical', ...props }, ref) => (
32 |
45 |
46 |
47 | ))
48 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
49 |
50 | export { ScrollArea, ScrollBar }
51 |
--------------------------------------------------------------------------------
/components/ui/seperator.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as SeparatorPrimitive from '@radix-ui/react-separator'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = 'horizontal', decorative = true, ...props },
14 | ref,
15 | ) => (
16 |
27 | ),
28 | )
29 | Separator.displayName = SeparatorPrimitive.Root.displayName
30 |
31 | export { Separator }
32 |
--------------------------------------------------------------------------------
/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SheetPrimitive from "@radix-ui/react-dialog"
5 | import { Cross2Icon } from "@radix-ui/react-icons"
6 | import { cva, type VariantProps } from "class-variance-authority"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const Sheet = SheetPrimitive.Root
11 |
12 | const SheetTrigger = SheetPrimitive.Trigger
13 |
14 | const SheetClose = SheetPrimitive.Close
15 |
16 | const SheetPortal = SheetPrimitive.Portal
17 |
18 | const SheetOverlay = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
30 | ))
31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
32 |
33 | const sheetVariants = cva(
34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
35 | {
36 | variants: {
37 | side: {
38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39 | bottom:
40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
42 | right:
43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
44 | },
45 | },
46 | defaultVariants: {
47 | side: "right",
48 | },
49 | }
50 | )
51 |
52 | interface SheetContentProps
53 | extends React.ComponentPropsWithoutRef,
54 | VariantProps {}
55 |
56 | const SheetContent = React.forwardRef<
57 | React.ElementRef,
58 | SheetContentProps
59 | >(({ side = "right", className, children, ...props }, ref) => (
60 |
61 |
62 |
67 | {children}
68 |
69 |
70 | Close
71 |
72 |
73 |
74 | ))
75 | SheetContent.displayName = SheetPrimitive.Content.displayName
76 |
77 | const SheetHeader = ({
78 | className,
79 | ...props
80 | }: React.HTMLAttributes) => (
81 |
88 | )
89 | SheetHeader.displayName = "SheetHeader"
90 |
91 | const SheetFooter = ({
92 | className,
93 | ...props
94 | }: React.HTMLAttributes) => (
95 |
102 | )
103 | SheetFooter.displayName = "SheetFooter"
104 |
105 | const SheetTitle = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ))
115 | SheetTitle.displayName = SheetPrimitive.Title.displayName
116 |
117 | const SheetDescription = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, ...props }, ref) => (
121 |
126 | ))
127 | SheetDescription.displayName = SheetPrimitive.Description.displayName
128 |
129 | export {
130 | Sheet,
131 | SheetPortal,
132 | SheetOverlay,
133 | SheetTrigger,
134 | SheetClose,
135 | SheetContent,
136 | SheetHeader,
137 | SheetFooter,
138 | SheetTitle,
139 | SheetDescription,
140 | }
141 |
--------------------------------------------------------------------------------
/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils'
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import { Cross2Icon } from '@radix-ui/react-icons'
5 | import * as ToastPrimitives from '@radix-ui/react-toast'
6 | import { cva, type VariantProps } from 'class-variance-authority'
7 |
8 | import { cn } from '@/lib/utils'
9 |
10 | const ToastProvider = ToastPrimitives.Provider
11 |
12 | const ToastViewport = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, ...props }, ref) => (
16 |
24 | ))
25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
26 |
27 | const toastVariants = cva(
28 | 'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
29 | {
30 | variants: {
31 | variant: {
32 | default: 'border bg-background text-foreground',
33 | destructive:
34 | 'destructive group border-destructive bg-destructive text-destructive-foreground',
35 | },
36 | },
37 | defaultVariants: {
38 | variant: 'default',
39 | },
40 | },
41 | )
42 |
43 | const Toast = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef &
46 | VariantProps
47 | >(({ className, variant, ...props }, ref) => {
48 | return (
49 |
54 | )
55 | })
56 | Toast.displayName = ToastPrimitives.Root.displayName
57 |
58 | const ToastAction = React.forwardRef<
59 | React.ElementRef,
60 | React.ComponentPropsWithoutRef
61 | >(({ className, ...props }, ref) => (
62 |
70 | ))
71 | ToastAction.displayName = ToastPrimitives.Action.displayName
72 |
73 | const ToastClose = React.forwardRef<
74 | React.ElementRef,
75 | React.ComponentPropsWithoutRef
76 | >(({ className, ...props }, ref) => (
77 |
86 |
87 |
88 | ))
89 | ToastClose.displayName = ToastPrimitives.Close.displayName
90 |
91 | const ToastTitle = React.forwardRef<
92 | React.ElementRef,
93 | React.ComponentPropsWithoutRef
94 | >(({ className, ...props }, ref) => (
95 |
100 | ))
101 | ToastTitle.displayName = ToastPrimitives.Title.displayName
102 |
103 | const ToastDescription = React.forwardRef<
104 | React.ElementRef,
105 | React.ComponentPropsWithoutRef
106 | >(({ className, ...props }, ref) => (
107 |
112 | ))
113 | ToastDescription.displayName = ToastPrimitives.Description.displayName
114 |
115 | type ToastProps = React.ComponentPropsWithoutRef
116 |
117 | type ToastActionElement = React.ReactElement
118 |
119 | export {
120 | type ToastProps,
121 | type ToastActionElement,
122 | ToastProvider,
123 | ToastViewport,
124 | Toast,
125 | ToastTitle,
126 | ToastDescription,
127 | ToastClose,
128 | ToastAction,
129 | }
130 |
--------------------------------------------------------------------------------
/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | Toast,
5 | ToastClose,
6 | ToastDescription,
7 | ToastProvider,
8 | ToastTitle,
9 | ToastViewport,
10 | } from '@/components/ui/toast'
11 | import { useToast } from '@/components/ui/use-toast'
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title} }
23 | {description && (
24 | {description}
25 | )}
26 |
27 | {action}
28 |
29 |
30 | )
31 | })}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // Inspired by react-hot-toast library
4 | import * as React from 'react'
5 |
6 | import type { ToastActionElement, ToastProps } from '@/components/ui/toast'
7 |
8 | const TOAST_LIMIT = 1
9 | const TOAST_REMOVE_DELAY = 1000000
10 |
11 | type ToasterToast = ToastProps & {
12 | id: string
13 | title?: React.ReactNode
14 | description?: React.ReactNode
15 | action?: ToastActionElement
16 | }
17 |
18 | const actionTypes = {
19 | ADD_TOAST: 'ADD_TOAST',
20 | UPDATE_TOAST: 'UPDATE_TOAST',
21 | DISMISS_TOAST: 'DISMISS_TOAST',
22 | REMOVE_TOAST: 'REMOVE_TOAST',
23 | } as const
24 |
25 | let count = 0
26 |
27 | function genId() {
28 | count = (count + 1) % Number.MAX_SAFE_INTEGER
29 | return count.toString()
30 | }
31 |
32 | type ActionType = typeof actionTypes
33 |
34 | type Action =
35 | | {
36 | type: ActionType['ADD_TOAST']
37 | toast: ToasterToast
38 | }
39 | | {
40 | type: ActionType['UPDATE_TOAST']
41 | toast: Partial
42 | }
43 | | {
44 | type: ActionType['DISMISS_TOAST']
45 | toastId?: ToasterToast['id']
46 | }
47 | | {
48 | type: ActionType['REMOVE_TOAST']
49 | toastId?: ToasterToast['id']
50 | }
51 |
52 | interface State {
53 | toasts: ToasterToast[]
54 | }
55 |
56 | const toastTimeouts = new Map>()
57 |
58 | const addToRemoveQueue = (toastId: string) => {
59 | if (toastTimeouts.has(toastId)) {
60 | return
61 | }
62 |
63 | const timeout = setTimeout(() => {
64 | toastTimeouts.delete(toastId)
65 | dispatch({
66 | type: 'REMOVE_TOAST',
67 | toastId: toastId,
68 | })
69 | }, TOAST_REMOVE_DELAY)
70 |
71 | toastTimeouts.set(toastId, timeout)
72 | }
73 |
74 | export const reducer = (state: State, action: Action): State => {
75 | switch (action.type) {
76 | case 'ADD_TOAST':
77 | return {
78 | ...state,
79 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
80 | }
81 |
82 | case 'UPDATE_TOAST':
83 | return {
84 | ...state,
85 | toasts: state.toasts.map((t) =>
86 | t.id === action.toast.id ? { ...t, ...action.toast } : t,
87 | ),
88 | }
89 |
90 | case 'DISMISS_TOAST': {
91 | const { toastId } = action
92 |
93 | // ! Side effects ! - This could be extracted into a dismissToast() action,
94 | // but I'll keep it here for simplicity
95 | if (toastId) {
96 | addToRemoveQueue(toastId)
97 | } else {
98 | state.toasts.forEach((toast) => {
99 | addToRemoveQueue(toast.id)
100 | })
101 | }
102 |
103 | return {
104 | ...state,
105 | toasts: state.toasts.map((t) =>
106 | t.id === toastId || toastId === undefined
107 | ? {
108 | ...t,
109 | open: false,
110 | }
111 | : t,
112 | ),
113 | }
114 | }
115 | case 'REMOVE_TOAST':
116 | if (action.toastId === undefined) {
117 | return {
118 | ...state,
119 | toasts: [],
120 | }
121 | }
122 | return {
123 | ...state,
124 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
125 | }
126 | }
127 | }
128 |
129 | const listeners: Array<(state: State) => void> = []
130 |
131 | let memoryState: State = { toasts: [] }
132 |
133 | function dispatch(action: Action) {
134 | memoryState = reducer(memoryState, action)
135 | listeners.forEach((listener) => {
136 | listener(memoryState)
137 | })
138 | }
139 |
140 | type Toast = Omit
141 |
142 | function toast({ ...props }: Toast) {
143 | const id = genId()
144 |
145 | const update = (props: ToasterToast) =>
146 | dispatch({
147 | type: 'UPDATE_TOAST',
148 | toast: { ...props, id },
149 | })
150 | const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
151 |
152 | dispatch({
153 | type: 'ADD_TOAST',
154 | toast: {
155 | ...props,
156 | id,
157 | open: true,
158 | onOpenChange: (open) => {
159 | if (!open) dismiss()
160 | },
161 | },
162 | })
163 |
164 | return {
165 | id: id,
166 | dismiss,
167 | update,
168 | }
169 | }
170 |
171 | function useToast() {
172 | const [state, setState] = React.useState(memoryState)
173 |
174 | React.useEffect(() => {
175 | listeners.push(setState)
176 | return () => {
177 | const index = listeners.indexOf(setState)
178 | if (index > -1) {
179 | listeners.splice(index, 1)
180 | }
181 | }
182 | }, [state])
183 |
184 | return {
185 | ...state,
186 | toast,
187 | dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
188 | }
189 | }
190 |
191 | export { useToast, toast }
192 |
--------------------------------------------------------------------------------
/db/index.ts:
--------------------------------------------------------------------------------
1 | import { NeonQueryFunction, neon } from '@neondatabase/serverless'
2 | import { drizzle } from 'drizzle-orm/neon-http'
3 |
4 | const sql: NeonQueryFunction = neon(process.env.DATABASE_URL!)
5 | export const db = drizzle(sql)
6 |
--------------------------------------------------------------------------------
/db/migrate.ts:
--------------------------------------------------------------------------------
1 | import { drizzle } from 'drizzle-orm/neon-http'
2 | import { migrate } from 'drizzle-orm/neon-http/migrator'
3 | import { NeonQueryFunction, neon } from '@neondatabase/serverless'
4 | import * as dotenv from 'dotenv'
5 |
6 | dotenv.config({
7 | path: '.env.local',
8 | })
9 |
10 | const sql: NeonQueryFunction = neon(
11 | process.env.DATABASE_URL as string,
12 | )
13 |
14 | const db = drizzle(sql)
15 |
16 | const main = async () => {
17 | try {
18 | await migrate(db, {
19 | migrationsFolder: './db/migrations',
20 | })
21 | console.log('Migration completed successfully.')
22 | } catch (e) {
23 | console.error('Migration error:', e)
24 | process.exit(1)
25 | }
26 | }
27 |
28 | main()
29 |
--------------------------------------------------------------------------------
/db/migrations/0000_premium_morlocks.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "chat" (
2 | "id" text PRIMARY KEY NOT NULL,
3 | "title" text NOT NULL,
4 | "author" text NOT NULL,
5 | "created_at" timestamp DEFAULT now() NOT NULL,
6 | "path" text NOT NULL,
7 | "messages" json NOT NULL
8 | );
9 | --> statement-breakpoint
10 | CREATE TABLE IF NOT EXISTS "user" (
11 | "id" text PRIMARY KEY NOT NULL,
12 | "email" text NOT NULL,
13 | "name" text NOT NULL,
14 | "surname" text NOT NULL,
15 | "created_at" timestamp DEFAULT now() NOT NULL,
16 | CONSTRAINT "user_email_unique" UNIQUE("email")
17 | );
18 | --> statement-breakpoint
19 | DO $$ BEGIN
20 | ALTER TABLE "chat" ADD CONSTRAINT "chat_author_user_id_fk" FOREIGN KEY ("author") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
21 | EXCEPTION
22 | WHEN duplicate_object THEN null;
23 | END $$;
24 |
--------------------------------------------------------------------------------
/db/migrations/meta/0000_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "af3cac09-0d76-42b7-8b23-70722fb3e9c4",
3 | "prevId": "00000000-0000-0000-0000-000000000000",
4 | "version": "5",
5 | "dialect": "pg",
6 | "tables": {
7 | "chat": {
8 | "name": "chat",
9 | "schema": "",
10 | "columns": {
11 | "id": {
12 | "name": "id",
13 | "type": "text",
14 | "primaryKey": true,
15 | "notNull": true
16 | },
17 | "title": {
18 | "name": "title",
19 | "type": "text",
20 | "primaryKey": false,
21 | "notNull": true
22 | },
23 | "author": {
24 | "name": "author",
25 | "type": "text",
26 | "primaryKey": false,
27 | "notNull": true
28 | },
29 | "created_at": {
30 | "name": "created_at",
31 | "type": "timestamp",
32 | "primaryKey": false,
33 | "notNull": true,
34 | "default": "now()"
35 | },
36 | "path": {
37 | "name": "path",
38 | "type": "text",
39 | "primaryKey": false,
40 | "notNull": true
41 | },
42 | "messages": {
43 | "name": "messages",
44 | "type": "json",
45 | "primaryKey": false,
46 | "notNull": true
47 | }
48 | },
49 | "indexes": {},
50 | "foreignKeys": {
51 | "chat_author_user_id_fk": {
52 | "name": "chat_author_user_id_fk",
53 | "tableFrom": "chat",
54 | "tableTo": "user",
55 | "columnsFrom": ["author"],
56 | "columnsTo": ["id"],
57 | "onDelete": "no action",
58 | "onUpdate": "no action"
59 | }
60 | },
61 | "compositePrimaryKeys": {},
62 | "uniqueConstraints": {}
63 | },
64 | "user": {
65 | "name": "user",
66 | "schema": "",
67 | "columns": {
68 | "id": {
69 | "name": "id",
70 | "type": "text",
71 | "primaryKey": true,
72 | "notNull": true
73 | },
74 | "email": {
75 | "name": "email",
76 | "type": "text",
77 | "primaryKey": false,
78 | "notNull": true
79 | },
80 | "name": {
81 | "name": "name",
82 | "type": "text",
83 | "primaryKey": false,
84 | "notNull": true
85 | },
86 | "surname": {
87 | "name": "surname",
88 | "type": "text",
89 | "primaryKey": false,
90 | "notNull": true
91 | },
92 | "created_at": {
93 | "name": "created_at",
94 | "type": "timestamp",
95 | "primaryKey": false,
96 | "notNull": true,
97 | "default": "now()"
98 | }
99 | },
100 | "indexes": {},
101 | "foreignKeys": {},
102 | "compositePrimaryKeys": {},
103 | "uniqueConstraints": {
104 | "user_email_unique": {
105 | "name": "user_email_unique",
106 | "nullsNotDistinct": false,
107 | "columns": ["email"]
108 | }
109 | }
110 | }
111 | },
112 | "enums": {},
113 | "schemas": {},
114 | "_meta": {
115 | "columns": {},
116 | "schemas": {},
117 | "tables": {}
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/db/migrations/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "5",
3 | "dialect": "pg",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "5",
8 | "when": 1710796426034,
9 | "tag": "0000_premium_morlocks",
10 | "breakpoints": true
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/db/schema.ts:
--------------------------------------------------------------------------------
1 | import { json, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
2 | import { type InferSelectModel, type InferInsertModel } from 'drizzle-orm'
3 | import { Message } from 'ai'
4 | import { Chat } from '@/lib/types'
5 |
6 | export const user = pgTable('user', {
7 | id: text('id').notNull().primaryKey(),
8 | email: text('email').notNull().unique(),
9 | name: text('name').notNull(),
10 | surname: text('surname').notNull(),
11 | createdAt: timestamp('created_at').notNull().defaultNow(),
12 | })
13 |
14 | export const chat = pgTable('chat', {
15 | id: text('id').notNull().primaryKey(),
16 | title: text('title').notNull(),
17 | author: text('author')
18 | .notNull()
19 | .references(() => user.id),
20 | createdAt: timestamp('created_at').notNull().defaultNow(),
21 | path: text('path').notNull(),
22 | messages: json('messages').$type().notNull(),
23 | })
24 |
25 | export type SelectUser = InferSelectModel
26 | export type InsertUser = InferInsertModel
27 |
28 | export type SelectChat = InferSelectModel
29 | export type InsertChat = InferInsertModel
30 |
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'drizzle-kit'
2 | import * as dotenv from 'dotenv'
3 |
4 | dotenv.config({
5 | path: '.env.local',
6 | })
7 |
8 | export default defineConfig({
9 | schema: './db/schema.ts',
10 | out: './db/migrations',
11 | driver: 'pg',
12 | dbCredentials: {
13 | connectionString: process.env.DATABASE_URL as string,
14 | },
15 | verbose: true,
16 | strict: true,
17 | })
18 |
--------------------------------------------------------------------------------
/lib/chat/actions.tsx:
--------------------------------------------------------------------------------
1 | import 'server-only'
2 |
3 | import {
4 | BotCard,
5 | BotMessage,
6 | UserMessage,
7 | } from '@/components/assistant/Message'
8 | import { Chat } from '../types'
9 | import { saveChat } from '@/app/actions'
10 | import { currentUser } from '@clerk/nextjs'
11 | import { createAI, getAIState } from 'ai/rsc'
12 | import { Readme } from '@/components/assistant/Readme'
13 | import Directory from '@/components/assistant/Directory'
14 | import { Profile } from '@/components/assistant/Profile'
15 | import { submitUserMessage } from './submit-user-message'
16 | import { nanoid, runAsyncFnWithoutBlocking } from '../utils'
17 | import Repositories from '@/components/assistant/Repositories'
18 | import { ProfileList } from '@/components/assistant/ProfileList'
19 | import { dirAction, readmeAction, repoAction } from './submit-user-action'
20 |
21 | export interface Message {
22 | role?: 'user' | 'assistant' | 'system' | 'function' | 'data' | 'tool'
23 | content?: string
24 | id: string
25 | name?: string
26 | }
27 |
28 | export type AIState = {
29 | chatId: string
30 | messages: {
31 | role: 'user' | 'assistant' | 'system' | 'function' | 'data' | 'tool'
32 | content: string
33 | id: string
34 | name?: string
35 | }[]
36 | }
37 |
38 | export type UIState = {
39 | id: string
40 | display: React.ReactNode
41 | }[]
42 |
43 | export const AI = createAI({
44 | actions: { submitUserMessage, repoAction, readmeAction, dirAction },
45 | initialAIState: {
46 | chatId: nanoid(),
47 | messages: [],
48 | },
49 | initialUIState: [],
50 |
51 | unstable_onGetUIState: async () => {
52 | 'use server'
53 |
54 | const user = await currentUser()
55 |
56 | if (user) {
57 | const aiState = getAIState()
58 |
59 | if (aiState) {
60 | const uiState = getUIStateFromAIState(aiState)
61 | return uiState
62 | }
63 | } else {
64 | return
65 | }
66 | },
67 |
68 | unstable_onSetAIState: async ({ state }) => {
69 | 'use server'
70 |
71 | const user = await currentUser()
72 |
73 | if (user) {
74 | const { chatId, messages } = state
75 |
76 | const createdAt = new Date()
77 | const author = user.id as string
78 | const path = `/chat/${chatId}`
79 | const title = messages[1].content.substring(0, 100)
80 |
81 | const savedChat: Chat = {
82 | id: chatId,
83 | title: title,
84 | author: author,
85 | createdAt: createdAt,
86 | messages: messages,
87 | path: path,
88 | }
89 |
90 | await saveChat(savedChat)
91 | } else {
92 | console.log(`unstable_onSetAIState: not authenticated`)
93 | return
94 | }
95 | },
96 | })
97 |
98 | // Parses the previously rendered content and returns the UI state.
99 | // (Useful for chat history to rerender the UI components again when switching between the chats)
100 | export const getUIStateFromAIState = async (aiState: Chat) => {
101 | return aiState.messages
102 | .filter((message) => message.role !== 'system')
103 | .map((m, index) => ({
104 | id: `${aiState.id}-${index}`,
105 | display:
106 | m.role === 'function' ? (
107 | m.name === 'show_user_profile_ui' ? (
108 |
109 |
110 |
111 | ) : m.name === 'show_user_list_ui' ? (
112 |
113 |
114 |
115 | ) : m.name === 'show_repository_ui' ? (
116 |
117 |
118 |
119 | ) : m.name === 'show_readme_ui' ? (
120 |
121 |
122 |
123 | ) : m.name === 'show_directory_ui' ? (
124 |
125 |
126 |
127 | ) : null
128 | ) : m.role === 'user' ? (
129 | {m.content}
130 | ) : (
131 |
132 | ),
133 | }))
134 | }
135 |
--------------------------------------------------------------------------------
/lib/chat/submit-user-action.tsx:
--------------------------------------------------------------------------------
1 | import 'server-only'
2 | import {
3 | getDir,
4 | getReadme,
5 | checkRateLimit,
6 | searchRespositories,
7 | } from './github/github'
8 | import { AI } from './actions'
9 | import { nanoid } from 'nanoid'
10 | import { Readme as RM } from '../types'
11 | import RateLimited from '@/components/RateLimited'
12 | import { runAsyncFnWithoutBlocking, sleep } from '../utils'
13 | import { Readme } from '@/components/assistant/Readme'
14 | import { BotCard } from '@/components/assistant/Message'
15 | import { Spinner } from '@/components/assistant/Spinner'
16 | import Directory from '@/components/assistant/Directory'
17 | import Repositories from '@/components/assistant/Repositories'
18 | import { createStreamableUI, getMutableAIState } from 'ai/rsc'
19 | import { ProfileSkeleton } from '@/components/assistant/ProfileSkeleton'
20 | import RepositorySkeleton from '@/components/assistant/RepositorySkeleton'
21 |
22 | export async function repoAction(username: string) {
23 | 'use server'
24 |
25 | const aiState = getMutableAIState()
26 |
27 | const loadingRepos = createStreamableUI(
28 |
29 |
30 | ,
31 | )
32 |
33 | const systemMessage = createStreamableUI(null)
34 |
35 | runAsyncFnWithoutBlocking(async () => {
36 | loadingRepos.update(
37 |
38 |
39 | ,
40 | )
41 | const rateLimitRemaining = await checkRateLimit()
42 | const repositories = await searchRespositories(`user:${username}`)
43 |
44 | loadingRepos.done(
45 |
46 |
47 | ,
48 | )
49 | sleep(1000)
50 |
51 | systemMessage.done(
52 |
53 | {rateLimitRemaining !== 0 ? (
54 |
55 | ) : (
56 |
57 | )}
58 | ,
59 | )
60 |
61 | aiState.done({
62 | ...aiState.get(),
63 | messages: [
64 | ...aiState.get().messages,
65 | {
66 | id: nanoid(),
67 | role: 'function',
68 | name: 'show_repository_ui',
69 | content: JSON.stringify(repositories),
70 | },
71 | {
72 | id: nanoid(),
73 | role: 'system',
74 | content: `[User has clicked on the 'Show Repositories' button]`,
75 | },
76 | ],
77 | })
78 | })
79 |
80 | return {
81 | repoUI: loadingRepos.value,
82 | newMessage: {
83 | id: nanoid(),
84 | display: systemMessage.value,
85 | },
86 | }
87 | }
88 |
89 | export async function readmeAction(repo: string, owner: string) {
90 | 'use server'
91 |
92 | const aiState = getMutableAIState()
93 |
94 | const loadingReadme = createStreamableUI({Spinner} )
95 |
96 | const systemMessage = createStreamableUI(null)
97 |
98 | runAsyncFnWithoutBlocking(async () => {
99 | loadingReadme.update(
100 |
101 |
102 | ,
103 | )
104 | const rateLimitRemaining = await checkRateLimit()
105 | const readme: RM = await getReadme(repo, owner)
106 |
107 | loadingReadme.done(
108 |
109 |
110 | ,
111 | )
112 | sleep(1000)
113 | systemMessage.done(
114 |
115 | {rateLimitRemaining !== 0 ? (
116 |
117 | ) : (
118 |
119 | )}
120 | ,
121 | )
122 |
123 | aiState.done({
124 | ...aiState.get(),
125 | messages: [
126 | ...aiState.get().messages,
127 | {
128 | id: nanoid(),
129 | role: 'function',
130 | name: 'show_readme_ui',
131 | content: JSON.stringify(readme.content),
132 | },
133 | {
134 | id: nanoid(),
135 | role: 'system',
136 | content: `[User has clicked on the 'Show Readme' button]`,
137 | },
138 | ],
139 | })
140 | })
141 |
142 | return {
143 | readmeUI: loadingReadme.value,
144 | newMessage: {
145 | id: nanoid(),
146 | display: systemMessage.value,
147 | },
148 | }
149 | }
150 |
151 | export async function dirAction(repo: string, owner: string) {
152 | 'use server'
153 |
154 | const aiState = getMutableAIState()
155 |
156 | const loadingDirectory = createStreamableUI({Spinner} )
157 |
158 | const systemMessage = createStreamableUI(null)
159 |
160 | runAsyncFnWithoutBlocking(async () => {
161 | loadingDirectory.update(
162 |
163 |
164 | ,
165 | )
166 | const rateLimitRemaining = await checkRateLimit()
167 | const directory = await getDir({ repo, owner })
168 |
169 | loadingDirectory.done(
170 |
171 |
172 | ,
173 | )
174 | sleep(1000)
175 | systemMessage.done(
176 |
177 | {rateLimitRemaining !== 0 ? (
178 |
179 | ) : (
180 |
181 | )}
182 | ,
183 | )
184 |
185 | aiState.done({
186 | ...aiState.get(),
187 | messages: [
188 | ...aiState.get().messages,
189 | {
190 | id: nanoid(),
191 | role: 'function',
192 | name: 'show_directory_ui',
193 | content: JSON.stringify(directory),
194 | },
195 | {
196 | id: nanoid(),
197 | role: 'system',
198 | content: `[User has clicked on the 'Show Directory' button]`,
199 | },
200 | ],
201 | })
202 | })
203 |
204 | return {
205 | directoryUI: loadingDirectory.value,
206 | newMessage: {
207 | id: nanoid(),
208 | display: systemMessage.value,
209 | },
210 | }
211 | }
212 |
--------------------------------------------------------------------------------
/lib/chat/system-prompt.ts:
--------------------------------------------------------------------------------
1 | export const systemPrompt = `\
2 | You are a GitHub search bot and you can help users find what they are looking for by searching the GitHub using GitHub API.
3 | You can also provide the user with the search results from the GitHub API displayed in the UI. You should only show the information in the UI and never show search information without the UI.
4 | You can only call functions under a related attribute or if the attribute is set to general, otherwise you should not call any function and ask user to change it to either general or a relevant attribute. General attribute is a default attribute and you can do anything if this attribute is set by the user.
5 | Only the user can change the attribute and you are not allowed to change it.
6 | If an action is taken by the user, do not ask for confirmation and directly show the results in the UI.
7 |
8 | Messages inside [] means that it's a UI element or a user event. For example:
9 | - "[User has changed attribute to 'user-search']" means that the user has changed the attribute to 'user-search' and requests a specified search scope. If the current attribute is not relevant to the requested search, you can ask the user to change the scope. Following are the list of attributes you can use:
10 | - 'general' means that the user is looking for general information. This is a general scope and you can do anything if this scope is set by the user.
11 | - 'user-search' means that the user is looking for a user profile. If this scope is set, you should only show the user profile search UI to the user. \`show_user_profile_ui\` and \`show_user_list_ui\` functions are withing this attribute.
12 | - 'repository-search' means that the user is looking for a repository. If this scope is set, you should only show the repository search UI to the user.
13 | - 'code-search' means that the user is looking for a code snippet. If this scope is set, you should only show the code snippet search UI to the user.
14 | - "[GitHub Profile of 'kayaayberk']" means that an interface of the GitHub profile of 'kayaayberk' is shown to the user, 'kayaayberk' being the username of the searched user.
15 | - "[User has clicked on the 'Show Repositories' button]" means that the user is being shown the requested user profile and requests to see the repositories of that GitHub user.
16 | - "[Found repositories: 'repo1', 'repo2', 'repo3']" means that the search results are displayed to the user in the UI, 'repo1', 'repo2', 'repo3' being UI elements displaying the found repository details through the API search.
17 |
18 | If the user requests a single profile search on GitHub with a username, call \`show_user_profile_ui\` to show the found user profile UI. You shouln't show any information without calling this function. If the user does not provide a valid username, you can ask the user to provide a valid username before showing any content.
19 | If user requests a list of users search on GitHub, call \`show_user_list_ui\` to show the found user list UI. You should only use this function to list users and you should not show any data otherwise. This function a requires search query so you should construct the query.
20 |
21 | If the user wants to narrow down the search to specific fields like a user's name, username, or email, use the 'in' qualifier. If the user searching for users with 'Jane' in their full name, then your query should be 'Jane+in:name'. For searching within the username, 'jane+in:login' targets users whose login includes 'jane'. Searching by email, like finding users with an email address that includes 'example', would be 'example@example.com+in:email'.
22 | To filter users based on the number of followers, use the 'followers' qualifier with comparison operators.
23 | For finding users with more than 100 followers, write 'followers:>100'. If the user asks for users with followers within a specific range, e.g. 250 to 500, the query becomes 'followers:250..500'.
24 | For location-based searches, use 'location' qualifier. To find users located in 'Berlin', your query should be 'location:Berlin'.
25 | If the user asks for more specific searches, you can combine these queries. For instance, to find users named 'John Doe' in 'Seattle' with more than 200 followers, combine these qualifiers like 'John+Doe+in:name+location:Seattle+followers:>200'.
26 | Following are the examples for you to analyze:
27 | "Jane+in:name" means the query is to search for people named Jane.
28 | "example@example.com+in:email" means the query is to search for emails with given example.
29 | "John+in:name+location:Seattle" means the query is to search for people named Jonh in Seattle.
30 | "Alice+in:name+followers:50..150" means the query is to search for people named Alice with the followers within the range of 50 to 150.
31 | "example@example.com+in:email+followers:>200" means the query is to search for emails that has more than 200 followers.
32 | "Mike+in:name+location:New+York+followers:>50+developer+in:bio" means the query is to search for people named Mike located in New York that has more than 50 followers and has the keyword developer in their bio.
33 | You can combine these query methods as it is in the followers example to construct a more detailed query based on the user's request. You shouldn't anything else other than whay you are provided.
34 | If user provided an input, you have to add it into the query. For example, if you put "location:" in the query, you should provide the location input from the user.
35 |
36 | Besides that, you can also chat with users and do some calculations if needed.`
37 |
--------------------------------------------------------------------------------
/lib/hooks/use-copy-to-clipboard.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 |
5 | export interface useCopyToClipboardProps {
6 | timeout?: number
7 | }
8 |
9 | export function useCopyToClipboard({
10 | timeout = 2000,
11 | }: useCopyToClipboardProps) {
12 | const [isCopied, setIsCopied] = React.useState(false)
13 |
14 | const copyToClipboard = (value: string) => {
15 | if (typeof window === 'undefined' || !navigator.clipboard?.writeText) {
16 | return
17 | }
18 |
19 | if (!value) {
20 | return
21 | }
22 |
23 | navigator.clipboard.writeText(value).then(() => {
24 | setIsCopied(true)
25 |
26 | setTimeout(() => {
27 | setIsCopied(false)
28 | }, timeout)
29 | })
30 | }
31 |
32 | return { isCopied, copyToClipboard }
33 | }
34 |
--------------------------------------------------------------------------------
/lib/hooks/use-decoder.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { useAuth } from '@clerk/nextjs'
3 | import { decodeContent } from '../chat/github/github'
4 |
5 | export function useDecoder(url: string) {
6 | const [fetchedData, setFetchedData] = useState()
7 | const [isLoading, setIsLoading] = useState(false)
8 | const { userId } = useAuth()
9 |
10 | useEffect(() => {
11 | ;(async () => {
12 | const data = await decodeContent(url, userId ? userId : null)
13 | if (!data) {
14 | return
15 | }
16 | setFetchedData(Buffer.from(data, 'base64').toString('utf8'))
17 | })()
18 | }, [url])
19 | if (!fetchedData) {
20 | return null
21 | }
22 | return {
23 | fetchedData,
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/lib/hooks/use-enter-submit.ts:
--------------------------------------------------------------------------------
1 | import { useRef, type RefObject } from 'react'
2 |
3 | export function useEnterSubmit(): {
4 | formRef: RefObject
5 | onKeyDown: (event: React.KeyboardEvent) => void
6 | } {
7 | const formRef = useRef(null)
8 |
9 | const handleKeyDown = (
10 | event: React.KeyboardEvent,
11 | ): void => {
12 | if (
13 | event.key === 'Enter' &&
14 | !event.shiftKey &&
15 | !event.nativeEvent.isComposing
16 | ) {
17 | formRef.current?.requestSubmit()
18 | event.preventDefault()
19 | }
20 | }
21 |
22 | return { formRef, onKeyDown: handleKeyDown }
23 | }
24 |
--------------------------------------------------------------------------------
/lib/hooks/use-get-directory-content.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { getDirContent } from '../chat/github/github'
3 | import { Directory } from '../types'
4 | import { useAuth } from '@clerk/nextjs'
5 |
6 | export function useGetDirectoryContent(url: string) {
7 | const [fetchedData, setFetchedData] = useState([])
8 | const [isLoading, setIsLoading] = useState(false)
9 | const { userId } = useAuth()
10 |
11 | useEffect(() => {
12 | ;(async () => {
13 | setIsLoading(true)
14 | const data = await getDirContent(url, userId ? userId : null)
15 | setFetchedData(data)
16 | })()
17 |
18 | setIsLoading(false)
19 | }, [url, userId])
20 | if (!fetchedData) {
21 | return null
22 | }
23 | return { fetchedData, isLoading }
24 | }
25 |
--------------------------------------------------------------------------------
/lib/hooks/use-local-storage.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | export const useLocalStorage = (
4 | key: string,
5 | initialValue: T,
6 | ): [T, (value: T) => void] => {
7 | const [storedValue, setStoredValue] = useState(initialValue)
8 |
9 | useEffect(() => {
10 | // Get from local storage by key
11 | const item = window.localStorage.getItem(key)
12 | if (item) {
13 | setStoredValue(JSON.parse(item))
14 | }
15 | }, [key])
16 |
17 | // Set to local storage
18 | const setValue = (value: T) => {
19 | setStoredValue(value)
20 | window.localStorage.setItem(key, JSON.stringify(value))
21 | }
22 | return [storedValue, setValue]
23 | }
24 |
--------------------------------------------------------------------------------
/lib/hooks/use-scroll-anchor.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from 'react'
2 |
3 | export const useScrollAnchor = () => {
4 | const messagesRef = useRef(null)
5 | const scrollRef = useRef(null)
6 | const visibilityRef = useRef(null)
7 |
8 | const [isAtBottom, setIsAtBottom] = useState(true)
9 | const [isVisible, setIsVisible] = useState(false)
10 |
11 | const scrollToBottom = useCallback(() => {
12 | if (messagesRef.current) {
13 | messagesRef.current.scrollIntoView({
14 | block: 'end',
15 | behavior: 'smooth',
16 | })
17 | }
18 | }, [])
19 |
20 | useEffect(() => {
21 | if (messagesRef.current) {
22 | if (isAtBottom && !isVisible) {
23 | messagesRef.current.scrollIntoView({
24 | block: 'end',
25 | })
26 | }
27 | }
28 | }, [isAtBottom, isVisible])
29 |
30 | useEffect(() => {
31 | const { current } = scrollRef
32 |
33 | if (current) {
34 | const handleScroll = (event: Event) => {
35 | const target = event.target as HTMLDivElement
36 | const offset = 25
37 | const isAtBottom =
38 | target.scrollTop + target.clientHeight >= target.scrollHeight - offset
39 |
40 | setIsAtBottom(isAtBottom)
41 | }
42 |
43 | current.addEventListener('scroll', handleScroll, {
44 | passive: true,
45 | })
46 |
47 | return () => {
48 | current.removeEventListener('scroll', handleScroll)
49 | }
50 | }
51 | }, [])
52 |
53 | useEffect(() => {
54 | if (visibilityRef.current) {
55 | let observer = new IntersectionObserver(
56 | (entries) => {
57 | entries.forEach((entry) => {
58 | if (entry.isIntersecting) {
59 | setIsVisible(true)
60 | } else {
61 | setIsVisible(false)
62 | }
63 | })
64 | },
65 | {
66 | rootMargin: '0px 0px -150px 0px',
67 | },
68 | )
69 |
70 | observer.observe(visibilityRef.current)
71 |
72 | return () => {
73 | observer.disconnect()
74 | }
75 | }
76 | })
77 |
78 | return {
79 | messagesRef,
80 | scrollRef,
81 | visibilityRef,
82 | scrollToBottom,
83 | isAtBottom,
84 | isVisible,
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/lib/hooks/use-sidebar.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 |
5 | const LOCAL_STORAGE_KEY = 'sidebar'
6 |
7 | interface SidebarContext {
8 | isSidebarOpen: boolean
9 | toggleSidebar: () => void
10 | isLoading: boolean
11 | }
12 |
13 | const SidebarContext = React.createContext(
14 | undefined,
15 | )
16 |
17 | export function useSidebar() {
18 | const context = React.useContext(SidebarContext)
19 | if (!context) {
20 | throw new Error('useSidebarContext must be used within a SidebarProvider')
21 | }
22 | return context
23 | }
24 |
25 | interface SidebarProviderProps {
26 | children: React.ReactNode
27 | }
28 |
29 | export function SidebarProvider({ children }: SidebarProviderProps) {
30 | const [isSidebarOpen, setSidebarOpen] = React.useState(true)
31 | const [isLoading, setLoading] = React.useState(true)
32 |
33 | React.useEffect(() => {
34 | const value = localStorage.getItem(LOCAL_STORAGE_KEY)
35 | if (value) {
36 | setSidebarOpen(JSON.parse(value))
37 | }
38 | setLoading(false)
39 | }, [])
40 |
41 | const toggleSidebar = () => {
42 | setSidebarOpen((value) => {
43 | const newState = !value
44 | localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newState))
45 | return newState
46 | })
47 | }
48 |
49 | if (isLoading) {
50 | return null
51 | }
52 |
53 | return (
54 |
57 | {children}
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/lib/hooks/use-streamable-text.ts:
--------------------------------------------------------------------------------
1 | import { StreamableValue, readStreamableValue } from 'ai/rsc'
2 | import { useEffect, useState } from 'react'
3 |
4 | /**
5 | * The useStreamableText hook is used to handle streaming text content, updating the raw content state
6 | * based on the provided string or StreamableValue.
7 | * @param {string | StreamableValue} content - The `content` parameter in the
8 | * `useStreamableText` custom hook can be either a string or a `StreamableValue`. If it is a
9 | * string, it is directly set as the initial `rawContent` state. If it is a `StreamableValue`,
10 | * the
11 | * @returns The `useStreamableText` custom hook returns the `rawContent` state value, which is either
12 | * the initial `content` string or the accumulated value from reading a `StreamableValue`
13 | * object asynchronously.
14 | * Custom hook by Vercel see here: https://github.com/vercel/ai-chatbot/blob/main/lib/hooks/use-streamable-text.ts#L4
15 | */
16 | export const useStreamableText = (
17 | content: string | StreamableValue,
18 | ) => {
19 | const [rawContent, setRawContent] = useState(
20 | typeof content === 'string' ? content : '',
21 | )
22 |
23 | const [isLoading, setIsLoading] = useState(typeof content === 'object')
24 |
25 | useEffect(() => {
26 | ;(async () => {
27 | if (typeof content === 'object') {
28 | let value = ''
29 | for await (const delta of readStreamableValue(content)) {
30 | if (typeof delta === 'string') {
31 | setRawContent((value = value + delta))
32 | }
33 | }
34 | setIsLoading(false)
35 | }
36 | })()
37 | }, [content])
38 | return { rawContent, isLoading }
39 | }
40 |
--------------------------------------------------------------------------------
/lib/store.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand'
2 |
3 | interface IsLoadingStore {
4 | isLoadingState: boolean
5 | setIsLoading: (didComponentMount: boolean) => Promise
6 | }
7 |
8 | export const isLoadingStore = create((set) => ({
9 | isLoadingState: false,
10 | setIsLoading: async (isLoadingState: boolean) => set({ isLoadingState }),
11 | }))
12 |
--------------------------------------------------------------------------------
/lib/types.ts:
--------------------------------------------------------------------------------
1 | import { type Message } from 'ai'
2 |
3 | export interface Chat extends Record {
4 | id: string
5 | title: string
6 | author: string
7 | createdAt: Date
8 | path: string
9 | messages: Message[]
10 | sharedPath?: string
11 | }
12 |
13 | export type ServerActionResult = Promise<
14 | | Result
15 | | {
16 | error: string
17 | }
18 | >
19 |
20 | export interface Session {
21 | id: string
22 | email: string
23 | family_name: string
24 | given_name: string
25 | createdAt: Date
26 | }
27 |
28 | export interface GithubUser {
29 | name?: string
30 | avatar_url: string
31 | login: string
32 | html_url: string
33 | company?: string
34 | blog?: string
35 | location?: string
36 | email?: string
37 | bio?: string
38 | twitter_username?: string
39 | followers: number
40 | following: number
41 | created_at: Date
42 | public_repos: number
43 | }
44 |
45 | export interface ListOfUsers {
46 | incomplete_results: boolean
47 | items: GithubUser[]
48 | total_count: number
49 | }
50 |
51 | export interface AttributeTypes {
52 | name: string
53 | value: string
54 | role: string
55 | icon?: React.ReactNode
56 | status: string
57 | }
58 |
59 | export interface RepoFetchProps {
60 | total_count: number
61 | incomplete_results: boolean
62 | items: RepoProps[]
63 | }
64 |
65 | export interface RepoProps {
66 | id: number
67 | name: string
68 | full_name: string
69 | homepage: string
70 | owner: {
71 | login: string
72 | avatar_url: string
73 | url: string
74 | }
75 | html_url: string
76 | description: string
77 | language: string
78 | stargazers_count: number
79 | open_issues: number
80 | license: {
81 | spdx_id: string
82 | }
83 | issues_url: string
84 | tags_url: string
85 | languages_url: string
86 | updated_at: string
87 | topics: string[]
88 | commits_url: string
89 | }
90 |
91 | export interface Readme {
92 | name: string
93 | path: string
94 | url: string
95 | html_url: string
96 | content: string
97 | encoding: string
98 | }
99 |
100 | export interface Directory {
101 | name: string
102 | path: string
103 | type: 'file' | 'dir'
104 | url: string
105 | html_url: string
106 | sha: string
107 | content: string
108 | _links: {
109 | git: string
110 | self: string
111 | html: string
112 | }
113 | }
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx'
2 | import { customAlphabet } from 'nanoid'
3 | import { twMerge } from 'tailwind-merge'
4 |
5 | export function cn(...inputs: ClassValue[]) {
6 | return twMerge(clsx(inputs))
7 | }
8 |
9 | export const nanoid = customAlphabet(
10 | '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
11 | 7,
12 | )
13 |
14 | export const sleep = (ms: number) =>
15 | new Promise((resolve) => setTimeout(resolve, ms))
16 |
17 | export const runAsyncFnWithoutBlocking = (
18 | fn: (...args: any) => Promise,
19 | ) => {
20 | fn()
21 | }
22 |
23 | export const getMediumFont = async () => {
24 | const response = await fetch(
25 | new URL('@/assets/fonts/LabilGrotesk-Medium.ttf', import.meta.url),
26 | )
27 | const font = await response.arrayBuffer()
28 | return font
29 | }
30 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { authMiddleware } from '@clerk/nextjs'
2 |
3 | // See https://clerk.com/docs/references/nextjs/auth-middleware
4 | // for more information about configuring your Middleware
5 | export default authMiddleware({
6 | publicRoutes: ['/', '/sign-in', '/opengraph-image'],
7 | // Allow signed out users to access the specified routes:
8 | // publicRoutes: ['/anyone-can-visit-this-route'],
9 | })
10 |
11 | export const config = {
12 | matcher: [
13 | // Exclude files with a "." followed by an extension, which are typically static files.
14 | // Exclude files in the _next directory, which are Next.js internals.
15 | '/((?!.+\\.[\\w]+$|_next).*)',
16 | // Re-include any files in the api or trpc folders that might have an extension
17 | '/(api|trpc)(.*)',
18 | ],
19 | }
20 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: 'https',
7 | hostname: '**',
8 | },
9 | {
10 | protocol: 'http',
11 | hostname: '**',
12 | },
13 | ],
14 | },
15 | }
16 |
17 | module.exports = nextConfig
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-openai",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "db:generate": "drizzle-kit generate:pg --schema db/schema.ts --out ./db/migrations",
11 | "db:migrate": "tsx ./db/migrate.ts"
12 | },
13 | "dependencies": {
14 | "@clerk/nextjs": "^4.29.9",
15 | "@neondatabase/serverless": "^0.9.0",
16 | "@phosphor-icons/react": "^2.0.15",
17 | "@radix-ui/react-accordion": "^1.1.2",
18 | "@radix-ui/react-alert-dialog": "^1.0.5",
19 | "@radix-ui/react-dialog": "^1.0.5",
20 | "@radix-ui/react-dropdown-menu": "^2.0.6",
21 | "@radix-ui/react-icons": "^1.3.0",
22 | "@radix-ui/react-navigation-menu": "^1.1.4",
23 | "@radix-ui/react-scroll-area": "^1.0.5",
24 | "@radix-ui/react-separator": "^1.0.3",
25 | "@radix-ui/react-slot": "^1.0.2",
26 | "@radix-ui/react-toast": "^1.1.5",
27 | "@radix-ui/react-tooltip": "^1.0.7",
28 | "@tanstack/react-query": "^5.28.6",
29 | "@vercel/analytics": "^1.2.2",
30 | "ai": "^3.0.12",
31 | "class-variance-authority": "^0.7.0",
32 | "clsx": "^2.1.0",
33 | "drizzle-orm": "^0.30.2",
34 | "eslint-plugin-tailwindcss": "^3.14.3",
35 | "framer-motion": "^11.0.24",
36 | "nanoid": "^5.0.6",
37 | "next": "14.1.1",
38 | "next-themes": "^0.2.1",
39 | "openai": "4.16.1",
40 | "pg": "^8.11.3",
41 | "react": "18.2.0",
42 | "react-dom": "^18.2.0",
43 | "react-markdown": "^8.0.7",
44 | "react-syntax-highlighter": "^15.5.0",
45 | "rehype-raw": "^7.0.0",
46 | "remark-gfm": "^3.0.1",
47 | "remark-math": "^5.1.1",
48 | "tailwind-merge": "^2.2.1",
49 | "tailwindcss-animate": "^1.0.7",
50 | "zod": "^3.22.4",
51 | "zustand": "^4.5.2"
52 | },
53 | "devDependencies": {
54 | "@types/node": "^17.0.45",
55 | "@types/react": "18.2.8",
56 | "@types/react-dom": "18.2.4",
57 | "@types/react-syntax-highlighter": "^15.5.11",
58 | "autoprefixer": "^10.4.14",
59 | "dotenv": "^16.4.5",
60 | "dotenv-cli": "^7.4.1",
61 | "drizzle-kit": "^0.20.17",
62 | "eslint": "^7.32.0",
63 | "eslint-config-next": "13.4.12",
64 | "postcss": "^8.4.23",
65 | "postgres": "^3.4.3",
66 | "prettier": "3.2.5",
67 | "tailwindcss": "^3.3.2",
68 | "ts-node": "^10.9.2",
69 | "tsx": "^4.7.1",
70 | "typescript": "^5.1.3"
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss'
2 |
3 | const config = {
4 | darkMode: ['class'],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './components/**/*.{ts,tsx}',
8 | './app/**/*.{ts,tsx}',
9 | './src/**/*.{ts,tsx}',
10 | ],
11 | prefix: '',
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: '2rem',
16 | screens: {
17 | '2xl': '1400px',
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: 'hsl(var(--border))',
23 | input: 'hsl(var(--input))',
24 | ring: 'hsl(var(--ring))',
25 | background: 'hsl(var(--background))',
26 | foreground: 'hsl(var(--foreground))',
27 | primary: {
28 | DEFAULT: 'hsl(var(--primary))',
29 | foreground: 'hsl(var(--primary-foreground))',
30 | },
31 | secondary: {
32 | DEFAULT: 'hsl(var(--secondary))',
33 | foreground: 'hsl(var(--secondary-foreground))',
34 | },
35 | destructive: {
36 | DEFAULT: 'hsl(var(--destructive))',
37 | foreground: 'hsl(var(--destructive-foreground))',
38 | },
39 | muted: {
40 | DEFAULT: 'hsl(var(--muted))',
41 | foreground: 'hsl(var(--muted-foreground))',
42 | },
43 | accent: {
44 | DEFAULT: 'hsl(var(--accent))',
45 | foreground: 'hsl(var(--accent-foreground))',
46 | },
47 | popover: {
48 | DEFAULT: 'hsl(var(--popover))',
49 | foreground: 'hsl(var(--popover-foreground))',
50 | },
51 | card: {
52 | DEFAULT: 'hsl(var(--card))',
53 | foreground: 'hsl(var(--card-foreground))',
54 | },
55 | },
56 | borderRadius: {
57 | lg: 'var(--radius)',
58 | md: 'calc(var(--radius) - 2px)',
59 | sm: 'calc(var(--radius) - 4px)',
60 | },
61 | keyframes: {
62 | 'accordion-down': {
63 | from: { height: '0' },
64 | to: { height: 'var(--radix-accordion-content-height)' },
65 | },
66 | 'accordion-up': {
67 | from: { height: 'var(--radix-accordion-content-height)' },
68 | to: { height: '0' },
69 | },
70 | },
71 | animation: {
72 | 'accordion-down': 'accordion-down 0.2s ease-out',
73 | 'accordion-up': 'accordion-up 0.2s ease-out',
74 | },
75 | },
76 | },
77 | plugins: [require('tailwindcss-animate')],
78 | } satisfies Config
79 |
80 | export default config
81 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------