├── .env.example ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── ai ├── index.ts └── rag-middleware.ts ├── app ├── (auth) │ ├── actions.ts │ ├── api │ │ └── auth │ │ │ └── [...nextauth] │ │ │ └── route.ts │ ├── auth.config.ts │ ├── auth.ts │ ├── login │ │ └── page.tsx │ └── register │ │ └── page.tsx ├── (chat) │ ├── [id] │ │ └── page.tsx │ ├── api │ │ ├── chat │ │ │ └── route.ts │ │ ├── files │ │ │ ├── delete │ │ │ │ └── route.ts │ │ │ ├── list │ │ │ │ └── route.ts │ │ │ └── upload │ │ │ │ └── route.ts │ │ └── history │ │ │ └── route.ts │ ├── opengraph-image.png │ ├── page.tsx │ └── twitter-image.png ├── db.ts ├── favicon.ico ├── globals.css ├── layout.tsx └── uncut-sans.woff2 ├── components ├── chat.tsx ├── data.ts ├── files.tsx ├── form.tsx ├── history.tsx ├── icons.tsx ├── markdown.tsx ├── message.tsx ├── navbar.tsx ├── submit-button.tsx └── use-scroll-to-bottom.ts ├── drizzle.config.ts ├── drizzle ├── 0000_pretty_dracula.sql └── meta │ ├── 0000_snapshot.json │ └── _journal.json ├── middleware.ts ├── migrate.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── iphone.png ├── tv.png └── watch.png ├── schema.ts ├── tailwind.config.ts ├── tsconfig.json └── utils ├── functions.ts └── pdf.ts /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY=**** 2 | 3 | # Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32` 4 | AUTH_SECRET=**** 5 | 6 | # Instructions to create kv database here: https://vercel.com/docs/storage/vercel-blob 7 | BLOB_READ_WRITE_TOKEN=**** 8 | 9 | # Instructions to create a database here: https://vercel.com/docs/storage/vercel-postgres/quickstart 10 | POSTGRES_URL=**** 11 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Vercel, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Internal Knowledge Base Preview 2 | 3 | This template demonstrates the usage of the [Language Model Middleware](https://sdk.vercel.ai/docs/ai-sdk-core/middleware#language-model-middleware) to perform retrieval augmented generation and enforce guardrails using the [AI SDK](https://sdk.vercel.ai/docs) and [Next.js](https://nextjs.org/). 4 | 5 | ## Deploy your own 6 | 7 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-sdk-preview-internal-knowledge-base&env=OPENAI_API_KEY%2CAUTH_SECRET&envDescription=API%20keys%20needed%20for%20application&envLink=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-sdk-preview-internal-knowledge-base%2Fblob%2Fmain%2F.env.example&stores=%5B%7B%22type%22%3A%22blob%22%7D%2C%7B%22type%22%3A%22postgres%22%7D%5D) 8 | 9 | ## How to use 10 | 11 | Run [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example: 12 | 13 | ```bash 14 | npx create-next-app --example https://github.com/vercel-labs/ai-sdk-preview-internal-knowledge-base ai-sdk-preview-internal-knowledge-base-example 15 | ``` 16 | 17 | ```bash 18 | yarn create next-app --example https://github.com/vercel-labs/ai-sdk-preview-internal-knowledge-base ai-sdk-preview-internal-knowledge-base-example 19 | ``` 20 | 21 | ```bash 22 | pnpm create next-app --example https://github.com/vercel-labs/ai-sdk-preview-internal-knowledge-base ai-sdk-preview-internal-knowledge-base-example 23 | ``` 24 | 25 | To run the example locally you need to: 26 | 27 | 1. Sign up for accounts with the AI providers you want to use (e.g., OpenAI, Anthropic). 28 | 2. Obtain API keys for each provider. 29 | 3. Set the required environment variables as shown in the `.env.example` file, but in a new file called `.env`. 30 | 4. `npm install` to install the required dependencies. 31 | 5. `npm run dev` to launch the development server. 32 | 33 | 34 | ## Learn More 35 | 36 | To learn more about the AI SDK or Next.js by Vercel, take a look at the following resources: 37 | 38 | - [AI SDK Documentation](https://sdk.vercel.ai/docs) 39 | - [Next.js Documentation](https://nextjs.org/docs) 40 | -------------------------------------------------------------------------------- /ai/index.ts: -------------------------------------------------------------------------------- 1 | import { openai } from "@ai-sdk/openai"; 2 | import { experimental_wrapLanguageModel as wrapLanguageModel } from "ai"; 3 | import { ragMiddleware } from "./rag-middleware"; 4 | 5 | export const customModel = wrapLanguageModel({ 6 | model: openai("gpt-4o"), 7 | middleware: ragMiddleware, 8 | }); 9 | -------------------------------------------------------------------------------- /ai/rag-middleware.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/app/(auth)/auth"; 2 | import { getChunksByFilePaths } from "@/app/db"; 3 | import { openai } from "@ai-sdk/openai"; 4 | import { 5 | cosineSimilarity, 6 | embed, 7 | Experimental_LanguageModelV1Middleware, 8 | generateObject, 9 | generateText, 10 | } from "ai"; 11 | import { z } from "zod"; 12 | 13 | // schema for validating the custom provider metadata 14 | const selectionSchema = z.object({ 15 | files: z.object({ 16 | selection: z.array(z.string()), 17 | }), 18 | }); 19 | 20 | export const ragMiddleware: Experimental_LanguageModelV1Middleware = { 21 | transformParams: async ({ params }) => { 22 | const session = await auth(); 23 | 24 | if (!session) return params; // no user session 25 | 26 | const { prompt: messages, providerMetadata } = params; 27 | 28 | // validate the provider metadata with Zod: 29 | const { success, data } = selectionSchema.safeParse(providerMetadata); 30 | 31 | if (!success) return params; // no files selected 32 | 33 | const selection = data.files.selection; 34 | 35 | const recentMessage = messages.pop(); 36 | 37 | if (!recentMessage || recentMessage.role !== "user") { 38 | if (recentMessage) { 39 | messages.push(recentMessage); 40 | } 41 | 42 | return params; 43 | } 44 | 45 | const lastUserMessageContent = recentMessage.content 46 | .filter((content) => content.type === "text") 47 | .map((content) => content.text) 48 | .join("\n"); 49 | 50 | // Classify the user prompt as whether it requires more context or not 51 | const { object: classification } = await generateObject({ 52 | // fast model for classification: 53 | model: openai("gpt-4o-mini", { structuredOutputs: true }), 54 | output: "enum", 55 | enum: ["question", "statement", "other"], 56 | system: "classify the user message as a question, statement, or other", 57 | prompt: lastUserMessageContent, 58 | }); 59 | 60 | // only use RAG for questions 61 | if (classification !== "question") { 62 | messages.push(recentMessage); 63 | return params; 64 | } 65 | 66 | // Use hypothetical document embeddings: 67 | const { text: hypotheticalAnswer } = await generateText({ 68 | // fast model for generating hypothetical answer: 69 | model: openai("gpt-4o-mini", { structuredOutputs: true }), 70 | system: "Answer the users question:", 71 | prompt: lastUserMessageContent, 72 | }); 73 | 74 | // Embed the hypothetical answer 75 | const { embedding: hypotheticalAnswerEmbedding } = await embed({ 76 | model: openai.embedding("text-embedding-3-small"), 77 | value: hypotheticalAnswer, 78 | }); 79 | 80 | // find relevant chunks based on the selection 81 | const chunksBySelection = await getChunksByFilePaths({ 82 | filePaths: selection.map((path) => `${session.user?.email}/${path}`), 83 | }); 84 | 85 | const chunksWithSimilarity = chunksBySelection.map((chunk) => ({ 86 | ...chunk, 87 | similarity: cosineSimilarity( 88 | hypotheticalAnswerEmbedding, 89 | chunk.embedding, 90 | ), 91 | })); 92 | 93 | // rank the chunks by similarity and take the top K 94 | chunksWithSimilarity.sort((a, b) => b.similarity - a.similarity); 95 | const k = 10; 96 | const topKChunks = chunksWithSimilarity.slice(0, k); 97 | 98 | // add the chunks to the last user message 99 | messages.push({ 100 | role: "user", 101 | content: [ 102 | ...recentMessage.content, 103 | { 104 | type: "text", 105 | text: "Here is some relevant information that you can use to answer the question:", 106 | }, 107 | ...topKChunks.map((chunk) => ({ 108 | type: "text" as const, 109 | text: chunk.content, 110 | })), 111 | ], 112 | }); 113 | 114 | return { ...params, prompt: messages }; 115 | }, 116 | }; 117 | -------------------------------------------------------------------------------- /app/(auth)/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { createUser, getUser } from "../db"; 4 | import { signIn } from "./auth"; 5 | 6 | export interface LoginActionState { 7 | status: "idle" | "in_progress" | "success" | "failed"; 8 | } 9 | 10 | export const login = async ( 11 | data: LoginActionState, 12 | formData: FormData, 13 | ): Promise => { 14 | try { 15 | await signIn("credentials", { 16 | email: formData.get("email") as string, 17 | password: formData.get("password") as string, 18 | redirect: false, 19 | }); 20 | 21 | return { status: "success" } as LoginActionState; 22 | } catch { 23 | return { status: "failed" } as LoginActionState; 24 | } 25 | }; 26 | 27 | export interface RegisterActionState { 28 | status: "idle" | "in_progress" | "success" | "failed" | "user_exists"; 29 | } 30 | 31 | export const register = async ( 32 | data: RegisterActionState, 33 | formData: FormData, 34 | ) => { 35 | let email = formData.get("email") as string; 36 | let password = formData.get("password") as string; 37 | let user = await getUser(email); 38 | 39 | if (user.length > 0) { 40 | return { status: "user_exists" } as RegisterActionState; 41 | } else { 42 | await createUser(email, password); 43 | await signIn("credentials", { 44 | email, 45 | password, 46 | redirect: false, 47 | }); 48 | return { status: "success" } as RegisterActionState; 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /app/(auth)/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST } from "@/app/(auth)/auth"; 2 | -------------------------------------------------------------------------------- /app/(auth)/auth.config.ts: -------------------------------------------------------------------------------- 1 | import { NextAuthConfig } from "next-auth"; 2 | 3 | export const authConfig = { 4 | pages: { 5 | signIn: "/login", 6 | newUser: "/", 7 | }, 8 | providers: [ 9 | // added later in auth.ts since it requires bcrypt which is only compatible with Node.js 10 | // while this file is also used in non-Node.js environments 11 | ], 12 | callbacks: { 13 | authorized({ auth, request: { nextUrl } }) { 14 | let isLoggedIn = !!auth?.user; 15 | let isOnChat = nextUrl.pathname.startsWith("/"); 16 | let isOnRegister = nextUrl.pathname.startsWith("/register"); 17 | let isOnLogin = nextUrl.pathname.startsWith("/login"); 18 | 19 | if (isLoggedIn && (isOnLogin || isOnRegister)) { 20 | return Response.redirect(new URL("/", nextUrl)); 21 | } 22 | 23 | if (isOnRegister || isOnLogin) { 24 | return true; // Always allow access to register and login pages 25 | } 26 | 27 | if (isOnChat) { 28 | if (isLoggedIn) return true; 29 | return false; // Redirect unauthenticated users to login page 30 | } 31 | 32 | if (isLoggedIn) { 33 | return Response.redirect(new URL("/", nextUrl)); 34 | } 35 | 36 | return true; 37 | }, 38 | }, 39 | } satisfies NextAuthConfig; 40 | -------------------------------------------------------------------------------- /app/(auth)/auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import Credentials from "next-auth/providers/credentials"; 3 | import { compare } from "bcrypt-ts"; 4 | import { getUser } from "@/app/db"; 5 | import { authConfig } from "./auth.config"; 6 | 7 | export const { 8 | handlers: { GET, POST }, 9 | auth, 10 | signIn, 11 | signOut, 12 | } = NextAuth({ 13 | ...authConfig, 14 | providers: [ 15 | Credentials({ 16 | async authorize({ email, password }: any) { 17 | let user = await getUser(email); 18 | if (user.length === 0) return null; 19 | let passwordsMatch = await compare(password, user[0].password!); 20 | if (passwordsMatch) return user[0] as any; 21 | }, 22 | }), 23 | ], 24 | }); 25 | -------------------------------------------------------------------------------- /app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { Form } from "@/components/form"; 5 | import { SubmitButton } from "@/components/submit-button"; 6 | import { useActionState, useEffect } from "react"; 7 | import { login, LoginActionState } from "../actions"; 8 | import { toast } from "sonner"; 9 | import { useRouter } from "next/navigation"; 10 | 11 | export default function Page() { 12 | const router = useRouter(); 13 | 14 | const [state, formAction] = useActionState( 15 | login, 16 | { 17 | status: "idle", 18 | }, 19 | ); 20 | 21 | useEffect(() => { 22 | if (state.status === "failed") { 23 | toast.error("Invalid credentials!"); 24 | } else if (state.status === "success") { 25 | router.refresh(); 26 | } 27 | }, [state.status, router]); 28 | 29 | return ( 30 |
31 |
32 |
33 |

