├── .dockerignore
├── .env.example
├── .eslintrc.json
├── .github
└── workflows
│ └── deploy-on-main.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── app
├── (chat)
│ ├── chat
│ │ └── [id]
│ │ │ └── page.tsx
│ ├── layout.tsx
│ └── page.tsx
├── actions.ts
├── api
│ └── status
│ │ └── route.ts
├── globals.css
├── layout.tsx
├── login
│ ├── actions.ts
│ └── page.tsx
├── new
│ └── page.tsx
├── opengraph-image.png
├── share
│ └── [id]
│ │ └── page.tsx
├── signup
│ ├── actions.ts
│ └── page.tsx
├── twitter-image.png
└── up
│ └── route.ts
├── auth.config.ts
├── auth.ts
├── components.json
├── components
├── button-scroll-to-bottom.tsx
├── chat-history.tsx
├── chat-list.tsx
├── chat-message-actions.tsx
├── chat-message.tsx
├── chat-panel.tsx
├── chat-share-dialog.tsx
├── chat.tsx
├── clear-history.tsx
├── empty-screen.tsx
├── external-link.tsx
├── footer.tsx
├── header.tsx
├── login-button.tsx
├── login-form.tsx
├── markdown.tsx
├── prompt-form.tsx
├── providers.tsx
├── sidebar-actions.tsx
├── sidebar-desktop.tsx
├── sidebar-footer.tsx
├── sidebar-item.tsx
├── sidebar-items.tsx
├── sidebar-list.tsx
├── sidebar-mobile.tsx
├── sidebar-toggle.tsx
├── sidebar.tsx
├── signup-form.tsx
├── stocks
│ ├── events-skeleton.tsx
│ ├── events.tsx
│ ├── index.tsx
│ ├── message.tsx
│ ├── spinner.tsx
│ ├── stock-purchase.tsx
│ ├── stock-skeleton.tsx
│ ├── stock.tsx
│ ├── stocks-skeleton.tsx
│ └── stocks.tsx
├── tailwind-indicator.tsx
├── theme-toggle.tsx
├── ui
│ ├── alert-dialog.tsx
│ ├── badge.tsx
│ ├── button.tsx
│ ├── codeblock.tsx
│ ├── dialog.tsx
│ ├── dropdown-menu.tsx
│ ├── icons.tsx
│ ├── input.tsx
│ ├── label.tsx
│ ├── select.tsx
│ ├── separator.tsx
│ ├── sheet.tsx
│ ├── sonner.tsx
│ ├── switch.tsx
│ ├── textarea.tsx
│ └── tooltip.tsx
└── user-menu.tsx
├── config
└── deploy.yml
├── lib
├── chat
│ └── actions.tsx
├── hooks
│ ├── use-copy-to-clipboard.tsx
│ ├── use-enter-submit.tsx
│ ├── use-local-storage.ts
│ ├── use-scroll-anchor.tsx
│ ├── use-sidebar.tsx
│ └── use-streamable-text.ts
├── storage.ts
├── types.ts
└── utils.ts
├── middleware.ts
├── next-env.d.ts
├── next.config.js
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── prettier.config.cjs
├── public
├── apple-touch-icon.png
├── favicon-16x16.png
├── favicon.ico
├── next.svg
├── thirteen.svg
└── vercel.svg
├── tailwind.config.ts
└── tsconfig.json
/.dockerignore:
--------------------------------------------------------------------------------
1 | Dockerfile
2 | .dockerignore
3 | node_modules
4 | npm - debug.log
5 | README.md
6 | .next
7 | .git
8 | .gitignore
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-on-main.yml:
--------------------------------------------------------------------------------
1 | name: CD
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | Deploy:
10 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
11 | runs-on: ubuntu-latest
12 |
13 | env:
14 | DOCKER_BUILDKIT: 1
15 | KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
16 | KAMAL_REGISTRY_USERNAME: ${{ secrets.KAMAL_REGISTRY_USERNAME }}
17 | VPS_IP: ${{ secrets.VPS_IP }}
18 | # Use the same ssh-agent socket value across all jobs
19 | SSH_AUTH_SOCK: /tmp/ssh_agent.sock
20 | OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
21 | AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
22 |
23 | steps:
24 | - name: Checkout code
25 | uses: actions/checkout@v3
26 |
27 | - name: Set up Ruby
28 | uses: ruby/setup-ruby@v1
29 | with:
30 | ruby-version: 3.2.2
31 | bundler-cache: true
32 |
33 | - name: Install dependencies
34 | run: |
35 | gem install specific_install
36 | gem specific_install https://github.com/basecamp/kamal.git
37 |
38 | - name: Setup SSH with a passphrase
39 | env:
40 | SSH_PASSPHRASE: ${{secrets.SSH_PASSPHRASE}}
41 | SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}}
42 | run: |
43 | ssh-agent -a $SSH_AUTH_SOCK > /dev/null
44 | echo "echo $SSH_PASSPHRASE" > ~/.ssh_askpass && chmod +x ~/.ssh_askpass
45 | echo "$SSH_PRIVATE_KEY" | tr -d '\r' | DISPLAY=None SSH_ASKPASS=~/.ssh_askpass ssh-add - >/dev/null
46 |
47 | # - uses: webfactory/ssh-agent@v0.7.0
48 | # with:
49 | # ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
50 |
51 | - name: Set up Docker Buildx
52 | id: buildx
53 | uses: docker/setup-buildx-action@v2
54 |
55 | - name: Run deploy command
56 | run: kamal deploy
57 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-alpine AS base
2 |
3 | # Disabling Telemetry
4 | ENV NEXT_TELEMETRY_DISABLED 1
5 | RUN apk add --no-cache libc6-compat curl python3 py3-pip
6 |
7 | RUN npm install pnpm -g
8 |
9 | FROM base AS deps
10 | WORKDIR /app
11 |
12 | # add env vars as config/deploy.yml is not working
13 | RUN echo "OPENAI_API_KEY=\"$OPENAI_API_KEY\"" > ./env
14 | RUN echo "AUTH_SECRET=\"$AUTH_SECRET\"" >> ./env
15 |
16 | COPY package.json pnpm-lock.yaml ./
17 | RUN pnpm install
18 |
19 | FROM base AS builder
20 | WORKDIR /app
21 | COPY --from=deps /app/node_modules ./node_modules
22 | COPY . .
23 |
24 | RUN pnpm run build
25 |
26 | FROM base AS runner
27 | WORKDIR /app
28 |
29 | ENV NODE_ENV production
30 |
31 | RUN addgroup --system --gid 1001 nodejs
32 | RUN adduser --system --uid 1001 nextjs
33 |
34 | COPY --from=builder /app/public ./public
35 | RUN mkdir .next
36 | RUN mkdir -p /ai-chatbot-data && chown -R nextjs:nodejs /ai-chatbot-data
37 | RUN chown nextjs:nodejs .next
38 |
39 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
40 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
41 |
42 | USER nextjs
43 |
44 | EXPOSE 3000
45 |
46 | ENV PORT 3000
47 | ENV HOSTNAME "0.0.0.0"
48 | ENV NODE_ENV=production
49 |
50 | CMD ["node", "server.js"]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2023 Vercel, Inc.
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software
10 | distributed under the License is distributed on an "AS IS" BASIS,
11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | See the License for the specific language governing permissions and
13 | limitations under the License.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | Next.js AI Chatbot
4 |
5 |
6 |
7 | An open-source AI chatbot app template built with Next.js, the Vercel AI SDK, OpenAI, and in memory & JSON file cache on a VPS.
8 |
9 |
10 |
11 | Features ·
12 | Model Providers ·
13 | Deploy Your Own ·
14 | Running locally ·
15 | Authors
16 |
17 |
18 |
19 | ## Features
20 |
21 | - DOES NOT run on Edge, runs on a €3.30/mo VM on Hetzner in Germany 🇩🇪
22 | - [Next.js](https://nextjs.org) App Router
23 | - React Server Components (RSCs), Suspense, and Server Actions
24 | - [Vercel AI SDK](https://sdk.vercel.ai/docs) for streaming chat UI
25 | - Support for OpenAI (default), Anthropic, Cohere, Hugging Face, or custom AI chat models and/or LangChain
26 | - [shadcn/ui](https://ui.shadcn.com)
27 | - Styling with [Tailwind CSS](https://tailwindcss.com)
28 | - [Radix UI](https://radix-ui.com) for headless component primitives
29 | - Icons from [Phosphor Icons](https://phosphoricons.com)
30 | - Chat History, rate limiting, and session storage with ~~[Vercel KV](https://vercel.com/storage/kv)~~ [local JSON files and in memory store](./lib/storage.ts)
31 | - [NextAuth.js](https://github.com/nextauthjs/next-auth) for authentication
32 |
33 | ## Model Providers
34 |
35 | This template ships with OpenAI `gpt-3.5-turbo` as the default. However, thanks to the [Vercel AI SDK](https://sdk.vercel.ai/docs), you can switch LLM providers to [Anthropic](https://anthropic.com), [Cohere](https://cohere.com/), [Hugging Face](https://huggingface.co), or using [LangChain](https://js.langchain.com) with just a few lines of code.
36 |
37 | ## Running locally
38 |
39 | You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js AI Chatbot.
40 |
41 | > Note: You should not commit your `.env` file or it will expose secrets that will allow others to control access to your various OpenAI and authentication provider accounts.
42 |
43 | ```bash
44 | pnpm install
45 | pnpm dev
46 | ```
47 |
48 | Your app template should now be running on [localhost:3000](http://localhost:3000/).
49 |
50 | ## Authors
51 |
52 | Hacked by Ashle Rudland ([@ashleyrudland](https://twitter.com/ashleyrudland)) to run on a VPS.
53 |
54 | This library is created by [Vercel](https://vercel.com) and [Next.js](https://nextjs.org) team members, with contributions from:
55 |
56 | - Jared Palmer ([@jaredpalmer](https://twitter.com/jaredpalmer)) - [Vercel](https://vercel.com)
57 | - Shu Ding ([@shuding\_](https://twitter.com/shuding_)) - [Vercel](https://vercel.com)
58 | - shadcn ([@shadcn](https://twitter.com/shadcn)) - [Vercel](https://vercel.com)
59 |
--------------------------------------------------------------------------------
/app/(chat)/chat/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import { type Metadata } from 'next'
2 | import { notFound, redirect } from 'next/navigation'
3 |
4 | import { auth } from '@/auth'
5 | import { getChat, getMissingKeys } from '@/app/actions'
6 | import { Chat } from '@/components/chat'
7 | import { AI } from '@/lib/chat/actions'
8 | import { Session } from '@/lib/types'
9 |
10 | export interface ChatPageProps {
11 | params: {
12 | id: string
13 | }
14 | }
15 |
16 | export async function generateMetadata({
17 | params
18 | }: ChatPageProps): Promise {
19 | const session = await auth()
20 |
21 | if (!session?.user) {
22 | return {}
23 | }
24 |
25 | const chat = await getChat(params.id, session.user.id)
26 | return {
27 | title: chat?.title.toString().slice(0, 50) ?? 'Chat'
28 | }
29 | }
30 |
31 | export default async function ChatPage({ params }: ChatPageProps) {
32 | const session = (await auth()) as Session
33 | const missingKeys = await getMissingKeys()
34 |
35 | if (!session?.user) {
36 | redirect(`/login?next=/chat/${params.id}`)
37 | }
38 |
39 | const userId = session.user.id as string
40 | const chat = await getChat(params.id, userId)
41 |
42 | if (!chat) {
43 | redirect('/')
44 | }
45 |
46 | if (chat?.userId !== session?.user?.id) {
47 | notFound()
48 | }
49 |
50 | return (
51 |
52 |
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/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/(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 |
--------------------------------------------------------------------------------
/app/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { revalidatePath } from 'next/cache'
4 | import { redirect } from 'next/navigation'
5 | import { kv } from '@vercel/kv'
6 |
7 | import { auth } from '@/auth'
8 | import { type Chat } from '@/lib/types'
9 | import {
10 | deleteChatById,
11 | deleteChatsByUserId,
12 | getChatById,
13 | getChatsByUserId,
14 | insertChat,
15 | updateChat
16 | } from '@/lib/storage'
17 |
18 | export async function getChats(userId?: string | null) {
19 | if (!userId) {
20 | return []
21 | }
22 |
23 | try {
24 | return getChatsByUserId(userId)
25 | } catch (error) {
26 | return []
27 | }
28 | }
29 |
30 | export async function getChat(id: string, userId: string) {
31 | const chat = await getChatById(id)
32 |
33 | if (!chat || (userId && chat.userId !== userId)) {
34 | return null
35 | }
36 |
37 | return chat
38 | }
39 |
40 | export async function removeChat({ id, path }: { id: string; path: string }) {
41 | const session = await auth()
42 |
43 | if (!session) {
44 | return {
45 | error: 'Unauthorized'
46 | }
47 | }
48 |
49 | //Convert uid to string for consistent comparison with session.user.id
50 | const uid = (await getChatById(id))?.userId
51 |
52 | if (uid !== session?.user?.id) {
53 | return {
54 | error: 'Unauthorized'
55 | }
56 | }
57 |
58 | await deleteChatById(id)
59 |
60 | revalidatePath('/')
61 | return revalidatePath(path)
62 | }
63 |
64 | export async function clearChats() {
65 | const session = await auth()
66 |
67 | if (!session?.user?.id) {
68 | return {
69 | error: 'Unauthorized'
70 | }
71 | }
72 |
73 | await deleteChatsByUserId(session.user.id)
74 |
75 | revalidatePath('/')
76 | return redirect('/')
77 | }
78 |
79 | export async function getSharedChat(id: string) {
80 | const chat = await getChatById(id)
81 |
82 | if (!chat || !chat.sharePath) {
83 | return null
84 | }
85 |
86 | return chat
87 | }
88 |
89 | export async function shareChat(id: string) {
90 | const session = await auth()
91 |
92 | if (!session?.user?.id) {
93 | return {
94 | error: 'Unauthorized'
95 | }
96 | }
97 |
98 | const chat = await getChatById(id)
99 |
100 | if (!chat || chat.userId !== session.user.id) {
101 | return {
102 | error: 'Something went wrong'
103 | }
104 | }
105 |
106 | const payload = {
107 | ...chat,
108 | sharePath: `/share/${chat.id}`
109 | }
110 |
111 | await updateChat(id, payload)
112 |
113 | return payload
114 | }
115 |
116 | export async function saveChat(chat: Chat) {
117 | const session = await auth()
118 |
119 | if (session && session.user) {
120 | await insertChat(chat)
121 | } else {
122 | return
123 | }
124 | }
125 |
126 | export async function refreshHistory(path: string) {
127 | redirect(path)
128 | }
129 |
130 | export async function getMissingKeys() {
131 | const keysRequired = ['OPENAI_API_KEY']
132 | return keysRequired
133 | .map(key => (process.env[key] ? '' : key))
134 | .filter(key => key !== '')
135 | }
136 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | --card: 0 0% 100%;
11 | --card-foreground: 240 10% 3.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 240 10% 3.9%;
15 |
16 | --primary: 240 5.9% 10%;
17 | --primary-foreground: 0 0% 98%;
18 |
19 | --secondary: 240 4.8% 95.9%;
20 | --secondary-foreground: 240 5.9% 10%;
21 |
22 | --muted: 240 4.8% 95.9%;
23 | --muted-foreground: 240 3.8% 46.1%;
24 |
25 | --accent: 240 4.8% 95.9%;
26 | --accent-foreground: 240 5.9% 10%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 0 0% 98%;
30 |
31 | --border: 240 5.9% 90%;
32 | --input: 240 5.9% 90%;
33 | --ring: 240 10% 3.9%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 240 10% 3.9%;
40 | --foreground: 0 0% 98%;
41 |
42 | --card: 240 10% 3.9%;
43 | --card-foreground: 0 0% 98%;
44 |
45 | --popover: 240 10% 3.9%;
46 | --popover-foreground: 0 0% 98%;
47 |
48 | --primary: 0 0% 98%;
49 | --primary-foreground: 240 5.9% 10%;
50 |
51 | --secondary: 240 3.7% 15.9%;
52 | --secondary-foreground: 0 0% 98%;
53 |
54 | --muted: 240 3.7% 15.9%;
55 | --muted-foreground: 240 5% 64.9%;
56 |
57 | --accent: 240 3.7% 15.9%;
58 | --accent-foreground: 0 0% 98%;
59 |
60 | --destructive: 0 62.8% 30.6%;
61 | --destructive-foreground: 0 0% 98%;
62 |
63 | --border: 240 3.7% 15.9%;
64 | --input: 240 3.7% 15.9%;
65 | --ring: 240 4.9% 83.9%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 | body {
74 | @apply bg-background text-foreground;
75 | }
76 | }
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { GeistSans } from 'geist/font/sans'
2 | import { GeistMono } from 'geist/font/mono'
3 |
4 | import '@/app/globals.css'
5 | import { cn } from '@/lib/utils'
6 | import { TailwindIndicator } from '@/components/tailwind-indicator'
7 | import { Providers } from '@/components/providers'
8 | import { Header } from '@/components/header'
9 | import { Toaster } from '@/components/ui/sonner'
10 |
11 | export const metadata = {
12 | metadataBase: process.env.VERCEL_URL
13 | ? new URL(`https://${process.env.VERCEL_URL}`)
14 | : undefined,
15 | title: {
16 | default: 'Next.js AI Chatbot',
17 | template: `%s - Next.js AI Chatbot`
18 | },
19 | description: 'An AI-powered chatbot template built with Next.js and Vercel.',
20 | icons: {
21 | icon: '/favicon.ico',
22 | shortcut: '/favicon-16x16.png',
23 | apple: '/apple-touch-icon.png'
24 | }
25 | }
26 |
27 | export const viewport = {
28 | themeColor: [
29 | { media: '(prefers-color-scheme: light)', color: 'white' },
30 | { media: '(prefers-color-scheme: dark)', color: 'black' }
31 | ]
32 | }
33 |
34 | interface RootLayoutProps {
35 | children: React.ReactNode
36 | }
37 |
38 | export default function RootLayout({ children }: RootLayoutProps) {
39 | return (
40 |
41 |
48 |
49 |
55 |
56 |
57 | {children}
58 |
59 |
60 |
61 |
62 |
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/app/login/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { signIn } from '@/auth'
4 | import { AuthError } from 'next-auth'
5 | import { z } from 'zod'
6 | import { ResultCode } from '@/lib/utils'
7 | import { getUserByEmail } from '@/lib/storage'
8 |
9 | export async function getUser(email: string) {
10 | const user = await getUserByEmail(email)
11 |
12 | console.log('user', user)
13 |
14 | return user
15 | }
16 |
17 | interface Result {
18 | type: string
19 | resultCode: ResultCode
20 | }
21 |
22 | export async function authenticate(
23 | _prevState: Result | undefined,
24 | formData: FormData
25 | ): Promise {
26 | try {
27 | const email = formData.get('email')
28 | const password = formData.get('password')
29 |
30 | const parsedCredentials = z
31 | .object({
32 | email: z.string().email(),
33 | password: z.string().min(6)
34 | })
35 | .safeParse({
36 | email,
37 | password
38 | })
39 |
40 | if (parsedCredentials.success) {
41 | await signIn('credentials', {
42 | email,
43 | password,
44 | redirect: false
45 | })
46 |
47 | return {
48 | type: 'success',
49 | resultCode: ResultCode.UserLoggedIn
50 | }
51 | } else {
52 | return {
53 | type: 'error',
54 | resultCode: ResultCode.InvalidCredentials
55 | }
56 | }
57 | } catch (error) {
58 | if (error instanceof AuthError) {
59 | switch (error.type) {
60 | case 'CredentialsSignin':
61 | return {
62 | type: 'error',
63 | resultCode: ResultCode.InvalidCredentials
64 | }
65 | default:
66 | return {
67 | type: 'error',
68 | resultCode: ResultCode.UnknownError
69 | }
70 | }
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/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/new/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation'
2 |
3 | export default async function NewPage() {
4 | redirect('/')
5 | }
6 |
--------------------------------------------------------------------------------
/app/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ashleyrudland/nextjs-ai-chatbot-on-vps/f016d75b69b7b201ad9ff503782da22fa1ea9da4/app/opengraph-image.png
--------------------------------------------------------------------------------
/app/share/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import { type Metadata } from 'next'
2 | import { notFound, redirect } from 'next/navigation'
3 |
4 | import { formatDate } from '@/lib/utils'
5 | import { getSharedChat } from '@/app/actions'
6 | import { ChatList } from '@/components/chat-list'
7 | import { FooterText } from '@/components/footer'
8 | import { AI, UIState, getUIStateFromAIState } from '@/lib/chat/actions'
9 |
10 | interface SharePageProps {
11 | params: {
12 | id: string
13 | }
14 | }
15 |
16 | export async function generateMetadata({
17 | params
18 | }: SharePageProps): Promise {
19 | const chat = await getSharedChat(params.id)
20 |
21 | return {
22 | title: chat?.title.slice(0, 50) ?? 'Chat'
23 | }
24 | }
25 |
26 | export default async function SharePage({ params }: SharePageProps) {
27 | const chat = await getSharedChat(params.id)
28 |
29 | if (!chat || !chat?.sharePath) {
30 | notFound()
31 | }
32 |
33 | const uiState: UIState = getUIStateFromAIState(chat)
34 |
35 | return (
36 | <>
37 |
38 |
39 |
40 |
41 |
{chat.title}
42 |
43 | {formatDate(chat.createdAt)} · {chat.messages.length} messages
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | >
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/app/signup/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { signIn } from '@/auth'
4 | import { ResultCode, getStringFromBuffer } from '@/lib/utils'
5 | import { z } from 'zod'
6 | import { getUser } from '../login/actions'
7 | import { AuthError } from 'next-auth'
8 | import { insertUser } from '@/lib/storage'
9 |
10 | export async function createUser(
11 | email: string,
12 | hashedPassword: string,
13 | salt: string
14 | ) {
15 | const existingUser = await getUser(email)
16 |
17 | if (existingUser) {
18 | return {
19 | type: 'error',
20 | resultCode: ResultCode.UserAlreadyExists
21 | }
22 | } else {
23 | const user = {
24 | id: crypto.randomUUID(),
25 | email,
26 | password: hashedPassword,
27 | salt
28 | }
29 |
30 | insertUser(user)
31 |
32 | return {
33 | type: 'success',
34 | resultCode: ResultCode.UserCreated
35 | }
36 | }
37 | }
38 |
39 | interface Result {
40 | type: string
41 | resultCode: ResultCode
42 | }
43 |
44 | export async function signup(
45 | _prevState: Result | undefined,
46 | formData: FormData
47 | ): Promise {
48 | const email = formData.get('email') as string
49 | const password = formData.get('password') as string
50 |
51 | const parsedCredentials = z
52 | .object({
53 | email: z.string().email(),
54 | password: z.string().min(6)
55 | })
56 | .safeParse({
57 | email,
58 | password
59 | })
60 |
61 | if (parsedCredentials.success) {
62 | const salt = crypto.randomUUID()
63 |
64 | const encoder = new TextEncoder()
65 | const saltedPassword = encoder.encode(password + salt)
66 | const hashedPasswordBuffer = await crypto.subtle.digest(
67 | 'SHA-256',
68 | saltedPassword
69 | )
70 | const hashedPassword = getStringFromBuffer(hashedPasswordBuffer)
71 |
72 | try {
73 | const result = await createUser(email, hashedPassword, salt)
74 |
75 | if (result.resultCode === ResultCode.UserCreated) {
76 | await signIn('credentials', {
77 | email,
78 | password,
79 | redirect: false
80 | })
81 | }
82 |
83 | return result
84 | } catch (error) {
85 | if (error instanceof AuthError) {
86 | switch (error.type) {
87 | case 'CredentialsSignin':
88 | return {
89 | type: 'error',
90 | resultCode: ResultCode.InvalidCredentials
91 | }
92 | default:
93 | return {
94 | type: 'error',
95 | resultCode: ResultCode.UnknownError
96 | }
97 | }
98 | } else {
99 | return {
100 | type: 'error',
101 | resultCode: ResultCode.UnknownError
102 | }
103 | }
104 | }
105 | } else {
106 | return {
107 | type: 'error',
108 | resultCode: ResultCode.InvalidCredentials
109 | }
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/app/twitter-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ashleyrudland/nextjs-ai-chatbot-on-vps/f016d75b69b7b201ad9ff503782da22fa1ea9da4/app/twitter-image.png
--------------------------------------------------------------------------------
/app/up/route.ts:
--------------------------------------------------------------------------------
1 | export async function GET() {
2 | return new Response('Ok', { status: 200 })
3 | }
4 |
--------------------------------------------------------------------------------
/auth.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextAuthConfig } from 'next-auth'
2 |
3 | export const authConfig = {
4 | trustHost: true,
5 | secret: process.env.AUTH_SECRET,
6 | pages: {
7 | signIn: '/login',
8 | newUser: '/signup'
9 | },
10 | callbacks: {
11 | async authorized({ auth, request: { nextUrl } }) {
12 | const isLoggedIn = !!auth?.user
13 | const isOnLoginPage = nextUrl.pathname.startsWith('/login')
14 | const isOnSignupPage = nextUrl.pathname.startsWith('/signup')
15 |
16 | if (isLoggedIn) {
17 | if (isOnLoginPage || isOnSignupPage) {
18 | return Response.redirect(new URL('/', nextUrl))
19 | }
20 | }
21 |
22 | return true
23 | },
24 | async jwt({ token, user }) {
25 | if (user) {
26 | token = { ...token, id: user.id }
27 | }
28 |
29 | return token
30 | },
31 | async session({ session, token }) {
32 | if (token) {
33 | const { id } = token as { id: string }
34 | const { user } = session
35 |
36 | session = { ...session, user: { ...user, id } }
37 | }
38 |
39 | return session
40 | }
41 | },
42 | providers: []
43 | } satisfies NextAuthConfig
44 |
--------------------------------------------------------------------------------
/auth.ts:
--------------------------------------------------------------------------------
1 | import NextAuth from 'next-auth'
2 | import Credentials from 'next-auth/providers/credentials'
3 | import { authConfig } from './auth.config'
4 | import { z } from 'zod'
5 | import { getStringFromBuffer } from './lib/utils'
6 | import { getUser } from './app/login/actions'
7 |
8 | export const { auth, signIn, signOut } = NextAuth({
9 | ...authConfig,
10 | providers: [
11 | Credentials({
12 | async authorize(credentials) {
13 | const parsedCredentials = z
14 | .object({
15 | email: z.string().email(),
16 | password: z.string().min(6)
17 | })
18 | .safeParse(credentials)
19 |
20 | if (parsedCredentials.success) {
21 | const { email, password } = parsedCredentials.data
22 | const user = await getUser(email)
23 |
24 | if (!user) return null
25 |
26 | const encoder = new TextEncoder()
27 | const saltedPassword = encoder.encode(password + user.salt)
28 | const hashedPasswordBuffer = await crypto.subtle.digest(
29 | 'SHA-256',
30 | saltedPassword
31 | )
32 | const hashedPassword = getStringFromBuffer(hashedPasswordBuffer)
33 |
34 | if (hashedPassword === user.password) {
35 | return user
36 | } else {
37 | return null
38 | }
39 | }
40 |
41 | return null
42 | }
43 | })
44 | ]
45 | })
46 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/components/button-scroll-to-bottom.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 |
5 | import { cn } from '@/lib/utils'
6 | import { Button, type ButtonProps } from '@/components/ui/button'
7 | import { IconArrowDown } from '@/components/ui/icons'
8 |
9 | interface ButtonScrollToBottomProps extends ButtonProps {
10 | isAtBottom: boolean
11 | scrollToBottom: () => void
12 | }
13 |
14 | export function ButtonScrollToBottom({
15 | className,
16 | isAtBottom,
17 | scrollToBottom,
18 | ...props
19 | }: ButtonScrollToBottomProps) {
20 | return (
21 | scrollToBottom()}
30 | {...props}
31 | >
32 |
33 | Scroll to bottom
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/components/chat-history.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import Link from 'next/link'
4 |
5 | import { cn } from '@/lib/utils'
6 | import { SidebarList } from '@/components/sidebar-list'
7 | import { buttonVariants } from '@/components/ui/button'
8 | import { IconPlus } from '@/components/ui/icons'
9 |
10 | interface ChatHistoryProps {
11 | userId?: string
12 | }
13 |
14 | export async function ChatHistory({ userId }: ChatHistoryProps) {
15 | return (
16 |
17 |
18 |
Chat History
19 |
20 |
21 |
28 |
29 | New Chat
30 |
31 |
32 |
35 | {Array.from({ length: 10 }).map((_, i) => (
36 |
40 | ))}
41 |
42 | }
43 | >
44 | {/* @ts-ignore */}
45 |
46 |
47 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/components/chat-list.tsx:
--------------------------------------------------------------------------------
1 | import { Separator } from '@/components/ui/separator'
2 | import { UIState } from '@/lib/chat/actions'
3 | import { Session } from '@/lib/types'
4 | import Link from 'next/link'
5 | import { ExclamationTriangleIcon } from '@radix-ui/react-icons'
6 |
7 | export interface ChatList {
8 | messages: UIState
9 | session?: Session
10 | isShared: boolean
11 | }
12 |
13 | export function ChatList({ messages, session, isShared }: ChatList) {
14 | if (!messages.length) {
15 | return null
16 | }
17 |
18 | return (
19 |
20 | {!isShared && !session ? (
21 | <>
22 |
23 |
24 |
25 |
26 |
27 |
28 | Please{' '}
29 |
30 | log in
31 | {' '}
32 | or{' '}
33 |
34 | sign up
35 | {' '}
36 | to save and revisit your chat history!
37 |
38 |
39 |
40 |
41 | >
42 | ) : null}
43 |
44 | {messages.map((message, index) => (
45 |
46 | {message.display}
47 | {index < messages.length - 1 && }
48 |
49 | ))}
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/components/chat-message-actions.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { type Message } from 'ai'
4 |
5 | import { Button } from '@/components/ui/button'
6 | import { IconCheck, IconCopy } from '@/components/ui/icons'
7 | import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard'
8 | import { cn } from '@/lib/utils'
9 |
10 | interface ChatMessageActionsProps extends React.ComponentProps<'div'> {
11 | message: Message
12 | }
13 |
14 | export function ChatMessageActions({
15 | message,
16 | className,
17 | ...props
18 | }: ChatMessageActionsProps) {
19 | const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 })
20 |
21 | const onCopy = () => {
22 | if (isCopied) return
23 | copyToClipboard(message.content)
24 | }
25 |
26 | return (
27 |
34 |
35 | {isCopied ? : }
36 | Copy message
37 |
38 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/components/chat-message.tsx:
--------------------------------------------------------------------------------
1 | // Inspired by Chatbot-UI and modified to fit the needs of this project
2 | // @see https://github.com/mckaywrigley/chatbot-ui/blob/main/components/Chat/ChatMessage.tsx
3 |
4 | import { Message } from 'ai'
5 | import remarkGfm from 'remark-gfm'
6 | import remarkMath from 'remark-math'
7 |
8 | import { cn } from '@/lib/utils'
9 | import { CodeBlock } from '@/components/ui/codeblock'
10 | import { MemoizedReactMarkdown } from '@/components/markdown'
11 | import { IconOpenAI, IconUser } from '@/components/ui/icons'
12 | import { ChatMessageActions } from '@/components/chat-message-actions'
13 |
14 | export interface ChatMessageProps {
15 | message: Message
16 | }
17 |
18 | export function ChatMessage({ message, ...props }: ChatMessageProps) {
19 | return (
20 |
24 |
32 | {message.role === 'user' ? : }
33 |
34 |
35 | {children}
41 | },
42 | code({ node, inline, className, children, ...props }) {
43 | if (children.length) {
44 | if (children[0] == '▍') {
45 | return (
46 | ▍
47 | )
48 | }
49 |
50 | children[0] = (children[0] as string).replace('`▍`', '▍')
51 | }
52 |
53 | const match = /language-(\w+)/.exec(className || '')
54 |
55 | if (inline) {
56 | return (
57 |
58 | {children}
59 |
60 | )
61 | }
62 |
63 | return (
64 |
70 | )
71 | }
72 | }}
73 | >
74 | {message.content}
75 |
76 |
77 |
78 |
79 | )
80 | }
81 |
--------------------------------------------------------------------------------
/components/chat-panel.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { shareChat } from '@/app/actions'
4 | import { Button } from '@/components/ui/button'
5 | import { PromptForm } from '@/components/prompt-form'
6 | import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom'
7 | import { IconShare } from '@/components/ui/icons'
8 | import { FooterText } from '@/components/footer'
9 | import { ChatShareDialog } from '@/components/chat-share-dialog'
10 | import { useAIState, useActions, useUIState } from 'ai/rsc'
11 | import type { AI } from '@/lib/chat/actions'
12 | import { nanoid } from 'nanoid'
13 | import { UserMessage } from './stocks/message'
14 |
15 | export interface ChatPanelProps {
16 | id?: string
17 | title?: string
18 | input: string
19 | setInput: (value: string) => void
20 | isAtBottom: boolean
21 | scrollToBottom: () => void
22 | }
23 |
24 | export function ChatPanel({
25 | id,
26 | title,
27 | input,
28 | setInput,
29 | isAtBottom,
30 | scrollToBottom
31 | }: ChatPanelProps) {
32 | const [aiState] = useAIState()
33 | const [messages, setMessages] = useUIState()
34 | const { submitUserMessage } = useActions()
35 | const [shareDialogOpen, setShareDialogOpen] = React.useState(false)
36 |
37 | const exampleMessages = [
38 | {
39 | heading: 'What are the',
40 | subheading: 'trending memecoins today?',
41 | message: `What are the trending memecoins today?`
42 | },
43 | {
44 | heading: 'What is the price of',
45 | subheading: '$DOGE right now?',
46 | message: 'What is the price of $DOGE right now?'
47 | },
48 | {
49 | heading: 'I would like to buy',
50 | subheading: '42 $DOGE',
51 | message: `I would like to buy 42 $DOGE`
52 | },
53 | {
54 | heading: 'What are some',
55 | subheading: `recent events about $DOGE?`,
56 | message: `What are some recent events about $DOGE?`
57 | }
58 | ]
59 |
60 | return (
61 |
62 |
66 |
67 |
68 |
69 | {messages.length === 0 &&
70 | exampleMessages.map((example, index) => (
71 |
1 && 'hidden md:block'
75 | }`}
76 | onClick={async () => {
77 | setMessages(currentMessages => [
78 | ...currentMessages,
79 | {
80 | id: nanoid(),
81 | display:
{example.message}
82 | }
83 | ])
84 |
85 | const responseMessage = await submitUserMessage(
86 | example.message
87 | )
88 |
89 | setMessages(currentMessages => [
90 | ...currentMessages,
91 | responseMessage
92 | ])
93 | }}
94 | >
95 |
{example.heading}
96 |
97 | {example.subheading}
98 |
99 |
100 | ))}
101 |
102 |
103 | {messages?.length >= 2 ? (
104 |
105 |
106 | {id && title ? (
107 | <>
108 | setShareDialogOpen(true)}
111 | >
112 |
113 | Share
114 |
115 | setShareDialogOpen(false)}
119 | shareChat={shareChat}
120 | chat={{
121 | id,
122 | title,
123 | messages: aiState.messages
124 | }}
125 | />
126 | >
127 | ) : null}
128 |
129 |
130 | ) : null}
131 |
132 |
136 |
137 |
138 | )
139 | }
140 |
--------------------------------------------------------------------------------
/components/chat-share-dialog.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import { type DialogProps } from '@radix-ui/react-dialog'
5 | import { toast } from 'sonner'
6 |
7 | import { ServerActionResult, type Chat } from '@/lib/types'
8 | import { Button } from '@/components/ui/button'
9 | import {
10 | Dialog,
11 | DialogContent,
12 | DialogDescription,
13 | DialogFooter,
14 | DialogHeader,
15 | DialogTitle
16 | } from '@/components/ui/dialog'
17 | import { IconSpinner } from '@/components/ui/icons'
18 | import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard'
19 |
20 | interface ChatShareDialogProps extends DialogProps {
21 | chat: Pick
22 | shareChat: (id: string) => ServerActionResult
23 | onCopy: () => void
24 | }
25 |
26 | export function ChatShareDialog({
27 | chat,
28 | shareChat,
29 | onCopy,
30 | ...props
31 | }: ChatShareDialogProps) {
32 | const { copyToClipboard } = useCopyToClipboard({ timeout: 1000 })
33 | const [isSharePending, startShareTransition] = React.useTransition()
34 |
35 | const copyShareLink = React.useCallback(
36 | async (chat: Chat) => {
37 | if (!chat.sharePath) {
38 | return toast.error('Could not copy share link to clipboard')
39 | }
40 |
41 | const url = new URL(window.location.href)
42 | url.pathname = chat.sharePath
43 | copyToClipboard(url.toString())
44 | onCopy()
45 | toast.success('Share link copied to clipboard')
46 | },
47 | [copyToClipboard, onCopy]
48 | )
49 |
50 | return (
51 |
52 |
53 |
54 | Share link to chat
55 |
56 | Anyone with the URL will be able to view the shared chat.
57 |
58 |
59 |
60 |
{chat.title}
61 |
62 | {chat.messages.length} messages
63 |
64 |
65 |
66 | {
69 | // @ts-ignore
70 | startShareTransition(async () => {
71 | const result = await shareChat(chat.id)
72 |
73 | if (result && 'error' in result) {
74 | toast.error(result.error)
75 | return
76 | }
77 |
78 | copyShareLink(result)
79 | })
80 | }}
81 | >
82 | {isSharePending ? (
83 | <>
84 |
85 | Copying...
86 | >
87 | ) : (
88 | <>Copy link>
89 | )}
90 |
91 |
92 |
93 |
94 | )
95 | }
96 |
--------------------------------------------------------------------------------
/components/chat.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { cn } from '@/lib/utils'
4 | import { ChatList } from '@/components/chat-list'
5 | import { ChatPanel } from '@/components/chat-panel'
6 | import { EmptyScreen } from '@/components/empty-screen'
7 | import { useLocalStorage } from '@/lib/hooks/use-local-storage'
8 | import { useEffect, useState } from 'react'
9 | import { useUIState, useAIState } from 'ai/rsc'
10 | import { Session } from '@/lib/types'
11 | import { usePathname, useRouter } from 'next/navigation'
12 | import { Message } from '@/lib/chat/actions'
13 | import { useScrollAnchor } from '@/lib/hooks/use-scroll-anchor'
14 | import { toast } from 'sonner'
15 |
16 | export interface ChatProps extends React.ComponentProps<'div'> {
17 | initialMessages?: Message[]
18 | id?: string
19 | session?: Session
20 | missingKeys: string[]
21 | }
22 |
23 | export function Chat({ id, className, session, missingKeys }: ChatProps) {
24 | const router = useRouter()
25 | const path = usePathname()
26 | const [input, setInput] = useState('')
27 | const [messages] = useUIState()
28 | const [aiState] = useAIState()
29 |
30 | const [_, setNewChatId] = useLocalStorage('newChatId', id)
31 |
32 | useEffect(() => {
33 | if (session?.user) {
34 | if (!path.includes('chat') && messages.length === 1) {
35 | window.history.replaceState({}, '', `/chat/${id}`)
36 | }
37 | }
38 | }, [id, path, session?.user, messages])
39 |
40 | useEffect(() => {
41 | const messagesLength = aiState.messages?.length
42 | if (messagesLength === 2) {
43 | router.refresh()
44 | }
45 | }, [aiState.messages, router])
46 |
47 | useEffect(() => {
48 | setNewChatId(id)
49 | })
50 |
51 | useEffect(() => {
52 | missingKeys.map(key => {
53 | toast.error(`Missing ${key} environment variable!`)
54 | })
55 | }, [missingKeys])
56 |
57 | const { messagesRef, scrollRef, visibilityRef, isAtBottom, scrollToBottom } =
58 | useScrollAnchor()
59 |
60 | return (
61 |
65 |
69 | {messages.length ? (
70 |
71 | ) : (
72 |
73 | )}
74 |
75 |
76 |
83 |
84 | )
85 | }
86 |
--------------------------------------------------------------------------------
/components/clear-history.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import { useRouter } from 'next/navigation'
5 | import { toast } from 'sonner'
6 |
7 | import { ServerActionResult } from '@/lib/types'
8 | import { Button } from '@/components/ui/button'
9 | import {
10 | AlertDialog,
11 | AlertDialogAction,
12 | AlertDialogCancel,
13 | AlertDialogContent,
14 | AlertDialogDescription,
15 | AlertDialogFooter,
16 | AlertDialogHeader,
17 | AlertDialogTitle,
18 | AlertDialogTrigger
19 | } from '@/components/ui/alert-dialog'
20 | import { IconSpinner } from '@/components/ui/icons'
21 |
22 | interface ClearHistoryProps {
23 | isEnabled: boolean
24 | clearChats: () => ServerActionResult
25 | }
26 |
27 | export function ClearHistory({
28 | isEnabled = false,
29 | clearChats
30 | }: ClearHistoryProps) {
31 | const [open, setOpen] = React.useState(false)
32 | const [isPending, startTransition] = React.useTransition()
33 | const router = useRouter()
34 |
35 | return (
36 |
37 |
38 |
39 | {isPending && }
40 | Clear history
41 |
42 |
43 |
44 |
45 | Are you absolutely sure?
46 |
47 | This will permanently delete your chat history and remove your data
48 | from our servers.
49 |
50 |
51 |
52 | Cancel
53 | {
56 | event.preventDefault()
57 | startTransition(async () => {
58 | const result = await clearChats()
59 | if (result && 'error' in result) {
60 | toast.error(result.error)
61 | return
62 | }
63 |
64 | setOpen(false)
65 | })
66 | }}
67 | >
68 | {isPending && }
69 | Delete
70 |
71 |
72 |
73 |
74 | )
75 | }
76 |
--------------------------------------------------------------------------------
/components/empty-screen.tsx:
--------------------------------------------------------------------------------
1 | import { UseChatHelpers } from 'ai/react'
2 |
3 | import { Button } from '@/components/ui/button'
4 | import { ExternalLink } from '@/components/external-link'
5 | import { IconArrowRight } from '@/components/ui/icons'
6 |
7 | const exampleMessages = [
8 | {
9 | heading: 'Explain technical concepts',
10 | message: `What is a "serverless function"?`
11 | },
12 | {
13 | heading: 'Summarize an article',
14 | message: 'Summarize the following article for a 2nd grader: \n'
15 | },
16 | {
17 | heading: 'Draft an email',
18 | message: `Draft an email to my boss about the following: \n`
19 | }
20 | ]
21 |
22 | export function EmptyScreen() {
23 | return (
24 |
25 |
26 |
27 | Welcome to Next.js AI Chatbot!
28 |
29 |
30 | This is an open source AI chatbot app template built with{' '}
31 | Next.js , the{' '}
32 |
33 | Vercel AI SDK
34 |
35 | , and{' '}
36 |
37 | Vercel KV
38 |
39 | .
40 |
41 |
42 | It uses{' '}
43 |
44 | React Server Components
45 | {' '}
46 | to combine text with generative UI as output of the LLM. The UI state
47 | is synced through the SDK so the model is aware of your interactions
48 | as they happen.
49 |
50 |
51 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/components/external-link.tsx:
--------------------------------------------------------------------------------
1 | export function ExternalLink({
2 | href,
3 | children
4 | }: {
5 | href: string
6 | children: React.ReactNode
7 | }) {
8 | return (
9 |
14 | {children}
15 |
22 |
26 |
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/components/footer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { cn } from '@/lib/utils'
4 | import { ExternalLink } from '@/components/external-link'
5 |
6 | export function FooterText({ className, ...props }: React.ComponentProps<'p'>) {
7 | return (
8 |
15 | Open source AI chatbot built with{' '}
16 | Next.js and{' '}
17 |
18 | Vercel AI SDK
19 |
20 | .
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/components/header.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import Link from 'next/link'
3 |
4 | import { cn } from '@/lib/utils'
5 | import { auth } from '@/auth'
6 | import { Button, buttonVariants } from '@/components/ui/button'
7 | import {
8 | IconGitHub,
9 | IconNextChat,
10 | IconSeparator,
11 | IconVercel
12 | } from '@/components/ui/icons'
13 | import { UserMenu } from '@/components/user-menu'
14 | import { SidebarMobile } from './sidebar-mobile'
15 | import { SidebarToggle } from './sidebar-toggle'
16 | import { ChatHistory } from './chat-history'
17 | import { Session } from '@/lib/types'
18 |
19 | async function UserOrLogin() {
20 | const session = (await auth()) as Session
21 | return (
22 | <>
23 | {session?.user ? (
24 | <>
25 |
26 |
27 |
28 |
29 | >
30 | ) : (
31 |
32 |
33 |
34 |
35 | )}
36 |
37 |
38 | {session?.user ? (
39 |
40 | ) : (
41 |
42 | Login
43 |
44 | )}
45 |
46 | >
47 | )
48 | }
49 |
50 | export function Header() {
51 | return (
52 |
53 |
54 | }>
55 |
56 |
57 |
58 |
78 |
79 | )
80 | }
81 |
--------------------------------------------------------------------------------
/components/login-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import { signIn } from 'next-auth/react'
5 |
6 | import { cn } from '@/lib/utils'
7 | import { Button, type ButtonProps } from '@/components/ui/button'
8 | import { IconGitHub, IconSpinner } from '@/components/ui/icons'
9 |
10 | interface LoginButtonProps extends ButtonProps {
11 | showGithubIcon?: boolean
12 | text?: string
13 | }
14 |
15 | export function LoginButton({
16 | text = 'Login with GitHub',
17 | showGithubIcon = true,
18 | className,
19 | ...props
20 | }: LoginButtonProps) {
21 | const [isLoading, setIsLoading] = React.useState(false)
22 | return (
23 | {
26 | setIsLoading(true)
27 | // next-auth signIn() function doesn't work yet at Edge Runtime due to usage of BroadcastChannel
28 | signIn('github', { callbackUrl: `/` })
29 | }}
30 | disabled={isLoading}
31 | className={cn(className)}
32 | {...props}
33 | >
34 | {isLoading ? (
35 |
36 | ) : showGithubIcon ? (
37 |
38 | ) : null}
39 | {text}
40 |
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/components/login-form.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useFormState, useFormStatus } from 'react-dom'
4 | import { authenticate } from '@/app/login/actions'
5 | import Link from 'next/link'
6 | import { useEffect } from 'react'
7 | import { toast } from 'sonner'
8 | import { IconSpinner } from './ui/icons'
9 | import { getMessageFromCode } from '@/lib/utils'
10 | import { useRouter } from 'next/navigation'
11 |
12 | export default function LoginForm() {
13 | const router = useRouter()
14 | const [result, dispatch] = useFormState(authenticate, undefined)
15 |
16 | useEffect(() => {
17 | if (result) {
18 | if (result.type === 'error') {
19 | toast.error(getMessageFromCode(result.resultCode))
20 | } else {
21 | toast.success(getMessageFromCode(result.resultCode))
22 | router.refresh()
23 | }
24 | }
25 | }, [result, router])
26 |
27 | return (
28 |
83 | )
84 | }
85 |
86 | function LoginButton() {
87 | const { pending } = useFormStatus()
88 |
89 | return (
90 |
94 | {pending ? : 'Log in'}
95 |
96 | )
97 | }
98 |
--------------------------------------------------------------------------------
/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/prompt-form.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import Textarea from 'react-textarea-autosize'
5 |
6 | import { useActions, useUIState } from 'ai/rsc'
7 |
8 | import { UserMessage } from './stocks/message'
9 | import { type AI } from '@/lib/chat/actions'
10 | import { Button } from '@/components/ui/button'
11 | import { IconArrowElbow, IconPlus } from '@/components/ui/icons'
12 | import {
13 | Tooltip,
14 | TooltipContent,
15 | TooltipTrigger
16 | } from '@/components/ui/tooltip'
17 | import { useEnterSubmit } from '@/lib/hooks/use-enter-submit'
18 | import { nanoid } from 'nanoid'
19 | import { useRouter } from 'next/navigation'
20 |
21 | export function PromptForm({
22 | input,
23 | setInput
24 | }: {
25 | input: string
26 | setInput: (value: string) => void
27 | }) {
28 | const router = useRouter()
29 | const { formRef, onKeyDown } = useEnterSubmit()
30 | const inputRef = React.useRef(null)
31 | const { submitUserMessage } = useActions()
32 | const [_, setMessages] = useUIState()
33 |
34 | React.useEffect(() => {
35 | if (inputRef.current) {
36 | inputRef.current.focus()
37 | }
38 | }, [])
39 |
40 | return (
41 |
114 | )
115 | }
116 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/components/sidebar-actions.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useRouter } from 'next/navigation'
4 | import * as React from 'react'
5 | import { toast } from 'sonner'
6 |
7 | import { ServerActionResult, type Chat } from '@/lib/types'
8 | import {
9 | AlertDialog,
10 | AlertDialogAction,
11 | AlertDialogCancel,
12 | AlertDialogContent,
13 | AlertDialogDescription,
14 | AlertDialogFooter,
15 | AlertDialogHeader,
16 | AlertDialogTitle
17 | } from '@/components/ui/alert-dialog'
18 | import { Button } from '@/components/ui/button'
19 | import { IconShare, IconSpinner, IconTrash } from '@/components/ui/icons'
20 | import { ChatShareDialog } from '@/components/chat-share-dialog'
21 | import {
22 | Tooltip,
23 | TooltipContent,
24 | TooltipTrigger
25 | } from '@/components/ui/tooltip'
26 |
27 | interface SidebarActionsProps {
28 | chat: Chat
29 | removeChat: (args: { id: string; path: string }) => ServerActionResult
30 | shareChat: (id: string) => ServerActionResult
31 | }
32 |
33 | export function SidebarActions({
34 | chat,
35 | removeChat,
36 | shareChat
37 | }: SidebarActionsProps) {
38 | const router = useRouter()
39 | const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
40 | const [shareDialogOpen, setShareDialogOpen] = React.useState(false)
41 | const [isRemovePending, startRemoveTransition] = React.useTransition()
42 |
43 | return (
44 | <>
45 |
46 |
47 |
48 | setShareDialogOpen(true)}
52 | >
53 |
54 | Share
55 |
56 |
57 | Share chat
58 |
59 |
60 |
61 | setDeleteDialogOpen(true)}
66 | >
67 |
68 | Delete
69 |
70 |
71 | Delete chat
72 |
73 |
74 | setShareDialogOpen(false)}
80 | />
81 |
82 |
83 |
84 | Are you absolutely sure?
85 |
86 | This will permanently delete your chat message and remove your
87 | data from our servers.
88 |
89 |
90 |
91 |
92 | Cancel
93 |
94 | {
97 | event.preventDefault()
98 | // @ts-ignore
99 | startRemoveTransition(async () => {
100 | const result = await removeChat({
101 | id: chat.id,
102 | path: chat.path
103 | })
104 |
105 | if (result && 'error' in result) {
106 | toast.error(result.error)
107 | return
108 | }
109 |
110 | setDeleteDialogOpen(false)
111 | router.refresh()
112 | router.push('/')
113 | toast.success('Chat deleted')
114 | })
115 | }}
116 | >
117 | {isRemovePending && }
118 | Delete
119 |
120 |
121 |
122 |
123 | >
124 | )
125 | }
126 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/components/sidebar-item.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 |
5 | import Link from 'next/link'
6 | import { usePathname } from 'next/navigation'
7 |
8 | import { motion } from 'framer-motion'
9 |
10 | import { buttonVariants } from '@/components/ui/button'
11 | import { IconMessage, IconUsers } from '@/components/ui/icons'
12 | import {
13 | Tooltip,
14 | TooltipContent,
15 | TooltipTrigger
16 | } from '@/components/ui/tooltip'
17 | import { useLocalStorage } from '@/lib/hooks/use-local-storage'
18 | import { type Chat } from '@/lib/types'
19 | import { cn } from '@/lib/utils'
20 |
21 | interface SidebarItemProps {
22 | index: number
23 | chat: Chat
24 | children: React.ReactNode
25 | }
26 |
27 | export function SidebarItem({ index, chat, children }: SidebarItemProps) {
28 | const pathname = usePathname()
29 |
30 | const isActive = pathname === chat.path
31 | const [newChatId, setNewChatId] = useLocalStorage('newChatId', null)
32 | const shouldAnimate = index === 0 && isActive && newChatId
33 |
34 | if (!chat?.id) return null
35 |
36 | return (
37 |
56 |
57 | {chat.sharePath ? (
58 |
59 |
63 |
64 |
65 | This is a shared chat.
66 |
67 | ) : (
68 |
69 | )}
70 |
71 |
79 |
83 |
84 | {shouldAnimate ? (
85 | chat.title.split('').map((character, index) => (
86 | {
107 | if (index === chat.title.length - 1) {
108 | setNewChatId(null)
109 | }
110 | }}
111 | >
112 | {character}
113 |
114 | ))
115 | ) : (
116 | {chat.title}
117 | )}
118 |
119 |
120 |
121 | {isActive && {children}
}
122 |
123 | )
124 | }
125 |
--------------------------------------------------------------------------------
/components/sidebar-items.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Chat } from '@/lib/types'
4 | import { AnimatePresence, motion } from 'framer-motion'
5 |
6 | import { removeChat, shareChat } from '@/app/actions'
7 |
8 | import { SidebarActions } from '@/components/sidebar-actions'
9 | import { SidebarItem } from '@/components/sidebar-item'
10 |
11 | interface SidebarItemsProps {
12 | chats?: Chat[]
13 | }
14 |
15 | export function SidebarItems({ chats }: SidebarItemsProps) {
16 | if (!chats?.length) return null
17 |
18 | return (
19 |
20 | {chats.map(
21 | (chat, index) =>
22 | chat && (
23 |
30 |
31 |
36 |
37 |
38 | )
39 | )}
40 |
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/components/sidebar-list.tsx:
--------------------------------------------------------------------------------
1 | import { clearChats, getChats } from '@/app/actions'
2 | import { ClearHistory } from '@/components/clear-history'
3 | import { SidebarItems } from '@/components/sidebar-items'
4 | import { ThemeToggle } from '@/components/theme-toggle'
5 | import { cache } from 'react'
6 |
7 | interface SidebarListProps {
8 | userId?: string
9 | children?: React.ReactNode
10 | }
11 |
12 | const loadChats = cache(async (userId?: string) => {
13 | return await getChats(userId)
14 | })
15 |
16 | export async function SidebarList({ userId }: SidebarListProps) {
17 | const chats = await loadChats(userId)
18 |
19 | return (
20 |
21 |
22 | {chats?.length ? (
23 |
24 |
25 |
26 | ) : (
27 |
28 |
No chat history
29 |
30 | )}
31 |
32 |
33 |
34 | 0} />
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/components/sidebar-mobile.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
4 |
5 | import { Sidebar } from '@/components/sidebar'
6 | import { Button } from '@/components/ui/button'
7 |
8 | import { IconSidebar } from '@/components/ui/icons'
9 |
10 | interface SidebarMobileProps {
11 | children: React.ReactNode
12 | }
13 |
14 | export function SidebarMobile({ children }: SidebarMobileProps) {
15 | return (
16 |
17 |
18 |
19 |
20 | Toggle Sidebar
21 |
22 |
23 |
27 | {children}
28 |
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/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 | {
17 | toggleSidebar()
18 | }}
19 | >
20 |
21 | Toggle Sidebar
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/components/signup-form.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useFormState, useFormStatus } from 'react-dom'
4 | import { signup } from '@/app/signup/actions'
5 | import Link from 'next/link'
6 | import { useEffect } from 'react'
7 | import { toast } from 'sonner'
8 | import { IconSpinner } from './ui/icons'
9 | import { getMessageFromCode } from '@/lib/utils'
10 | import { useRouter } from 'next/navigation'
11 |
12 | export default function SignupForm() {
13 | const router = useRouter()
14 | const [result, dispatch] = useFormState(signup, undefined)
15 |
16 | useEffect(() => {
17 | if (result) {
18 | if (result.type === 'error') {
19 | toast.error(getMessageFromCode(result.resultCode))
20 | } else {
21 | toast.success(getMessageFromCode(result.resultCode))
22 | router.refresh()
23 | }
24 | }
25 | }, [result, router])
26 |
27 | return (
28 |
32 |
33 |
Sign up for an account!
34 |
35 |
36 |
40 | Email
41 |
42 |
43 |
51 |
52 |
53 |
54 |
58 | Password
59 |
60 |
61 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | Already have an account?
78 | Log in
79 |
80 |
81 | )
82 | }
83 |
84 | function LoginButton() {
85 | const { pending } = useFormStatus()
86 |
87 | return (
88 |
92 | {pending ? : 'Create account'}
93 |
94 | )
95 | }
96 |
--------------------------------------------------------------------------------
/components/stocks/events-skeleton.tsx:
--------------------------------------------------------------------------------
1 | const placeholderEvents = [
2 | {
3 | date: '2022-10-01',
4 | headline: 'NVIDIA releases new AI-powered graphics card',
5 | description:
6 | 'NVIDIA unveils the latest graphics card infused with AI capabilities, revolutionizing gaming and rendering experiences.'
7 | }
8 | ]
9 |
10 | export const EventsSkeleton = () => {
11 | return (
12 |
13 | {placeholderEvents.map(event => (
14 |
18 |
19 | {event.date}
20 |
21 |
22 | {event.headline}
23 |
24 |
25 | {event.description.slice(0, 70)}...
26 |
27 |
28 | ))}
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/components/stocks/events.tsx:
--------------------------------------------------------------------------------
1 | import { format, parseISO } from 'date-fns'
2 |
3 | interface Event {
4 | date: string
5 | headline: string
6 | description: string
7 | }
8 |
9 | export function Events({ props: events }: { props: Event[] }) {
10 | return (
11 |
12 | {events.map(event => (
13 |
17 |
18 | {format(parseISO(event.date), 'dd LLL, yyyy')}
19 |
20 |
21 | {event.headline}
22 |
23 |
24 | {event.description.slice(0, 70)}...
25 |
26 |
27 | ))}
28 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/components/stocks/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import dynamic from 'next/dynamic'
4 | import { StockSkeleton } from './stock-skeleton'
5 | import { StocksSkeleton } from './stocks-skeleton'
6 | import { EventsSkeleton } from './events-skeleton'
7 |
8 | export { spinner } from './spinner'
9 | export { BotCard, BotMessage, SystemMessage } from './message'
10 |
11 | const Stock = dynamic(() => import('./stock').then(mod => mod.Stock), {
12 | ssr: false,
13 | loading: () =>
14 | })
15 |
16 | const Purchase = dynamic(
17 | () => import('./stock-purchase').then(mod => mod.Purchase),
18 | {
19 | ssr: false,
20 | loading: () => (
21 |
22 | )
23 | }
24 | )
25 |
26 | const Stocks = dynamic(() => import('./stocks').then(mod => mod.Stocks), {
27 | ssr: false,
28 | loading: () =>
29 | })
30 |
31 | const Events = dynamic(() => import('./events').then(mod => mod.Events), {
32 | ssr: false,
33 | loading: () =>
34 | })
35 |
36 | export { Stock, Purchase, Stocks, Events }
37 |
--------------------------------------------------------------------------------
/components/stocks/message.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { IconOpenAI, IconUser } from '@/components/ui/icons'
4 | import { cn } from '@/lib/utils'
5 | import { spinner } from './spinner'
6 | import { CodeBlock } from '../ui/codeblock'
7 | import { MemoizedReactMarkdown } from '../markdown'
8 | import remarkGfm from 'remark-gfm'
9 | import remarkMath from 'remark-math'
10 | import { StreamableValue } from 'ai/rsc'
11 | import { useStreamableText } from '@/lib/hooks/use-streamable-text'
12 |
13 | // Different types of message bubbles.
14 |
15 | export function UserMessage({ children }: { children: React.ReactNode }) {
16 | return (
17 |
18 |
19 |
20 |
21 |
22 | {children}
23 |
24 |
25 | )
26 | }
27 |
28 | export function BotMessage({
29 | content,
30 | className
31 | }: {
32 | content: string | StreamableValue
33 | className?: string
34 | }) {
35 | const text = useStreamableText(content)
36 |
37 | return (
38 |
39 |
40 |
41 |
42 |
43 | {children}
49 | },
50 | code({ node, inline, className, children, ...props }) {
51 | if (children.length) {
52 | if (children[0] == '▍') {
53 | return (
54 | ▍
55 | )
56 | }
57 |
58 | children[0] = (children[0] as string).replace('`▍`', '▍')
59 | }
60 |
61 | const match = /language-(\w+)/.exec(className || '')
62 |
63 | if (inline) {
64 | return (
65 |
66 | {children}
67 |
68 | )
69 | }
70 |
71 | return (
72 |
78 | )
79 | }
80 | }}
81 | >
82 | {text}
83 |
84 |
85 |
86 | )
87 | }
88 |
89 | export function BotCard({
90 | children,
91 | showAvatar = true
92 | }: {
93 | children: React.ReactNode
94 | showAvatar?: boolean
95 | }) {
96 | return (
97 |
98 |
104 |
105 |
106 |
{children}
107 |
108 | )
109 | }
110 |
111 | export function SystemMessage({ children }: { children: React.ReactNode }) {
112 | return (
113 |
120 | )
121 | }
122 |
123 | export function SpinnerMessage() {
124 | return (
125 |
126 |
127 |
128 |
129 |
130 | {spinner}
131 |
132 |
133 | )
134 | }
135 |
--------------------------------------------------------------------------------
/components/stocks/spinner.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | export const spinner = (
4 |
14 |
15 |
16 | )
17 |
--------------------------------------------------------------------------------
/components/stocks/stock-purchase.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useId, useState } from 'react'
4 | import { useActions, useAIState, useUIState } from 'ai/rsc'
5 | import { formatNumber } from '@/lib/utils'
6 |
7 | import type { AI } from '@/lib/chat/actions'
8 |
9 | interface Purchase {
10 | numberOfShares?: number
11 | symbol: string
12 | price: number
13 | status: 'requires_action' | 'completed' | 'expired'
14 | }
15 |
16 | export function Purchase({
17 | props: { numberOfShares, symbol, price, status = 'requires_action' }
18 | }: {
19 | props: Purchase
20 | }) {
21 | const [value, setValue] = useState(numberOfShares || 100)
22 | const [purchasingUI, setPurchasingUI] = useState(null)
23 | const [aiState, setAIState] = useAIState()
24 | const [, setMessages] = useUIState()
25 | const { confirmPurchase } = useActions()
26 |
27 | // Unique identifier for this UI component.
28 | const id = useId()
29 |
30 | // Whenever the slider changes, we need to update the local value state and the history
31 | // so LLM also knows what's going on.
32 | function onSliderChange(e: React.ChangeEvent) {
33 | const newValue = Number(e.target.value)
34 | setValue(newValue)
35 |
36 | // Insert a hidden history info to the list.
37 | const message = {
38 | role: 'system' as const,
39 | content: `[User has changed to purchase ${newValue} shares of ${name}. Total cost: $${(
40 | newValue * price
41 | ).toFixed(2)}]`,
42 |
43 | // Identifier of this UI component, so we don't insert it many times.
44 | id
45 | }
46 |
47 | // If last history state is already this info, update it. This is to avoid
48 | // adding every slider change to the history.
49 | if (aiState.messages[aiState.messages.length - 1]?.id === id) {
50 | setAIState({
51 | ...aiState,
52 | messages: [...aiState.messages.slice(0, -1), message]
53 | })
54 |
55 | return
56 | }
57 |
58 | // If it doesn't exist, append it to history.
59 | setAIState({ ...aiState, messages: [...aiState.messages, message] })
60 | }
61 |
62 | return (
63 |
64 |
65 | +1.23% ↑
66 |
67 |
{symbol}
68 |
${price}
69 | {purchasingUI ? (
70 |
{purchasingUI}
71 | ) : status === 'requires_action' ? (
72 | <>
73 |
74 |
Shares to purchase
75 |
84 |
85 | 10
86 |
87 |
88 | 100
89 |
90 |
91 | 500
92 |
93 |
94 | 1000
95 |
96 |
97 |
98 |
99 |
Total cost
100 |
101 |
102 | {value}
103 |
104 | shares
105 |
106 |
107 |
×
108 |
109 | ${price}
110 |
111 | per share
112 |
113 |
114 |
115 | = {formatNumber(value * price)}
116 |
117 |
118 |
119 |
120 |
{
123 | const response = await confirmPurchase(symbol, price, value)
124 | setPurchasingUI(response.purchasingUI)
125 |
126 | // Insert a new system message to the UI.
127 | setMessages((currentMessages: any) => [
128 | ...currentMessages,
129 | response.newMessage
130 | ])
131 | }}
132 | >
133 | Purchase
134 |
135 | >
136 | ) : status === 'completed' ? (
137 |
138 | You have successfully purchased {value} ${symbol}. Total cost:{' '}
139 | {formatNumber(value * price)}
140 |
141 | ) : status === 'expired' ? (
142 |
Your checkout session has expired!
143 | ) : null}
144 |
145 | )
146 | }
147 |
--------------------------------------------------------------------------------
/components/stocks/stock-skeleton.tsx:
--------------------------------------------------------------------------------
1 | export const StockSkeleton = () => {
2 | return (
3 |
4 |
5 | xxxxxxx
6 |
7 |
8 | xxxx
9 |
10 |
11 | xxxx
12 |
13 |
14 | xxxxxx xxx xx xxxx xx xxx
15 |
16 |
17 |
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/components/stocks/stock.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState, useRef, useEffect, useId } from 'react'
4 | import { scaleLinear } from 'd3-scale'
5 | import { subMonths, format } from 'date-fns'
6 | import { useResizeObserver } from 'usehooks-ts'
7 | import { useAIState } from 'ai/rsc'
8 |
9 | interface Stock {
10 | symbol: string
11 | price: number
12 | delta: number
13 | }
14 |
15 | export function Stock({ props: { symbol, price, delta } }: { props: Stock }) {
16 | const [aiState, setAIState] = useAIState()
17 | const id = useId()
18 |
19 | const [priceAtTime, setPriceAtTime] = useState({
20 | time: '00:00',
21 | value: price.toFixed(2),
22 | x: 0
23 | })
24 |
25 | const [startHighlight, setStartHighlight] = useState(0)
26 | const [endHighlight, setEndHighlight] = useState(0)
27 |
28 | const chartRef = useRef(null)
29 | const { width = 0 } = useResizeObserver({
30 | ref: chartRef,
31 | box: 'border-box'
32 | })
33 |
34 | const xToDate = scaleLinear(
35 | [0, width],
36 | [subMonths(new Date(), 6), new Date()]
37 | )
38 | const xToValue = scaleLinear(
39 | [0, width],
40 | [price - price / 2, price + price / 2]
41 | )
42 |
43 | useEffect(() => {
44 | if (startHighlight && endHighlight) {
45 | const message = {
46 | id,
47 | role: 'system' as const,
48 | content: `[User has highlighted dates between between ${format(
49 | xToDate(startHighlight),
50 | 'd LLL'
51 | )} and ${format(xToDate(endHighlight), 'd LLL, yyyy')}`
52 | }
53 |
54 | if (aiState.messages[aiState.messages.length - 1]?.id === id) {
55 | setAIState({
56 | ...aiState,
57 | messages: [...aiState.messages.slice(0, -1), message]
58 | })
59 | } else {
60 | setAIState({
61 | ...aiState,
62 | messages: [...aiState.messages, message]
63 | })
64 | }
65 | }
66 | }, [startHighlight, endHighlight])
67 |
68 | return (
69 |
70 |
71 | {`${delta > 0 ? '+' : ''}${((delta / price) * 100).toFixed(2)}% ${
72 | delta > 0 ? '↑' : '↓'
73 | }`}
74 |
75 |
{symbol}
76 |
${price}
77 |
78 | Closed: Feb 27, 4:59 PM EST
79 |
80 |
81 |
{
84 | if (chartRef.current) {
85 | const { clientX } = event
86 | const { left } = chartRef.current.getBoundingClientRect()
87 |
88 | setStartHighlight(clientX - left)
89 | setEndHighlight(0)
90 |
91 | setPriceAtTime({
92 | time: format(xToDate(clientX), 'dd LLL yy'),
93 | value: xToValue(clientX).toFixed(2),
94 | x: clientX - left
95 | })
96 | }
97 | }}
98 | onPointerUp={event => {
99 | if (chartRef.current) {
100 | const { clientX } = event
101 | const { left } = chartRef.current.getBoundingClientRect()
102 |
103 | setEndHighlight(clientX - left)
104 | }
105 | }}
106 | onPointerMove={event => {
107 | if (chartRef.current) {
108 | const { clientX } = event
109 | const { left } = chartRef.current.getBoundingClientRect()
110 |
111 | setPriceAtTime({
112 | time: format(xToDate(clientX), 'dd LLL yy'),
113 | value: xToValue(clientX).toFixed(2),
114 | x: clientX - left
115 | })
116 | }
117 | }}
118 | onPointerLeave={() => {
119 | setPriceAtTime({
120 | time: '00:00',
121 | value: price.toFixed(2),
122 | x: 0
123 | })
124 | }}
125 | ref={chartRef}
126 | >
127 | {priceAtTime.x > 0 ? (
128 |
135 |
${priceAtTime.value}
136 |
137 | {priceAtTime.time}
138 |
139 |
140 | ) : null}
141 |
142 | {startHighlight ? (
143 |
153 | ) : null}
154 |
155 |
161 |
162 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
199 |
206 |
207 |
208 |
209 | )
210 | }
211 |
--------------------------------------------------------------------------------
/components/stocks/stocks-skeleton.tsx:
--------------------------------------------------------------------------------
1 | export const StocksSkeleton = () => {
2 | return (
3 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/components/stocks/stocks.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useActions, useUIState } from 'ai/rsc'
4 |
5 | import type { AI } from '@/lib/chat/actions'
6 |
7 | interface Stock {
8 | symbol: string
9 | price: number
10 | delta: number
11 | }
12 |
13 | export function Stocks({ props: stocks }: { props: Stock[] }) {
14 | const [, setMessages] = useUIState()
15 | const { submitUserMessage } = useActions()
16 |
17 | return (
18 |
19 |
20 | {stocks.map(stock => (
21 |
{
25 | const response = await submitUserMessage(`View ${stock.symbol}`)
26 | setMessages(currentMessages => [...currentMessages, response])
27 | }}
28 | >
29 | 0 ? 'text-green-600' : 'text-red-600'
32 | } flex w-11 flex-row justify-center rounded-md bg-white/10 p-2`}
33 | >
34 | {stock.delta > 0 ? '↑' : '↓'}
35 |
36 |
37 |
{stock.symbol}
38 |
39 | ${stock.price.toExponential(1)}
40 |
41 |
42 |
43 |
0 ? 'text-green-600' : 'text-red-600'
46 | } bold text-right uppercase`}
47 | >
48 | {` ${((stock.delta / stock.price) * 100).toExponential(1)}%`}
49 |
50 |
0 ? 'text-green-700' : 'text-red-700'
53 | } text-right text-base`}
54 | >
55 | {stock.delta.toExponential(1)}
56 |
57 |
58 |
59 | ))}
60 |
61 |
62 | Note: Data and latency are simulated for illustrative purposes and
63 | should not be considered as financial advice.
64 |
65 |
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/components/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import { useTheme } from 'next-themes'
5 |
6 | import { Button } from '@/components/ui/button'
7 | import { IconMoon, IconSun } from '@/components/ui/icons'
8 |
9 | export function ThemeToggle() {
10 | const { setTheme, theme } = useTheme()
11 | const [_, startTransition] = React.useTransition()
12 |
13 | return (
14 | {
18 | startTransition(() => {
19 | setTheme(theme === 'light' ? 'dark' : 'light')
20 | })
21 | }}
22 | >
23 | {!theme ? null : theme === 'dark' ? (
24 |
25 | ) : (
26 |
27 | )}
28 | Toggle theme
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/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: 'size-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/codeblock.tsx:
--------------------------------------------------------------------------------
1 | // Inspired by Chatbot-UI and modified to fit the needs of this project
2 | // @see https://github.com/mckaywrigley/chatbot-ui/blob/main/components/Markdown/CodeBlock.tsx
3 |
4 | 'use client'
5 |
6 | import { FC, memo } from 'react'
7 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
8 | import { coldarkDark } from 'react-syntax-highlighter/dist/cjs/styles/prism'
9 |
10 | import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard'
11 | import { IconCheck, IconCopy, IconDownload } from '@/components/ui/icons'
12 | import { Button } from '@/components/ui/button'
13 |
14 | interface Props {
15 | language: string
16 | value: string
17 | }
18 |
19 | interface languageMap {
20 | [key: string]: string | undefined
21 | }
22 |
23 | export const programmingLanguages: languageMap = {
24 | javascript: '.js',
25 | python: '.py',
26 | java: '.java',
27 | c: '.c',
28 | cpp: '.cpp',
29 | 'c++': '.cpp',
30 | 'c#': '.cs',
31 | ruby: '.rb',
32 | php: '.php',
33 | swift: '.swift',
34 | 'objective-c': '.m',
35 | kotlin: '.kt',
36 | typescript: '.ts',
37 | go: '.go',
38 | perl: '.pl',
39 | rust: '.rs',
40 | scala: '.scala',
41 | haskell: '.hs',
42 | lua: '.lua',
43 | shell: '.sh',
44 | sql: '.sql',
45 | html: '.html',
46 | css: '.css'
47 | // add more file extensions here, make sure the key is same as language prop in CodeBlock.tsx component
48 | }
49 |
50 | export const generateRandomString = (length: number, lowercase = false) => {
51 | const chars = 'ABCDEFGHJKLMNPQRSTUVWXY3456789' // excluding similar looking characters like Z, 2, I, 1, O, 0
52 | let result = ''
53 | for (let i = 0; i < length; i++) {
54 | result += chars.charAt(Math.floor(Math.random() * chars.length))
55 | }
56 | return lowercase ? result.toLowerCase() : result
57 | }
58 |
59 | const CodeBlock: FC = memo(({ language, value }) => {
60 | const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 })
61 |
62 | const downloadAsFile = () => {
63 | if (typeof window === 'undefined') {
64 | return
65 | }
66 | const fileExtension = programmingLanguages[language] || '.file'
67 | const suggestedFileName = `file-${generateRandomString(
68 | 3,
69 | true
70 | )}${fileExtension}`
71 | const fileName = window.prompt('Enter file name' || '', suggestedFileName)
72 |
73 | if (!fileName) {
74 | // User pressed cancel on prompt.
75 | return
76 | }
77 |
78 | const blob = new Blob([value], { type: 'text/plain' })
79 | const url = URL.createObjectURL(blob)
80 | const link = document.createElement('a')
81 | link.download = fileName
82 | link.href = url
83 | link.style.display = 'none'
84 | document.body.appendChild(link)
85 | link.click()
86 | document.body.removeChild(link)
87 | URL.revokeObjectURL(url)
88 | }
89 |
90 | const onCopy = () => {
91 | if (isCopied) return
92 | copyToClipboard(value)
93 | }
94 |
95 | return (
96 |
97 |
98 |
{language}
99 |
100 |
106 |
107 | Download
108 |
109 |
115 | {isCopied ? : }
116 | Copy code
117 |
118 |
119 |
120 |
141 | {value}
142 |
143 |
144 | )
145 | })
146 | CodeBlock.displayName = 'CodeBlock'
147 |
148 | export { CodeBlock }
149 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { Cross2Icon } from "@radix-ui/react-icons"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogTrigger,
116 | DialogClose,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/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/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/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import {
5 | CaretSortIcon,
6 | CheckIcon,
7 | ChevronDownIcon,
8 | ChevronUpIcon,
9 | } from "@radix-ui/react-icons"
10 | import * as SelectPrimitive from "@radix-ui/react-select"
11 |
12 | import { cn } from "@/lib/utils"
13 |
14 | const Select = SelectPrimitive.Root
15 |
16 | const SelectGroup = SelectPrimitive.Group
17 |
18 | const SelectValue = SelectPrimitive.Value
19 |
20 | const SelectTrigger = React.forwardRef<
21 | React.ElementRef,
22 | React.ComponentPropsWithoutRef
23 | >(({ className, children, ...props }, ref) => (
24 | span]:line-clamp-1",
28 | className
29 | )}
30 | {...props}
31 | >
32 | {children}
33 |
34 |
35 |
36 |
37 | ))
38 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
39 |
40 | const SelectScrollUpButton = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 |
53 |
54 | ))
55 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
56 |
57 | const SelectScrollDownButton = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, ...props }, ref) => (
61 |
69 |
70 |
71 | ))
72 | SelectScrollDownButton.displayName =
73 | SelectPrimitive.ScrollDownButton.displayName
74 |
75 | const SelectContent = React.forwardRef<
76 | React.ElementRef,
77 | React.ComponentPropsWithoutRef
78 | >(({ className, children, position = "popper", ...props }, ref) => (
79 |
80 |
91 |
92 |
99 | {children}
100 |
101 |
102 |
103 |
104 | ))
105 | SelectContent.displayName = SelectPrimitive.Content.displayName
106 |
107 | const SelectLabel = React.forwardRef<
108 | React.ElementRef,
109 | React.ComponentPropsWithoutRef
110 | >(({ className, ...props }, ref) => (
111 |
116 | ))
117 | SelectLabel.displayName = SelectPrimitive.Label.displayName
118 |
119 | const SelectItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ))
139 | SelectItem.displayName = SelectPrimitive.Item.displayName
140 |
141 | const SelectSeparator = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef
144 | >(({ className, ...props }, ref) => (
145 |
150 | ))
151 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
152 |
153 | export {
154 | Select,
155 | SelectGroup,
156 | SelectValue,
157 | SelectTrigger,
158 | SelectContent,
159 | SelectLabel,
160 | SelectItem,
161 | SelectSeparator,
162 | SelectScrollUpButton,
163 | SelectScrollDownButton,
164 | }
165 |
--------------------------------------------------------------------------------
/components/ui/separator.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/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTheme } from "next-themes"
4 | import { Toaster as Sonner } from "sonner"
5 |
6 | type ToasterProps = React.ComponentProps
7 |
8 | const Toaster = ({ ...props }: ToasterProps) => {
9 | const { theme = "system" } = useTheme()
10 |
11 | return (
12 |
28 | )
29 | }
30 |
31 | export { Toaster }
32 |
--------------------------------------------------------------------------------
/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SwitchPrimitives from "@radix-ui/react-switch"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ))
27 | Switch.displayName = SwitchPrimitives.Root.displayName
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/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/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/user-menu.tsx:
--------------------------------------------------------------------------------
1 | import { type Session } from '@/lib/types'
2 |
3 | import { Button } from '@/components/ui/button'
4 | import {
5 | DropdownMenu,
6 | DropdownMenuContent,
7 | DropdownMenuItem,
8 | DropdownMenuSeparator,
9 | DropdownMenuTrigger
10 | } from '@/components/ui/dropdown-menu'
11 | import { signOut } from '@/auth'
12 |
13 | export interface UserMenuProps {
14 | user: Session['user']
15 | }
16 |
17 | function getUserInitials(name: string) {
18 | const [firstName, lastName] = name.split(' ')
19 | return lastName ? `${firstName[0]}${lastName[0]}` : firstName.slice(0, 2)
20 | }
21 |
22 | export function UserMenu({ user }: UserMenuProps) {
23 | return (
24 |
25 |
26 |
27 |
28 |
29 | {getUserInitials(user.email)}
30 |
31 | {user.email}
32 |
33 |
34 |
35 |
36 | {user.email}
37 |
38 |
39 | {
41 | 'use server'
42 | await signOut()
43 | }}
44 | >
45 |
46 | Sign Out
47 |
48 |
49 |
50 |
51 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lib/chat/actions.tsx:
--------------------------------------------------------------------------------
1 | import 'server-only'
2 |
3 | import {
4 | createAI,
5 | createStreamableUI,
6 | getMutableAIState,
7 | getAIState,
8 | render,
9 | createStreamableValue
10 | } from 'ai/rsc'
11 | import OpenAI from 'openai'
12 |
13 | import {
14 | spinner,
15 | BotCard,
16 | BotMessage,
17 | SystemMessage,
18 | Stock,
19 | Purchase
20 | } from '@/components/stocks'
21 |
22 | import { z } from 'zod'
23 | import { EventsSkeleton } from '@/components/stocks/events-skeleton'
24 | import { Events } from '@/components/stocks/events'
25 | import { StocksSkeleton } from '@/components/stocks/stocks-skeleton'
26 | import { Stocks } from '@/components/stocks/stocks'
27 | import { StockSkeleton } from '@/components/stocks/stock-skeleton'
28 | import {
29 | formatNumber,
30 | runAsyncFnWithoutBlocking,
31 | sleep,
32 | nanoid
33 | } from '@/lib/utils'
34 | import { saveChat } from '@/app/actions'
35 | import { SpinnerMessage, UserMessage } from '@/components/stocks/message'
36 | import { Chat } from '@/lib/types'
37 | import { auth } from '@/auth'
38 |
39 | const openai = new OpenAI({
40 | apiKey: process.env.OPENAI_API_KEY || ''
41 | })
42 |
43 | async function confirmPurchase(symbol: string, price: number, amount: number) {
44 | 'use server'
45 |
46 | const aiState = getMutableAIState()
47 |
48 | const purchasing = createStreamableUI(
49 |
50 | {spinner}
51 |
52 | Purchasing {amount} ${symbol}...
53 |
54 |
55 | )
56 |
57 | const systemMessage = createStreamableUI(null)
58 |
59 | runAsyncFnWithoutBlocking(async () => {
60 | await sleep(1000)
61 |
62 | purchasing.update(
63 |
64 | {spinner}
65 |
66 | Purchasing {amount} ${symbol}... working on it...
67 |
68 |
69 | )
70 |
71 | await sleep(1000)
72 |
73 | purchasing.done(
74 |
75 |
76 | You have successfully purchased {amount} ${symbol}. Total cost:{' '}
77 | {formatNumber(amount * price)}
78 |
79 |
80 | )
81 |
82 | systemMessage.done(
83 |
84 | You have purchased {amount} shares of {symbol} at ${price}. Total cost ={' '}
85 | {formatNumber(amount * price)}.
86 |
87 | )
88 |
89 | aiState.done({
90 | ...aiState.get(),
91 | messages: [
92 | ...aiState.get().messages.slice(0, -1),
93 | {
94 | id: nanoid(),
95 | role: 'function',
96 | name: 'showStockPurchase',
97 | content: JSON.stringify({
98 | symbol,
99 | price,
100 | defaultAmount: amount,
101 | status: 'completed'
102 | })
103 | },
104 | {
105 | id: nanoid(),
106 | role: 'system',
107 | content: `[User has purchased ${amount} shares of ${symbol} at ${price}. Total cost = ${
108 | amount * price
109 | }]`
110 | }
111 | ]
112 | })
113 | })
114 |
115 | return {
116 | purchasingUI: purchasing.value,
117 | newMessage: {
118 | id: nanoid(),
119 | display: systemMessage.value
120 | }
121 | }
122 | }
123 |
124 | async function submitUserMessage(content: string) {
125 | 'use server'
126 |
127 | const aiState = getMutableAIState()
128 |
129 | aiState.update({
130 | ...aiState.get(),
131 | messages: [
132 | ...aiState.get().messages,
133 | {
134 | id: nanoid(),
135 | role: 'user',
136 | content
137 | }
138 | ]
139 | })
140 |
141 | let textStream: undefined | ReturnType>
142 | let textNode: undefined | React.ReactNode
143 |
144 | const ui = render({
145 | model: 'gpt-3.5-turbo',
146 | provider: openai,
147 | initial: ,
148 | messages: [
149 | {
150 | role: 'system',
151 | content: `\
152 | You are a stock trading conversation bot and you can help users buy stocks, step by step.
153 | You and the user can discuss stock prices and the user can adjust the amount of stocks they want to buy, or place an order, in the UI.
154 |
155 | Messages inside [] means that it's a UI element or a user event. For example:
156 | - "[Price of AAPL = 100]" means that an interface of the stock price of AAPL is shown to the user.
157 | - "[User has changed the amount of AAPL to 10]" means that the user has changed the amount of AAPL to 10 in the UI.
158 |
159 | If the user requests purchasing a stock, call \`show_stock_purchase_ui\` to show the purchase UI.
160 | If the user just wants the price, call \`show_stock_price\` to show the price.
161 | If you want to show trending stocks, call \`list_stocks\`.
162 | If you want to show events, call \`get_events\`.
163 | If the user wants to sell stock, or complete another impossible task, respond that you are a demo and cannot do that.
164 |
165 | Besides that, you can also chat with users and do some calculations if needed.`
166 | },
167 | ...aiState.get().messages.map((message: any) => ({
168 | role: message.role,
169 | content: message.content,
170 | name: message.name
171 | }))
172 | ],
173 | text: ({ content, done, delta }) => {
174 | if (!textStream) {
175 | textStream = createStreamableValue('')
176 | textNode =
177 | }
178 |
179 | if (done) {
180 | textStream.done()
181 | aiState.done({
182 | ...aiState.get(),
183 | messages: [
184 | ...aiState.get().messages,
185 | {
186 | id: nanoid(),
187 | role: 'assistant',
188 | content
189 | }
190 | ]
191 | })
192 | } else {
193 | textStream.update(delta)
194 | }
195 |
196 | return textNode
197 | },
198 | functions: {
199 | listStocks: {
200 | description: 'List three imaginary stocks that are trending.',
201 | parameters: z.object({
202 | stocks: z.array(
203 | z.object({
204 | symbol: z.string().describe('The symbol of the stock'),
205 | price: z.number().describe('The price of the stock'),
206 | delta: z.number().describe('The change in price of the stock')
207 | })
208 | )
209 | }),
210 | render: async function* ({ stocks }) {
211 | yield (
212 |
213 |
214 |
215 | )
216 |
217 | await sleep(1000)
218 |
219 | aiState.done({
220 | ...aiState.get(),
221 | messages: [
222 | ...aiState.get().messages,
223 | {
224 | id: nanoid(),
225 | role: 'function',
226 | name: 'listStocks',
227 | content: JSON.stringify(stocks)
228 | }
229 | ]
230 | })
231 |
232 | return (
233 |
234 |
235 |
236 | )
237 | }
238 | },
239 | showStockPrice: {
240 | description:
241 | 'Get the current stock price of a given stock or currency. Use this to show the price to the user.',
242 | parameters: z.object({
243 | symbol: z
244 | .string()
245 | .describe(
246 | 'The name or symbol of the stock or currency. e.g. DOGE/AAPL/USD.'
247 | ),
248 | price: z.number().describe('The price of the stock.'),
249 | delta: z.number().describe('The change in price of the stock')
250 | }),
251 | render: async function* ({ symbol, price, delta }) {
252 | yield (
253 |
254 |
255 |
256 | )
257 |
258 | await sleep(1000)
259 |
260 | aiState.done({
261 | ...aiState.get(),
262 | messages: [
263 | ...aiState.get().messages,
264 | {
265 | id: nanoid(),
266 | role: 'function',
267 | name: 'showStockPrice',
268 | content: JSON.stringify({ symbol, price, delta })
269 | }
270 | ]
271 | })
272 |
273 | return (
274 |
275 |
276 |
277 | )
278 | }
279 | },
280 | showStockPurchase: {
281 | description:
282 | 'Show price and the UI to purchase a stock or currency. Use this if the user wants to purchase a stock or currency.',
283 | parameters: z.object({
284 | symbol: z
285 | .string()
286 | .describe(
287 | 'The name or symbol of the stock or currency. e.g. DOGE/AAPL/USD.'
288 | ),
289 | price: z.number().describe('The price of the stock.'),
290 | numberOfShares: z
291 | .number()
292 | .describe(
293 | 'The **number of shares** for a stock or currency to purchase. Can be optional if the user did not specify it.'
294 | )
295 | }),
296 | render: async function* ({ symbol, price, numberOfShares = 100 }) {
297 | if (numberOfShares <= 0 || numberOfShares > 1000) {
298 | aiState.done({
299 | ...aiState.get(),
300 | messages: [
301 | ...aiState.get().messages,
302 | {
303 | id: nanoid(),
304 | role: 'system',
305 | content: `[User has selected an invalid amount]`
306 | }
307 | ]
308 | })
309 |
310 | return
311 | }
312 |
313 | aiState.done({
314 | ...aiState.get(),
315 | messages: [
316 | ...aiState.get().messages,
317 | {
318 | id: nanoid(),
319 | role: 'function',
320 | name: 'showStockPurchase',
321 | content: JSON.stringify({
322 | symbol,
323 | price,
324 | numberOfShares
325 | })
326 | }
327 | ]
328 | })
329 |
330 | return (
331 |
332 |
340 |
341 | )
342 | }
343 | },
344 | getEvents: {
345 | description:
346 | 'List funny imaginary events between user highlighted dates that describe stock activity.',
347 | parameters: z.object({
348 | events: z.array(
349 | z.object({
350 | date: z
351 | .string()
352 | .describe('The date of the event, in ISO-8601 format'),
353 | headline: z.string().describe('The headline of the event'),
354 | description: z.string().describe('The description of the event')
355 | })
356 | )
357 | }),
358 | render: async function* ({ events }) {
359 | yield (
360 |
361 |
362 |
363 | )
364 |
365 | await sleep(1000)
366 |
367 | aiState.done({
368 | ...aiState.get(),
369 | messages: [
370 | ...aiState.get().messages,
371 | {
372 | id: nanoid(),
373 | role: 'function',
374 | name: 'getEvents',
375 | content: JSON.stringify(events)
376 | }
377 | ]
378 | })
379 |
380 | return (
381 |
382 |
383 |
384 | )
385 | }
386 | }
387 | }
388 | })
389 |
390 | return {
391 | id: nanoid(),
392 | display: ui
393 | }
394 | }
395 |
396 | export type Message = {
397 | role: 'user' | 'assistant' | 'system' | 'function' | 'data' | 'tool'
398 | content: string
399 | id: string
400 | name?: string
401 | }
402 |
403 | export type AIState = {
404 | chatId: string
405 | messages: Message[]
406 | }
407 |
408 | export type UIState = {
409 | id: string
410 | display: React.ReactNode
411 | }[]
412 |
413 | export const AI = createAI({
414 | actions: {
415 | submitUserMessage,
416 | confirmPurchase
417 | },
418 | initialUIState: [],
419 | initialAIState: { chatId: nanoid(), messages: [] },
420 | unstable_onGetUIState: async () => {
421 | 'use server'
422 |
423 | const session = await auth()
424 |
425 | if (session && session.user) {
426 | const aiState = getAIState()
427 |
428 | if (aiState) {
429 | const uiState = getUIStateFromAIState(aiState)
430 | return uiState
431 | }
432 | } else {
433 | return
434 | }
435 | },
436 | unstable_onSetAIState: async ({ state, done }) => {
437 | 'use server'
438 |
439 | const session = await auth()
440 |
441 | if (session && session.user) {
442 | const { chatId, messages } = state
443 |
444 | const createdAt = new Date()
445 | const userId = session.user.id as string
446 | const path = `/chat/${chatId}`
447 | const title = messages[0].content.substring(0, 100)
448 |
449 | const chat: Chat = {
450 | id: chatId,
451 | title,
452 | userId,
453 | createdAt,
454 | messages,
455 | path
456 | }
457 |
458 | await saveChat(chat)
459 | } else {
460 | return
461 | }
462 | }
463 | })
464 |
465 | export const getUIStateFromAIState = (aiState: Chat) => {
466 | return aiState.messages
467 | .filter(message => message.role !== 'system')
468 | .map((message, index) => ({
469 | id: `${aiState.chatId}-${index}`,
470 | display:
471 | message.role === 'function' ? (
472 | message.name === 'listStocks' ? (
473 |
474 |
475 |
476 | ) : message.name === 'showStockPrice' ? (
477 |
478 |
479 |
480 | ) : message.name === 'showStockPurchase' ? (
481 |
482 |
483 |
484 | ) : message.name === 'getEvents' ? (
485 |
486 |
487 |
488 | ) : null
489 | ) : message.role === 'user' ? (
490 | {message.content}
491 | ) : (
492 |
493 | )
494 | }))
495 | }
496 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/hooks/use-scroll-anchor.tsx:
--------------------------------------------------------------------------------
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 | 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/storage.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { readFile, writeFile, stat } from 'fs/promises'
4 | import path from 'path'
5 | import { Chat, User } from '@/lib/types'
6 |
7 | const storageRoot = path.resolve(
8 | process.env.NODE_ENV === 'production' ? '/ai-chatbot-data/' : './'
9 | )
10 |
11 | const parse = (json: string) => {
12 | if (!json) {
13 | return null
14 | }
15 | try {
16 | return JSON.parse(json)
17 | } catch (error) {
18 | return null
19 | }
20 | }
21 |
22 | const getFileSize = async (fileName: string) => {
23 | try {
24 | const stats = await stat(path.join(storageRoot, fileName))
25 | return stats.size
26 | } catch (error) {
27 | return 0
28 | }
29 | }
30 |
31 | const loadJson = async (fileName: string) => {
32 | try {
33 | const json = await readFile(path.join(storageRoot, fileName), 'utf-8')
34 | return parse(json)
35 | } catch (error) {
36 | return null
37 | }
38 | }
39 |
40 | class KeyValueStorage {
41 | private groups: {
42 | [groupName: string]: { [partitionKey: string]: string[] }
43 | } = {}
44 |
45 | private items: {
46 | [id: string]: any
47 | } = {}
48 |
49 | constructor() {
50 | this.load()
51 | }
52 |
53 | async load() {
54 | const loadedGroups = await loadJson('groups.json')
55 | if (loadedGroups) {
56 | this.groups = loadedGroups
57 | }
58 | const loadedItems = await loadJson('items.json')
59 | if (loadedItems) {
60 | this.items = loadedItems
61 | }
62 | }
63 |
64 | async save() {
65 | await writeFile(
66 | path.join(storageRoot, 'groups.json'),
67 | JSON.stringify(this.groups)
68 | )
69 | await writeFile(
70 | path.join(storageRoot, 'items.json'),
71 | JSON.stringify(this.items)
72 | )
73 | }
74 |
75 | async getItemsByPartitionKey(
76 | group: string,
77 | partitionKey: string
78 | ): Promise {
79 | const ids = this.groups[group]?.[partitionKey] ?? []
80 | const records = ids.map(id => this.getItem(group, id))
81 |
82 | return records.filter(record => !!record) as T[]
83 | }
84 |
85 | getItem(group: string, id: string): T | null {
86 | return this.items[`${group}:${id}`]
87 | }
88 |
89 | deleteItem(group: string, id: string, partitionKeyField?: keyof T) {
90 | const item = this.getItem(group, id)
91 |
92 | if (item) {
93 | delete this.items[`${group}:${id}`]
94 |
95 | if (partitionKeyField) {
96 | const pt = item[partitionKeyField] as string
97 | if (this.groups[group][pt]) {
98 | this.groups[group][pt] = this.groups[group][pt].filter(
99 | (itemId: string) => itemId !== id
100 | )
101 | }
102 | }
103 | }
104 | this.save()
105 | }
106 |
107 | setItem(
108 | group: string,
109 | id: string,
110 | payload: T,
111 | partitionKeyField?: keyof T
112 | ) {
113 | this.items[`${group}:${id}`] = payload
114 |
115 | if (partitionKeyField) {
116 | const pt = payload[partitionKeyField] as string
117 | if (!this.groups[group]) {
118 | this.groups[group] = {}
119 | }
120 | if (!this.groups[group][pt]) {
121 | this.groups[group][pt] = []
122 | }
123 | if (!this.groups[group][pt].includes(id)) {
124 | this.groups[group][pt].push(id)
125 | }
126 | }
127 | this.save()
128 | }
129 | deleteItemsByPartitionKey(group: string, pt: string) {
130 | this.groups[group][pt].forEach((id: string) => {
131 | this.deleteItem(group, id)
132 | })
133 | this.save()
134 | }
135 |
136 | async getStats() {
137 | const itemsSize = await getFileSize('items.json')
138 | const groupsSize = await getFileSize('groups.json')
139 | const dbSize = itemsSize + groupsSize
140 |
141 | // count all the records by group
142 | const records: { [group: string]: number } = {}
143 |
144 | Object.keys(this.items).forEach(key => {
145 | const group = key.split(':')[0]
146 | records[group] = (records[group] || 0) + 1
147 | })
148 |
149 | return {
150 | dbSize,
151 | records,
152 | updated: new Date().toISOString()
153 | }
154 | }
155 | }
156 |
157 | const kv = new KeyValueStorage()
158 |
159 | export const getChatsByUserId = async (userId: string) =>
160 | kv.getItemsByPartitionKey('chat', userId)
161 |
162 | export const getChatById = (chatId: string) => kv.getItem('chat', chatId)
163 |
164 | export const deleteChatById = (chatId: string) =>
165 | kv.deleteItem('chat', chatId, 'userId')
166 |
167 | export const deleteChatsByUserId = (userId: string) =>
168 | kv.deleteItemsByPartitionKey('chat', userId)
169 |
170 | export const updateChat = (chatId: string, payload: Chat) =>
171 | kv.setItem('chat', chatId, payload, 'userId')
172 |
173 | export const insertChat = (payload: Chat) =>
174 | kv.setItem('chat', payload.id, payload, 'userId')
175 |
176 | export const insertUser = (payload: User) =>
177 | kv.setItem('user', payload.email, payload)
178 |
179 | export const getUserByEmail = (email: string) => kv.getItem('user', email)
180 |
181 | export const getStats = async () => {
182 | const stats = await kv.getStats()
183 | return stats
184 | }
185 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } 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 | ) // 7-character random string
13 |
14 | export async function fetcher(
15 | input: RequestInfo,
16 | init?: RequestInit
17 | ): Promise {
18 | const res = await fetch(input, init)
19 |
20 | if (!res.ok) {
21 | const json = await res.json()
22 | if (json.error) {
23 | const error = new Error(json.error) as Error & {
24 | status: number
25 | }
26 | error.status = res.status
27 | throw error
28 | } else {
29 | throw new Error('An unexpected error occurred')
30 | }
31 | }
32 |
33 | return res.json()
34 | }
35 |
36 | export function formatDate(input: string | number | Date): string {
37 | const date = new Date(input)
38 | return date.toLocaleDateString('en-US', {
39 | month: 'long',
40 | day: 'numeric',
41 | year: 'numeric'
42 | })
43 | }
44 |
45 | export const formatNumber = (value: number) =>
46 | new Intl.NumberFormat('en-US', {
47 | style: 'currency',
48 | currency: 'USD'
49 | }).format(value)
50 |
51 | export const runAsyncFnWithoutBlocking = (
52 | fn: (...args: any) => Promise
53 | ) => {
54 | fn()
55 | }
56 |
57 | export const sleep = (ms: number) =>
58 | new Promise(resolve => setTimeout(resolve, ms))
59 |
60 | export const getStringFromBuffer = (buffer: ArrayBuffer) =>
61 | Array.from(new Uint8Array(buffer))
62 | .map(b => b.toString(16).padStart(2, '0'))
63 | .join('')
64 |
65 | export enum ResultCode {
66 | InvalidCredentials = 'INVALID_CREDENTIALS',
67 | InvalidSubmission = 'INVALID_SUBMISSION',
68 | UserAlreadyExists = 'USER_ALREADY_EXISTS',
69 | UnknownError = 'UNKNOWN_ERROR',
70 | UserCreated = 'USER_CREATED',
71 | UserLoggedIn = 'USER_LOGGED_IN'
72 | }
73 |
74 | export const getMessageFromCode = (resultCode: string) => {
75 | switch (resultCode) {
76 | case ResultCode.InvalidCredentials:
77 | return 'Invalid credentials!'
78 | case ResultCode.InvalidSubmission:
79 | return 'Invalid submission, please try again!'
80 | case ResultCode.UserAlreadyExists:
81 | return 'User already exists, please log in!'
82 | case ResultCode.UserCreated:
83 | return 'User created, welcome!'
84 | case ResultCode.UnknownError:
85 | return 'Something went wrong, please try again!'
86 | case ResultCode.UserLoggedIn:
87 | return 'Logged in!'
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "scripts": {
4 | "dev": "next dev",
5 | "build": "next build",
6 | "start": "next start",
7 | "lint": "next lint",
8 | "lint:fix": "next lint --fix",
9 | "preview": "next build && next start",
10 | "seed": "node -r dotenv/config ./scripts/seed.mjs",
11 | "type-check": "tsc --noEmit",
12 | "format:write": "prettier --write \"{app,lib,components}/**/*.{ts,tsx,mdx}\" --cache",
13 | "format:check": "prettier --check \"{app,lib,components}**/*.{ts,tsx,mdx}\" --cache"
14 | },
15 | "dependencies": {
16 | "@radix-ui/react-alert-dialog": "^1.0.5",
17 | "@radix-ui/react-dialog": "^1.0.5",
18 | "@radix-ui/react-dropdown-menu": "^2.0.6",
19 | "@radix-ui/react-icons": "^1.3.0",
20 | "@radix-ui/react-label": "^2.0.2",
21 | "@radix-ui/react-select": "^2.0.0",
22 | "@radix-ui/react-separator": "^1.0.3",
23 | "@radix-ui/react-slot": "^1.0.2",
24 | "@radix-ui/react-switch": "^1.0.3",
25 | "@radix-ui/react-tooltip": "^1.0.7",
26 | "@vercel/analytics": "^1.1.2",
27 | "@vercel/kv": "^1.0.1",
28 | "@vercel/og": "^0.6.2",
29 | "ai": "^3.0.12",
30 | "class-variance-authority": "^0.7.0",
31 | "clsx": "^2.1.0",
32 | "d3-scale": "^4.0.2",
33 | "date-fns": "^3.3.1",
34 | "focus-trap-react": "^10.2.3",
35 | "framer-motion": "^10.18.0",
36 | "geist": "^1.2.1",
37 | "nanoid": "^5.0.4",
38 | "next": "14.1.3",
39 | "next-auth": "5.0.0-beta.4",
40 | "next-themes": "^0.2.1",
41 | "openai": "^4.24.7",
42 | "react": "^18.2.0",
43 | "react-dom": "^18.2.0",
44 | "react-intersection-observer": "^9.5.3",
45 | "react-markdown": "^8.0.7",
46 | "react-syntax-highlighter": "^15.5.0",
47 | "react-textarea-autosize": "^8.5.3",
48 | "remark-gfm": "^3.0.1",
49 | "remark-math": "^5.1.1",
50 | "sonner": "^1.4.3",
51 | "usehooks-ts": "^2.16.0",
52 | "zod": "^3.22.4"
53 | },
54 | "devDependencies": {
55 | "@tailwindcss/typography": "^0.5.10",
56 | "@types/d3-scale": "^4.0.8",
57 | "@types/node": "^20.11.5",
58 | "@types/react": "^18.2.48",
59 | "@types/react-dom": "^18.2.18",
60 | "@types/react-syntax-highlighter": "^15.5.11",
61 | "@typescript-eslint/parser": "^6.19.0",
62 | "autoprefixer": "^10.4.17",
63 | "dotenv": "^16.4.5",
64 | "eslint": "^8.56.0",
65 | "eslint-config-next": "14.1.0",
66 | "eslint-config-prettier": "^9.1.0",
67 | "eslint-plugin-tailwindcss": "^3.14.0",
68 | "postcss": "^8.4.33",
69 | "prettier": "^3.2.4",
70 | "tailwind-merge": "^2.2.0",
71 | "tailwindcss": "^3.4.1",
72 | "tailwindcss-animate": "^1.0.7",
73 | "typescript": "^5.3.3"
74 | },
75 | "packageManager": "pnpm@8.6.3"
76 | }
77 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/prettier.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import('prettier').Config} */
2 | module.exports = {
3 | endOfLine: 'lf',
4 | semi: false,
5 | useTabs: false,
6 | singleQuote: true,
7 | arrowParens: 'avoid',
8 | tabWidth: 2,
9 | trailingComma: 'none',
10 | importOrder: [
11 | '^(react/(.*)$)|^(react$)',
12 | '^(next/(.*)$)|^(next$)',
13 | '',
14 | '',
15 | '^types$',
16 | '^@/types/(.*)$',
17 | '^@/config/(.*)$',
18 | '^@/lib/(.*)$',
19 | '^@/hooks/(.*)$',
20 | '^@/components/ui/(.*)$',
21 | '^@/components/(.*)$',
22 | '^@/registry/(.*)$',
23 | '^@/styles/(.*)$',
24 | '^@/app/(.*)$',
25 | '',
26 | '^[./]'
27 | ],
28 | importOrderSeparation: false,
29 | importOrderSortSpecifiers: true,
30 | importOrderBuiltinModulesToTop: true,
31 | importOrderParserPlugins: ['typescript', 'jsx', 'decorators-legacy'],
32 | importOrderMergeDuplicateImports: true,
33 | importOrderCombineTypeAndValueImports: true
34 | }
35 |
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ashleyrudland/nextjs-ai-chatbot-on-vps/f016d75b69b7b201ad9ff503782da22fa1ea9da4/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ashleyrudland/nextjs-ai-chatbot-on-vps/f016d75b69b7b201ad9ff503782da22fa1ea9da4/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ashleyrudland/nextjs-ai-chatbot-on-vps/f016d75b69b7b201ad9ff503782da22fa1ea9da4/public/favicon.ico
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/thirteen.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ['class'],
4 | content: [
5 | './pages/**/*.{ts,tsx}',
6 | './components/**/*.{ts,tsx}',
7 | './app/**/*.{ts,tsx}',
8 | './src/**/*.{ts,tsx}'
9 | ],
10 | prefix: '',
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: '2rem',
15 | screens: {
16 | '2xl': '1400px'
17 | }
18 | },
19 | extend: {
20 | fontFamily: {
21 | sans: ['var(--font-geist-sans)'],
22 | mono: ['var(--font-geist-mono)']
23 | },
24 | colors: {
25 | border: 'hsl(var(--border))',
26 | input: 'hsl(var(--input))',
27 | ring: 'hsl(var(--ring))',
28 | background: 'hsl(var(--background))',
29 | foreground: 'hsl(var(--foreground))',
30 | primary: {
31 | DEFAULT: 'hsl(var(--primary))',
32 | foreground: 'hsl(var(--primary-foreground))'
33 | },
34 | secondary: {
35 | DEFAULT: 'hsl(var(--secondary))',
36 | foreground: 'hsl(var(--secondary-foreground))'
37 | },
38 | destructive: {
39 | DEFAULT: 'hsl(var(--destructive))',
40 | foreground: 'hsl(var(--destructive-foreground))'
41 | },
42 | muted: {
43 | DEFAULT: 'hsl(var(--muted))',
44 | foreground: 'hsl(var(--muted-foreground))'
45 | },
46 | accent: {
47 | DEFAULT: 'hsl(var(--accent))',
48 | foreground: 'hsl(var(--accent-foreground))'
49 | },
50 | popover: {
51 | DEFAULT: 'hsl(var(--popover))',
52 | foreground: 'hsl(var(--popover-foreground))'
53 | },
54 | card: {
55 | DEFAULT: 'hsl(var(--card))',
56 | foreground: 'hsl(var(--card-foreground))'
57 | }
58 | },
59 | borderRadius: {
60 | lg: 'var(--radius)',
61 | md: 'calc(var(--radius) - 2px)',
62 | sm: 'calc(var(--radius) - 4px)'
63 | },
64 | keyframes: {
65 | 'accordion-down': {
66 | from: { height: '0' },
67 | to: { height: 'var(--radix-accordion-content-height)' }
68 | },
69 | 'accordion-up': {
70 | from: { height: 'var(--radix-accordion-content-height)' },
71 | to: { height: '0' }
72 | }
73 | },
74 | animation: {
75 | 'accordion-down': 'accordion-down 0.2s ease-out',
76 | 'accordion-up': 'accordion-up 0.2s ease-out'
77 | }
78 | }
79 | },
80 | plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')]
81 | }
82 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "noEmit": true,
9 | "incremental": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "baseUrl": ".",
17 | "paths": {
18 | "@/*": ["./*"]
19 | },
20 | "plugins": [
21 | {
22 | "name": "next"
23 | }
24 | ],
25 | "strictNullChecks": true
26 | },
27 | "include": [
28 | "next-env.d.ts",
29 | "next-auth.d.ts",
30 | "**/*.ts",
31 | "**/*.tsx",
32 | ".next/types/**/*.ts"
33 | ],
34 | "exclude": ["node_modules"]
35 | }
36 |
--------------------------------------------------------------------------------