├── app ├── up │ └── route.ts ├── twitter-image.png ├── opengraph-image.png ├── new │ └── page.tsx ├── api │ └── status │ │ └── route.ts ├── (chat) │ ├── layout.tsx │ ├── page.tsx │ └── chat │ │ └── [id] │ │ └── page.tsx ├── login │ ├── page.tsx │ └── actions.ts ├── signup │ ├── page.tsx │ └── actions.ts ├── share │ └── [id] │ │ └── page.tsx ├── globals.css ├── layout.tsx └── actions.ts ├── public ├── favicon.ico ├── favicon-16x16.png ├── apple-touch-icon.png ├── vercel.svg ├── thirteen.svg └── next.svg ├── postcss.config.js ├── .dockerignore ├── next-env.d.ts ├── middleware.ts ├── next.config.js ├── .env.example ├── components ├── markdown.tsx ├── sidebar-footer.tsx ├── stocks │ ├── spinner.tsx │ ├── stocks-skeleton.tsx │ ├── stock-skeleton.tsx │ ├── events.tsx │ ├── events-skeleton.tsx │ ├── index.tsx │ ├── stocks.tsx │ ├── message.tsx │ ├── stock-purchase.tsx │ └── stock.tsx ├── sidebar.tsx ├── providers.tsx ├── sidebar-desktop.tsx ├── sidebar-toggle.tsx ├── tailwind-indicator.tsx ├── footer.tsx ├── external-link.tsx ├── ui │ ├── label.tsx │ ├── textarea.tsx │ ├── input.tsx │ ├── separator.tsx │ ├── sonner.tsx │ ├── tooltip.tsx │ ├── switch.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── dialog.tsx │ ├── codeblock.tsx │ ├── sheet.tsx │ ├── alert-dialog.tsx │ ├── select.tsx │ └── dropdown-menu.tsx ├── theme-toggle.tsx ├── sidebar-mobile.tsx ├── button-scroll-to-bottom.tsx ├── sidebar-items.tsx ├── chat-message-actions.tsx ├── login-button.tsx ├── sidebar-list.tsx ├── chat-history.tsx ├── chat-list.tsx ├── empty-screen.tsx ├── user-menu.tsx ├── clear-history.tsx ├── chat.tsx ├── header.tsx ├── chat-message.tsx ├── chat-share-dialog.tsx ├── signup-form.tsx ├── login-form.tsx ├── prompt-form.tsx ├── sidebar-item.tsx ├── sidebar-actions.tsx └── chat-panel.tsx ├── config └── deploy.yml ├── components.json ├── LICENSE ├── .gitignore ├── .eslintrc.json ├── lib ├── hooks │ ├── use-enter-submit.tsx │ ├── use-local-storage.ts │ ├── use-streamable-text.ts │ ├── use-copy-to-clipboard.tsx │ ├── use-sidebar.tsx │ └── use-scroll-anchor.tsx ├── types.ts ├── utils.ts ├── storage.ts └── chat │ └── actions.tsx ├── tsconfig.json ├── prettier.config.cjs ├── auth.config.ts ├── Dockerfile ├── auth.ts ├── .github └── workflows │ └── deploy-on-main.yml ├── tailwind.config.ts ├── package.json └── README.md /app/up/route.ts: -------------------------------------------------------------------------------- 1 | export async function GET() { 2 | return new Response('Ok', { status: 200 }) 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleyrudland/nextjs-ai-chatbot-on-vps/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /app/twitter-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleyrudland/nextjs-ai-chatbot-on-vps/HEAD/app/twitter-image.png -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleyrudland/nextjs-ai-chatbot-on-vps/HEAD/app/opengraph-image.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleyrudland/nextjs-ai-chatbot-on-vps/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashleyrudland/nextjs-ai-chatbot-on-vps/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm - debug.log 5 | README.md 6 | .next 7 | .git 8 | .gitignore -------------------------------------------------------------------------------- /app/new/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation' 2 | 3 | export default async function NewPage() { 4 | redirect('/') 5 | } 6 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth' 2 | import { authConfig } from './auth.config' 3 | 4 | export default NextAuth(authConfig).auth 5 | 6 | export const config = { 7 | matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'] 8 | } 9 | -------------------------------------------------------------------------------- /app/api/status/route.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers' 2 | import { NextResponse } from 'next/server' 3 | 4 | import { getStats } from '@/lib/storage' 5 | 6 | export async function GET() { 7 | // stay dynamic 8 | cookies().get('token') 9 | 10 | const stats = await getStats() 11 | return NextResponse.json(stats) 12 | } 13 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | output: 'standalone', 4 | images: { 5 | remotePatterns: [ 6 | { 7 | protocol: 'https', 8 | hostname: 'avatars.githubusercontent.com', 9 | port: '', 10 | pathname: '**' 11 | } 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # You must first activate a Billing Account here: https://platform.openai.com/account/billing/overview 2 | # Then get your OpenAI API Key here: https://platform.openai.com/account/api-keys 3 | OPENAI_API_KEY=XXXXXXXX 4 | 5 | # Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32` 6 | AUTH_SECRET=XXXXXXXX 7 | -------------------------------------------------------------------------------- /components/markdown.tsx: -------------------------------------------------------------------------------- 1 | import { FC, memo } from 'react' 2 | import ReactMarkdown, { Options } from 'react-markdown' 3 | 4 | export const MemoizedReactMarkdown: FC = memo( 5 | ReactMarkdown, 6 | (prevProps, nextProps) => 7 | prevProps.children === nextProps.children && 8 | prevProps.className === nextProps.className 9 | ) 10 | -------------------------------------------------------------------------------- /components/sidebar-footer.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils' 2 | 3 | export function SidebarFooter({ 4 | children, 5 | className, 6 | ...props 7 | }: React.ComponentProps<'div'>) { 8 | return ( 9 |
13 | {children} 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /config/deploy.yml: -------------------------------------------------------------------------------- 1 | # config/deploy.yml 2 | service: server 3 | image: ashleyrudland87/ai-chatbot 4 | servers: 5 | - <%= ENV["VPS_IP"] %> 6 | registry: 7 | username: 8 | - KAMAL_REGISTRY_USERNAME 9 | password: 10 | - KAMAL_REGISTRY_PASSWORD 11 | port: 3000 12 | volumes: 13 | - "ai-chatbot-data:/ai-chatbot-data/" 14 | env: 15 | secret: 16 | - OPENAI_API_KEY 17 | - AUTH_SECRET 18 | -------------------------------------------------------------------------------- /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": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /app/(chat)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { SidebarDesktop } from '@/components/sidebar-desktop' 2 | 3 | interface ChatLayoutProps { 4 | children: React.ReactNode 5 | } 6 | 7 | export default async function ChatLayout({ children }: ChatLayoutProps) { 8 | return ( 9 |
10 | 11 | {children} 12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from '@/auth' 2 | import LoginForm from '@/components/login-form' 3 | import { Session } from '@/lib/types' 4 | import { redirect } from 'next/navigation' 5 | 6 | export default async function LoginPage() { 7 | const session = (await auth()) as Session 8 | 9 | if (session) { 10 | redirect('/') 11 | } 12 | 13 | return ( 14 |
15 | 16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /app/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from '@/auth' 2 | import SignupForm from '@/components/signup-form' 3 | import { Session } from '@/lib/types' 4 | import { redirect } from 'next/navigation' 5 | 6 | export default async function SignupPage() { 7 | const session = (await auth()) as Session 8 | 9 | if (session) { 10 | redirect('/') 11 | } 12 | 13 | return ( 14 |
15 | 16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /components/stocks/spinner.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | export const spinner = ( 4 | 14 | 15 | 16 | ) 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Vercel, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /components/sidebar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | 5 | import { useSidebar } from '@/lib/hooks/use-sidebar' 6 | import { cn } from '@/lib/utils' 7 | 8 | export interface SidebarProps extends React.ComponentProps<'div'> {} 9 | 10 | export function Sidebar({ className, children }: SidebarProps) { 11 | const { isSidebarOpen, isLoading } = useSidebar() 12 | 13 | return ( 14 |
18 | {children} 19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { ThemeProvider as NextThemesProvider } from 'next-themes' 5 | import { ThemeProviderProps } from 'next-themes/dist/types' 6 | import { SidebarProvider } from '@/lib/hooks/use-sidebar' 7 | import { TooltipProvider } from '@/components/ui/tooltip' 8 | 9 | export function Providers({ children, ...props }: ThemeProviderProps) { 10 | return ( 11 | 12 | 13 | {children} 14 | 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /.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 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .pnpm-debug.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | # turbo 33 | .turbo 34 | 35 | .env 36 | .vercel 37 | .vscode 38 | .env*.local 39 | 40 | groups.json 41 | items.json 42 | deploy.sh 43 | -------------------------------------------------------------------------------- /components/sidebar-desktop.tsx: -------------------------------------------------------------------------------- 1 | import { Sidebar } from '@/components/sidebar' 2 | 3 | import { auth } from '@/auth' 4 | import { ChatHistory } from '@/components/chat-history' 5 | 6 | export async function SidebarDesktop() { 7 | const session = await auth() 8 | 9 | if (!session?.user?.id) { 10 | return null 11 | } 12 | 13 | return ( 14 | 15 | {/* @ts-ignore */} 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "root": true, 4 | "extends": [ 5 | "next/core-web-vitals", 6 | "prettier", 7 | "plugin:tailwindcss/recommended" 8 | ], 9 | "plugins": ["tailwindcss"], 10 | "rules": { 11 | "tailwindcss/no-custom-classname": "off", 12 | "tailwindcss/classnames-order": "off" 13 | }, 14 | "settings": { 15 | "tailwindcss": { 16 | "callees": ["cn", "cva"], 17 | "config": "tailwind.config.js" 18 | } 19 | }, 20 | "overrides": [ 21 | { 22 | "files": ["*.ts", "*.tsx"], 23 | "parser": "@typescript-eslint/parser" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /components/stocks/stocks-skeleton.tsx: -------------------------------------------------------------------------------- 1 | export const StocksSkeleton = () => { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /components/sidebar-toggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | 5 | import { useSidebar } from '@/lib/hooks/use-sidebar' 6 | import { Button } from '@/components/ui/button' 7 | import { IconSidebar } from '@/components/ui/icons' 8 | 9 | export function SidebarToggle() { 10 | const { toggleSidebar } = useSidebar() 11 | 12 | return ( 13 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /components/tailwind-indicator.tsx: -------------------------------------------------------------------------------- 1 | export function TailwindIndicator() { 2 | if (process.env.NODE_ENV === 'production') return null 3 | 4 | return ( 5 |
6 |
xs
7 |
sm
8 |
md
9 |
lg
10 |
xl
11 |
2xl
12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /lib/hooks/use-enter-submit.tsx: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /app/(chat)/page.tsx: -------------------------------------------------------------------------------- 1 | import { nanoid } from '@/lib/utils' 2 | import { Chat } from '@/components/chat' 3 | import { AI } from '@/lib/chat/actions' 4 | import { auth } from '@/auth' 5 | import { Session } from '@/lib/types' 6 | import { getMissingKeys } from '../actions' 7 | 8 | export const metadata = { 9 | title: 'Next.js AI Chatbot' 10 | } 11 | 12 | export default async function IndexPage() { 13 | const id = nanoid() 14 | const session = (await auth()) as Session 15 | const missingKeys = await getMissingKeys() 16 | 17 | return ( 18 | 19 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /components/footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { cn } from '@/lib/utils' 4 | import { ExternalLink } from '@/components/external-link' 5 | 6 | export function FooterText({ className, ...props }: React.ComponentProps<'p'>) { 7 | return ( 8 |

15 | Open source AI chatbot built with{' '} 16 | Next.js and{' '} 17 | 18 | Vercel AI SDK 19 | 20 | . 21 |

22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /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 | // Retrieve from localStorage 11 | const item = window.localStorage.getItem(key) 12 | if (item) { 13 | setStoredValue(JSON.parse(item)) 14 | } 15 | }, [key]) 16 | 17 | const setValue = (value: T) => { 18 | // Save state 19 | setStoredValue(value) 20 | // Save to localStorage 21 | window.localStorage.setItem(key, JSON.stringify(value)) 22 | } 23 | return [storedValue, setValue] 24 | } 25 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | import { Message } from 'ai' 2 | 3 | export interface Chat extends Record { 4 | id: string 5 | title: string 6 | createdAt: Date 7 | userId: string 8 | path: string 9 | messages: Message[] 10 | sharePath?: string 11 | } 12 | 13 | export type ServerActionResult = Promise< 14 | | Result 15 | | { 16 | error: string 17 | } 18 | > 19 | 20 | export interface Session { 21 | user: { 22 | id: string 23 | email: string 24 | } 25 | } 26 | 27 | export interface AuthResult { 28 | type: string 29 | message: string 30 | } 31 | 32 | export interface User extends Record { 33 | id: string 34 | email: string 35 | password: string 36 | salt: string 37 | } 38 | -------------------------------------------------------------------------------- /components/external-link.tsx: -------------------------------------------------------------------------------- 1 | export function ExternalLink({ 2 | href, 3 | children 4 | }: { 5 | href: string 6 | children: React.ReactNode 7 | }) { 8 | return ( 9 | 14 | {children} 15 | 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /lib/hooks/use-streamable-text.ts: -------------------------------------------------------------------------------- 1 | import { StreamableValue, readStreamableValue } from 'ai/rsc' 2 | import { useEffect, useState } from 'react' 3 | 4 | export const useStreamableText = ( 5 | content: string | StreamableValue 6 | ) => { 7 | const [rawContent, setRawContent] = useState( 8 | typeof content === 'string' ? content : '' 9 | ) 10 | 11 | useEffect(() => { 12 | ;(async () => { 13 | if (typeof content === 'object') { 14 | let value = '' 15 | for await (const delta of readStreamableValue(content)) { 16 | console.log(delta) 17 | if (typeof delta === 'string') { 18 | setRawContent((value = value + delta)) 19 | } 20 | } 21 | } 22 | })() 23 | }, [content]) 24 | 25 | return rawContent 26 | } 27 | -------------------------------------------------------------------------------- /lib/hooks/use-copy-to-clipboard.tsx: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /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 |