Sign In

34 |

35 | Use your email and password to sign in 36 |

37 |
38 |
39 | Sign in 40 |

41 | {"Don't have an account? "} 42 | 46 | Sign up 47 | 48 | {" for free."} 49 |

50 |
51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /app/(auth)/register/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { Form } from "@/components/form"; 5 | import { SubmitButton } from "@/components/submit-button"; 6 | import { register, RegisterActionState } from "../actions"; 7 | import { useActionState, useEffect } from "react"; 8 | import { toast } from "sonner"; 9 | import { useRouter } from "next/navigation"; 10 | 11 | export default function Page() { 12 | const router = useRouter(); 13 | const [state, formAction] = useActionState( 14 | register, 15 | { 16 | status: "idle", 17 | }, 18 | ); 19 | 20 | useEffect(() => { 21 | if (state.status === "user_exists") { 22 | toast.error("Account already exists"); 23 | } else if (state.status === "failed") { 24 | toast.error("Failed to create account"); 25 | } else if (state.status === "success") { 26 | toast.success("Account created successfully"); 27 | router.refresh(); 28 | } 29 | }, [state, router]); 30 | 31 | return ( 32 |
33 |
34 |
35 |

Sign Up

36 |

37 | Create an account with your email and password 38 |

39 |
40 |
41 | Sign Up 42 |

43 | {"Already have an account? "} 44 | 48 | Sign in 49 | 50 | {" instead."} 51 |

52 |
53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /app/(chat)/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Message } from "ai"; 2 | import { Chat } from "@/schema"; 3 | import { getChatById } from "@/app/db"; 4 | import { notFound } from "next/navigation"; 5 | import { Chat as PreviewChat } from "@/components/chat"; 6 | import { auth } from "@/app/(auth)/auth"; 7 | 8 | export default async function Page({ params }: { params: any }) { 9 | const { id } = params; 10 | const chatFromDb = await getChatById({ id }); 11 | 12 | if (!chatFromDb) { 13 | notFound(); 14 | } 15 | 16 | // type casting 17 | const chat: Chat = { 18 | ...chatFromDb, 19 | messages: chatFromDb.messages as Message[], 20 | }; 21 | 22 | const session = await auth(); 23 | 24 | if (chat.author !== session?.user?.email) { 25 | notFound(); 26 | } 27 | 28 | return ( 29 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /app/(chat)/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { customModel } from "@/ai"; 2 | import { auth } from "@/app/(auth)/auth"; 3 | import { createMessage } from "@/app/db"; 4 | import { streamText } from "ai"; 5 | 6 | export async function POST(request: Request) { 7 | const { id, messages, selectedFilePathnames } = await request.json(); 8 | 9 | const session = await auth(); 10 | 11 | if (!session) { 12 | return new Response("Unauthorized", { status: 401 }); 13 | } 14 | 15 | const result = streamText({ 16 | model: customModel, 17 | system: 18 | "you are a friendly assistant! keep your responses concise and helpful.", 19 | messages, 20 | experimental_providerMetadata: { 21 | files: { 22 | selection: selectedFilePathnames, 23 | }, 24 | }, 25 | onFinish: async ({ text }) => { 26 | await createMessage({ 27 | id, 28 | messages: [...messages, { role: "assistant", content: text }], 29 | author: session.user?.email!, 30 | }); 31 | }, 32 | experimental_telemetry: { 33 | isEnabled: true, 34 | functionId: "stream-text", 35 | }, 36 | }); 37 | 38 | return result.toDataStreamResponse({}); 39 | } 40 | -------------------------------------------------------------------------------- /app/(chat)/api/files/delete/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/app/(auth)/auth"; 2 | import { deleteChunksByFilePath } from "@/app/db"; 3 | import { head, del } from "@vercel/blob"; 4 | 5 | export async function DELETE(request: Request) { 6 | const { searchParams } = new URL(request.url); 7 | 8 | let session = await auth(); 9 | 10 | if (!session) { 11 | return Response.redirect("/login"); 12 | } 13 | 14 | const { user } = session; 15 | 16 | if (!user || !user.email) { 17 | return Response.redirect("/login"); 18 | } 19 | 20 | if (request.body === null) { 21 | return new Response("Request body is empty", { status: 400 }); 22 | } 23 | 24 | const fileurl = searchParams.get("fileurl"); 25 | 26 | if (fileurl === null) { 27 | return new Response("File url not provided", { status: 400 }); 28 | } 29 | 30 | const { pathname } = await head(fileurl); 31 | 32 | if (!pathname.startsWith(user.email)) { 33 | return new Response("Unauthorized", { status: 400 }); 34 | } 35 | 36 | await del(fileurl); 37 | await deleteChunksByFilePath({ filePath: pathname }); 38 | 39 | return Response.json({}); 40 | } 41 | -------------------------------------------------------------------------------- /app/(chat)/api/files/list/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/app/(auth)/auth"; 2 | import { list } from "@vercel/blob"; 3 | 4 | export async function GET() { 5 | let session = await auth(); 6 | 7 | if (!session) { 8 | return Response.redirect("/login"); 9 | } 10 | 11 | const { user } = session; 12 | 13 | if (!user) { 14 | return Response.redirect("/login"); 15 | } 16 | 17 | const { blobs } = await list({ prefix: user.email! }); 18 | 19 | return Response.json( 20 | blobs.map((blob) => ({ 21 | ...blob, 22 | pathname: blob.pathname.replace(`${user.email}/`, ""), 23 | })), 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /app/(chat)/api/files/upload/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/app/(auth)/auth"; 2 | import { insertChunks } from "@/app/db"; 3 | import { getPdfContentFromUrl } from "@/utils/pdf"; 4 | import { openai } from "@ai-sdk/openai"; 5 | import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters"; 6 | import { put } from "@vercel/blob"; 7 | import { embedMany } from "ai"; 8 | 9 | export async function POST(request: Request) { 10 | const { searchParams } = new URL(request.url); 11 | const filename = searchParams.get("filename"); 12 | 13 | let session = await auth(); 14 | 15 | if (!session) { 16 | return Response.redirect("/login"); 17 | } 18 | 19 | const { user } = session; 20 | 21 | if (!user) { 22 | return Response.redirect("/login"); 23 | } 24 | 25 | if (request.body === null) { 26 | return new Response("Request body is empty", { status: 400 }); 27 | } 28 | 29 | const { downloadUrl } = await put(`${user.email}/${filename}`, request.body, { 30 | access: "public", 31 | }); 32 | 33 | const content = await getPdfContentFromUrl(downloadUrl); 34 | const textSplitter = new RecursiveCharacterTextSplitter({ 35 | chunkSize: 1000, 36 | }); 37 | const chunkedContent = await textSplitter.createDocuments([content]); 38 | 39 | const { embeddings } = await embedMany({ 40 | model: openai.embedding("text-embedding-3-small"), 41 | values: chunkedContent.map((chunk) => chunk.pageContent), 42 | }); 43 | 44 | await insertChunks({ 45 | chunks: chunkedContent.map((chunk, i) => ({ 46 | id: `${user.email}/${filename}/${i}`, 47 | filePath: `${user.email}/${filename}`, 48 | content: chunk.pageContent, 49 | embedding: embeddings[i], 50 | })), 51 | }); 52 | 53 | return Response.json({}); 54 | } 55 | -------------------------------------------------------------------------------- /app/(chat)/api/history/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/app/(auth)/auth"; 2 | import { getChatsByUser } from "@/app/db"; 3 | 4 | export async function GET() { 5 | let session = await auth(); 6 | 7 | if (!session || !session.user) { 8 | return Response.json("Unauthorized!", { status: 401 }); 9 | } 10 | 11 | const chats = await getChatsByUser({ email: session.user.email! }); 12 | return Response.json(chats); 13 | } 14 | -------------------------------------------------------------------------------- /app/(chat)/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-internal-knowledge-base/f6230c77d6d0dbe3a5a5ea92eb791c6ecf9fbd73/app/(chat)/opengraph-image.png -------------------------------------------------------------------------------- /app/(chat)/page.tsx: -------------------------------------------------------------------------------- 1 | import { Chat } from "@/components/chat"; 2 | import { generateId } from "ai"; 3 | import { auth } from "@/app/(auth)/auth"; 4 | 5 | export default async function Page() { 6 | const session = await auth(); 7 | return ; 8 | } 9 | -------------------------------------------------------------------------------- /app/(chat)/twitter-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-internal-knowledge-base/f6230c77d6d0dbe3a5a5ea92eb791c6ecf9fbd73/app/(chat)/twitter-image.png -------------------------------------------------------------------------------- /app/db.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/postgres-js"; 2 | import { desc, eq, inArray } from "drizzle-orm"; 3 | import postgres from "postgres"; 4 | import { genSaltSync, hashSync } from "bcrypt-ts"; 5 | import { chat, chunk, user } from "@/schema"; 6 | 7 | // Optionally, if not using email/pass login, you can 8 | // use the Drizzle adapter for Auth.js / NextAuth 9 | // https://authjs.dev/reference/adapter/drizzle 10 | let client = postgres(`${process.env.POSTGRES_URL!}?sslmode=require`); 11 | let db = drizzle(client); 12 | 13 | export async function getUser(email: string) { 14 | return await db.select().from(user).where(eq(user.email, email)); 15 | } 16 | 17 | export async function createUser(email: string, password: string) { 18 | let salt = genSaltSync(10); 19 | let hash = hashSync(password, salt); 20 | 21 | return await db.insert(user).values({ email, password: hash }); 22 | } 23 | 24 | export async function createMessage({ 25 | id, 26 | messages, 27 | author, 28 | }: { 29 | id: string; 30 | messages: any; 31 | author: string; 32 | }) { 33 | const selectedChats = await db.select().from(chat).where(eq(chat.id, id)); 34 | 35 | if (selectedChats.length > 0) { 36 | return await db 37 | .update(chat) 38 | .set({ 39 | messages: JSON.stringify(messages), 40 | }) 41 | .where(eq(chat.id, id)); 42 | } 43 | 44 | return await db.insert(chat).values({ 45 | id, 46 | createdAt: new Date(), 47 | messages: JSON.stringify(messages), 48 | author, 49 | }); 50 | } 51 | 52 | export async function getChatsByUser({ email }: { email: string }) { 53 | return await db 54 | .select() 55 | .from(chat) 56 | .where(eq(chat.author, email)) 57 | .orderBy(desc(chat.createdAt)); 58 | } 59 | 60 | export async function getChatById({ id }: { id: string }) { 61 | const [selectedChat] = await db.select().from(chat).where(eq(chat.id, id)); 62 | return selectedChat; 63 | } 64 | 65 | export async function insertChunks({ chunks }: { chunks: any[] }) { 66 | return await db.insert(chunk).values(chunks); 67 | } 68 | 69 | export async function getChunksByFilePaths({ 70 | filePaths, 71 | }: { 72 | filePaths: Array; 73 | }) { 74 | return await db 75 | .select() 76 | .from(chunk) 77 | .where(inArray(chunk.filePath, filePaths)); 78 | } 79 | 80 | export async function deleteChunksByFilePath({ 81 | filePath, 82 | }: { 83 | filePath: string; 84 | }) { 85 | return await db.delete(chunk).where(eq(chunk.filePath, filePath)); 86 | } 87 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-internal-knowledge-base/f6230c77d6d0dbe3a5a5ea92eb791c6ecf9fbd73/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @font-face { 12 | font-family: "uncut sans"; 13 | src: url("./uncut-sans.woff2") format("woff2"); 14 | } 15 | 16 | * { 17 | font-family: "uncut sans", sans-serif; 18 | } 19 | 20 | @media (prefers-color-scheme: dark) { 21 | :root { 22 | --foreground-rgb: 255, 255, 255; 23 | --background-start-rgb: 0, 0, 0; 24 | --background-end-rgb: 0, 0, 0; 25 | } 26 | } 27 | 28 | @layer utilities { 29 | .text-balance { 30 | text-wrap: balance; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from "@/components/navbar"; 2 | import { Metadata } from "next"; 3 | import { Toaster } from "sonner"; 4 | import "./globals.css"; 5 | 6 | export const metadata: Metadata = { 7 | metadataBase: new URL( 8 | "https://ai-sdk-preview-internal-knowledge-base.vercel.app", 9 | ), 10 | title: "Internal Knowledge Base", 11 | description: 12 | "Internal Knowledge Base using Retrieval Augmented Generation and Middleware", 13 | }; 14 | 15 | export default function RootLayout({ 16 | children, 17 | }: Readonly<{ 18 | children: React.ReactNode; 19 | }>) { 20 | return ( 21 | 22 | 23 | 24 | 25 | {children} 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /app/uncut-sans.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-internal-knowledge-base/f6230c77d6d0dbe3a5a5ea92eb791c6ecf9fbd73/app/uncut-sans.woff2 -------------------------------------------------------------------------------- /components/chat.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Message } from "ai"; 4 | import { useChat } from "ai/react"; 5 | import { useEffect, useState } from "react"; 6 | import { Files } from "@/components/files"; 7 | import { AnimatePresence, motion } from "framer-motion"; 8 | import { FileIcon } from "@/components/icons"; 9 | import { Message as PreviewMessage } from "@/components/message"; 10 | import { useScrollToBottom } from "@/components/use-scroll-to-bottom"; 11 | import { Session } from "next-auth"; 12 | 13 | const suggestedActions = [ 14 | { 15 | title: "What's the summary", 16 | label: "of these documents?", 17 | action: "what's the summary of these documents?", 18 | }, 19 | { 20 | title: "Who is the author", 21 | label: "of these documents?", 22 | action: "who is the author of these documents?", 23 | }, 24 | ]; 25 | 26 | export function Chat({ 27 | id, 28 | initialMessages, 29 | session, 30 | }: { 31 | id: string; 32 | initialMessages: Array; 33 | session: Session | null; 34 | }) { 35 | const [selectedFilePathnames, setSelectedFilePathnames] = useState< 36 | Array 37 | >([]); 38 | const [isFilesVisible, setIsFilesVisible] = useState(false); 39 | const [isMounted, setIsMounted] = useState(false); 40 | 41 | useEffect(() => { 42 | if (isMounted !== false && session && session.user) { 43 | localStorage.setItem( 44 | `${session.user.email}/selected-file-pathnames`, 45 | JSON.stringify(selectedFilePathnames), 46 | ); 47 | } 48 | }, [selectedFilePathnames, isMounted, session]); 49 | 50 | useEffect(() => { 51 | setIsMounted(true); 52 | }, []); 53 | 54 | useEffect(() => { 55 | if (session && session.user) { 56 | setSelectedFilePathnames( 57 | JSON.parse( 58 | localStorage.getItem( 59 | `${session.user.email}/selected-file-pathnames`, 60 | ) || "[]", 61 | ), 62 | ); 63 | } 64 | }, [session]); 65 | 66 | const { messages, handleSubmit, input, setInput, append } = useChat({ 67 | body: { id, selectedFilePathnames }, 68 | initialMessages, 69 | onFinish: () => { 70 | window.history.replaceState({}, "", `/${id}`); 71 | }, 72 | }); 73 | 74 | const [messagesContainerRef, messagesEndRef] = 75 | useScrollToBottom(); 76 | 77 | return ( 78 |
79 |
80 |
84 | {messages.map((message, index) => ( 85 | 90 | ))} 91 |
95 |
96 | 97 | {messages.length === 0 && ( 98 |
99 | {suggestedActions.map((suggestedAction, index) => ( 100 | 1 ? "hidden sm:block" : "block"} 106 | > 107 | 121 | 122 | ))} 123 |
124 | )} 125 | 126 |
130 | { 135 | setInput(event.target.value); 136 | }} 137 | /> 138 | 139 |
{ 142 | setIsFilesVisible(!isFilesVisible); 143 | }} 144 | > 145 | 146 | 152 | {selectedFilePathnames?.length} 153 | 154 |
155 |
156 |
157 | 158 | 159 | {isFilesVisible && ( 160 | 165 | )} 166 | 167 |
168 | ); 169 | } 170 | -------------------------------------------------------------------------------- /components/data.ts: -------------------------------------------------------------------------------- 1 | export interface Order { 2 | id: string; 3 | name: string; 4 | orderedAt: string; 5 | image: string; 6 | } 7 | 8 | export const ORDERS: Order[] = [ 9 | { 10 | id: "539182", 11 | name: "Apple TV", 12 | orderedAt: "2024-08-25", 13 | image: "tv.png", 14 | }, 15 | { 16 | id: "281958", 17 | name: "Apple iPhone 14 Pro", 18 | orderedAt: "2024-08-24", 19 | image: "iphone.png", 20 | }, 21 | { 22 | id: "281958", 23 | name: "Apple Watch Ultra 2", 24 | orderedAt: "2024-08-26", 25 | image: "watch.png", 26 | }, 27 | ]; 28 | 29 | export interface TrackingInformation { 30 | orderId: string; 31 | progress: "Shipped" | "Out for Delivery" | "Delivered"; 32 | description: string; 33 | } 34 | 35 | export const TRACKING_INFORMATION = [ 36 | { 37 | orderId: "412093", 38 | progress: "Shipped", 39 | description: "Last Updated Today 4:30 PM", 40 | }, 41 | { 42 | orderId: "281958", 43 | progress: "Out for Delivery", 44 | description: "ETA Today 5:45 PM", 45 | }, 46 | { 47 | orderId: "539182", 48 | progress: "Delivered", 49 | description: "Delivered Today 3:00 PM", 50 | }, 51 | ]; 52 | 53 | export const getOrders = () => { 54 | return ORDERS; 55 | }; 56 | 57 | export const getTrackingInformation = ({ orderId }: { orderId: string }) => { 58 | return TRACKING_INFORMATION.find((info) => info.orderId === orderId); 59 | }; 60 | -------------------------------------------------------------------------------- /components/files.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import useSWR from "swr"; 4 | import { 5 | CheckedSquare, 6 | InfoIcon, 7 | LoaderIcon, 8 | TrashIcon, 9 | UncheckedSquare, 10 | UploadIcon, 11 | } from "./icons"; 12 | import { Dispatch, SetStateAction, useRef, useState } from "react"; 13 | import { fetcher } from "@/utils/functions"; 14 | import cx from "classnames"; 15 | import { motion } from "framer-motion"; 16 | import { useOnClickOutside, useWindowSize } from "usehooks-ts"; 17 | 18 | export const Files = ({ 19 | selectedFilePathnames, 20 | setSelectedFilePathnames, 21 | setIsFilesVisible, 22 | }: { 23 | selectedFilePathnames: string[]; 24 | setSelectedFilePathnames: Dispatch>; 25 | setIsFilesVisible: Dispatch>; 26 | }) => { 27 | const inputFileRef = useRef(null); 28 | const [uploadQueue, setUploadQueue] = useState>([]); 29 | const [deleteQueue, setDeleteQueue] = useState>([]); 30 | const { 31 | data: files, 32 | mutate, 33 | isLoading, 34 | } = useSWR< 35 | Array<{ 36 | pathname: string; 37 | }> 38 | >("api/files/list", fetcher, { 39 | fallbackData: [], 40 | }); 41 | 42 | const { width } = useWindowSize(); 43 | const isDesktop = width > 768; 44 | 45 | const drawerRef = useRef(null); 46 | useOnClickOutside([drawerRef], () => { 47 | setIsFilesVisible(false); 48 | }); 49 | 50 | return ( 51 | 57 | 77 |
78 |
79 |
80 | Manage Knowledge Base 81 |
82 |
83 | 84 | { 93 | const file = event.target.files![0]; 94 | 95 | if (file) { 96 | setUploadQueue((currentQueue) => [...currentQueue, file.name]); 97 | 98 | await fetch(`/api/files/upload?filename=${file.name}`, { 99 | method: "POST", 100 | body: file, 101 | }); 102 | 103 | setUploadQueue((currentQueue) => 104 | currentQueue.filter((filename) => filename !== file.name), 105 | ); 106 | 107 | mutate([...(files || []), { pathname: file.name }]); 108 | } 109 | }} 110 | /> 111 | 112 |
{ 115 | inputFileRef.current?.click(); 116 | }} 117 | > 118 | 119 |
Upload a file
120 |
121 |
122 | 123 |
124 | {isLoading ? ( 125 |
126 | {[44, 32, 52].map((item) => ( 127 |
131 |
132 |
135 |
136 |
137 | ))} 138 |
139 | ) : null} 140 | 141 | {!isLoading && 142 | files?.length === 0 && 143 | uploadQueue.length === 0 && 144 | deleteQueue.length === 0 ? ( 145 |
146 |
147 | 148 |
No files found
149 |
150 |
151 | ) : null} 152 | 153 | {files?.map((file: any) => ( 154 |
162 |
{ 165 | setSelectedFilePathnames((currentSelections) => { 166 | if (currentSelections.includes(file.pathname)) { 167 | return currentSelections.filter( 168 | (path) => path !== file.pathname, 169 | ); 170 | } else { 171 | return [...currentSelections, file.pathname]; 172 | } 173 | }); 174 | }} 175 | > 176 |
185 | {deleteQueue.includes(file.pathname) ? ( 186 |
187 | 188 |
189 | ) : selectedFilePathnames.includes(file.pathname) ? ( 190 | 191 | ) : ( 192 | 193 | )} 194 |
195 | 196 |
197 |
198 | {file.pathname} 199 |
200 |
201 |
202 | 203 |
{ 206 | setDeleteQueue((currentQueue) => [ 207 | ...currentQueue, 208 | file.pathname, 209 | ]); 210 | 211 | await fetch(`/api/files/delete?fileurl=${file.url}`, { 212 | method: "DELETE", 213 | }); 214 | 215 | setDeleteQueue((currentQueue) => 216 | currentQueue.filter( 217 | (filename) => filename !== file.pathname, 218 | ), 219 | ); 220 | 221 | setSelectedFilePathnames((currentSelections) => 222 | currentSelections.filter((path) => path !== file.pathname), 223 | ); 224 | 225 | mutate(files.filter((f) => f.pathname !== file.pathname)); 226 | }} 227 | > 228 | 229 |
230 |
231 | ))} 232 | 233 | {uploadQueue.map((fileName) => ( 234 |
238 |
239 |
240 | 241 |
242 |
243 | 244 |
245 |
246 | {fileName} 247 |
248 |
249 | 250 |
251 |
252 | ))} 253 |
254 | 255 |
256 |
257 | {`${selectedFilePathnames.length}/${files?.length}`} Selected 258 |
259 |
260 | 261 | 262 | ); 263 | }; 264 | -------------------------------------------------------------------------------- /components/form.tsx: -------------------------------------------------------------------------------- 1 | export function Form({ 2 | action, 3 | children, 4 | }: { 5 | action: any; 6 | children: React.ReactNode; 7 | }) { 8 | return ( 9 |
10 |
11 | 17 | 26 |
27 |
28 | 34 | 41 |
42 | {children} 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /components/history.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { motion, AnimatePresence } from "framer-motion"; 4 | import { InfoIcon, MenuIcon, PencilEditIcon } from "./icons"; 5 | import { useEffect, useState } from "react"; 6 | import useSWR from "swr"; 7 | import Link from "next/link"; 8 | import cx from "classnames"; 9 | import { useParams, usePathname } from "next/navigation"; 10 | import { Chat } from "@/schema"; 11 | import { fetcher } from "@/utils/functions"; 12 | 13 | export const History = () => { 14 | const { id } = useParams(); 15 | const pathname = usePathname(); 16 | 17 | const [isHistoryVisible, setIsHistoryVisible] = useState(false); 18 | const { 19 | data: history, 20 | error, 21 | isLoading, 22 | mutate, 23 | } = useSWR>("/api/history", fetcher, { 24 | fallbackData: [], 25 | }); 26 | 27 | useEffect(() => { 28 | mutate(); 29 | }, [pathname, mutate]); 30 | 31 | return ( 32 | <> 33 |
{ 36 | setIsHistoryVisible(true); 37 | }} 38 | > 39 | 40 |
41 | 42 | 43 | {isHistoryVisible && ( 44 | <> 45 | { 51 | setIsHistoryVisible(false); 52 | }} 53 | /> 54 | 55 | 62 |
63 |
64 |
History
65 |
66 | {history === undefined ? "loading" : history.length} chats 67 |
68 |
69 | 70 | { 74 | setIsHistoryVisible(false); 75 | }} 76 | > 77 | 78 | 79 |
80 | 81 |
82 | {error && error.status === 401 ? ( 83 |
84 | 85 |
Login to save and revisit previous chats!
86 |
87 | ) : null} 88 | 89 | {!isLoading && history?.length === 0 && !error ? ( 90 |
91 | 92 |
No chats found
93 |
94 | ) : null} 95 | 96 | {isLoading && !error ? ( 97 |
98 | {[44, 32, 28, 52].map((item) => ( 99 |
103 |
106 |
107 | ))} 108 |
109 | ) : null} 110 | 111 | {history && 112 | history.map((chat) => ( 113 | 123 | {chat.messages[0].content as string} 124 | 125 | ))} 126 |
127 | 128 | 129 | )} 130 | 131 | 132 | ); 133 | }; 134 | -------------------------------------------------------------------------------- /components/icons.tsx: -------------------------------------------------------------------------------- 1 | export const BotIcon = () => { 2 | return ( 3 | 10 | 16 | 17 | ); 18 | }; 19 | 20 | export const UserIcon = () => { 21 | return ( 22 | 30 | 36 | 37 | ); 38 | }; 39 | 40 | export const AttachmentIcon = () => { 41 | return ( 42 | 49 | 55 | 56 | ); 57 | }; 58 | 59 | export const VercelIcon = ({ size = 17 }) => { 60 | return ( 61 | 68 | 74 | 75 | ); 76 | }; 77 | 78 | export const GitIcon = () => { 79 | return ( 80 | 87 | 88 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | ); 102 | }; 103 | 104 | export const BoxIcon = ({ size = 16 }: { size: number }) => { 105 | return ( 106 | 113 | 119 | 120 | ); 121 | }; 122 | 123 | export const HomeIcon = ({ size = 16 }: { size: number }) => { 124 | return ( 125 | 132 | 138 | 139 | ); 140 | }; 141 | 142 | export const GPSIcon = ({ size = 16 }: { size: number }) => { 143 | return ( 144 | 151 | 159 | 160 | ); 161 | }; 162 | 163 | export const InvoiceIcon = ({ size = 16 }: { size: number }) => { 164 | return ( 165 | 172 | 178 | 179 | ); 180 | }; 181 | 182 | export const LogoOpenAI = ({ size = 16 }: { size?: number }) => { 183 | return ( 184 | 191 | 195 | 196 | ); 197 | }; 198 | 199 | export const LogoGoogle = ({ size = 16 }: { size?: number }) => { 200 | return ( 201 | 209 | 213 | 217 | 221 | 225 | 226 | ); 227 | }; 228 | 229 | export const LogoAnthropic = () => { 230 | return ( 231 | 241 | 245 | 246 | ); 247 | }; 248 | 249 | export const RouteIcon = ({ size = 16 }: { size?: number }) => { 250 | return ( 251 | 258 | 264 | 265 | ); 266 | }; 267 | 268 | export const FileIcon = ({ size = 16 }: { size?: number }) => { 269 | return ( 270 | 277 | 283 | 284 | ); 285 | }; 286 | 287 | export const LoaderIcon = ({ size = 16 }: { size?: number }) => { 288 | return ( 289 | 296 | 297 | 298 | 304 | 310 | 316 | 322 | 328 | 334 | 340 | 346 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | ); 360 | }; 361 | 362 | export const UploadIcon = ({ size = 16 }: { size?: number }) => { 363 | return ( 364 | 372 | 378 | 379 | ); 380 | }; 381 | 382 | export const MenuIcon = ({ size = 16 }: { size?: number }) => { 383 | return ( 384 | 391 | 397 | 398 | ); 399 | }; 400 | 401 | export const PencilEditIcon = ({ size = 16 }: { size?: number }) => { 402 | return ( 403 | 410 | 416 | 417 | ); 418 | }; 419 | 420 | export const CheckedSquare = ({ size = 16 }: { size?: number }) => { 421 | return ( 422 | 429 | 435 | 436 | ); 437 | }; 438 | 439 | export const UncheckedSquare = ({ size = 16 }: { size?: number }) => { 440 | return ( 441 | 448 | 457 | 458 | ); 459 | }; 460 | 461 | export const MoreIcon = ({ size = 16 }: { size?: number }) => { 462 | return ( 463 | 470 | 476 | 477 | ); 478 | }; 479 | 480 | export const TrashIcon = ({ size = 16 }: { size?: number }) => { 481 | return ( 482 | 489 | 495 | 496 | ); 497 | }; 498 | 499 | export const InfoIcon = ({ size = 16 }: { size?: number }) => { 500 | return ( 501 | 508 | 514 | 515 | ); 516 | }; 517 | -------------------------------------------------------------------------------- /components/markdown.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import React from "react"; 3 | import ReactMarkdown from "react-markdown"; 4 | import remarkGfm from "remark-gfm"; 5 | 6 | const NonMemoizedMarkdown = ({ children }: { children: string }) => { 7 | const components = { 8 | code: ({ node, inline, className, children, ...props }: any) => { 9 | const match = /language-(\w+)/.exec(className || ""); 10 | return !inline && match ? ( 11 |
15 |           {children}
16 |         
17 | ) : ( 18 | 22 | {children} 23 | 24 | ); 25 | }, 26 | ol: ({ node, children, ...props }: any) => { 27 | return ( 28 |
    29 | {children} 30 |
31 | ); 32 | }, 33 | li: ({ node, children, ...props }: any) => { 34 | return ( 35 |
  • 36 | {children} 37 |
  • 38 | ); 39 | }, 40 | ul: ({ node, children, ...props }: any) => { 41 | return ( 42 |
      43 | {children} 44 |
    45 | ); 46 | }, 47 | strong: ({ node, children, ...props }: any) => { 48 | return ( 49 | 50 | {children} 51 | 52 | ); 53 | }, 54 | a: ({ node, children, ...props }: any) => { 55 | return ( 56 | 62 | {children} 63 | 64 | ); 65 | }, 66 | }; 67 | 68 | return ( 69 | 70 | {children} 71 | 72 | ); 73 | }; 74 | 75 | export const Markdown = React.memo( 76 | NonMemoizedMarkdown, 77 | (prevProps, nextProps) => prevProps.children === nextProps.children, 78 | ); 79 | -------------------------------------------------------------------------------- /components/message.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { motion } from "framer-motion"; 4 | import { BotIcon, UserIcon } from "./icons"; 5 | import { ReactNode } from "react"; 6 | import { Markdown } from "./markdown"; 7 | 8 | export const Message = ({ 9 | role, 10 | content, 11 | }: { 12 | role: string; 13 | content: string | ReactNode; 14 | }) => { 15 | return ( 16 | 21 |
    22 | {role === "assistant" ? : } 23 |
    24 | 25 |
    26 |
    27 | {content as string} 28 |
    29 |
    30 |
    31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /components/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { auth, signOut } from "@/app/(auth)/auth"; 2 | import Link from "next/link"; 3 | import { History } from "./history"; 4 | 5 | export const Navbar = async () => { 6 | let session = await auth(); 7 | 8 | return ( 9 |
    10 |
    11 | 12 |
    13 | Internal Knowledge Base 14 |
    15 |
    16 | 17 | {session ? ( 18 |
    19 |
    20 | {session.user?.email} 21 |
    22 |
    23 |
    { 25 | "use server"; 26 | await signOut(); 27 | }} 28 | > 29 | 35 |
    36 |
    37 |
    38 | ) : ( 39 | 43 | Login 44 | 45 | )} 46 |
    47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /components/submit-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LoaderIcon } from "@/components/icons"; 4 | import { useFormStatus } from "react-dom"; 5 | 6 | export function SubmitButton({ children }: { children: React.ReactNode }) { 7 | const { pending } = useFormStatus(); 8 | 9 | return ( 10 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /components/use-scroll-to-bottom.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, RefObject } from "react"; 2 | 3 | export function useScrollToBottom(): [ 4 | RefObject, 5 | RefObject, 6 | ] { 7 | const containerRef = useRef(null); 8 | const endRef = useRef(null); 9 | 10 | useEffect(() => { 11 | const container = containerRef.current; 12 | const end = endRef.current; 13 | 14 | if (container && end) { 15 | const observer = new MutationObserver(() => { 16 | end.scrollIntoView({ behavior: "instant", block: "end" }); 17 | }); 18 | 19 | observer.observe(container, { 20 | childList: true, 21 | subtree: true, 22 | }); 23 | 24 | return () => observer.disconnect(); 25 | } 26 | }, []); 27 | 28 | return [containerRef, endRef]; 29 | } 30 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | import dotenv from "dotenv"; 3 | 4 | dotenv.config({ 5 | path: ".env.local", 6 | }); 7 | 8 | export default defineConfig({ 9 | schema: "./schema.ts", 10 | out: "./drizzle", 11 | dialect: "postgresql", 12 | dbCredentials: { 13 | url: process.env.POSTGRES_URL!, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /drizzle/0000_pretty_dracula.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "Chat" ( 2 | "id" text PRIMARY KEY NOT NULL, 3 | "createdAt" timestamp NOT NULL, 4 | "messages" json NOT NULL, 5 | "author" varchar(64) NOT NULL 6 | ); 7 | --> statement-breakpoint 8 | CREATE TABLE IF NOT EXISTS "Chunk" ( 9 | "id" text PRIMARY KEY NOT NULL, 10 | "filePath" text NOT NULL, 11 | "content" text NOT NULL, 12 | "embedding" real[] NOT NULL 13 | ); 14 | --> statement-breakpoint 15 | CREATE TABLE IF NOT EXISTS "User" ( 16 | "email" varchar(64) PRIMARY KEY NOT NULL, 17 | "password" varchar(64) 18 | ); 19 | --> statement-breakpoint 20 | DO $$ BEGIN 21 | ALTER TABLE "Chat" ADD CONSTRAINT "Chat_author_User_email_fk" FOREIGN KEY ("author") REFERENCES "public"."User"("email") ON DELETE no action ON UPDATE no action; 22 | EXCEPTION 23 | WHEN duplicate_object THEN null; 24 | END $$; 25 | -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "3f8197c3-143f-4add-b4d7-2c893c46de0f", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.Chat": { 8 | "name": "Chat", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "text", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "createdAt": { 18 | "name": "createdAt", 19 | "type": "timestamp", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "messages": { 24 | "name": "messages", 25 | "type": "json", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "author": { 30 | "name": "author", 31 | "type": "varchar(64)", 32 | "primaryKey": false, 33 | "notNull": true 34 | } 35 | }, 36 | "indexes": {}, 37 | "foreignKeys": { 38 | "Chat_author_User_email_fk": { 39 | "name": "Chat_author_User_email_fk", 40 | "tableFrom": "Chat", 41 | "tableTo": "User", 42 | "columnsFrom": [ 43 | "author" 44 | ], 45 | "columnsTo": [ 46 | "email" 47 | ], 48 | "onDelete": "no action", 49 | "onUpdate": "no action" 50 | } 51 | }, 52 | "compositePrimaryKeys": {}, 53 | "uniqueConstraints": {} 54 | }, 55 | "public.Chunk": { 56 | "name": "Chunk", 57 | "schema": "", 58 | "columns": { 59 | "id": { 60 | "name": "id", 61 | "type": "text", 62 | "primaryKey": true, 63 | "notNull": true 64 | }, 65 | "filePath": { 66 | "name": "filePath", 67 | "type": "text", 68 | "primaryKey": false, 69 | "notNull": true 70 | }, 71 | "content": { 72 | "name": "content", 73 | "type": "text", 74 | "primaryKey": false, 75 | "notNull": true 76 | }, 77 | "embedding": { 78 | "name": "embedding", 79 | "type": "real[]", 80 | "primaryKey": false, 81 | "notNull": true 82 | } 83 | }, 84 | "indexes": {}, 85 | "foreignKeys": {}, 86 | "compositePrimaryKeys": {}, 87 | "uniqueConstraints": {} 88 | }, 89 | "public.User": { 90 | "name": "User", 91 | "schema": "", 92 | "columns": { 93 | "email": { 94 | "name": "email", 95 | "type": "varchar(64)", 96 | "primaryKey": true, 97 | "notNull": true 98 | }, 99 | "password": { 100 | "name": "password", 101 | "type": "varchar(64)", 102 | "primaryKey": false, 103 | "notNull": false 104 | } 105 | }, 106 | "indexes": {}, 107 | "foreignKeys": {}, 108 | "compositePrimaryKeys": {}, 109 | "uniqueConstraints": {} 110 | } 111 | }, 112 | "enums": {}, 113 | "schemas": {}, 114 | "sequences": {}, 115 | "_meta": { 116 | "columns": {}, 117 | "schemas": {}, 118 | "tables": {} 119 | } 120 | } -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1726832743574, 9 | "tag": "0000_pretty_dracula", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import { authConfig } from "@/app/(auth)/auth.config"; 3 | 4 | export default NextAuth(authConfig).auth; 5 | 6 | export const config = { 7 | matcher: ["/", "/:id", "/api/:path*", "/login", "/register"], 8 | }; 9 | -------------------------------------------------------------------------------- /migrate.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/postgres-js"; 2 | import { migrate } from "drizzle-orm/postgres-js/migrator"; 3 | import postgres from "postgres"; 4 | import dotenv from "dotenv"; 5 | 6 | dotenv.config({ 7 | path: ".env.local", 8 | }); 9 | 10 | const runMigrate = async () => { 11 | if (!process.env.POSTGRES_URL) { 12 | throw new Error("POSTGRES_URL is not defined"); 13 | } 14 | 15 | const connection = postgres(process.env.POSTGRES_URL, { max: 1 }); 16 | const db = drizzle(connection); 17 | 18 | console.log("⏳ Running migrations..."); 19 | 20 | const start = Date.now(); 21 | await migrate(db, { migrationsFolder: "./drizzle" }); 22 | const end = Date.now(); 23 | 24 | console.log("✅ Migrations completed in", end - start, "ms"); 25 | process.exit(0); 26 | }; 27 | 28 | runMigrate().catch((err) => { 29 | console.error("❌ Migration failed"); 30 | console.error(err); 31 | process.exit(1); 32 | }); 33 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: {}, 4 | serverExternalPackages: ["pdf-parse"], 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ai-sdk-preview-internal-knowledge-base", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@ai-sdk/openai": "^1.0.10", 13 | "@langchain/textsplitters": "^0.0.3", 14 | "@vercel/analytics": "^1.3.1", 15 | "@vercel/blob": "^0.24.0", 16 | "@vercel/kv": "^2.0.0", 17 | "ai": "4.0.21", 18 | "bcrypt-ts": "^5.0.2", 19 | "classnames": "^2.5.1", 20 | "d3-scale": "^4.0.2", 21 | "date-fns": "^3.6.0", 22 | "dotenv": "^16.4.5", 23 | "drizzle-orm": "^0.33.0", 24 | "framer-motion": "^11.3.19", 25 | "next": "15.0.0-canary.152", 26 | "next-auth": "5.0.0-beta.21", 27 | "pdf-parse": "^1.1.1", 28 | "postgres": "^3.4.4", 29 | "react": "19.0.0-rc-7771d3a7-20240827", 30 | "react-dom": "19.0.0-rc-7771d3a7-20240827", 31 | "react-markdown": "^9.0.1", 32 | "remark-gfm": "^4.0.0", 33 | "sonner": "^1.5.0", 34 | "swr": "^2.2.5", 35 | "use-local-storage": "^3.0.0", 36 | "usehooks-ts": "^3.1.0", 37 | "zod": "^3.23.8" 38 | }, 39 | "devDependencies": { 40 | "@types/d3-scale": "^4.0.8", 41 | "@types/node": "^20", 42 | "@types/pdf-parse": "^1.1.4", 43 | "@types/react": "^18", 44 | "@types/react-dom": "^18", 45 | "drizzle-kit": "^0.24.2", 46 | "eslint": "^8", 47 | "eslint-config-next": "14.2.5", 48 | "postcss": "^8", 49 | "tailwindcss": "^3.4.1", 50 | "tsx": "^4.15.7", 51 | "typescript": "^5" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-internal-knowledge-base/f6230c77d6d0dbe3a5a5ea92eb791c6ecf9fbd73/public/iphone.png -------------------------------------------------------------------------------- /public/tv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-internal-knowledge-base/f6230c77d6d0dbe3a5a5ea92eb791c6ecf9fbd73/public/tv.png -------------------------------------------------------------------------------- /public/watch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-internal-knowledge-base/f6230c77d6d0dbe3a5a5ea92eb791c6ecf9fbd73/public/watch.png -------------------------------------------------------------------------------- /schema.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "ai"; 2 | import { InferSelectModel } from "drizzle-orm"; 3 | import { 4 | pgTable, 5 | varchar, 6 | text, 7 | real, 8 | timestamp, 9 | json, 10 | } from "drizzle-orm/pg-core"; 11 | 12 | export const user = pgTable("User", { 13 | email: varchar("email", { length: 64 }).primaryKey().notNull(), 14 | password: varchar("password", { length: 64 }), 15 | }); 16 | 17 | export const chat = pgTable("Chat", { 18 | id: text("id").primaryKey().notNull(), 19 | createdAt: timestamp("createdAt").notNull(), 20 | messages: json("messages").notNull(), 21 | author: varchar("author", { length: 64 }) 22 | .notNull() 23 | .references(() => user.email), 24 | }); 25 | 26 | export const chunk = pgTable("Chunk", { 27 | id: text("id").primaryKey().notNull(), 28 | filePath: text("filePath").notNull(), 29 | content: text("content").notNull(), 30 | embedding: real("embedding").array().notNull(), 31 | }); 32 | 33 | export type Chat = Omit, "messages"> & { 34 | messages: Array; 35 | }; 36 | 37 | export type Chunk = InferSelectModel; 38 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: {}, 11 | }, 12 | plugins: [], 13 | safelist: ["w-32", "w-44", "w-52"], 14 | }; 15 | export default config; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /utils/functions.ts: -------------------------------------------------------------------------------- 1 | interface ApplicationError extends Error { 2 | info: string; 3 | status: number; 4 | } 5 | 6 | export const fetcher = async (url: string) => { 7 | const res = await fetch(url); 8 | 9 | if (!res.ok) { 10 | const error = new Error( 11 | "An error occurred while fetching the data.", 12 | ) as ApplicationError; 13 | 14 | error.info = await res.json(); 15 | error.status = res.status; 16 | 17 | throw error; 18 | } 19 | 20 | return res.json(); 21 | }; 22 | 23 | export function getLocalStorage(key: string) { 24 | if (typeof window !== "undefined") { 25 | return JSON.parse(localStorage.getItem(key) || "[]"); 26 | } 27 | return []; 28 | } 29 | -------------------------------------------------------------------------------- /utils/pdf.ts: -------------------------------------------------------------------------------- 1 | import pdf from "pdf-parse"; 2 | 3 | export async function getPdfContentFromUrl(url: string): Promise { 4 | const response = await fetch(url); 5 | const arrayBuffer = await response.arrayBuffer(); 6 | const buffer = Buffer.from(arrayBuffer); 7 | const data = await pdf(buffer); 8 | return data.text; 9 | } 10 | --------------------------------------------------------------------------------