diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000000000000000000000000000000000000..6a40fdd61a94e06acf0747d0385114b9d59e9adc
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,19 @@
+# Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
+AUTH_SECRET=****
+
+# The following keys below are automatically created and
+# added to your environment when you deploy on vercel
+
+# Get your xAI API Key here for chat and image models: https://console.x.ai/
+XAI_API_KEY=****
+
+# Instructions to create a Vercel Blob Store here: https://vercel.com/docs/storage/vercel-blob
+BLOB_READ_WRITE_TOKEN=****
+
+# Instructions to create a PostgreSQL database here: https://vercel.com/docs/storage/vercel-postgres/quickstart
+POSTGRES_URL=****
+
+
+# Instructions to create a Redis store here:
+# https://vercel.com/docs/redis
+REDIS_URL=****
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000000000000000000000000000000000000..a44aff84ac5ce70c29748de56a9adfddfa34ad2f
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,23 @@
+{
+ "extends": [
+ "next/core-web-vitals",
+ "plugin:import/recommended",
+ "plugin:import/typescript",
+ "prettier",
+ "plugin:tailwindcss/recommended"
+ ],
+ "plugins": ["tailwindcss"],
+ "rules": {
+ "tailwindcss/no-custom-classname": "off",
+ "tailwindcss/classnames-order": "off"
+ },
+ "settings": {
+ "import/resolver": {
+ "typescript": {
+ "alwaysTryTypes": true,
+ "project": "./tsconfig.json"
+ }
+ }
+ },
+ "ignorePatterns": ["**/components/ui/**"]
+}
diff --git a/.gitattributes b/.gitattributes
index a6344aac8c09253b3b630fb776ae94478aa0275b..0437453b0e127df202752375d169d5987d80694a 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.zst filter=lfs diff=lfs merge=lfs -text
*tfevents* filter=lfs diff=lfs merge=lfs -text
+app/(chat)/opengraph-image.png filter=lfs diff=lfs merge=lfs -text
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..54b31f9fcfd3f5ec40cf48a83763d0e6179cc78f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,43 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+node_modules
+.pnp
+.pnp.js
+
+# testing
+coverage
+
+# next.js
+.next/
+out/
+build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# local env files
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+# turbo
+.turbo
+
+.env
+.vercel
+.env*.local
+
+# Playwright
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/*
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000000000000000000000000000000000000..699ed73319bc2151d50e5b1b2802e55c2f513119
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": ["biomejs.biome"]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000000000000000000000000000000000000..fb5a7ca0b58a4b703119131fc664f2edbeac6ec1
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,17 @@
+{
+ "editor.formatOnSave": true,
+ "[javascript]": {
+ "editor.defaultFormatter": "biomejs.biome"
+ },
+ "[typescript]": {
+ "editor.defaultFormatter": "biomejs.biome"
+ },
+ "[typescriptreact]": {
+ "editor.defaultFormatter": "biomejs.biome"
+ },
+ "typescript.tsdk": "node_modules/typescript/lib",
+ "eslint.workingDirectories": [
+ { "pattern": "app/*" },
+ { "pattern": "packages/*" }
+ ]
+}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..81da705069139a5cf8cf9d9c63ceaedd91bf4b9c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,19 @@
+FROM node:20-alpine
+USER root
+
+USER 1000
+WORKDIR /usr/src/app
+# Copy package.json and package-lock.json to the container
+COPY --chown=1000 package.json package-lock.json ./
+
+# Copy the rest of the application files to the container
+COPY --chown=1000 . .
+
+RUN npm install
+RUN npm run build
+
+# Expose the application port (assuming your app runs on port 3000)
+EXPOSE 3000
+
+# Start the application
+CMD ["npm", "start"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..695ee2d6a04b691b3f3aa1c4e76e7c4b3a931042
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,13 @@
+Copyright 2024 Vercel, Inc.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
\ No newline at end of file
diff --git a/README.md b/README.md
index 855c4c1ab5851d6c2225eab7440696382bd92b75..44a8cb208c5a2e8f9085877be23a0b42383691d7 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,62 @@
----
-title: Next Chat
-emoji: 👀
-colorFrom: yellow
-colorTo: purple
-sdk: docker
-pinned: false
----
-
-Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
+
+
+ Chat SDK
+
+
+
+ Chat SDK is a free, open-source template built with Next.js and the AI SDK that helps you quickly build powerful chatbot applications.
+
+
+
+ Read Docs ·
+ Features ·
+ Model Providers ·
+ Deploy Your Own ·
+ Running locally
+
+
+
+## Features
+
+- [Next.js](https://nextjs.org) App Router
+ - Advanced routing for seamless navigation and performance
+ - React Server Components (RSCs) and Server Actions for server-side rendering and increased performance
+- [AI SDK](https://sdk.vercel.ai/docs)
+ - Unified API for generating text, structured objects, and tool calls with LLMs
+ - Hooks for building dynamic chat and generative user interfaces
+ - Supports xAI (default), OpenAI, Fireworks, and other model providers
+- [shadcn/ui](https://ui.shadcn.com)
+ - Styling with [Tailwind CSS](https://tailwindcss.com)
+ - Component primitives from [Radix UI](https://radix-ui.com) for accessibility and flexibility
+- Data Persistence
+ - [Neon Serverless Postgres](https://vercel.com/marketplace/neon) for saving chat history and user data
+ - [Vercel Blob](https://vercel.com/storage/blob) for efficient file storage
+- [Auth.js](https://authjs.dev)
+ - Simple and secure authentication
+
+## Model Providers
+
+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.
+
+## Deploy Your Own
+
+You can deploy your own version of the Next.js AI Chatbot to Vercel with one click:
+
+[](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)
+
+## Running locally
+
+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.
+
+> 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.
+
+1. Install Vercel CLI: `npm i -g vercel`
+2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
+3. Download your environment variables: `vercel env pull`
+
+```bash
+pnpm install
+pnpm dev
+```
+
+Your app template should now be running on [localhost:3000](http://localhost:3000).
diff --git a/app/(auth)/actions.ts b/app/(auth)/actions.ts
new file mode 100644
index 0000000000000000000000000000000000000000..84f8ffde4391e2f19d453770bbf6444b239f44ec
--- /dev/null
+++ b/app/(auth)/actions.ts
@@ -0,0 +1,84 @@
+'use server';
+
+import { z } from 'zod';
+
+import { createUser, getUser } from '@/lib/db/queries';
+
+import { signIn } from './auth';
+
+const authFormSchema = z.object({
+ email: z.string().email(),
+ password: z.string().min(6),
+});
+
+export interface LoginActionState {
+ status: 'idle' | 'in_progress' | 'success' | 'failed' | 'invalid_data';
+}
+
+export const login = async (
+ _: LoginActionState,
+ formData: FormData,
+): Promise => {
+ try {
+ const validatedData = authFormSchema.parse({
+ email: formData.get('email'),
+ password: formData.get('password'),
+ });
+
+ await signIn('credentials', {
+ email: validatedData.email,
+ password: validatedData.password,
+ redirect: false,
+ });
+
+ return { status: 'success' };
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return { status: 'invalid_data' };
+ }
+
+ return { status: 'failed' };
+ }
+};
+
+export interface RegisterActionState {
+ status:
+ | 'idle'
+ | 'in_progress'
+ | 'success'
+ | 'failed'
+ | 'user_exists'
+ | 'invalid_data';
+}
+
+export const register = async (
+ _: RegisterActionState,
+ formData: FormData,
+): Promise => {
+ try {
+ const validatedData = authFormSchema.parse({
+ email: formData.get('email'),
+ password: formData.get('password'),
+ });
+
+ const [user] = await getUser(validatedData.email);
+
+ if (user) {
+ return { status: 'user_exists' } as RegisterActionState;
+ }
+ await createUser(validatedData.email, validatedData.password);
+ await signIn('credentials', {
+ email: validatedData.email,
+ password: validatedData.password,
+ redirect: false,
+ });
+
+ return { status: 'success' };
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return { status: 'invalid_data' };
+ }
+
+ return { status: 'failed' };
+ }
+};
diff --git a/app/(auth)/api/auth/[...nextauth]/route.ts b/app/(auth)/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ba2423472f3917c14f4179ae6b62b83537e27db4
--- /dev/null
+++ b/app/(auth)/api/auth/[...nextauth]/route.ts
@@ -0,0 +1 @@
+export { GET, POST } from '@/app/(auth)/auth';
diff --git a/app/(auth)/api/auth/guest/route.ts b/app/(auth)/api/auth/guest/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..25af1fa7b7d321158467287f6153df17a080955e
--- /dev/null
+++ b/app/(auth)/api/auth/guest/route.ts
@@ -0,0 +1,21 @@
+import { signIn } from '@/app/(auth)/auth';
+import { isDevelopmentEnvironment } from '@/lib/constants';
+import { getToken } from 'next-auth/jwt';
+import { NextResponse } from 'next/server';
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const redirectUrl = searchParams.get('redirectUrl') || '/';
+
+ const token = await getToken({
+ req: request,
+ secret: process.env.AUTH_SECRET,
+ secureCookie: !isDevelopmentEnvironment,
+ });
+
+ if (token) {
+ return NextResponse.redirect(new URL('/', request.url));
+ }
+
+ return signIn('guest', { redirect: true, redirectTo: redirectUrl });
+}
diff --git a/app/(auth)/auth.config.ts b/app/(auth)/auth.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b7d7d50bf1fbabea159b49a879bf5685c0590101
--- /dev/null
+++ b/app/(auth)/auth.config.ts
@@ -0,0 +1,13 @@
+import type { NextAuthConfig } from 'next-auth';
+
+export const authConfig = {
+ pages: {
+ signIn: '/login',
+ newUser: '/',
+ },
+ providers: [
+ // added later in auth.ts since it requires bcrypt which is only compatible with Node.js
+ // while this file is also used in non-Node.js environments
+ ],
+ callbacks: {},
+} satisfies NextAuthConfig;
diff --git a/app/(auth)/auth.ts b/app/(auth)/auth.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9185248db04994a6dac857d8323c96e242726a28
--- /dev/null
+++ b/app/(auth)/auth.ts
@@ -0,0 +1,92 @@
+import { compare } from 'bcrypt-ts';
+import NextAuth, { type DefaultSession } from 'next-auth';
+import Credentials from 'next-auth/providers/credentials';
+import { createGuestUser, getUser } from '@/lib/db/queries';
+import { authConfig } from './auth.config';
+import { DUMMY_PASSWORD } from '@/lib/constants';
+import type { DefaultJWT } from 'next-auth/jwt';
+
+export type UserType = 'guest' | 'regular';
+
+declare module 'next-auth' {
+ interface Session extends DefaultSession {
+ user: {
+ id: string;
+ type: UserType;
+ } & DefaultSession['user'];
+ }
+
+ interface User {
+ id?: string;
+ email?: string | null;
+ type: UserType;
+ }
+}
+
+declare module 'next-auth/jwt' {
+ interface JWT extends DefaultJWT {
+ id: string;
+ type: UserType;
+ }
+}
+
+export const {
+ handlers: { GET, POST },
+ auth,
+ signIn,
+ signOut,
+} = NextAuth({
+ ...authConfig,
+ providers: [
+ Credentials({
+ credentials: {},
+ async authorize({ email, password }: any) {
+ const users = await getUser(email);
+
+ if (users.length === 0) {
+ await compare(password, DUMMY_PASSWORD);
+ return null;
+ }
+
+ const [user] = users;
+
+ if (!user.password) {
+ await compare(password, DUMMY_PASSWORD);
+ return null;
+ }
+
+ const passwordsMatch = await compare(password, user.password);
+
+ if (!passwordsMatch) return null;
+
+ return { ...user, type: 'regular' };
+ },
+ }),
+ Credentials({
+ id: 'guest',
+ credentials: {},
+ async authorize() {
+ const [guestUser] = await createGuestUser();
+ return { ...guestUser, type: 'guest' };
+ },
+ }),
+ ],
+ callbacks: {
+ async jwt({ token, user }) {
+ if (user) {
+ token.id = user.id as string;
+ token.type = user.type;
+ }
+
+ return token;
+ },
+ async session({ session, token }) {
+ if (session.user) {
+ session.user.id = token.id;
+ session.user.type = token.type;
+ }
+
+ return session;
+ },
+ },
+});
diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..33e9e827096256bdab915c5e45d3fee97b65defb
--- /dev/null
+++ b/app/(auth)/login/page.tsx
@@ -0,0 +1,77 @@
+'use client';
+
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { useActionState, useEffect, useState } from 'react';
+import { toast } from '@/components/toast';
+
+import { AuthForm } from '@/components/auth-form';
+import { SubmitButton } from '@/components/submit-button';
+
+import { login, type LoginActionState } from '../actions';
+import { useSession } from 'next-auth/react';
+
+export default function Page() {
+ const router = useRouter();
+
+ const [email, setEmail] = useState('');
+ const [isSuccessful, setIsSuccessful] = useState(false);
+
+ const [state, formAction] = useActionState(
+ login,
+ {
+ status: 'idle',
+ },
+ );
+
+ const { update: updateSession } = useSession();
+
+ useEffect(() => {
+ if (state.status === 'failed') {
+ toast({
+ type: 'error',
+ description: 'Invalid credentials!',
+ });
+ } else if (state.status === 'invalid_data') {
+ toast({
+ type: 'error',
+ description: 'Failed validating your submission!',
+ });
+ } else if (state.status === 'success') {
+ setIsSuccessful(true);
+ updateSession();
+ router.refresh();
+ }
+ }, [state.status]);
+
+ const handleSubmit = (formData: FormData) => {
+ setEmail(formData.get('email') as string);
+ formAction(formData);
+ };
+
+ return (
+
+
+
+
Sign In
+
+ Use your email and password to sign in
+
+
+
+ Sign in
+
+ {"Don't have an account? "}
+
+ Sign up
+
+ {' for free.'}
+
+
+
+
+ );
+}
diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ab2ee8266714aec0622e135e91e5ea3fdd24462f
--- /dev/null
+++ b/app/(auth)/register/page.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { useActionState, useEffect, useState } from 'react';
+
+import { AuthForm } from '@/components/auth-form';
+import { SubmitButton } from '@/components/submit-button';
+
+import { register, type RegisterActionState } from '../actions';
+import { toast } from '@/components/toast';
+import { useSession } from 'next-auth/react';
+
+export default function Page() {
+ const router = useRouter();
+
+ const [email, setEmail] = useState('');
+ const [isSuccessful, setIsSuccessful] = useState(false);
+
+ const [state, formAction] = useActionState(
+ register,
+ {
+ status: 'idle',
+ },
+ );
+
+ const { update: updateSession } = useSession();
+
+ useEffect(() => {
+ if (state.status === 'user_exists') {
+ toast({ type: 'error', description: 'Account already exists!' });
+ } else if (state.status === 'failed') {
+ toast({ type: 'error', description: 'Failed to create account!' });
+ } else if (state.status === 'invalid_data') {
+ toast({
+ type: 'error',
+ description: 'Failed validating your submission!',
+ });
+ } else if (state.status === 'success') {
+ toast({ type: 'success', description: 'Account created successfully!' });
+
+ setIsSuccessful(true);
+ updateSession();
+ router.refresh();
+ }
+ }, [state]);
+
+ const handleSubmit = (formData: FormData) => {
+ setEmail(formData.get('email') as string);
+ formAction(formData);
+ };
+
+ return (
+
+
+
+
Sign Up
+
+ Create an account with your email and password
+
+
+
+ Sign Up
+
+ {'Already have an account? '}
+
+ Sign in
+
+ {' instead.'}
+
+
+
+
+ );
+}
diff --git a/app/(chat)/actions.ts b/app/(chat)/actions.ts
new file mode 100644
index 0000000000000000000000000000000000000000..14ee7dc2a4d5567bafd573d8d0b421bf2430fc8c
--- /dev/null
+++ b/app/(chat)/actions.ts
@@ -0,0 +1,53 @@
+'use server';
+
+import { generateText, type UIMessage } from 'ai';
+import { cookies } from 'next/headers';
+import {
+ deleteMessagesByChatIdAfterTimestamp,
+ getMessageById,
+ updateChatVisiblityById,
+} from '@/lib/db/queries';
+import type { VisibilityType } from '@/components/visibility-selector';
+import { myProvider } from '@/lib/ai/providers';
+
+export async function saveChatModelAsCookie(model: string) {
+ const cookieStore = await cookies();
+ cookieStore.set('chat-model', model);
+}
+
+export async function generateTitleFromUserMessage({
+ message,
+}: {
+ message: UIMessage;
+}) {
+ const { text: title } = await generateText({
+ model: myProvider.languageModel('title-model'),
+ system: `\n
+ - you will generate a short title based on the first message a user begins a conversation with
+ - ensure it is not more than 80 characters long
+ - the title should be a summary of the user's message
+ - do not use quotes or colons`,
+ prompt: JSON.stringify(message),
+ });
+
+ return title;
+}
+
+export async function deleteTrailingMessages({ id }: { id: string }) {
+ const [message] = await getMessageById({ id });
+
+ await deleteMessagesByChatIdAfterTimestamp({
+ chatId: message.chatId,
+ timestamp: message.createdAt,
+ });
+}
+
+export async function updateChatVisibility({
+ chatId,
+ visibility,
+}: {
+ chatId: string;
+ visibility: VisibilityType;
+}) {
+ await updateChatVisiblityById({ chatId, visibility });
+}
diff --git a/app/(chat)/api/chat/[id]/stream/route.ts b/app/(chat)/api/chat/[id]/stream/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..23e84885e66d3fe1c750eef7d7f5a4065e38d7a1
--- /dev/null
+++ b/app/(chat)/api/chat/[id]/stream/route.ts
@@ -0,0 +1,112 @@
+import { auth } from '@/app/(auth)/auth';
+import {
+ getChatById,
+ getMessagesByChatId,
+ getStreamIdsByChatId,
+} from '@/lib/db/queries';
+import type { Chat } from '@/lib/db/schema';
+import { ChatSDKError } from '@/lib/errors';
+import type { ChatMessage } from '@/lib/types';
+import { createUIMessageStream, JsonToSseTransformStream } from 'ai';
+import { getStreamContext } from '../../route';
+import { differenceInSeconds } from 'date-fns';
+
+export async function GET(
+ _: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id: chatId } = await params;
+
+ const streamContext = getStreamContext();
+ const resumeRequestedAt = new Date();
+
+ if (!streamContext) {
+ return new Response(null, { status: 204 });
+ }
+
+ if (!chatId) {
+ return new ChatSDKError('bad_request:api').toResponse();
+ }
+
+ const session = await auth();
+
+ if (!session?.user) {
+ return new ChatSDKError('unauthorized:chat').toResponse();
+ }
+
+ let chat: Chat;
+
+ try {
+ chat = await getChatById({ id: chatId });
+ } catch {
+ return new ChatSDKError('not_found:chat').toResponse();
+ }
+
+ if (!chat) {
+ return new ChatSDKError('not_found:chat').toResponse();
+ }
+
+ if (chat.visibility === 'private' && chat.userId !== session.user.id) {
+ return new ChatSDKError('forbidden:chat').toResponse();
+ }
+
+ const streamIds = await getStreamIdsByChatId({ chatId });
+
+ if (!streamIds.length) {
+ return new ChatSDKError('not_found:stream').toResponse();
+ }
+
+ const recentStreamId = streamIds.at(-1);
+
+ if (!recentStreamId) {
+ return new ChatSDKError('not_found:stream').toResponse();
+ }
+
+ const emptyDataStream = createUIMessageStream({
+ execute: () => {},
+ });
+
+ const stream = await streamContext.resumableStream(recentStreamId, () =>
+ emptyDataStream.pipeThrough(new JsonToSseTransformStream()),
+ );
+
+ /*
+ * For when the generation is streaming during SSR
+ * but the resumable stream has concluded at this point.
+ */
+ if (!stream) {
+ const messages = await getMessagesByChatId({ id: chatId });
+ const mostRecentMessage = messages.at(-1);
+
+ if (!mostRecentMessage) {
+ return new Response(emptyDataStream, { status: 200 });
+ }
+
+ if (mostRecentMessage.role !== 'assistant') {
+ return new Response(emptyDataStream, { status: 200 });
+ }
+
+ const messageCreatedAt = new Date(mostRecentMessage.createdAt);
+
+ if (differenceInSeconds(resumeRequestedAt, messageCreatedAt) > 15) {
+ return new Response(emptyDataStream, { status: 200 });
+ }
+
+ const restoredStream = createUIMessageStream({
+ execute: ({ writer }) => {
+ writer.write({
+ type: 'data-appendMessage',
+ data: JSON.stringify(mostRecentMessage),
+ transient: true,
+ });
+ },
+ });
+
+ return new Response(
+ restoredStream.pipeThrough(new JsonToSseTransformStream()),
+ { status: 200 },
+ );
+ }
+
+ return new Response(stream, { status: 200 });
+}
diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b7ea099a574c3b44cac51cb8d37677807cb6c583
--- /dev/null
+++ b/app/(chat)/api/chat/route.ts
@@ -0,0 +1,251 @@
+import {
+ convertToModelMessages,
+ createUIMessageStream,
+ JsonToSseTransformStream,
+ smoothStream,
+ stepCountIs,
+ streamText,
+} from 'ai';
+import { auth, type UserType } from '@/app/(auth)/auth';
+import { type RequestHints, systemPrompt } from '@/lib/ai/prompts';
+import {
+ createStreamId,
+ deleteChatById,
+ getChatById,
+ getMessageCountByUserId,
+ getMessagesByChatId,
+ saveChat,
+ saveMessages,
+} from '@/lib/db/queries';
+import { convertToUIMessages, generateUUID } from '@/lib/utils';
+import { generateTitleFromUserMessage } from '../../actions';
+import { createDocument } from '@/lib/ai/tools/create-document';
+import { updateDocument } from '@/lib/ai/tools/update-document';
+import { requestSuggestions } from '@/lib/ai/tools/request-suggestions';
+import { getWeather } from '@/lib/ai/tools/get-weather';
+import { isProductionEnvironment } from '@/lib/constants';
+import { myProvider } from '@/lib/ai/providers';
+import { entitlementsByUserType } from '@/lib/ai/entitlements';
+import { postRequestBodySchema, type PostRequestBody } from './schema';
+import { geolocation } from '@vercel/functions';
+import {
+ createResumableStreamContext,
+ type ResumableStreamContext,
+} from 'resumable-stream';
+import { after } from 'next/server';
+import { ChatSDKError } from '@/lib/errors';
+import type { ChatMessage } from '@/lib/types';
+import type { ChatModel } from '@/lib/ai/models';
+import type { VisibilityType } from '@/components/visibility-selector';
+
+export const maxDuration = 60;
+
+let globalStreamContext: ResumableStreamContext | null = null;
+
+export function getStreamContext() {
+ if (!globalStreamContext) {
+ try {
+ globalStreamContext = createResumableStreamContext({
+ waitUntil: after,
+ });
+ } catch (error: any) {
+ if (error.message.includes('REDIS_URL')) {
+ console.log(
+ ' > Resumable streams are disabled due to missing REDIS_URL',
+ );
+ } else {
+ console.error(error);
+ }
+ }
+ }
+
+ return globalStreamContext;
+}
+
+export async function POST(request: Request) {
+ let requestBody: PostRequestBody;
+
+ try {
+ const json = await request.json();
+ requestBody = postRequestBodySchema.parse(json);
+ } catch (_) {
+ return new ChatSDKError('bad_request:api').toResponse();
+ }
+
+ try {
+ const {
+ id,
+ message,
+ selectedChatModel,
+ selectedVisibilityType,
+ }: {
+ id: string;
+ message: ChatMessage;
+ selectedChatModel: ChatModel['id'];
+ selectedVisibilityType: VisibilityType;
+ } = requestBody;
+
+ const session = await auth();
+
+ if (!session?.user) {
+ return new ChatSDKError('unauthorized:chat').toResponse();
+ }
+
+ const userType: UserType = session.user.type;
+
+ const messageCount = await getMessageCountByUserId({
+ id: session.user.id,
+ differenceInHours: 24,
+ });
+
+ if (messageCount > entitlementsByUserType[userType].maxMessagesPerDay) {
+ return new ChatSDKError('rate_limit:chat').toResponse();
+ }
+
+ const chat = await getChatById({ id });
+
+ if (!chat) {
+ const title = await generateTitleFromUserMessage({
+ message,
+ });
+
+ await saveChat({
+ id,
+ userId: session.user.id,
+ title,
+ visibility: selectedVisibilityType,
+ });
+ } else {
+ if (chat.userId !== session.user.id) {
+ return new ChatSDKError('forbidden:chat').toResponse();
+ }
+ }
+
+ const messagesFromDb = await getMessagesByChatId({ id });
+ const uiMessages = [...convertToUIMessages(messagesFromDb), message];
+
+ const { longitude, latitude, city, country } = geolocation(request);
+
+ const requestHints: RequestHints = {
+ longitude,
+ latitude,
+ city,
+ country,
+ };
+
+ await saveMessages({
+ messages: [
+ {
+ chatId: id,
+ id: message.id,
+ role: 'user',
+ parts: message.parts,
+ attachments: [],
+ createdAt: new Date(),
+ },
+ ],
+ });
+
+ const streamId = generateUUID();
+ await createStreamId({ streamId, chatId: id });
+
+ const stream = createUIMessageStream({
+ execute: ({ writer: dataStream }) => {
+ const result = streamText({
+ model: myProvider.languageModel(selectedChatModel),
+ system: systemPrompt({ selectedChatModel, requestHints }),
+ messages: convertToModelMessages(uiMessages),
+ stopWhen: stepCountIs(5),
+ experimental_activeTools:
+ selectedChatModel === 'chat-model-reasoning'
+ ? []
+ : [
+ 'getWeather',
+ 'createDocument',
+ 'updateDocument',
+ 'requestSuggestions',
+ ],
+ experimental_transform: smoothStream({ chunking: 'word' }),
+ tools: {
+ getWeather,
+ createDocument: createDocument({ session, dataStream }),
+ updateDocument: updateDocument({ session, dataStream }),
+ requestSuggestions: requestSuggestions({
+ session,
+ dataStream,
+ }),
+ },
+ experimental_telemetry: {
+ isEnabled: isProductionEnvironment,
+ functionId: 'stream-text',
+ },
+ });
+
+ result.consumeStream();
+
+ dataStream.merge(
+ result.toUIMessageStream({
+ sendReasoning: true,
+ }),
+ );
+ },
+ generateId: generateUUID,
+ onFinish: async ({ messages }) => {
+ await saveMessages({
+ messages: messages.map((message) => ({
+ id: message.id,
+ role: message.role,
+ parts: message.parts,
+ createdAt: new Date(),
+ attachments: [],
+ chatId: id,
+ })),
+ });
+ },
+ onError: () => {
+ return 'Oops, an error occurred!';
+ },
+ });
+
+ const streamContext = getStreamContext();
+
+ if (streamContext) {
+ return new Response(
+ await streamContext.resumableStream(streamId, () =>
+ stream.pipeThrough(new JsonToSseTransformStream()),
+ ),
+ );
+ } else {
+ return new Response(stream.pipeThrough(new JsonToSseTransformStream()));
+ }
+ } catch (error) {
+ if (error instanceof ChatSDKError) {
+ return error.toResponse();
+ }
+ }
+}
+
+export async function DELETE(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const id = searchParams.get('id');
+
+ if (!id) {
+ return new ChatSDKError('bad_request:api').toResponse();
+ }
+
+ const session = await auth();
+
+ if (!session?.user) {
+ return new ChatSDKError('unauthorized:chat').toResponse();
+ }
+
+ const chat = await getChatById({ id });
+
+ if (chat.userId !== session.user.id) {
+ return new ChatSDKError('forbidden:chat').toResponse();
+ }
+
+ const deletedChat = await deleteChatById({ id });
+
+ return Response.json(deletedChat, { status: 200 });
+}
diff --git a/app/(chat)/api/chat/schema.ts b/app/(chat)/api/chat/schema.ts
new file mode 100644
index 0000000000000000000000000000000000000000..555ef8b95c85fc701ee0cade678027bf4051e926
--- /dev/null
+++ b/app/(chat)/api/chat/schema.ts
@@ -0,0 +1,28 @@
+import { z } from 'zod';
+
+const textPartSchema = z.object({
+ type: z.enum(['text']),
+ text: z.string().min(1).max(2000),
+});
+
+const filePartSchema = z.object({
+ type: z.enum(['file']),
+ mediaType: z.enum(['image/jpeg', 'image/png']),
+ name: z.string().min(1).max(100),
+ url: z.string().url(),
+});
+
+const partSchema = z.union([textPartSchema, filePartSchema]);
+
+export const postRequestBodySchema = z.object({
+ id: z.string().uuid(),
+ message: z.object({
+ id: z.string().uuid(),
+ role: z.enum(['user']),
+ parts: z.array(partSchema),
+ }),
+ selectedChatModel: z.enum(['chat-model', 'chat-model-reasoning']),
+ selectedVisibilityType: z.enum(['public', 'private']),
+});
+
+export type PostRequestBody = z.infer;
diff --git a/app/(chat)/api/document/route.ts b/app/(chat)/api/document/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..de4183673c63dbec41cd8b3301ac1e73f33cc72f
--- /dev/null
+++ b/app/(chat)/api/document/route.ts
@@ -0,0 +1,126 @@
+import { auth } from '@/app/(auth)/auth';
+import type { ArtifactKind } from '@/components/artifact';
+import {
+ deleteDocumentsByIdAfterTimestamp,
+ getDocumentsById,
+ saveDocument,
+} from '@/lib/db/queries';
+import { ChatSDKError } from '@/lib/errors';
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const id = searchParams.get('id');
+
+ if (!id) {
+ return new ChatSDKError(
+ 'bad_request:api',
+ 'Parameter id is missing',
+ ).toResponse();
+ }
+
+ const session = await auth();
+
+ if (!session?.user) {
+ return new ChatSDKError('unauthorized:document').toResponse();
+ }
+
+ const documents = await getDocumentsById({ id });
+
+ const [document] = documents;
+
+ if (!document) {
+ return new ChatSDKError('not_found:document').toResponse();
+ }
+
+ if (document.userId !== session.user.id) {
+ return new ChatSDKError('forbidden:document').toResponse();
+ }
+
+ return Response.json(documents, { status: 200 });
+}
+
+export async function POST(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const id = searchParams.get('id');
+
+ if (!id) {
+ return new ChatSDKError(
+ 'bad_request:api',
+ 'Parameter id is required.',
+ ).toResponse();
+ }
+
+ const session = await auth();
+
+ if (!session?.user) {
+ return new ChatSDKError('not_found:document').toResponse();
+ }
+
+ const {
+ content,
+ title,
+ kind,
+ }: { content: string; title: string; kind: ArtifactKind } =
+ await request.json();
+
+ const documents = await getDocumentsById({ id });
+
+ if (documents.length > 0) {
+ const [document] = documents;
+
+ if (document.userId !== session.user.id) {
+ return new ChatSDKError('forbidden:document').toResponse();
+ }
+ }
+
+ const document = await saveDocument({
+ id,
+ content,
+ title,
+ kind,
+ userId: session.user.id,
+ });
+
+ return Response.json(document, { status: 200 });
+}
+
+export async function DELETE(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const id = searchParams.get('id');
+ const timestamp = searchParams.get('timestamp');
+
+ if (!id) {
+ return new ChatSDKError(
+ 'bad_request:api',
+ 'Parameter id is required.',
+ ).toResponse();
+ }
+
+ if (!timestamp) {
+ return new ChatSDKError(
+ 'bad_request:api',
+ 'Parameter timestamp is required.',
+ ).toResponse();
+ }
+
+ const session = await auth();
+
+ if (!session?.user) {
+ return new ChatSDKError('unauthorized:document').toResponse();
+ }
+
+ const documents = await getDocumentsById({ id });
+
+ const [document] = documents;
+
+ if (document.userId !== session.user.id) {
+ return new ChatSDKError('forbidden:document').toResponse();
+ }
+
+ const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({
+ id,
+ timestamp: new Date(timestamp),
+ });
+
+ return Response.json(documentsDeleted, { status: 200 });
+}
diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..699a4cbef89d6a650882721538e7f58df2cffc06
--- /dev/null
+++ b/app/(chat)/api/files/upload/route.ts
@@ -0,0 +1,68 @@
+import { put } from '@vercel/blob';
+import { NextResponse } from 'next/server';
+import { z } from 'zod';
+
+import { auth } from '@/app/(auth)/auth';
+
+// Use Blob instead of File since File is not available in Node.js environment
+const FileSchema = z.object({
+ file: z
+ .instanceof(Blob)
+ .refine((file) => file.size <= 5 * 1024 * 1024, {
+ message: 'File size should be less than 5MB',
+ })
+ // Update the file type based on the kind of files you want to accept
+ .refine((file) => ['image/jpeg', 'image/png'].includes(file.type), {
+ message: 'File type should be JPEG or PNG',
+ }),
+});
+
+export async function POST(request: Request) {
+ const session = await auth();
+
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ if (request.body === null) {
+ return new Response('Request body is empty', { status: 400 });
+ }
+
+ try {
+ const formData = await request.formData();
+ const file = formData.get('file') as Blob;
+
+ if (!file) {
+ return NextResponse.json({ error: 'No file uploaded' }, { status: 400 });
+ }
+
+ const validatedFile = FileSchema.safeParse({ file });
+
+ if (!validatedFile.success) {
+ const errorMessage = validatedFile.error.errors
+ .map((error) => error.message)
+ .join(', ');
+
+ return NextResponse.json({ error: errorMessage }, { status: 400 });
+ }
+
+ // Get filename from formData since Blob doesn't have name property
+ const filename = (formData.get('file') as File).name;
+ const fileBuffer = await file.arrayBuffer();
+
+ try {
+ const data = await put(`${filename}`, fileBuffer, {
+ access: 'public',
+ });
+
+ return NextResponse.json(data);
+ } catch (error) {
+ return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
+ }
+ } catch (error) {
+ return NextResponse.json(
+ { error: 'Failed to process request' },
+ { status: 500 },
+ );
+ }
+}
diff --git a/app/(chat)/api/history/route.ts b/app/(chat)/api/history/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e28290c627492b3024cfdcf0b7952c25e30f0d1a
--- /dev/null
+++ b/app/(chat)/api/history/route.ts
@@ -0,0 +1,34 @@
+import { auth } from '@/app/(auth)/auth';
+import type { NextRequest } from 'next/server';
+import { getChatsByUserId } from '@/lib/db/queries';
+import { ChatSDKError } from '@/lib/errors';
+
+export async function GET(request: NextRequest) {
+ const { searchParams } = request.nextUrl;
+
+ const limit = Number.parseInt(searchParams.get('limit') || '10');
+ const startingAfter = searchParams.get('starting_after');
+ const endingBefore = searchParams.get('ending_before');
+
+ if (startingAfter && endingBefore) {
+ return new ChatSDKError(
+ 'bad_request:api',
+ 'Only one of starting_after or ending_before can be provided.',
+ ).toResponse();
+ }
+
+ const session = await auth();
+
+ if (!session?.user) {
+ return new ChatSDKError('unauthorized:chat').toResponse();
+ }
+
+ const chats = await getChatsByUserId({
+ id: session.user.id,
+ limit,
+ startingAfter,
+ endingBefore,
+ });
+
+ return Response.json(chats);
+}
diff --git a/app/(chat)/api/suggestions/route.ts b/app/(chat)/api/suggestions/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ffbda2fb3225e4d5a49be968434d00954741a4ac
--- /dev/null
+++ b/app/(chat)/api/suggestions/route.ts
@@ -0,0 +1,37 @@
+import { auth } from '@/app/(auth)/auth';
+import { getSuggestionsByDocumentId } from '@/lib/db/queries';
+import { ChatSDKError } from '@/lib/errors';
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const documentId = searchParams.get('documentId');
+
+ if (!documentId) {
+ return new ChatSDKError(
+ 'bad_request:api',
+ 'Parameter documentId is required.',
+ ).toResponse();
+ }
+
+ const session = await auth();
+
+ if (!session?.user) {
+ return new ChatSDKError('unauthorized:suggestions').toResponse();
+ }
+
+ const suggestions = await getSuggestionsByDocumentId({
+ documentId,
+ });
+
+ const [suggestion] = suggestions;
+
+ if (!suggestion) {
+ return Response.json([], { status: 200 });
+ }
+
+ if (suggestion.userId !== session.user.id) {
+ return new ChatSDKError('forbidden:api').toResponse();
+ }
+
+ return Response.json(suggestions, { status: 200 });
+}
diff --git a/app/(chat)/api/vote/route.ts b/app/(chat)/api/vote/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b3d7e45d4ff5722f13e81467f650773550367003
--- /dev/null
+++ b/app/(chat)/api/vote/route.ts
@@ -0,0 +1,75 @@
+import { auth } from '@/app/(auth)/auth';
+import { getChatById, getVotesByChatId, voteMessage } from '@/lib/db/queries';
+import { ChatSDKError } from '@/lib/errors';
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const chatId = searchParams.get('chatId');
+
+ if (!chatId) {
+ return new ChatSDKError(
+ 'bad_request:api',
+ 'Parameter chatId is required.',
+ ).toResponse();
+ }
+
+ const session = await auth();
+
+ if (!session?.user) {
+ return new ChatSDKError('unauthorized:vote').toResponse();
+ }
+
+ const chat = await getChatById({ id: chatId });
+
+ if (!chat) {
+ return new ChatSDKError('not_found:chat').toResponse();
+ }
+
+ if (chat.userId !== session.user.id) {
+ return new ChatSDKError('forbidden:vote').toResponse();
+ }
+
+ const votes = await getVotesByChatId({ id: chatId });
+
+ return Response.json(votes, { status: 200 });
+}
+
+export async function PATCH(request: Request) {
+ const {
+ chatId,
+ messageId,
+ type,
+ }: { chatId: string; messageId: string; type: 'up' | 'down' } =
+ await request.json();
+
+ if (!chatId || !messageId || !type) {
+ return new ChatSDKError(
+ 'bad_request:api',
+ 'Parameters chatId, messageId, and type are required.',
+ ).toResponse();
+ }
+
+ const session = await auth();
+
+ if (!session?.user) {
+ return new ChatSDKError('unauthorized:vote').toResponse();
+ }
+
+ const chat = await getChatById({ id: chatId });
+
+ if (!chat) {
+ return new ChatSDKError('not_found:vote').toResponse();
+ }
+
+ if (chat.userId !== session.user.id) {
+ return new ChatSDKError('forbidden:vote').toResponse();
+ }
+
+ await voteMessage({
+ chatId,
+ messageId,
+ type: type,
+ });
+
+ return new Response('Message voted', { status: 200 });
+}
diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b9dc99d0f2c2d2ac72a8e7442aceecf941fa4996
--- /dev/null
+++ b/app/(chat)/chat/[id]/page.tsx
@@ -0,0 +1,76 @@
+import { cookies } from 'next/headers';
+import { notFound, redirect } from 'next/navigation';
+
+import { auth } from '@/app/(auth)/auth';
+import { Chat } from '@/components/chat';
+import { getChatById, getMessagesByChatId } from '@/lib/db/queries';
+import { DataStreamHandler } from '@/components/data-stream-handler';
+import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models';
+import { convertToUIMessages } from '@/lib/utils';
+
+export default async function Page(props: { params: Promise<{ id: string }> }) {
+ const params = await props.params;
+ const { id } = params;
+ const chat = await getChatById({ id });
+
+ if (!chat) {
+ notFound();
+ }
+
+ const session = await auth();
+
+ if (!session) {
+ redirect('/api/auth/guest');
+ }
+
+ if (chat.visibility === 'private') {
+ if (!session.user) {
+ return notFound();
+ }
+
+ if (session.user.id !== chat.userId) {
+ return notFound();
+ }
+ }
+
+ const messagesFromDb = await getMessagesByChatId({
+ id,
+ });
+
+ const uiMessages = convertToUIMessages(messagesFromDb);
+
+ const cookieStore = await cookies();
+ const chatModelFromCookie = cookieStore.get('chat-model');
+
+ if (!chatModelFromCookie) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..dfd9da2fdfda2ba78029dad94afcea655e27021c
--- /dev/null
+++ b/app/(chat)/layout.tsx
@@ -0,0 +1,33 @@
+import { cookies } from 'next/headers';
+
+import { AppSidebar } from '@/components/app-sidebar';
+import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
+import { auth } from '../(auth)/auth';
+import Script from 'next/script';
+import { DataStreamProvider } from '@/components/data-stream-provider';
+
+export const experimental_ppr = true;
+
+export default async function Layout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [session, cookieStore] = await Promise.all([auth(), cookies()]);
+ const isCollapsed = cookieStore.get('sidebar:state')?.value !== 'true';
+
+ return (
+ <>
+
+
+
+
+ {children}
+
+
+ >
+ );
+}
diff --git a/app/(chat)/opengraph-image.png b/app/(chat)/opengraph-image.png
new file mode 100644
index 0000000000000000000000000000000000000000..8e1860cd0383110cf2b3ca8b5a38c061360c20df
--- /dev/null
+++ b/app/(chat)/opengraph-image.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2debd7baddd1f018254740064e2f5aee84c62937083c707d577bd4f99fc3be36
+size 165578
diff --git a/app/(chat)/page.tsx b/app/(chat)/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..41328cb71f035d844f90b9fe42ec6b9ffa4de354
--- /dev/null
+++ b/app/(chat)/page.tsx
@@ -0,0 +1,55 @@
+import { cookies } from 'next/headers';
+
+import { Chat } from '@/components/chat';
+import { DEFAULT_CHAT_MODEL } from '@/lib/ai/models';
+import { generateUUID } from '@/lib/utils';
+import { DataStreamHandler } from '@/components/data-stream-handler';
+import { auth } from '../(auth)/auth';
+import { redirect } from 'next/navigation';
+
+export default async function Page() {
+ const session = await auth();
+
+ if (!session) {
+ redirect('/api/auth/guest');
+ }
+
+ const id = generateUUID();
+
+ const cookieStore = await cookies();
+ const modelIdFromCookie = cookieStore.get('chat-model');
+
+ if (!modelIdFromCookie) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/app/(chat)/twitter-image.png b/app/(chat)/twitter-image.png
new file mode 100644
index 0000000000000000000000000000000000000000..79fbc0f9c7a461a2982ade09f414bc8faa9ec60a
Binary files /dev/null and b/app/(chat)/twitter-image.png differ
diff --git a/app/favicon.ico b/app/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..7452b5dc69b2a634c86779ee4cd4c13319549b43
Binary files /dev/null and b/app/favicon.ico differ
diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 0000000000000000000000000000000000000000..3409b987edded4b19b4df5a29ef3214f9fb376d2
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,164 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ --foreground-rgb: 0, 0, 0;
+ --background-start-rgb: 214, 219, 220;
+ --background-end-rgb: 255, 255, 255;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --foreground-rgb: 255, 255, 255;
+ --background-start-rgb: 0, 0, 0;
+ --background-end-rgb: 0, 0, 0;
+ }
+}
+
+@layer utilities {
+ .text-balance {
+ text-wrap: balance;
+ }
+}
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 240 10% 3.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 240 10% 3.9%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 240 10% 3.9%;
+ --primary: 240 5.9% 10%;
+ --primary-foreground: 0 0% 98%;
+ --secondary: 240 4.8% 95.9%;
+ --secondary-foreground: 240 5.9% 10%;
+ --muted: 240 4.8% 95.9%;
+ --muted-foreground: 240 3.8% 46.1%;
+ --accent: 240 4.8% 95.9%;
+ --accent-foreground: 240 5.9% 10%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 240 5.9% 90%;
+ --input: 240 5.9% 90%;
+ --ring: 240 10% 3.9%;
+ --chart-1: 12 76% 61%;
+ --chart-2: 173 58% 39%;
+ --chart-3: 197 37% 24%;
+ --chart-4: 43 74% 66%;
+ --chart-5: 27 87% 67%;
+ --radius: 0.5rem;
+ --sidebar-background: 0 0% 98%;
+ --sidebar-foreground: 240 5.3% 26.1%;
+ --sidebar-primary: 240 5.9% 10%;
+ --sidebar-primary-foreground: 0 0% 98%;
+ --sidebar-accent: 240 4.8% 95.9%;
+ --sidebar-accent-foreground: 240 5.9% 10%;
+ --sidebar-border: 220 13% 91%;
+ --sidebar-ring: 217.2 91.2% 59.8%;
+ }
+ .dark {
+ --background: 240 10% 3.9%;
+ --foreground: 0 0% 98%;
+ --card: 240 10% 3.9%;
+ --card-foreground: 0 0% 98%;
+ --popover: 240 10% 3.9%;
+ --popover-foreground: 0 0% 98%;
+ --primary: 0 0% 98%;
+ --primary-foreground: 240 5.9% 10%;
+ --secondary: 240 3.7% 15.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 240 3.7% 15.9%;
+ --muted-foreground: 240 5% 64.9%;
+ --accent: 240 3.7% 15.9%;
+ --accent-foreground: 0 0% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 240 3.7% 15.9%;
+ --input: 240 3.7% 15.9%;
+ --ring: 240 4.9% 83.9%;
+ --chart-1: 220 70% 50%;
+ --chart-2: 160 60% 45%;
+ --chart-3: 30 80% 55%;
+ --chart-4: 280 65% 60%;
+ --chart-5: 340 75% 55%;
+ --sidebar-background: 240 5.9% 10%;
+ --sidebar-foreground: 240 4.8% 95.9%;
+ --sidebar-primary: 224.3 76.3% 48%;
+ --sidebar-primary-foreground: 0 0% 100%;
+ --sidebar-accent: 240 3.7% 15.9%;
+ --sidebar-accent-foreground: 240 4.8% 95.9%;
+ --sidebar-border: 240 3.7% 15.9%;
+ --sidebar-ring: 217.2 91.2% 59.8%;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+.skeleton {
+ * {
+ pointer-events: none !important;
+ }
+
+ *[class^="text-"] {
+ color: transparent;
+ @apply rounded-md bg-foreground/20 select-none animate-pulse;
+ }
+
+ .skeleton-bg {
+ @apply bg-foreground/10;
+ }
+
+ .skeleton-div {
+ @apply bg-foreground/20 animate-pulse;
+ }
+}
+
+.ProseMirror {
+ outline: none;
+}
+
+.cm-editor,
+.cm-gutters {
+ @apply bg-background dark:bg-zinc-800 outline-none selection:bg-zinc-900 !important;
+}
+
+.ͼo.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
+.ͼo.cm-selectionBackground,
+.ͼo.cm-content::selection {
+ @apply bg-zinc-200 dark:bg-zinc-900 !important;
+}
+
+.cm-activeLine,
+.cm-activeLineGutter {
+ @apply bg-transparent !important;
+}
+
+.cm-activeLine {
+ @apply rounded-r-sm !important;
+}
+
+.cm-lineNumbers {
+ @apply min-w-7;
+}
+
+.cm-foldGutter {
+ @apply min-w-3;
+}
+
+.cm-lineNumbers .cm-activeLineGutter {
+ @apply rounded-l-sm !important;
+}
+
+.suggestion-highlight {
+ @apply bg-blue-200 hover:bg-blue-300 dark:hover:bg-blue-400/50 dark:text-blue-50 dark:bg-blue-500/40;
+}
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1813e0f71b3800e9bf362bdda7ea3b366e04c961
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,86 @@
+import { Toaster } from 'sonner';
+import type { Metadata } from 'next';
+import { Geist, Geist_Mono } from 'next/font/google';
+import { ThemeProvider } from '@/components/theme-provider';
+
+import './globals.css';
+import { SessionProvider } from 'next-auth/react';
+
+export const metadata: Metadata = {
+ metadataBase: new URL('https://chat.vercel.ai'),
+ title: 'Next.js Chatbot Template',
+ description: 'Next.js chatbot template using the AI SDK.',
+};
+
+export const viewport = {
+ maximumScale: 1, // Disable auto-zoom on mobile Safari
+};
+
+const geist = Geist({
+ subsets: ['latin'],
+ display: 'swap',
+ variable: '--font-geist',
+});
+
+const geistMono = Geist_Mono({
+ subsets: ['latin'],
+ display: 'swap',
+ variable: '--font-geist-mono',
+});
+
+const LIGHT_THEME_COLOR = 'hsl(0 0% 100%)';
+const DARK_THEME_COLOR = 'hsl(240deg 10% 3.92%)';
+const THEME_COLOR_SCRIPT = `\
+(function() {
+ var html = document.documentElement;
+ var meta = document.querySelector('meta[name="theme-color"]');
+ if (!meta) {
+ meta = document.createElement('meta');
+ meta.setAttribute('name', 'theme-color');
+ document.head.appendChild(meta);
+ }
+ function updateThemeColor() {
+ var isDark = html.classList.contains('dark');
+ meta.setAttribute('content', isDark ? '${DARK_THEME_COLOR}' : '${LIGHT_THEME_COLOR}');
+ }
+ var observer = new MutationObserver(updateThemeColor);
+ observer.observe(html, { attributes: true, attributeFilter: ['class'] });
+ updateThemeColor();
+})();`;
+
+export default async function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/artifacts/actions.ts b/artifacts/actions.ts
new file mode 100644
index 0000000000000000000000000000000000000000..854e7378a357f1ddc8d1d6e2c3276ea779fb0281
--- /dev/null
+++ b/artifacts/actions.ts
@@ -0,0 +1,8 @@
+'use server';
+
+import { getSuggestionsByDocumentId } from '@/lib/db/queries';
+
+export async function getSuggestions({ documentId }: { documentId: string }) {
+ const suggestions = await getSuggestionsByDocumentId({ documentId });
+ return suggestions ?? [];
+}
diff --git a/artifacts/code/client.tsx b/artifacts/code/client.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..25a1597180d7b253b80c3da79a85b043a097516b
--- /dev/null
+++ b/artifacts/code/client.tsx
@@ -0,0 +1,280 @@
+import { Artifact } from '@/components/create-artifact';
+import { CodeEditor } from '@/components/code-editor';
+import {
+ CopyIcon,
+ LogsIcon,
+ MessageIcon,
+ PlayIcon,
+ RedoIcon,
+ UndoIcon,
+} from '@/components/icons';
+import { toast } from 'sonner';
+import { generateUUID } from '@/lib/utils';
+import {
+ Console,
+ type ConsoleOutput,
+ type ConsoleOutputContent,
+} from '@/components/console';
+
+const OUTPUT_HANDLERS = {
+ matplotlib: `
+ import io
+ import base64
+ from matplotlib import pyplot as plt
+
+ # Clear any existing plots
+ plt.clf()
+ plt.close('all')
+
+ # Switch to agg backend
+ plt.switch_backend('agg')
+
+ def setup_matplotlib_output():
+ def custom_show():
+ if plt.gcf().get_size_inches().prod() * plt.gcf().dpi ** 2 > 25_000_000:
+ print("Warning: Plot size too large, reducing quality")
+ plt.gcf().set_dpi(100)
+
+ png_buf = io.BytesIO()
+ plt.savefig(png_buf, format='png')
+ png_buf.seek(0)
+ png_base64 = base64.b64encode(png_buf.read()).decode('utf-8')
+ print(f'data:image/png;base64,{png_base64}')
+ png_buf.close()
+
+ plt.clf()
+ plt.close('all')
+
+ plt.show = custom_show
+ `,
+ basic: `
+ # Basic output capture setup
+ `,
+};
+
+function detectRequiredHandlers(code: string): string[] {
+ const handlers: string[] = ['basic'];
+
+ if (code.includes('matplotlib') || code.includes('plt.')) {
+ handlers.push('matplotlib');
+ }
+
+ return handlers;
+}
+
+interface Metadata {
+ outputs: Array;
+}
+
+export const codeArtifact = new Artifact<'code', Metadata>({
+ kind: 'code',
+ description:
+ 'Useful for code generation; Code execution is only available for python code.',
+ initialize: async ({ setMetadata }) => {
+ setMetadata({
+ outputs: [],
+ });
+ },
+ onStreamPart: ({ streamPart, setArtifact }) => {
+ if (streamPart.type === 'data-codeDelta') {
+ setArtifact((draftArtifact) => ({
+ ...draftArtifact,
+ content: streamPart.data,
+ isVisible:
+ draftArtifact.status === 'streaming' &&
+ draftArtifact.content.length > 300 &&
+ draftArtifact.content.length < 310
+ ? true
+ : draftArtifact.isVisible,
+ status: 'streaming',
+ }));
+ }
+ },
+ content: ({ metadata, setMetadata, ...props }) => {
+ return (
+ <>
+
+
+
+
+ {metadata?.outputs && (
+ {
+ setMetadata({
+ ...metadata,
+ outputs: [],
+ });
+ }}
+ />
+ )}
+ >
+ );
+ },
+ actions: [
+ {
+ icon: ,
+ label: 'Run',
+ description: 'Execute code',
+ onClick: async ({ content, setMetadata }) => {
+ const runId = generateUUID();
+ const outputContent: Array = [];
+
+ setMetadata((metadata) => ({
+ ...metadata,
+ outputs: [
+ ...metadata.outputs,
+ {
+ id: runId,
+ contents: [],
+ status: 'in_progress',
+ },
+ ],
+ }));
+
+ try {
+ // @ts-expect-error - loadPyodide is not defined
+ const currentPyodideInstance = await globalThis.loadPyodide({
+ indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.23.4/full/',
+ });
+
+ currentPyodideInstance.setStdout({
+ batched: (output: string) => {
+ outputContent.push({
+ type: output.startsWith('data:image/png;base64')
+ ? 'image'
+ : 'text',
+ value: output,
+ });
+ },
+ });
+
+ await currentPyodideInstance.loadPackagesFromImports(content, {
+ messageCallback: (message: string) => {
+ setMetadata((metadata) => ({
+ ...metadata,
+ outputs: [
+ ...metadata.outputs.filter((output) => output.id !== runId),
+ {
+ id: runId,
+ contents: [{ type: 'text', value: message }],
+ status: 'loading_packages',
+ },
+ ],
+ }));
+ },
+ });
+
+ const requiredHandlers = detectRequiredHandlers(content);
+ for (const handler of requiredHandlers) {
+ if (OUTPUT_HANDLERS[handler as keyof typeof OUTPUT_HANDLERS]) {
+ await currentPyodideInstance.runPythonAsync(
+ OUTPUT_HANDLERS[handler as keyof typeof OUTPUT_HANDLERS],
+ );
+
+ if (handler === 'matplotlib') {
+ await currentPyodideInstance.runPythonAsync(
+ 'setup_matplotlib_output()',
+ );
+ }
+ }
+ }
+
+ await currentPyodideInstance.runPythonAsync(content);
+
+ setMetadata((metadata) => ({
+ ...metadata,
+ outputs: [
+ ...metadata.outputs.filter((output) => output.id !== runId),
+ {
+ id: runId,
+ contents: outputContent,
+ status: 'completed',
+ },
+ ],
+ }));
+ } catch (error: any) {
+ setMetadata((metadata) => ({
+ ...metadata,
+ outputs: [
+ ...metadata.outputs.filter((output) => output.id !== runId),
+ {
+ id: runId,
+ contents: [{ type: 'text', value: error.message }],
+ status: 'failed',
+ },
+ ],
+ }));
+ }
+ },
+ },
+ {
+ icon: ,
+ description: 'View Previous version',
+ onClick: ({ handleVersionChange }) => {
+ handleVersionChange('prev');
+ },
+ isDisabled: ({ currentVersionIndex }) => {
+ if (currentVersionIndex === 0) {
+ return true;
+ }
+
+ return false;
+ },
+ },
+ {
+ icon: ,
+ description: 'View Next version',
+ onClick: ({ handleVersionChange }) => {
+ handleVersionChange('next');
+ },
+ isDisabled: ({ isCurrentVersion }) => {
+ if (isCurrentVersion) {
+ return true;
+ }
+
+ return false;
+ },
+ },
+ {
+ icon: ,
+ description: 'Copy code to clipboard',
+ onClick: ({ content }) => {
+ navigator.clipboard.writeText(content);
+ toast.success('Copied to clipboard!');
+ },
+ },
+ ],
+ toolbar: [
+ {
+ icon: ,
+ description: 'Add comments',
+ onClick: ({ sendMessage }) => {
+ sendMessage({
+ role: 'user',
+ parts: [
+ {
+ type: 'text',
+ text: 'Add comments to the code snippet for understanding',
+ },
+ ],
+ });
+ },
+ },
+ {
+ icon: ,
+ description: 'Add logs',
+ onClick: ({ sendMessage }) => {
+ sendMessage({
+ role: 'user',
+ parts: [
+ {
+ type: 'text',
+ text: 'Add logs to the code snippet for debugging',
+ },
+ ],
+ });
+ },
+ },
+ ],
+});
diff --git a/artifacts/code/server.ts b/artifacts/code/server.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5b397bfa46090e60aab83f6fd6454dd1922de4b9
--- /dev/null
+++ b/artifacts/code/server.ts
@@ -0,0 +1,75 @@
+import { z } from 'zod';
+import { streamObject } from 'ai';
+import { myProvider } from '@/lib/ai/providers';
+import { codePrompt, updateDocumentPrompt } from '@/lib/ai/prompts';
+import { createDocumentHandler } from '@/lib/artifacts/server';
+
+export const codeDocumentHandler = createDocumentHandler<'code'>({
+ kind: 'code',
+ onCreateDocument: async ({ title, dataStream }) => {
+ let draftContent = '';
+
+ const { fullStream } = streamObject({
+ model: myProvider.languageModel('artifact-model'),
+ system: codePrompt,
+ prompt: title,
+ schema: z.object({
+ code: z.string(),
+ }),
+ });
+
+ for await (const delta of fullStream) {
+ const { type } = delta;
+
+ if (type === 'object') {
+ const { object } = delta;
+ const { code } = object;
+
+ if (code) {
+ dataStream.write({
+ type: 'data-codeDelta',
+ data: code ?? '',
+ transient: true,
+ });
+
+ draftContent = code;
+ }
+ }
+ }
+
+ return draftContent;
+ },
+ onUpdateDocument: async ({ document, description, dataStream }) => {
+ let draftContent = '';
+
+ const { fullStream } = streamObject({
+ model: myProvider.languageModel('artifact-model'),
+ system: updateDocumentPrompt(document.content, 'code'),
+ prompt: description,
+ schema: z.object({
+ code: z.string(),
+ }),
+ });
+
+ for await (const delta of fullStream) {
+ const { type } = delta;
+
+ if (type === 'object') {
+ const { object } = delta;
+ const { code } = object;
+
+ if (code) {
+ dataStream.write({
+ type: 'data-codeDelta',
+ data: code ?? '',
+ transient: true,
+ });
+
+ draftContent = code;
+ }
+ }
+ }
+
+ return draftContent;
+ },
+});
diff --git a/artifacts/image/client.tsx b/artifacts/image/client.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7b88b9d827a24e33616809cb708e551e45cf8520
--- /dev/null
+++ b/artifacts/image/client.tsx
@@ -0,0 +1,76 @@
+import { Artifact } from '@/components/create-artifact';
+import { CopyIcon, RedoIcon, UndoIcon } from '@/components/icons';
+import { ImageEditor } from '@/components/image-editor';
+import { toast } from 'sonner';
+
+export const imageArtifact = new Artifact({
+ kind: 'image',
+ description: 'Useful for image generation',
+ onStreamPart: ({ streamPart, setArtifact }) => {
+ if (streamPart.type === 'data-imageDelta') {
+ setArtifact((draftArtifact) => ({
+ ...draftArtifact,
+ content: streamPart.data,
+ isVisible: true,
+ status: 'streaming',
+ }));
+ }
+ },
+ content: ImageEditor,
+ actions: [
+ {
+ icon: ,
+ description: 'View Previous version',
+ onClick: ({ handleVersionChange }) => {
+ handleVersionChange('prev');
+ },
+ isDisabled: ({ currentVersionIndex }) => {
+ if (currentVersionIndex === 0) {
+ return true;
+ }
+
+ return false;
+ },
+ },
+ {
+ icon: ,
+ description: 'View Next version',
+ onClick: ({ handleVersionChange }) => {
+ handleVersionChange('next');
+ },
+ isDisabled: ({ isCurrentVersion }) => {
+ if (isCurrentVersion) {
+ return true;
+ }
+
+ return false;
+ },
+ },
+ {
+ icon: ,
+ description: 'Copy image to clipboard',
+ onClick: ({ content }) => {
+ const img = new Image();
+ img.src = `data:image/png;base64,${content}`;
+
+ img.onload = () => {
+ const canvas = document.createElement('canvas');
+ canvas.width = img.width;
+ canvas.height = img.height;
+ const ctx = canvas.getContext('2d');
+ ctx?.drawImage(img, 0, 0);
+ canvas.toBlob((blob) => {
+ if (blob) {
+ navigator.clipboard.write([
+ new ClipboardItem({ 'image/png': blob }),
+ ]);
+ }
+ }, 'image/png');
+ };
+
+ toast.success('Copied image to clipboard!');
+ },
+ },
+ ],
+ toolbar: [],
+});
diff --git a/artifacts/image/server.ts b/artifacts/image/server.ts
new file mode 100644
index 0000000000000000000000000000000000000000..14a994c7ceec6b3a514fb88d43cf9692be4eccdb
--- /dev/null
+++ b/artifacts/image/server.ts
@@ -0,0 +1,45 @@
+import { myProvider } from '@/lib/ai/providers';
+import { createDocumentHandler } from '@/lib/artifacts/server';
+import { experimental_generateImage } from 'ai';
+
+export const imageDocumentHandler = createDocumentHandler<'image'>({
+ kind: 'image',
+ onCreateDocument: async ({ title, dataStream }) => {
+ let draftContent = '';
+
+ const { image } = await experimental_generateImage({
+ model: myProvider.imageModel('small-model'),
+ prompt: title,
+ n: 1,
+ });
+
+ draftContent = image.base64;
+
+ dataStream.write({
+ type: 'data-imageDelta',
+ data: image.base64,
+ transient: true,
+ });
+
+ return draftContent;
+ },
+ onUpdateDocument: async ({ description, dataStream }) => {
+ let draftContent = '';
+
+ const { image } = await experimental_generateImage({
+ model: myProvider.imageModel('small-model'),
+ prompt: description,
+ n: 1,
+ });
+
+ draftContent = image.base64;
+
+ dataStream.write({
+ type: 'data-imageDelta',
+ data: image.base64,
+ transient: true,
+ });
+
+ return draftContent;
+ },
+});
diff --git a/artifacts/sheet/client.tsx b/artifacts/sheet/client.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3e7dd7578140e00f2788b81c58a379073acaada6
--- /dev/null
+++ b/artifacts/sheet/client.tsx
@@ -0,0 +1,121 @@
+import { Artifact } from '@/components/create-artifact';
+import {
+ CopyIcon,
+ LineChartIcon,
+ RedoIcon,
+ SparklesIcon,
+ UndoIcon,
+} from '@/components/icons';
+import { SpreadsheetEditor } from '@/components/sheet-editor';
+import { parse, unparse } from 'papaparse';
+import { toast } from 'sonner';
+
+type Metadata = any;
+
+export const sheetArtifact = new Artifact<'sheet', Metadata>({
+ kind: 'sheet',
+ description: 'Useful for working with spreadsheets',
+ initialize: async () => {},
+ onStreamPart: ({ setArtifact, streamPart }) => {
+ if (streamPart.type === 'data-sheetDelta') {
+ setArtifact((draftArtifact) => ({
+ ...draftArtifact,
+ content: streamPart.data,
+ isVisible: true,
+ status: 'streaming',
+ }));
+ }
+ },
+ content: ({
+ content,
+ currentVersionIndex,
+ isCurrentVersion,
+ onSaveContent,
+ status,
+ }) => {
+ return (
+
+ );
+ },
+ actions: [
+ {
+ icon: ,
+ description: 'View Previous version',
+ onClick: ({ handleVersionChange }) => {
+ handleVersionChange('prev');
+ },
+ isDisabled: ({ currentVersionIndex }) => {
+ if (currentVersionIndex === 0) {
+ return true;
+ }
+
+ return false;
+ },
+ },
+ {
+ icon: ,
+ description: 'View Next version',
+ onClick: ({ handleVersionChange }) => {
+ handleVersionChange('next');
+ },
+ isDisabled: ({ isCurrentVersion }) => {
+ if (isCurrentVersion) {
+ return true;
+ }
+
+ return false;
+ },
+ },
+ {
+ icon: ,
+ description: 'Copy as .csv',
+ onClick: ({ content }) => {
+ const parsed = parse(content, { skipEmptyLines: true });
+
+ const nonEmptyRows = parsed.data.filter((row) =>
+ row.some((cell) => cell.trim() !== ''),
+ );
+
+ const cleanedCsv = unparse(nonEmptyRows);
+
+ navigator.clipboard.writeText(cleanedCsv);
+ toast.success('Copied csv to clipboard!');
+ },
+ },
+ ],
+ toolbar: [
+ {
+ description: 'Format and clean data',
+ icon: ,
+ onClick: ({ sendMessage }) => {
+ sendMessage({
+ role: 'user',
+ parts: [
+ { type: 'text', text: 'Can you please format and clean the data?' },
+ ],
+ });
+ },
+ },
+ {
+ description: 'Analyze and visualize data',
+ icon: ,
+ onClick: ({ sendMessage }) => {
+ sendMessage({
+ role: 'user',
+ parts: [
+ {
+ type: 'text',
+ text: 'Can you please analyze and visualize the data by creating a new code artifact in python?',
+ },
+ ],
+ });
+ },
+ },
+ ],
+});
diff --git a/artifacts/sheet/server.ts b/artifacts/sheet/server.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5b19e99f52ab423b4f56992f45a486b4a987a0f2
--- /dev/null
+++ b/artifacts/sheet/server.ts
@@ -0,0 +1,81 @@
+import { myProvider } from '@/lib/ai/providers';
+import { sheetPrompt, updateDocumentPrompt } from '@/lib/ai/prompts';
+import { createDocumentHandler } from '@/lib/artifacts/server';
+import { streamObject } from 'ai';
+import { z } from 'zod';
+
+export const sheetDocumentHandler = createDocumentHandler<'sheet'>({
+ kind: 'sheet',
+ onCreateDocument: async ({ title, dataStream }) => {
+ let draftContent = '';
+
+ const { fullStream } = streamObject({
+ model: myProvider.languageModel('artifact-model'),
+ system: sheetPrompt,
+ prompt: title,
+ schema: z.object({
+ csv: z.string().describe('CSV data'),
+ }),
+ });
+
+ for await (const delta of fullStream) {
+ const { type } = delta;
+
+ if (type === 'object') {
+ const { object } = delta;
+ const { csv } = object;
+
+ if (csv) {
+ dataStream.write({
+ type: 'data-sheetDelta',
+ data: csv,
+ transient: true,
+ });
+
+ draftContent = csv;
+ }
+ }
+ }
+
+ dataStream.write({
+ type: 'data-sheetDelta',
+ data: draftContent,
+ transient: true,
+ });
+
+ return draftContent;
+ },
+ onUpdateDocument: async ({ document, description, dataStream }) => {
+ let draftContent = '';
+
+ const { fullStream } = streamObject({
+ model: myProvider.languageModel('artifact-model'),
+ system: updateDocumentPrompt(document.content, 'sheet'),
+ prompt: description,
+ schema: z.object({
+ csv: z.string(),
+ }),
+ });
+
+ for await (const delta of fullStream) {
+ const { type } = delta;
+
+ if (type === 'object') {
+ const { object } = delta;
+ const { csv } = object;
+
+ if (csv) {
+ dataStream.write({
+ type: 'data-sheetDelta',
+ data: csv,
+ transient: true,
+ });
+
+ draftContent = csv;
+ }
+ }
+ }
+
+ return draftContent;
+ },
+});
diff --git a/artifacts/text/client.tsx b/artifacts/text/client.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f2371ac5fdd4243facd643ed085b5bee3ce046ff
--- /dev/null
+++ b/artifacts/text/client.tsx
@@ -0,0 +1,181 @@
+import { Artifact } from '@/components/create-artifact';
+import { DiffView } from '@/components/diffview';
+import { DocumentSkeleton } from '@/components/document-skeleton';
+import { Editor } from '@/components/text-editor';
+import {
+ ClockRewind,
+ CopyIcon,
+ MessageIcon,
+ PenIcon,
+ RedoIcon,
+ UndoIcon,
+} from '@/components/icons';
+import type { Suggestion } from '@/lib/db/schema';
+import { toast } from 'sonner';
+import { getSuggestions } from '../actions';
+
+interface TextArtifactMetadata {
+ suggestions: Array;
+}
+
+export const textArtifact = new Artifact<'text', TextArtifactMetadata>({
+ kind: 'text',
+ description: 'Useful for text content, like drafting essays and emails.',
+ initialize: async ({ documentId, setMetadata }) => {
+ const suggestions = await getSuggestions({ documentId });
+
+ setMetadata({
+ suggestions,
+ });
+ },
+ onStreamPart: ({ streamPart, setMetadata, setArtifact }) => {
+ if (streamPart.type === 'data-suggestion') {
+ setMetadata((metadata) => {
+ return {
+ suggestions: [...metadata.suggestions, streamPart.data],
+ };
+ });
+ }
+
+ if (streamPart.type === 'data-textDelta') {
+ setArtifact((draftArtifact) => {
+ return {
+ ...draftArtifact,
+ content: draftArtifact.content + streamPart.data,
+ isVisible:
+ draftArtifact.status === 'streaming' &&
+ draftArtifact.content.length > 400 &&
+ draftArtifact.content.length < 450
+ ? true
+ : draftArtifact.isVisible,
+ status: 'streaming',
+ };
+ });
+ }
+ },
+ content: ({
+ mode,
+ status,
+ content,
+ isCurrentVersion,
+ currentVersionIndex,
+ onSaveContent,
+ getDocumentContentById,
+ isLoading,
+ metadata,
+ }) => {
+ if (isLoading) {
+ return ;
+ }
+
+ if (mode === 'diff') {
+ const oldContent = getDocumentContentById(currentVersionIndex - 1);
+ const newContent = getDocumentContentById(currentVersionIndex);
+
+ return ;
+ }
+
+ return (
+ <>
+
+
+
+ {metadata?.suggestions && metadata.suggestions.length > 0 ? (
+
+ ) : null}
+
+ >
+ );
+ },
+ actions: [
+ {
+ icon: ,
+ description: 'View changes',
+ onClick: ({ handleVersionChange }) => {
+ handleVersionChange('toggle');
+ },
+ isDisabled: ({ currentVersionIndex, setMetadata }) => {
+ if (currentVersionIndex === 0) {
+ return true;
+ }
+
+ return false;
+ },
+ },
+ {
+ icon: ,
+ description: 'View Previous version',
+ onClick: ({ handleVersionChange }) => {
+ handleVersionChange('prev');
+ },
+ isDisabled: ({ currentVersionIndex }) => {
+ if (currentVersionIndex === 0) {
+ return true;
+ }
+
+ return false;
+ },
+ },
+ {
+ icon: ,
+ description: 'View Next version',
+ onClick: ({ handleVersionChange }) => {
+ handleVersionChange('next');
+ },
+ isDisabled: ({ isCurrentVersion }) => {
+ if (isCurrentVersion) {
+ return true;
+ }
+
+ return false;
+ },
+ },
+ {
+ icon: ,
+ description: 'Copy to clipboard',
+ onClick: ({ content }) => {
+ navigator.clipboard.writeText(content);
+ toast.success('Copied to clipboard!');
+ },
+ },
+ ],
+ toolbar: [
+ {
+ icon: ,
+ description: 'Add final polish',
+ onClick: ({ sendMessage }) => {
+ sendMessage({
+ role: 'user',
+ parts: [
+ {
+ type: 'text',
+ text: 'Please add final polish and check for grammar, add section titles for better structure, and ensure everything reads smoothly.',
+ },
+ ],
+ });
+ },
+ },
+ {
+ icon: ,
+ description: 'Request suggestions',
+ onClick: ({ sendMessage }) => {
+ sendMessage({
+ role: 'user',
+ parts: [
+ {
+ type: 'text',
+ text: 'Please add suggestions you have that could improve the writing.',
+ },
+ ],
+ });
+ },
+ },
+ ],
+});
diff --git a/artifacts/text/server.ts b/artifacts/text/server.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c352621158c44ea81daca0167028168d6b88fec7
--- /dev/null
+++ b/artifacts/text/server.ts
@@ -0,0 +1,73 @@
+import { smoothStream, streamText } from 'ai';
+import { myProvider } from '@/lib/ai/providers';
+import { createDocumentHandler } from '@/lib/artifacts/server';
+import { updateDocumentPrompt } from '@/lib/ai/prompts';
+
+export const textDocumentHandler = createDocumentHandler<'text'>({
+ kind: 'text',
+ onCreateDocument: async ({ title, dataStream }) => {
+ let draftContent = '';
+
+ const { fullStream } = streamText({
+ model: myProvider.languageModel('artifact-model'),
+ system:
+ 'Write about the given topic. Markdown is supported. Use headings wherever appropriate.',
+ experimental_transform: smoothStream({ chunking: 'word' }),
+ prompt: title,
+ });
+
+ for await (const delta of fullStream) {
+ const { type } = delta;
+
+ if (type === 'text-delta') {
+ const { text } = delta;
+
+ draftContent += text;
+
+ dataStream.write({
+ type: 'data-textDelta',
+ data: text,
+ transient: true,
+ });
+ }
+ }
+
+ return draftContent;
+ },
+ onUpdateDocument: async ({ document, description, dataStream }) => {
+ let draftContent = '';
+
+ const { fullStream } = streamText({
+ model: myProvider.languageModel('artifact-model'),
+ system: updateDocumentPrompt(document.content, 'text'),
+ experimental_transform: smoothStream({ chunking: 'word' }),
+ prompt: description,
+ providerOptions: {
+ openai: {
+ prediction: {
+ type: 'content',
+ content: document.content,
+ },
+ },
+ },
+ });
+
+ for await (const delta of fullStream) {
+ const { type } = delta;
+
+ if (type === 'text-delta') {
+ const { text } = delta;
+
+ draftContent += text;
+
+ dataStream.write({
+ type: 'data-textDelta',
+ data: text,
+ transient: true,
+ });
+ }
+ }
+
+ return draftContent;
+ },
+});
diff --git a/biome.jsonc b/biome.jsonc
new file mode 100644
index 0000000000000000000000000000000000000000..d227936e56e7fc4584d435cf7bdb4d21ed759a27
--- /dev/null
+++ b/biome.jsonc
@@ -0,0 +1,135 @@
+{
+ "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
+ "files": {
+ "ignoreUnknown": false,
+ "ignore": [
+ "**/pnpm-lock.yaml",
+ "lib/db/migrations",
+ "lib/editor/react-renderer.tsx",
+ "node_modules",
+ ".next",
+ "public",
+ ".vercel"
+ ]
+ },
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "defaultBranch": "main",
+ "useIgnoreFile": true
+ },
+ "formatter": {
+ "enabled": true,
+ "formatWithErrors": false,
+ "indentStyle": "space",
+ "indentWidth": 2,
+ "lineEnding": "lf",
+ "lineWidth": 80,
+ "attributePosition": "auto"
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": true,
+ "a11y": {
+ "useHtmlLang": "warn", // Not in recommended ruleset, turning on manually
+ "noHeaderScope": "warn", // Not in recommended ruleset, turning on manually
+ "useValidAriaRole": {
+ "level": "warn",
+ "options": {
+ "ignoreNonDom": false,
+ "allowInvalidRoles": ["none", "text"]
+ }
+ },
+ "useSemanticElements": "off", // Rule is buggy, revisit later
+ "noSvgWithoutTitle": "off", // We do not intend to adhere to this rule
+ "useMediaCaption": "off", // We would need a cultural change to turn this on
+ "noAutofocus": "off", // We're highly intentional about when we use autofocus
+ "noBlankTarget": "off", // Covered by Conformance
+ "useFocusableInteractive": "off", // Disable focusable interactive element requirement
+ "useAriaPropsForRole": "off", // Disable required ARIA attributes check
+ "useKeyWithClickEvents": "off" // Disable keyboard event requirement with click events
+ },
+ "complexity": {
+ "noUselessStringConcat": "warn", // Not in recommended ruleset, turning on manually
+ "noForEach": "off", // forEach is too familiar to ban
+ "noUselessSwitchCase": "off", // Turned off due to developer preferences
+ "noUselessThisAlias": "off", // Turned off due to developer preferences
+ "noBannedTypes": "off"
+ },
+ "correctness": {
+ "noUnusedImports": "warn", // Not in recommended ruleset, turning on manually
+ "useArrayLiterals": "warn", // Not in recommended ruleset, turning on manually
+ "noNewSymbol": "warn", // Not in recommended ruleset, turning on manually
+ "useJsxKeyInIterable": "off", // Rule is buggy, revisit later
+ "useExhaustiveDependencies": "off", // Community feedback on this rule has been poor, we will continue with ESLint
+ "noUnnecessaryContinue": "off" // Turned off due to developer preferences
+ },
+ "security": {
+ "noDangerouslySetInnerHtml": "off" // Covered by Conformance
+ },
+ "style": {
+ "useFragmentSyntax": "warn", // Not in recommended ruleset, turning on manually
+ "noYodaExpression": "warn", // Not in recommended ruleset, turning on manually
+ "useDefaultParameterLast": "warn", // Not in recommended ruleset, turning on manually
+ "useExponentiationOperator": "off", // Obscure and arguably not easily readable
+ "noUnusedTemplateLiteral": "off", // Stylistic opinion
+ "noUselessElse": "off" // Stylistic opinion
+ },
+ "suspicious": {
+ "noExplicitAny": "off" // We trust Vercelians to use any only when necessary
+ },
+ "nursery": {
+ "noStaticElementInteractions": "warn",
+ "noHeadImportInDocument": "warn",
+ "noDocumentImportInPage": "warn",
+ "noDuplicateElseIf": "warn",
+ "noIrregularWhitespace": "warn",
+ "useValidAutocomplete": "warn"
+ }
+ }
+ },
+ "javascript": {
+ "jsxRuntime": "reactClassic",
+ "formatter": {
+ "jsxQuoteStyle": "double",
+ "quoteProperties": "asNeeded",
+ "trailingCommas": "all",
+ "semicolons": "always",
+ "arrowParentheses": "always",
+ "bracketSpacing": true,
+ "bracketSameLine": false,
+ "quoteStyle": "single",
+ "attributePosition": "auto"
+ }
+ },
+ "json": {
+ "formatter": {
+ "enabled": true,
+ "trailingCommas": "none"
+ },
+ "parser": {
+ "allowComments": true,
+ "allowTrailingCommas": false
+ }
+ },
+ "css": {
+ "formatter": { "enabled": false },
+ "linter": { "enabled": false }
+ },
+ "organizeImports": { "enabled": false },
+ "overrides": [
+ // Playwright requires an object destructure, even if empty
+ // https://github.com/microsoft/playwright/issues/30007
+ {
+ "include": ["playwright/**"],
+ "linter": {
+ "rules": {
+ "correctness": {
+ "noEmptyPattern": "off"
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/components.json b/components.json
new file mode 100644
index 0000000000000000000000000000000000000000..5b6d2bc9a7b4a1553e19955a486dfd6dff07e8e3
--- /dev/null
+++ b/components.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.ts",
+ "css": "app/globals.css",
+ "baseColor": "zinc",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ }
+}
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5b08bb58c8483d6b3b5a43c69964d6ad55636f69
--- /dev/null
+++ b/components/app-sidebar.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import type { User } from 'next-auth';
+import { useRouter } from 'next/navigation';
+
+import { PlusIcon } from '@/components/icons';
+import { SidebarHistory } from '@/components/sidebar-history';
+import { SidebarUserNav } from '@/components/sidebar-user-nav';
+import { Button } from '@/components/ui/button';
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarHeader,
+ SidebarMenu,
+ useSidebar,
+} from '@/components/ui/sidebar';
+import Link from 'next/link';
+import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
+
+export function AppSidebar({ user }: { user: User | undefined }) {
+ const router = useRouter();
+ const { setOpenMobile } = useSidebar();
+
+ return (
+
+
+
+
+
{
+ setOpenMobile(false);
+ }}
+ className="flex flex-row gap-3 items-center"
+ >
+
+ Chatbot
+
+
+
+
+
+
+ New Chat
+
+
+
+
+
+
+
+ {user && }
+
+ );
+}
diff --git a/components/artifact-actions.tsx b/components/artifact-actions.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..009cbedaa5fdd6b8ec53b22c13571ade4243842c
--- /dev/null
+++ b/components/artifact-actions.tsx
@@ -0,0 +1,100 @@
+import { Button } from './ui/button';
+import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
+import { artifactDefinitions, UIArtifact } from './artifact';
+import { Dispatch, memo, SetStateAction, useState } from 'react';
+import { ArtifactActionContext } from './create-artifact';
+import { cn } from '@/lib/utils';
+import { toast } from 'sonner';
+
+interface ArtifactActionsProps {
+ artifact: UIArtifact;
+ handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void;
+ currentVersionIndex: number;
+ isCurrentVersion: boolean;
+ mode: 'edit' | 'diff';
+ metadata: any;
+ setMetadata: Dispatch>;
+}
+
+function PureArtifactActions({
+ artifact,
+ handleVersionChange,
+ currentVersionIndex,
+ isCurrentVersion,
+ mode,
+ metadata,
+ setMetadata,
+}: ArtifactActionsProps) {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const artifactDefinition = artifactDefinitions.find(
+ (definition) => definition.kind === artifact.kind,
+ );
+
+ if (!artifactDefinition) {
+ throw new Error('Artifact definition not found!');
+ }
+
+ const actionContext: ArtifactActionContext = {
+ content: artifact.content,
+ handleVersionChange,
+ currentVersionIndex,
+ isCurrentVersion,
+ mode,
+ metadata,
+ setMetadata,
+ };
+
+ return (
+
+ {artifactDefinition.actions.map((action) => (
+
+
+
+
+ {action.description}
+
+ ))}
+
+ );
+}
+
+export const ArtifactActions = memo(
+ PureArtifactActions,
+ (prevProps, nextProps) => {
+ if (prevProps.artifact.status !== nextProps.artifact.status) return false;
+ if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex)
+ return false;
+ if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) return false;
+ if (prevProps.artifact.content !== nextProps.artifact.content) return false;
+
+ return true;
+ },
+);
diff --git a/components/artifact-close-button.tsx b/components/artifact-close-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..27fa9cd3a5a246eaa02a44ae66c524bcd6927b13
--- /dev/null
+++ b/components/artifact-close-button.tsx
@@ -0,0 +1,30 @@
+import { memo } from 'react';
+import { CrossIcon } from './icons';
+import { Button } from './ui/button';
+import { initialArtifactData, useArtifact } from '@/hooks/use-artifact';
+
+function PureArtifactCloseButton() {
+ const { setArtifact } = useArtifact();
+
+ return (
+
+ );
+}
+
+export const ArtifactCloseButton = memo(PureArtifactCloseButton, () => true);
diff --git a/components/artifact-messages.tsx b/components/artifact-messages.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4d71435562a42af5adf6dc95d79d911534f0af10
--- /dev/null
+++ b/components/artifact-messages.tsx
@@ -0,0 +1,99 @@
+import { PreviewMessage, ThinkingMessage } from './message';
+import type { Vote } from '@/lib/db/schema';
+import { memo } from 'react';
+import equal from 'fast-deep-equal';
+import type { UIArtifact } from './artifact';
+import type { UseChatHelpers } from '@ai-sdk/react';
+import { motion } from 'framer-motion';
+import { useMessages } from '@/hooks/use-messages';
+import type { ChatMessage } from '@/lib/types';
+
+interface ArtifactMessagesProps {
+ chatId: string;
+ status: UseChatHelpers['status'];
+ votes: Array | undefined;
+ messages: ChatMessage[];
+ setMessages: UseChatHelpers['setMessages'];
+ regenerate: UseChatHelpers['regenerate'];
+ isReadonly: boolean;
+ artifactStatus: UIArtifact['status'];
+}
+
+function PureArtifactMessages({
+ chatId,
+ status,
+ votes,
+ messages,
+ setMessages,
+ regenerate,
+ isReadonly,
+}: ArtifactMessagesProps) {
+ const {
+ containerRef: messagesContainerRef,
+ endRef: messagesEndRef,
+ onViewportEnter,
+ onViewportLeave,
+ hasSentMessage,
+ } = useMessages({
+ chatId,
+ status,
+ });
+
+ return (
+
+ {messages.map((message, index) => (
+
vote.messageId === message.id)
+ : undefined
+ }
+ setMessages={setMessages}
+ regenerate={regenerate}
+ isReadonly={isReadonly}
+ requiresScrollPadding={
+ hasSentMessage && index === messages.length - 1
+ }
+ />
+ ))}
+
+ {status === 'submitted' &&
+ messages.length > 0 &&
+ messages[messages.length - 1].role === 'user' && }
+
+
+
+ );
+}
+
+function areEqual(
+ prevProps: ArtifactMessagesProps,
+ nextProps: ArtifactMessagesProps,
+) {
+ if (
+ prevProps.artifactStatus === 'streaming' &&
+ nextProps.artifactStatus === 'streaming'
+ )
+ return true;
+
+ if (prevProps.status !== nextProps.status) return false;
+ if (prevProps.status && nextProps.status) return false;
+ if (prevProps.messages.length !== nextProps.messages.length) return false;
+ if (!equal(prevProps.votes, nextProps.votes)) return false;
+
+ return true;
+}
+
+export const ArtifactMessages = memo(PureArtifactMessages, areEqual);
diff --git a/components/artifact.tsx b/components/artifact.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6b266d9e6e9785c6d24953e0f0aa4aa2c82ff686
--- /dev/null
+++ b/components/artifact.tsx
@@ -0,0 +1,511 @@
+import { formatDistance } from 'date-fns';
+import { AnimatePresence, motion } from 'framer-motion';
+import {
+ type Dispatch,
+ memo,
+ type SetStateAction,
+ useCallback,
+ useEffect,
+ useState,
+} from 'react';
+import useSWR, { useSWRConfig } from 'swr';
+import { useDebounceCallback, useWindowSize } from 'usehooks-ts';
+import type { Document, Vote } from '@/lib/db/schema';
+import { fetcher } from '@/lib/utils';
+import { MultimodalInput } from './multimodal-input';
+import { Toolbar } from './toolbar';
+import { VersionFooter } from './version-footer';
+import { ArtifactActions } from './artifact-actions';
+import { ArtifactCloseButton } from './artifact-close-button';
+import { ArtifactMessages } from './artifact-messages';
+import { useSidebar } from './ui/sidebar';
+import { useArtifact } from '@/hooks/use-artifact';
+import { imageArtifact } from '@/artifacts/image/client';
+import { codeArtifact } from '@/artifacts/code/client';
+import { sheetArtifact } from '@/artifacts/sheet/client';
+import { textArtifact } from '@/artifacts/text/client';
+import equal from 'fast-deep-equal';
+import type { UseChatHelpers } from '@ai-sdk/react';
+import type { VisibilityType } from './visibility-selector';
+import type { Attachment, ChatMessage } from '@/lib/types';
+
+export const artifactDefinitions = [
+ textArtifact,
+ codeArtifact,
+ imageArtifact,
+ sheetArtifact,
+];
+export type ArtifactKind = (typeof artifactDefinitions)[number]['kind'];
+
+export interface UIArtifact {
+ title: string;
+ documentId: string;
+ kind: ArtifactKind;
+ content: string;
+ isVisible: boolean;
+ status: 'streaming' | 'idle';
+ boundingBox: {
+ top: number;
+ left: number;
+ width: number;
+ height: number;
+ };
+}
+
+function PureArtifact({
+ chatId,
+ input,
+ setInput,
+ status,
+ stop,
+ attachments,
+ setAttachments,
+ sendMessage,
+ messages,
+ setMessages,
+ regenerate,
+ votes,
+ isReadonly,
+ selectedVisibilityType,
+}: {
+ chatId: string;
+ input: string;
+ setInput: Dispatch>;
+ status: UseChatHelpers['status'];
+ stop: UseChatHelpers['stop'];
+ attachments: Attachment[];
+ setAttachments: Dispatch>;
+ messages: ChatMessage[];
+ setMessages: UseChatHelpers['setMessages'];
+ votes: Array | undefined;
+ sendMessage: UseChatHelpers['sendMessage'];
+ regenerate: UseChatHelpers['regenerate'];
+ isReadonly: boolean;
+ selectedVisibilityType: VisibilityType;
+}) {
+ const { artifact, setArtifact, metadata, setMetadata } = useArtifact();
+
+ const {
+ data: documents,
+ isLoading: isDocumentsFetching,
+ mutate: mutateDocuments,
+ } = useSWR>(
+ artifact.documentId !== 'init' && artifact.status !== 'streaming'
+ ? `/api/document?id=${artifact.documentId}`
+ : null,
+ fetcher,
+ );
+
+ const [mode, setMode] = useState<'edit' | 'diff'>('edit');
+ const [document, setDocument] = useState(null);
+ const [currentVersionIndex, setCurrentVersionIndex] = useState(-1);
+
+ const { open: isSidebarOpen } = useSidebar();
+
+ useEffect(() => {
+ if (documents && documents.length > 0) {
+ const mostRecentDocument = documents.at(-1);
+
+ if (mostRecentDocument) {
+ setDocument(mostRecentDocument);
+ setCurrentVersionIndex(documents.length - 1);
+ setArtifact((currentArtifact) => ({
+ ...currentArtifact,
+ content: mostRecentDocument.content ?? '',
+ }));
+ }
+ }
+ }, [documents, setArtifact]);
+
+ useEffect(() => {
+ mutateDocuments();
+ }, [artifact.status, mutateDocuments]);
+
+ const { mutate } = useSWRConfig();
+ const [isContentDirty, setIsContentDirty] = useState(false);
+
+ const handleContentChange = useCallback(
+ (updatedContent: string) => {
+ if (!artifact) return;
+
+ mutate>(
+ `/api/document?id=${artifact.documentId}`,
+ async (currentDocuments) => {
+ if (!currentDocuments) return undefined;
+
+ const currentDocument = currentDocuments.at(-1);
+
+ if (!currentDocument || !currentDocument.content) {
+ setIsContentDirty(false);
+ return currentDocuments;
+ }
+
+ if (currentDocument.content !== updatedContent) {
+ await fetch(`/api/document?id=${artifact.documentId}`, {
+ method: 'POST',
+ body: JSON.stringify({
+ title: artifact.title,
+ content: updatedContent,
+ kind: artifact.kind,
+ }),
+ });
+
+ setIsContentDirty(false);
+
+ const newDocument = {
+ ...currentDocument,
+ content: updatedContent,
+ createdAt: new Date(),
+ };
+
+ return [...currentDocuments, newDocument];
+ }
+ return currentDocuments;
+ },
+ { revalidate: false },
+ );
+ },
+ [artifact, mutate],
+ );
+
+ const debouncedHandleContentChange = useDebounceCallback(
+ handleContentChange,
+ 2000,
+ );
+
+ const saveContent = useCallback(
+ (updatedContent: string, debounce: boolean) => {
+ if (document && updatedContent !== document.content) {
+ setIsContentDirty(true);
+
+ if (debounce) {
+ debouncedHandleContentChange(updatedContent);
+ } else {
+ handleContentChange(updatedContent);
+ }
+ }
+ },
+ [document, debouncedHandleContentChange, handleContentChange],
+ );
+
+ function getDocumentContentById(index: number) {
+ if (!documents) return '';
+ if (!documents[index]) return '';
+ return documents[index].content ?? '';
+ }
+
+ const handleVersionChange = (type: 'next' | 'prev' | 'toggle' | 'latest') => {
+ if (!documents) return;
+
+ if (type === 'latest') {
+ setCurrentVersionIndex(documents.length - 1);
+ setMode('edit');
+ }
+
+ if (type === 'toggle') {
+ setMode((mode) => (mode === 'edit' ? 'diff' : 'edit'));
+ }
+
+ if (type === 'prev') {
+ if (currentVersionIndex > 0) {
+ setCurrentVersionIndex((index) => index - 1);
+ }
+ } else if (type === 'next') {
+ if (currentVersionIndex < documents.length - 1) {
+ setCurrentVersionIndex((index) => index + 1);
+ }
+ }
+ };
+
+ const [isToolbarVisible, setIsToolbarVisible] = useState(false);
+
+ /*
+ * NOTE: if there are no documents, or if
+ * the documents are being fetched, then
+ * we mark it as the current version.
+ */
+
+ const isCurrentVersion =
+ documents && documents.length > 0
+ ? currentVersionIndex === documents.length - 1
+ : true;
+
+ const { width: windowWidth, height: windowHeight } = useWindowSize();
+ const isMobile = windowWidth ? windowWidth < 768 : false;
+
+ const artifactDefinition = artifactDefinitions.find(
+ (definition) => definition.kind === artifact.kind,
+ );
+
+ if (!artifactDefinition) {
+ throw new Error('Artifact definition not found!');
+ }
+
+ useEffect(() => {
+ if (artifact.documentId !== 'init') {
+ if (artifactDefinition.initialize) {
+ artifactDefinition.initialize({
+ documentId: artifact.documentId,
+ setMetadata,
+ });
+ }
+ }
+ }, [artifact.documentId, artifactDefinition, setMetadata]);
+
+ return (
+
+ {artifact.isVisible && (
+
+ {!isMobile && (
+
+ )}
+
+ {!isMobile && (
+
+
+ {!isCurrentVersion && (
+
+ )}
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
{artifact.title}
+
+ {isContentDirty ? (
+
+ Saving changes...
+
+ ) : document ? (
+
+ {`Updated ${formatDistance(
+ new Date(document.createdAt),
+ new Date(),
+ {
+ addSuffix: true,
+ },
+ )}`}
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {isCurrentVersion && (
+
+ )}
+
+
+
+
+ {!isCurrentVersion && (
+
+ )}
+
+
+
+ )}
+
+ );
+}
+
+export const Artifact = memo(PureArtifact, (prevProps, nextProps) => {
+ if (prevProps.status !== nextProps.status) return false;
+ if (!equal(prevProps.votes, nextProps.votes)) return false;
+ if (prevProps.input !== nextProps.input) return false;
+ if (!equal(prevProps.messages, nextProps.messages.length)) return false;
+ if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType)
+ return false;
+
+ return true;
+});
diff --git a/components/auth-form.tsx b/components/auth-form.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..009eed273cddf7eb53090ab183588bfcbccde51e
--- /dev/null
+++ b/components/auth-form.tsx
@@ -0,0 +1,60 @@
+import Form from 'next/form';
+
+import { Input } from './ui/input';
+import { Label } from './ui/label';
+
+export function AuthForm({
+ action,
+ children,
+ defaultEmail = '',
+}: {
+ action: NonNullable<
+ string | ((formData: FormData) => void | Promise) | undefined
+ >;
+ children: React.ReactNode;
+ defaultEmail?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/components/chat-header.tsx b/components/chat-header.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..612f74de5088c907cf8ad203e5e086115d22d453
--- /dev/null
+++ b/components/chat-header.tsx
@@ -0,0 +1,95 @@
+'use client';
+
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { useWindowSize } from 'usehooks-ts';
+
+import { ModelSelector } from '@/components/model-selector';
+import { SidebarToggle } from '@/components/sidebar-toggle';
+import { Button } from '@/components/ui/button';
+import { PlusIcon, VercelIcon } from './icons';
+import { useSidebar } from './ui/sidebar';
+import { memo } from 'react';
+import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
+import { type VisibilityType, VisibilitySelector } from './visibility-selector';
+import type { Session } from 'next-auth';
+
+function PureChatHeader({
+ chatId,
+ selectedModelId,
+ selectedVisibilityType,
+ isReadonly,
+ session,
+}: {
+ chatId: string;
+ selectedModelId: string;
+ selectedVisibilityType: VisibilityType;
+ isReadonly: boolean;
+ session: Session;
+}) {
+ const router = useRouter();
+ const { open } = useSidebar();
+
+ const { width: windowWidth } = useWindowSize();
+
+ return (
+
+ );
+}
+
+export const ChatHeader = memo(PureChatHeader, (prevProps, nextProps) => {
+ return prevProps.selectedModelId === nextProps.selectedModelId;
+});
diff --git a/components/chat.tsx b/components/chat.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2f8191c65da52d62894c2b75a9d0c98a313487e7
--- /dev/null
+++ b/components/chat.tsx
@@ -0,0 +1,190 @@
+'use client';
+
+import { DefaultChatTransport } from 'ai';
+import { useChat } from '@ai-sdk/react';
+import { useEffect, useState } from 'react';
+import useSWR, { useSWRConfig } from 'swr';
+import { ChatHeader } from '@/components/chat-header';
+import type { Vote } from '@/lib/db/schema';
+import { fetcher, fetchWithErrorHandlers, generateUUID } from '@/lib/utils';
+import { Artifact } from './artifact';
+import { MultimodalInput } from './multimodal-input';
+import { Messages } from './messages';
+import type { VisibilityType } from './visibility-selector';
+import { useArtifactSelector } from '@/hooks/use-artifact';
+import { unstable_serialize } from 'swr/infinite';
+import { getChatHistoryPaginationKey } from './sidebar-history';
+import { toast } from './toast';
+import type { Session } from 'next-auth';
+import { useSearchParams } from 'next/navigation';
+import { useChatVisibility } from '@/hooks/use-chat-visibility';
+import { useAutoResume } from '@/hooks/use-auto-resume';
+import { ChatSDKError } from '@/lib/errors';
+import type { Attachment, ChatMessage } from '@/lib/types';
+import { useDataStream } from './data-stream-provider';
+
+export function Chat({
+ id,
+ initialMessages,
+ initialChatModel,
+ initialVisibilityType,
+ isReadonly,
+ session,
+ autoResume,
+}: {
+ id: string;
+ initialMessages: ChatMessage[];
+ initialChatModel: string;
+ initialVisibilityType: VisibilityType;
+ isReadonly: boolean;
+ session: Session;
+ autoResume: boolean;
+}) {
+ const { visibilityType } = useChatVisibility({
+ chatId: id,
+ initialVisibilityType,
+ });
+
+ const { mutate } = useSWRConfig();
+ const { setDataStream } = useDataStream();
+
+ const [input, setInput] = useState('');
+
+ const {
+ messages,
+ setMessages,
+ sendMessage,
+ status,
+ stop,
+ regenerate,
+ resumeStream,
+ } = useChat({
+ id,
+ messages: initialMessages,
+ experimental_throttle: 100,
+ generateId: generateUUID,
+ transport: new DefaultChatTransport({
+ api: '/api/chat',
+ fetch: fetchWithErrorHandlers,
+ prepareSendMessagesRequest({ messages, id, body }) {
+ return {
+ body: {
+ id,
+ message: messages.at(-1),
+ selectedChatModel: initialChatModel,
+ selectedVisibilityType: visibilityType,
+ ...body,
+ },
+ };
+ },
+ }),
+ onData: (dataPart) => {
+ setDataStream((ds) => (ds ? [...ds, dataPart] : []));
+ },
+ onFinish: () => {
+ mutate(unstable_serialize(getChatHistoryPaginationKey));
+ },
+ onError: (error) => {
+ if (error instanceof ChatSDKError) {
+ toast({
+ type: 'error',
+ description: error.message,
+ });
+ }
+ },
+ });
+
+ const searchParams = useSearchParams();
+ const query = searchParams.get('query');
+
+ const [hasAppendedQuery, setHasAppendedQuery] = useState(false);
+
+ useEffect(() => {
+ if (query && !hasAppendedQuery) {
+ sendMessage({
+ role: 'user' as const,
+ parts: [{ type: 'text', text: query }],
+ });
+
+ setHasAppendedQuery(true);
+ window.history.replaceState({}, '', `/chat/${id}`);
+ }
+ }, [query, sendMessage, hasAppendedQuery, id]);
+
+ const { data: votes } = useSWR>(
+ messages.length >= 2 ? `/api/vote?chatId=${id}` : null,
+ fetcher,
+ );
+
+ const [attachments, setAttachments] = useState>([]);
+ const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
+
+ useAutoResume({
+ autoResume,
+ initialMessages,
+ resumeStream,
+ setMessages,
+ });
+
+ return (
+ <>
+
+
+
+
+
+
+ {!isReadonly && (
+
+
+
+ )}
+
+
+
+
+ >
+ );
+}
diff --git a/components/code-editor.tsx b/components/code-editor.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ab81e139076b9afbddaa4177034e1ce4963bde6e
--- /dev/null
+++ b/components/code-editor.tsx
@@ -0,0 +1,113 @@
+'use client';
+
+import { EditorView } from '@codemirror/view';
+import { EditorState, Transaction } from '@codemirror/state';
+import { python } from '@codemirror/lang-python';
+import { oneDark } from '@codemirror/theme-one-dark';
+import { basicSetup } from 'codemirror';
+import React, { memo, useEffect, useRef } from 'react';
+import { Suggestion } from '@/lib/db/schema';
+
+type EditorProps = {
+ content: string;
+ onSaveContent: (updatedContent: string, debounce: boolean) => void;
+ status: 'streaming' | 'idle';
+ isCurrentVersion: boolean;
+ currentVersionIndex: number;
+ suggestions: Array;
+};
+
+function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
+ const containerRef = useRef(null);
+ const editorRef = useRef(null);
+
+ useEffect(() => {
+ if (containerRef.current && !editorRef.current) {
+ const startState = EditorState.create({
+ doc: content,
+ extensions: [basicSetup, python(), oneDark],
+ });
+
+ editorRef.current = new EditorView({
+ state: startState,
+ parent: containerRef.current,
+ });
+ }
+
+ return () => {
+ if (editorRef.current) {
+ editorRef.current.destroy();
+ editorRef.current = null;
+ }
+ };
+ // NOTE: we only want to run this effect once
+ // eslint-disable-next-line
+ }, []);
+
+ useEffect(() => {
+ if (editorRef.current) {
+ const updateListener = EditorView.updateListener.of((update) => {
+ if (update.docChanged) {
+ const transaction = update.transactions.find(
+ (tr) => !tr.annotation(Transaction.remote),
+ );
+
+ if (transaction) {
+ const newContent = update.state.doc.toString();
+ onSaveContent(newContent, true);
+ }
+ }
+ });
+
+ const currentSelection = editorRef.current.state.selection;
+
+ const newState = EditorState.create({
+ doc: editorRef.current.state.doc,
+ extensions: [basicSetup, python(), oneDark, updateListener],
+ selection: currentSelection,
+ });
+
+ editorRef.current.setState(newState);
+ }
+ }, [onSaveContent]);
+
+ useEffect(() => {
+ if (editorRef.current && content) {
+ const currentContent = editorRef.current.state.doc.toString();
+
+ if (status === 'streaming' || currentContent !== content) {
+ const transaction = editorRef.current.state.update({
+ changes: {
+ from: 0,
+ to: currentContent.length,
+ insert: content,
+ },
+ annotations: [Transaction.remote.of(true)],
+ });
+
+ editorRef.current.dispatch(transaction);
+ }
+ }
+ }, [content, status]);
+
+ return (
+
+ );
+}
+
+function areEqual(prevProps: EditorProps, nextProps: EditorProps) {
+ if (prevProps.suggestions !== nextProps.suggestions) return false;
+ if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex)
+ return false;
+ if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) return false;
+ if (prevProps.status === 'streaming' && nextProps.status === 'streaming')
+ return false;
+ if (prevProps.content !== nextProps.content) return false;
+
+ return true;
+}
+
+export const CodeEditor = memo(PureCodeEditor, areEqual);
diff --git a/components/console.tsx b/components/console.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8fc3f256aede0f322199240b13725a4b71545712
--- /dev/null
+++ b/components/console.tsx
@@ -0,0 +1,180 @@
+import { TerminalWindowIcon, CrossSmallIcon } from './icons';
+import { Loader } from './elements/loader';
+import { Button } from './ui/button';
+import {
+ type Dispatch,
+ type SetStateAction,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import { cn } from '@/lib/utils';
+import { useArtifactSelector } from '@/hooks/use-artifact';
+
+export interface ConsoleOutputContent {
+ type: 'text' | 'image';
+ value: string;
+}
+
+export interface ConsoleOutput {
+ id: string;
+ status: 'in_progress' | 'loading_packages' | 'completed' | 'failed';
+ contents: Array;
+}
+
+interface ConsoleProps {
+ consoleOutputs: Array;
+ setConsoleOutputs: Dispatch>>;
+}
+
+export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
+ const [height, setHeight] = useState(300);
+ const [isResizing, setIsResizing] = useState(false);
+ const consoleEndRef = useRef(null);
+
+ const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
+
+ const minHeight = 100;
+ const maxHeight = 800;
+
+ const startResizing = useCallback(() => {
+ setIsResizing(true);
+ }, []);
+
+ const stopResizing = useCallback(() => {
+ setIsResizing(false);
+ }, []);
+
+ const resize = useCallback(
+ (e: MouseEvent) => {
+ if (isResizing) {
+ const newHeight = window.innerHeight - e.clientY;
+ if (newHeight >= minHeight && newHeight <= maxHeight) {
+ setHeight(newHeight);
+ }
+ }
+ },
+ [isResizing],
+ );
+
+ useEffect(() => {
+ window.addEventListener('mousemove', resize);
+ window.addEventListener('mouseup', stopResizing);
+ return () => {
+ window.removeEventListener('mousemove', resize);
+ window.removeEventListener('mouseup', stopResizing);
+ };
+ }, [resize, stopResizing]);
+
+ useEffect(() => {
+ consoleEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [consoleOutputs]);
+
+ useEffect(() => {
+ if (!isArtifactVisible) {
+ setConsoleOutputs([]);
+ }
+ }, [isArtifactVisible, setConsoleOutputs]);
+
+ return consoleOutputs.length > 0 ? (
+ <>
+
+
+
+
+
+
+
+
+
+ {consoleOutputs.map((consoleOutput, index) => (
+
+
+ [{index + 1}]
+
+ {['in_progress', 'loading_packages'].includes(
+ consoleOutput.status,
+ ) ? (
+
+
+
+
+
+ {consoleOutput.status === 'in_progress'
+ ? 'Initializing...'
+ : consoleOutput.status === 'loading_packages'
+ ? consoleOutput.contents.map((content) =>
+ content.type === 'text' ? content.value : null,
+ )
+ : null}
+
+
+ ) : (
+
+ {consoleOutput.contents.map((content, index) =>
+ content.type === 'image' ? (
+
+
+
+ ) : (
+
+ {content.value}
+
+ ),
+ )}
+
+ )}
+
+ ))}
+
+
+
+ >
+ ) : null;
+}
diff --git a/components/create-artifact.tsx b/components/create-artifact.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..eda62e2afb6ea37c6b84f9d83b996a325f624171
--- /dev/null
+++ b/components/create-artifact.tsx
@@ -0,0 +1,93 @@
+import type { Suggestion } from '@/lib/db/schema';
+import type { UseChatHelpers } from '@ai-sdk/react';
+import type { ComponentType, Dispatch, ReactNode, SetStateAction } from 'react';
+import type { UIArtifact } from './artifact';
+import type { ChatMessage, CustomUIDataTypes } from '@/lib/types';
+import type { DataUIPart } from 'ai';
+
+export type ArtifactActionContext = {
+ content: string;
+ handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void;
+ currentVersionIndex: number;
+ isCurrentVersion: boolean;
+ mode: 'edit' | 'diff';
+ metadata: M;
+ setMetadata: Dispatch>;
+};
+
+type ArtifactAction = {
+ icon: ReactNode;
+ label?: string;
+ description: string;
+ onClick: (context: ArtifactActionContext) => Promise | void;
+ isDisabled?: (context: ArtifactActionContext) => boolean;
+};
+
+export type ArtifactToolbarContext = {
+ sendMessage: UseChatHelpers['sendMessage'];
+};
+
+export type ArtifactToolbarItem = {
+ description: string;
+ icon: ReactNode;
+ onClick: (context: ArtifactToolbarContext) => void;
+};
+
+interface ArtifactContent {
+ title: string;
+ content: string;
+ mode: 'edit' | 'diff';
+ isCurrentVersion: boolean;
+ currentVersionIndex: number;
+ status: 'streaming' | 'idle';
+ suggestions: Array;
+ onSaveContent: (updatedContent: string, debounce: boolean) => void;
+ isInline: boolean;
+ getDocumentContentById: (index: number) => string;
+ isLoading: boolean;
+ metadata: M;
+ setMetadata: Dispatch>;
+}
+
+interface InitializeParameters {
+ documentId: string;
+ setMetadata: Dispatch>;
+}
+
+type ArtifactConfig = {
+ kind: T;
+ description: string;
+ content: ComponentType>;
+ actions: Array>;
+ toolbar: ArtifactToolbarItem[];
+ initialize?: (parameters: InitializeParameters) => void;
+ onStreamPart: (args: {
+ setMetadata: Dispatch>;
+ setArtifact: Dispatch>;
+ streamPart: DataUIPart;
+ }) => void;
+};
+
+export class Artifact {
+ readonly kind: T;
+ readonly description: string;
+ readonly content: ComponentType>;
+ readonly actions: Array>;
+ readonly toolbar: ArtifactToolbarItem[];
+ readonly initialize?: (parameters: InitializeParameters) => void;
+ readonly onStreamPart: (args: {
+ setMetadata: Dispatch>;
+ setArtifact: Dispatch>;
+ streamPart: DataUIPart;
+ }) => void;
+
+ constructor(config: ArtifactConfig) {
+ this.kind = config.kind;
+ this.description = config.description;
+ this.content = config.content;
+ this.actions = config.actions || [];
+ this.toolbar = config.toolbar || [];
+ this.initialize = config.initialize || (async () => ({}));
+ this.onStreamPart = config.onStreamPart;
+ }
+}
diff --git a/components/data-stream-handler.tsx b/components/data-stream-handler.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a42ffbe9ef04b051da3168830b0f6fcbd6348787
--- /dev/null
+++ b/components/data-stream-handler.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import { artifactDefinitions } from './artifact';
+import { initialArtifactData, useArtifact } from '@/hooks/use-artifact';
+import { useDataStream } from './data-stream-provider';
+
+export function DataStreamHandler() {
+ const { dataStream } = useDataStream();
+
+ const { artifact, setArtifact, setMetadata } = useArtifact();
+ const lastProcessedIndex = useRef(-1);
+
+ useEffect(() => {
+ if (!dataStream?.length) return;
+
+ const newDeltas = dataStream.slice(lastProcessedIndex.current + 1);
+ lastProcessedIndex.current = dataStream.length - 1;
+
+ newDeltas.forEach((delta) => {
+ const artifactDefinition = artifactDefinitions.find(
+ (artifactDefinition) => artifactDefinition.kind === artifact.kind,
+ );
+
+ if (artifactDefinition?.onStreamPart) {
+ artifactDefinition.onStreamPart({
+ streamPart: delta,
+ setArtifact,
+ setMetadata,
+ });
+ }
+
+ setArtifact((draftArtifact) => {
+ if (!draftArtifact) {
+ return { ...initialArtifactData, status: 'streaming' };
+ }
+
+ switch (delta.type) {
+ case 'data-id':
+ return {
+ ...draftArtifact,
+ documentId: delta.data,
+ status: 'streaming',
+ };
+
+ case 'data-title':
+ return {
+ ...draftArtifact,
+ title: delta.data,
+ status: 'streaming',
+ };
+
+ case 'data-kind':
+ return {
+ ...draftArtifact,
+ kind: delta.data,
+ status: 'streaming',
+ };
+
+ case 'data-clear':
+ return {
+ ...draftArtifact,
+ content: '',
+ status: 'streaming',
+ };
+
+ case 'data-finish':
+ return {
+ ...draftArtifact,
+ status: 'idle',
+ };
+
+ default:
+ return draftArtifact;
+ }
+ });
+ });
+ }, [dataStream, setArtifact, setMetadata, artifact]);
+
+ return null;
+}
diff --git a/components/data-stream-provider.tsx b/components/data-stream-provider.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0f7c36753fd1cdca8b0215eda4a8c75d79c75546
--- /dev/null
+++ b/components/data-stream-provider.tsx
@@ -0,0 +1,40 @@
+'use client';
+
+import React, { createContext, useContext, useMemo, useState } from 'react';
+import type { DataUIPart } from 'ai';
+import type { CustomUIDataTypes } from '@/lib/types';
+
+interface DataStreamContextValue {
+ dataStream: DataUIPart[];
+ setDataStream: React.Dispatch<
+ React.SetStateAction[]>
+ >;
+}
+
+const DataStreamContext = createContext(null);
+
+export function DataStreamProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [dataStream, setDataStream] = useState[]>(
+ [],
+ );
+
+ const value = useMemo(() => ({ dataStream, setDataStream }), [dataStream]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useDataStream() {
+ const context = useContext(DataStreamContext);
+ if (!context) {
+ throw new Error('useDataStream must be used within a DataStreamProvider');
+ }
+ return context;
+}
diff --git a/components/diffview.tsx b/components/diffview.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ae6444a0107cf5a9e76d97cf1efba1af0aa82e65
--- /dev/null
+++ b/components/diffview.tsx
@@ -0,0 +1,100 @@
+import OrderedMap from 'orderedmap';
+import {
+ Schema,
+ type Node as ProsemirrorNode,
+ type MarkSpec,
+ DOMParser,
+} from 'prosemirror-model';
+import { schema } from 'prosemirror-schema-basic';
+import { addListNodes } from 'prosemirror-schema-list';
+import { EditorState } from 'prosemirror-state';
+import { EditorView } from 'prosemirror-view';
+import React, { useEffect, useRef } from 'react';
+import { renderToString } from 'react-dom/server';
+import { Streamdown } from 'streamdown';
+
+import { diffEditor, DiffType } from '@/lib/editor/diff';
+
+const diffSchema = new Schema({
+ nodes: addListNodes(schema.spec.nodes, 'paragraph block*', 'block'),
+ marks: OrderedMap.from({
+ ...schema.spec.marks.toObject(),
+ diffMark: {
+ attrs: { type: { default: '' } },
+ toDOM(mark) {
+ let className = '';
+
+ switch (mark.attrs.type) {
+ case DiffType.Inserted:
+ className =
+ 'bg-green-100 text-green-700 dark:bg-green-500/70 dark:text-green-300';
+ break;
+ case DiffType.Deleted:
+ className =
+ 'bg-red-100 line-through text-red-600 dark:bg-red-500/70 dark:text-red-300';
+ break;
+ default:
+ className = '';
+ }
+ return ['span', { class: className }, 0];
+ },
+ } as MarkSpec,
+ }),
+});
+
+function computeDiff(oldDoc: ProsemirrorNode, newDoc: ProsemirrorNode) {
+ return diffEditor(diffSchema, oldDoc.toJSON(), newDoc.toJSON());
+}
+
+type DiffEditorProps = {
+ oldContent: string;
+ newContent: string;
+};
+
+export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
+ const editorRef = useRef(null);
+ const viewRef = useRef(null);
+
+ useEffect(() => {
+ if (editorRef.current && !viewRef.current) {
+ const parser = DOMParser.fromSchema(diffSchema);
+
+ const oldHtmlContent = renderToString(
+ {oldContent},
+ );
+ const newHtmlContent = renderToString(
+ {newContent},
+ );
+
+ const oldContainer = document.createElement('div');
+ oldContainer.innerHTML = oldHtmlContent;
+
+ const newContainer = document.createElement('div');
+ newContainer.innerHTML = newHtmlContent;
+
+ const oldDoc = parser.parse(oldContainer);
+ const newDoc = parser.parse(newContainer);
+
+ const diffedDoc = computeDiff(oldDoc, newDoc);
+
+ const state = EditorState.create({
+ doc: diffedDoc,
+ plugins: [],
+ });
+
+ viewRef.current = new EditorView(editorRef.current, {
+ state,
+ editable: () => false,
+ });
+ }
+
+ return () => {
+ if (viewRef.current) {
+ viewRef.current.destroy();
+ viewRef.current = null;
+ }
+ };
+ }, [oldContent, newContent]);
+
+ return ;
+};
diff --git a/components/document-preview.tsx b/components/document-preview.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a1b70655d72ead77a1319bf37cd8980ed2d16e2f
--- /dev/null
+++ b/components/document-preview.tsx
@@ -0,0 +1,285 @@
+'use client';
+
+import {
+ memo,
+ type MouseEvent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+} from 'react';
+import type { ArtifactKind, UIArtifact } from './artifact';
+import { FileIcon, FullscreenIcon, ImageIcon, LoaderIcon } from './icons';
+import { cn, fetcher } from '@/lib/utils';
+import type { Document } from '@/lib/db/schema';
+import { InlineDocumentSkeleton } from './document-skeleton';
+import useSWR from 'swr';
+import { Editor } from './text-editor';
+import { DocumentToolCall, DocumentToolResult } from './document';
+import { CodeEditor } from './code-editor';
+import { useArtifact } from '@/hooks/use-artifact';
+import equal from 'fast-deep-equal';
+import { SpreadsheetEditor } from './sheet-editor';
+import { ImageEditor } from './image-editor';
+
+interface DocumentPreviewProps {
+ isReadonly: boolean;
+ result?: any;
+ args?: any;
+}
+
+export function DocumentPreview({
+ isReadonly,
+ result,
+ args,
+}: DocumentPreviewProps) {
+ const { artifact, setArtifact } = useArtifact();
+
+ const { data: documents, isLoading: isDocumentsFetching } = useSWR<
+ Array
+ >(result ? `/api/document?id=${result.id}` : null, fetcher);
+
+ const previewDocument = useMemo(() => documents?.[0], [documents]);
+ const hitboxRef = useRef(null);
+
+ useEffect(() => {
+ const boundingBox = hitboxRef.current?.getBoundingClientRect();
+
+ if (artifact.documentId && boundingBox) {
+ setArtifact((artifact) => ({
+ ...artifact,
+ boundingBox: {
+ left: boundingBox.x,
+ top: boundingBox.y,
+ width: boundingBox.width,
+ height: boundingBox.height,
+ },
+ }));
+ }
+ }, [artifact.documentId, setArtifact]);
+
+ if (artifact.isVisible) {
+ if (result) {
+ return (
+
+ );
+ }
+
+ if (args) {
+ return (
+
+ );
+ }
+ }
+
+ if (isDocumentsFetching) {
+ return ;
+ }
+
+ const document: Document | null = previewDocument
+ ? previewDocument
+ : artifact.status === 'streaming'
+ ? {
+ title: artifact.title,
+ kind: artifact.kind,
+ content: artifact.content,
+ id: artifact.documentId,
+ createdAt: new Date(),
+ userId: 'noop',
+ }
+ : null;
+
+ if (!document) return ;
+
+ return (
+
+
+
+
+
+ );
+}
+
+const LoadingSkeleton = ({ artifactKind }: { artifactKind: ArtifactKind }) => (
+
+
+ {artifactKind === 'image' ? (
+
+ ) : (
+
+
+
+ )}
+
+);
+
+const PureHitboxLayer = ({
+ hitboxRef,
+ result,
+ setArtifact,
+}: {
+ hitboxRef: React.RefObject;
+ result: any;
+ setArtifact: (
+ updaterFn: UIArtifact | ((currentArtifact: UIArtifact) => UIArtifact),
+ ) => void;
+}) => {
+ const handleClick = useCallback(
+ (event: MouseEvent) => {
+ const boundingBox = event.currentTarget.getBoundingClientRect();
+
+ setArtifact((artifact) =>
+ artifact.status === 'streaming'
+ ? { ...artifact, isVisible: true }
+ : {
+ ...artifact,
+ title: result.title,
+ documentId: result.id,
+ kind: result.kind,
+ isVisible: true,
+ boundingBox: {
+ left: boundingBox.x,
+ top: boundingBox.y,
+ width: boundingBox.width,
+ height: boundingBox.height,
+ },
+ },
+ );
+ },
+ [setArtifact, result],
+ );
+
+ return (
+
+ );
+};
+
+const HitboxLayer = memo(PureHitboxLayer, (prevProps, nextProps) => {
+ if (!equal(prevProps.result, nextProps.result)) return false;
+ return true;
+});
+
+const PureDocumentHeader = ({
+ title,
+ kind,
+ isStreaming,
+}: {
+ title: string;
+ kind: ArtifactKind;
+ isStreaming: boolean;
+}) => (
+
+
+
+ {isStreaming ? (
+
+
+
+ ) : kind === 'image' ? (
+
+ ) : (
+
+ )}
+
+
{title}
+
+
+
+);
+
+const DocumentHeader = memo(PureDocumentHeader, (prevProps, nextProps) => {
+ if (prevProps.title !== nextProps.title) return false;
+ if (prevProps.isStreaming !== nextProps.isStreaming) return false;
+
+ return true;
+});
+
+const DocumentContent = ({ document }: { document: Document }) => {
+ const { artifact } = useArtifact();
+
+ const containerClassName = cn(
+ 'h-[257px] overflow-y-scroll border rounded-b-2xl dark:bg-muted border-t-0 dark:border-zinc-700',
+ {
+ 'p-4 sm:px-14 sm:py-16': document.kind === 'text',
+ 'p-0': document.kind === 'code',
+ },
+ );
+
+ const commonProps = {
+ content: document.content ?? '',
+ isCurrentVersion: true,
+ currentVersionIndex: 0,
+ status: artifact.status,
+ saveContent: () => {},
+ suggestions: [],
+ };
+
+ return (
+
+ {document.kind === 'text' ? (
+
{}} />
+ ) : document.kind === 'code' ? (
+
+ ) : document.kind === 'sheet' ? (
+
+ ) : document.kind === 'image' ? (
+
+ ) : null}
+
+ );
+};
diff --git a/components/document-skeleton.tsx b/components/document-skeleton.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5a9a9f7d58d60bf85e50db46dfcea08fc6086c58
--- /dev/null
+++ b/components/document-skeleton.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { ArtifactKind } from './artifact';
+
+export const DocumentSkeleton = ({
+ artifactKind,
+}: {
+ artifactKind: ArtifactKind;
+}) => {
+ return artifactKind === 'image' ? (
+
+ ) : (
+
+ );
+};
+
+export const InlineDocumentSkeleton = () => {
+ return (
+
+ );
+};
diff --git a/components/document.tsx b/components/document.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6b5ffe6995eba38e1caaf8a4eb6668055dcb0ad5
--- /dev/null
+++ b/components/document.tsx
@@ -0,0 +1,162 @@
+import { memo } from 'react';
+
+import type { ArtifactKind } from './artifact';
+import { FileIcon, LoaderIcon, MessageIcon, PencilEditIcon } from './icons';
+import { toast } from 'sonner';
+import { useArtifact } from '@/hooks/use-artifact';
+
+const getActionText = (
+ type: 'create' | 'update' | 'request-suggestions',
+ tense: 'present' | 'past',
+) => {
+ switch (type) {
+ case 'create':
+ return tense === 'present' ? 'Creating' : 'Created';
+ case 'update':
+ return tense === 'present' ? 'Updating' : 'Updated';
+ case 'request-suggestions':
+ return tense === 'present'
+ ? 'Adding suggestions'
+ : 'Added suggestions to';
+ default:
+ return null;
+ }
+};
+
+interface DocumentToolResultProps {
+ type: 'create' | 'update' | 'request-suggestions';
+ result: { id: string; title: string; kind: ArtifactKind };
+ isReadonly: boolean;
+}
+
+function PureDocumentToolResult({
+ type,
+ result,
+ isReadonly,
+}: DocumentToolResultProps) {
+ const { setArtifact } = useArtifact();
+
+ return (
+
+ );
+}
+
+export const DocumentToolResult = memo(PureDocumentToolResult, () => true);
+
+interface DocumentToolCallProps {
+ type: 'create' | 'update' | 'request-suggestions';
+ args:
+ | { title: string; kind: ArtifactKind } // for create
+ | { id: string; description: string } // for update
+ | { documentId: string }; // for request-suggestions
+ isReadonly: boolean;
+}
+
+function PureDocumentToolCall({
+ type,
+ args,
+ isReadonly,
+}: DocumentToolCallProps) {
+ const { setArtifact } = useArtifact();
+
+ return (
+
+ );
+}
+
+export const DocumentToolCall = memo(PureDocumentToolCall, () => true);
diff --git a/components/elements/actions.tsx b/components/elements/actions.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..eeb64ee8251b3f773c64684933f7c909af333b07
--- /dev/null
+++ b/components/elements/actions.tsx
@@ -0,0 +1,65 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import { cn } from '@/lib/utils';
+import type { ComponentProps } from 'react';
+
+export type ActionsProps = ComponentProps<'div'>;
+
+export const Actions = ({ className, children, ...props }: ActionsProps) => (
+
+ {children}
+
+);
+
+export type ActionProps = ComponentProps & {
+ tooltip?: string;
+ label?: string;
+};
+
+export const Action = ({
+ tooltip,
+ children,
+ label,
+ className,
+ variant = 'ghost',
+ size = 'sm',
+ ...props
+}: ActionProps) => {
+ const button = (
+
+ );
+
+ if (tooltip) {
+ return (
+
+
+ {button}
+
+ {tooltip}
+
+
+
+ );
+ }
+
+ return button;
+};
diff --git a/components/elements/branch.tsx b/components/elements/branch.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..dbb8f646a9682d5ebea5d2c349ca641d9d70b7bb
--- /dev/null
+++ b/components/elements/branch.tsx
@@ -0,0 +1,212 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import type { UIMessage } from 'ai';
+import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
+import type { ComponentProps, HTMLAttributes, ReactElement } from 'react';
+import { createContext, useContext, useEffect, useState } from 'react';
+
+type BranchContextType = {
+ currentBranch: number;
+ totalBranches: number;
+ goToPrevious: () => void;
+ goToNext: () => void;
+ branches: ReactElement[];
+ setBranches: (branches: ReactElement[]) => void;
+};
+
+const BranchContext = createContext(null);
+
+const useBranch = () => {
+ const context = useContext(BranchContext);
+
+ if (!context) {
+ throw new Error('Branch components must be used within Branch');
+ }
+
+ return context;
+};
+
+export type BranchProps = HTMLAttributes & {
+ defaultBranch?: number;
+ onBranchChange?: (branchIndex: number) => void;
+};
+
+export const Branch = ({
+ defaultBranch = 0,
+ onBranchChange,
+ className,
+ ...props
+}: BranchProps) => {
+ const [currentBranch, setCurrentBranch] = useState(defaultBranch);
+ const [branches, setBranches] = useState([]);
+
+ const handleBranchChange = (newBranch: number) => {
+ setCurrentBranch(newBranch);
+ onBranchChange?.(newBranch);
+ };
+
+ const goToPrevious = () => {
+ const newBranch =
+ currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
+ handleBranchChange(newBranch);
+ };
+
+ const goToNext = () => {
+ const newBranch =
+ currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
+ handleBranchChange(newBranch);
+ };
+
+ const contextValue: BranchContextType = {
+ currentBranch,
+ totalBranches: branches.length,
+ goToPrevious,
+ goToNext,
+ branches,
+ setBranches,
+ };
+
+ return (
+
+ div]:pb-0', className)}
+ {...props}
+ />
+
+ );
+};
+
+export type BranchMessagesProps = HTMLAttributes
;
+
+export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
+ const { currentBranch, setBranches, branches } = useBranch();
+ const childrenArray = Array.isArray(children) ? children : [children];
+
+ // Use useEffect to update branches when they change
+ useEffect(() => {
+ if (branches.length !== childrenArray.length) {
+ setBranches(childrenArray);
+ }
+ }, [childrenArray, branches, setBranches]);
+
+ return childrenArray.map((branch, index) => (
+ div]:pb-0',
+ index === currentBranch ? 'block' : 'hidden',
+ )}
+ key={branch.key}
+ {...props}
+ >
+ {branch}
+
+ ));
+};
+
+export type BranchSelectorProps = HTMLAttributes & {
+ from: UIMessage['role'];
+};
+
+export const BranchSelector = ({
+ className,
+ from,
+ ...props
+}: BranchSelectorProps) => {
+ const { totalBranches } = useBranch();
+
+ // Don't render if there's only one branch
+ if (totalBranches <= 1) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export type BranchPreviousProps = ComponentProps;
+
+export const BranchPrevious = ({
+ className,
+ children,
+ ...props
+}: BranchPreviousProps) => {
+ const { goToPrevious, totalBranches } = useBranch();
+
+ return (
+
+ );
+};
+
+export type BranchNextProps = ComponentProps;
+
+export const BranchNext = ({
+ className,
+ children,
+ ...props
+}: BranchNextProps) => {
+ const { goToNext, totalBranches } = useBranch();
+
+ return (
+
+ );
+};
+
+export type BranchPageProps = HTMLAttributes;
+
+export const BranchPage = ({ className, ...props }: BranchPageProps) => {
+ const { currentBranch, totalBranches } = useBranch();
+
+ return (
+
+ {currentBranch + 1} of {totalBranches}
+
+ );
+};
diff --git a/components/elements/code-block.tsx b/components/elements/code-block.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c6df4b27d35a7b534627b1e241a50412b07cbcac
--- /dev/null
+++ b/components/elements/code-block.tsx
@@ -0,0 +1,148 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { CheckIcon, CopyIcon } from 'lucide-react';
+import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
+import { createContext, useContext, useState } from 'react';
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
+import {
+ oneDark,
+ oneLight,
+} from 'react-syntax-highlighter/dist/esm/styles/prism';
+
+type CodeBlockContextType = {
+ code: string;
+};
+
+const CodeBlockContext = createContext({
+ code: '',
+});
+
+export type CodeBlockProps = HTMLAttributes & {
+ code: string;
+ language: string;
+ showLineNumbers?: boolean;
+ children?: ReactNode;
+};
+
+export const CodeBlock = ({
+ code,
+ language,
+ showLineNumbers = false,
+ className,
+ children,
+ ...props
+}: CodeBlockProps) => (
+
+
+
+
+ {code}
+
+
+ {code}
+
+ {children && (
+
+ {children}
+
+ )}
+
+
+
+);
+
+export type CodeBlockCopyButtonProps = ComponentProps & {
+ onCopy?: () => void;
+ onError?: (error: Error) => void;
+ timeout?: number;
+};
+
+export const CodeBlockCopyButton = ({
+ onCopy,
+ onError,
+ timeout = 2000,
+ children,
+ className,
+ ...props
+}: CodeBlockCopyButtonProps) => {
+ const [isCopied, setIsCopied] = useState(false);
+ const { code } = useContext(CodeBlockContext);
+
+ const copyToClipboard = async () => {
+ if (typeof window === 'undefined' || !navigator.clipboard.writeText) {
+ onError?.(new Error('Clipboard API not available'));
+ return;
+ }
+
+ try {
+ await navigator.clipboard.writeText(code);
+ setIsCopied(true);
+ onCopy?.();
+ setTimeout(() => setIsCopied(false), timeout);
+ } catch (error) {
+ onError?.(error as Error);
+ }
+ };
+
+ const Icon = isCopied ? CheckIcon : CopyIcon;
+
+ return (
+
+ );
+};
diff --git a/components/elements/conversation.tsx b/components/elements/conversation.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c817ad7b4abaad1c3f8294b496b0a3b83e8fd1ad
--- /dev/null
+++ b/components/elements/conversation.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { ArrowDownIcon } from 'lucide-react';
+import type { ComponentProps } from 'react';
+import { useCallback } from 'react';
+import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
+
+export type ConversationProps = ComponentProps;
+
+export const Conversation = ({ className, ...props }: ConversationProps) => (
+
+);
+
+export type ConversationContentProps = ComponentProps<
+ typeof StickToBottom.Content
+>;
+
+export const ConversationContent = ({
+ className,
+ ...props
+}: ConversationContentProps) => (
+
+);
+
+export type ConversationScrollButtonProps = ComponentProps;
+
+export const ConversationScrollButton = ({
+ className,
+ ...props
+}: ConversationScrollButtonProps) => {
+ const { isAtBottom, scrollToBottom } = useStickToBottomContext();
+
+ const handleScrollToBottom = useCallback(() => {
+ scrollToBottom();
+ }, [scrollToBottom]);
+
+ return (
+ !isAtBottom && (
+
+ )
+ );
+};
diff --git a/components/elements/image.tsx b/components/elements/image.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..011fdf50c9949f11d621608a6ee0d138d29ad268
--- /dev/null
+++ b/components/elements/image.tsx
@@ -0,0 +1,24 @@
+import { cn } from '@/lib/utils';
+import type { Experimental_GeneratedImage } from 'ai';
+
+export type ImageProps = Experimental_GeneratedImage & {
+ className?: string;
+ alt?: string;
+};
+
+export const Image = ({
+ base64,
+ uint8Array,
+ mediaType,
+ ...props
+}: ImageProps) => (
+
+);
diff --git a/components/elements/inline-citation.tsx b/components/elements/inline-citation.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3d26f97dc822738660b7b636f3918a4c1f095854
--- /dev/null
+++ b/components/elements/inline-citation.tsx
@@ -0,0 +1,287 @@
+'use client';
+
+import { Badge } from '@/components/ui/badge';
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ type CarouselApi,
+} from '@/components/ui/carousel';
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from '@/components/ui/hover-card';
+import { cn } from '@/lib/utils';
+import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react';
+import {
+ type ComponentProps,
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from 'react';
+
+export type InlineCitationProps = ComponentProps<'span'>;
+
+export const InlineCitation = ({
+ className,
+ ...props
+}: InlineCitationProps) => (
+
+);
+
+export type InlineCitationTextProps = ComponentProps<'span'>;
+
+export const InlineCitationText = ({
+ className,
+ ...props
+}: InlineCitationTextProps) => (
+
+);
+
+export type InlineCitationCardProps = ComponentProps;
+
+export const InlineCitationCard = (props: InlineCitationCardProps) => (
+
+);
+
+export type InlineCitationCardTriggerProps = ComponentProps & {
+ sources: string[];
+};
+
+export const InlineCitationCardTrigger = ({
+ sources,
+ className,
+ ...props
+}: InlineCitationCardTriggerProps) => (
+
+
+ {sources.length ? (
+ <>
+ {new URL(sources[0]).hostname}{' '}
+ {sources.length > 1 && `+${sources.length - 1}`}
+ >
+ ) : (
+ 'unknown'
+ )}
+
+
+);
+
+export type InlineCitationCardBodyProps = ComponentProps<'div'>;
+
+export const InlineCitationCardBody = ({
+ className,
+ ...props
+}: InlineCitationCardBodyProps) => (
+
+);
+
+const CarouselApiContext = createContext(undefined);
+
+const useCarouselApi = () => {
+ const context = useContext(CarouselApiContext);
+ return context;
+};
+
+export type InlineCitationCarouselProps = ComponentProps;
+
+export const InlineCitationCarousel = ({
+ className,
+ children,
+ ...props
+}: InlineCitationCarouselProps) => {
+ const [api, setApi] = useState();
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export type InlineCitationCarouselContentProps = ComponentProps<'div'>;
+
+export const InlineCitationCarouselContent = (
+ props: InlineCitationCarouselContentProps,
+) => ;
+
+export type InlineCitationCarouselItemProps = ComponentProps<'div'>;
+
+export const InlineCitationCarouselItem = ({
+ className,
+ ...props
+}: InlineCitationCarouselItemProps) => (
+
+);
+
+export type InlineCitationCarouselHeaderProps = ComponentProps<'div'>;
+
+export const InlineCitationCarouselHeader = ({
+ className,
+ ...props
+}: InlineCitationCarouselHeaderProps) => (
+
+);
+
+export type InlineCitationCarouselIndexProps = ComponentProps<'div'>;
+
+export const InlineCitationCarouselIndex = ({
+ children,
+ className,
+ ...props
+}: InlineCitationCarouselIndexProps) => {
+ const api = useCarouselApi();
+ const [current, setCurrent] = useState(0);
+ const [count, setCount] = useState(0);
+
+ useEffect(() => {
+ if (!api) {
+ return;
+ }
+
+ setCount(api.scrollSnapList().length);
+ setCurrent(api.selectedScrollSnap() + 1);
+
+ api.on('select', () => {
+ setCurrent(api.selectedScrollSnap() + 1);
+ });
+ }, [api]);
+
+ return (
+
+ {children ?? `${current}/${count}`}
+
+ );
+};
+
+export type InlineCitationCarouselPrevProps = ComponentProps<'button'>;
+
+export const InlineCitationCarouselPrev = ({
+ className,
+ ...props
+}: InlineCitationCarouselPrevProps) => {
+ const api = useCarouselApi();
+
+ const handleClick = useCallback(() => {
+ if (api) {
+ api.scrollPrev();
+ }
+ }, [api]);
+
+ return (
+
+ );
+};
+
+export type InlineCitationCarouselNextProps = ComponentProps<'button'>;
+
+export const InlineCitationCarouselNext = ({
+ className,
+ ...props
+}: InlineCitationCarouselNextProps) => {
+ const api = useCarouselApi();
+
+ const handleClick = useCallback(() => {
+ if (api) {
+ api.scrollNext();
+ }
+ }, [api]);
+
+ return (
+
+ );
+};
+
+export type InlineCitationSourceProps = ComponentProps<'div'> & {
+ title?: string;
+ url?: string;
+ description?: string;
+};
+
+export const InlineCitationSource = ({
+ title,
+ url,
+ description,
+ className,
+ children,
+ ...props
+}: InlineCitationSourceProps) => (
+
+ {title && (
+
{title}
+ )}
+ {url && (
+
{url}
+ )}
+ {description && (
+
+ {description}
+
+ )}
+ {children}
+
+);
+
+export type InlineCitationQuoteProps = ComponentProps<'blockquote'>;
+
+export const InlineCitationQuote = ({
+ children,
+ className,
+ ...props
+}: InlineCitationQuoteProps) => (
+
+ {children}
+
+);
diff --git a/components/elements/loader.tsx b/components/elements/loader.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..af919d4a406b08bd75e4eb817773b4a7a242c646
--- /dev/null
+++ b/components/elements/loader.tsx
@@ -0,0 +1,96 @@
+import { cn } from '@/lib/utils';
+import type { HTMLAttributes } from 'react';
+
+type LoaderIconProps = {
+ size?: number;
+};
+
+const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
+
+);
+
+export type LoaderProps = HTMLAttributes & {
+ size?: number;
+};
+
+export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
+
+
+
+);
diff --git a/components/elements/message.tsx b/components/elements/message.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..feaceafbf0c18befcb961ab69e05537317071b77
--- /dev/null
+++ b/components/elements/message.tsx
@@ -0,0 +1,58 @@
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { cn } from '@/lib/utils';
+import type { UIMessage } from 'ai';
+import type { ComponentProps, HTMLAttributes } from 'react';
+
+export type MessageProps = HTMLAttributes & {
+ from: UIMessage['role'];
+};
+
+export const Message = ({ className, from, ...props }: MessageProps) => (
+ div]:max-w-[80%]',
+ className,
+ )}
+ {...props}
+ />
+);
+
+export type MessageContentProps = HTMLAttributes
;
+
+export const MessageContent = ({
+ children,
+ className,
+ ...props
+}: MessageContentProps) => (
+
+ {children}
+
+);
+
+export type MessageAvatarProps = ComponentProps & {
+ src: string;
+ name?: string;
+};
+
+export const MessageAvatar = ({
+ src,
+ name,
+ className,
+ ...props
+}: MessageAvatarProps) => (
+
+
+ {name?.slice(0, 2) || 'ME'}
+
+);
diff --git a/components/elements/prompt-input.tsx b/components/elements/prompt-input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..82a703dddce5d35b450ffd4c1cc0d87d92dbe553
--- /dev/null
+++ b/components/elements/prompt-input.tsx
@@ -0,0 +1,235 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Textarea } from '@/components/ui/textarea';
+import { cn } from '@/lib/utils';
+import type { ChatStatus } from 'ai';
+import { Loader2Icon, SendIcon, SquareIcon, XIcon } from 'lucide-react';
+import type {
+ ComponentProps,
+ HTMLAttributes,
+ KeyboardEventHandler,
+} from 'react';
+import { Children } from 'react';
+
+export type PromptInputProps = HTMLAttributes;
+
+export const PromptInput = ({ className, ...props }: PromptInputProps) => (
+
+);
+
+export type PromptInputTextareaProps = ComponentProps & {
+ minHeight?: number;
+ maxHeight?: number;
+ disableAutoResize?: boolean;
+ resizeOnNewLinesOnly?: boolean;
+};
+
+export const PromptInputTextarea = ({
+ onChange,
+ className,
+ placeholder = 'What would you like to know?',
+ minHeight = 48,
+ maxHeight = 164,
+ disableAutoResize = false,
+ resizeOnNewLinesOnly = false,
+ ...props
+}: PromptInputTextareaProps) => {
+ const handleKeyDown: KeyboardEventHandler = (e) => {
+ if (e.key === 'Enter') {
+ // Don't submit if IME composition is in progress
+ if (e.nativeEvent.isComposing) {
+ return;
+ }
+
+ if (e.shiftKey) {
+ // Allow newline
+ return;
+ }
+
+ // Submit on Enter (without Shift)
+ e.preventDefault();
+ const form = e.currentTarget.form;
+ if (form) {
+ form.requestSubmit();
+ }
+ }
+ };
+
+ return (
+
+ ) : (
+
+ )
+ }
+ errorText={undefined}
+ />
+ )}
+
+
+ );
+ }
+ })}
+
+ {!isReadonly && (
+
+ )}
+
+
+ {message.role === 'user' && (
+
+ )}
+
+
+
+ );
+};
+
+export const PreviewMessage = memo(
+ PurePreviewMessage,
+ (prevProps, nextProps) => {
+ if (prevProps.isLoading !== nextProps.isLoading) return false;
+ if (prevProps.message.id !== nextProps.message.id) return false;
+ if (prevProps.requiresScrollPadding !== nextProps.requiresScrollPadding)
+ return false;
+ if (!equal(prevProps.message.parts, nextProps.message.parts)) return false;
+ if (!equal(prevProps.vote, nextProps.vote)) return false;
+
+ return false;
+ },
+);
+
+export const ThinkingMessage = () => {
+ const role = 'assistant';
+
+ return (
+
+
+
+ );
+};
diff --git a/components/messages.tsx b/components/messages.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d975f448d5a97964eec2a76357e181ac049f6796
--- /dev/null
+++ b/components/messages.tsx
@@ -0,0 +1,98 @@
+import { PreviewMessage, ThinkingMessage } from './message';
+import { Greeting } from './greeting';
+import { memo } from 'react';
+import type { Vote } from '@/lib/db/schema';
+import equal from 'fast-deep-equal';
+import type { UseChatHelpers } from '@ai-sdk/react';
+import { motion } from 'framer-motion';
+import { useMessages } from '@/hooks/use-messages';
+import type { ChatMessage } from '@/lib/types';
+import { useDataStream } from './data-stream-provider';
+import { Conversation, ConversationContent, ConversationScrollButton } from './elements/conversation';
+
+interface MessagesProps {
+ chatId: string;
+ status: UseChatHelpers['status'];
+ votes: Array | undefined;
+ messages: ChatMessage[];
+ setMessages: UseChatHelpers['setMessages'];
+ regenerate: UseChatHelpers['regenerate'];
+ isReadonly: boolean;
+ isArtifactVisible: boolean;
+}
+
+function PureMessages({
+ chatId,
+ status,
+ votes,
+ messages,
+ setMessages,
+ regenerate,
+ isReadonly,
+}: MessagesProps) {
+ const {
+ containerRef: messagesContainerRef,
+ endRef: messagesEndRef,
+ onViewportEnter,
+ onViewportLeave,
+ hasSentMessage,
+ } = useMessages({
+ chatId,
+ status,
+ });
+
+ useDataStream();
+
+ return (
+
+
+
+ {messages.length === 0 && }
+
+ {messages.map((message, index) => (
+ vote.messageId === message.id)
+ : undefined
+ }
+ setMessages={setMessages}
+ regenerate={regenerate}
+ isReadonly={isReadonly}
+ requiresScrollPadding={
+ hasSentMessage && index === messages.length - 1
+ }
+ />
+ ))}
+
+ {status === 'submitted' &&
+ messages.length > 0 &&
+ messages[messages.length - 1].role === 'user' && }
+
+
+
+
+
+
+ );
+}
+
+export const Messages = memo(PureMessages, (prevProps, nextProps) => {
+ if (prevProps.isArtifactVisible && nextProps.isArtifactVisible) return true;
+
+ if (prevProps.status !== nextProps.status) return false;
+ if (prevProps.messages.length !== nextProps.messages.length) return false;
+ if (!equal(prevProps.messages, nextProps.messages)) return false;
+ if (!equal(prevProps.votes, nextProps.votes)) return false;
+
+ return false;
+});
diff --git a/components/model-selector.tsx b/components/model-selector.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f43f622e7f04d8c20dc98e385c5cb872541561fd
--- /dev/null
+++ b/components/model-selector.tsx
@@ -0,0 +1,105 @@
+'use client';
+
+import { startTransition, useMemo, useOptimistic, useState } from 'react';
+
+import { saveChatModelAsCookie } from '@/app/(chat)/actions';
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { chatModels } from '@/lib/ai/models';
+import { cn } from '@/lib/utils';
+
+import { CheckCircleFillIcon, ChevronDownIcon } from './icons';
+import { entitlementsByUserType } from '@/lib/ai/entitlements';
+import type { Session } from 'next-auth';
+
+export function ModelSelector({
+ session,
+ selectedModelId,
+ className,
+}: {
+ session: Session;
+ selectedModelId: string;
+} & React.ComponentProps) {
+ const [open, setOpen] = useState(false);
+ const [optimisticModelId, setOptimisticModelId] =
+ useOptimistic(selectedModelId);
+
+ const userType = session.user.type;
+ const { availableChatModelIds } = entitlementsByUserType[userType];
+
+ const availableChatModels = chatModels.filter((chatModel) =>
+ availableChatModelIds.includes(chatModel.id),
+ );
+
+ const selectedChatModel = useMemo(
+ () =>
+ availableChatModels.find(
+ (chatModel) => chatModel.id === optimisticModelId,
+ ),
+ [optimisticModelId, availableChatModels],
+ );
+
+ return (
+
+
+
+
+
+ {availableChatModels.map((chatModel) => {
+ const { id } = chatModel;
+
+ return (
+ {
+ setOpen(false);
+
+ startTransition(() => {
+ setOptimisticModelId(id);
+ saveChatModelAsCookie(id);
+ });
+ }}
+ data-active={id === optimisticModelId}
+ asChild
+ >
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b4a98c96b641b1982b37b7b4ac9e356c02ae9c6b
--- /dev/null
+++ b/components/multimodal-input.tsx
@@ -0,0 +1,426 @@
+'use client';
+
+import type { UIMessage } from 'ai';
+import {
+ useRef,
+ useEffect,
+ useState,
+ useCallback,
+ type Dispatch,
+ type SetStateAction,
+ type ChangeEvent,
+ memo,
+} from 'react';
+import { toast } from 'sonner';
+import { useLocalStorage, useWindowSize } from 'usehooks-ts';
+
+import { ArrowUpIcon, PaperclipIcon, StopIcon } from './icons';
+import { PreviewAttachment } from './preview-attachment';
+import { Button } from './ui/button';
+import { SuggestedActions } from './suggested-actions';
+import {
+ PromptInput,
+ PromptInputTextarea,
+ PromptInputToolbar,
+ PromptInputTools,
+ PromptInputSubmit,
+} from './elements/prompt-input';
+import equal from 'fast-deep-equal';
+import type { UseChatHelpers } from '@ai-sdk/react';
+import { AnimatePresence, motion } from 'framer-motion';
+import { ArrowDown } from 'lucide-react';
+import { useScrollToBottom } from '@/hooks/use-scroll-to-bottom';
+import type { VisibilityType } from './visibility-selector';
+import type { Attachment, ChatMessage } from '@/lib/types';
+
+function PureMultimodalInput({
+ chatId,
+ input,
+ setInput,
+ status,
+ stop,
+ attachments,
+ setAttachments,
+ messages,
+ setMessages,
+ sendMessage,
+ className,
+ selectedVisibilityType,
+}: {
+ chatId: string;
+ input: string;
+ setInput: Dispatch>;
+ status: UseChatHelpers['status'];
+ stop: () => void;
+ attachments: Array;
+ setAttachments: Dispatch>>;
+ messages: Array;
+ setMessages: UseChatHelpers['setMessages'];
+ sendMessage: UseChatHelpers['sendMessage'];
+ className?: string;
+ selectedVisibilityType: VisibilityType;
+}) {
+ const textareaRef = useRef(null);
+ const { width } = useWindowSize();
+
+ useEffect(() => {
+ if (textareaRef.current) {
+ adjustHeight();
+ }
+ }, []);
+
+ const adjustHeight = () => {
+ if (textareaRef.current) {
+ textareaRef.current.style.height = 'auto';
+ textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
+ }
+ };
+
+ const resetHeight = () => {
+ if (textareaRef.current) {
+ textareaRef.current.style.height = 'auto';
+ textareaRef.current.style.height = '98px';
+ }
+ };
+
+ const [localStorageInput, setLocalStorageInput] = useLocalStorage(
+ 'input',
+ '',
+ );
+
+ useEffect(() => {
+ if (textareaRef.current) {
+ const domValue = textareaRef.current.value;
+ // Prefer DOM value over localStorage to handle hydration
+ const finalValue = domValue || localStorageInput || '';
+ setInput(finalValue);
+ adjustHeight();
+ }
+ // Only run once after hydration
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ setLocalStorageInput(input);
+ }, [input, setLocalStorageInput]);
+
+ const handleInput = (event: React.ChangeEvent) => {
+ setInput(event.target.value);
+ };
+
+ const fileInputRef = useRef(null);
+ const [uploadQueue, setUploadQueue] = useState>([]);
+
+ const submitForm = useCallback(() => {
+ window.history.replaceState({}, '', `/chat/${chatId}`);
+
+ sendMessage({
+ role: 'user',
+ parts: [
+ ...attachments.map((attachment) => ({
+ type: 'file' as const,
+ url: attachment.url,
+ name: attachment.name,
+ mediaType: attachment.contentType,
+ })),
+ {
+ type: 'text',
+ text: input,
+ },
+ ],
+ });
+
+ setAttachments([]);
+ setLocalStorageInput('');
+ resetHeight();
+ setInput('');
+
+ if (width && width > 768) {
+ textareaRef.current?.focus();
+ }
+ }, [
+ input,
+ setInput,
+ attachments,
+ sendMessage,
+ setAttachments,
+ setLocalStorageInput,
+ width,
+ chatId,
+ ]);
+
+ const uploadFile = async (file: File) => {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ try {
+ const response = await fetch('/api/files/upload', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ const { url, pathname, contentType } = data;
+
+ return {
+ url,
+ name: pathname,
+ contentType: contentType,
+ };
+ }
+ const { error } = await response.json();
+ toast.error(error);
+ } catch (error) {
+ toast.error('Failed to upload file, please try again!');
+ }
+ };
+
+ const handleFileChange = useCallback(
+ async (event: ChangeEvent) => {
+ const files = Array.from(event.target.files || []);
+
+ setUploadQueue(files.map((file) => file.name));
+
+ try {
+ const uploadPromises = files.map((file) => uploadFile(file));
+ const uploadedAttachments = await Promise.all(uploadPromises);
+ const successfullyUploadedAttachments = uploadedAttachments.filter(
+ (attachment) => attachment !== undefined,
+ );
+
+ setAttachments((currentAttachments) => [
+ ...currentAttachments,
+ ...successfullyUploadedAttachments,
+ ]);
+ } catch (error) {
+ console.error('Error uploading files!', error);
+ } finally {
+ setUploadQueue([]);
+ }
+ },
+ [setAttachments],
+ );
+
+ const { isAtBottom, scrollToBottom } = useScrollToBottom();
+
+ useEffect(() => {
+ if (status === 'submitted') {
+ scrollToBottom();
+ }
+ }, [status, scrollToBottom]);
+
+ return (
+
+
+ {!isAtBottom && (
+
+
+
+ )}
+
+
+ {messages.length === 0 &&
+ attachments.length === 0 &&
+ uploadQueue.length === 0 && (
+
+ )}
+
+
+
+
{
+ event.preventDefault();
+ if (status !== 'ready') {
+ toast.error('Please wait for the model to finish its response!');
+ } else {
+ submitForm();
+ }
+ }}
+ >
+ {(attachments.length > 0 || uploadQueue.length > 0) && (
+
+ {attachments.map((attachment) => (
+
{
+ setAttachments((currentAttachments) =>
+ currentAttachments.filter((a) => a.url !== attachment.url),
+ );
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ }}
+ />
+ ))}
+
+ {uploadQueue.map((filename) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ {status === 'submitted' ? (
+
+ ) : (
+ 0}
+ className="bg-primary hover:bg-primary/90 text-primary-foreground size-9 rounded-full"
+ />
+ )}
+
+
+
+ );
+}
+
+export const MultimodalInput = memo(
+ PureMultimodalInput,
+ (prevProps, nextProps) => {
+ if (prevProps.input !== nextProps.input) return false;
+ if (prevProps.status !== nextProps.status) return false;
+ if (!equal(prevProps.attachments, nextProps.attachments)) return false;
+ if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType)
+ return false;
+
+ return true;
+ },
+);
+
+function PureAttachmentsButton({
+ fileInputRef,
+ status,
+}: {
+ fileInputRef: React.MutableRefObject;
+ status: UseChatHelpers['status'];
+}) {
+ return (
+
+ );
+}
+
+const AttachmentsButton = memo(PureAttachmentsButton);
+
+function PureStopButton({
+ stop,
+ setMessages,
+}: {
+ stop: () => void;
+ setMessages: UseChatHelpers['setMessages'];
+}) {
+ return (
+
+ );
+}
+
+const StopButton = memo(PureStopButton);
+
+function PureSendButton({
+ submitForm,
+ input,
+ uploadQueue,
+}: {
+ submitForm: () => void;
+ input: string;
+ uploadQueue: Array;
+}) {
+ return (
+
+ );
+}
+
+const SendButton = memo(PureSendButton, (prevProps, nextProps) => {
+ if (prevProps.uploadQueue.length !== nextProps.uploadQueue.length)
+ return false;
+ if (prevProps.input !== nextProps.input) return false;
+ return true;
+});
diff --git a/components/preview-attachment.tsx b/components/preview-attachment.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ccd41be6cc49d686615afc15a7dcc9ad1ecfbbce
--- /dev/null
+++ b/components/preview-attachment.tsx
@@ -0,0 +1,55 @@
+import type { Attachment } from '@/lib/types';
+import { Loader } from './elements/loader';
+import { CrossSmallIcon, PencilEditIcon } from './icons';
+import { Button } from './ui/button';
+
+export const PreviewAttachment = ({
+ attachment,
+ isUploading = false,
+ onRemove,
+ onEdit,
+}: {
+ attachment: Attachment;
+ isUploading?: boolean;
+ onRemove?: () => void;
+ onEdit?: () => void;
+}) => {
+ const { name, url, contentType } = attachment;
+
+ return (
+
+ {contentType?.startsWith('image') ? (
+

+ ) : (
+
+ File
+
+ )}
+
+ {isUploading && (
+
+
+
+ )}
+
+ {onRemove && !isUploading && (
+
+ )}
+
+
+ {name}
+
+
+ );
+};
diff --git a/components/sheet-editor.tsx b/components/sheet-editor.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b056a35131966f7a3389af1bc03306a0181a73fe
--- /dev/null
+++ b/components/sheet-editor.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+import React, { memo, useEffect, useMemo, useState } from 'react';
+import DataGrid, { textEditor } from 'react-data-grid';
+import { parse, unparse } from 'papaparse';
+import { useTheme } from 'next-themes';
+import { cn } from '@/lib/utils';
+
+import 'react-data-grid/lib/styles.css';
+
+type SheetEditorProps = {
+ content: string;
+ saveContent: (content: string, isCurrentVersion: boolean) => void;
+ status: string;
+ isCurrentVersion: boolean;
+ currentVersionIndex: number;
+};
+
+const MIN_ROWS = 50;
+const MIN_COLS = 26;
+
+const PureSpreadsheetEditor = ({
+ content,
+ saveContent,
+ status,
+ isCurrentVersion,
+}: SheetEditorProps) => {
+ const { resolvedTheme } = useTheme();
+
+ const parseData = useMemo(() => {
+ if (!content) return Array(MIN_ROWS).fill(Array(MIN_COLS).fill(''));
+ const result = parse(content, { skipEmptyLines: true });
+
+ const paddedData = result.data.map((row) => {
+ const paddedRow = [...row];
+ while (paddedRow.length < MIN_COLS) {
+ paddedRow.push('');
+ }
+ return paddedRow;
+ });
+
+ while (paddedData.length < MIN_ROWS) {
+ paddedData.push(Array(MIN_COLS).fill(''));
+ }
+
+ return paddedData;
+ }, [content]);
+
+ const columns = useMemo(() => {
+ const rowNumberColumn = {
+ key: 'rowNumber',
+ name: '',
+ frozen: true,
+ width: 50,
+ renderCell: ({ rowIdx }: { rowIdx: number }) => rowIdx + 1,
+ cellClass: 'border-t border-r dark:bg-zinc-950 dark:text-zinc-50',
+ headerCellClass: 'border-t border-r dark:bg-zinc-900 dark:text-zinc-50',
+ };
+
+ const dataColumns = Array.from({ length: MIN_COLS }, (_, i) => ({
+ key: i.toString(),
+ name: String.fromCharCode(65 + i),
+ renderEditCell: textEditor,
+ width: 120,
+ cellClass: cn(`border-t dark:bg-zinc-950 dark:text-zinc-50`, {
+ 'border-l': i !== 0,
+ }),
+ headerCellClass: cn(`border-t dark:bg-zinc-900 dark:text-zinc-50`, {
+ 'border-l': i !== 0,
+ }),
+ }));
+
+ return [rowNumberColumn, ...dataColumns];
+ }, []);
+
+ const initialRows = useMemo(() => {
+ return parseData.map((row, rowIndex) => {
+ const rowData: any = {
+ id: rowIndex,
+ rowNumber: rowIndex + 1,
+ };
+
+ columns.slice(1).forEach((col, colIndex) => {
+ rowData[col.key] = row[colIndex] || '';
+ });
+
+ return rowData;
+ });
+ }, [parseData, columns]);
+
+ const [localRows, setLocalRows] = useState(initialRows);
+
+ useEffect(() => {
+ setLocalRows(initialRows);
+ }, [initialRows]);
+
+ const generateCsv = (data: any[][]) => {
+ return unparse(data);
+ };
+
+ const handleRowsChange = (newRows: any[]) => {
+ setLocalRows(newRows);
+
+ const updatedData = newRows.map((row) => {
+ return columns.slice(1).map((col) => row[col.key] || '');
+ });
+
+ const newCsvContent = generateCsv(updatedData);
+ saveContent(newCsvContent, true);
+ };
+
+ return (
+ {
+ if (args.column.key !== 'rowNumber') {
+ args.selectCell(true);
+ }
+ }}
+ style={{ height: '100%' }}
+ defaultColumnOptions={{
+ resizable: true,
+ sortable: true,
+ }}
+ />
+ );
+};
+
+function areEqual(prevProps: SheetEditorProps, nextProps: SheetEditorProps) {
+ return (
+ prevProps.currentVersionIndex === nextProps.currentVersionIndex &&
+ prevProps.isCurrentVersion === nextProps.isCurrentVersion &&
+ !(prevProps.status === 'streaming' && nextProps.status === 'streaming') &&
+ prevProps.content === nextProps.content &&
+ prevProps.saveContent === nextProps.saveContent
+ );
+}
+
+export const SpreadsheetEditor = memo(PureSpreadsheetEditor, areEqual);
diff --git a/components/sidebar-history-item.tsx b/components/sidebar-history-item.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a9ab9ebb65c14c6062f161b5ac8419defeb76b76
--- /dev/null
+++ b/components/sidebar-history-item.tsx
@@ -0,0 +1,118 @@
+import type { Chat } from '@/lib/db/schema';
+import {
+ SidebarMenuAction,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from './ui/sidebar';
+import Link from 'next/link';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from './ui/dropdown-menu';
+import {
+ CheckCircleFillIcon,
+ GlobeIcon,
+ LockIcon,
+ MoreHorizontalIcon,
+ ShareIcon,
+ TrashIcon,
+} from './icons';
+import { memo } from 'react';
+import { useChatVisibility } from '@/hooks/use-chat-visibility';
+
+const PureChatItem = ({
+ chat,
+ isActive,
+ onDelete,
+ setOpenMobile,
+}: {
+ chat: Chat;
+ isActive: boolean;
+ onDelete: (chatId: string) => void;
+ setOpenMobile: (open: boolean) => void;
+}) => {
+ const { visibilityType, setVisibilityType } = useChatVisibility({
+ chatId: chat.id,
+ initialVisibilityType: chat.visibility,
+ });
+
+ return (
+
+
+ setOpenMobile(false)}>
+ {chat.title}
+
+
+
+
+
+
+
+ More
+
+
+
+
+
+
+
+ Share
+
+
+
+ {
+ setVisibilityType('private');
+ }}
+ >
+
+
+ Private
+
+ {visibilityType === 'private' ? (
+
+ ) : null}
+
+ {
+ setVisibilityType('public');
+ }}
+ >
+
+
+ Public
+
+ {visibilityType === 'public' ? : null}
+
+
+
+
+
+ onDelete(chat.id)}
+ >
+
+ Delete
+
+
+
+
+ );
+};
+
+export const ChatItem = memo(PureChatItem, (prevProps, nextProps) => {
+ if (prevProps.isActive !== nextProps.isActive) return false;
+ return true;
+});
diff --git a/components/sidebar-history.tsx b/components/sidebar-history.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..04b441fced382d31a38d52f12e1250fceea3f45a
--- /dev/null
+++ b/components/sidebar-history.tsx
@@ -0,0 +1,365 @@
+'use client';
+
+import { isToday, isYesterday, subMonths, subWeeks } from 'date-fns';
+import { useParams, useRouter } from 'next/navigation';
+import type { User } from 'next-auth';
+import { useState } from 'react';
+import { toast } from 'sonner';
+import { motion } from 'framer-motion';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import {
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarMenu,
+ useSidebar,
+} from '@/components/ui/sidebar';
+import type { Chat } from '@/lib/db/schema';
+import { fetcher } from '@/lib/utils';
+import { ChatItem } from './sidebar-history-item';
+import useSWRInfinite from 'swr/infinite';
+import { LoaderIcon } from './icons';
+
+type GroupedChats = {
+ today: Chat[];
+ yesterday: Chat[];
+ lastWeek: Chat[];
+ lastMonth: Chat[];
+ older: Chat[];
+};
+
+export interface ChatHistory {
+ chats: Array;
+ hasMore: boolean;
+}
+
+const PAGE_SIZE = 20;
+
+const groupChatsByDate = (chats: Chat[]): GroupedChats => {
+ const now = new Date();
+ const oneWeekAgo = subWeeks(now, 1);
+ const oneMonthAgo = subMonths(now, 1);
+
+ return chats.reduce(
+ (groups, chat) => {
+ const chatDate = new Date(chat.createdAt);
+
+ if (isToday(chatDate)) {
+ groups.today.push(chat);
+ } else if (isYesterday(chatDate)) {
+ groups.yesterday.push(chat);
+ } else if (chatDate > oneWeekAgo) {
+ groups.lastWeek.push(chat);
+ } else if (chatDate > oneMonthAgo) {
+ groups.lastMonth.push(chat);
+ } else {
+ groups.older.push(chat);
+ }
+
+ return groups;
+ },
+ {
+ today: [],
+ yesterday: [],
+ lastWeek: [],
+ lastMonth: [],
+ older: [],
+ } as GroupedChats,
+ );
+};
+
+export function getChatHistoryPaginationKey(
+ pageIndex: number,
+ previousPageData: ChatHistory,
+) {
+ if (previousPageData && previousPageData.hasMore === false) {
+ return null;
+ }
+
+ if (pageIndex === 0) return `/api/history?limit=${PAGE_SIZE}`;
+
+ const firstChatFromPage = previousPageData.chats.at(-1);
+
+ if (!firstChatFromPage) return null;
+
+ return `/api/history?ending_before=${firstChatFromPage.id}&limit=${PAGE_SIZE}`;
+}
+
+export function SidebarHistory({ user }: { user: User | undefined }) {
+ const { setOpenMobile } = useSidebar();
+ const { id } = useParams();
+
+ const {
+ data: paginatedChatHistories,
+ setSize,
+ isValidating,
+ isLoading,
+ mutate,
+ } = useSWRInfinite(getChatHistoryPaginationKey, fetcher, {
+ fallbackData: [],
+ });
+
+ const router = useRouter();
+ const [deleteId, setDeleteId] = useState(null);
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
+
+ const hasReachedEnd = paginatedChatHistories
+ ? paginatedChatHistories.some((page) => page.hasMore === false)
+ : false;
+
+ const hasEmptyChatHistory = paginatedChatHistories
+ ? paginatedChatHistories.every((page) => page.chats.length === 0)
+ : false;
+
+ const handleDelete = async () => {
+ const deletePromise = fetch(`/api/chat?id=${deleteId}`, {
+ method: 'DELETE',
+ });
+
+ toast.promise(deletePromise, {
+ loading: 'Deleting chat...',
+ success: () => {
+ mutate((chatHistories) => {
+ if (chatHistories) {
+ return chatHistories.map((chatHistory) => ({
+ ...chatHistory,
+ chats: chatHistory.chats.filter((chat) => chat.id !== deleteId),
+ }));
+ }
+ });
+
+ return 'Chat deleted successfully';
+ },
+ error: 'Failed to delete chat',
+ });
+
+ setShowDeleteDialog(false);
+
+ if (deleteId === id) {
+ router.push('/');
+ }
+ };
+
+ if (!user) {
+ return (
+
+
+
+ Login to save and revisit previous chats!
+
+
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+
+ Today
+
+
+
+ {[44, 32, 28, 64, 52].map((item) => (
+
+ ))}
+
+
+
+ );
+ }
+
+ if (hasEmptyChatHistory) {
+ return (
+
+
+
+ Your conversations will appear here once you start chatting!
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+ {paginatedChatHistories &&
+ (() => {
+ const chatsFromHistory = paginatedChatHistories.flatMap(
+ (paginatedChatHistory) => paginatedChatHistory.chats,
+ );
+
+ const groupedChats = groupChatsByDate(chatsFromHistory);
+
+ return (
+
+ {groupedChats.today.length > 0 && (
+
+
+ Today
+
+ {groupedChats.today.map((chat) => (
+
{
+ setDeleteId(chatId);
+ setShowDeleteDialog(true);
+ }}
+ setOpenMobile={setOpenMobile}
+ />
+ ))}
+
+ )}
+
+ {groupedChats.yesterday.length > 0 && (
+
+
+ Yesterday
+
+ {groupedChats.yesterday.map((chat) => (
+
{
+ setDeleteId(chatId);
+ setShowDeleteDialog(true);
+ }}
+ setOpenMobile={setOpenMobile}
+ />
+ ))}
+
+ )}
+
+ {groupedChats.lastWeek.length > 0 && (
+
+
+ Last 7 days
+
+ {groupedChats.lastWeek.map((chat) => (
+
{
+ setDeleteId(chatId);
+ setShowDeleteDialog(true);
+ }}
+ setOpenMobile={setOpenMobile}
+ />
+ ))}
+
+ )}
+
+ {groupedChats.lastMonth.length > 0 && (
+
+
+ Last 30 days
+
+ {groupedChats.lastMonth.map((chat) => (
+
{
+ setDeleteId(chatId);
+ setShowDeleteDialog(true);
+ }}
+ setOpenMobile={setOpenMobile}
+ />
+ ))}
+
+ )}
+
+ {groupedChats.older.length > 0 && (
+
+
+ Older than last month
+
+ {groupedChats.older.map((chat) => (
+
{
+ setDeleteId(chatId);
+ setShowDeleteDialog(true);
+ }}
+ setOpenMobile={setOpenMobile}
+ />
+ ))}
+
+ )}
+
+ );
+ })()}
+
+
+ {
+ if (!isValidating && !hasReachedEnd) {
+ setSize((size) => size + 1);
+ }
+ }}
+ />
+
+ {hasReachedEnd ? (
+
+ You have reached the end of your chat history.
+
+ ) : (
+
+
+
+
+
Loading Chats...
+
+ )}
+
+
+
+
+
+
+ Are you absolutely sure?
+
+ This action cannot be undone. This will permanently delete your
+ chat and remove it from our servers.
+
+
+
+ Cancel
+
+ Continue
+
+
+
+
+ >
+ );
+}
diff --git a/components/sidebar-toggle.tsx b/components/sidebar-toggle.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..83f47671a9e27f4f680850abb95b338a2fb1e7eb
--- /dev/null
+++ b/components/sidebar-toggle.tsx
@@ -0,0 +1,33 @@
+import type { ComponentProps } from 'react';
+
+import { type SidebarTrigger, useSidebar } from '@/components/ui/sidebar';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+
+import { SidebarLeftIcon } from './icons';
+import { Button } from './ui/button';
+
+export function SidebarToggle({
+ className,
+}: ComponentProps) {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+
+
+
+
+ Toggle Sidebar
+
+ );
+}
diff --git a/components/sidebar-user-nav.tsx b/components/sidebar-user-nav.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..fc55298f85a09e7f8e043887842e82f5b33031d8
--- /dev/null
+++ b/components/sidebar-user-nav.tsx
@@ -0,0 +1,114 @@
+'use client';
+
+import { ChevronUp } from 'lucide-react';
+import Image from 'next/image';
+import type { User } from 'next-auth';
+import { signOut, useSession } from 'next-auth/react';
+import { useTheme } from 'next-themes';
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from '@/components/ui/sidebar';
+import { useRouter } from 'next/navigation';
+import { toast } from './toast';
+import { LoaderIcon } from './icons';
+import { guestRegex } from '@/lib/constants';
+
+export function SidebarUserNav({ user }: { user: User }) {
+ const router = useRouter();
+ const { data, status } = useSession();
+ const { setTheme, resolvedTheme } = useTheme();
+
+ const isGuest = guestRegex.test(data?.user?.email ?? '');
+
+ return (
+
+
+
+
+ {status === 'loading' ? (
+
+
+
+
+ Loading auth status
+
+
+
+
+
+
+ ) : (
+
+
+
+ {isGuest ? 'Guest' : user?.email}
+
+
+
+ )}
+
+
+ setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
+ >
+ {`Toggle ${resolvedTheme === 'light' ? 'dark' : 'light'} mode`}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/sign-out-form.tsx b/components/sign-out-form.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7fe9ee66713b9e36b0edea690abe05bea600e24f
--- /dev/null
+++ b/components/sign-out-form.tsx
@@ -0,0 +1,25 @@
+import Form from 'next/form';
+
+import { signOut } from '@/app/(auth)/auth';
+
+export const SignOutForm = () => {
+ return (
+
+ );
+};
diff --git a/components/submit-button.tsx b/components/submit-button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1ec6acb50668e92f2d28c0b205b71ec18aa48f10
--- /dev/null
+++ b/components/submit-button.tsx
@@ -0,0 +1,38 @@
+'use client';
+
+import { useFormStatus } from 'react-dom';
+
+import { LoaderIcon } from '@/components/icons';
+
+import { Button } from './ui/button';
+
+export function SubmitButton({
+ children,
+ isSuccessful,
+}: {
+ children: React.ReactNode;
+ isSuccessful: boolean;
+}) {
+ const { pending } = useFormStatus();
+
+ return (
+
+ );
+}
diff --git a/components/suggested-actions.tsx b/components/suggested-actions.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..af1577459838fee5e0b4d8fb89f09f48fa0d48bb
--- /dev/null
+++ b/components/suggested-actions.tsx
@@ -0,0 +1,66 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import { memo } from 'react';
+import type { UseChatHelpers } from '@ai-sdk/react';
+import type { VisibilityType } from './visibility-selector';
+import type { ChatMessage } from '@/lib/types';
+import { Suggestion } from './elements/suggestion';
+
+interface SuggestedActionsProps {
+ chatId: string;
+ sendMessage: UseChatHelpers['sendMessage'];
+ selectedVisibilityType: VisibilityType;
+}
+
+function PureSuggestedActions({
+ chatId,
+ sendMessage,
+ selectedVisibilityType,
+}: SuggestedActionsProps) {
+ const suggestedActions = [
+ 'What are the advantages of using Next.js?',
+ 'Write code to demonstrate Dijkstra\'s algorithm',
+ 'Help me write an essay about Silicon Valley',
+ 'What is the weather in San Francisco?',
+ ];
+
+ return (
+
+ {suggestedActions.map((suggestedAction, index) => (
+
+ {
+ window.history.replaceState({}, '', `/chat/${chatId}`);
+ sendMessage({
+ role: 'user',
+ parts: [{ type: 'text', text: suggestion }],
+ });
+ }}
+ className="text-left w-full h-auto whitespace-normal p-3"
+ >
+ {suggestedAction}
+
+
+ ))}
+
+ );
+}
+
+export const SuggestedActions = memo(
+ PureSuggestedActions,
+ (prevProps, nextProps) => {
+ if (prevProps.chatId !== nextProps.chatId) return false;
+ if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType)
+ return false;
+
+ return true;
+ },
+);
diff --git a/components/suggestion.tsx b/components/suggestion.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..757126df68cdfe5ad038829aec1137132422edec
--- /dev/null
+++ b/components/suggestion.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import { AnimatePresence, motion } from 'framer-motion';
+import { useState } from 'react';
+import { useWindowSize } from 'usehooks-ts';
+
+import type { UISuggestion } from '@/lib/editor/suggestions';
+
+import { CrossIcon, MessageIcon } from './icons';
+import { Button } from './ui/button';
+import { cn } from '@/lib/utils';
+import type { ArtifactKind } from './artifact';
+
+export const Suggestion = ({
+ suggestion,
+ onApply,
+ artifactKind,
+}: {
+ suggestion: UISuggestion;
+ onApply: () => void;
+ artifactKind: ArtifactKind;
+}) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const { width: windowWidth } = useWindowSize();
+
+ return (
+
+ {!isExpanded ? (
+ {
+ setIsExpanded(true);
+ }}
+ whileHover={{ scale: 1.1 }}
+ >
+
+
+ ) : (
+
+
+
+
+
+ {suggestion.description}
+
+
+ )}
+
+ );
+};
diff --git a/components/text-editor.tsx b/components/text-editor.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c24dd33e6c207bb28832b330d70c06330641073e
--- /dev/null
+++ b/components/text-editor.tsx
@@ -0,0 +1,164 @@
+'use client';
+
+import { exampleSetup } from 'prosemirror-example-setup';
+import { inputRules } from 'prosemirror-inputrules';
+import { EditorState } from 'prosemirror-state';
+import { EditorView } from 'prosemirror-view';
+import React, { memo, useEffect, useRef } from 'react';
+
+import type { Suggestion } from '@/lib/db/schema';
+import {
+ documentSchema,
+ handleTransaction,
+ headingRule,
+} from '@/lib/editor/config';
+import {
+ buildContentFromDocument,
+ buildDocumentFromContent,
+ createDecorations,
+} from '@/lib/editor/functions';
+import {
+ projectWithPositions,
+ suggestionsPlugin,
+ suggestionsPluginKey,
+} from '@/lib/editor/suggestions';
+
+type EditorProps = {
+ content: string;
+ onSaveContent: (updatedContent: string, debounce: boolean) => void;
+ status: 'streaming' | 'idle';
+ isCurrentVersion: boolean;
+ currentVersionIndex: number;
+ suggestions: Array;
+};
+
+function PureEditor({
+ content,
+ onSaveContent,
+ suggestions,
+ status,
+}: EditorProps) {
+ const containerRef = useRef(null);
+ const editorRef = useRef(null);
+
+ useEffect(() => {
+ if (containerRef.current && !editorRef.current) {
+ const state = EditorState.create({
+ doc: buildDocumentFromContent(content),
+ plugins: [
+ ...exampleSetup({ schema: documentSchema, menuBar: false }),
+ inputRules({
+ rules: [
+ headingRule(1),
+ headingRule(2),
+ headingRule(3),
+ headingRule(4),
+ headingRule(5),
+ headingRule(6),
+ ],
+ }),
+ suggestionsPlugin,
+ ],
+ });
+
+ editorRef.current = new EditorView(containerRef.current, {
+ state,
+ });
+ }
+
+ return () => {
+ if (editorRef.current) {
+ editorRef.current.destroy();
+ editorRef.current = null;
+ }
+ };
+ // NOTE: we only want to run this effect once
+ // eslint-disable-next-line
+ }, []);
+
+ useEffect(() => {
+ if (editorRef.current) {
+ editorRef.current.setProps({
+ dispatchTransaction: (transaction) => {
+ handleTransaction({
+ transaction,
+ editorRef,
+ onSaveContent,
+ });
+ },
+ });
+ }
+ }, [onSaveContent]);
+
+ useEffect(() => {
+ if (editorRef.current && content) {
+ const currentContent = buildContentFromDocument(
+ editorRef.current.state.doc,
+ );
+
+ if (status === 'streaming') {
+ const newDocument = buildDocumentFromContent(content);
+
+ const transaction = editorRef.current.state.tr.replaceWith(
+ 0,
+ editorRef.current.state.doc.content.size,
+ newDocument.content,
+ );
+
+ transaction.setMeta('no-save', true);
+ editorRef.current.dispatch(transaction);
+ return;
+ }
+
+ if (currentContent !== content) {
+ const newDocument = buildDocumentFromContent(content);
+
+ const transaction = editorRef.current.state.tr.replaceWith(
+ 0,
+ editorRef.current.state.doc.content.size,
+ newDocument.content,
+ );
+
+ transaction.setMeta('no-save', true);
+ editorRef.current.dispatch(transaction);
+ }
+ }
+ }, [content, status]);
+
+ useEffect(() => {
+ if (editorRef.current?.state.doc && content) {
+ const projectedSuggestions = projectWithPositions(
+ editorRef.current.state.doc,
+ suggestions,
+ ).filter(
+ (suggestion) => suggestion.selectionStart && suggestion.selectionEnd,
+ );
+
+ const decorations = createDecorations(
+ projectedSuggestions,
+ editorRef.current,
+ );
+
+ const transaction = editorRef.current.state.tr;
+ transaction.setMeta(suggestionsPluginKey, { decorations });
+ editorRef.current.dispatch(transaction);
+ }
+ }, [suggestions, content]);
+
+ return (
+
+ );
+}
+
+function areEqual(prevProps: EditorProps, nextProps: EditorProps) {
+ return (
+ prevProps.suggestions === nextProps.suggestions &&
+ prevProps.currentVersionIndex === nextProps.currentVersionIndex &&
+ prevProps.isCurrentVersion === nextProps.isCurrentVersion &&
+ !(prevProps.status === 'streaming' && nextProps.status === 'streaming') &&
+ prevProps.content === nextProps.content &&
+ prevProps.onSaveContent === nextProps.onSaveContent
+ );
+}
+
+export const Editor = memo(PureEditor, areEqual);
diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5b192cb115be5f26101bdb893142bbb4e167d8e5
--- /dev/null
+++ b/components/theme-provider.tsx
@@ -0,0 +1,8 @@
+'use client';
+
+import { ThemeProvider as NextThemesProvider } from 'next-themes';
+import type { ThemeProviderProps } from 'next-themes/dist/types';
+
+export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
+ return {children};
+}
diff --git a/components/toast.tsx b/components/toast.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5e948546685f10a22576684054b50917b6e0fff7
--- /dev/null
+++ b/components/toast.tsx
@@ -0,0 +1,73 @@
+'use client';
+
+import React, { useEffect, useRef, useState, type ReactNode } from 'react';
+import { toast as sonnerToast } from 'sonner';
+import { CheckCircleFillIcon, WarningIcon } from './icons';
+import { cn } from '@/lib/utils';
+
+const iconsByType: Record<'success' | 'error', ReactNode> = {
+ success: ,
+ error: ,
+};
+
+export function toast(props: Omit) {
+ return sonnerToast.custom((id) => (
+
+ ));
+}
+
+function Toast(props: ToastProps) {
+ const { id, type, description } = props;
+
+ const descriptionRef = useRef(null);
+ const [multiLine, setMultiLine] = useState(false);
+
+ useEffect(() => {
+ const el = descriptionRef.current;
+ if (!el) return;
+
+ const update = () => {
+ const lineHeight = Number.parseFloat(getComputedStyle(el).lineHeight);
+ const lines = Math.round(el.scrollHeight / lineHeight);
+ setMultiLine(lines > 1);
+ };
+
+ update(); // initial check
+ const ro = new ResizeObserver(update); // re-check on width changes
+ ro.observe(el);
+
+ return () => ro.disconnect();
+ }, [description]);
+
+ return (
+
+
+
+ {iconsByType[type]}
+
+
+ {description}
+
+
+
+ );
+}
+
+interface ToastProps {
+ id: string | number;
+ type: 'success' | 'error';
+ description: string;
+}
diff --git a/components/toolbar.tsx b/components/toolbar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..15164d63d1238c68624b95d80b1f42f3cabd46cf
--- /dev/null
+++ b/components/toolbar.tsx
@@ -0,0 +1,465 @@
+'use client';
+import cx from 'classnames';
+import {
+ AnimatePresence,
+ motion,
+ useMotionValue,
+ useTransform,
+} from 'framer-motion';
+import {
+ type Dispatch,
+ memo,
+ type ReactNode,
+ type SetStateAction,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import { useOnClickOutside } from 'usehooks-ts';
+import { nanoid } from 'nanoid';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+
+import { ArrowUpIcon, StopIcon, SummarizeIcon } from './icons';
+import { artifactDefinitions, type ArtifactKind } from './artifact';
+import type { ArtifactToolbarItem } from './create-artifact';
+import type { UseChatHelpers } from '@ai-sdk/react';
+import type { ChatMessage } from '@/lib/types';
+
+type ToolProps = {
+ description: string;
+ icon: ReactNode;
+ selectedTool: string | null;
+ setSelectedTool: Dispatch>;
+ isToolbarVisible?: boolean;
+ setIsToolbarVisible?: Dispatch>;
+ isAnimating: boolean;
+ sendMessage: UseChatHelpers['sendMessage'];
+ onClick: ({
+ sendMessage,
+ }: {
+ sendMessage: UseChatHelpers['sendMessage'];
+ }) => void;
+};
+
+const Tool = ({
+ description,
+ icon,
+ selectedTool,
+ setSelectedTool,
+ isToolbarVisible,
+ setIsToolbarVisible,
+ isAnimating,
+ sendMessage,
+ onClick,
+}: ToolProps) => {
+ const [isHovered, setIsHovered] = useState(false);
+
+ useEffect(() => {
+ if (selectedTool !== description) {
+ setIsHovered(false);
+ }
+ }, [selectedTool, description]);
+
+ const handleSelect = () => {
+ if (!isToolbarVisible && setIsToolbarVisible) {
+ setIsToolbarVisible(true);
+ return;
+ }
+
+ if (!selectedTool) {
+ setIsHovered(true);
+ setSelectedTool(description);
+ return;
+ }
+
+ if (selectedTool !== description) {
+ setSelectedTool(description);
+ } else {
+ setSelectedTool(null);
+ onClick({ sendMessage });
+ }
+ };
+
+ return (
+
+
+ {
+ setIsHovered(true);
+ }}
+ onHoverEnd={() => {
+ if (selectedTool !== description) setIsHovered(false);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ handleSelect();
+ }
+ }}
+ initial={{ scale: 1, opacity: 0 }}
+ animate={{ opacity: 1, transition: { delay: 0.1 } }}
+ whileHover={{ scale: 1.1 }}
+ whileTap={{ scale: 0.95 }}
+ exit={{
+ scale: 0.9,
+ opacity: 0,
+ transition: { duration: 0.1 },
+ }}
+ onClick={() => {
+ handleSelect();
+ }}
+ >
+ {selectedTool === description ? : icon}
+
+
+
+ {description}
+
+
+ );
+};
+
+const randomArr = [...Array(6)].map((x) => nanoid(5));
+
+const ReadingLevelSelector = ({
+ setSelectedTool,
+ sendMessage,
+ isAnimating,
+}: {
+ setSelectedTool: Dispatch>;
+ isAnimating: boolean;
+ sendMessage: UseChatHelpers['sendMessage'];
+}) => {
+ const LEVELS = [
+ 'Elementary',
+ 'Middle School',
+ 'Keep current level',
+ 'High School',
+ 'College',
+ 'Graduate',
+ ];
+
+ const y = useMotionValue(-40 * 2);
+ const dragConstraints = 5 * 40 + 2;
+ const yToLevel = useTransform(y, [0, -dragConstraints], [0, 5]);
+
+ const [currentLevel, setCurrentLevel] = useState(2);
+ const [hasUserSelectedLevel, setHasUserSelectedLevel] =
+ useState(false);
+
+ useEffect(() => {
+ const unsubscribe = yToLevel.on('change', (latest) => {
+ const level = Math.min(5, Math.max(0, Math.round(Math.abs(latest))));
+ setCurrentLevel(level);
+ });
+
+ return () => unsubscribe();
+ }, [yToLevel]);
+
+ return (
+
+ {randomArr.map((id) => (
+
+
+
+ ))}
+
+
+
+
+ {
+ setHasUserSelectedLevel(false);
+ }}
+ onDragEnd={() => {
+ if (currentLevel === 2) {
+ setSelectedTool(null);
+ } else {
+ setHasUserSelectedLevel(true);
+ }
+ }}
+ onClick={() => {
+ if (currentLevel !== 2 && hasUserSelectedLevel) {
+ sendMessage({
+ role: 'user',
+ parts: [
+ {
+ type: 'text',
+ text: `Please adjust the reading level to ${LEVELS[currentLevel]} level.`,
+ },
+ ],
+ });
+
+ setSelectedTool(null);
+ }
+ }}
+ >
+ {currentLevel === 2 ? : }
+
+
+
+ {LEVELS[currentLevel]}
+
+
+
+
+ );
+};
+
+export const Tools = ({
+ isToolbarVisible,
+ selectedTool,
+ setSelectedTool,
+ sendMessage,
+ isAnimating,
+ setIsToolbarVisible,
+ tools,
+}: {
+ isToolbarVisible: boolean;
+ selectedTool: string | null;
+ setSelectedTool: Dispatch>;
+ sendMessage: UseChatHelpers['sendMessage'];
+ isAnimating: boolean;
+ setIsToolbarVisible: Dispatch>;
+ tools: Array;
+}) => {
+ const [primaryTool, ...secondaryTools] = tools;
+
+ return (
+
+
+ {isToolbarVisible &&
+ secondaryTools.map((secondaryTool) => (
+
+ ))}
+
+
+
+
+ );
+};
+
+const PureToolbar = ({
+ isToolbarVisible,
+ setIsToolbarVisible,
+ sendMessage,
+ status,
+ stop,
+ setMessages,
+ artifactKind,
+}: {
+ isToolbarVisible: boolean;
+ setIsToolbarVisible: Dispatch>;
+ status: UseChatHelpers['status'];
+ sendMessage: UseChatHelpers['sendMessage'];
+ stop: UseChatHelpers['stop'];
+ setMessages: UseChatHelpers['setMessages'];
+ artifactKind: ArtifactKind;
+}) => {
+ const toolbarRef = useRef(null);
+ const timeoutRef = useRef>();
+
+ const [selectedTool, setSelectedTool] = useState(null);
+ const [isAnimating, setIsAnimating] = useState(false);
+
+ useOnClickOutside(toolbarRef, () => {
+ setIsToolbarVisible(false);
+ setSelectedTool(null);
+ });
+
+ const startCloseTimer = () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+
+ timeoutRef.current = setTimeout(() => {
+ setSelectedTool(null);
+ setIsToolbarVisible(false);
+ }, 2000);
+ };
+
+ const cancelCloseTimer = () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ if (status === 'streaming') {
+ setIsToolbarVisible(false);
+ }
+ }, [status, setIsToolbarVisible]);
+
+ const artifactDefinition = artifactDefinitions.find(
+ (definition) => definition.kind === artifactKind,
+ );
+
+ if (!artifactDefinition) {
+ throw new Error('Artifact definition not found!');
+ }
+
+ const toolsByArtifactKind = artifactDefinition.toolbar;
+
+ if (toolsByArtifactKind.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {
+ if (status === 'streaming') return;
+
+ cancelCloseTimer();
+ setIsToolbarVisible(true);
+ }}
+ onHoverEnd={() => {
+ if (status === 'streaming') return;
+
+ startCloseTimer();
+ }}
+ onAnimationStart={() => {
+ setIsAnimating(true);
+ }}
+ onAnimationComplete={() => {
+ setIsAnimating(false);
+ }}
+ ref={toolbarRef}
+ >
+ {status === 'streaming' ? (
+ {
+ stop();
+ setMessages((messages) => messages);
+ }}
+ >
+
+
+ ) : selectedTool === 'adjust-reading-level' ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export const Toolbar = memo(PureToolbar, (prevProps, nextProps) => {
+ if (prevProps.status !== nextProps.status) return false;
+ if (prevProps.isToolbarVisible !== nextProps.isToolbarVisible) return false;
+ if (prevProps.artifactKind !== nextProps.artifactKind) return false;
+
+ return true;
+});
diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d8d2f15dcc89d8e3fc25c93f7d56c173e0d95fcb
--- /dev/null
+++ b/components/ui/alert-dialog.tsx
@@ -0,0 +1,141 @@
+'use client';
+
+import * as React from 'react';
+import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
+
+import { cn } from '@/lib/utils';
+import { buttonVariants } from '@/components/ui/button';
+
+const AlertDialog = AlertDialogPrimitive.Root;
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal;
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogHeader.displayName = 'AlertDialogHeader';
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogFooter.displayName = 'AlertDialogFooter';
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName;
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+};
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..51e507ba9d08bcdbb1fb630498f1cbdf2bf50093
--- /dev/null
+++ b/components/ui/avatar.tsx
@@ -0,0 +1,50 @@
+"use client"
+
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+const Avatar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Avatar.displayName = AvatarPrimitive.Root.displayName
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarImage.displayName = AvatarPrimitive.Image.displayName
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f000e3ef5176395b067dfc3f3e1256a80c450015
--- /dev/null
+++ b/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7b63ff12aa3146b575e313bb3b1e4a33df9c5ae4
--- /dev/null
+++ b/components/ui/button.tsx
@@ -0,0 +1,56 @@
+import * as React from 'react';
+import { Slot } from '@radix-ui/react-slot';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+import { cn } from '@/lib/utils';
+
+const buttonVariants = cva(
+ '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',
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ destructive:
+ 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
+ outline:
+ 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
+ secondary:
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ ghost: 'hover:bg-accent hover:text-accent-foreground',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-10 px-4 py-2',
+ sm: 'h-9 rounded-md px-3',
+ lg: 'h-11 rounded-md px-8',
+ icon: 'h-10 w-10',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : 'button';
+ return (
+
+ );
+ },
+);
+Button.displayName = 'Button';
+
+export { Button, buttonVariants };
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..41235e8ad5d868b7ab21b24d9230352e4d95782e
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,86 @@
+import * as React from 'react';
+
+import { cn } from '@/lib/utils';
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+Card.displayName = 'Card';
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardHeader.displayName = 'CardHeader';
+
+const CardTitle = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardTitle.displayName = 'CardTitle';
+
+const CardDescription = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardDescription.displayName = 'CardDescription';
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardContent.displayName = 'CardContent';
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardFooter.displayName = 'CardFooter';
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardDescription,
+ CardContent,
+};
diff --git a/components/ui/carousel.tsx b/components/ui/carousel.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ec505d00d95ecdbbc1501d1bfa7020e51d6010a7
--- /dev/null
+++ b/components/ui/carousel.tsx
@@ -0,0 +1,262 @@
+"use client"
+
+import * as React from "react"
+import useEmblaCarousel, {
+ type UseEmblaCarouselType,
+} from "embla-carousel-react"
+import { ArrowLeft, ArrowRight } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+type CarouselApi = UseEmblaCarouselType[1]
+type UseCarouselParameters = Parameters
+type CarouselOptions = UseCarouselParameters[0]
+type CarouselPlugin = UseCarouselParameters[1]
+
+type CarouselProps = {
+ opts?: CarouselOptions
+ plugins?: CarouselPlugin
+ orientation?: "horizontal" | "vertical"
+ setApi?: (api: CarouselApi) => void
+}
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0]
+ api: ReturnType[1]
+ scrollPrev: () => void
+ scrollNext: () => void
+ canScrollPrev: boolean
+ canScrollNext: boolean
+} & CarouselProps
+
+const CarouselContext = React.createContext(null)
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext)
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ")
+ }
+
+ return context
+}
+
+const Carousel = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & CarouselProps
+>(
+ (
+ {
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins
+ )
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false)
+ const [canScrollNext, setCanScrollNext] = React.useState(false)
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) {
+ return
+ }
+
+ setCanScrollPrev(api.canScrollPrev())
+ setCanScrollNext(api.canScrollNext())
+ }, [])
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev()
+ }, [api])
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext()
+ }, [api])
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault()
+ scrollPrev()
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault()
+ scrollNext()
+ }
+ },
+ [scrollPrev, scrollNext]
+ )
+
+ React.useEffect(() => {
+ if (!api || !setApi) {
+ return
+ }
+
+ setApi(api)
+ }, [api, setApi])
+
+ React.useEffect(() => {
+ if (!api) {
+ return
+ }
+
+ onSelect(api)
+ api.on("reInit", onSelect)
+ api.on("select", onSelect)
+
+ return () => {
+ api?.off("select", onSelect)
+ }
+ }, [api, onSelect])
+
+ return (
+
+
+ {children}
+
+
+ )
+ }
+)
+Carousel.displayName = "Carousel"
+
+const CarouselContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { carouselRef, orientation } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselContent.displayName = "CarouselContent"
+
+const CarouselItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { orientation } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselItem.displayName = "CarouselItem"
+
+const CarouselPrevious = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps
+>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselPrevious.displayName = "CarouselPrevious"
+
+const CarouselNext = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps
+>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselNext.displayName = "CarouselNext"
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+}
diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9fa48946afd1eb56bd932377fd888e3986304676
--- /dev/null
+++ b/components/ui/collapsible.tsx
@@ -0,0 +1,11 @@
+"use client"
+
+import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
+
+const Collapsible = CollapsiblePrimitive.Root
+
+const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
+
+const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b514f1aaa15951c5cb27843f9294e5786ee0d6a9
--- /dev/null
+++ b/components/ui/dropdown-menu.tsx
@@ -0,0 +1,200 @@
+'use client';
+
+import * as React from 'react';
+import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
+import { Check, ChevronRight, Circle } from 'lucide-react';
+
+import { cn } from '@/lib/utils';
+
+const DropdownMenu = DropdownMenuPrimitive.Root;
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group;
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub;
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+));
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName;
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName;
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName;
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ );
+};
+DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+};
diff --git a/components/ui/hover-card.tsx b/components/ui/hover-card.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..74efa6bee7037d46ea6f1a08a89b68963660cca0
--- /dev/null
+++ b/components/ui/hover-card.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import * as React from "react"
+import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
+
+import { cn } from "@/lib/utils"
+
+const HoverCard = HoverCardPrimitive.Root
+
+const HoverCardTrigger = HoverCardPrimitive.Trigger
+
+const HoverCardContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+
+))
+HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
+
+export { HoverCard, HoverCardTrigger, HoverCardContent }
diff --git a/components/ui/input.tsx b/components/ui/input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d22223dda9daf830c3e0e5520d4ca7499398124c
--- /dev/null
+++ b/components/ui/input.tsx
@@ -0,0 +1,22 @@
+import * as React from 'react';
+
+import { cn } from '@/lib/utils';
+
+const Input = React.forwardRef>(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Input.displayName = 'Input';
+
+export { Input };
diff --git a/components/ui/label.tsx b/components/ui/label.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7114fb0cda372762746fa7d5f5af814e19dd4922
--- /dev/null
+++ b/components/ui/label.tsx
@@ -0,0 +1,26 @@
+'use client';
+
+import * as React from 'react';
+import * as LabelPrimitive from '@radix-ui/react-label';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+import { cn } from '@/lib/utils';
+
+const labelVariants = cva(
+ 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
+);
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+));
+Label.displayName = LabelPrimitive.Root.displayName;
+
+export { Label };
diff --git a/components/ui/scroll-area.tsx b/components/ui/scroll-area.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0b4a48d87fabda1c6e9612172abb56a78e26c14f
--- /dev/null
+++ b/components/ui/scroll-area.tsx
@@ -0,0 +1,48 @@
+"use client"
+
+import * as React from "react"
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
+
+import { cn } from "@/lib/utils"
+
+const ScrollArea = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+ {children}
+
+
+
+
+))
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
+
+const ScrollBar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = "vertical", ...props }, ref) => (
+
+
+
+))
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
+
+export { ScrollArea, ScrollBar }
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c8171f70563f8e8f0ee02dea70f97fc67fc01e63
--- /dev/null
+++ b/components/ui/select.tsx
@@ -0,0 +1,160 @@
+'use client';
+
+import * as React from 'react';
+import * as SelectPrimitive from '@radix-ui/react-select';
+import { Check, ChevronDown, ChevronUp } from 'lucide-react';
+
+import { cn } from '@/lib/utils';
+
+const Select = SelectPrimitive.Root;
+
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1',
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = 'popper', ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+
+ {children}
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+};
diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8ee9f4f4d5a2bd28e580144efacde658f3848aad
--- /dev/null
+++ b/components/ui/separator.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import * as React from 'react';
+import * as SeparatorPrimitive from '@radix-ui/react-separator';
+
+import { cn } from '@/lib/utils';
+
+const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(
+ (
+ { className, orientation = 'horizontal', decorative = true, ...props },
+ ref,
+ ) => (
+
+ ),
+);
+Separator.displayName = SeparatorPrimitive.Root.displayName;
+
+export { Separator };
diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1d2ea98573162f75b24ff6ada52f548d2f566ca2
--- /dev/null
+++ b/components/ui/sheet.tsx
@@ -0,0 +1,140 @@
+'use client';
+
+import * as React from 'react';
+import * as SheetPrimitive from '@radix-ui/react-dialog';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { X } from 'lucide-react';
+
+import { cn } from '@/lib/utils';
+
+const Sheet = SheetPrimitive.Root;
+
+const SheetTrigger = SheetPrimitive.Trigger;
+
+const SheetClose = SheetPrimitive.Close;
+
+const SheetPortal = SheetPrimitive.Portal;
+
+const SheetOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
+
+const sheetVariants = cva(
+ 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
+ {
+ variants: {
+ side: {
+ top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
+ bottom:
+ 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
+ left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
+ right:
+ 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
+ },
+ },
+ defaultVariants: {
+ side: 'right',
+ },
+ },
+);
+
+interface SheetContentProps
+ extends React.ComponentPropsWithoutRef,
+ VariantProps {}
+
+const SheetContent = React.forwardRef<
+ React.ElementRef,
+ SheetContentProps
+>(({ side = 'right', className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+SheetContent.displayName = SheetPrimitive.Content.displayName;
+
+const SheetHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+SheetHeader.displayName = 'SheetHeader';
+
+const SheetFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+SheetFooter.displayName = 'SheetFooter';
+
+const SheetTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetTitle.displayName = SheetPrimitive.Title.displayName;
+
+const SheetDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetDescription.displayName = SheetPrimitive.Description.displayName;
+
+export {
+ Sheet,
+ SheetPortal,
+ SheetOverlay,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+};
diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..16e1437d6fce17c1ea74cc1bb9533657331c71d6
--- /dev/null
+++ b/components/ui/sidebar.tsx
@@ -0,0 +1,771 @@
+'use client';
+
+import * as React from 'react';
+import { Slot } from '@radix-ui/react-slot';
+import { VariantProps, cva } from 'class-variance-authority';
+import { PanelLeft } from 'lucide-react';
+
+import { useIsMobile } from '@/hooks/use-mobile';
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Separator } from '@/components/ui/separator';
+import { Sheet, SheetContent } from '@/components/ui/sheet';
+import { Skeleton } from '@/components/ui/skeleton';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+
+const SIDEBAR_COOKIE_NAME = 'sidebar:state';
+const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
+const SIDEBAR_WIDTH = '16rem';
+const SIDEBAR_WIDTH_MOBILE = '18rem';
+const SIDEBAR_WIDTH_ICON = '3rem';
+const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
+
+type SidebarContext = {
+ state: 'expanded' | 'collapsed';
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ openMobile: boolean;
+ setOpenMobile: (open: boolean) => void;
+ isMobile: boolean;
+ toggleSidebar: () => void;
+};
+
+const SidebarContext = React.createContext(null);
+
+function useSidebar() {
+ const context = React.useContext(SidebarContext);
+ if (!context) {
+ throw new Error('useSidebar must be used within a SidebarProvider.');
+ }
+
+ return context;
+}
+
+const SidebarProvider = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<'div'> & {
+ defaultOpen?: boolean;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ }
+>(
+ (
+ {
+ defaultOpen = true,
+ open: openProp,
+ onOpenChange: setOpenProp,
+ className,
+ style,
+ children,
+ ...props
+ },
+ ref,
+ ) => {
+ const isMobile = useIsMobile();
+ const [openMobile, setOpenMobile] = React.useState(false);
+
+ // This is the internal state of the sidebar.
+ // We use openProp and setOpenProp for control from outside the component.
+ const [_open, _setOpen] = React.useState(defaultOpen);
+ const open = openProp ?? _open;
+ const setOpen = React.useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === 'function' ? value(open) : value;
+ if (setOpenProp) {
+ setOpenProp(openState);
+ } else {
+ _setOpen(openState);
+ }
+
+ // This sets the cookie to keep the sidebar state.
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
+ },
+ [setOpenProp, open],
+ );
+
+ // Helper to toggle the sidebar.
+ const toggleSidebar = React.useCallback(() => {
+ return isMobile
+ ? setOpenMobile((open) => !open)
+ : setOpen((open) => !open);
+ }, [isMobile, setOpen, setOpenMobile]);
+
+ // Adds a keyboard shortcut to toggle the sidebar.
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
+ (event.metaKey || event.ctrlKey)
+ ) {
+ event.preventDefault();
+ toggleSidebar();
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [toggleSidebar]);
+
+ // We add a state so that we can do data-state="expanded" or "collapsed".
+ // This makes it easier to style the sidebar with Tailwind classes.
+ const state = open ? 'expanded' : 'collapsed';
+
+ const contextValue = React.useMemo(
+ () => ({
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ }),
+ [
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ ],
+ );
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+ },
+);
+SidebarProvider.displayName = 'SidebarProvider';
+
+const Sidebar = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<'div'> & {
+ side?: 'left' | 'right';
+ variant?: 'sidebar' | 'floating' | 'inset';
+ collapsible?: 'offcanvas' | 'icon' | 'none';
+ }
+>(
+ (
+ {
+ side = 'left',
+ variant = 'sidebar',
+ collapsible = 'offcanvas',
+ className,
+ children,
+ ...props
+ },
+ ref,
+ ) => {
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
+
+ if (collapsible === 'none') {
+ return (
+
+ {children}
+
+ );
+ }
+
+ if (isMobile) {
+ return (
+
+
+ {children}
+
+
+ );
+ }
+
+ return (
+
+ {/* This is what handles the sidebar gap on desktop */}
+
+
+
+ );
+ },
+);
+Sidebar.displayName = 'Sidebar';
+
+const SidebarTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentProps
+>(({ className, onClick, ...props }, ref) => {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+
+ );
+});
+SidebarTrigger.displayName = 'SidebarTrigger';
+
+const SidebarRail = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<'button'>
+>(({ className, ...props }, ref) => {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+
+ );
+});
+SidebarRail.displayName = 'SidebarRail';
+
+const SidebarInset = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<'main'>
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+SidebarInset.displayName = 'SidebarInset';
+
+const SidebarInput = React.forwardRef<
+ React.ElementRef,
+ React.ComponentProps
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+SidebarInput.displayName = 'SidebarInput';
+
+const SidebarHeader = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<'div'>
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+SidebarHeader.displayName = 'SidebarHeader';
+
+const SidebarFooter = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<'div'>
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+SidebarFooter.displayName = 'SidebarFooter';
+
+const SidebarSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentProps
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+SidebarSeparator.displayName = 'SidebarSeparator';
+
+const SidebarContent = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<'div'>
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+SidebarContent.displayName = 'SidebarContent';
+
+const SidebarGroup = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<'div'>
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+SidebarGroup.displayName = 'SidebarGroup';
+
+const SidebarGroupLabel = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<'div'> & { asChild?: boolean }
+>(({ className, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : 'div';
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0',
+ 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
+ className,
+ )}
+ {...props}
+ />
+ );
+});
+SidebarGroupLabel.displayName = 'SidebarGroupLabel';
+
+const SidebarGroupAction = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<'button'> & { asChild?: boolean }
+>(({ className, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : 'button';
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0',
+ // Increases the hit area of the button on mobile.
+ 'after:absolute after:-inset-2 after:md:hidden',
+ 'group-data-[collapsible=icon]:hidden',
+ className,
+ )}
+ {...props}
+ />
+ );
+});
+SidebarGroupAction.displayName = 'SidebarGroupAction';
+
+const SidebarGroupContent = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<'div'>
+>(({ className, ...props }, ref) => (
+
+));
+SidebarGroupContent.displayName = 'SidebarGroupContent';
+
+const SidebarMenu = React.forwardRef<
+ HTMLUListElement,
+ React.ComponentProps<'ul'>
+>(({ className, ...props }, ref) => (
+
+));
+SidebarMenu.displayName = 'SidebarMenu';
+
+const SidebarMenuItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentProps<'li'>
+>(({ className, ...props }, ref) => (
+
+));
+SidebarMenuItem.displayName = 'SidebarMenuItem';
+
+const sidebarMenuButtonVariants = cva(
+ 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
+ {
+ variants: {
+ variant: {
+ default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
+ outline:
+ 'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
+ },
+ size: {
+ default: 'h-8 text-sm',
+ sm: 'h-7 text-xs',
+ lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+);
+
+const SidebarMenuButton = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<'button'> & {
+ asChild?: boolean;
+ isActive?: boolean;
+ tooltip?: string | React.ComponentProps;
+ } & VariantProps
+>(
+ (
+ {
+ asChild = false,
+ isActive = false,
+ variant = 'default',
+ size = 'default',
+ tooltip,
+ className,
+ ...props
+ },
+ ref,
+ ) => {
+ const Comp = asChild ? Slot : 'button';
+ const { isMobile, state } = useSidebar();
+
+ const button = (
+
+ );
+
+ if (!tooltip) {
+ return button;
+ }
+
+ if (typeof tooltip === 'string') {
+ tooltip = {
+ children: tooltip,
+ };
+ }
+
+ return (
+
+ {button}
+
+
+ );
+ },
+);
+SidebarMenuButton.displayName = 'SidebarMenuButton';
+
+const SidebarMenuAction = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<'button'> & {
+ asChild?: boolean;
+ showOnHover?: boolean;
+ }
+>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : 'button';
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0',
+ // Increases the hit area of the button on mobile.
+ 'after:absolute after:-inset-2 after:md:hidden',
+ 'peer-data-[size=sm]/menu-button:top-1',
+ 'peer-data-[size=default]/menu-button:top-1.5',
+ 'peer-data-[size=lg]/menu-button:top-2.5',
+ 'group-data-[collapsible=icon]:hidden',
+ showOnHover &&
+ 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',
+ className,
+ )}
+ {...props}
+ />
+ );
+});
+SidebarMenuAction.displayName = 'SidebarMenuAction';
+
+const SidebarMenuBadge = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<'div'>
+>(({ className, ...props }, ref) => (
+
+));
+SidebarMenuBadge.displayName = 'SidebarMenuBadge';
+
+const SidebarMenuSkeleton = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<'div'> & {
+ showIcon?: boolean;
+ }
+>(({ className, showIcon = false, ...props }, ref) => {
+ // Random width between 50 to 90%.
+ const width = React.useMemo(() => {
+ return `${Math.floor(Math.random() * 40) + 50}%`;
+ }, []);
+
+ return (
+
+ {showIcon && (
+
+ )}
+
+
+ );
+});
+SidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton';
+
+const SidebarMenuSub = React.forwardRef<
+ HTMLUListElement,
+ React.ComponentProps<'ul'>
+>(({ className, ...props }, ref) => (
+
+));
+SidebarMenuSub.displayName = 'SidebarMenuSub';
+
+const SidebarMenuSubItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentProps<'li'>
+>(({ ...props }, ref) => );
+SidebarMenuSubItem.displayName = 'SidebarMenuSubItem';
+
+const SidebarMenuSubButton = React.forwardRef<
+ HTMLAnchorElement,
+ React.ComponentProps<'a'> & {
+ asChild?: boolean;
+ size?: 'sm' | 'md';
+ isActive?: boolean;
+ }
+>(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => {
+ const Comp = asChild ? Slot : 'a';
+
+ return (
+ span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
+ 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
+ size === 'sm' && 'text-xs',
+ size === 'md' && 'text-sm',
+ 'group-data-[collapsible=icon]:hidden',
+ className,
+ )}
+ {...props}
+ />
+ );
+});
+SidebarMenuSubButton.displayName = 'SidebarMenuSubButton';
+
+export {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarInput,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSkeleton,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarProvider,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+};
diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a626d9baa6a2128151d0cfab9ba84b265908c867
--- /dev/null
+++ b/components/ui/skeleton.tsx
@@ -0,0 +1,15 @@
+import { cn } from '@/lib/utils';
+
+function Skeleton({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+export { Skeleton };
diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..63745407e5d86e434a1b914c64d9e80c7c1c6cd2
--- /dev/null
+++ b/components/ui/textarea.tsx
@@ -0,0 +1,22 @@
+import * as React from 'react';
+
+import { cn } from '@/lib/utils';
+
+const Textarea = React.forwardRef<
+ HTMLTextAreaElement,
+ React.ComponentProps<'textarea'>
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+Textarea.displayName = 'Textarea';
+
+export { Textarea };
diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..338e7f8d73f01065fefb5e2ac5fb0616bea7076d
--- /dev/null
+++ b/components/ui/tooltip.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import * as React from 'react';
+import * as TooltipPrimitive from '@radix-ui/react-tooltip';
+
+import { cn } from '@/lib/utils';
+
+const TooltipProvider = TooltipPrimitive.Provider;
+
+const Tooltip = TooltipPrimitive.Root;
+
+const TooltipTrigger = TooltipPrimitive.Trigger;
+
+const TooltipContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+));
+TooltipContent.displayName = TooltipPrimitive.Content.displayName;
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/components/version-footer.tsx b/components/version-footer.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..49b8760bd029fb734fd6f453bc1d20eb5e7a2ff9
--- /dev/null
+++ b/components/version-footer.tsx
@@ -0,0 +1,107 @@
+'use client';
+
+import { isAfter } from 'date-fns';
+import { motion } from 'framer-motion';
+import { useState } from 'react';
+import { useSWRConfig } from 'swr';
+import { useWindowSize } from 'usehooks-ts';
+
+import type { Document } from '@/lib/db/schema';
+import { getDocumentTimestampByIndex } from '@/lib/utils';
+
+import { LoaderIcon } from './icons';
+import { Button } from './ui/button';
+import { useArtifact } from '@/hooks/use-artifact';
+
+interface VersionFooterProps {
+ handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void;
+ documents: Array | undefined;
+ currentVersionIndex: number;
+}
+
+export const VersionFooter = ({
+ handleVersionChange,
+ documents,
+ currentVersionIndex,
+}: VersionFooterProps) => {
+ const { artifact } = useArtifact();
+
+ const { width } = useWindowSize();
+ const isMobile = width < 768;
+
+ const { mutate } = useSWRConfig();
+ const [isMutating, setIsMutating] = useState(false);
+
+ if (!documents) return;
+
+ return (
+
+
+
You are viewing a previous version
+
+ Restore this version to make edits
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/components/visibility-selector.tsx b/components/visibility-selector.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b2d6dec28f9766dac449ff07387ca7836281f5d8
--- /dev/null
+++ b/components/visibility-selector.tsx
@@ -0,0 +1,110 @@
+'use client';
+
+import { type ReactNode, useMemo, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { cn } from '@/lib/utils';
+import {
+ CheckCircleFillIcon,
+ ChevronDownIcon,
+ GlobeIcon,
+ LockIcon,
+} from './icons';
+import { useChatVisibility } from '@/hooks/use-chat-visibility';
+
+export type VisibilityType = 'private' | 'public';
+
+const visibilities: Array<{
+ id: VisibilityType;
+ label: string;
+ description: string;
+ icon: ReactNode;
+}> = [
+ {
+ id: 'private',
+ label: 'Private',
+ description: 'Only you can access this chat',
+ icon: ,
+ },
+ {
+ id: 'public',
+ label: 'Public',
+ description: 'Anyone with the link can access this chat',
+ icon: ,
+ },
+];
+
+export function VisibilitySelector({
+ chatId,
+ className,
+ selectedVisibilityType,
+}: {
+ chatId: string;
+ selectedVisibilityType: VisibilityType;
+} & React.ComponentProps) {
+ const [open, setOpen] = useState(false);
+
+ const { visibilityType, setVisibilityType } = useChatVisibility({
+ chatId,
+ initialVisibilityType: selectedVisibilityType,
+ });
+
+ const selectedVisibility = useMemo(
+ () => visibilities.find((visibility) => visibility.id === visibilityType),
+ [visibilityType],
+ );
+
+ return (
+
+
+
+
+
+
+ {visibilities.map((visibility) => (
+ {
+ setVisibilityType(visibility.id);
+ setOpen(false);
+ }}
+ className="gap-4 group/item flex flex-row justify-between items-center"
+ data-active={visibility.id === visibilityType}
+ >
+
+ {visibility.label}
+ {visibility.description && (
+
+ {visibility.description}
+
+ )}
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/components/weather.tsx b/components/weather.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ff402a52a4a82d5e271da72947af31aeaa6d3d18
--- /dev/null
+++ b/components/weather.tsx
@@ -0,0 +1,311 @@
+'use client';
+
+import cx from 'classnames';
+import { format, isWithinInterval } from 'date-fns';
+import { useEffect, useState } from 'react';
+
+interface WeatherAtLocation {
+ latitude: number;
+ longitude: number;
+ generationtime_ms: number;
+ utc_offset_seconds: number;
+ timezone: string;
+ timezone_abbreviation: string;
+ elevation: number;
+ current_units: {
+ time: string;
+ interval: string;
+ temperature_2m: string;
+ };
+ current: {
+ time: string;
+ interval: number;
+ temperature_2m: number;
+ };
+ hourly_units: {
+ time: string;
+ temperature_2m: string;
+ };
+ hourly: {
+ time: string[];
+ temperature_2m: number[];
+ };
+ daily_units: {
+ time: string;
+ sunrise: string;
+ sunset: string;
+ };
+ daily: {
+ time: string[];
+ sunrise: string[];
+ sunset: string[];
+ };
+}
+
+const SAMPLE = {
+ latitude: 37.763283,
+ longitude: -122.41286,
+ generationtime_ms: 0.027894973754882812,
+ utc_offset_seconds: 0,
+ timezone: 'GMT',
+ timezone_abbreviation: 'GMT',
+ elevation: 18,
+ current_units: { time: 'iso8601', interval: 'seconds', temperature_2m: '°C' },
+ current: { time: '2024-10-07T19:30', interval: 900, temperature_2m: 29.3 },
+ hourly_units: { time: 'iso8601', temperature_2m: '°C' },
+ hourly: {
+ time: [
+ '2024-10-07T00:00',
+ '2024-10-07T01:00',
+ '2024-10-07T02:00',
+ '2024-10-07T03:00',
+ '2024-10-07T04:00',
+ '2024-10-07T05:00',
+ '2024-10-07T06:00',
+ '2024-10-07T07:00',
+ '2024-10-07T08:00',
+ '2024-10-07T09:00',
+ '2024-10-07T10:00',
+ '2024-10-07T11:00',
+ '2024-10-07T12:00',
+ '2024-10-07T13:00',
+ '2024-10-07T14:00',
+ '2024-10-07T15:00',
+ '2024-10-07T16:00',
+ '2024-10-07T17:00',
+ '2024-10-07T18:00',
+ '2024-10-07T19:00',
+ '2024-10-07T20:00',
+ '2024-10-07T21:00',
+ '2024-10-07T22:00',
+ '2024-10-07T23:00',
+ '2024-10-08T00:00',
+ '2024-10-08T01:00',
+ '2024-10-08T02:00',
+ '2024-10-08T03:00',
+ '2024-10-08T04:00',
+ '2024-10-08T05:00',
+ '2024-10-08T06:00',
+ '2024-10-08T07:00',
+ '2024-10-08T08:00',
+ '2024-10-08T09:00',
+ '2024-10-08T10:00',
+ '2024-10-08T11:00',
+ '2024-10-08T12:00',
+ '2024-10-08T13:00',
+ '2024-10-08T14:00',
+ '2024-10-08T15:00',
+ '2024-10-08T16:00',
+ '2024-10-08T17:00',
+ '2024-10-08T18:00',
+ '2024-10-08T19:00',
+ '2024-10-08T20:00',
+ '2024-10-08T21:00',
+ '2024-10-08T22:00',
+ '2024-10-08T23:00',
+ '2024-10-09T00:00',
+ '2024-10-09T01:00',
+ '2024-10-09T02:00',
+ '2024-10-09T03:00',
+ '2024-10-09T04:00',
+ '2024-10-09T05:00',
+ '2024-10-09T06:00',
+ '2024-10-09T07:00',
+ '2024-10-09T08:00',
+ '2024-10-09T09:00',
+ '2024-10-09T10:00',
+ '2024-10-09T11:00',
+ '2024-10-09T12:00',
+ '2024-10-09T13:00',
+ '2024-10-09T14:00',
+ '2024-10-09T15:00',
+ '2024-10-09T16:00',
+ '2024-10-09T17:00',
+ '2024-10-09T18:00',
+ '2024-10-09T19:00',
+ '2024-10-09T20:00',
+ '2024-10-09T21:00',
+ '2024-10-09T22:00',
+ '2024-10-09T23:00',
+ '2024-10-10T00:00',
+ '2024-10-10T01:00',
+ '2024-10-10T02:00',
+ '2024-10-10T03:00',
+ '2024-10-10T04:00',
+ '2024-10-10T05:00',
+ '2024-10-10T06:00',
+ '2024-10-10T07:00',
+ '2024-10-10T08:00',
+ '2024-10-10T09:00',
+ '2024-10-10T10:00',
+ '2024-10-10T11:00',
+ '2024-10-10T12:00',
+ '2024-10-10T13:00',
+ '2024-10-10T14:00',
+ '2024-10-10T15:00',
+ '2024-10-10T16:00',
+ '2024-10-10T17:00',
+ '2024-10-10T18:00',
+ '2024-10-10T19:00',
+ '2024-10-10T20:00',
+ '2024-10-10T21:00',
+ '2024-10-10T22:00',
+ '2024-10-10T23:00',
+ '2024-10-11T00:00',
+ '2024-10-11T01:00',
+ '2024-10-11T02:00',
+ '2024-10-11T03:00',
+ ],
+ temperature_2m: [
+ 36.6, 32.8, 29.5, 28.6, 29.2, 28.2, 27.5, 26.6, 26.5, 26, 25, 23.5, 23.9,
+ 24.2, 22.9, 21, 24, 28.1, 31.4, 33.9, 32.1, 28.9, 26.9, 25.2, 23, 21.1,
+ 19.6, 18.6, 17.7, 16.8, 16.2, 15.5, 14.9, 14.4, 14.2, 13.7, 13.3, 12.9,
+ 12.5, 13.5, 15.8, 17.7, 19.6, 21, 21.9, 22.3, 22, 20.7, 18.9, 17.9, 17.3,
+ 17, 16.7, 16.2, 15.6, 15.2, 15, 15, 15.1, 14.8, 14.8, 14.9, 14.7, 14.8,
+ 15.3, 16.2, 17.9, 19.6, 20.5, 21.6, 21, 20.7, 19.3, 18.7, 18.4, 17.9,
+ 17.3, 17, 17, 16.8, 16.4, 16.2, 16, 15.8, 15.7, 15.4, 15.4, 16.1, 16.7,
+ 17, 18.6, 19, 19.5, 19.4, 18.5, 17.9, 17.5, 16.7, 16.3, 16.1,
+ ],
+ },
+ daily_units: {
+ time: 'iso8601',
+ sunrise: 'iso8601',
+ sunset: 'iso8601',
+ },
+ daily: {
+ time: [
+ '2024-10-07',
+ '2024-10-08',
+ '2024-10-09',
+ '2024-10-10',
+ '2024-10-11',
+ ],
+ sunrise: [
+ '2024-10-07T07:15',
+ '2024-10-08T07:16',
+ '2024-10-09T07:17',
+ '2024-10-10T07:18',
+ '2024-10-11T07:19',
+ ],
+ sunset: [
+ '2024-10-07T19:00',
+ '2024-10-08T18:58',
+ '2024-10-09T18:57',
+ '2024-10-10T18:55',
+ '2024-10-11T18:54',
+ ],
+ },
+};
+
+function n(num: number): number {
+ return Math.ceil(num);
+}
+
+export function Weather({
+ weatherAtLocation = SAMPLE,
+}: {
+ weatherAtLocation?: WeatherAtLocation;
+}) {
+ const currentHigh = Math.max(
+ ...weatherAtLocation.hourly.temperature_2m.slice(0, 24),
+ );
+ const currentLow = Math.min(
+ ...weatherAtLocation.hourly.temperature_2m.slice(0, 24),
+ );
+
+ const isDay = isWithinInterval(new Date(weatherAtLocation.current.time), {
+ start: new Date(weatherAtLocation.daily.sunrise[0]),
+ end: new Date(weatherAtLocation.daily.sunset[0]),
+ });
+
+ const [isMobile, setIsMobile] = useState(false);
+
+ useEffect(() => {
+ const handleResize = () => {
+ setIsMobile(window.innerWidth < 768);
+ };
+
+ handleResize();
+ window.addEventListener('resize', handleResize);
+
+ return () => window.removeEventListener('resize', handleResize);
+ }, []);
+
+ const hoursToShow = isMobile ? 5 : 6;
+
+ // Find the index of the current time or the next closest time
+ const currentTimeIndex = weatherAtLocation.hourly.time.findIndex(
+ (time) => new Date(time) >= new Date(weatherAtLocation.current.time),
+ );
+
+ // Slice the arrays to get the desired number of items
+ const displayTimes = weatherAtLocation.hourly.time.slice(
+ currentTimeIndex,
+ currentTimeIndex + hoursToShow,
+ );
+ const displayTemperatures = weatherAtLocation.hourly.temperature_2m.slice(
+ currentTimeIndex,
+ currentTimeIndex + hoursToShow,
+ );
+
+ return (
+
+
+
+
+
+ {n(weatherAtLocation.current.temperature_2m)}
+ {weatherAtLocation.current_units.temperature_2m}
+
+
+
+
{`H:${n(currentHigh)}° L:${n(currentLow)}°`}
+
+
+
+ {displayTimes.map((time, index) => (
+
+
+ {format(new Date(time), 'ha')}
+
+
+
+ {n(displayTemperatures[index])}
+ {weatherAtLocation.hourly_units.temperature_2m}
+
+
+ ))}
+
+
+ );
+}
diff --git a/drizzle.config.ts b/drizzle.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7c4a8410ab7d00e31c99bb229aa9798c6fc5d461
--- /dev/null
+++ b/drizzle.config.ts
@@ -0,0 +1,16 @@
+import { config } from 'dotenv';
+import { defineConfig } from 'drizzle-kit';
+
+config({
+ path: '.env.local',
+});
+
+export default defineConfig({
+ schema: './lib/db/schema.ts',
+ out: './lib/db/migrations',
+ dialect: 'postgresql',
+ dbCredentials: {
+ // biome-ignore lint: Forbidden non-null assertion.
+ url: process.env.POSTGRES_URL!,
+ },
+});
diff --git a/hooks/use-artifact.ts b/hooks/use-artifact.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9133fe80291a2f87fda08df13a9df5e045bc9bbf
--- /dev/null
+++ b/hooks/use-artifact.ts
@@ -0,0 +1,85 @@
+'use client';
+
+import useSWR from 'swr';
+import { UIArtifact } from '@/components/artifact';
+import { useCallback, useMemo } from 'react';
+
+export const initialArtifactData: UIArtifact = {
+ documentId: 'init',
+ content: '',
+ kind: 'text',
+ title: '',
+ status: 'idle',
+ isVisible: false,
+ boundingBox: {
+ top: 0,
+ left: 0,
+ width: 0,
+ height: 0,
+ },
+};
+
+type Selector = (state: UIArtifact) => T;
+
+export function useArtifactSelector(selector: Selector) {
+ const { data: localArtifact } = useSWR('artifact', null, {
+ fallbackData: initialArtifactData,
+ });
+
+ const selectedValue = useMemo(() => {
+ if (!localArtifact) return selector(initialArtifactData);
+ return selector(localArtifact);
+ }, [localArtifact, selector]);
+
+ return selectedValue;
+}
+
+export function useArtifact() {
+ const { data: localArtifact, mutate: setLocalArtifact } = useSWR(
+ 'artifact',
+ null,
+ {
+ fallbackData: initialArtifactData,
+ },
+ );
+
+ const artifact = useMemo(() => {
+ if (!localArtifact) return initialArtifactData;
+ return localArtifact;
+ }, [localArtifact]);
+
+ const setArtifact = useCallback(
+ (updaterFn: UIArtifact | ((currentArtifact: UIArtifact) => UIArtifact)) => {
+ setLocalArtifact((currentArtifact) => {
+ const artifactToUpdate = currentArtifact || initialArtifactData;
+
+ if (typeof updaterFn === 'function') {
+ return updaterFn(artifactToUpdate);
+ }
+
+ return updaterFn;
+ });
+ },
+ [setLocalArtifact],
+ );
+
+ const { data: localArtifactMetadata, mutate: setLocalArtifactMetadata } =
+ useSWR