├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── app ├── (chat) │ ├── chat │ │ └── [id] │ │ │ └── page.tsx │ ├── layout.tsx │ └── page.tsx ├── actions.ts ├── analyticsInstance.ts ├── globals.css ├── layout.tsx ├── login │ ├── actions.ts │ └── page.tsx ├── opengraph-image.png ├── share │ └── [id] │ │ └── page.tsx ├── signup │ ├── actions.ts │ └── page.tsx └── twitter-image.png ├── 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-scroll-anchor.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 │ ├── event.tsx │ ├── events-skeleton.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 ├── example-env-vars ├── lib ├── chat │ └── actions.tsx ├── hooks │ ├── use-at-bottom.tsx │ ├── use-copy-to-clipboard.tsx │ ├── use-enter-submit.tsx │ ├── use-local-storage.ts │ ├── use-sidebar.tsx │ └── use-streamable-text.ts ├── types.ts └── utils.ts ├── middleware.ts ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.cjs ├── public ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon.ico ├── next.svg ├── repo_img.png ├── thirteen.svg └── vercel.svg ├── scripts └── seed.mjs ├── tailwind.config.ts └── tsconfig.json /.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 | -------------------------------------------------------------------------------- /.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 | .env 32 | .env.* 33 | 34 | # turbo 35 | .turbo 36 | 37 | .env 38 | .vercel 39 | .vscode 40 | .env*.local 41 | -------------------------------------------------------------------------------- /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 | Next.js 14 and App Router-ready AI chatbot with Segment analytics 3 |

Next.js AI Chatbot with Twilio Segment analytics instrumentation

4 | 5 |

6 | An open-source AI copilot with built-in Segment analytics built with Next.js, the Vercel AI SDK, OpenAI, Vercel KV and Twilio Segment. 7 |

8 | 9 |

10 | Features · 11 | Model Providers · 12 | Deploy Your Own · 13 | Running locally · 14 | Authors 15 |

