├── .env.example
├── .eslintrc.json
├── .github
└── workflows
│ ├── lint.yml
│ └── playwright.yml
├── .gitignore
├── .vscode
├── extensions.json
└── settings.json
├── LICENSE
├── README.md
├── app
├── (auth)
│ ├── actions.ts
│ ├── api
│ │ └── auth
│ │ │ ├── [...nextauth]
│ │ │ └── route.ts
│ │ │ └── guest
│ │ │ └── route.ts
│ ├── auth.config.ts
│ ├── auth.ts
│ ├── login
│ │ └── page.tsx
│ └── register
│ │ └── page.tsx
├── (chat)
│ ├── actions.ts
│ ├── api
│ │ ├── chat
│ │ │ ├── route.ts
│ │ │ └── schema.ts
│ │ ├── document
│ │ │ └── route.ts
│ │ ├── files
│ │ │ └── upload
│ │ │ │ └── route.ts
│ │ ├── history
│ │ │ └── route.ts
│ │ ├── suggestions
│ │ │ └── route.ts
│ │ └── vote
│ │ │ └── route.ts
│ ├── chat
│ │ └── [id]
│ │ │ └── page.tsx
│ ├── layout.tsx
│ ├── opengraph-image.png
│ ├── page.tsx
│ └── twitter-image.png
├── favicon.ico
├── globals.css
└── layout.tsx
├── artifacts
├── actions.ts
├── code
│ ├── client.tsx
│ └── server.ts
├── image
│ ├── client.tsx
│ └── server.ts
├── sheet
│ ├── client.tsx
│ └── server.ts
└── text
│ ├── client.tsx
│ └── server.ts
├── biome.jsonc
├── components.json
├── components
├── app-sidebar.tsx
├── artifact-actions.tsx
├── artifact-close-button.tsx
├── artifact-messages.tsx
├── artifact.tsx
├── auth-form.tsx
├── chat-header.tsx
├── chat.tsx
├── code-block.tsx
├── code-editor.tsx
├── console.tsx
├── create-artifact.tsx
├── data-stream-handler.tsx
├── diffview.tsx
├── document-preview.tsx
├── document-skeleton.tsx
├── document.tsx
├── greeting.tsx
├── icons.tsx
├── image-editor.tsx
├── markdown.tsx
├── message-actions.tsx
├── message-editor.tsx
├── message-reasoning.tsx
├── message.tsx
├── messages.tsx
├── model-selector.tsx
├── multimodal-input.tsx
├── preview-attachment.tsx
├── sheet-editor.tsx
├── sidebar-history-item.tsx
├── sidebar-history.tsx
├── sidebar-toggle.tsx
├── sidebar-user-nav.tsx
├── sign-out-form.tsx
├── submit-button.tsx
├── suggested-actions.tsx
├── suggestion.tsx
├── text-editor.tsx
├── theme-provider.tsx
├── toast.tsx
├── toolbar.tsx
├── ui
│ ├── alert-dialog.tsx
│ ├── button.tsx
│ ├── card.tsx
│ ├── dropdown-menu.tsx
│ ├── input.tsx
│ ├── label.tsx
│ ├── select.tsx
│ ├── separator.tsx
│ ├── sheet.tsx
│ ├── sidebar.tsx
│ ├── skeleton.tsx
│ ├── textarea.tsx
│ └── tooltip.tsx
├── version-footer.tsx
├── visibility-selector.tsx
└── weather.tsx
├── drizzle.config.ts
├── hooks
├── use-artifact.ts
├── use-auto-resume.ts
├── use-chat-visibility.ts
├── use-messages.tsx
├── use-mobile.tsx
└── use-scroll-to-bottom.tsx
├── instrumentation.ts
├── lib
├── ai
│ ├── entitlements.ts
│ ├── models.test.ts
│ ├── models.ts
│ ├── prompts.ts
│ ├── providers.ts
│ └── tools
│ │ ├── create-document.ts
│ │ ├── get-weather.ts
│ │ ├── request-suggestions.ts
│ │ └── update-document.ts
├── artifacts
│ └── server.ts
├── constants.ts
├── db
│ ├── helpers
│ │ └── 01-core-to-parts.ts
│ ├── migrate.ts
│ ├── migrations
│ │ ├── 0000_keen_devos.sql
│ │ ├── 0001_sparkling_blue_marvel.sql
│ │ ├── 0002_wandering_riptide.sql
│ │ ├── 0003_cloudy_glorian.sql
│ │ ├── 0004_odd_slayback.sql
│ │ ├── 0005_wooden_whistler.sql
│ │ ├── 0006_marvelous_frog_thor.sql
│ │ └── meta
│ │ │ ├── 0000_snapshot.json
│ │ │ ├── 0001_snapshot.json
│ │ │ ├── 0002_snapshot.json
│ │ │ ├── 0003_snapshot.json
│ │ │ ├── 0004_snapshot.json
│ │ │ ├── 0005_snapshot.json
│ │ │ ├── 0006_snapshot.json
│ │ │ └── _journal.json
│ ├── queries.ts
│ ├── schema.ts
│ └── utils.ts
├── editor
│ ├── config.ts
│ ├── diff.js
│ ├── functions.tsx
│ ├── react-renderer.tsx
│ └── suggestions.tsx
├── errors.ts
├── types.ts
└── utils.ts
├── middleware.ts
├── next-env.d.ts
├── next.config.ts
├── package.json
├── playwright.config.ts
├── pnpm-lock.yaml
├── postcss.config.mjs
├── public
└── images
│ ├── demo-thumbnail.png
│ └── mouth of the seine, monet.jpg
├── tailwind.config.ts
├── tests
├── e2e
│ ├── artifacts.test.ts
│ ├── chat.test.ts
│ ├── reasoning.test.ts
│ └── session.test.ts
├── fixtures.ts
├── helpers.ts
├── pages
│ ├── artifact.ts
│ ├── auth.ts
│ └── chat.ts
├── prompts
│ ├── basic.ts
│ ├── routes.ts
│ └── utils.ts
└── routes
│ ├── chat.test.ts
│ └── document.test.ts
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | # Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
2 | AUTH_SECRET=****
3 |
4 | # The following keys below are automatically created and
5 | # added to your environment when you deploy on vercel
6 |
7 | # Get your xAI API Key here for chat and image models: https://console.x.ai/
8 | XAI_API_KEY=****
9 |
10 | # Instructions to create a Vercel Blob Store here: https://vercel.com/docs/storage/vercel-blob
11 | BLOB_READ_WRITE_TOKEN=****
12 |
13 | # Instructions to create a PostgreSQL database here: https://vercel.com/docs/storage/vercel-postgres/quickstart
14 | POSTGRES_URL=****
15 |
16 |
17 | # Instructions to create a Redis store here:
18 | # https://vercel.com/docs/redis
19 | REDIS_URL=****
20 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "next/core-web-vitals",
4 | "plugin:import/recommended",
5 | "plugin:import/typescript",
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 | "import/resolver": {
16 | "typescript": {
17 | "alwaysTryTypes": true,
18 | "project": "./tsconfig.json"
19 | }
20 | }
21 | },
22 | "ignorePatterns": ["**/components/ui/**"]
23 | }
24 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Lint
2 | on:
3 | push:
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-22.04
8 | strategy:
9 | matrix:
10 | node-version: [20]
11 | steps:
12 | - uses: actions/checkout@v4
13 | - name: Install pnpm
14 | uses: pnpm/action-setup@v4
15 | with:
16 | version: 9.12.3
17 | - name: Use Node.js ${{ matrix.node-version }}
18 | uses: actions/setup-node@v4
19 | with:
20 | node-version: ${{ matrix.node-version }}
21 | cache: 'pnpm'
22 | - name: Install dependencies
23 | run: pnpm install
24 | - name: Run lint
25 | run: pnpm lint
--------------------------------------------------------------------------------
/.github/workflows/playwright.yml:
--------------------------------------------------------------------------------
1 | name: Playwright Tests
2 | on:
3 | push:
4 | branches: [main, master]
5 | pull_request:
6 | branches: [main, master]
7 |
8 | jobs:
9 | test:
10 | timeout-minutes: 30
11 | runs-on: ubuntu-latest
12 | env:
13 | AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
14 | POSTGRES_URL: ${{ secrets.POSTGRES_URL }}
15 | BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
16 | REDIS_URL: ${{ secrets.REDIS_URL }}
17 |
18 | steps:
19 | - uses: actions/checkout@v4
20 | with:
21 | fetch-depth: 1
22 |
23 | - uses: actions/setup-node@v4
24 | with:
25 | node-version: lts/*
26 |
27 | - name: Install pnpm
28 | uses: pnpm/action-setup@v2
29 | with:
30 | version: latest
31 | run_install: false
32 |
33 | - name: Get pnpm store directory
34 | id: pnpm-cache
35 | shell: bash
36 | run: |
37 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
38 |
39 | - uses: actions/cache@v3
40 | with:
41 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
42 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
43 | restore-keys: |
44 | ${{ runner.os }}-pnpm-store-
45 |
46 | - uses: actions/setup-node@v4
47 | with:
48 | node-version: lts/*
49 | cache: "pnpm"
50 |
51 | - name: Install dependencies
52 | run: pnpm install --frozen-lockfile
53 |
54 | - name: Cache Playwright browsers
55 | uses: actions/cache@v3
56 | id: playwright-cache
57 | with:
58 | path: ~/.cache/ms-playwright
59 | key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }}
60 |
61 | - name: Install Playwright Browsers
62 | if: steps.playwright-cache.outputs.cache-hit != 'true'
63 | run: pnpm exec playwright install --with-deps chromium
64 |
65 | - name: Run Playwright tests
66 | run: pnpm test
67 |
68 | - uses: actions/upload-artifact@v4
69 | if: always() && !cancelled()
70 | with:
71 | name: playwright-report
72 | path: playwright-report/
73 | retention-days: 7
74 |
--------------------------------------------------------------------------------
/.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 | .env*.local
38 |
39 | # Playwright
40 | /test-results/
41 | /playwright-report/
42 | /blob-report/
43 | /playwright/*
44 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["biomejs.biome"]
3 | }
4 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "[javascript]": {
4 | "editor.defaultFormatter": "biomejs.biome"
5 | },
6 | "[typescript]": {
7 | "editor.defaultFormatter": "biomejs.biome"
8 | },
9 | "[typescriptreact]": {
10 | "editor.defaultFormatter": "biomejs.biome"
11 | },
12 | "typescript.tsdk": "node_modules/typescript/lib",
13 | "eslint.workingDirectories": [
14 | { "pattern": "app/*" },
15 | { "pattern": "packages/*" }
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2024 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 | Chat SDK
4 |
5 |
6 |
7 | Chat SDK is a free, open-source template built with Next.js and the AI SDK that helps you quickly build powerful chatbot applications.
8 |
9 |
10 |
11 | Read Docs ·
12 | Features ·
13 | Model Providers ·
14 | Deploy Your Own ·
15 | Running locally
16 |
17 |
18 |
19 | ## Features
20 |
21 | - [Next.js](https://nextjs.org) App Router
22 | - Advanced routing for seamless navigation and performance
23 | - React Server Components (RSCs) and Server Actions for server-side rendering and increased performance
24 | - [AI SDK](https://sdk.vercel.ai/docs)
25 | - Unified API for generating text, structured objects, and tool calls with LLMs
26 | - Hooks for building dynamic chat and generative user interfaces
27 | - Supports xAI (default), OpenAI, Fireworks, and other model providers
28 | - [shadcn/ui](https://ui.shadcn.com)
29 | - Styling with [Tailwind CSS](https://tailwindcss.com)
30 | - Component primitives from [Radix UI](https://radix-ui.com) for accessibility and flexibility
31 | - Data Persistence
32 | - [Neon Serverless Postgres](https://vercel.com/marketplace/neon) for saving chat history and user data
33 | - [Vercel Blob](https://vercel.com/storage/blob) for efficient file storage
34 | - [Auth.js](https://authjs.dev)
35 | - Simple and secure authentication
36 |
37 | ## Model Providers
38 |
39 | This template ships with [xAI](https://x.ai) `grok-2-1212` as the default chat model. However, with the [AI SDK](https://sdk.vercel.ai/docs), you can switch LLM providers to [OpenAI](https://openai.com), [Anthropic](https://anthropic.com), [Cohere](https://cohere.com/), and [many more](https://sdk.vercel.ai/providers/ai-sdk-providers) with just a few lines of code.
40 |
41 | ## Deploy Your Own
42 |
43 | You can deploy your own version of the Next.js AI Chatbot to Vercel with one click:
44 |
45 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fai-chatbot&env=AUTH_SECRET&envDescription=Learn+more+about+how+to+get+the+API+Keys+for+the+application&envLink=https%3A%2F%2Fgithub.com%2Fvercel%2Fai-chatbot%2Fblob%2Fmain%2F.env.example&demo-title=AI+Chatbot&demo-description=An+Open-Source+AI+Chatbot+Template+Built+With+Next.js+and+the+AI+SDK+by+Vercel.&demo-url=https%3A%2F%2Fchat.vercel.ai&products=%5B%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22ai%22%2C%22productSlug%22%3A%22grok%22%2C%22integrationSlug%22%3A%22xai%22%7D%2C%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22productSlug%22%3A%22neon%22%2C%22integrationSlug%22%3A%22neon%22%7D%2C%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22productSlug%22%3A%22upstash-kv%22%2C%22integrationSlug%22%3A%22upstash%22%7D%2C%7B%22type%22%3A%22blob%22%7D%5D)
46 |
47 | ## Running locally
48 |
49 | 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.
50 |
51 | > Note: You should not commit your `.env` file or it will expose secrets that will allow others to control access to your various AI and authentication provider accounts.
52 |
53 | 1. Install Vercel CLI: `npm i -g vercel`
54 | 2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
55 | 3. Download your environment variables: `vercel env pull`
56 |
57 | ```bash
58 | pnpm install
59 | pnpm dev
60 | ```
61 |
62 | Your app template should now be running on [localhost:3000](http://localhost:3000).
63 |
--------------------------------------------------------------------------------
/app/(auth)/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { z } from 'zod';
4 |
5 | import { createUser, getUser } from '@/lib/db/queries';
6 |
7 | import { signIn } from './auth';
8 |
9 | const authFormSchema = z.object({
10 | email: z.string().email(),
11 | password: z.string().min(6),
12 | });
13 |
14 | export interface LoginActionState {
15 | status: 'idle' | 'in_progress' | 'success' | 'failed' | 'invalid_data';
16 | }
17 |
18 | export const login = async (
19 | _: LoginActionState,
20 | formData: FormData,
21 | ): Promise => {
22 | try {
23 | const validatedData = authFormSchema.parse({
24 | email: formData.get('email'),
25 | password: formData.get('password'),
26 | });
27 |
28 | await signIn('credentials', {
29 | email: validatedData.email,
30 | password: validatedData.password,
31 | redirect: false,
32 | });
33 |
34 | return { status: 'success' };
35 | } catch (error) {
36 | if (error instanceof z.ZodError) {
37 | return { status: 'invalid_data' };
38 | }
39 |
40 | return { status: 'failed' };
41 | }
42 | };
43 |
44 | export interface RegisterActionState {
45 | status:
46 | | 'idle'
47 | | 'in_progress'
48 | | 'success'
49 | | 'failed'
50 | | 'user_exists'
51 | | 'invalid_data';
52 | }
53 |
54 | export const register = async (
55 | _: RegisterActionState,
56 | formData: FormData,
57 | ): Promise => {
58 | try {
59 | const validatedData = authFormSchema.parse({
60 | email: formData.get('email'),
61 | password: formData.get('password'),
62 | });
63 |
64 | const [user] = await getUser(validatedData.email);
65 |
66 | if (user) {
67 | return { status: 'user_exists' } as RegisterActionState;
68 | }
69 | await createUser(validatedData.email, validatedData.password);
70 | await signIn('credentials', {
71 | email: validatedData.email,
72 | password: validatedData.password,
73 | redirect: false,
74 | });
75 |
76 | return { status: 'success' };
77 | } catch (error) {
78 | if (error instanceof z.ZodError) {
79 | return { status: 'invalid_data' };
80 | }
81 |
82 | return { status: 'failed' };
83 | }
84 | };
85 |
--------------------------------------------------------------------------------
/app/(auth)/api/auth/[...nextauth]/route.ts:
--------------------------------------------------------------------------------
1 | export { GET, POST } from '@/app/(auth)/auth';
2 |
--------------------------------------------------------------------------------
/app/(auth)/api/auth/guest/route.ts:
--------------------------------------------------------------------------------
1 | import { signIn } from '@/app/(auth)/auth';
2 | import { isDevelopmentEnvironment } from '@/lib/constants';
3 | import { getToken } from 'next-auth/jwt';
4 | import { NextResponse } from 'next/server';
5 |
6 | export async function GET(request: Request) {
7 | const { searchParams } = new URL(request.url);
8 | const redirectUrl = searchParams.get('redirectUrl') || '/';
9 |
10 | const token = await getToken({
11 | req: request,
12 | secret: process.env.AUTH_SECRET,
13 | secureCookie: !isDevelopmentEnvironment,
14 | });
15 |
16 | if (token) {
17 | return NextResponse.redirect(new URL('/', request.url));
18 | }
19 |
20 | return signIn('guest', { redirect: true, redirectTo: redirectUrl });
21 | }
22 |
--------------------------------------------------------------------------------
/app/(auth)/auth.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextAuthConfig } from 'next-auth';
2 |
3 | export const authConfig = {
4 | pages: {
5 | signIn: '/login',
6 | newUser: '/',
7 | },
8 | providers: [
9 | // added later in auth.ts since it requires bcrypt which is only compatible with Node.js
10 | // while this file is also used in non-Node.js environments
11 | ],
12 | callbacks: {},
13 | } satisfies NextAuthConfig;
14 |
--------------------------------------------------------------------------------
/app/(auth)/auth.ts:
--------------------------------------------------------------------------------
1 | import { compare } from 'bcrypt-ts';
2 | import NextAuth, { type DefaultSession } from 'next-auth';
3 | import Credentials from 'next-auth/providers/credentials';
4 | import { createGuestUser, getUser } from '@/lib/db/queries';
5 | import { authConfig } from './auth.config';
6 | import { DUMMY_PASSWORD } from '@/lib/constants';
7 | import type { DefaultJWT } from 'next-auth/jwt';
8 |
9 | export type UserType = 'guest' | 'regular';
10 |
11 | declare module 'next-auth' {
12 | interface Session extends DefaultSession {
13 | user: {
14 | id: string;
15 | type: UserType;
16 | } & DefaultSession['user'];
17 | }
18 |
19 | interface User {
20 | id?: string;
21 | email?: string | null;
22 | type: UserType;
23 | }
24 | }
25 |
26 | declare module 'next-auth/jwt' {
27 | interface JWT extends DefaultJWT {
28 | id: string;
29 | type: UserType;
30 | }
31 | }
32 |
33 | export const {
34 | handlers: { GET, POST },
35 | auth,
36 | signIn,
37 | signOut,
38 | } = NextAuth({
39 | ...authConfig,
40 | providers: [
41 | Credentials({
42 | credentials: {},
43 | async authorize({ email, password }: any) {
44 | const users = await getUser(email);
45 |
46 | if (users.length === 0) {
47 | await compare(password, DUMMY_PASSWORD);
48 | return null;
49 | }
50 |
51 | const [user] = users;
52 |
53 | if (!user.password) {
54 | await compare(password, DUMMY_PASSWORD);
55 | return null;
56 | }
57 |
58 | const passwordsMatch = await compare(password, user.password);
59 |
60 | if (!passwordsMatch) return null;
61 |
62 | return { ...user, type: 'regular' };
63 | },
64 | }),
65 | Credentials({
66 | id: 'guest',
67 | credentials: {},
68 | async authorize() {
69 | const [guestUser] = await createGuestUser();
70 | return { ...guestUser, type: 'guest' };
71 | },
72 | }),
73 | ],
74 | callbacks: {
75 | async jwt({ token, user }) {
76 | if (user) {
77 | token.id = user.id as string;
78 | token.type = user.type;
79 | }
80 |
81 | return token;
82 | },
83 | async session({ session, token }) {
84 | if (session.user) {
85 | session.user.id = token.id;
86 | session.user.type = token.type;
87 | }
88 |
89 | return session;
90 | },
91 | },
92 | });
93 |
--------------------------------------------------------------------------------
/app/(auth)/login/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Link from 'next/link';
4 | import { useRouter } from 'next/navigation';
5 | import { useActionState, useEffect, useState } from 'react';
6 | import { toast } from '@/components/toast';
7 |
8 | import { AuthForm } from '@/components/auth-form';
9 | import { SubmitButton } from '@/components/submit-button';
10 |
11 | import { login, type LoginActionState } from '../actions';
12 | import { useSession } from 'next-auth/react';
13 |
14 | export default function Page() {
15 | const router = useRouter();
16 |
17 | const [email, setEmail] = useState('');
18 | const [isSuccessful, setIsSuccessful] = useState(false);
19 |
20 | const [state, formAction] = useActionState(
21 | login,
22 | {
23 | status: 'idle',
24 | },
25 | );
26 |
27 | const { update: updateSession } = useSession();
28 |
29 | useEffect(() => {
30 | if (state.status === 'failed') {
31 | toast({
32 | type: 'error',
33 | description: 'Invalid credentials!',
34 | });
35 | } else if (state.status === 'invalid_data') {
36 | toast({
37 | type: 'error',
38 | description: 'Failed validating your submission!',
39 | });
40 | } else if (state.status === 'success') {
41 | setIsSuccessful(true);
42 | updateSession();
43 | router.refresh();
44 | }
45 | }, [state.status]);
46 |
47 | const handleSubmit = (formData: FormData) => {
48 | setEmail(formData.get('email') as string);
49 | formAction(formData);
50 | };
51 |
52 | return (
53 |
54 |
55 |
56 |
Sign In
57 |
58 | Use your email and password to sign in
59 |
60 |
61 |
62 | Sign in
63 |
64 | {"Don't have an account? "}
65 |
69 | Sign up
70 |
71 | {' for free.'}
72 |
73 |
74 |
75 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/app/(auth)/register/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Link from 'next/link';
4 | import { useRouter } from 'next/navigation';
5 | import { useActionState, useEffect, useState } from 'react';
6 |
7 | import { AuthForm } from '@/components/auth-form';
8 | import { SubmitButton } from '@/components/submit-button';
9 |
10 | import { register, type RegisterActionState } from '../actions';
11 | import { toast } from '@/components/toast';
12 | import { useSession } from 'next-auth/react';
13 |
14 | export default function Page() {
15 | const router = useRouter();
16 |
17 | const [email, setEmail] = useState('');
18 | const [isSuccessful, setIsSuccessful] = useState(false);
19 |
20 | const [state, formAction] = useActionState(
21 | register,
22 | {
23 | status: 'idle',
24 | },
25 | );
26 |
27 | const { update: updateSession } = useSession();
28 |
29 | useEffect(() => {
30 | if (state.status === 'user_exists') {
31 | toast({ type: 'error', description: 'Account already exists!' });
32 | } else if (state.status === 'failed') {
33 | toast({ type: 'error', description: 'Failed to create account!' });
34 | } else if (state.status === 'invalid_data') {
35 | toast({
36 | type: 'error',
37 | description: 'Failed validating your submission!',
38 | });
39 | } else if (state.status === 'success') {
40 | toast({ type: 'success', description: 'Account created successfully!' });
41 |
42 | setIsSuccessful(true);
43 | updateSession();
44 | router.refresh();
45 | }
46 | }, [state]);
47 |
48 | const handleSubmit = (formData: FormData) => {
49 | setEmail(formData.get('email') as string);
50 | formAction(formData);
51 | };
52 |
53 | return (
54 |
55 |
56 |
57 |
Sign Up
58 |
59 | Create an account with your email and password
60 |
61 |
62 |
63 | Sign Up
64 |
65 | {'Already have an account? '}
66 |
70 | Sign in
71 |
72 | {' instead.'}
73 |
74 |
75 |
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/app/(chat)/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { generateText, type UIMessage } from 'ai';
4 | import { cookies } from 'next/headers';
5 | import {
6 | deleteMessagesByChatIdAfterTimestamp,
7 | getMessageById,
8 | updateChatVisiblityById,
9 | } from '@/lib/db/queries';
10 | import type { VisibilityType } from '@/components/visibility-selector';
11 | import { myProvider } from '@/lib/ai/providers';
12 |
13 | export async function saveChatModelAsCookie(model: string) {
14 | const cookieStore = await cookies();
15 | cookieStore.set('chat-model', model);
16 | }
17 |
18 | export async function generateTitleFromUserMessage({
19 | message,
20 | }: {
21 | message: UIMessage;
22 | }) {
23 | const { text: title } = await generateText({
24 | model: myProvider.languageModel('title-model'),
25 | system: `\n
26 | - you will generate a short title based on the first message a user begins a conversation with
27 | - ensure it is not more than 80 characters long
28 | - the title should be a summary of the user's message
29 | - do not use quotes or colons`,
30 | prompt: JSON.stringify(message),
31 | });
32 |
33 | return title;
34 | }
35 |
36 | export async function deleteTrailingMessages({ id }: { id: string }) {
37 | const [message] = await getMessageById({ id });
38 |
39 | await deleteMessagesByChatIdAfterTimestamp({
40 | chatId: message.chatId,
41 | timestamp: message.createdAt,
42 | });
43 | }
44 |
45 | export async function updateChatVisibility({
46 | chatId,
47 | visibility,
48 | }: {
49 | chatId: string;
50 | visibility: VisibilityType;
51 | }) {
52 | await updateChatVisiblityById({ chatId, visibility });
53 | }
54 |
--------------------------------------------------------------------------------
/app/(chat)/api/chat/schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 |
3 | const textPartSchema = z.object({
4 | text: z.string().min(1).max(2000),
5 | type: z.enum(['text']),
6 | });
7 |
8 | export const postRequestBodySchema = z.object({
9 | id: z.string().uuid(),
10 | message: z.object({
11 | id: z.string().uuid(),
12 | createdAt: z.coerce.date(),
13 | role: z.enum(['user']),
14 | content: z.string().min(1).max(2000),
15 | parts: z.array(textPartSchema),
16 | experimental_attachments: z
17 | .array(
18 | z.object({
19 | url: z.string().url(),
20 | name: z.string().min(1).max(2000),
21 | contentType: z.enum(['image/png', 'image/jpg', 'image/jpeg']),
22 | }),
23 | )
24 | .optional(),
25 | }),
26 | selectedChatModel: z.enum(['chat-model', 'chat-model-reasoning']),
27 | selectedVisibilityType: z.enum(['public', 'private']),
28 | });
29 |
30 | export type PostRequestBody = z.infer;
31 |
--------------------------------------------------------------------------------
/app/(chat)/api/document/route.ts:
--------------------------------------------------------------------------------
1 | import { auth } from '@/app/(auth)/auth';
2 | import type { ArtifactKind } from '@/components/artifact';
3 | import {
4 | deleteDocumentsByIdAfterTimestamp,
5 | getDocumentsById,
6 | saveDocument,
7 | } from '@/lib/db/queries';
8 | import { ChatSDKError } from '@/lib/errors';
9 |
10 | export async function GET(request: Request) {
11 | const { searchParams } = new URL(request.url);
12 | const id = searchParams.get('id');
13 |
14 | if (!id) {
15 | return new ChatSDKError(
16 | 'bad_request:api',
17 | 'Parameter id is missing',
18 | ).toResponse();
19 | }
20 |
21 | const session = await auth();
22 |
23 | if (!session?.user) {
24 | return new ChatSDKError('unauthorized:document').toResponse();
25 | }
26 |
27 | const documents = await getDocumentsById({ id });
28 |
29 | const [document] = documents;
30 |
31 | if (!document) {
32 | return new ChatSDKError('not_found:document').toResponse();
33 | }
34 |
35 | if (document.userId !== session.user.id) {
36 | return new ChatSDKError('forbidden:document').toResponse();
37 | }
38 |
39 | return Response.json(documents, { status: 200 });
40 | }
41 |
42 | export async function POST(request: Request) {
43 | const { searchParams } = new URL(request.url);
44 | const id = searchParams.get('id');
45 |
46 | if (!id) {
47 | return new ChatSDKError(
48 | 'bad_request:api',
49 | 'Parameter id is required.',
50 | ).toResponse();
51 | }
52 |
53 | const session = await auth();
54 |
55 | if (!session?.user) {
56 | return new ChatSDKError('not_found:document').toResponse();
57 | }
58 |
59 | const {
60 | content,
61 | title,
62 | kind,
63 | }: { content: string; title: string; kind: ArtifactKind } =
64 | await request.json();
65 |
66 | const documents = await getDocumentsById({ id });
67 |
68 | if (documents.length > 0) {
69 | const [document] = documents;
70 |
71 | if (document.userId !== session.user.id) {
72 | return new ChatSDKError('forbidden:document').toResponse();
73 | }
74 | }
75 |
76 | const document = await saveDocument({
77 | id,
78 | content,
79 | title,
80 | kind,
81 | userId: session.user.id,
82 | });
83 |
84 | return Response.json(document, { status: 200 });
85 | }
86 |
87 | export async function DELETE(request: Request) {
88 | const { searchParams } = new URL(request.url);
89 | const id = searchParams.get('id');
90 | const timestamp = searchParams.get('timestamp');
91 |
92 | if (!id) {
93 | return new ChatSDKError(
94 | 'bad_request:api',
95 | 'Parameter id is required.',
96 | ).toResponse();
97 | }
98 |
99 | if (!timestamp) {
100 | return new ChatSDKError(
101 | 'bad_request:api',
102 | 'Parameter timestamp is required.',
103 | ).toResponse();
104 | }
105 |
106 | const session = await auth();
107 |
108 | if (!session?.user) {
109 | return new ChatSDKError('unauthorized:document').toResponse();
110 | }
111 |
112 | const documents = await getDocumentsById({ id });
113 |
114 | const [document] = documents;
115 |
116 | if (document.userId !== session.user.id) {
117 | return new ChatSDKError('forbidden:document').toResponse();
118 | }
119 |
120 | const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({
121 | id,
122 | timestamp: new Date(timestamp),
123 | });
124 |
125 | return Response.json(documentsDeleted, { status: 200 });
126 | }
127 |
--------------------------------------------------------------------------------
/app/(chat)/api/files/upload/route.ts:
--------------------------------------------------------------------------------
1 | import { put } from '@vercel/blob';
2 | import { NextResponse } from 'next/server';
3 | import { z } from 'zod';
4 |
5 | import { auth } from '@/app/(auth)/auth';
6 |
7 | // Use Blob instead of File since File is not available in Node.js environment
8 | const FileSchema = z.object({
9 | file: z
10 | .instanceof(Blob)
11 | .refine((file) => file.size <= 5 * 1024 * 1024, {
12 | message: 'File size should be less than 5MB',
13 | })
14 | // Update the file type based on the kind of files you want to accept
15 | .refine((file) => ['image/jpeg', 'image/png'].includes(file.type), {
16 | message: 'File type should be JPEG or PNG',
17 | }),
18 | });
19 |
20 | export async function POST(request: Request) {
21 | const session = await auth();
22 |
23 | if (!session) {
24 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
25 | }
26 |
27 | if (request.body === null) {
28 | return new Response('Request body is empty', { status: 400 });
29 | }
30 |
31 | try {
32 | const formData = await request.formData();
33 | const file = formData.get('file') as Blob;
34 |
35 | if (!file) {
36 | return NextResponse.json({ error: 'No file uploaded' }, { status: 400 });
37 | }
38 |
39 | const validatedFile = FileSchema.safeParse({ file });
40 |
41 | if (!validatedFile.success) {
42 | const errorMessage = validatedFile.error.errors
43 | .map((error) => error.message)
44 | .join(', ');
45 |
46 | return NextResponse.json({ error: errorMessage }, { status: 400 });
47 | }
48 |
49 | // Get filename from formData since Blob doesn't have name property
50 | const filename = (formData.get('file') as File).name;
51 | const fileBuffer = await file.arrayBuffer();
52 |
53 | try {
54 | const data = await put(`${filename}`, fileBuffer, {
55 | access: 'public',
56 | });
57 |
58 | return NextResponse.json(data);
59 | } catch (error) {
60 | return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
61 | }
62 | } catch (error) {
63 | return NextResponse.json(
64 | { error: 'Failed to process request' },
65 | { status: 500 },
66 | );
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/app/(chat)/api/history/route.ts:
--------------------------------------------------------------------------------
1 | import { auth } from '@/app/(auth)/auth';
2 | import type { NextRequest } from 'next/server';
3 | import { getChatsByUserId } from '@/lib/db/queries';
4 | import { ChatSDKError } from '@/lib/errors';
5 |
6 | export async function GET(request: NextRequest) {
7 | const { searchParams } = request.nextUrl;
8 |
9 | const limit = Number.parseInt(searchParams.get('limit') || '10');
10 | const startingAfter = searchParams.get('starting_after');
11 | const endingBefore = searchParams.get('ending_before');
12 |
13 | if (startingAfter && endingBefore) {
14 | return new ChatSDKError(
15 | 'bad_request:api',
16 | 'Only one of starting_after or ending_before can be provided.',
17 | ).toResponse();
18 | }
19 |
20 | const session = await auth();
21 |
22 | if (!session?.user) {
23 | return new ChatSDKError('unauthorized:chat').toResponse();
24 | }
25 |
26 | const chats = await getChatsByUserId({
27 | id: session.user.id,
28 | limit,
29 | startingAfter,
30 | endingBefore,
31 | });
32 |
33 | return Response.json(chats);
34 | }
35 |
--------------------------------------------------------------------------------
/app/(chat)/api/suggestions/route.ts:
--------------------------------------------------------------------------------
1 | import { auth } from '@/app/(auth)/auth';
2 | import { getSuggestionsByDocumentId } from '@/lib/db/queries';
3 | import { ChatSDKError } from '@/lib/errors';
4 |
5 | export async function GET(request: Request) {
6 | const { searchParams } = new URL(request.url);
7 | const documentId = searchParams.get('documentId');
8 |
9 | if (!documentId) {
10 | return new ChatSDKError(
11 | 'bad_request:api',
12 | 'Parameter documentId is required.',
13 | ).toResponse();
14 | }
15 |
16 | const session = await auth();
17 |
18 | if (!session?.user) {
19 | return new ChatSDKError('unauthorized:suggestions').toResponse();
20 | }
21 |
22 | const suggestions = await getSuggestionsByDocumentId({
23 | documentId,
24 | });
25 |
26 | const [suggestion] = suggestions;
27 |
28 | if (!suggestion) {
29 | return Response.json([], { status: 200 });
30 | }
31 |
32 | if (suggestion.userId !== session.user.id) {
33 | return new ChatSDKError('forbidden:api').toResponse();
34 | }
35 |
36 | return Response.json(suggestions, { status: 200 });
37 | }
38 |
--------------------------------------------------------------------------------
/app/(chat)/api/vote/route.ts:
--------------------------------------------------------------------------------
1 | import { auth } from '@/app/(auth)/auth';
2 | import { getChatById, getVotesByChatId, voteMessage } from '@/lib/db/queries';
3 | import { ChatSDKError } from '@/lib/errors';
4 |
5 | export async function GET(request: Request) {
6 | const { searchParams } = new URL(request.url);
7 | const chatId = searchParams.get('chatId');
8 |
9 | if (!chatId) {
10 | return new ChatSDKError(
11 | 'bad_request:api',
12 | 'Parameter chatId is required.',
13 | ).toResponse();
14 | }
15 |
16 | const session = await auth();
17 |
18 | if (!session?.user) {
19 | return new ChatSDKError('unauthorized:vote').toResponse();
20 | }
21 |
22 | const chat = await getChatById({ id: chatId });
23 |
24 | if (!chat) {
25 | return new ChatSDKError('not_found:chat').toResponse();
26 | }
27 |
28 | if (chat.userId !== session.user.id) {
29 | return new ChatSDKError('forbidden:vote').toResponse();
30 | }
31 |
32 | const votes = await getVotesByChatId({ id: chatId });
33 |
34 | return Response.json(votes, { status: 200 });
35 | }
36 |
37 | export async function PATCH(request: Request) {
38 | const {
39 | chatId,
40 | messageId,
41 | type,
42 | }: { chatId: string; messageId: string; type: 'up' | 'down' } =
43 | await request.json();
44 |
45 | if (!chatId || !messageId || !type) {
46 | return new ChatSDKError(
47 | 'bad_request:api',
48 | 'Parameters chatId, messageId, and type are required.',
49 | ).toResponse();
50 | }
51 |
52 | const session = await auth();
53 |
54 | if (!session?.user) {
55 | return new ChatSDKError('unauthorized:vote').toResponse();
56 | }
57 |
58 | const chat = await getChatById({ id: chatId });
59 |
60 | if (!chat) {
61 | return new ChatSDKError('not_found:vote').toResponse();
62 | }
63 |
64 | if (chat.userId !== session.user.id) {
65 | return new ChatSDKError('forbidden:vote').toResponse();
66 | }
67 |
68 | await voteMessage({
69 | chatId,
70 | messageId,
71 | type: type,
72 | });
73 |
74 | return new Response('Message voted', { status: 200 });
75 | }
76 |
--------------------------------------------------------------------------------
/app/(chat)/chat/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import { cookies } from 'next/headers';
2 | import { notFound, redirect } from 'next/navigation';
3 |
4 | import { auth } from '@/app/(auth)/auth';
5 | import { Chat } from '@/components/chat';
6 | import { getChatById, getMessagesByChatId } from '@/lib/db/queries';
7 | import { DataStreamHandler } from '@/components/data-stream-handler';
8 | import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models';
9 | import type { DBMessage } from '@/lib/db/schema';
10 | import type { Attachment, UIMessage } from 'ai';
11 |
12 | export default async function Page(props: { params: Promise<{ id: string }> }) {
13 | const params = await props.params;
14 | const { id } = params;
15 | const chat = await getChatById({ id });
16 |
17 | if (!chat) {
18 | notFound();
19 | }
20 |
21 | const session = await auth();
22 |
23 | if (!session) {
24 | redirect('/api/auth/guest');
25 | }
26 |
27 | if (chat.visibility === 'private') {
28 | if (!session.user) {
29 | return notFound();
30 | }
31 |
32 | if (session.user.id !== chat.userId) {
33 | return notFound();
34 | }
35 | }
36 |
37 | const messagesFromDb = await getMessagesByChatId({
38 | id,
39 | });
40 |
41 | function convertToUIMessages(messages: Array): Array {
42 | return messages.map((message) => ({
43 | id: message.id,
44 | parts: message.parts as UIMessage['parts'],
45 | role: message.role as UIMessage['role'],
46 | // Note: content will soon be deprecated in @ai-sdk/react
47 | content: '',
48 | createdAt: message.createdAt,
49 | experimental_attachments:
50 | (message.attachments as Array) ?? [],
51 | }));
52 | }
53 |
54 | const cookieStore = await cookies();
55 | const chatModelFromCookie = cookieStore.get('chat-model');
56 |
57 | if (!chatModelFromCookie) {
58 | return (
59 | <>
60 |
69 |
70 | >
71 | );
72 | }
73 |
74 | return (
75 | <>
76 |
85 |
86 | >
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/app/(chat)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { cookies } from 'next/headers';
2 |
3 | import { AppSidebar } from '@/components/app-sidebar';
4 | import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
5 | import { auth } from '../(auth)/auth';
6 | import Script from 'next/script';
7 |
8 | export const experimental_ppr = true;
9 |
10 | export default async function Layout({
11 | children,
12 | }: {
13 | children: React.ReactNode;
14 | }) {
15 | const [session, cookieStore] = await Promise.all([auth(), cookies()]);
16 | const isCollapsed = cookieStore.get('sidebar:state')?.value !== 'true';
17 |
18 | return (
19 | <>
20 |
24 |
25 |
26 | {children}
27 |
28 | >
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/app/(chat)/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vercel/ai-chatbot/7d8e71383f55c766ca575da2cac0a8d89283c031/app/(chat)/opengraph-image.png
--------------------------------------------------------------------------------
/app/(chat)/page.tsx:
--------------------------------------------------------------------------------
1 | import { cookies } from 'next/headers';
2 |
3 | import { Chat } from '@/components/chat';
4 | import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models';
5 | import { generateUUID } from '@/lib/utils';
6 | import { DataStreamHandler } from '@/components/data-stream-handler';
7 | import { auth } from '../(auth)/auth';
8 | import { redirect } from 'next/navigation';
9 |
10 | export default async function Page() {
11 | const session = await auth();
12 |
13 | if (!session) {
14 | redirect('/api/auth/guest');
15 | }
16 |
17 | const id = generateUUID();
18 |
19 | const cookieStore = await cookies();
20 | const modelIdFromCookie = cookieStore.get('chat-model');
21 |
22 | if (!modelIdFromCookie) {
23 | return (
24 | <>
25 |
35 |
36 | >
37 | );
38 | }
39 |
40 | return (
41 | <>
42 |
52 |
53 | >
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/app/(chat)/twitter-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vercel/ai-chatbot/7d8e71383f55c766ca575da2cac0a8d89283c031/app/(chat)/twitter-image.png
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vercel/ai-chatbot/7d8e71383f55c766ca575da2cac0a8d89283c031/app/favicon.ico
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Toaster } from 'sonner';
2 | import type { Metadata } from 'next';
3 | import { Geist, Geist_Mono } from 'next/font/google';
4 | import { ThemeProvider } from '@/components/theme-provider';
5 |
6 | import './globals.css';
7 | import { SessionProvider } from 'next-auth/react';
8 |
9 | export const metadata: Metadata = {
10 | metadataBase: new URL('https://chat.vercel.ai'),
11 | title: 'Next.js Chatbot Template',
12 | description: 'Next.js chatbot template using the AI SDK.',
13 | };
14 |
15 | export const viewport = {
16 | maximumScale: 1, // Disable auto-zoom on mobile Safari
17 | };
18 |
19 | const geist = Geist({
20 | subsets: ['latin'],
21 | display: 'swap',
22 | variable: '--font-geist',
23 | });
24 |
25 | const geistMono = Geist_Mono({
26 | subsets: ['latin'],
27 | display: 'swap',
28 | variable: '--font-geist-mono',
29 | });
30 |
31 | const LIGHT_THEME_COLOR = 'hsl(0 0% 100%)';
32 | const DARK_THEME_COLOR = 'hsl(240deg 10% 3.92%)';
33 | const THEME_COLOR_SCRIPT = `\
34 | (function() {
35 | var html = document.documentElement;
36 | var meta = document.querySelector('meta[name="theme-color"]');
37 | if (!meta) {
38 | meta = document.createElement('meta');
39 | meta.setAttribute('name', 'theme-color');
40 | document.head.appendChild(meta);
41 | }
42 | function updateThemeColor() {
43 | var isDark = html.classList.contains('dark');
44 | meta.setAttribute('content', isDark ? '${DARK_THEME_COLOR}' : '${LIGHT_THEME_COLOR}');
45 | }
46 | var observer = new MutationObserver(updateThemeColor);
47 | observer.observe(html, { attributes: true, attributeFilter: ['class'] });
48 | updateThemeColor();
49 | })();`;
50 |
51 | export default async function RootLayout({
52 | children,
53 | }: Readonly<{
54 | children: React.ReactNode;
55 | }>) {
56 | return (
57 |
66 |
67 |
72 |
73 |
74 |
80 |
81 | {children}
82 |
83 |
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/artifacts/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | import { getSuggestionsByDocumentId } from '@/lib/db/queries';
4 |
5 | export async function getSuggestions({ documentId }: { documentId: string }) {
6 | const suggestions = await getSuggestionsByDocumentId({ documentId });
7 | return suggestions ?? [];
8 | }
9 |
--------------------------------------------------------------------------------
/artifacts/code/server.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 | import { streamObject } from 'ai';
3 | import { myProvider } from '@/lib/ai/providers';
4 | import { codePrompt, updateDocumentPrompt } from '@/lib/ai/prompts';
5 | import { createDocumentHandler } from '@/lib/artifacts/server';
6 |
7 | export const codeDocumentHandler = createDocumentHandler<'code'>({
8 | kind: 'code',
9 | onCreateDocument: async ({ title, dataStream }) => {
10 | let draftContent = '';
11 |
12 | const { fullStream } = streamObject({
13 | model: myProvider.languageModel('artifact-model'),
14 | system: codePrompt,
15 | prompt: title,
16 | schema: z.object({
17 | code: z.string(),
18 | }),
19 | });
20 |
21 | for await (const delta of fullStream) {
22 | const { type } = delta;
23 |
24 | if (type === 'object') {
25 | const { object } = delta;
26 | const { code } = object;
27 |
28 | if (code) {
29 | dataStream.writeData({
30 | type: 'code-delta',
31 | content: code ?? '',
32 | });
33 |
34 | draftContent = code;
35 | }
36 | }
37 | }
38 |
39 | return draftContent;
40 | },
41 | onUpdateDocument: async ({ document, description, dataStream }) => {
42 | let draftContent = '';
43 |
44 | const { fullStream } = streamObject({
45 | model: myProvider.languageModel('artifact-model'),
46 | system: updateDocumentPrompt(document.content, 'code'),
47 | prompt: description,
48 | schema: z.object({
49 | code: z.string(),
50 | }),
51 | });
52 |
53 | for await (const delta of fullStream) {
54 | const { type } = delta;
55 |
56 | if (type === 'object') {
57 | const { object } = delta;
58 | const { code } = object;
59 |
60 | if (code) {
61 | dataStream.writeData({
62 | type: 'code-delta',
63 | content: code ?? '',
64 | });
65 |
66 | draftContent = code;
67 | }
68 | }
69 | }
70 |
71 | return draftContent;
72 | },
73 | });
74 |
--------------------------------------------------------------------------------
/artifacts/image/client.tsx:
--------------------------------------------------------------------------------
1 | import { Artifact } from '@/components/create-artifact';
2 | import { CopyIcon, RedoIcon, UndoIcon } from '@/components/icons';
3 | import { ImageEditor } from '@/components/image-editor';
4 | import { toast } from 'sonner';
5 |
6 | export const imageArtifact = new Artifact({
7 | kind: 'image',
8 | description: 'Useful for image generation',
9 | onStreamPart: ({ streamPart, setArtifact }) => {
10 | if (streamPart.type === 'image-delta') {
11 | setArtifact((draftArtifact) => ({
12 | ...draftArtifact,
13 | content: streamPart.content as string,
14 | isVisible: true,
15 | status: 'streaming',
16 | }));
17 | }
18 | },
19 | content: ImageEditor,
20 | actions: [
21 | {
22 | icon: ,
23 | description: 'View Previous version',
24 | onClick: ({ handleVersionChange }) => {
25 | handleVersionChange('prev');
26 | },
27 | isDisabled: ({ currentVersionIndex }) => {
28 | if (currentVersionIndex === 0) {
29 | return true;
30 | }
31 |
32 | return false;
33 | },
34 | },
35 | {
36 | icon: ,
37 | description: 'View Next version',
38 | onClick: ({ handleVersionChange }) => {
39 | handleVersionChange('next');
40 | },
41 | isDisabled: ({ isCurrentVersion }) => {
42 | if (isCurrentVersion) {
43 | return true;
44 | }
45 |
46 | return false;
47 | },
48 | },
49 | {
50 | icon: ,
51 | description: 'Copy image to clipboard',
52 | onClick: ({ content }) => {
53 | const img = new Image();
54 | img.src = `data:image/png;base64,${content}`;
55 |
56 | img.onload = () => {
57 | const canvas = document.createElement('canvas');
58 | canvas.width = img.width;
59 | canvas.height = img.height;
60 | const ctx = canvas.getContext('2d');
61 | ctx?.drawImage(img, 0, 0);
62 | canvas.toBlob((blob) => {
63 | if (blob) {
64 | navigator.clipboard.write([
65 | new ClipboardItem({ 'image/png': blob }),
66 | ]);
67 | }
68 | }, 'image/png');
69 | };
70 |
71 | toast.success('Copied image to clipboard!');
72 | },
73 | },
74 | ],
75 | toolbar: [],
76 | });
77 |
--------------------------------------------------------------------------------
/artifacts/image/server.ts:
--------------------------------------------------------------------------------
1 | import { myProvider } from '@/lib/ai/providers';
2 | import { createDocumentHandler } from '@/lib/artifacts/server';
3 | import { experimental_generateImage } from 'ai';
4 |
5 | export const imageDocumentHandler = createDocumentHandler<'image'>({
6 | kind: 'image',
7 | onCreateDocument: async ({ title, dataStream }) => {
8 | let draftContent = '';
9 |
10 | const { image } = await experimental_generateImage({
11 | model: myProvider.imageModel('small-model'),
12 | prompt: title,
13 | n: 1,
14 | });
15 |
16 | draftContent = image.base64;
17 |
18 | dataStream.writeData({
19 | type: 'image-delta',
20 | content: image.base64,
21 | });
22 |
23 | return draftContent;
24 | },
25 | onUpdateDocument: async ({ description, dataStream }) => {
26 | let draftContent = '';
27 |
28 | const { image } = await experimental_generateImage({
29 | model: myProvider.imageModel('small-model'),
30 | prompt: description,
31 | n: 1,
32 | });
33 |
34 | draftContent = image.base64;
35 |
36 | dataStream.writeData({
37 | type: 'image-delta',
38 | content: image.base64,
39 | });
40 |
41 | return draftContent;
42 | },
43 | });
44 |
--------------------------------------------------------------------------------
/artifacts/sheet/client.tsx:
--------------------------------------------------------------------------------
1 | import { Artifact } from '@/components/create-artifact';
2 | import {
3 | CopyIcon,
4 | LineChartIcon,
5 | RedoIcon,
6 | SparklesIcon,
7 | UndoIcon,
8 | } from '@/components/icons';
9 | import { SpreadsheetEditor } from '@/components/sheet-editor';
10 | import { parse, unparse } from 'papaparse';
11 | import { toast } from 'sonner';
12 |
13 | type Metadata = any;
14 |
15 | export const sheetArtifact = new Artifact<'sheet', Metadata>({
16 | kind: 'sheet',
17 | description: 'Useful for working with spreadsheets',
18 | initialize: async () => {},
19 | onStreamPart: ({ setArtifact, streamPart }) => {
20 | if (streamPart.type === 'sheet-delta') {
21 | setArtifact((draftArtifact) => ({
22 | ...draftArtifact,
23 | content: streamPart.content as string,
24 | isVisible: true,
25 | status: 'streaming',
26 | }));
27 | }
28 | },
29 | content: ({
30 | content,
31 | currentVersionIndex,
32 | isCurrentVersion,
33 | onSaveContent,
34 | status,
35 | }) => {
36 | return (
37 |
44 | );
45 | },
46 | actions: [
47 | {
48 | icon: ,
49 | description: 'View Previous version',
50 | onClick: ({ handleVersionChange }) => {
51 | handleVersionChange('prev');
52 | },
53 | isDisabled: ({ currentVersionIndex }) => {
54 | if (currentVersionIndex === 0) {
55 | return true;
56 | }
57 |
58 | return false;
59 | },
60 | },
61 | {
62 | icon: ,
63 | description: 'View Next version',
64 | onClick: ({ handleVersionChange }) => {
65 | handleVersionChange('next');
66 | },
67 | isDisabled: ({ isCurrentVersion }) => {
68 | if (isCurrentVersion) {
69 | return true;
70 | }
71 |
72 | return false;
73 | },
74 | },
75 | {
76 | icon: ,
77 | description: 'Copy as .csv',
78 | onClick: ({ content }) => {
79 | const parsed = parse(content, { skipEmptyLines: true });
80 |
81 | const nonEmptyRows = parsed.data.filter((row) =>
82 | row.some((cell) => cell.trim() !== ''),
83 | );
84 |
85 | const cleanedCsv = unparse(nonEmptyRows);
86 |
87 | navigator.clipboard.writeText(cleanedCsv);
88 | toast.success('Copied csv to clipboard!');
89 | },
90 | },
91 | ],
92 | toolbar: [
93 | {
94 | description: 'Format and clean data',
95 | icon: ,
96 | onClick: ({ appendMessage }) => {
97 | appendMessage({
98 | role: 'user',
99 | content: 'Can you please format and clean the data?',
100 | });
101 | },
102 | },
103 | {
104 | description: 'Analyze and visualize data',
105 | icon: ,
106 | onClick: ({ appendMessage }) => {
107 | appendMessage({
108 | role: 'user',
109 | content:
110 | 'Can you please analyze and visualize the data by creating a new code artifact in python?',
111 | });
112 | },
113 | },
114 | ],
115 | });
116 |
--------------------------------------------------------------------------------
/artifacts/sheet/server.ts:
--------------------------------------------------------------------------------
1 | import { myProvider } from '@/lib/ai/providers';
2 | import { sheetPrompt, updateDocumentPrompt } from '@/lib/ai/prompts';
3 | import { createDocumentHandler } from '@/lib/artifacts/server';
4 | import { streamObject } from 'ai';
5 | import { z } from 'zod';
6 |
7 | export const sheetDocumentHandler = createDocumentHandler<'sheet'>({
8 | kind: 'sheet',
9 | onCreateDocument: async ({ title, dataStream }) => {
10 | let draftContent = '';
11 |
12 | const { fullStream } = streamObject({
13 | model: myProvider.languageModel('artifact-model'),
14 | system: sheetPrompt,
15 | prompt: title,
16 | schema: z.object({
17 | csv: z.string().describe('CSV data'),
18 | }),
19 | });
20 |
21 | for await (const delta of fullStream) {
22 | const { type } = delta;
23 |
24 | if (type === 'object') {
25 | const { object } = delta;
26 | const { csv } = object;
27 |
28 | if (csv) {
29 | dataStream.writeData({
30 | type: 'sheet-delta',
31 | content: csv,
32 | });
33 |
34 | draftContent = csv;
35 | }
36 | }
37 | }
38 |
39 | dataStream.writeData({
40 | type: 'sheet-delta',
41 | content: draftContent,
42 | });
43 |
44 | return draftContent;
45 | },
46 | onUpdateDocument: async ({ document, description, dataStream }) => {
47 | let draftContent = '';
48 |
49 | const { fullStream } = streamObject({
50 | model: myProvider.languageModel('artifact-model'),
51 | system: updateDocumentPrompt(document.content, 'sheet'),
52 | prompt: description,
53 | schema: z.object({
54 | csv: z.string(),
55 | }),
56 | });
57 |
58 | for await (const delta of fullStream) {
59 | const { type } = delta;
60 |
61 | if (type === 'object') {
62 | const { object } = delta;
63 | const { csv } = object;
64 |
65 | if (csv) {
66 | dataStream.writeData({
67 | type: 'sheet-delta',
68 | content: csv,
69 | });
70 |
71 | draftContent = csv;
72 | }
73 | }
74 | }
75 |
76 | return draftContent;
77 | },
78 | });
79 |
--------------------------------------------------------------------------------
/artifacts/text/server.ts:
--------------------------------------------------------------------------------
1 | import { smoothStream, streamText } from 'ai';
2 | import { myProvider } from '@/lib/ai/providers';
3 | import { createDocumentHandler } from '@/lib/artifacts/server';
4 | import { updateDocumentPrompt } from '@/lib/ai/prompts';
5 |
6 | export const textDocumentHandler = createDocumentHandler<'text'>({
7 | kind: 'text',
8 | onCreateDocument: async ({ title, dataStream }) => {
9 | let draftContent = '';
10 |
11 | const { fullStream } = streamText({
12 | model: myProvider.languageModel('artifact-model'),
13 | system:
14 | 'Write about the given topic. Markdown is supported. Use headings wherever appropriate.',
15 | experimental_transform: smoothStream({ chunking: 'word' }),
16 | prompt: title,
17 | });
18 |
19 | for await (const delta of fullStream) {
20 | const { type } = delta;
21 |
22 | if (type === 'text-delta') {
23 | const { textDelta } = delta;
24 |
25 | draftContent += textDelta;
26 |
27 | dataStream.writeData({
28 | type: 'text-delta',
29 | content: textDelta,
30 | });
31 | }
32 | }
33 |
34 | return draftContent;
35 | },
36 | onUpdateDocument: async ({ document, description, dataStream }) => {
37 | let draftContent = '';
38 |
39 | const { fullStream } = streamText({
40 | model: myProvider.languageModel('artifact-model'),
41 | system: updateDocumentPrompt(document.content, 'text'),
42 | experimental_transform: smoothStream({ chunking: 'word' }),
43 | prompt: description,
44 | experimental_providerMetadata: {
45 | openai: {
46 | prediction: {
47 | type: 'content',
48 | content: document.content,
49 | },
50 | },
51 | },
52 | });
53 |
54 | for await (const delta of fullStream) {
55 | const { type } = delta;
56 |
57 | if (type === 'text-delta') {
58 | const { textDelta } = delta;
59 |
60 | draftContent += textDelta;
61 | dataStream.writeData({
62 | type: 'text-delta',
63 | content: textDelta,
64 | });
65 | }
66 | }
67 |
68 | return draftContent;
69 | },
70 | });
71 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
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 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/components/app-sidebar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { User } from 'next-auth';
4 | import { useRouter } from 'next/navigation';
5 |
6 | import { PlusIcon } from '@/components/icons';
7 | import { SidebarHistory } from '@/components/sidebar-history';
8 | import { SidebarUserNav } from '@/components/sidebar-user-nav';
9 | import { Button } from '@/components/ui/button';
10 | import {
11 | Sidebar,
12 | SidebarContent,
13 | SidebarFooter,
14 | SidebarHeader,
15 | SidebarMenu,
16 | useSidebar,
17 | } from '@/components/ui/sidebar';
18 | import Link from 'next/link';
19 | import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
20 |
21 | export function AppSidebar({ user }: { user: User | undefined }) {
22 | const router = useRouter();
23 | const { setOpenMobile } = useSidebar();
24 |
25 | return (
26 |
27 |
28 |
29 |
30 |
{
33 | setOpenMobile(false);
34 | }}
35 | className="flex flex-row gap-3 items-center"
36 | >
37 |
38 | Chatbot
39 |
40 |
41 |
42 |
43 |
55 |
56 | New Chat
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | {user && }
65 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/components/artifact-actions.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from './ui/button';
2 | import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
3 | import { artifactDefinitions, UIArtifact } from './artifact';
4 | import { Dispatch, memo, SetStateAction, useState } from 'react';
5 | import { ArtifactActionContext } from './create-artifact';
6 | import { cn } from '@/lib/utils';
7 | import { toast } from 'sonner';
8 |
9 | interface ArtifactActionsProps {
10 | artifact: UIArtifact;
11 | handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void;
12 | currentVersionIndex: number;
13 | isCurrentVersion: boolean;
14 | mode: 'edit' | 'diff';
15 | metadata: any;
16 | setMetadata: Dispatch>;
17 | }
18 |
19 | function PureArtifactActions({
20 | artifact,
21 | handleVersionChange,
22 | currentVersionIndex,
23 | isCurrentVersion,
24 | mode,
25 | metadata,
26 | setMetadata,
27 | }: ArtifactActionsProps) {
28 | const [isLoading, setIsLoading] = useState(false);
29 |
30 | const artifactDefinition = artifactDefinitions.find(
31 | (definition) => definition.kind === artifact.kind,
32 | );
33 |
34 | if (!artifactDefinition) {
35 | throw new Error('Artifact definition not found!');
36 | }
37 |
38 | const actionContext: ArtifactActionContext = {
39 | content: artifact.content,
40 | handleVersionChange,
41 | currentVersionIndex,
42 | isCurrentVersion,
43 | mode,
44 | metadata,
45 | setMetadata,
46 | };
47 |
48 | return (
49 |
50 | {artifactDefinition.actions.map((action) => (
51 |
52 |
53 |
81 |
82 | {action.description}
83 |
84 | ))}
85 |
86 | );
87 | }
88 |
89 | export const ArtifactActions = memo(
90 | PureArtifactActions,
91 | (prevProps, nextProps) => {
92 | if (prevProps.artifact.status !== nextProps.artifact.status) return false;
93 | if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex)
94 | return false;
95 | if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) return false;
96 | if (prevProps.artifact.content !== nextProps.artifact.content) return false;
97 |
98 | return true;
99 | },
100 | );
101 |
--------------------------------------------------------------------------------
/components/artifact-close-button.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { CrossIcon } from './icons';
3 | import { Button } from './ui/button';
4 | import { initialArtifactData, useArtifact } from '@/hooks/use-artifact';
5 |
6 | function PureArtifactCloseButton() {
7 | const { setArtifact } = useArtifact();
8 |
9 | return (
10 |
27 | );
28 | }
29 |
30 | export const ArtifactCloseButton = memo(PureArtifactCloseButton, () => true);
31 |
--------------------------------------------------------------------------------
/components/artifact-messages.tsx:
--------------------------------------------------------------------------------
1 | import { PreviewMessage, ThinkingMessage } from './message';
2 | import type { Vote } from '@/lib/db/schema';
3 | import type { UIMessage } from 'ai';
4 | import { memo } from 'react';
5 | import equal from 'fast-deep-equal';
6 | import type { UIArtifact } from './artifact';
7 | import type { UseChatHelpers } from '@ai-sdk/react';
8 | import { motion } from 'framer-motion';
9 | import { useMessages } from '@/hooks/use-messages';
10 |
11 | interface ArtifactMessagesProps {
12 | chatId: string;
13 | status: UseChatHelpers['status'];
14 | votes: Array | undefined;
15 | messages: Array;
16 | setMessages: UseChatHelpers['setMessages'];
17 | reload: UseChatHelpers['reload'];
18 | isReadonly: boolean;
19 | artifactStatus: UIArtifact['status'];
20 | }
21 |
22 | function PureArtifactMessages({
23 | chatId,
24 | status,
25 | votes,
26 | messages,
27 | setMessages,
28 | reload,
29 | isReadonly,
30 | }: ArtifactMessagesProps) {
31 | const {
32 | containerRef: messagesContainerRef,
33 | endRef: messagesEndRef,
34 | onViewportEnter,
35 | onViewportLeave,
36 | hasSentMessage,
37 | } = useMessages({
38 | chatId,
39 | status,
40 | });
41 |
42 | return (
43 |
47 | {messages.map((message, index) => (
48 |
vote.messageId === message.id)
56 | : undefined
57 | }
58 | setMessages={setMessages}
59 | reload={reload}
60 | isReadonly={isReadonly}
61 | requiresScrollPadding={
62 | hasSentMessage && index === messages.length - 1
63 | }
64 | />
65 | ))}
66 |
67 | {status === 'submitted' &&
68 | messages.length > 0 &&
69 | messages[messages.length - 1].role === 'user' && }
70 |
71 |
77 |
78 | );
79 | }
80 |
81 | function areEqual(
82 | prevProps: ArtifactMessagesProps,
83 | nextProps: ArtifactMessagesProps,
84 | ) {
85 | if (
86 | prevProps.artifactStatus === 'streaming' &&
87 | nextProps.artifactStatus === 'streaming'
88 | )
89 | return true;
90 |
91 | if (prevProps.status !== nextProps.status) return false;
92 | if (prevProps.status && nextProps.status) return false;
93 | if (prevProps.messages.length !== nextProps.messages.length) return false;
94 | if (!equal(prevProps.votes, nextProps.votes)) return false;
95 |
96 | return true;
97 | }
98 |
99 | export const ArtifactMessages = memo(PureArtifactMessages, areEqual);
100 |
--------------------------------------------------------------------------------
/components/auth-form.tsx:
--------------------------------------------------------------------------------
1 | import Form from 'next/form';
2 |
3 | import { Input } from './ui/input';
4 | import { Label } from './ui/label';
5 |
6 | export function AuthForm({
7 | action,
8 | children,
9 | defaultEmail = '',
10 | }: {
11 | action: NonNullable<
12 | string | ((formData: FormData) => void | Promise) | undefined
13 | >;
14 | children: React.ReactNode;
15 | defaultEmail?: string;
16 | }) {
17 | return (
18 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/components/chat-header.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Link from 'next/link';
4 | import { useRouter } from 'next/navigation';
5 | import { useWindowSize } from 'usehooks-ts';
6 |
7 | import { ModelSelector } from '@/components/model-selector';
8 | import { SidebarToggle } from '@/components/sidebar-toggle';
9 | import { Button } from '@/components/ui/button';
10 | import { PlusIcon, VercelIcon } from './icons';
11 | import { useSidebar } from './ui/sidebar';
12 | import { memo } from 'react';
13 | import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
14 | import { type VisibilityType, VisibilitySelector } from './visibility-selector';
15 | import type { Session } from 'next-auth';
16 |
17 | function PureChatHeader({
18 | chatId,
19 | selectedModelId,
20 | selectedVisibilityType,
21 | isReadonly,
22 | session,
23 | }: {
24 | chatId: string;
25 | selectedModelId: string;
26 | selectedVisibilityType: VisibilityType;
27 | isReadonly: boolean;
28 | session: Session;
29 | }) {
30 | const router = useRouter();
31 | const { open } = useSidebar();
32 |
33 | const { width: windowWidth } = useWindowSize();
34 |
35 | return (
36 |
37 |
38 |
39 | {(!open || windowWidth < 768) && (
40 |
41 |
42 |
53 |
54 | New Chat
55 |
56 | )}
57 |
58 | {!isReadonly && (
59 |
64 | )}
65 |
66 | {!isReadonly && (
67 |
72 | )}
73 |
74 |
86 |
87 | );
88 | }
89 |
90 | export const ChatHeader = memo(PureChatHeader, (prevProps, nextProps) => {
91 | return prevProps.selectedModelId === nextProps.selectedModelId;
92 | });
93 |
--------------------------------------------------------------------------------
/components/code-block.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | interface CodeBlockProps {
4 | node: any;
5 | inline: boolean;
6 | className: string;
7 | children: any;
8 | }
9 |
10 | export function CodeBlock({
11 | node,
12 | inline,
13 | className,
14 | children,
15 | ...props
16 | }: CodeBlockProps) {
17 | if (!inline) {
18 | return (
19 |
20 |
24 | {children}
25 |
26 |
27 | );
28 | } else {
29 | return (
30 |
34 | {children}
35 |
36 | );
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/components/code-editor.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { EditorView } from '@codemirror/view';
4 | import { EditorState, Transaction } from '@codemirror/state';
5 | import { python } from '@codemirror/lang-python';
6 | import { oneDark } from '@codemirror/theme-one-dark';
7 | import { basicSetup } from 'codemirror';
8 | import React, { memo, useEffect, useRef } from 'react';
9 | import { Suggestion } from '@/lib/db/schema';
10 |
11 | type EditorProps = {
12 | content: string;
13 | onSaveContent: (updatedContent: string, debounce: boolean) => void;
14 | status: 'streaming' | 'idle';
15 | isCurrentVersion: boolean;
16 | currentVersionIndex: number;
17 | suggestions: Array;
18 | };
19 |
20 | function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
21 | const containerRef = useRef(null);
22 | const editorRef = useRef(null);
23 |
24 | useEffect(() => {
25 | if (containerRef.current && !editorRef.current) {
26 | const startState = EditorState.create({
27 | doc: content,
28 | extensions: [basicSetup, python(), oneDark],
29 | });
30 |
31 | editorRef.current = new EditorView({
32 | state: startState,
33 | parent: containerRef.current,
34 | });
35 | }
36 |
37 | return () => {
38 | if (editorRef.current) {
39 | editorRef.current.destroy();
40 | editorRef.current = null;
41 | }
42 | };
43 | // NOTE: we only want to run this effect once
44 | // eslint-disable-next-line
45 | }, []);
46 |
47 | useEffect(() => {
48 | if (editorRef.current) {
49 | const updateListener = EditorView.updateListener.of((update) => {
50 | if (update.docChanged) {
51 | const transaction = update.transactions.find(
52 | (tr) => !tr.annotation(Transaction.remote),
53 | );
54 |
55 | if (transaction) {
56 | const newContent = update.state.doc.toString();
57 | onSaveContent(newContent, true);
58 | }
59 | }
60 | });
61 |
62 | const currentSelection = editorRef.current.state.selection;
63 |
64 | const newState = EditorState.create({
65 | doc: editorRef.current.state.doc,
66 | extensions: [basicSetup, python(), oneDark, updateListener],
67 | selection: currentSelection,
68 | });
69 |
70 | editorRef.current.setState(newState);
71 | }
72 | }, [onSaveContent]);
73 |
74 | useEffect(() => {
75 | if (editorRef.current && content) {
76 | const currentContent = editorRef.current.state.doc.toString();
77 |
78 | if (status === 'streaming' || currentContent !== content) {
79 | const transaction = editorRef.current.state.update({
80 | changes: {
81 | from: 0,
82 | to: currentContent.length,
83 | insert: content,
84 | },
85 | annotations: [Transaction.remote.of(true)],
86 | });
87 |
88 | editorRef.current.dispatch(transaction);
89 | }
90 | }
91 | }, [content, status]);
92 |
93 | return (
94 |
98 | );
99 | }
100 |
101 | function areEqual(prevProps: EditorProps, nextProps: EditorProps) {
102 | if (prevProps.suggestions !== nextProps.suggestions) return false;
103 | if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex)
104 | return false;
105 | if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) return false;
106 | if (prevProps.status === 'streaming' && nextProps.status === 'streaming')
107 | return false;
108 | if (prevProps.content !== nextProps.content) return false;
109 |
110 | return true;
111 | }
112 |
113 | export const CodeEditor = memo(PureCodeEditor, areEqual);
114 |
--------------------------------------------------------------------------------
/components/create-artifact.tsx:
--------------------------------------------------------------------------------
1 | import { Suggestion } from '@/lib/db/schema';
2 | import { UseChatHelpers } from '@ai-sdk/react';
3 | import { ComponentType, Dispatch, ReactNode, SetStateAction } from 'react';
4 | import { DataStreamDelta } from './data-stream-handler';
5 | import { UIArtifact } from './artifact';
6 |
7 | export type ArtifactActionContext = {
8 | content: string;
9 | handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void;
10 | currentVersionIndex: number;
11 | isCurrentVersion: boolean;
12 | mode: 'edit' | 'diff';
13 | metadata: M;
14 | setMetadata: Dispatch>;
15 | };
16 |
17 | type ArtifactAction = {
18 | icon: ReactNode;
19 | label?: string;
20 | description: string;
21 | onClick: (context: ArtifactActionContext) => Promise | void;
22 | isDisabled?: (context: ArtifactActionContext) => boolean;
23 | };
24 |
25 | export type ArtifactToolbarContext = {
26 | appendMessage: UseChatHelpers['append'];
27 | };
28 |
29 | export type ArtifactToolbarItem = {
30 | description: string;
31 | icon: ReactNode;
32 | onClick: (context: ArtifactToolbarContext) => void;
33 | };
34 |
35 | interface ArtifactContent {
36 | title: string;
37 | content: string;
38 | mode: 'edit' | 'diff';
39 | isCurrentVersion: boolean;
40 | currentVersionIndex: number;
41 | status: 'streaming' | 'idle';
42 | suggestions: Array;
43 | onSaveContent: (updatedContent: string, debounce: boolean) => void;
44 | isInline: boolean;
45 | getDocumentContentById: (index: number) => string;
46 | isLoading: boolean;
47 | metadata: M;
48 | setMetadata: Dispatch>;
49 | }
50 |
51 | interface InitializeParameters {
52 | documentId: string;
53 | setMetadata: Dispatch>;
54 | }
55 |
56 | type ArtifactConfig = {
57 | kind: T;
58 | description: string;
59 | content: ComponentType>;
60 | actions: Array>;
61 | toolbar: ArtifactToolbarItem[];
62 | initialize?: (parameters: InitializeParameters) => void;
63 | onStreamPart: (args: {
64 | setMetadata: Dispatch>;
65 | setArtifact: Dispatch>;
66 | streamPart: DataStreamDelta;
67 | }) => void;
68 | };
69 |
70 | export class Artifact {
71 | readonly kind: T;
72 | readonly description: string;
73 | readonly content: ComponentType>;
74 | readonly actions: Array>;
75 | readonly toolbar: ArtifactToolbarItem[];
76 | readonly initialize?: (parameters: InitializeParameters) => void;
77 | readonly onStreamPart: (args: {
78 | setMetadata: Dispatch>;
79 | setArtifact: Dispatch>;
80 | streamPart: DataStreamDelta;
81 | }) => void;
82 |
83 | constructor(config: ArtifactConfig) {
84 | this.kind = config.kind;
85 | this.description = config.description;
86 | this.content = config.content;
87 | this.actions = config.actions || [];
88 | this.toolbar = config.toolbar || [];
89 | this.initialize = config.initialize || (async () => ({}));
90 | this.onStreamPart = config.onStreamPart;
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/components/data-stream-handler.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useChat } from '@ai-sdk/react';
4 | import { useEffect, useRef } from 'react';
5 | import { artifactDefinitions, ArtifactKind } from './artifact';
6 | import { Suggestion } from '@/lib/db/schema';
7 | import { initialArtifactData, useArtifact } from '@/hooks/use-artifact';
8 |
9 | export type DataStreamDelta = {
10 | type:
11 | | 'text-delta'
12 | | 'code-delta'
13 | | 'sheet-delta'
14 | | 'image-delta'
15 | | 'title'
16 | | 'id'
17 | | 'suggestion'
18 | | 'clear'
19 | | 'finish'
20 | | 'kind';
21 | content: string | Suggestion;
22 | };
23 |
24 | export function DataStreamHandler({ id }: { id: string }) {
25 | const { data: dataStream } = useChat({ id });
26 | const { artifact, setArtifact, setMetadata } = useArtifact();
27 | const lastProcessedIndex = useRef(-1);
28 |
29 | useEffect(() => {
30 | if (!dataStream?.length) return;
31 |
32 | const newDeltas = dataStream.slice(lastProcessedIndex.current + 1);
33 | lastProcessedIndex.current = dataStream.length - 1;
34 |
35 | (newDeltas as DataStreamDelta[]).forEach((delta: DataStreamDelta) => {
36 | const artifactDefinition = artifactDefinitions.find(
37 | (artifactDefinition) => artifactDefinition.kind === artifact.kind,
38 | );
39 |
40 | if (artifactDefinition?.onStreamPart) {
41 | artifactDefinition.onStreamPart({
42 | streamPart: delta,
43 | setArtifact,
44 | setMetadata,
45 | });
46 | }
47 |
48 | setArtifact((draftArtifact) => {
49 | if (!draftArtifact) {
50 | return { ...initialArtifactData, status: 'streaming' };
51 | }
52 |
53 | switch (delta.type) {
54 | case 'id':
55 | return {
56 | ...draftArtifact,
57 | documentId: delta.content as string,
58 | status: 'streaming',
59 | };
60 |
61 | case 'title':
62 | return {
63 | ...draftArtifact,
64 | title: delta.content as string,
65 | status: 'streaming',
66 | };
67 |
68 | case 'kind':
69 | return {
70 | ...draftArtifact,
71 | kind: delta.content as ArtifactKind,
72 | status: 'streaming',
73 | };
74 |
75 | case 'clear':
76 | return {
77 | ...draftArtifact,
78 | content: '',
79 | status: 'streaming',
80 | };
81 |
82 | case 'finish':
83 | return {
84 | ...draftArtifact,
85 | status: 'idle',
86 | };
87 |
88 | default:
89 | return draftArtifact;
90 | }
91 | });
92 | });
93 | }, [dataStream, setArtifact, setMetadata, artifact]);
94 |
95 | return null;
96 | }
97 |
--------------------------------------------------------------------------------
/components/diffview.tsx:
--------------------------------------------------------------------------------
1 | import OrderedMap from 'orderedmap';
2 | import {
3 | Schema,
4 | type Node as ProsemirrorNode,
5 | type MarkSpec,
6 | DOMParser,
7 | } from 'prosemirror-model';
8 | import { schema } from 'prosemirror-schema-basic';
9 | import { addListNodes } from 'prosemirror-schema-list';
10 | import { EditorState } from 'prosemirror-state';
11 | import { EditorView } from 'prosemirror-view';
12 | import React, { useEffect, useRef } from 'react';
13 | import { renderToString } from 'react-dom/server';
14 | import ReactMarkdown from 'react-markdown';
15 |
16 | import { diffEditor, DiffType } from '@/lib/editor/diff';
17 |
18 | const diffSchema = new Schema({
19 | nodes: addListNodes(schema.spec.nodes, 'paragraph block*', 'block'),
20 | marks: OrderedMap.from({
21 | ...schema.spec.marks.toObject(),
22 | diffMark: {
23 | attrs: { type: { default: '' } },
24 | toDOM(mark) {
25 | let className = '';
26 |
27 | switch (mark.attrs.type) {
28 | case DiffType.Inserted:
29 | className =
30 | 'bg-green-100 text-green-700 dark:bg-green-500/70 dark:text-green-300';
31 | break;
32 | case DiffType.Deleted:
33 | className =
34 | 'bg-red-100 line-through text-red-600 dark:bg-red-500/70 dark:text-red-300';
35 | break;
36 | default:
37 | className = '';
38 | }
39 | return ['span', { class: className }, 0];
40 | },
41 | } as MarkSpec,
42 | }),
43 | });
44 |
45 | function computeDiff(oldDoc: ProsemirrorNode, newDoc: ProsemirrorNode) {
46 | return diffEditor(diffSchema, oldDoc.toJSON(), newDoc.toJSON());
47 | }
48 |
49 | type DiffEditorProps = {
50 | oldContent: string;
51 | newContent: string;
52 | };
53 |
54 | export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
55 | const editorRef = useRef(null);
56 | const viewRef = useRef(null);
57 |
58 | useEffect(() => {
59 | if (editorRef.current && !viewRef.current) {
60 | const parser = DOMParser.fromSchema(diffSchema);
61 |
62 | const oldHtmlContent = renderToString(
63 | {oldContent},
64 | );
65 | const newHtmlContent = renderToString(
66 | {newContent},
67 | );
68 |
69 | const oldContainer = document.createElement('div');
70 | oldContainer.innerHTML = oldHtmlContent;
71 |
72 | const newContainer = document.createElement('div');
73 | newContainer.innerHTML = newHtmlContent;
74 |
75 | const oldDoc = parser.parse(oldContainer);
76 | const newDoc = parser.parse(newContainer);
77 |
78 | const diffedDoc = computeDiff(oldDoc, newDoc);
79 |
80 | const state = EditorState.create({
81 | doc: diffedDoc,
82 | plugins: [],
83 | });
84 |
85 | viewRef.current = new EditorView(editorRef.current, {
86 | state,
87 | editable: () => false,
88 | });
89 | }
90 |
91 | return () => {
92 | if (viewRef.current) {
93 | viewRef.current.destroy();
94 | viewRef.current = null;
95 | }
96 | };
97 | }, [oldContent, newContent]);
98 |
99 | return ;
100 | };
101 |
--------------------------------------------------------------------------------
/components/document-skeleton.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ArtifactKind } from './artifact';
4 |
5 | export const DocumentSkeleton = ({
6 | artifactKind,
7 | }: {
8 | artifactKind: ArtifactKind;
9 | }) => {
10 | return artifactKind === 'image' ? (
11 |
14 | ) : (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | );
25 | };
26 |
27 | export const InlineDocumentSkeleton = () => {
28 | return (
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/components/greeting.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 |
3 | export const Greeting = () => {
4 | return (
5 |
9 |
16 | Hello there!
17 |
18 |
25 | How can I help you today?
26 |
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/components/image-editor.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderIcon } from './icons';
2 | import cn from 'classnames';
3 |
4 | interface ImageEditorProps {
5 | title: string;
6 | content: string;
7 | isCurrentVersion: boolean;
8 | currentVersionIndex: number;
9 | status: string;
10 | isInline: boolean;
11 | }
12 |
13 | export function ImageEditor({
14 | title,
15 | content,
16 | status,
17 | isInline,
18 | }: ImageEditorProps) {
19 | return (
20 |
26 | {status === 'streaming' ? (
27 |
28 | {!isInline && (
29 |
30 |
31 |
32 | )}
33 |
Generating Image...
34 |
35 | ) : (
36 |
37 |
44 |
45 | )}
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/components/markdown.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import React, { memo } from 'react';
3 | import ReactMarkdown, { type Components } from 'react-markdown';
4 | import remarkGfm from 'remark-gfm';
5 | import { CodeBlock } from './code-block';
6 |
7 | const components: Partial = {
8 | // @ts-expect-error
9 | code: CodeBlock,
10 | pre: ({ children }) => <>{children}>,
11 | ol: ({ node, children, ...props }) => {
12 | return (
13 |
14 | {children}
15 |
16 | );
17 | },
18 | li: ({ node, children, ...props }) => {
19 | return (
20 |
21 | {children}
22 |
23 | );
24 | },
25 | ul: ({ node, children, ...props }) => {
26 | return (
27 |
30 | );
31 | },
32 | strong: ({ node, children, ...props }) => {
33 | return (
34 |
35 | {children}
36 |
37 | );
38 | },
39 | a: ({ node, children, ...props }) => {
40 | return (
41 | // @ts-expect-error
42 |
48 | {children}
49 |
50 | );
51 | },
52 | h1: ({ node, children, ...props }) => {
53 | return (
54 |
55 | {children}
56 |
57 | );
58 | },
59 | h2: ({ node, children, ...props }) => {
60 | return (
61 |
62 | {children}
63 |
64 | );
65 | },
66 | h3: ({ node, children, ...props }) => {
67 | return (
68 |
69 | {children}
70 |
71 | );
72 | },
73 | h4: ({ node, children, ...props }) => {
74 | return (
75 |
76 | {children}
77 |
78 | );
79 | },
80 | h5: ({ node, children, ...props }) => {
81 | return (
82 |
83 | {children}
84 |
85 | );
86 | },
87 | h6: ({ node, children, ...props }) => {
88 | return (
89 |
90 | {children}
91 |
92 | );
93 | },
94 | };
95 |
96 | const remarkPlugins = [remarkGfm];
97 |
98 | const NonMemoizedMarkdown = ({ children }: { children: string }) => {
99 | return (
100 |
101 | {children}
102 |
103 | );
104 | };
105 |
106 | export const Markdown = memo(
107 | NonMemoizedMarkdown,
108 | (prevProps, nextProps) => prevProps.children === nextProps.children,
109 | );
110 |
--------------------------------------------------------------------------------
/components/message-editor.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ChatRequestOptions, Message } from 'ai';
4 | import { Button } from './ui/button';
5 | import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
6 | import { Textarea } from './ui/textarea';
7 | import { deleteTrailingMessages } from '@/app/(chat)/actions';
8 | import { UseChatHelpers } from '@ai-sdk/react';
9 |
10 | export type MessageEditorProps = {
11 | message: Message;
12 | setMode: Dispatch>;
13 | setMessages: UseChatHelpers['setMessages'];
14 | reload: UseChatHelpers['reload'];
15 | };
16 |
17 | export function MessageEditor({
18 | message,
19 | setMode,
20 | setMessages,
21 | reload,
22 | }: MessageEditorProps) {
23 | const [isSubmitting, setIsSubmitting] = useState(false);
24 |
25 | const [draftContent, setDraftContent] = useState(message.content);
26 | const textareaRef = useRef(null);
27 |
28 | useEffect(() => {
29 | if (textareaRef.current) {
30 | adjustHeight();
31 | }
32 | }, []);
33 |
34 | const adjustHeight = () => {
35 | if (textareaRef.current) {
36 | textareaRef.current.style.height = 'auto';
37 | textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
38 | }
39 | };
40 |
41 | const handleInput = (event: React.ChangeEvent) => {
42 | setDraftContent(event.target.value);
43 | adjustHeight();
44 | };
45 |
46 | return (
47 |
48 |
55 |
56 |
57 |
66 |
101 |
102 |
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/components/message-reasoning.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { ChevronDownIcon, LoaderIcon } from './icons';
5 | import { motion, AnimatePresence } from 'framer-motion';
6 | import { Markdown } from './markdown';
7 |
8 | interface MessageReasoningProps {
9 | isLoading: boolean;
10 | reasoning: string;
11 | }
12 |
13 | export function MessageReasoning({
14 | isLoading,
15 | reasoning,
16 | }: MessageReasoningProps) {
17 | const [isExpanded, setIsExpanded] = useState(true);
18 |
19 | const variants = {
20 | collapsed: {
21 | height: 0,
22 | opacity: 0,
23 | marginTop: 0,
24 | marginBottom: 0,
25 | },
26 | expanded: {
27 | height: 'auto',
28 | opacity: 1,
29 | marginTop: '1rem',
30 | marginBottom: '0.5rem',
31 | },
32 | };
33 |
34 | return (
35 |
36 | {isLoading ? (
37 |
38 |
Reasoning
39 |
40 |
41 |
42 |
43 | ) : (
44 |
45 |
Reasoned for a few seconds
46 |
56 |
57 | )}
58 |
59 |
60 | {isExpanded && (
61 |
72 | {reasoning}
73 |
74 | )}
75 |
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/components/messages.tsx:
--------------------------------------------------------------------------------
1 | import type { UIMessage } from 'ai';
2 | import { PreviewMessage, ThinkingMessage } from './message';
3 | import { Greeting } from './greeting';
4 | import { memo } from 'react';
5 | import type { Vote } from '@/lib/db/schema';
6 | import equal from 'fast-deep-equal';
7 | import type { UseChatHelpers } from '@ai-sdk/react';
8 | import { motion } from 'framer-motion';
9 | import { useMessages } from '@/hooks/use-messages';
10 |
11 | interface MessagesProps {
12 | chatId: string;
13 | status: UseChatHelpers['status'];
14 | votes: Array | undefined;
15 | messages: Array;
16 | setMessages: UseChatHelpers['setMessages'];
17 | reload: UseChatHelpers['reload'];
18 | isReadonly: boolean;
19 | isArtifactVisible: boolean;
20 | }
21 |
22 | function PureMessages({
23 | chatId,
24 | status,
25 | votes,
26 | messages,
27 | setMessages,
28 | reload,
29 | isReadonly,
30 | }: MessagesProps) {
31 | const {
32 | containerRef: messagesContainerRef,
33 | endRef: messagesEndRef,
34 | onViewportEnter,
35 | onViewportLeave,
36 | hasSentMessage,
37 | } = useMessages({
38 | chatId,
39 | status,
40 | });
41 |
42 | return (
43 |
47 | {messages.length === 0 &&
}
48 |
49 | {messages.map((message, index) => (
50 |
vote.messageId === message.id)
58 | : undefined
59 | }
60 | setMessages={setMessages}
61 | reload={reload}
62 | isReadonly={isReadonly}
63 | requiresScrollPadding={
64 | hasSentMessage && index === messages.length - 1
65 | }
66 | />
67 | ))}
68 |
69 | {status === 'submitted' &&
70 | messages.length > 0 &&
71 | messages[messages.length - 1].role === 'user' && }
72 |
73 |
79 |
80 | );
81 | }
82 |
83 | export const Messages = memo(PureMessages, (prevProps, nextProps) => {
84 | if (prevProps.isArtifactVisible && nextProps.isArtifactVisible) return true;
85 |
86 | if (prevProps.status !== nextProps.status) return false;
87 | if (prevProps.status && nextProps.status) return false;
88 | if (prevProps.messages.length !== nextProps.messages.length) return false;
89 | if (!equal(prevProps.messages, nextProps.messages)) return false;
90 | if (!equal(prevProps.votes, nextProps.votes)) return false;
91 |
92 | return true;
93 | });
94 |
--------------------------------------------------------------------------------
/components/model-selector.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { startTransition, useMemo, useOptimistic, useState } from 'react';
4 |
5 | import { saveChatModelAsCookie } from '@/app/(chat)/actions';
6 | import { Button } from '@/components/ui/button';
7 | import {
8 | DropdownMenu,
9 | DropdownMenuContent,
10 | DropdownMenuItem,
11 | DropdownMenuTrigger,
12 | } from '@/components/ui/dropdown-menu';
13 | import { chatModels } from '@/lib/ai/models';
14 | import { cn } from '@/lib/utils';
15 |
16 | import { CheckCircleFillIcon, ChevronDownIcon } from './icons';
17 | import { entitlementsByUserType } from '@/lib/ai/entitlements';
18 | import type { Session } from 'next-auth';
19 |
20 | export function ModelSelector({
21 | session,
22 | selectedModelId,
23 | className,
24 | }: {
25 | session: Session;
26 | selectedModelId: string;
27 | } & React.ComponentProps) {
28 | const [open, setOpen] = useState(false);
29 | const [optimisticModelId, setOptimisticModelId] =
30 | useOptimistic(selectedModelId);
31 |
32 | const userType = session.user.type;
33 | const { availableChatModelIds } = entitlementsByUserType[userType];
34 |
35 | const availableChatModels = chatModels.filter((chatModel) =>
36 | availableChatModelIds.includes(chatModel.id),
37 | );
38 |
39 | const selectedChatModel = useMemo(
40 | () =>
41 | availableChatModels.find(
42 | (chatModel) => chatModel.id === optimisticModelId,
43 | ),
44 | [optimisticModelId, availableChatModels],
45 | );
46 |
47 | return (
48 |
49 |
56 |
64 |
65 |
66 | {availableChatModels.map((chatModel) => {
67 | const { id } = chatModel;
68 |
69 | return (
70 | {
74 | setOpen(false);
75 |
76 | startTransition(() => {
77 | setOptimisticModelId(id);
78 | saveChatModelAsCookie(id);
79 | });
80 | }}
81 | data-active={id === optimisticModelId}
82 | asChild
83 | >
84 |
99 |
100 | );
101 | })}
102 |
103 |
104 | );
105 | }
106 |
--------------------------------------------------------------------------------
/components/preview-attachment.tsx:
--------------------------------------------------------------------------------
1 | import type { Attachment } from 'ai';
2 |
3 | import { LoaderIcon } from './icons';
4 |
5 | export const PreviewAttachment = ({
6 | attachment,
7 | isUploading = false,
8 | }: {
9 | attachment: Attachment;
10 | isUploading?: boolean;
11 | }) => {
12 | const { name, url, contentType } = attachment;
13 |
14 | return (
15 |
16 |
17 | {contentType ? (
18 | contentType.startsWith('image') ? (
19 | // NOTE: it is recommended to use next/image for images
20 | // eslint-disable-next-line @next/next/no-img-element
21 |

27 | ) : (
28 |
29 | )
30 | ) : (
31 |
32 | )}
33 |
34 | {isUploading && (
35 |
39 |
40 |
41 | )}
42 |
43 |
{name}
44 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/components/sidebar-history-item.tsx:
--------------------------------------------------------------------------------
1 | import type { Chat } from '@/lib/db/schema';
2 | import {
3 | SidebarMenuAction,
4 | SidebarMenuButton,
5 | SidebarMenuItem,
6 | } from './ui/sidebar';
7 | import Link from 'next/link';
8 | import {
9 | DropdownMenu,
10 | DropdownMenuContent,
11 | DropdownMenuItem,
12 | DropdownMenuPortal,
13 | DropdownMenuSub,
14 | DropdownMenuSubContent,
15 | DropdownMenuSubTrigger,
16 | DropdownMenuTrigger,
17 | } from './ui/dropdown-menu';
18 | import {
19 | CheckCircleFillIcon,
20 | GlobeIcon,
21 | LockIcon,
22 | MoreHorizontalIcon,
23 | ShareIcon,
24 | TrashIcon,
25 | } from './icons';
26 | import { memo } from 'react';
27 | import { useChatVisibility } from '@/hooks/use-chat-visibility';
28 |
29 | const PureChatItem = ({
30 | chat,
31 | isActive,
32 | onDelete,
33 | setOpenMobile,
34 | }: {
35 | chat: Chat;
36 | isActive: boolean;
37 | onDelete: (chatId: string) => void;
38 | setOpenMobile: (open: boolean) => void;
39 | }) => {
40 | const { visibilityType, setVisibilityType } = useChatVisibility({
41 | chatId: chat.id,
42 | initialVisibilityType: chat.visibility,
43 | });
44 |
45 | return (
46 |
47 |
48 | setOpenMobile(false)}>
49 | {chat.title}
50 |
51 |
52 |
53 |
54 |
55 |
59 |
60 | More
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | Share
69 |
70 |
71 |
72 | {
75 | setVisibilityType('private');
76 | }}
77 | >
78 |
79 |
80 | Private
81 |
82 | {visibilityType === 'private' ? (
83 |
84 | ) : null}
85 |
86 | {
89 | setVisibilityType('public');
90 | }}
91 | >
92 |
93 |
94 | Public
95 |
96 | {visibilityType === 'public' ? : null}
97 |
98 |
99 |
100 |
101 |
102 | onDelete(chat.id)}
105 | >
106 |
107 | Delete
108 |
109 |
110 |
111 |
112 | );
113 | };
114 |
115 | export const ChatItem = memo(PureChatItem, (prevProps, nextProps) => {
116 | if (prevProps.isActive !== nextProps.isActive) return false;
117 | return true;
118 | });
119 |
--------------------------------------------------------------------------------
/components/sidebar-toggle.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps } from 'react';
2 |
3 | import { type SidebarTrigger, useSidebar } from '@/components/ui/sidebar';
4 | import {
5 | Tooltip,
6 | TooltipContent,
7 | TooltipTrigger,
8 | } from '@/components/ui/tooltip';
9 |
10 | import { SidebarLeftIcon } from './icons';
11 | import { Button } from './ui/button';
12 |
13 | export function SidebarToggle({
14 | className,
15 | }: ComponentProps) {
16 | const { toggleSidebar } = useSidebar();
17 |
18 | return (
19 |
20 |
21 |
29 |
30 | Toggle Sidebar
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/components/sign-out-form.tsx:
--------------------------------------------------------------------------------
1 | import Form from 'next/form';
2 |
3 | import { signOut } from '@/app/(auth)/auth';
4 |
5 | export const SignOutForm = () => {
6 | return (
7 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/components/submit-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useFormStatus } from 'react-dom';
4 |
5 | import { LoaderIcon } from '@/components/icons';
6 |
7 | import { Button } from './ui/button';
8 |
9 | export function SubmitButton({
10 | children,
11 | isSuccessful,
12 | }: {
13 | children: React.ReactNode;
14 | isSuccessful: boolean;
15 | }) {
16 | const { pending } = useFormStatus();
17 |
18 | return (
19 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/components/suggested-actions.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { motion } from 'framer-motion';
4 | import { Button } from './ui/button';
5 | import { memo } from 'react';
6 | import type { UseChatHelpers } from '@ai-sdk/react';
7 | import type { VisibilityType } from './visibility-selector';
8 |
9 | interface SuggestedActionsProps {
10 | chatId: string;
11 | append: UseChatHelpers['append'];
12 | selectedVisibilityType: VisibilityType;
13 | }
14 |
15 | function PureSuggestedActions({
16 | chatId,
17 | append,
18 | selectedVisibilityType,
19 | }: SuggestedActionsProps) {
20 | const suggestedActions = [
21 | {
22 | title: 'What are the advantages',
23 | label: 'of using Next.js?',
24 | action: 'What are the advantages of using Next.js?',
25 | },
26 | {
27 | title: 'Write code to',
28 | label: `demonstrate djikstra's algorithm`,
29 | action: `Write code to demonstrate djikstra's algorithm`,
30 | },
31 | {
32 | title: 'Help me write an essay',
33 | label: `about silicon valley`,
34 | action: `Help me write an essay about silicon valley`,
35 | },
36 | {
37 | title: 'What is the weather',
38 | label: 'in San Francisco?',
39 | action: 'What is the weather in San Francisco?',
40 | },
41 | ];
42 |
43 | return (
44 |
48 | {suggestedActions.map((suggestedAction, index) => (
49 | 1 ? 'hidden sm:block' : 'block'}
56 | >
57 |
74 |
75 | ))}
76 |
77 | );
78 | }
79 |
80 | export const SuggestedActions = memo(
81 | PureSuggestedActions,
82 | (prevProps, nextProps) => {
83 | if (prevProps.chatId !== nextProps.chatId) return false;
84 | if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType)
85 | return false;
86 |
87 | return true;
88 | },
89 | );
90 |
--------------------------------------------------------------------------------
/components/suggestion.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { AnimatePresence, motion } from 'framer-motion';
4 | import { useState } from 'react';
5 | import { useWindowSize } from 'usehooks-ts';
6 |
7 | import type { UISuggestion } from '@/lib/editor/suggestions';
8 |
9 | import { CrossIcon, MessageIcon } from './icons';
10 | import { Button } from './ui/button';
11 | import { cn } from '@/lib/utils';
12 | import { ArtifactKind } from './artifact';
13 |
14 | export const Suggestion = ({
15 | suggestion,
16 | onApply,
17 | artifactKind,
18 | }: {
19 | suggestion: UISuggestion;
20 | onApply: () => void;
21 | artifactKind: ArtifactKind;
22 | }) => {
23 | const [isExpanded, setIsExpanded] = useState(false);
24 | const { width: windowWidth } = useWindowSize();
25 |
26 | return (
27 |
28 | {!isExpanded ? (
29 | {
35 | setIsExpanded(true);
36 | }}
37 | whileHover={{ scale: 1.1 }}
38 | >
39 |
40 |
41 | ) : (
42 |
51 |
52 |
56 |
65 |
66 | {suggestion.description}
67 |
74 |
75 | )}
76 |
77 | );
78 | };
79 |
--------------------------------------------------------------------------------
/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ThemeProvider as NextThemesProvider } from 'next-themes';
4 | import type { ThemeProviderProps } from 'next-themes/dist/types';
5 |
6 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
7 | return {children};
8 | }
9 |
--------------------------------------------------------------------------------
/components/toast.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useEffect, useRef, useState, type ReactNode } from 'react';
4 | import { toast as sonnerToast } from 'sonner';
5 | import { CheckCircleFillIcon, WarningIcon } from './icons';
6 | import { cn } from '@/lib/utils';
7 |
8 | const iconsByType: Record<'success' | 'error', ReactNode> = {
9 | success: ,
10 | error: ,
11 | };
12 |
13 | export function toast(props: Omit) {
14 | return sonnerToast.custom((id) => (
15 |
16 | ));
17 | }
18 |
19 | function Toast(props: ToastProps) {
20 | const { id, type, description } = props;
21 |
22 | const descriptionRef = useRef(null);
23 | const [multiLine, setMultiLine] = useState(false);
24 |
25 | useEffect(() => {
26 | const el = descriptionRef.current;
27 | if (!el) return;
28 |
29 | const update = () => {
30 | const lineHeight = Number.parseFloat(getComputedStyle(el).lineHeight);
31 | const lines = Math.round(el.scrollHeight / lineHeight);
32 | setMultiLine(lines > 1);
33 | };
34 |
35 | update(); // initial check
36 | const ro = new ResizeObserver(update); // re-check on width changes
37 | ro.observe(el);
38 |
39 | return () => ro.disconnect();
40 | }, [description]);
41 |
42 | return (
43 |
44 |
52 |
59 | {iconsByType[type]}
60 |
61 |
62 | {description}
63 |
64 |
65 |
66 | );
67 | }
68 |
69 | interface ToastProps {
70 | id: string | number;
71 | type: 'success' | 'error';
72 | description: string;
73 | }
74 |
--------------------------------------------------------------------------------
/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 gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-primary text-primary-foreground hover:bg-primary/90',
13 | destructive:
14 | 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
15 | outline:
16 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
17 | secondary:
18 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
19 | ghost: 'hover:bg-accent hover:text-accent-foreground',
20 | link: 'text-primary underline-offset-4 hover:underline',
21 | },
22 | size: {
23 | default: 'h-10 px-4 py-2',
24 | sm: 'h-9 rounded-md px-3',
25 | lg: 'h-11 rounded-md px-8',
26 | icon: 'h-10 w-10',
27 | },
28 | },
29 | defaultVariants: {
30 | variant: 'default',
31 | size: 'default',
32 | },
33 | },
34 | );
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean;
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : 'button';
45 | return (
46 |
51 | );
52 | },
53 | );
54 | Button.displayName = 'Button';
55 |
56 | export { Button, buttonVariants };
57 |
--------------------------------------------------------------------------------
/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ));
18 | Card.displayName = 'Card';
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | CardHeader.displayName = 'CardHeader';
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLDivElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ));
45 | CardTitle.displayName = 'CardTitle';
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLDivElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ));
57 | CardDescription.displayName = 'CardDescription';
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ));
65 | CardContent.displayName = 'CardContent';
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ));
77 | CardFooter.displayName = 'CardFooter';
78 |
79 | export {
80 | Card,
81 | CardHeader,
82 | CardFooter,
83 | CardTitle,
84 | CardDescription,
85 | CardContent,
86 | };
87 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | );
18 | },
19 | );
20 | Input.displayName = 'Input';
21 |
22 | export { Input };
23 |
--------------------------------------------------------------------------------
/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/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/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils';
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | );
13 | }
14 |
15 | export { Skeleton };
16 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.ComponentProps<'textarea'>
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | );
19 | });
20 | Textarea.displayName = 'Textarea';
21 |
22 | export { Textarea };
23 |
--------------------------------------------------------------------------------
/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/version-footer.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { isAfter } from 'date-fns';
4 | import { motion } from 'framer-motion';
5 | import { useState } from 'react';
6 | import { useSWRConfig } from 'swr';
7 | import { useWindowSize } from 'usehooks-ts';
8 |
9 | import type { Document } from '@/lib/db/schema';
10 | import { getDocumentTimestampByIndex } from '@/lib/utils';
11 |
12 | import { LoaderIcon } from './icons';
13 | import { Button } from './ui/button';
14 | import { useArtifact } from '@/hooks/use-artifact';
15 |
16 | interface VersionFooterProps {
17 | handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void;
18 | documents: Array | undefined;
19 | currentVersionIndex: number;
20 | }
21 |
22 | export const VersionFooter = ({
23 | handleVersionChange,
24 | documents,
25 | currentVersionIndex,
26 | }: VersionFooterProps) => {
27 | const { artifact } = useArtifact();
28 |
29 | const { width } = useWindowSize();
30 | const isMobile = width < 768;
31 |
32 | const { mutate } = useSWRConfig();
33 | const [isMutating, setIsMutating] = useState(false);
34 |
35 | if (!documents) return;
36 |
37 | return (
38 |
45 |
46 |
You are viewing a previous version
47 |
48 | Restore this version to make edits
49 |
50 |
51 |
52 |
53 |
96 |
104 |
105 |
106 | );
107 | };
108 |
--------------------------------------------------------------------------------
/components/visibility-selector.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { type ReactNode, useMemo, useState } from 'react';
4 | import { Button } from '@/components/ui/button';
5 | import {
6 | DropdownMenu,
7 | DropdownMenuContent,
8 | DropdownMenuItem,
9 | DropdownMenuTrigger,
10 | } from '@/components/ui/dropdown-menu';
11 | import { cn } from '@/lib/utils';
12 | import {
13 | CheckCircleFillIcon,
14 | ChevronDownIcon,
15 | GlobeIcon,
16 | LockIcon,
17 | } from './icons';
18 | import { useChatVisibility } from '@/hooks/use-chat-visibility';
19 |
20 | export type VisibilityType = 'private' | 'public';
21 |
22 | const visibilities: Array<{
23 | id: VisibilityType;
24 | label: string;
25 | description: string;
26 | icon: ReactNode;
27 | }> = [
28 | {
29 | id: 'private',
30 | label: 'Private',
31 | description: 'Only you can access this chat',
32 | icon: ,
33 | },
34 | {
35 | id: 'public',
36 | label: 'Public',
37 | description: 'Anyone with the link can access this chat',
38 | icon: ,
39 | },
40 | ];
41 |
42 | export function VisibilitySelector({
43 | chatId,
44 | className,
45 | selectedVisibilityType,
46 | }: {
47 | chatId: string;
48 | selectedVisibilityType: VisibilityType;
49 | } & React.ComponentProps) {
50 | const [open, setOpen] = useState(false);
51 |
52 | const { visibilityType, setVisibilityType } = useChatVisibility({
53 | chatId,
54 | initialVisibilityType: selectedVisibilityType,
55 | });
56 |
57 | const selectedVisibility = useMemo(
58 | () => visibilities.find((visibility) => visibility.id === visibilityType),
59 | [visibilityType],
60 | );
61 |
62 | return (
63 |
64 |
71 |
80 |
81 |
82 |
83 | {visibilities.map((visibility) => (
84 | {
88 | setVisibilityType(visibility.id);
89 | setOpen(false);
90 | }}
91 | className="gap-4 group/item flex flex-row justify-between items-center"
92 | data-active={visibility.id === visibilityType}
93 | >
94 |
95 | {visibility.label}
96 | {visibility.description && (
97 |
98 | {visibility.description}
99 |
100 | )}
101 |
102 |
103 |
104 |
105 |
106 | ))}
107 |
108 |
109 | );
110 | }
111 |
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { config } from 'dotenv';
2 | import { defineConfig } from 'drizzle-kit';
3 |
4 | config({
5 | path: '.env.local',
6 | });
7 |
8 | export default defineConfig({
9 | schema: './lib/db/schema.ts',
10 | out: './lib/db/migrations',
11 | dialect: 'postgresql',
12 | dbCredentials: {
13 | // biome-ignore lint: Forbidden non-null assertion.
14 | url: process.env.POSTGRES_URL!,
15 | },
16 | });
17 |
--------------------------------------------------------------------------------
/hooks/use-artifact.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import useSWR from 'swr';
4 | import { UIArtifact } from '@/components/artifact';
5 | import { useCallback, useMemo } from 'react';
6 |
7 | export const initialArtifactData: UIArtifact = {
8 | documentId: 'init',
9 | content: '',
10 | kind: 'text',
11 | title: '',
12 | status: 'idle',
13 | isVisible: false,
14 | boundingBox: {
15 | top: 0,
16 | left: 0,
17 | width: 0,
18 | height: 0,
19 | },
20 | };
21 |
22 | type Selector = (state: UIArtifact) => T;
23 |
24 | export function useArtifactSelector(selector: Selector) {
25 | const { data: localArtifact } = useSWR('artifact', null, {
26 | fallbackData: initialArtifactData,
27 | });
28 |
29 | const selectedValue = useMemo(() => {
30 | if (!localArtifact) return selector(initialArtifactData);
31 | return selector(localArtifact);
32 | }, [localArtifact, selector]);
33 |
34 | return selectedValue;
35 | }
36 |
37 | export function useArtifact() {
38 | const { data: localArtifact, mutate: setLocalArtifact } = useSWR(
39 | 'artifact',
40 | null,
41 | {
42 | fallbackData: initialArtifactData,
43 | },
44 | );
45 |
46 | const artifact = useMemo(() => {
47 | if (!localArtifact) return initialArtifactData;
48 | return localArtifact;
49 | }, [localArtifact]);
50 |
51 | const setArtifact = useCallback(
52 | (updaterFn: UIArtifact | ((currentArtifact: UIArtifact) => UIArtifact)) => {
53 | setLocalArtifact((currentArtifact) => {
54 | const artifactToUpdate = currentArtifact || initialArtifactData;
55 |
56 | if (typeof updaterFn === 'function') {
57 | return updaterFn(artifactToUpdate);
58 | }
59 |
60 | return updaterFn;
61 | });
62 | },
63 | [setLocalArtifact],
64 | );
65 |
66 | const { data: localArtifactMetadata, mutate: setLocalArtifactMetadata } =
67 | useSWR(
68 | () =>
69 | artifact.documentId ? `artifact-metadata-${artifact.documentId}` : null,
70 | null,
71 | {
72 | fallbackData: null,
73 | },
74 | );
75 |
76 | return useMemo(
77 | () => ({
78 | artifact,
79 | setArtifact,
80 | metadata: localArtifactMetadata,
81 | setMetadata: setLocalArtifactMetadata,
82 | }),
83 | [artifact, setArtifact, localArtifactMetadata, setLocalArtifactMetadata],
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/hooks/use-auto-resume.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect } from 'react';
4 | import type { UIMessage } from 'ai';
5 | import type { UseChatHelpers } from '@ai-sdk/react';
6 | import type { DataPart } from '@/lib/types';
7 |
8 | export interface UseAutoResumeParams {
9 | autoResume: boolean;
10 | initialMessages: UIMessage[];
11 | experimental_resume: UseChatHelpers['experimental_resume'];
12 | data: UseChatHelpers['data'];
13 | setMessages: UseChatHelpers['setMessages'];
14 | }
15 |
16 | export function useAutoResume({
17 | autoResume,
18 | initialMessages,
19 | experimental_resume,
20 | data,
21 | setMessages,
22 | }: UseAutoResumeParams) {
23 | useEffect(() => {
24 | if (!autoResume) return;
25 |
26 | const mostRecentMessage = initialMessages.at(-1);
27 |
28 | if (mostRecentMessage?.role === 'user') {
29 | experimental_resume();
30 | }
31 |
32 | // we intentionally run this once
33 | // eslint-disable-next-line react-hooks/exhaustive-deps
34 | }, []);
35 |
36 | useEffect(() => {
37 | if (!data) return;
38 | if (data.length === 0) return;
39 |
40 | const dataPart = data[0] as DataPart;
41 |
42 | if (dataPart.type === 'append-message') {
43 | const message = JSON.parse(dataPart.message) as UIMessage;
44 | setMessages([...initialMessages, message]);
45 | }
46 | }, [data, initialMessages, setMessages]);
47 | }
48 |
--------------------------------------------------------------------------------
/hooks/use-chat-visibility.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useMemo } from 'react';
4 | import useSWR, { useSWRConfig } from 'swr';
5 | import { unstable_serialize } from 'swr/infinite';
6 | import { updateChatVisibility } from '@/app/(chat)/actions';
7 | import {
8 | getChatHistoryPaginationKey,
9 | type ChatHistory,
10 | } from '@/components/sidebar-history';
11 | import type { VisibilityType } from '@/components/visibility-selector';
12 |
13 | export function useChatVisibility({
14 | chatId,
15 | initialVisibilityType,
16 | }: {
17 | chatId: string;
18 | initialVisibilityType: VisibilityType;
19 | }) {
20 | const { mutate, cache } = useSWRConfig();
21 | const history: ChatHistory = cache.get('/api/history')?.data;
22 |
23 | const { data: localVisibility, mutate: setLocalVisibility } = useSWR(
24 | `${chatId}-visibility`,
25 | null,
26 | {
27 | fallbackData: initialVisibilityType,
28 | },
29 | );
30 |
31 | const visibilityType = useMemo(() => {
32 | if (!history) return localVisibility;
33 | const chat = history.chats.find((chat) => chat.id === chatId);
34 | if (!chat) return 'private';
35 | return chat.visibility;
36 | }, [history, chatId, localVisibility]);
37 |
38 | const setVisibilityType = (updatedVisibilityType: VisibilityType) => {
39 | setLocalVisibility(updatedVisibilityType);
40 | mutate(unstable_serialize(getChatHistoryPaginationKey));
41 |
42 | updateChatVisibility({
43 | chatId: chatId,
44 | visibility: updatedVisibilityType,
45 | });
46 | };
47 |
48 | return { visibilityType, setVisibilityType };
49 | }
50 |
--------------------------------------------------------------------------------
/hooks/use-messages.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useScrollToBottom } from './use-scroll-to-bottom';
3 | import type { UseChatHelpers } from '@ai-sdk/react';
4 |
5 | export function useMessages({
6 | chatId,
7 | status,
8 | }: {
9 | chatId: string;
10 | status: UseChatHelpers['status'];
11 | }) {
12 | const {
13 | containerRef,
14 | endRef,
15 | isAtBottom,
16 | scrollToBottom,
17 | onViewportEnter,
18 | onViewportLeave,
19 | } = useScrollToBottom();
20 |
21 | const [hasSentMessage, setHasSentMessage] = useState(false);
22 |
23 | useEffect(() => {
24 | if (chatId) {
25 | scrollToBottom('instant');
26 | setHasSentMessage(false);
27 | }
28 | }, [chatId, scrollToBottom]);
29 |
30 | useEffect(() => {
31 | if (status === 'submitted') {
32 | setHasSentMessage(true);
33 | }
34 | }, [status]);
35 |
36 | return {
37 | containerRef,
38 | endRef,
39 | isAtBottom,
40 | scrollToBottom,
41 | onViewportEnter,
42 | onViewportLeave,
43 | hasSentMessage,
44 | };
45 | }
46 |
--------------------------------------------------------------------------------
/hooks/use-mobile.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | const MOBILE_BREAKPOINT = 768;
4 |
5 | export function useIsMobile() {
6 | const [isMobile, setIsMobile] = React.useState(
7 | undefined,
8 | );
9 |
10 | React.useEffect(() => {
11 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
12 | const onChange = () => {
13 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
14 | };
15 | mql.addEventListener('change', onChange);
16 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
17 | return () => mql.removeEventListener('change', onChange);
18 | }, []);
19 |
20 | return !!isMobile;
21 | }
22 |
--------------------------------------------------------------------------------
/hooks/use-scroll-to-bottom.tsx:
--------------------------------------------------------------------------------
1 | import useSWR from 'swr';
2 | import { useRef, useEffect, useCallback } from 'react';
3 |
4 | type ScrollFlag = ScrollBehavior | false;
5 |
6 | export function useScrollToBottom() {
7 | const containerRef = useRef(null);
8 | const endRef = useRef(null);
9 |
10 | const { data: isAtBottom = false, mutate: setIsAtBottom } = useSWR(
11 | 'messages:is-at-bottom',
12 | null,
13 | { fallbackData: false },
14 | );
15 |
16 | const { data: scrollBehavior = false, mutate: setScrollBehavior } =
17 | useSWR('messages:should-scroll', null, { fallbackData: false });
18 |
19 | useEffect(() => {
20 | if (scrollBehavior) {
21 | endRef.current?.scrollIntoView({ behavior: scrollBehavior });
22 | setScrollBehavior(false);
23 | }
24 | }, [setScrollBehavior, scrollBehavior]);
25 |
26 | const scrollToBottom = useCallback(
27 | (scrollBehavior: ScrollBehavior = 'smooth') => {
28 | setScrollBehavior(scrollBehavior);
29 | },
30 | [setScrollBehavior],
31 | );
32 |
33 | function onViewportEnter() {
34 | setIsAtBottom(true);
35 | }
36 |
37 | function onViewportLeave() {
38 | setIsAtBottom(false);
39 | }
40 |
41 | return {
42 | containerRef,
43 | endRef,
44 | isAtBottom,
45 | scrollToBottom,
46 | onViewportEnter,
47 | onViewportLeave,
48 | };
49 | }
50 |
--------------------------------------------------------------------------------
/instrumentation.ts:
--------------------------------------------------------------------------------
1 | import { registerOTel } from '@vercel/otel';
2 |
3 | export function register() {
4 | registerOTel({ serviceName: 'ai-chatbot' });
5 | }
6 |
--------------------------------------------------------------------------------
/lib/ai/entitlements.ts:
--------------------------------------------------------------------------------
1 | import type { UserType } from '@/app/(auth)/auth';
2 | import type { ChatModel } from './models';
3 |
4 | interface Entitlements {
5 | maxMessagesPerDay: number;
6 | availableChatModelIds: Array;
7 | }
8 |
9 | export const entitlementsByUserType: Record = {
10 | /*
11 | * For users without an account
12 | */
13 | guest: {
14 | maxMessagesPerDay: 20,
15 | availableChatModelIds: ['chat-model', 'chat-model-reasoning'],
16 | },
17 |
18 | /*
19 | * For users with an account
20 | */
21 | regular: {
22 | maxMessagesPerDay: 100,
23 | availableChatModelIds: ['chat-model', 'chat-model-reasoning'],
24 | },
25 |
26 | /*
27 | * TODO: For users with an account and a paid membership
28 | */
29 | };
30 |
--------------------------------------------------------------------------------
/lib/ai/models.test.ts:
--------------------------------------------------------------------------------
1 | import { simulateReadableStream } from 'ai';
2 | import { MockLanguageModelV1 } from 'ai/test';
3 | import { getResponseChunksByPrompt } from '@/tests/prompts/utils';
4 |
5 | export const chatModel = new MockLanguageModelV1({
6 | doGenerate: async () => ({
7 | rawCall: { rawPrompt: null, rawSettings: {} },
8 | finishReason: 'stop',
9 | usage: { promptTokens: 10, completionTokens: 20 },
10 | text: `Hello, world!`,
11 | }),
12 | doStream: async ({ prompt }) => ({
13 | stream: simulateReadableStream({
14 | chunkDelayInMs: 500,
15 | initialDelayInMs: 1000,
16 | chunks: getResponseChunksByPrompt(prompt),
17 | }),
18 | rawCall: { rawPrompt: null, rawSettings: {} },
19 | }),
20 | });
21 |
22 | export const reasoningModel = new MockLanguageModelV1({
23 | doGenerate: async () => ({
24 | rawCall: { rawPrompt: null, rawSettings: {} },
25 | finishReason: 'stop',
26 | usage: { promptTokens: 10, completionTokens: 20 },
27 | text: `Hello, world!`,
28 | }),
29 | doStream: async ({ prompt }) => ({
30 | stream: simulateReadableStream({
31 | chunkDelayInMs: 500,
32 | initialDelayInMs: 1000,
33 | chunks: getResponseChunksByPrompt(prompt, true),
34 | }),
35 | rawCall: { rawPrompt: null, rawSettings: {} },
36 | }),
37 | });
38 |
39 | export const titleModel = new MockLanguageModelV1({
40 | doGenerate: async () => ({
41 | rawCall: { rawPrompt: null, rawSettings: {} },
42 | finishReason: 'stop',
43 | usage: { promptTokens: 10, completionTokens: 20 },
44 | text: `This is a test title`,
45 | }),
46 | doStream: async () => ({
47 | stream: simulateReadableStream({
48 | chunkDelayInMs: 500,
49 | initialDelayInMs: 1000,
50 | chunks: [
51 | { type: 'text-delta', textDelta: 'This is a test title' },
52 | {
53 | type: 'finish',
54 | finishReason: 'stop',
55 | logprobs: undefined,
56 | usage: { completionTokens: 10, promptTokens: 3 },
57 | },
58 | ],
59 | }),
60 | rawCall: { rawPrompt: null, rawSettings: {} },
61 | }),
62 | });
63 |
64 | export const artifactModel = new MockLanguageModelV1({
65 | doGenerate: async () => ({
66 | rawCall: { rawPrompt: null, rawSettings: {} },
67 | finishReason: 'stop',
68 | usage: { promptTokens: 10, completionTokens: 20 },
69 | text: `Hello, world!`,
70 | }),
71 | doStream: async ({ prompt }) => ({
72 | stream: simulateReadableStream({
73 | chunkDelayInMs: 50,
74 | initialDelayInMs: 100,
75 | chunks: getResponseChunksByPrompt(prompt),
76 | }),
77 | rawCall: { rawPrompt: null, rawSettings: {} },
78 | }),
79 | });
80 |
--------------------------------------------------------------------------------
/lib/ai/models.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_CHAT_MODEL: string = 'chat-model';
2 |
3 | export interface ChatModel {
4 | id: string;
5 | name: string;
6 | description: string;
7 | }
8 |
9 | export const chatModels: Array = [
10 | {
11 | id: 'chat-model',
12 | name: 'Chat model',
13 | description: 'Primary model for all-purpose chat',
14 | },
15 | {
16 | id: 'chat-model-reasoning',
17 | name: 'Reasoning model',
18 | description: 'Uses advanced reasoning',
19 | },
20 | ];
21 |
--------------------------------------------------------------------------------
/lib/ai/providers.ts:
--------------------------------------------------------------------------------
1 | import {
2 | customProvider,
3 | extractReasoningMiddleware,
4 | wrapLanguageModel,
5 | } from 'ai';
6 | import { xai } from '@ai-sdk/xai';
7 | import { isTestEnvironment } from '../constants';
8 | import {
9 | artifactModel,
10 | chatModel,
11 | reasoningModel,
12 | titleModel,
13 | } from './models.test';
14 |
15 | export const myProvider = isTestEnvironment
16 | ? customProvider({
17 | languageModels: {
18 | 'chat-model': chatModel,
19 | 'chat-model-reasoning': reasoningModel,
20 | 'title-model': titleModel,
21 | 'artifact-model': artifactModel,
22 | },
23 | })
24 | : customProvider({
25 | languageModels: {
26 | 'chat-model': xai('grok-2-vision-1212'),
27 | 'chat-model-reasoning': wrapLanguageModel({
28 | model: xai('grok-3-mini-beta'),
29 | middleware: extractReasoningMiddleware({ tagName: 'think' }),
30 | }),
31 | 'title-model': xai('grok-2-1212'),
32 | 'artifact-model': xai('grok-2-1212'),
33 | },
34 | imageModels: {
35 | 'small-model': xai.image('grok-2-image'),
36 | },
37 | });
38 |
--------------------------------------------------------------------------------
/lib/ai/tools/create-document.ts:
--------------------------------------------------------------------------------
1 | import { generateUUID } from '@/lib/utils';
2 | import { DataStreamWriter, tool } from 'ai';
3 | import { z } from 'zod';
4 | import { Session } from 'next-auth';
5 | import {
6 | artifactKinds,
7 | documentHandlersByArtifactKind,
8 | } from '@/lib/artifacts/server';
9 |
10 | interface CreateDocumentProps {
11 | session: Session;
12 | dataStream: DataStreamWriter;
13 | }
14 |
15 | export const createDocument = ({ session, dataStream }: CreateDocumentProps) =>
16 | tool({
17 | description:
18 | 'Create a document for a writing or content creation activities. This tool will call other functions that will generate the contents of the document based on the title and kind.',
19 | parameters: z.object({
20 | title: z.string(),
21 | kind: z.enum(artifactKinds),
22 | }),
23 | execute: async ({ title, kind }) => {
24 | const id = generateUUID();
25 |
26 | dataStream.writeData({
27 | type: 'kind',
28 | content: kind,
29 | });
30 |
31 | dataStream.writeData({
32 | type: 'id',
33 | content: id,
34 | });
35 |
36 | dataStream.writeData({
37 | type: 'title',
38 | content: title,
39 | });
40 |
41 | dataStream.writeData({
42 | type: 'clear',
43 | content: '',
44 | });
45 |
46 | const documentHandler = documentHandlersByArtifactKind.find(
47 | (documentHandlerByArtifactKind) =>
48 | documentHandlerByArtifactKind.kind === kind,
49 | );
50 |
51 | if (!documentHandler) {
52 | throw new Error(`No document handler found for kind: ${kind}`);
53 | }
54 |
55 | await documentHandler.onCreateDocument({
56 | id,
57 | title,
58 | dataStream,
59 | session,
60 | });
61 |
62 | dataStream.writeData({ type: 'finish', content: '' });
63 |
64 | return {
65 | id,
66 | title,
67 | kind,
68 | content: 'A document was created and is now visible to the user.',
69 | };
70 | },
71 | });
72 |
--------------------------------------------------------------------------------
/lib/ai/tools/get-weather.ts:
--------------------------------------------------------------------------------
1 | import { tool } from 'ai';
2 | import { z } from 'zod';
3 |
4 | export const getWeather = tool({
5 | description: 'Get the current weather at a location',
6 | parameters: z.object({
7 | latitude: z.number(),
8 | longitude: z.number(),
9 | }),
10 | execute: async ({ latitude, longitude }) => {
11 | const response = await fetch(
12 | `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m&hourly=temperature_2m&daily=sunrise,sunset&timezone=auto`,
13 | );
14 |
15 | const weatherData = await response.json();
16 | return weatherData;
17 | },
18 | });
19 |
--------------------------------------------------------------------------------
/lib/ai/tools/request-suggestions.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 | import { Session } from 'next-auth';
3 | import { DataStreamWriter, streamObject, tool } from 'ai';
4 | import { getDocumentById, saveSuggestions } from '@/lib/db/queries';
5 | import { Suggestion } from '@/lib/db/schema';
6 | import { generateUUID } from '@/lib/utils';
7 | import { myProvider } from '../providers';
8 |
9 | interface RequestSuggestionsProps {
10 | session: Session;
11 | dataStream: DataStreamWriter;
12 | }
13 |
14 | export const requestSuggestions = ({
15 | session,
16 | dataStream,
17 | }: RequestSuggestionsProps) =>
18 | tool({
19 | description: 'Request suggestions for a document',
20 | parameters: z.object({
21 | documentId: z
22 | .string()
23 | .describe('The ID of the document to request edits'),
24 | }),
25 | execute: async ({ documentId }) => {
26 | const document = await getDocumentById({ id: documentId });
27 |
28 | if (!document || !document.content) {
29 | return {
30 | error: 'Document not found',
31 | };
32 | }
33 |
34 | const suggestions: Array<
35 | Omit
36 | > = [];
37 |
38 | const { elementStream } = streamObject({
39 | model: myProvider.languageModel('artifact-model'),
40 | system:
41 | 'You are a help writing assistant. Given a piece of writing, please offer suggestions to improve the piece of writing and describe the change. It is very important for the edits to contain full sentences instead of just words. Max 5 suggestions.',
42 | prompt: document.content,
43 | output: 'array',
44 | schema: z.object({
45 | originalSentence: z.string().describe('The original sentence'),
46 | suggestedSentence: z.string().describe('The suggested sentence'),
47 | description: z.string().describe('The description of the suggestion'),
48 | }),
49 | });
50 |
51 | for await (const element of elementStream) {
52 | const suggestion = {
53 | originalText: element.originalSentence,
54 | suggestedText: element.suggestedSentence,
55 | description: element.description,
56 | id: generateUUID(),
57 | documentId: documentId,
58 | isResolved: false,
59 | };
60 |
61 | dataStream.writeData({
62 | type: 'suggestion',
63 | content: suggestion,
64 | });
65 |
66 | suggestions.push(suggestion);
67 | }
68 |
69 | if (session.user?.id) {
70 | const userId = session.user.id;
71 |
72 | await saveSuggestions({
73 | suggestions: suggestions.map((suggestion) => ({
74 | ...suggestion,
75 | userId,
76 | createdAt: new Date(),
77 | documentCreatedAt: document.createdAt,
78 | })),
79 | });
80 | }
81 |
82 | return {
83 | id: documentId,
84 | title: document.title,
85 | kind: document.kind,
86 | message: 'Suggestions have been added to the document',
87 | };
88 | },
89 | });
90 |
--------------------------------------------------------------------------------
/lib/ai/tools/update-document.ts:
--------------------------------------------------------------------------------
1 | import { DataStreamWriter, tool } from 'ai';
2 | import { Session } from 'next-auth';
3 | import { z } from 'zod';
4 | import { getDocumentById, saveDocument } from '@/lib/db/queries';
5 | import { documentHandlersByArtifactKind } from '@/lib/artifacts/server';
6 |
7 | interface UpdateDocumentProps {
8 | session: Session;
9 | dataStream: DataStreamWriter;
10 | }
11 |
12 | export const updateDocument = ({ session, dataStream }: UpdateDocumentProps) =>
13 | tool({
14 | description: 'Update a document with the given description.',
15 | parameters: z.object({
16 | id: z.string().describe('The ID of the document to update'),
17 | description: z
18 | .string()
19 | .describe('The description of changes that need to be made'),
20 | }),
21 | execute: async ({ id, description }) => {
22 | const document = await getDocumentById({ id });
23 |
24 | if (!document) {
25 | return {
26 | error: 'Document not found',
27 | };
28 | }
29 |
30 | dataStream.writeData({
31 | type: 'clear',
32 | content: document.title,
33 | });
34 |
35 | const documentHandler = documentHandlersByArtifactKind.find(
36 | (documentHandlerByArtifactKind) =>
37 | documentHandlerByArtifactKind.kind === document.kind,
38 | );
39 |
40 | if (!documentHandler) {
41 | throw new Error(`No document handler found for kind: ${document.kind}`);
42 | }
43 |
44 | await documentHandler.onUpdateDocument({
45 | document,
46 | description,
47 | dataStream,
48 | session,
49 | });
50 |
51 | dataStream.writeData({ type: 'finish', content: '' });
52 |
53 | return {
54 | id,
55 | title: document.title,
56 | kind: document.kind,
57 | content: 'The document has been updated successfully.',
58 | };
59 | },
60 | });
61 |
--------------------------------------------------------------------------------
/lib/artifacts/server.ts:
--------------------------------------------------------------------------------
1 | import { codeDocumentHandler } from '@/artifacts/code/server';
2 | import { imageDocumentHandler } from '@/artifacts/image/server';
3 | import { sheetDocumentHandler } from '@/artifacts/sheet/server';
4 | import { textDocumentHandler } from '@/artifacts/text/server';
5 | import { ArtifactKind } from '@/components/artifact';
6 | import { DataStreamWriter } from 'ai';
7 | import { Document } from '../db/schema';
8 | import { saveDocument } from '../db/queries';
9 | import { Session } from 'next-auth';
10 |
11 | export interface SaveDocumentProps {
12 | id: string;
13 | title: string;
14 | kind: ArtifactKind;
15 | content: string;
16 | userId: string;
17 | }
18 |
19 | export interface CreateDocumentCallbackProps {
20 | id: string;
21 | title: string;
22 | dataStream: DataStreamWriter;
23 | session: Session;
24 | }
25 |
26 | export interface UpdateDocumentCallbackProps {
27 | document: Document;
28 | description: string;
29 | dataStream: DataStreamWriter;
30 | session: Session;
31 | }
32 |
33 | export interface DocumentHandler {
34 | kind: T;
35 | onCreateDocument: (args: CreateDocumentCallbackProps) => Promise;
36 | onUpdateDocument: (args: UpdateDocumentCallbackProps) => Promise;
37 | }
38 |
39 | export function createDocumentHandler(config: {
40 | kind: T;
41 | onCreateDocument: (params: CreateDocumentCallbackProps) => Promise;
42 | onUpdateDocument: (params: UpdateDocumentCallbackProps) => Promise;
43 | }): DocumentHandler {
44 | return {
45 | kind: config.kind,
46 | onCreateDocument: async (args: CreateDocumentCallbackProps) => {
47 | const draftContent = await config.onCreateDocument({
48 | id: args.id,
49 | title: args.title,
50 | dataStream: args.dataStream,
51 | session: args.session,
52 | });
53 |
54 | if (args.session?.user?.id) {
55 | await saveDocument({
56 | id: args.id,
57 | title: args.title,
58 | content: draftContent,
59 | kind: config.kind,
60 | userId: args.session.user.id,
61 | });
62 | }
63 |
64 | return;
65 | },
66 | onUpdateDocument: async (args: UpdateDocumentCallbackProps) => {
67 | const draftContent = await config.onUpdateDocument({
68 | document: args.document,
69 | description: args.description,
70 | dataStream: args.dataStream,
71 | session: args.session,
72 | });
73 |
74 | if (args.session?.user?.id) {
75 | await saveDocument({
76 | id: args.document.id,
77 | title: args.document.title,
78 | content: draftContent,
79 | kind: config.kind,
80 | userId: args.session.user.id,
81 | });
82 | }
83 |
84 | return;
85 | },
86 | };
87 | }
88 |
89 | /*
90 | * Use this array to define the document handlers for each artifact kind.
91 | */
92 | export const documentHandlersByArtifactKind: Array = [
93 | textDocumentHandler,
94 | codeDocumentHandler,
95 | imageDocumentHandler,
96 | sheetDocumentHandler,
97 | ];
98 |
99 | export const artifactKinds = ['text', 'code', 'image', 'sheet'] as const;
100 |
--------------------------------------------------------------------------------
/lib/constants.ts:
--------------------------------------------------------------------------------
1 | import { generateDummyPassword } from './db/utils';
2 |
3 | export const isProductionEnvironment = process.env.NODE_ENV === 'production';
4 | export const isDevelopmentEnvironment = process.env.NODE_ENV === 'development';
5 | export const isTestEnvironment = Boolean(
6 | process.env.PLAYWRIGHT_TEST_BASE_URL ||
7 | process.env.PLAYWRIGHT ||
8 | process.env.CI_PLAYWRIGHT,
9 | );
10 |
11 | export const guestRegex = /^guest-\d+$/;
12 |
13 | export const DUMMY_PASSWORD = generateDummyPassword();
14 |
--------------------------------------------------------------------------------
/lib/db/migrate.ts:
--------------------------------------------------------------------------------
1 | import { config } from 'dotenv';
2 | import { drizzle } from 'drizzle-orm/postgres-js';
3 | import { migrate } from 'drizzle-orm/postgres-js/migrator';
4 | import postgres from 'postgres';
5 |
6 | config({
7 | path: '.env.local',
8 | });
9 |
10 | const runMigrate = async () => {
11 | if (!process.env.POSTGRES_URL) {
12 | throw new Error('POSTGRES_URL is not defined');
13 | }
14 |
15 | const connection = postgres(process.env.POSTGRES_URL, { max: 1 });
16 | const db = drizzle(connection);
17 |
18 | console.log('⏳ Running migrations...');
19 |
20 | const start = Date.now();
21 | await migrate(db, { migrationsFolder: './lib/db/migrations' });
22 | const end = Date.now();
23 |
24 | console.log('✅ Migrations completed in', end - start, 'ms');
25 | process.exit(0);
26 | };
27 |
28 | runMigrate().catch((err) => {
29 | console.error('❌ Migration failed');
30 | console.error(err);
31 | process.exit(1);
32 | });
33 |
--------------------------------------------------------------------------------
/lib/db/migrations/0000_keen_devos.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "Chat" (
2 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3 | "createdAt" timestamp NOT NULL,
4 | "messages" json NOT NULL,
5 | "userId" uuid NOT NULL
6 | );
7 | --> statement-breakpoint
8 | CREATE TABLE IF NOT EXISTS "User" (
9 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
10 | "email" varchar(64) NOT NULL,
11 | "password" varchar(64)
12 | );
13 | --> statement-breakpoint
14 | DO $$ BEGIN
15 | ALTER TABLE "Chat" ADD CONSTRAINT "Chat_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;
16 | EXCEPTION
17 | WHEN duplicate_object THEN null;
18 | END $$;
19 |
--------------------------------------------------------------------------------
/lib/db/migrations/0001_sparkling_blue_marvel.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "Suggestion" (
2 | "id" uuid DEFAULT gen_random_uuid() NOT NULL,
3 | "documentId" uuid NOT NULL,
4 | "documentCreatedAt" timestamp NOT NULL,
5 | "originalText" text NOT NULL,
6 | "suggestedText" text NOT NULL,
7 | "description" text,
8 | "isResolved" boolean DEFAULT false NOT NULL,
9 | "userId" uuid NOT NULL,
10 | "createdAt" timestamp NOT NULL,
11 | CONSTRAINT "Suggestion_id_pk" PRIMARY KEY("id")
12 | );
13 | --> statement-breakpoint
14 | CREATE TABLE IF NOT EXISTS "Document" (
15 | "id" uuid DEFAULT gen_random_uuid() NOT NULL,
16 | "createdAt" timestamp NOT NULL,
17 | "title" text NOT NULL,
18 | "content" text,
19 | "userId" uuid NOT NULL,
20 | CONSTRAINT "Document_id_createdAt_pk" PRIMARY KEY("id","createdAt")
21 | );
22 | --> statement-breakpoint
23 | DO $$ BEGIN
24 | ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;
25 | EXCEPTION
26 | WHEN duplicate_object THEN null;
27 | END $$;
28 | --> statement-breakpoint
29 | DO $$ BEGIN
30 | ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk" FOREIGN KEY ("documentId","documentCreatedAt") REFERENCES "public"."Document"("id","createdAt") ON DELETE no action ON UPDATE no action;
31 | EXCEPTION
32 | WHEN duplicate_object THEN null;
33 | END $$;
34 | --> statement-breakpoint
35 | DO $$ BEGIN
36 | ALTER TABLE "Document" ADD CONSTRAINT "Document_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;
37 | EXCEPTION
38 | WHEN duplicate_object THEN null;
39 | END $$;
40 |
--------------------------------------------------------------------------------
/lib/db/migrations/0002_wandering_riptide.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "Message" (
2 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3 | "chatId" uuid NOT NULL,
4 | "role" varchar NOT NULL,
5 | "content" json NOT NULL,
6 | "createdAt" timestamp NOT NULL
7 | );
8 | --> statement-breakpoint
9 | CREATE TABLE IF NOT EXISTS "Vote" (
10 | "chatId" uuid NOT NULL,
11 | "messageId" uuid NOT NULL,
12 | "isUpvoted" boolean NOT NULL,
13 | CONSTRAINT "Vote_chatId_messageId_pk" PRIMARY KEY("chatId","messageId")
14 | );
15 | --> statement-breakpoint
16 | ALTER TABLE "Chat" ADD COLUMN "title" text NOT NULL;--> statement-breakpoint
17 | DO $$ BEGIN
18 | ALTER TABLE "Message" ADD CONSTRAINT "Message_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE no action ON UPDATE no action;
19 | EXCEPTION
20 | WHEN duplicate_object THEN null;
21 | END $$;
22 | --> statement-breakpoint
23 | DO $$ BEGIN
24 | ALTER TABLE "Vote" ADD CONSTRAINT "Vote_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE no action ON UPDATE no action;
25 | EXCEPTION
26 | WHEN duplicate_object THEN null;
27 | END $$;
28 | --> statement-breakpoint
29 | DO $$ BEGIN
30 | ALTER TABLE "Vote" ADD CONSTRAINT "Vote_messageId_Message_id_fk" FOREIGN KEY ("messageId") REFERENCES "public"."Message"("id") ON DELETE no action ON UPDATE no action;
31 | EXCEPTION
32 | WHEN duplicate_object THEN null;
33 | END $$;
34 | --> statement-breakpoint
35 | ALTER TABLE "Chat" DROP COLUMN IF EXISTS "messages";
--------------------------------------------------------------------------------
/lib/db/migrations/0003_cloudy_glorian.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "Chat" ADD COLUMN "visibility" varchar DEFAULT 'private' NOT NULL;
--------------------------------------------------------------------------------
/lib/db/migrations/0004_odd_slayback.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "Document" ADD COLUMN "text" varchar DEFAULT 'text' NOT NULL;
--------------------------------------------------------------------------------
/lib/db/migrations/0005_wooden_whistler.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "Message_v2" (
2 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3 | "chatId" uuid NOT NULL,
4 | "role" varchar NOT NULL,
5 | "parts" json NOT NULL,
6 | "attachments" json NOT NULL,
7 | "createdAt" timestamp NOT NULL
8 | );
9 | --> statement-breakpoint
10 | CREATE TABLE IF NOT EXISTS "Vote_v2" (
11 | "chatId" uuid NOT NULL,
12 | "messageId" uuid NOT NULL,
13 | "isUpvoted" boolean NOT NULL,
14 | CONSTRAINT "Vote_v2_chatId_messageId_pk" PRIMARY KEY("chatId","messageId")
15 | );
16 | --> statement-breakpoint
17 | DO $$ BEGIN
18 | ALTER TABLE "Message_v2" ADD CONSTRAINT "Message_v2_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE no action ON UPDATE no action;
19 | EXCEPTION
20 | WHEN duplicate_object THEN null;
21 | END $$;
22 | --> statement-breakpoint
23 | DO $$ BEGIN
24 | ALTER TABLE "Vote_v2" ADD CONSTRAINT "Vote_v2_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE no action ON UPDATE no action;
25 | EXCEPTION
26 | WHEN duplicate_object THEN null;
27 | END $$;
28 | --> statement-breakpoint
29 | DO $$ BEGIN
30 | ALTER TABLE "Vote_v2" ADD CONSTRAINT "Vote_v2_messageId_Message_v2_id_fk" FOREIGN KEY ("messageId") REFERENCES "public"."Message_v2"("id") ON DELETE no action ON UPDATE no action;
31 | EXCEPTION
32 | WHEN duplicate_object THEN null;
33 | END $$;
34 |
--------------------------------------------------------------------------------
/lib/db/migrations/0006_marvelous_frog_thor.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "Stream" (
2 | "id" uuid DEFAULT gen_random_uuid() NOT NULL,
3 | "chatId" uuid NOT NULL,
4 | "createdAt" timestamp NOT NULL,
5 | CONSTRAINT "Stream_id_pk" PRIMARY KEY("id")
6 | );
7 | --> statement-breakpoint
8 | DO $$ BEGIN
9 | ALTER TABLE "Stream" ADD CONSTRAINT "Stream_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE no action ON UPDATE no action;
10 | EXCEPTION
11 | WHEN duplicate_object THEN null;
12 | END $$;
13 |
--------------------------------------------------------------------------------
/lib/db/migrations/meta/0000_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "715ec9ec-6715-4d0f-9f6c-9b5c7f09827c",
3 | "prevId": "00000000-0000-0000-0000-000000000000",
4 | "version": "7",
5 | "dialect": "postgresql",
6 | "tables": {
7 | "public.Chat": {
8 | "name": "Chat",
9 | "schema": "",
10 | "columns": {
11 | "id": {
12 | "name": "id",
13 | "type": "uuid",
14 | "primaryKey": true,
15 | "notNull": true,
16 | "default": "gen_random_uuid()"
17 | },
18 | "createdAt": {
19 | "name": "createdAt",
20 | "type": "timestamp",
21 | "primaryKey": false,
22 | "notNull": true
23 | },
24 | "messages": {
25 | "name": "messages",
26 | "type": "json",
27 | "primaryKey": false,
28 | "notNull": true
29 | },
30 | "userId": {
31 | "name": "userId",
32 | "type": "uuid",
33 | "primaryKey": false,
34 | "notNull": true
35 | }
36 | },
37 | "indexes": {},
38 | "foreignKeys": {
39 | "Chat_userId_User_id_fk": {
40 | "name": "Chat_userId_User_id_fk",
41 | "tableFrom": "Chat",
42 | "tableTo": "User",
43 | "columnsFrom": [
44 | "userId"
45 | ],
46 | "columnsTo": [
47 | "id"
48 | ],
49 | "onDelete": "no action",
50 | "onUpdate": "no action"
51 | }
52 | },
53 | "compositePrimaryKeys": {},
54 | "uniqueConstraints": {}
55 | },
56 | "public.User": {
57 | "name": "User",
58 | "schema": "",
59 | "columns": {
60 | "id": {
61 | "name": "id",
62 | "type": "uuid",
63 | "primaryKey": true,
64 | "notNull": true,
65 | "default": "gen_random_uuid()"
66 | },
67 | "email": {
68 | "name": "email",
69 | "type": "varchar(64)",
70 | "primaryKey": false,
71 | "notNull": true
72 | },
73 | "password": {
74 | "name": "password",
75 | "type": "varchar(64)",
76 | "primaryKey": false,
77 | "notNull": false
78 | }
79 | },
80 | "indexes": {},
81 | "foreignKeys": {},
82 | "compositePrimaryKeys": {},
83 | "uniqueConstraints": {}
84 | }
85 | },
86 | "enums": {},
87 | "schemas": {},
88 | "sequences": {},
89 | "_meta": {
90 | "columns": {},
91 | "schemas": {},
92 | "tables": {}
93 | }
94 | }
--------------------------------------------------------------------------------
/lib/db/migrations/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "postgresql",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "7",
8 | "when": 1728598022383,
9 | "tag": "0000_keen_devos",
10 | "breakpoints": true
11 | },
12 | {
13 | "idx": 1,
14 | "version": "7",
15 | "when": 1730207363999,
16 | "tag": "0001_sparkling_blue_marvel",
17 | "breakpoints": true
18 | },
19 | {
20 | "idx": 2,
21 | "version": "7",
22 | "when": 1730725226313,
23 | "tag": "0002_wandering_riptide",
24 | "breakpoints": true
25 | },
26 | {
27 | "idx": 3,
28 | "version": "7",
29 | "when": 1733403031014,
30 | "tag": "0003_cloudy_glorian",
31 | "breakpoints": true
32 | },
33 | {
34 | "idx": 4,
35 | "version": "7",
36 | "when": 1733945232355,
37 | "tag": "0004_odd_slayback",
38 | "breakpoints": true
39 | },
40 | {
41 | "idx": 5,
42 | "version": "7",
43 | "when": 1741934630596,
44 | "tag": "0005_wooden_whistler",
45 | "breakpoints": true
46 | },
47 | {
48 | "idx": 6,
49 | "version": "7",
50 | "when": 1746118166211,
51 | "tag": "0006_marvelous_frog_thor",
52 | "breakpoints": true
53 | }
54 | ]
55 | }
--------------------------------------------------------------------------------
/lib/db/utils.ts:
--------------------------------------------------------------------------------
1 | import { generateId } from 'ai';
2 | import { genSaltSync, hashSync } from 'bcrypt-ts';
3 |
4 | export function generateHashedPassword(password: string) {
5 | const salt = genSaltSync(10);
6 | const hash = hashSync(password, salt);
7 |
8 | return hash;
9 | }
10 |
11 | export function generateDummyPassword() {
12 | const password = generateId(12);
13 | const hashedPassword = generateHashedPassword(password);
14 |
15 | return hashedPassword;
16 | }
17 |
--------------------------------------------------------------------------------
/lib/editor/config.ts:
--------------------------------------------------------------------------------
1 | import { textblockTypeInputRule } from 'prosemirror-inputrules';
2 | import { Schema } from 'prosemirror-model';
3 | import { schema } from 'prosemirror-schema-basic';
4 | import { addListNodes } from 'prosemirror-schema-list';
5 | import type { Transaction } from 'prosemirror-state';
6 | import type { EditorView } from 'prosemirror-view';
7 | import type { MutableRefObject } from 'react';
8 |
9 | import { buildContentFromDocument } from './functions';
10 |
11 | export const documentSchema = new Schema({
12 | nodes: addListNodes(schema.spec.nodes, 'paragraph block*', 'block'),
13 | marks: schema.spec.marks,
14 | });
15 |
16 | export function headingRule(level: number) {
17 | return textblockTypeInputRule(
18 | new RegExp(`^(#{1,${level}})\\s$`),
19 | documentSchema.nodes.heading,
20 | () => ({ level }),
21 | );
22 | }
23 |
24 | export const handleTransaction = ({
25 | transaction,
26 | editorRef,
27 | onSaveContent,
28 | }: {
29 | transaction: Transaction;
30 | editorRef: MutableRefObject;
31 | onSaveContent: (updatedContent: string, debounce: boolean) => void;
32 | }) => {
33 | if (!editorRef || !editorRef.current) return;
34 |
35 | const newState = editorRef.current.state.apply(transaction);
36 | editorRef.current.updateState(newState);
37 |
38 | if (transaction.docChanged && !transaction.getMeta('no-save')) {
39 | const updatedContent = buildContentFromDocument(newState.doc);
40 |
41 | if (transaction.getMeta('no-debounce')) {
42 | onSaveContent(updatedContent, false);
43 | } else {
44 | onSaveContent(updatedContent, true);
45 | }
46 | }
47 | };
48 |
--------------------------------------------------------------------------------
/lib/editor/functions.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { defaultMarkdownSerializer } from 'prosemirror-markdown';
4 | import { DOMParser, type Node } from 'prosemirror-model';
5 | import { Decoration, DecorationSet, type EditorView } from 'prosemirror-view';
6 | import { renderToString } from 'react-dom/server';
7 |
8 | import { Markdown } from '@/components/markdown';
9 |
10 | import { documentSchema } from './config';
11 | import { createSuggestionWidget, type UISuggestion } from './suggestions';
12 |
13 | export const buildDocumentFromContent = (content: string) => {
14 | const parser = DOMParser.fromSchema(documentSchema);
15 | const stringFromMarkdown = renderToString({content});
16 | const tempContainer = document.createElement('div');
17 | tempContainer.innerHTML = stringFromMarkdown;
18 | return parser.parse(tempContainer);
19 | };
20 |
21 | export const buildContentFromDocument = (document: Node) => {
22 | return defaultMarkdownSerializer.serialize(document);
23 | };
24 |
25 | export const createDecorations = (
26 | suggestions: Array,
27 | view: EditorView,
28 | ) => {
29 | const decorations: Array = [];
30 |
31 | for (const suggestion of suggestions) {
32 | decorations.push(
33 | Decoration.inline(
34 | suggestion.selectionStart,
35 | suggestion.selectionEnd,
36 | {
37 | class: 'suggestion-highlight',
38 | },
39 | {
40 | suggestionId: suggestion.id,
41 | type: 'highlight',
42 | },
43 | ),
44 | );
45 |
46 | decorations.push(
47 | Decoration.widget(
48 | suggestion.selectionStart,
49 | (view) => {
50 | const { dom } = createSuggestionWidget(suggestion, view);
51 | return dom;
52 | },
53 | {
54 | suggestionId: suggestion.id,
55 | type: 'widget',
56 | },
57 | ),
58 | );
59 | }
60 |
61 | return DecorationSet.create(view.state.doc, decorations);
62 | };
63 |
--------------------------------------------------------------------------------
/lib/editor/react-renderer.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client';
2 |
3 | export class ReactRenderer {
4 | static render(component: React.ReactElement, dom: HTMLElement) {
5 | const root = createRoot(dom);
6 | root.render(component);
7 |
8 | return {
9 | destroy: () => root.unmount(),
10 | };
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/lib/types.ts:
--------------------------------------------------------------------------------
1 | export type DataPart = { type: 'append-message'; message: string };
2 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import type { CoreAssistantMessage, CoreToolMessage, UIMessage } from 'ai';
2 | import { type ClassValue, clsx } from 'clsx';
3 | import { twMerge } from 'tailwind-merge';
4 | import type { Document } from '@/lib/db/schema';
5 | import { ChatSDKError, type ErrorCode } from './errors';
6 |
7 | export function cn(...inputs: ClassValue[]) {
8 | return twMerge(clsx(inputs));
9 | }
10 |
11 | export const fetcher = async (url: string) => {
12 | const response = await fetch(url);
13 |
14 | if (!response.ok) {
15 | const { code, cause } = await response.json();
16 | throw new ChatSDKError(code as ErrorCode, cause);
17 | }
18 |
19 | return response.json();
20 | };
21 |
22 | export async function fetchWithErrorHandlers(
23 | input: RequestInfo | URL,
24 | init?: RequestInit,
25 | ) {
26 | try {
27 | const response = await fetch(input, init);
28 |
29 | if (!response.ok) {
30 | const { code, cause } = await response.json();
31 | throw new ChatSDKError(code as ErrorCode, cause);
32 | }
33 |
34 | return response;
35 | } catch (error: unknown) {
36 | if (typeof navigator !== 'undefined' && !navigator.onLine) {
37 | throw new ChatSDKError('offline:chat');
38 | }
39 |
40 | throw error;
41 | }
42 | }
43 |
44 | export function getLocalStorage(key: string) {
45 | if (typeof window !== 'undefined') {
46 | return JSON.parse(localStorage.getItem(key) || '[]');
47 | }
48 | return [];
49 | }
50 |
51 | export function generateUUID(): string {
52 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
53 | const r = (Math.random() * 16) | 0;
54 | const v = c === 'x' ? r : (r & 0x3) | 0x8;
55 | return v.toString(16);
56 | });
57 | }
58 |
59 | type ResponseMessageWithoutId = CoreToolMessage | CoreAssistantMessage;
60 | type ResponseMessage = ResponseMessageWithoutId & { id: string };
61 |
62 | export function getMostRecentUserMessage(messages: Array) {
63 | const userMessages = messages.filter((message) => message.role === 'user');
64 | return userMessages.at(-1);
65 | }
66 |
67 | export function getDocumentTimestampByIndex(
68 | documents: Array,
69 | index: number,
70 | ) {
71 | if (!documents) return new Date();
72 | if (index > documents.length) return new Date();
73 |
74 | return documents[index].createdAt;
75 | }
76 |
77 | export function getTrailingMessageId({
78 | messages,
79 | }: {
80 | messages: Array;
81 | }): string | null {
82 | const trailingMessage = messages.at(-1);
83 |
84 | if (!trailingMessage) return null;
85 |
86 | return trailingMessage.id;
87 | }
88 |
89 | export function sanitizeText(text: string) {
90 | return text.replace('', '');
91 | }
92 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse, type NextRequest } from 'next/server';
2 | import { getToken } from 'next-auth/jwt';
3 | import { guestRegex, isDevelopmentEnvironment } from './lib/constants';
4 |
5 | export async function middleware(request: NextRequest) {
6 | const { pathname } = request.nextUrl;
7 |
8 | /*
9 | * Playwright starts the dev server and requires a 200 status to
10 | * begin the tests, so this ensures that the tests can start
11 | */
12 | if (pathname.startsWith('/ping')) {
13 | return new Response('pong', { status: 200 });
14 | }
15 |
16 | if (pathname.startsWith('/api/auth')) {
17 | return NextResponse.next();
18 | }
19 |
20 | const token = await getToken({
21 | req: request,
22 | secret: process.env.AUTH_SECRET,
23 | secureCookie: !isDevelopmentEnvironment,
24 | });
25 |
26 | if (!token) {
27 | const redirectUrl = encodeURIComponent(request.url);
28 |
29 | return NextResponse.redirect(
30 | new URL(`/api/auth/guest?redirectUrl=${redirectUrl}`, request.url),
31 | );
32 | }
33 |
34 | const isGuest = guestRegex.test(token?.email ?? '');
35 |
36 | if (token && !isGuest && ['/login', '/register'].includes(pathname)) {
37 | return NextResponse.redirect(new URL('/', request.url));
38 | }
39 |
40 | return NextResponse.next();
41 | }
42 |
43 | export const config = {
44 | matcher: [
45 | '/',
46 | '/chat/:id',
47 | '/api/:path*',
48 | '/login',
49 | '/register',
50 |
51 | /*
52 | * Match all request paths except for the ones starting with:
53 | * - _next/static (static files)
54 | * - _next/image (image optimization files)
55 | * - favicon.ico, sitemap.xml, robots.txt (metadata files)
56 | */
57 | '/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
58 | ],
59 | };
60 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
6 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from 'next';
2 |
3 | const nextConfig: NextConfig = {
4 | experimental: {
5 | ppr: true,
6 | },
7 | images: {
8 | remotePatterns: [
9 | {
10 | hostname: 'avatar.vercel.sh',
11 | },
12 | ],
13 | },
14 | };
15 |
16 | export default nextConfig;
17 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ai-chatbot",
3 | "version": "3.0.23",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbo",
7 | "build": "tsx lib/db/migrate && next build",
8 | "start": "next start",
9 | "lint": "next lint && biome lint --write --unsafe",
10 | "lint:fix": "next lint --fix && biome lint --write --unsafe",
11 | "format": "biome format --write",
12 | "db:generate": "drizzle-kit generate",
13 | "db:migrate": "npx tsx lib/db/migrate.ts",
14 | "db:studio": "drizzle-kit studio",
15 | "db:push": "drizzle-kit push",
16 | "db:pull": "drizzle-kit pull",
17 | "db:check": "drizzle-kit check",
18 | "db:up": "drizzle-kit up",
19 | "test": "export PLAYWRIGHT=True && pnpm exec playwright test"
20 | },
21 | "dependencies": {
22 | "@ai-sdk/react": "^1.2.11",
23 | "@ai-sdk/xai": "^1.2.15",
24 | "@codemirror/lang-javascript": "^6.2.2",
25 | "@codemirror/lang-python": "^6.1.6",
26 | "@codemirror/state": "^6.5.0",
27 | "@codemirror/theme-one-dark": "^6.1.2",
28 | "@codemirror/view": "^6.35.3",
29 | "@opentelemetry/api": "^1.9.0",
30 | "@opentelemetry/api-logs": "^0.200.0",
31 | "@radix-ui/react-alert-dialog": "^1.1.2",
32 | "@radix-ui/react-dialog": "^1.1.2",
33 | "@radix-ui/react-dropdown-menu": "^2.1.2",
34 | "@radix-ui/react-icons": "^1.3.0",
35 | "@radix-ui/react-label": "^2.1.0",
36 | "@radix-ui/react-select": "^2.1.2",
37 | "@radix-ui/react-separator": "^1.1.0",
38 | "@radix-ui/react-slot": "^1.1.0",
39 | "@radix-ui/react-tooltip": "^1.1.3",
40 | "@radix-ui/react-visually-hidden": "^1.1.0",
41 | "@vercel/analytics": "^1.3.1",
42 | "@vercel/blob": "^0.24.1",
43 | "@vercel/functions": "^2.0.0",
44 | "@vercel/otel": "^1.12.0",
45 | "@vercel/postgres": "^0.10.0",
46 | "ai": "4.3.13",
47 | "bcrypt-ts": "^5.0.2",
48 | "class-variance-authority": "^0.7.0",
49 | "classnames": "^2.5.1",
50 | "clsx": "^2.1.1",
51 | "codemirror": "^6.0.1",
52 | "date-fns": "^4.1.0",
53 | "diff-match-patch": "^1.0.5",
54 | "dotenv": "^16.4.5",
55 | "drizzle-orm": "^0.34.0",
56 | "fast-deep-equal": "^3.1.3",
57 | "framer-motion": "^11.3.19",
58 | "geist": "^1.3.1",
59 | "lucide-react": "^0.446.0",
60 | "nanoid": "^5.0.8",
61 | "next": "15.3.0-canary.31",
62 | "next-auth": "5.0.0-beta.25",
63 | "next-themes": "^0.3.0",
64 | "orderedmap": "^2.1.1",
65 | "papaparse": "^5.5.2",
66 | "postgres": "^3.4.4",
67 | "prosemirror-example-setup": "^1.2.3",
68 | "prosemirror-inputrules": "^1.4.0",
69 | "prosemirror-markdown": "^1.13.1",
70 | "prosemirror-model": "^1.23.0",
71 | "prosemirror-schema-basic": "^1.2.3",
72 | "prosemirror-schema-list": "^1.4.1",
73 | "prosemirror-state": "^1.4.3",
74 | "prosemirror-view": "^1.34.3",
75 | "react": "19.0.0-rc-45804af1-20241021",
76 | "react-data-grid": "7.0.0-beta.47",
77 | "react-dom": "19.0.0-rc-45804af1-20241021",
78 | "react-markdown": "^9.0.1",
79 | "react-resizable-panels": "^2.1.7",
80 | "redis": "^5.0.0",
81 | "remark-gfm": "^4.0.0",
82 | "resumable-stream": "^2.0.0",
83 | "server-only": "^0.0.1",
84 | "sonner": "^1.5.0",
85 | "swr": "^2.2.5",
86 | "tailwind-merge": "^2.5.2",
87 | "tailwindcss-animate": "^1.0.7",
88 | "usehooks-ts": "^3.1.0",
89 | "zod": "^3.23.8"
90 | },
91 | "devDependencies": {
92 | "@biomejs/biome": "1.9.4",
93 | "@playwright/test": "^1.50.1",
94 | "@tailwindcss/typography": "^0.5.15",
95 | "@types/d3-scale": "^4.0.8",
96 | "@types/node": "^22.8.6",
97 | "@types/papaparse": "^5.3.15",
98 | "@types/pdf-parse": "^1.1.4",
99 | "@types/react": "^18",
100 | "@types/react-dom": "^18",
101 | "drizzle-kit": "^0.25.0",
102 | "eslint": "^8.57.0",
103 | "eslint-config-next": "14.2.5",
104 | "eslint-config-prettier": "^9.1.0",
105 | "eslint-import-resolver-typescript": "^3.6.3",
106 | "eslint-plugin-tailwindcss": "^3.17.5",
107 | "postcss": "^8",
108 | "tailwindcss": "^3.4.1",
109 | "tsx": "^4.19.1",
110 | "typescript": "^5.6.3"
111 | },
112 | "packageManager": "pnpm@9.12.3"
113 | }
114 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, devices } from '@playwright/test';
2 |
3 | /**
4 | * Read environment variables from file.
5 | * https://github.com/motdotla/dotenv
6 | */
7 | import { config } from 'dotenv';
8 |
9 | config({
10 | path: '.env.local',
11 | });
12 |
13 | /* Use process.env.PORT by default and fallback to port 3000 */
14 | const PORT = process.env.PORT || 3000;
15 |
16 | /**
17 | * Set webServer.url and use.baseURL with the location
18 | * of the WebServer respecting the correct set port
19 | */
20 | const baseURL = `http://localhost:${PORT}`;
21 |
22 | /**
23 | * See https://playwright.dev/docs/test-configuration.
24 | */
25 | export default defineConfig({
26 | testDir: './tests',
27 | /* Run tests in files in parallel */
28 | fullyParallel: true,
29 | /* Fail the build on CI if you accidentally left test.only in the source code. */
30 | forbidOnly: !!process.env.CI,
31 | /* Retry on CI only */
32 | retries: 0,
33 | /* Opt out of parallel tests on CI. */
34 | workers: process.env.CI ? 2 : 8,
35 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */
36 | reporter: 'html',
37 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
38 | use: {
39 | /* Base URL to use in actions like `await page.goto('/')`. */
40 | baseURL,
41 |
42 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
43 | trace: 'retain-on-failure',
44 | },
45 |
46 | /* Configure global timeout for each test */
47 | timeout: 120 * 1000, // 120 seconds
48 | expect: {
49 | timeout: 120 * 1000,
50 | },
51 |
52 | /* Configure projects */
53 | projects: [
54 | {
55 | name: 'e2e',
56 | testMatch: /e2e\/.*.test.ts/,
57 | use: {
58 | ...devices['Desktop Chrome'],
59 | },
60 | },
61 | {
62 | name: 'routes',
63 | testMatch: /routes\/.*.test.ts/,
64 | use: {
65 | ...devices['Desktop Chrome'],
66 | },
67 | },
68 |
69 | // {
70 | // name: 'firefox',
71 | // use: { ...devices['Desktop Firefox'] },
72 | // },
73 |
74 | // {
75 | // name: 'webkit',
76 | // use: { ...devices['Desktop Safari'] },
77 | // },
78 |
79 | /* Test against mobile viewports. */
80 | // {
81 | // name: 'Mobile Chrome',
82 | // use: { ...devices['Pixel 5'] },
83 | // },
84 | // {
85 | // name: 'Mobile Safari',
86 | // use: { ...devices['iPhone 12'] },
87 | // },
88 |
89 | /* Test against branded browsers. */
90 | // {
91 | // name: 'Microsoft Edge',
92 | // use: { ...devices['Desktop Edge'], channel: 'msedge' },
93 | // },
94 | // {
95 | // name: 'Google Chrome',
96 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' },
97 | // },
98 | ],
99 |
100 | /* Run your local dev server before starting the tests */
101 | webServer: {
102 | command: 'pnpm dev',
103 | url: `${baseURL}/ping`,
104 | timeout: 120 * 1000,
105 | reuseExistingServer: !process.env.CI,
106 | },
107 | });
108 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | 'tailwindcss/nesting': {},
6 | },
7 | };
8 |
9 | export default config;
10 |
--------------------------------------------------------------------------------
/public/images/demo-thumbnail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vercel/ai-chatbot/7d8e71383f55c766ca575da2cac0a8d89283c031/public/images/demo-thumbnail.png
--------------------------------------------------------------------------------
/public/images/mouth of the seine, monet.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vercel/ai-chatbot/7d8e71383f55c766ca575da2cac0a8d89283c031/public/images/mouth of the seine, monet.jpg
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 |
3 | const config: Config = {
4 | darkMode: ['class'],
5 | content: [
6 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
7 | './components/**/*.{js,ts,jsx,tsx,mdx}',
8 | './app/**/*.{js,ts,jsx,tsx,mdx}',
9 | ],
10 | theme: {
11 | extend: {
12 | fontFamily: {
13 | sans: ['var(--font-geist)'],
14 | mono: ['var(--font-geist-mono)'],
15 | },
16 | screens: {
17 | 'toast-mobile': '600px',
18 | },
19 | borderRadius: {
20 | lg: 'var(--radius)',
21 | md: 'calc(var(--radius) - 2px)',
22 | sm: 'calc(var(--radius) - 4px)',
23 | },
24 | colors: {
25 | background: 'hsl(var(--background))',
26 | foreground: 'hsl(var(--foreground))',
27 | card: {
28 | DEFAULT: 'hsl(var(--card))',
29 | foreground: 'hsl(var(--card-foreground))',
30 | },
31 | popover: {
32 | DEFAULT: 'hsl(var(--popover))',
33 | foreground: 'hsl(var(--popover-foreground))',
34 | },
35 | primary: {
36 | DEFAULT: 'hsl(var(--primary))',
37 | foreground: 'hsl(var(--primary-foreground))',
38 | },
39 | secondary: {
40 | DEFAULT: 'hsl(var(--secondary))',
41 | foreground: 'hsl(var(--secondary-foreground))',
42 | },
43 | muted: {
44 | DEFAULT: 'hsl(var(--muted))',
45 | foreground: 'hsl(var(--muted-foreground))',
46 | },
47 | accent: {
48 | DEFAULT: 'hsl(var(--accent))',
49 | foreground: 'hsl(var(--accent-foreground))',
50 | },
51 | destructive: {
52 | DEFAULT: 'hsl(var(--destructive))',
53 | foreground: 'hsl(var(--destructive-foreground))',
54 | },
55 | border: 'hsl(var(--border))',
56 | input: 'hsl(var(--input))',
57 | ring: 'hsl(var(--ring))',
58 | chart: {
59 | '1': 'hsl(var(--chart-1))',
60 | '2': 'hsl(var(--chart-2))',
61 | '3': 'hsl(var(--chart-3))',
62 | '4': 'hsl(var(--chart-4))',
63 | '5': 'hsl(var(--chart-5))',
64 | },
65 | sidebar: {
66 | DEFAULT: 'hsl(var(--sidebar-background))',
67 | foreground: 'hsl(var(--sidebar-foreground))',
68 | primary: 'hsl(var(--sidebar-primary))',
69 | 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
70 | accent: 'hsl(var(--sidebar-accent))',
71 | 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
72 | border: 'hsl(var(--sidebar-border))',
73 | ring: 'hsl(var(--sidebar-ring))',
74 | },
75 | },
76 | },
77 | },
78 | plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
79 | };
80 | export default config;
81 |
--------------------------------------------------------------------------------
/tests/e2e/artifacts.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from '../fixtures';
2 | import { ChatPage } from '../pages/chat';
3 | import { ArtifactPage } from '../pages/artifact';
4 |
5 | test.describe('Artifacts activity', () => {
6 | let chatPage: ChatPage;
7 | let artifactPage: ArtifactPage;
8 |
9 | test.beforeEach(async ({ page }) => {
10 | chatPage = new ChatPage(page);
11 | artifactPage = new ArtifactPage(page);
12 |
13 | await chatPage.createNewChat();
14 | });
15 |
16 | test('Create a text artifact', async () => {
17 | await chatPage.createNewChat();
18 |
19 | await chatPage.sendUserMessage(
20 | 'Help me write an essay about Silicon Valley',
21 | );
22 | await artifactPage.isGenerationComplete();
23 |
24 | expect(artifactPage.artifact).toBeVisible();
25 |
26 | const assistantMessage = await chatPage.getRecentAssistantMessage();
27 | expect(assistantMessage.content).toBe(
28 | 'A document was created and is now visible to the user.',
29 | );
30 |
31 | await chatPage.hasChatIdInUrl();
32 | });
33 |
34 | test('Toggle artifact visibility', async () => {
35 | await chatPage.createNewChat();
36 |
37 | await chatPage.sendUserMessage(
38 | 'Help me write an essay about Silicon Valley',
39 | );
40 | await artifactPage.isGenerationComplete();
41 |
42 | expect(artifactPage.artifact).toBeVisible();
43 |
44 | const assistantMessage = await chatPage.getRecentAssistantMessage();
45 | expect(assistantMessage.content).toBe(
46 | 'A document was created and is now visible to the user.',
47 | );
48 |
49 | await artifactPage.closeArtifact();
50 | await chatPage.isElementNotVisible('artifact');
51 | });
52 |
53 | test('Send follow up message after generation', async () => {
54 | await chatPage.createNewChat();
55 |
56 | await chatPage.sendUserMessage(
57 | 'Help me write an essay about Silicon Valley',
58 | );
59 | await artifactPage.isGenerationComplete();
60 |
61 | expect(artifactPage.artifact).toBeVisible();
62 |
63 | const assistantMessage = await artifactPage.getRecentAssistantMessage();
64 | expect(assistantMessage.content).toBe(
65 | 'A document was created and is now visible to the user.',
66 | );
67 |
68 | await artifactPage.sendUserMessage('Thanks!');
69 | await artifactPage.isGenerationComplete();
70 |
71 | const secondAssistantMessage = await chatPage.getRecentAssistantMessage();
72 | expect(secondAssistantMessage.content).toBe("You're welcome!");
73 | });
74 | });
75 |
--------------------------------------------------------------------------------
/tests/e2e/reasoning.test.ts:
--------------------------------------------------------------------------------
1 | import { ChatPage } from '../pages/chat';
2 | import { test, expect } from '../fixtures';
3 |
4 | test.describe('chat activity with reasoning', () => {
5 | let chatPage: ChatPage;
6 |
7 | test.beforeEach(async ({ curieContext }) => {
8 | chatPage = new ChatPage(curieContext.page);
9 | await chatPage.createNewChat();
10 | });
11 |
12 | test('Curie can send message and generate response with reasoning', async () => {
13 | await chatPage.sendUserMessage('Why is the sky blue?');
14 | await chatPage.isGenerationComplete();
15 |
16 | const assistantMessage = await chatPage.getRecentAssistantMessage();
17 | expect(assistantMessage.content).toBe("It's just blue duh!");
18 |
19 | expect(assistantMessage.reasoning).toBe(
20 | 'The sky is blue because of rayleigh scattering!',
21 | );
22 | });
23 |
24 | test('Curie can toggle reasoning visibility', async () => {
25 | await chatPage.sendUserMessage('Why is the sky blue?');
26 | await chatPage.isGenerationComplete();
27 |
28 | const assistantMessage = await chatPage.getRecentAssistantMessage();
29 | const reasoningElement =
30 | assistantMessage.element.getByTestId('message-reasoning');
31 | expect(reasoningElement).toBeVisible();
32 |
33 | await assistantMessage.toggleReasoningVisibility();
34 | await expect(reasoningElement).not.toBeVisible();
35 |
36 | await assistantMessage.toggleReasoningVisibility();
37 | await expect(reasoningElement).toBeVisible();
38 | });
39 |
40 | test('Curie can edit message and resubmit', async () => {
41 | await chatPage.sendUserMessage('Why is the sky blue?');
42 | await chatPage.isGenerationComplete();
43 |
44 | const assistantMessage = await chatPage.getRecentAssistantMessage();
45 | const reasoningElement =
46 | assistantMessage.element.getByTestId('message-reasoning');
47 | expect(reasoningElement).toBeVisible();
48 |
49 | const userMessage = await chatPage.getRecentUserMessage();
50 |
51 | await userMessage.edit('Why is grass green?');
52 | await chatPage.isGenerationComplete();
53 |
54 | const updatedAssistantMessage = await chatPage.getRecentAssistantMessage();
55 |
56 | expect(updatedAssistantMessage.content).toBe("It's just green duh!");
57 |
58 | expect(updatedAssistantMessage.reasoning).toBe(
59 | 'Grass is green because of chlorophyll absorption!',
60 | );
61 | });
62 | });
63 |
--------------------------------------------------------------------------------
/tests/fixtures.ts:
--------------------------------------------------------------------------------
1 | import { expect as baseExpect, test as baseTest } from '@playwright/test';
2 | import { createAuthenticatedContext, type UserContext } from './helpers';
3 | import { getUnixTime } from 'date-fns';
4 |
5 | interface Fixtures {
6 | adaContext: UserContext;
7 | babbageContext: UserContext;
8 | curieContext: UserContext;
9 | }
10 |
11 | export const test = baseTest.extend<{}, Fixtures>({
12 | adaContext: [
13 | async ({ browser }, use, workerInfo) => {
14 | const ada = await createAuthenticatedContext({
15 | browser,
16 | name: `ada-${workerInfo.workerIndex}-${getUnixTime(new Date())}`,
17 | });
18 |
19 | await use(ada);
20 | await ada.context.close();
21 | },
22 | { scope: 'worker' },
23 | ],
24 | babbageContext: [
25 | async ({ browser }, use, workerInfo) => {
26 | const babbage = await createAuthenticatedContext({
27 | browser,
28 | name: `babbage-${workerInfo.workerIndex}-${getUnixTime(new Date())}`,
29 | });
30 |
31 | await use(babbage);
32 | await babbage.context.close();
33 | },
34 | { scope: 'worker' },
35 | ],
36 | curieContext: [
37 | async ({ browser }, use, workerInfo) => {
38 | const curie = await createAuthenticatedContext({
39 | browser,
40 | name: `curie-${workerInfo.workerIndex}-${getUnixTime(new Date())}`,
41 | chatModel: 'chat-model-reasoning',
42 | });
43 |
44 | await use(curie);
45 | await curie.context.close();
46 | },
47 | { scope: 'worker' },
48 | ],
49 | });
50 |
51 | export const expect = baseExpect;
52 |
--------------------------------------------------------------------------------
/tests/helpers.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs';
2 | import path from 'node:path';
3 | import {
4 | type APIRequestContext,
5 | type Browser,
6 | type BrowserContext,
7 | expect,
8 | type Page,
9 | } from '@playwright/test';
10 | import { generateId } from 'ai';
11 | import { ChatPage } from './pages/chat';
12 | import { getUnixTime } from 'date-fns';
13 |
14 | export type UserContext = {
15 | context: BrowserContext;
16 | page: Page;
17 | request: APIRequestContext;
18 | };
19 |
20 | export async function createAuthenticatedContext({
21 | browser,
22 | name,
23 | chatModel = 'chat-model',
24 | }: {
25 | browser: Browser;
26 | name: string;
27 | chatModel?: 'chat-model' | 'chat-model-reasoning';
28 | }): Promise {
29 | const directory = path.join(__dirname, '../playwright/.sessions');
30 |
31 | if (!fs.existsSync(directory)) {
32 | fs.mkdirSync(directory, { recursive: true });
33 | }
34 |
35 | const storageFile = path.join(directory, `${name}.json`);
36 |
37 | const context = await browser.newContext();
38 | const page = await context.newPage();
39 |
40 | const email = `test-${name}@playwright.com`;
41 | const password = generateId(16);
42 |
43 | await page.goto('http://localhost:3000/register');
44 | await page.getByPlaceholder('user@acme.com').click();
45 | await page.getByPlaceholder('user@acme.com').fill(email);
46 | await page.getByLabel('Password').click();
47 | await page.getByLabel('Password').fill(password);
48 | await page.getByRole('button', { name: 'Sign Up' }).click();
49 |
50 | await expect(page.getByTestId('toast')).toContainText(
51 | 'Account created successfully!',
52 | );
53 |
54 | const chatPage = new ChatPage(page);
55 | await chatPage.createNewChat();
56 | await chatPage.chooseModelFromSelector('chat-model-reasoning');
57 | await expect(chatPage.getSelectedModel()).resolves.toEqual('Reasoning model');
58 |
59 | await page.waitForTimeout(1000);
60 | await context.storageState({ path: storageFile });
61 | await page.close();
62 |
63 | const newContext = await browser.newContext({ storageState: storageFile });
64 | const newPage = await newContext.newPage();
65 |
66 | return {
67 | context: newContext,
68 | page: newPage,
69 | request: newContext.request,
70 | };
71 | }
72 |
73 | export function generateRandomTestUser() {
74 | const email = `test-${getUnixTime(new Date())}@playwright.com`;
75 | const password = generateId(16);
76 |
77 | return {
78 | email,
79 | password,
80 | };
81 | }
82 |
--------------------------------------------------------------------------------
/tests/pages/artifact.ts:
--------------------------------------------------------------------------------
1 | import { expect, Page } from '@playwright/test';
2 |
3 | export class ArtifactPage {
4 | constructor(private page: Page) {}
5 |
6 | public get artifact() {
7 | return this.page.getByTestId('artifact');
8 | }
9 |
10 | public get sendButton() {
11 | return this.artifact.getByTestId('send-button');
12 | }
13 |
14 | public get stopButton() {
15 | return this.page.getByTestId('stop-button');
16 | }
17 |
18 | public get multimodalInput() {
19 | return this.page.getByTestId('multimodal-input');
20 | }
21 |
22 | async isGenerationComplete() {
23 | const response = await this.page.waitForResponse((response) =>
24 | response.url().includes('/api/chat'),
25 | );
26 |
27 | await response.finished();
28 | }
29 |
30 | async sendUserMessage(message: string) {
31 | await this.artifact.getByTestId('multimodal-input').click();
32 | await this.artifact.getByTestId('multimodal-input').fill(message);
33 | await this.artifact.getByTestId('send-button').click();
34 | }
35 |
36 | async getRecentAssistantMessage() {
37 | const messageElements = await this.artifact
38 | .getByTestId('message-assistant')
39 | .all();
40 | const lastMessageElement = messageElements[messageElements.length - 1];
41 |
42 | const content = await lastMessageElement
43 | .getByTestId('message-content')
44 | .innerText()
45 | .catch(() => null);
46 |
47 | const reasoningElement = await lastMessageElement
48 | .getByTestId('message-reasoning')
49 | .isVisible()
50 | .then(async (visible) =>
51 | visible
52 | ? await lastMessageElement
53 | .getByTestId('message-reasoning')
54 | .innerText()
55 | : null,
56 | )
57 | .catch(() => null);
58 |
59 | return {
60 | element: lastMessageElement,
61 | content,
62 | reasoning: reasoningElement,
63 | async toggleReasoningVisibility() {
64 | await lastMessageElement
65 | .getByTestId('message-reasoning-toggle')
66 | .click();
67 | },
68 | };
69 | }
70 |
71 | async getRecentUserMessage() {
72 | const messageElements = await this.artifact
73 | .getByTestId('message-user')
74 | .all();
75 | const lastMessageElement = messageElements[messageElements.length - 1];
76 |
77 | const content = await lastMessageElement.innerText();
78 |
79 | const hasAttachments = await lastMessageElement
80 | .getByTestId('message-attachments')
81 | .isVisible()
82 | .catch(() => false);
83 |
84 | const attachments = hasAttachments
85 | ? await lastMessageElement.getByTestId('message-attachments').all()
86 | : [];
87 |
88 | const page = this.artifact;
89 |
90 | return {
91 | element: lastMessageElement,
92 | content,
93 | attachments,
94 | async edit(newMessage: string) {
95 | await page.getByTestId('message-edit-button').click();
96 | await page.getByTestId('message-editor').fill(newMessage);
97 | await page.getByTestId('message-editor-send-button').click();
98 | await expect(
99 | page.getByTestId('message-editor-send-button'),
100 | ).not.toBeVisible();
101 | },
102 | };
103 | }
104 |
105 | async closeArtifact() {
106 | return this.page.getByTestId('artifact-close-button').click();
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/tests/pages/auth.ts:
--------------------------------------------------------------------------------
1 | import type { Page } from '@playwright/test';
2 | import { expect } from '../fixtures';
3 |
4 | export class AuthPage {
5 | constructor(private page: Page) {}
6 |
7 | async gotoLogin() {
8 | await this.page.goto('/login');
9 | await expect(this.page.getByRole('heading')).toContainText('Sign In');
10 | }
11 |
12 | async gotoRegister() {
13 | await this.page.goto('/register');
14 | await expect(this.page.getByRole('heading')).toContainText('Sign Up');
15 | }
16 |
17 | async register(email: string, password: string) {
18 | await this.gotoRegister();
19 | await this.page.getByPlaceholder('user@acme.com').click();
20 | await this.page.getByPlaceholder('user@acme.com').fill(email);
21 | await this.page.getByLabel('Password').click();
22 | await this.page.getByLabel('Password').fill(password);
23 | await this.page.getByRole('button', { name: 'Sign Up' }).click();
24 | }
25 |
26 | async login(email: string, password: string) {
27 | await this.gotoLogin();
28 | await this.page.getByPlaceholder('user@acme.com').click();
29 | await this.page.getByPlaceholder('user@acme.com').fill(email);
30 | await this.page.getByLabel('Password').click();
31 | await this.page.getByLabel('Password').fill(password);
32 | await this.page.getByRole('button', { name: 'Sign In' }).click();
33 | }
34 |
35 | async logout(email: string, password: string) {
36 | await this.login(email, password);
37 | await this.page.waitForURL('/');
38 |
39 | await this.openSidebar();
40 |
41 | const userNavButton = this.page.getByTestId('user-nav-button');
42 | await expect(userNavButton).toBeVisible();
43 |
44 | await userNavButton.click();
45 | const userNavMenu = this.page.getByTestId('user-nav-menu');
46 | await expect(userNavMenu).toBeVisible();
47 |
48 | const authMenuItem = this.page.getByTestId('user-nav-item-auth');
49 | await expect(authMenuItem).toContainText('Sign out');
50 |
51 | await authMenuItem.click();
52 |
53 | const userEmail = this.page.getByTestId('user-email');
54 | await expect(userEmail).toContainText('Guest');
55 | }
56 |
57 | async expectToastToContain(text: string) {
58 | await expect(this.page.getByTestId('toast')).toContainText(text);
59 | }
60 |
61 | async openSidebar() {
62 | const sidebarToggleButton = this.page.getByTestId('sidebar-toggle-button');
63 | await sidebarToggleButton.click();
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/tests/prompts/basic.ts:
--------------------------------------------------------------------------------
1 | import type { CoreMessage } from 'ai';
2 |
3 | export const TEST_PROMPTS: Record = {
4 | USER_SKY: {
5 | role: 'user',
6 | content: [{ type: 'text', text: 'Why is the sky blue?' }],
7 | },
8 | USER_GRASS: {
9 | role: 'user',
10 | content: [{ type: 'text', text: 'Why is grass green?' }],
11 | },
12 | USER_THANKS: {
13 | role: 'user',
14 | content: [{ type: 'text', text: 'Thanks!' }],
15 | },
16 | USER_NEXTJS: {
17 | role: 'user',
18 | content: [
19 | { type: 'text', text: 'What are the advantages of using Next.js?' },
20 | ],
21 | },
22 | USER_IMAGE_ATTACHMENT: {
23 | role: 'user',
24 | content: [
25 | {
26 | type: 'text',
27 | text: 'Who painted this?',
28 | },
29 | {
30 | type: 'image',
31 | image: '...',
32 | },
33 | ],
34 | },
35 | USER_TEXT_ARTIFACT: {
36 | role: 'user',
37 | content: [
38 | {
39 | type: 'text',
40 | text: 'Help me write an essay about Silicon Valley',
41 | },
42 | ],
43 | },
44 | CREATE_DOCUMENT_TEXT_CALL: {
45 | role: 'user',
46 | content: [
47 | {
48 | type: 'text',
49 | text: 'Essay about Silicon Valley',
50 | },
51 | ],
52 | },
53 | CREATE_DOCUMENT_TEXT_RESULT: {
54 | role: 'tool',
55 | content: [
56 | {
57 | type: 'tool-result',
58 | toolCallId: 'call_123',
59 | toolName: 'createDocument',
60 | result: {
61 | id: '3ca386a4-40c6-4630-8ed1-84cbd46cc7eb',
62 | title: 'Essay about Silicon Valley',
63 | kind: 'text',
64 | content: 'A document was created and is now visible to the user.',
65 | },
66 | },
67 | ],
68 | },
69 | GET_WEATHER_CALL: {
70 | role: 'user',
71 | content: [
72 | {
73 | type: 'text',
74 | text: "What's the weather in sf?",
75 | },
76 | ],
77 | },
78 | GET_WEATHER_RESULT: {
79 | role: 'tool',
80 | content: [
81 | {
82 | type: 'tool-result',
83 | toolCallId: 'call_456',
84 | toolName: 'getWeather',
85 | result: {
86 | latitude: 37.763283,
87 | longitude: -122.41286,
88 | generationtime_ms: 0.06449222564697266,
89 | utc_offset_seconds: -25200,
90 | timezone: 'America/Los_Angeles',
91 | timezone_abbreviation: 'GMT-7',
92 | elevation: 18,
93 | current_units: {
94 | time: 'iso8601',
95 | interval: 'seconds',
96 | temperature_2m: '°C',
97 | },
98 | current: {
99 | time: '2025-03-10T14:00',
100 | interval: 900,
101 | temperature_2m: 17,
102 | },
103 | daily_units: {
104 | time: 'iso8601',
105 | sunrise: 'iso8601',
106 | sunset: 'iso8601',
107 | },
108 | daily: {
109 | time: [
110 | '2025-03-10',
111 | '2025-03-11',
112 | '2025-03-12',
113 | '2025-03-13',
114 | '2025-03-14',
115 | '2025-03-15',
116 | '2025-03-16',
117 | ],
118 | sunrise: [
119 | '2025-03-10T07:27',
120 | '2025-03-11T07:25',
121 | '2025-03-12T07:24',
122 | '2025-03-13T07:22',
123 | '2025-03-14T07:21',
124 | '2025-03-15T07:19',
125 | '2025-03-16T07:18',
126 | ],
127 | sunset: [
128 | '2025-03-10T19:12',
129 | '2025-03-11T19:13',
130 | '2025-03-12T19:14',
131 | '2025-03-13T19:15',
132 | '2025-03-14T19:16',
133 | '2025-03-15T19:17',
134 | '2025-03-16T19:17',
135 | ],
136 | },
137 | },
138 | },
139 | ],
140 | },
141 | };
142 |
--------------------------------------------------------------------------------
/tests/prompts/routes.ts:
--------------------------------------------------------------------------------
1 | import { generateUUID } from '@/lib/utils';
2 |
3 | export const TEST_PROMPTS = {
4 | SKY: {
5 | MESSAGE: {
6 | id: generateUUID(),
7 | createdAt: new Date().toISOString(),
8 | role: 'user',
9 | content: 'Why is the sky blue?',
10 | parts: [{ type: 'text', text: 'Why is the sky blue?' }],
11 | },
12 | OUTPUT_STREAM: [
13 | '0:"It\'s "',
14 | '0:"just "',
15 | '0:"blue "',
16 | '0:"duh! "',
17 | 'e:{"finishReason":"stop","usage":{"promptTokens":3,"completionTokens":10},"isContinued":false}',
18 | 'd:{"finishReason":"stop","usage":{"promptTokens":3,"completionTokens":10}}',
19 | ],
20 | },
21 | GRASS: {
22 | MESSAGE: {
23 | id: generateUUID(),
24 | createdAt: new Date().toISOString(),
25 | role: 'user',
26 | content: 'Why is grass green?',
27 | parts: [{ type: 'text', text: 'Why is grass green?' }],
28 | },
29 |
30 | OUTPUT_STREAM: [
31 | '0:"It\'s "',
32 | '0:"just "',
33 | '0:"green "',
34 | '0:"duh! "',
35 | 'e:{"finishReason":"stop","usage":{"promptTokens":3,"completionTokens":10},"isContinued":false}',
36 | 'd:{"finishReason":"stop","usage":{"promptTokens":3,"completionTokens":10}}',
37 | ],
38 | },
39 | };
40 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": [
26 | "next-env.d.ts",
27 | "**/*.ts",
28 | "**/*.tsx",
29 | ".next/types/**/*.ts",
30 | "next.config.js"
31 | ],
32 | "exclude": ["node_modules"]
33 | }
34 |
--------------------------------------------------------------------------------