16 |
17 | 18 | ## Features 19 | 20 | - [Next.js](https://nextjs.org) App Router 21 | - React Server Components (RSCs), Suspense, and Server Actions 22 | - [Vercel AI SDK](https://sdk.vercel.ai/docs) for streaming chat UI 23 | - Support for OpenAI (default), Anthropic, Cohere, Hugging Face, or custom AI chat models and/or LangChain 24 | - [shadcn/ui](https://ui.shadcn.com) 25 | - Styling with [Tailwind CSS](https://tailwindcss.com) 26 | - [Radix UI](https://radix-ui.com) for headless component primitives 27 | - Icons from [Phosphor Icons](https://phosphoricons.com) 28 | - Chat History, rate limiting, and session storage with [Vercel KV](https://vercel.com/storage/kv) and [Vercel Postgres](https://vercel.com/docs/storage/vercel-postgres) 29 | - [analytics-node](https://github.com/segmentio/analytics-node) for model observability with [Twilio Segment](https://segment.com/) 30 | 31 | ## Model Providers 32 | 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. 33 | 34 | ## Deploy Your Own 35 | 36 | You can deploy your own version of the Next.js AI Chatbot with Twilio Segment observability to Vercel with one click: 37 | 38 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?demo-description=An%20open-source%20AI%20copilot%20with%20built-in%20segment%20analytics%20using%20Next.js%2C%20Vercel%20AI%20SDK%2C%20OpenAI%2C%20Vercel%20KV%2C%20and%20Twilio%20Segment.&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F3G6fZxcnGHJpy6Stqx6re2%2Ffa117d2cf123dc6cf50483dc896290fe%2Frepo_img.png&demo-title=Next.js%20AI%20Chatbot%20with%20Twilio%20Segment%20Analytics&demo-url=https%3A%2F%2Fsegment-ai-copilot.vercel.app&env=OPENAI_API_KEY%2CNEXT_PUBLIC_SEGMENT_WRITE_KEY%2CAUTH_SECRET%2CKV_URL%2CKV_REST_API_URL%2CKV_REST_API_TOKEN%2CKV_REST_API_READ_ONLY_TOKEN%2CPOSTGRES_DATABASE%2CPOSTGRES_HOST%2CPOSTGRES_PASSWORD%2CPOSTGRES_PRISMA_URL%2CPOSTGRES_URL%2CPOSTGRES_URL_NON_POOLING%2CPOSTGRES_URL_NO_SSL%2CPOSTGRES_USER&envDescription=Learn%20how%20to%20configure%20the%20environment%20variables&envLink=https%3A%2F%2Fgithub.com%2Fvaithschmitz%2Fsegment-ai-copilot%3Ftab%3Dreadme-ov-file%23creating-a-kv-database-instance&repository-name=nextjs-ai-chatbot-with-twilio-segment&repository-url=https%3A%2F%2Fgithub.com%2Fvaithschmitz%2Fsegment-ai-copilot) 39 | 40 | ## Creating a KV Database Instance 41 | 42 | Follow the steps outlined in the [quick start guide](https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database) provided by Vercel. This guide will assist you in creating and configuring your KV database instance on Vercel, enabling your application to interact with it. 43 | 44 | Remember to update your environment variables (`KV_URL`, `KV_REST_API_URL`, `KV_REST_API_TOKEN`, `KV_REST_API_READ_ONLY_TOKEN`) in the `.env` file with the appropriate credentials provided during the KV database setup. 45 | 46 | ## Creating a Postgres Database Instance 47 | 48 | Similarly, follow the steps outline in the [quick start guide](https://vercel.com/docs/storage/vercel-postgres/quickstart) provided by Vercel. This guide will assist you in creating and configuring your Postgres database instance on Vercel, enabling your application to interact with it. 49 | 50 | Remember to update your environment variables (`POSTGRES_URL`, `POSTGRES_PRISMA_URL`, `POSTGRES_URL_NO_SSL`, `POSTGRES_URL_NON_POOLING`, `POSTGRES_USER`, `POSTGRES_HOST`, `POSTGRES_PASSWORD`, `POSTGRES_DATABASE`) in the `.env` file with the appropriate credentials provided during the Postgres database setup. 51 | 52 | ## Enabling Twilio Segment model tracking 53 | 54 | For the full guided walkthrough, follow the blog post [here.](https://segment.com/blog/instrumenting-user-insights-for-your-ai-copilot) 55 | 56 | Use your existing Twilio Segment account or [create a free one here](https://segment.com/signup/). Create a new `node.js` source by following the instructions [here](https://segment.com/docs/connections/sources/) and record your write key. 57 | 58 | Remember to update the segment write key in your environment `.env` file (`NEXT_PUBLIC_SEGMENT_WRITE_KEY`) with the write key to your node.js Segment source. 59 | 60 | 61 | ## Running locally 62 | 63 | You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js AI Chatbot. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/projects/environment-variables) for this, but a `.env` file is all that is necessary. 64 | 65 | > Note: You should not commit your `.env` file or it will expose secrets that will allow others to control access to your various OpenAI, Twilio Segment and authentication provider accounts. 66 | 67 | 1. Install Vercel CLI: `npm i -g vercel` 68 | 2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link` 69 | 3. Download your environment variables: `vercel env pull` 70 | 71 | ```bash 72 | pnpm install 73 | pnpm seed 74 | pnpm dev 75 | ``` 76 | 77 | Your app template should now be running on [localhost:3000](http://localhost:3000/). 78 | 79 | ## Authors 80 | 81 | This template is created by [Vercel](https://vercel.com), [Next.js](https://nextjs.org) and [Twilio Segment](https://segment.com/) team members, with contributions from: 82 | 83 | - Jared Palmer ([@jaredpalmer](https://twitter.com/jaredpalmer)) - [Vercel](https://vercel.com) 84 | - Shu Ding ([@shuding\_](https://twitter.com/shuding_)) - [Vercel](https://vercel.com) 85 | - shadcn ([@shadcn](https://twitter.com/shadcn)) - [Vercel](https://vercel.com) 86 | - Vaith Schmitz [(@vaith)](https://www.linkedin.com/in/vaithschmitz/) - [Twilio Segment](https://segment.com/) 87 | -------------------------------------------------------------------------------- /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 |
12 | {children} 13 |
14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /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 | 9 | export const metadata = { 10 | title: 'Next.js AI Chatbot' 11 | } 12 | 13 | export default async function IndexPage() { 14 | const id = nanoid() 15 | const session = (await auth()) as Session 16 | const missingKeys = await getMissingKeys() 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 | 10 | export async function getChats(userId?: string | null) { 11 | if (!userId) { 12 | return [] 13 | } 14 | 15 | try { 16 | const pipeline = kv.pipeline() 17 | const chats: string[] = await kv.zrange(`user:chat:${userId}`, 0, -1, { 18 | rev: true 19 | }) 20 | 21 | for (const chat of chats) { 22 | pipeline.hgetall(chat) 23 | } 24 | 25 | const results = await pipeline.exec() 26 | 27 | return results as Chat[] 28 | } catch (error) { 29 | return [] 30 | } 31 | } 32 | 33 | export async function getChat(id: string, userId: string) { 34 | const chat = await kv.hgetall(`chat:${id}`) 35 | 36 | if (!chat || (userId && chat.userId !== userId)) { 37 | return null 38 | } 39 | 40 | return chat 41 | } 42 | 43 | export async function removeChat({ id, path }: { id: string; path: string }) { 44 | const session = await auth() 45 | 46 | if (!session) { 47 | return { 48 | error: 'Unauthorized' 49 | } 50 | } 51 | 52 | //Convert uid to string for consistent comparison with session.user.id 53 | const uid = String(await kv.hget(`chat:${id}`, 'userId')) 54 | 55 | if (uid !== session?.user?.id) { 56 | return { 57 | error: 'Unauthorized' 58 | } 59 | } 60 | 61 | await kv.del(`chat:${id}`) 62 | await kv.zrem(`user:chat:${session.user.id}`, `chat:${id}`) 63 | 64 | revalidatePath('/') 65 | return revalidatePath(path) 66 | } 67 | 68 | export async function clearChats() { 69 | const session = await auth() 70 | 71 | if (!session?.user?.id) { 72 | return { 73 | error: 'Unauthorized' 74 | } 75 | } 76 | 77 | const chats: string[] = await kv.zrange(`user:chat:${session.user.id}`, 0, -1) 78 | if (!chats.length) { 79 | return redirect('/') 80 | } 81 | const pipeline = kv.pipeline() 82 | 83 | for (const chat of chats) { 84 | pipeline.del(chat) 85 | pipeline.zrem(`user:chat:${session.user.id}`, chat) 86 | } 87 | 88 | await pipeline.exec() 89 | 90 | revalidatePath('/') 91 | return redirect('/') 92 | } 93 | 94 | export async function getSharedChat(id: string) { 95 | const chat = await kv.hgetall(`chat:${id}`) 96 | 97 | if (!chat || !chat.sharePath) { 98 | return null 99 | } 100 | 101 | return chat 102 | } 103 | 104 | export async function shareChat(id: string) { 105 | const session = await auth() 106 | 107 | if (!session?.user?.id) { 108 | return { 109 | error: 'Unauthorized' 110 | } 111 | } 112 | 113 | const chat = await kv.hgetall(`chat:${id}`) 114 | 115 | if (!chat || chat.userId !== session.user.id) { 116 | return { 117 | error: 'Something went wrong' 118 | } 119 | } 120 | 121 | const payload = { 122 | ...chat, 123 | sharePath: `/share/${chat.id}` 124 | } 125 | 126 | await kv.hmset(`chat:${chat.id}`, payload) 127 | 128 | return payload 129 | } 130 | 131 | export async function saveChat(chat: Chat) { 132 | const session = await auth() 133 | 134 | if (session && session.user) { 135 | const pipeline = kv.pipeline() 136 | pipeline.hmset(`chat:${chat.id}`, chat) 137 | pipeline.zadd(`user:chat:${chat.userId}`, { 138 | score: Date.now(), 139 | member: `chat:${chat.id}` 140 | }) 141 | await pipeline.exec() 142 | } else { 143 | return 144 | } 145 | } 146 | 147 | export async function refreshHistory(path: string) { 148 | redirect(path) 149 | } 150 | 151 | export async function getMissingKeys() { 152 | const keysRequired = ['OPENAI_API_KEY'] 153 | return keysRequired 154 | .map(key => (process.env[key] ? '' : key)) 155 | .filter(key => key !== '') 156 | } 157 | -------------------------------------------------------------------------------- /app/analyticsInstance.ts: -------------------------------------------------------------------------------- 1 | import { Analytics } from '@segment/analytics-node' 2 | 3 | const analyticsSingleton = () => { 4 | return new Analytics({ writeKey: process.env.NEXT_PUBLIC_SEGMENT_WRITE_KEY as string}) 5 | } 6 | 7 | declare global { 8 | var analyticsGlobal: undefined | ReturnType 9 | } 10 | 11 | const analytics = globalThis.analyticsGlobal ?? analyticsSingleton() 12 | 13 | export default analytics 14 | 15 | if (process.env.NODE_ENV !== 'production') globalThis.analyticsGlobal = analytics -------------------------------------------------------------------------------- /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: new URL(`https://${process.env.VERCEL_URL}`), 13 | title: { 14 | default: 'Next.js AI Chatbot', 15 | template: `%s - Next.js AI Chatbot` 16 | }, 17 | description: 'An AI-powered chatbot template built with Next.js and Vercel.', 18 | icons: { 19 | icon: '/favicon.ico', 20 | shortcut: '/favicon-16x16.png', 21 | apple: '/apple-touch-icon.png' 22 | } 23 | } 24 | 25 | export const viewport = { 26 | themeColor: [ 27 | { media: '(prefers-color-scheme: light)', color: 'white' }, 28 | { media: '(prefers-color-scheme: dark)', color: 'black' } 29 | ] 30 | } 31 | 32 | interface RootLayoutProps { 33 | children: React.ReactNode 34 | } 35 | 36 | export default function RootLayout({ children }: RootLayoutProps) { 37 | return ( 38 | 39 | 46 | 47 | 53 |
54 |
55 |
{children}
56 |
57 | 58 |
59 | 60 | 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /app/login/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { signIn } from '@/auth' 4 | import { AuthResult } from '@/lib/types' 5 | import { AuthError } from 'next-auth' 6 | import { z } from 'zod' 7 | 8 | 9 | 10 | export async function authenticate( 11 | _prevState: AuthResult | undefined, 12 | formData: FormData 13 | ) { 14 | try { 15 | const email = formData.get('email') 16 | const password = formData.get('password') 17 | 18 | const parsedCredentials = z 19 | .object({ 20 | email: z.string().email(), 21 | password: z.string().min(6) 22 | }) 23 | .safeParse({ 24 | email, 25 | password 26 | }) 27 | 28 | if (parsedCredentials.success) { 29 | await signIn('credentials', { 30 | email, 31 | password, 32 | redirectTo: '/' 33 | }) 34 | 35 | } else { 36 | return { type: 'error', message: 'Invalid credentials!' } 37 | } 38 | } catch (error) { 39 | if (error instanceof AuthError) { 40 | switch (error.type) { 41 | case 'CredentialsSignin': 42 | return { type: 'error', message: 'Invalid credentials!' } 43 | default: 44 | return { 45 | type: 'error', 46 | message: 'Something went wrong, please try again!' 47 | } 48 | } 49 | } 50 | 51 | throw error 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /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/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vaithschmitz/segment-ai-copilot/b285cecb633814a627e6af7ce336240a2dce662c/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 | export const runtime = 'edge' 11 | export const preferredRegion = 'home' 12 | 13 | interface SharePageProps { 14 | params: { 15 | id: string 16 | } 17 | } 18 | 19 | export async function generateMetadata({ 20 | params 21 | }: SharePageProps): Promise { 22 | const chat = await getSharedChat(params.id) 23 | 24 | return { 25 | title: chat?.title.slice(0, 50) ?? 'Chat' 26 | } 27 | } 28 | 29 | export default async function SharePage({ params }: SharePageProps) { 30 | const chat = await getSharedChat(params.id) 31 | 32 | if (!chat || !chat?.sharePath) { 33 | notFound() 34 | } 35 | 36 | const uiState: UIState = getUIStateFromAIState(chat) 37 | 38 | return ( 39 | <> 40 |
41 |
42 |
43 |
44 |

{chat.title}

45 |
46 | {formatDate(chat.createdAt)} · {chat.messages.length} messages 47 |
48 |
49 |
50 |
51 | 52 | 53 | 54 |
55 | 56 | 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /app/signup/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { signIn } from '@/auth' 4 | import { db } from '@vercel/postgres' 5 | import { getStringFromBuffer } from '@/lib/utils' 6 | import { z } from 'zod' 7 | import { AuthResult } from '@/lib/types' 8 | 9 | 10 | export async function signup( 11 | _prevState: AuthResult | undefined, 12 | formData: FormData 13 | ) { 14 | const email = formData.get('email') as string 15 | const password = formData.get('password') as string 16 | 17 | const parsedCredentials = z 18 | .object({ 19 | email: z.string().email(), 20 | password: z.string().min(6) 21 | }) 22 | .safeParse({ 23 | email, 24 | password 25 | }) 26 | 27 | if (parsedCredentials.success) { 28 | const salt = crypto.randomUUID() 29 | 30 | const encoder = new TextEncoder() 31 | const saltedPassword = encoder.encode(password + salt) 32 | const hashedPasswordBuffer = await crypto.subtle.digest( 33 | 'SHA-256', 34 | saltedPassword 35 | ) 36 | const hashedPassword = getStringFromBuffer(hashedPasswordBuffer) 37 | 38 | const client = await db.connect() 39 | 40 | try { 41 | await client.sql` 42 | INSERT INTO users (email, password, salt) 43 | VALUES (${email}, ${hashedPassword}, ${salt}) 44 | ON CONFLICT (id) DO NOTHING; 45 | ` 46 | 47 | await signIn('credentials', { 48 | email, 49 | password, 50 | redirect: false 51 | }) 52 | return { type: 'success', message: 'Account created!' } 53 | } catch (error) { 54 | const { message } = error as Error 55 | 56 | if ( 57 | message.startsWith('duplicate key value violates unique constraint') 58 | ) { 59 | return { type: 'error', message: 'User already exists! Please log in.' } 60 | } else { 61 | return { 62 | type: 'error', 63 | message: 'Something went wrong! Please try again.' 64 | } 65 | } 66 | } finally { 67 | client.release() 68 | } 69 | } else { 70 | return { 71 | type: 'error', 72 | message: 'Invalid entries, please try again!' 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /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/vaithschmitz/segment-ai-copilot/b285cecb633814a627e6af7ce336240a2dce662c/app/twitter-image.png -------------------------------------------------------------------------------- /auth.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextAuthConfig } from 'next-auth' 2 | 3 | export const authConfig = { 4 | secret: process.env.AUTH_SECRET, 5 | pages: { 6 | signIn: '/login', 7 | newUser: '/signup' 8 | }, 9 | callbacks: { 10 | async authorized({ auth, request: { nextUrl } }) { 11 | const isLoggedIn = !!auth?.user 12 | const isOnLoginPage = nextUrl.pathname.startsWith('/login') 13 | const isOnSignupPage = nextUrl.pathname.startsWith('/signup') 14 | 15 | if (isLoggedIn) { 16 | if (isOnLoginPage || isOnSignupPage) { 17 | return Response.redirect(new URL('/', nextUrl)) 18 | } 19 | } 20 | 21 | return true 22 | }, 23 | async jwt({ token, user }) { 24 | if (user) { 25 | token = { ...token, id: user.id } 26 | } 27 | 28 | return token 29 | }, 30 | async session({ session, token }) { 31 | if (token) { 32 | const { id } = token as { id: string } 33 | const { user } = session 34 | 35 | session = { ...session, user: { ...user, id } } 36 | } 37 | 38 | return session 39 | } 40 | }, 41 | providers: [] 42 | } satisfies NextAuthConfig 43 | -------------------------------------------------------------------------------- /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 { sql } from '@vercel/postgres' 6 | import { getStringFromBuffer } from './lib/utils' 7 | 8 | interface User { 9 | id: string 10 | name: string 11 | email: string 12 | password: string 13 | salt: string 14 | } 15 | 16 | async function getUser(email: string): Promise { 17 | try { 18 | const user = await sql`SELECT * FROM users WHERE email=${email}` 19 | return user.rows[0] 20 | } catch (error) { 21 | throw new Error('Failed to fetch user.') 22 | } 23 | } 24 | 25 | export const { auth, signIn, signOut } = NextAuth({ 26 | ...authConfig, 27 | providers: [ 28 | Credentials({ 29 | async authorize(credentials) { 30 | const parsedCredentials = z 31 | .object({ 32 | email: z.string().email(), 33 | password: z.string().min(6) 34 | }) 35 | .safeParse(credentials) 36 | 37 | if (parsedCredentials.success) { 38 | const { email, password } = parsedCredentials.data 39 | const user = await getUser(email) 40 | 41 | if (!user) return null 42 | 43 | const encoder = new TextEncoder() 44 | const saltedPassword = encoder.encode(password + user.salt) 45 | const hashedPasswordBuffer = await crypto.subtle.digest( 46 | 'SHA-256', 47 | saltedPassword 48 | ) 49 | const hashedPassword = getStringFromBuffer(hashedPasswordBuffer) 50 | 51 | if (hashedPassword === user.password) { 52 | return user 53 | } else { 54 | return null 55 | } 56 | } 57 | 58 | return null 59 | } 60 | }) 61 | ] 62 | }) 63 | -------------------------------------------------------------------------------- /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 { useAtBottom } from '@/lib/hooks/use-at-bottom' 7 | import { Button, type ButtonProps } from '@/components/ui/button' 8 | import { IconArrowDown } from '@/components/ui/icons' 9 | 10 | export function ButtonScrollToBottom({ className, ...props }: ButtonProps) { 11 | const isAtBottom = useAtBottom() 12 | 13 | return ( 14 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /components/chat-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 | 4 | export interface ChatList { 5 | messages: UIState 6 | } 7 | 8 | export function ChatList({ messages }: ChatList) { 9 | if (!messages.length) { 10 | return null 11 | } 12 | 13 | return ( 14 |
15 | {messages.map((message, index) => ( 16 |
17 | {message.display} 18 | {index < messages.length - 1 && } 19 |
20 | ))} 21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /components/chat-message-actions.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { type Message } from 'ai' 4 | 5 | import { Button } from '@/components/ui/button' 6 | import { IconCheck, IconCopy } from '@/components/ui/icons' 7 | import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' 8 | import { cn } from '@/lib/utils' 9 | 10 | interface ChatMessageActionsProps extends React.ComponentProps<'div'> { 11 | message: Message 12 | } 13 | 14 | export function ChatMessageActions({ 15 | message, 16 | className, 17 | ...props 18 | }: ChatMessageActionsProps) { 19 | const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }) 20 | 21 | const onCopy = () => { 22 | if (isCopied) return 23 | copyToClipboard(message.content) 24 | } 25 | 26 | return ( 27 |
34 | 38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /components/chat-message.tsx: -------------------------------------------------------------------------------- 1 | // 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 | } 21 | 22 | export function ChatPanel({ id, title, input, setInput }: ChatPanelProps) { 23 | const [aiState] = useAIState() 24 | const [messages, setMessages] = useUIState() 25 | const [shareDialogOpen, setShareDialogOpen] = React.useState(false) 26 | 27 | 28 | return ( 29 |
30 | 31 | 32 |
33 | 34 | {messages?.length >= 2 ? ( 35 |
36 |
37 | {id && title ? ( 38 | <> 39 | 46 | setShareDialogOpen(false)} 50 | shareChat={shareChat} 51 | chat={{ 52 | id, 53 | title, 54 | messages: aiState.messages 55 | }} 56 | /> 57 | 58 | ) : null} 59 |
60 |
61 | ) : null} 62 | 63 |
64 | 65 | 66 |
67 |
68 |
69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /components/chat-scroll-anchor.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { useInView } from 'react-intersection-observer' 5 | import { useAtBottom } from '@/lib/hooks/use-at-bottom' 6 | 7 | interface ChatScrollAnchorProps { 8 | trackVisibility?: boolean 9 | } 10 | 11 | export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) { 12 | const isAtBottom = useAtBottom() 13 | const { ref, entry, inView } = useInView({ 14 | trackVisibility, 15 | delay: 100, 16 | rootMargin: '0px 0px -125px 0px' 17 | }) 18 | 19 | React.useEffect(() => { 20 | if (isAtBottom && trackVisibility && !inView) { 21 | entry?.target.scrollIntoView({ 22 | block: 'start' 23 | }) 24 | } 25 | }, [inView, entry, isAtBottom, trackVisibility]) 26 | 27 | return
28 | } 29 | -------------------------------------------------------------------------------- /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 | 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 { ChatScrollAnchor } from '@/components/chat-scroll-anchor' 8 | import { useLocalStorage } from '@/lib/hooks/use-local-storage' 9 | import { useEffect, useState } from 'react' 10 | import { useUIState, useAIState } from 'ai/rsc' 11 | import { Session } from '@/lib/types' 12 | import { usePathname, useRouter } from 'next/navigation' 13 | import { Message } from '@/lib/chat/actions' 14 | import { toast } from 'sonner' 15 | import analytics from '@/app/analyticsInstance' 16 | 17 | 18 | export interface ChatProps extends React.ComponentProps<'div'> { 19 | initialMessages?: Message[] 20 | id?: string 21 | session?: Session 22 | missingKeys: string[] 23 | } 24 | 25 | // changed hooks to only fire on load 26 | export function Chat({ id, className, session, missingKeys }: ChatProps) { 27 | const router = useRouter() 28 | const path = usePathname() 29 | const [input, setInput] = useState('') 30 | const [messages] = useUIState() 31 | const [aiState] = useAIState() 32 | const isLoading = true 33 | 34 | const [_, setNewChatId] = useLocalStorage('newChatId', id) 35 | 36 | useEffect(() => { 37 | if (session?.user) { 38 | if (!path.includes('chat') && messages.length === 1) { 39 | window.history.replaceState({}, '', `/chat/${id}`) 40 | } 41 | } 42 | }, [id, path, session?.user, messages]) 43 | 44 | useEffect(() => { 45 | const messagesLength = aiState.messages?.length 46 | if (messagesLength === 2) { 47 | router.refresh() 48 | } 49 | }, [aiState.messages, router]) 50 | 51 | useEffect(() => { 52 | setNewChatId(id) 53 | if (messages.length === 0) { 54 | analytics.track({ 55 | userId: '123', 56 | event: 'Conversation Started', 57 | properties: { 58 | conversationId: id 59 | } 60 | }) 61 | } 62 | }, []) 63 | 64 | useEffect(() => { 65 | missingKeys.map(key => { 66 | toast.error(`Missing ${key} environment variable!`) 67 | }) 68 | }, [missingKeys]) 69 | 70 | return ( 71 | <> 72 |
73 | {messages.length ? ( 74 | <> 75 | 76 | 77 | 78 | ) : ( 79 | 80 | )} 81 |
82 | 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 | 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 | export function EmptyScreen({ setInput }: Pick) { 8 | return ( 9 |
10 |
11 |

12 | Welcome to the Twilio Segment Finance Copilot 13 |

14 |

15 | This is an open source AI chatbot app template with baked in model observability built with:{' '}

16 | Twilio Segment, {' '} 17 | Next.js, the{' '} 18 | 19 | Vercel AI SDK 20 | 21 | , and{' '} 22 | 23 | Vercel KV 24 | 25 | . 26 |

27 |

28 | `You can ask me things like:

“What’s the price of a certain stock?”

“What are five great stocks?”

“Buy 300 stocks”` 29 |

30 |
31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /components/external-link.tsx: -------------------------------------------------------------------------------- 1 | export function ExternalLink({ 2 | href, 3 | children 4 | }: { 5 | href: string 6 | children: React.ReactNode 7 | }) { 8 | return ( 9 | 14 | {children} 15 | 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /components/footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { cn } from '@/lib/utils' 4 | import { ExternalLink } from '@/components/external-link' 5 | 6 | export function FooterText({ className, ...props }: React.ComponentProps<'p'>) { 7 | return ( 8 |

15 | Open source AI chatbot built with{' '} 16 | Twilio Segment, 17 | Next.js and{' '} 18 | 19 | Vercel KV 20 | 21 | . 22 |

23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /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 | 39 | {session?.user ? ( 40 | 41 | ) : ( 42 | 45 | )} 46 |
47 | 48 | ) 49 | } 50 | 51 | export function Header() { 52 | return ( 53 |
54 |
55 | }> 56 | 57 | 58 |
59 | 70 |
71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /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 | 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 | 10 | export default function LoginForm() { 11 | const [result, dispatch] = useFormState(authenticate, undefined) 12 | 13 | useEffect(() => { 14 | if (result) { 15 | if (result.type === 'error') { 16 | toast.error(result.message) 17 | } else { 18 | toast.success(result.message) 19 | } 20 | } 21 | }, [result]) 22 | 23 | return ( 24 |
28 |
29 |

Please log in to continue.

30 |
31 |
32 | 38 |
39 | 47 |
48 |
49 |
50 | 56 |
57 | 66 |
67 |
68 |
69 | 70 |
71 | 72 | 76 | No account yet?
Sign up
77 | 78 |
79 | ) 80 | } 81 | 82 | function LoginButton() { 83 | const { pending } = useFormStatus() 84 | 85 | return ( 86 | 92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /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 | import analytics from '@/app/analyticsInstance' 6 | 7 | import { useActions, useUIState } from 'ai/rsc' 8 | 9 | import { UserMessage } from './stocks/message' 10 | import { type AI } from '@/lib/chat/actions' 11 | import { Button } from '@/components/ui/button' 12 | import { IconArrowElbow, IconPlus } from '@/components/ui/icons' 13 | import { 14 | Tooltip, 15 | TooltipContent, 16 | TooltipTrigger 17 | } from '@/components/ui/tooltip' 18 | import { useEnterSubmit } from '@/lib/hooks/use-enter-submit' 19 | import { nanoid } from 'nanoid' 20 | import { useRouter } from 'next/navigation' 21 | 22 | export function PromptForm({ 23 | input, 24 | setInput 25 | }: { 26 | input: string 27 | setInput: (value: string) => void 28 | }) { 29 | const router = useRouter() 30 | const { formRef, onKeyDown } = useEnterSubmit() 31 | const inputRef = React.useRef(null) 32 | const { submitUserMessage } = useActions() 33 | const [_, setMessages] = useUIState() 34 | 35 | React.useEffect(() => { 36 | if (inputRef.current) { 37 | inputRef.current.focus() 38 | } 39 | }, []) 40 | 41 | return ( 42 |
{ 45 | e.preventDefault() 46 | // Blur focus on mobile 47 | if (window.innerWidth < 600) { 48 | e.target['message']?.blur() 49 | } 50 | 51 | const value = input.trim() 52 | setInput('') 53 | if (!value) return 54 | 55 | // sending user prompt to Segment 56 | analytics.track({ 57 | userId: "123", 58 | event: "Message Sent", 59 | properties:{ 60 | content:value, 61 | conversationId: window.localStorage.getItem('newChatId') 62 | } 63 | }) 64 | 65 | 66 | // Optimistically add user message UI 67 | setMessages(currentMessages => [ 68 | ...currentMessages, 69 | { 70 | id: nanoid(), 71 | display: {value} 72 | } 73 | ]) 74 | 75 | // Submit and get response message 76 | const responseMessage = await submitUserMessage(value) 77 | setMessages(currentMessages => [...currentMessages, responseMessage]) 78 | }} 79 | > 80 |
81 | 82 | 83 | 94 | 95 | New Chat 96 | 97 |