├── .env.example ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── app ├── (preview) │ ├── api │ │ └── chat │ │ │ └── route.ts │ ├── globals.css │ ├── layout.tsx │ ├── opengraph-image.png │ ├── page.tsx │ └── uncut-sans.woff2 └── favicon.ico ├── components.json ├── components ├── icons.tsx ├── project-overview.tsx └── ui │ ├── button.tsx │ ├── input.tsx │ └── label.tsx ├── drizzle.config.ts ├── lib ├── actions │ └── resources.ts ├── ai │ └── embedding.ts ├── db │ ├── index.ts │ ├── migrate.ts │ ├── migrations │ │ ├── 0000_yielding_bloodaxe.sql │ │ └── meta │ │ │ ├── 0000_snapshot.json │ │ │ └── _journal.json │ └── schema │ │ ├── embeddings.ts │ │ └── resources.ts ├── env.mjs └── utils.ts ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── next.svg └── vercel.svg ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgres://postgres:postgres@localhost:5432/{DB_NAME} 2 | OPENAI_API_KEY=sk*** 3 | -------------------------------------------------------------------------------- /.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 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Vercel Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI SDK RAG Template 2 | 3 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnicoalbanese%2Fai-sdk-rag-template&env=OPENAI_API_KEY&envDescription=You%20will%20need%20an%20OPENAI%20API%20Key.&project-name=ai-sdk-rag&repository-name=ai-sdk-rag&stores=%5B%7B%22type%22%3A%22postgres%22%7D%5D&skippable-integrations=1) 4 | 5 | A [Next.js](https://nextjs.org/) application, powered by the Vercel AI SDK, that uses retrieval-augmented generation (RAG) to reason and respond with information outside of the model's training data. 6 | 7 | ## Features 8 | 9 | - Information retrieval and addition through tool calls using the [`streamText`](https://sdk.vercel.ai/docs/reference/ai-sdk-core/stream-text) function 10 | - Real-time streaming of model responses to the frontend using the [`useChat`](https://sdk.vercel.ai/docs/reference/ai-sdk-ui/use-chat) hook 11 | - Vector embedding storage with [DrizzleORM](https://orm.drizzle.team/) and [PostgreSQL](https://www.postgresql.org/) 12 | - Animated UI with [Framer Motion](https://www.framer.com/motion/) 13 | 14 | ## Getting Started 15 | 16 | To get the project up and running, follow these steps: 17 | 18 | 1. Install dependencies: 19 | 20 | ```bash 21 | npm install 22 | ``` 23 | 24 | 2. Copy the example environment file: 25 | 26 | ```bash 27 | cp .env.example .env 28 | ``` 29 | 30 | 3. Add your OpenAI API key and PostgreSQL connection string to the `.env` file: 31 | 32 | ``` 33 | OPENAI_API_KEY=your_api_key_here 34 | DATABASE_URL=your_postgres_connection_string_here 35 | ``` 36 | 37 | 4. Migrate the database schema: 38 | 39 | ```bash 40 | npm run db:migrate 41 | ``` 42 | 43 | 5. Start the development server: 44 | ```bash 45 | npm run dev 46 | ``` 47 | 48 | Your project should now be running on [http://localhost:3000](http://localhost:3000). 49 | -------------------------------------------------------------------------------- /app/(preview)/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { createResource } from "@/lib/actions/resources"; 2 | import { findRelevantContent } from "@/lib/ai/embedding"; 3 | import { openai } from "@ai-sdk/openai"; 4 | import { generateObject, streamText, tool } from "ai"; 5 | import { z } from "zod"; 6 | 7 | // Allow streaming responses up to 30 seconds 8 | export const maxDuration = 30; 9 | 10 | export async function POST(req: Request) { 11 | const { messages } = await req.json(); 12 | 13 | const result = streamText({ 14 | model: openai("gpt-4o"), 15 | messages, 16 | system: `You are a helpful assistant acting as the users' second brain. 17 | Use tools on every request. 18 | Be sure to getInformation from your knowledge base before answering any questions. 19 | If the user presents infromation about themselves, use the addResource tool to store it. 20 | If a response requires multiple tools, call one tool after another without responding to the user. 21 | If a response requires information from an additional tool to generate a response, call the appropriate tools in order before responding to the user. 22 | ONLY respond to questions using information from tool calls. 23 | if no relevant information is found in the tool calls, respond, "Sorry, I don't know." 24 | Be sure to adhere to any instructions in tool calls ie. if they say to responsd like "...", do exactly that. 25 | If the relevant information is not a direct match to the users prompt, you can be creative in deducing the answer. 26 | Keep responses short and concise. Answer in a single sentence where possible. 27 | If you are unsure, use the getInformation tool and you can use common sense to reason based on the information you do have. 28 | Use your abilities as a reasoning machine to answer questions based on the information you do have. 29 | `, 30 | tools: { 31 | addResource: tool({ 32 | description: `add a resource to your knowledge base. 33 | If the user provides a random piece of knowledge unprompted, use this tool without asking for confirmation.`, 34 | parameters: z.object({ 35 | content: z 36 | .string() 37 | .describe("the content or resource to add to the knowledge base"), 38 | }), 39 | execute: async ({ content }) => createResource({ content }), 40 | }), 41 | getInformation: tool({ 42 | description: `get information from your knowledge base to answer questions.`, 43 | parameters: z.object({ 44 | question: z.string().describe("the users question"), 45 | similarQuestions: z.array(z.string()).describe("keywords to search"), 46 | }), 47 | execute: async ({ similarQuestions }) => { 48 | const results = await Promise.all( 49 | similarQuestions.map( 50 | async (question) => await findRelevantContent(question), 51 | ), 52 | ); 53 | // Flatten the array of arrays and remove duplicates based on 'name' 54 | const uniqueResults = Array.from( 55 | new Map(results.flat().map((item) => [item?.name, item])).values(), 56 | ); 57 | return uniqueResults; 58 | }, 59 | }), 60 | understandQuery: tool({ 61 | description: `understand the users query. use this tool on every prompt.`, 62 | parameters: z.object({ 63 | query: z.string().describe("the users query"), 64 | toolsToCallInOrder: z 65 | .array(z.string()) 66 | .describe( 67 | "these are the tools you need to call in the order necessary to respond to the users query", 68 | ), 69 | }), 70 | execute: async ({ query }) => { 71 | const { object } = await generateObject({ 72 | model: openai("gpt-4o"), 73 | system: 74 | "You are a query understanding assistant. Analyze the user query and generate similar questions.", 75 | schema: z.object({ 76 | questions: z 77 | .array(z.string()) 78 | .max(3) 79 | .describe("similar questions to the user's query. be concise."), 80 | }), 81 | prompt: `Analyze this query: "${query}". Provide the following: 82 | 3 similar questions that could help answer the user's query`, 83 | }); 84 | return object.questions; 85 | }, 86 | }), 87 | }, 88 | }); 89 | 90 | return result.toDataStreamResponse(); 91 | } 92 | -------------------------------------------------------------------------------- /app/(preview)/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 3.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 0 0% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 0 0% 3.9%; 15 | 16 | --primary: 0 0% 9%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | 22 | --muted: 0 0% 96.1%; 23 | --muted-foreground: 0 0% 45.1%; 24 | 25 | --accent: 0 0% 96.1%; 26 | --accent-foreground: 0 0% 9%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 0 0% 89.8%; 32 | --input: 0 0% 89.8%; 33 | --ring: 0 0% 3.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 0 0% 3.9%; 40 | --foreground: 0 0% 98%; 41 | 42 | --card: 0 0% 3.9%; 43 | --card-foreground: 0 0% 98%; 44 | 45 | --popover: 0 0% 3.9%; 46 | --popover-foreground: 0 0% 98%; 47 | 48 | --primary: 0 0% 98%; 49 | --primary-foreground: 0 0% 9%; 50 | 51 | --secondary: 0 0% 14.9%; 52 | --secondary-foreground: 0 0% 98%; 53 | 54 | --muted: 0 0% 14.9%; 55 | --muted-foreground: 0 0% 63.9%; 56 | 57 | --accent: 0 0% 14.9%; 58 | --accent-foreground: 0 0% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 0 0% 98%; 62 | 63 | --border: 0 0% 14.9%; 64 | --input: 0 0% 14.9%; 65 | --ring: 0 0% 83.1%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | 78 | .no-scrollbar-gutter { 79 | /* Remove the scrollbar gutter */ 80 | scrollbar-gutter: auto; 81 | 82 | /* Enable vertical scrolling */ 83 | overflow-y: scroll; 84 | 85 | /* Hide scrollbar for IE, Edge and Firefox */ 86 | /* -ms-overflow-style: none; /* IE and Edge */ 87 | /* scrollbar-width: none; /* Firefox */ 88 | } 89 | 90 | /* Hide scrollbar for Chrome, Safari and Opera */ 91 | .no-scrollbar-gutter::-webkit-scrollbar { 92 | display: none; 93 | } 94 | 95 | @font-face { 96 | font-family: "uncut sans"; 97 | src: url("./uncut-sans.woff2") format("woff2"); 98 | } 99 | 100 | * { 101 | font-family: "uncut sans", sans-serif; 102 | } 103 | 104 | #markdown a { 105 | @apply text-blue-500; 106 | } -------------------------------------------------------------------------------- /app/(preview)/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | 3 | import "./globals.css"; 4 | 5 | export const metadata: Metadata = { 6 | metadataBase: new URL("https://ai-sdk-preview-rag.vercel.app"), 7 | title: "Retrieval Augmented Generation Preview", 8 | description: 9 | "Augment language model generations with vector based retrieval using the Vercel AI SDK", 10 | }; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: Readonly<{ 15 | children: React.ReactNode; 16 | }>) { 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /app/(preview)/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-rag/6f3418b5482f9f01626701b4a18de959721eed13/app/(preview)/opengraph-image.png -------------------------------------------------------------------------------- /app/(preview)/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Input } from "@/components/ui/input"; 4 | import { Message } from "ai"; 5 | import { useChat } from "ai/react"; 6 | import { useEffect, useMemo, useState } from "react"; 7 | import { AnimatePresence, motion } from "framer-motion"; 8 | import ReactMarkdown, { Options } from "react-markdown"; 9 | import React from "react"; 10 | import ProjectOverview from "@/components/project-overview"; 11 | import { LoadingIcon } from "@/components/icons"; 12 | import { cn } from "@/lib/utils"; 13 | import { toast } from "sonner"; 14 | 15 | export default function Chat() { 16 | const [toolCall, setToolCall] = useState(); 17 | const { messages, input, handleInputChange, handleSubmit, isLoading } = 18 | useChat({ 19 | maxSteps: 4, 20 | onToolCall({ toolCall }) { 21 | setToolCall(toolCall.toolName); 22 | }, 23 | onError: (error) => { 24 | toast.error("You've been rate limited, please try again later!"); 25 | }, 26 | }); 27 | 28 | const [isExpanded, setIsExpanded] = useState(false); 29 | 30 | useEffect(() => { 31 | if (messages.length > 0) setIsExpanded(true); 32 | }, [messages]); 33 | 34 | const currentToolCall = useMemo(() => { 35 | const tools = messages?.slice(-1)[0]?.toolInvocations; 36 | if (tools && toolCall === tools[0].toolName) { 37 | return tools[0].toolName; 38 | } else { 39 | return undefined; 40 | } 41 | }, [toolCall, messages]); 42 | 43 | const awaitingResponse = useMemo(() => { 44 | if ( 45 | isLoading && 46 | currentToolCall === undefined && 47 | messages.slice(-1)[0].role === "user" 48 | ) { 49 | return true; 50 | } else { 51 | return false; 52 | } 53 | }, [isLoading, currentToolCall, messages]); 54 | 55 | const userQuery: Message | undefined = messages 56 | .filter((m) => m.role === "user") 57 | .slice(-1)[0]; 58 | 59 | const lastAssistantMessage: Message | undefined = messages 60 | .filter((m) => m.role !== "user") 61 | .slice(-1)[0]; 62 | 63 | return ( 64 |
65 |
66 | 67 | 83 |
84 |
85 | 93 |
94 | 100 | 101 | {awaitingResponse || currentToolCall ? ( 102 |
103 |
104 | {userQuery.content} 105 |
106 | 107 |
108 | ) : lastAssistantMessage ? ( 109 |
110 |
111 | {userQuery.content} 112 |
113 | 114 |
115 | ) : null} 116 |
117 |
118 |
119 |
120 |
121 |
122 | ); 123 | } 124 | 125 | const AssistantMessage = ({ message }: { message: Message | undefined }) => { 126 | if (message === undefined) return "HELLO"; 127 | 128 | return ( 129 | 130 | 138 | 141 | {message.content} 142 | 143 | 144 | 145 | ); 146 | }; 147 | 148 | const Loading = ({ tool }: { tool?: string }) => { 149 | const toolName = 150 | tool === "getInformation" 151 | ? "Getting information" 152 | : tool === "addResource" 153 | ? "Adding information" 154 | : "Thinking"; 155 | 156 | return ( 157 | 158 | 165 |
166 |
167 | 168 |
169 |
170 | {toolName}... 171 |
172 |
173 |
174 |
175 | ); 176 | }; 177 | 178 | const MemoizedReactMarkdown: React.FC = React.memo( 179 | ReactMarkdown, 180 | (prevProps, nextProps) => 181 | prevProps.children === nextProps.children && 182 | prevProps.className === nextProps.className, 183 | ); 184 | -------------------------------------------------------------------------------- /app/(preview)/uncut-sans.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-rag/6f3418b5482f9f01626701b4a18de959721eed13/app/(preview)/uncut-sans.woff2 -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-rag/6f3418b5482f9f01626701b4a18de959721eed13/app/favicon.ico -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /components/icons.tsx: -------------------------------------------------------------------------------- 1 | export const VercelIcon = ({ size = 17 }) => { 2 | return ( 3 | 10 | 16 | 17 | ); 18 | }; 19 | 20 | export const InformationIcon = ({ size = 17 }) => { 21 | return ( 22 | 29 | 35 | 36 | ); 37 | }; 38 | 39 | export const LoadingIcon = () => { 40 | return ( 41 | 48 | 49 | 50 | 56 | 62 | 68 | 74 | 80 | 86 | 92 | 98 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | ); 112 | }; 113 | -------------------------------------------------------------------------------- /components/project-overview.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { motion } from "framer-motion"; 3 | import Link from "next/link"; 4 | import { InformationIcon, VercelIcon } from "./icons"; 5 | 6 | const ProjectOverview = () => { 7 | return ( 8 | 14 |
15 |

16 | 17 | + 18 | 19 |

20 |

21 | The{" "} 22 | 26 | useChat 27 | {" "} 28 | hook along with the{" "} 29 | 33 | streamText 34 | {" "} 35 | function allows you to build applications with retrieval augmented 36 | generation (RAG) capabilities. Data is stored as vector embeddings 37 | using DrizzleORM and PostgreSQL. 38 |

39 |

40 | Learn how to build this project by following this{" "} 41 | 46 | guide 47 | 48 | . 49 |

50 |
51 |
52 | ); 53 | }; 54 | 55 | export default ProjectOverview; 56 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium 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", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | }, 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | import { env } from "@/lib/env.mjs"; 3 | 4 | export default { 5 | schema: "./lib/db/schema", 6 | dialect: "postgresql", 7 | out: "./lib/db/migrations", 8 | dbCredentials: { 9 | url: env.DATABASE_URL, 10 | } 11 | } satisfies Config; -------------------------------------------------------------------------------- /lib/actions/resources.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { 4 | NewResourceParams, 5 | insertResourceSchema, 6 | resources, 7 | } from "@/lib/db/schema/resources"; 8 | import { generateEmbeddings } from "../ai/embedding"; 9 | import { db } from "../db"; 10 | import { embeddings as embeddingsTable } from "../db/schema/embeddings"; 11 | 12 | export const createResource = async (input: NewResourceParams) => { 13 | try { 14 | const { content } = insertResourceSchema.parse(input); 15 | 16 | const [resource] = await db 17 | .insert(resources) 18 | .values({ content }) 19 | .returning(); 20 | 21 | const embeddings = await generateEmbeddings(content); 22 | await db.insert(embeddingsTable).values( 23 | embeddings.map((embedding) => ({ 24 | resourceId: resource.id, 25 | ...embedding, 26 | })), 27 | ); 28 | return "Resource successfully created and embedded."; 29 | } catch (error) { 30 | return error instanceof Error && error.message.length > 0 31 | ? error.message 32 | : "Error, please try again."; 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /lib/ai/embedding.ts: -------------------------------------------------------------------------------- 1 | import { embed, embedMany } from "ai"; 2 | import { openai } from "@ai-sdk/openai"; 3 | import { cosineDistance, desc, gt, sql } from "drizzle-orm"; 4 | import { embeddings } from "../db/schema/embeddings"; 5 | import { db } from "../db"; 6 | 7 | const embeddingModel = openai.embedding("text-embedding-ada-002"); 8 | 9 | const generateChunks = (input: string): string[] => { 10 | return input 11 | .trim() 12 | .split(".") 13 | .filter((i) => i !== ""); 14 | }; 15 | 16 | export const generateEmbeddings = async ( 17 | value: string, 18 | ): Promise> => { 19 | const chunks = generateChunks(value); 20 | const { embeddings } = await embedMany({ 21 | model: embeddingModel, 22 | values: chunks, 23 | }); 24 | return embeddings.map((e, i) => ({ content: chunks[i], embedding: e })); 25 | }; 26 | 27 | export const generateEmbedding = async (value: string): Promise => { 28 | const input = value.replaceAll("\n", " "); 29 | const { embedding } = await embed({ 30 | model: embeddingModel, 31 | value: input, 32 | }); 33 | return embedding; 34 | }; 35 | 36 | export const findRelevantContent = async (userQuery: string) => { 37 | const userQueryEmbedded = await generateEmbedding(userQuery); 38 | const similarity = sql`1 - (${cosineDistance(embeddings.embedding, userQueryEmbedded)})`; 39 | const similarGuides = await db 40 | .select({ name: embeddings.content, similarity }) 41 | .from(embeddings) 42 | .where(gt(similarity, 0.3)) 43 | .orderBy((t) => desc(t.similarity)) 44 | .limit(4); 45 | return similarGuides; 46 | }; 47 | -------------------------------------------------------------------------------- /lib/db/index.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/postgres-js"; 2 | import postgres from "postgres"; 3 | import { env } from "@/lib/env.mjs"; 4 | 5 | const client = postgres(env.DATABASE_URL!); 6 | export const db = drizzle(client); 7 | 8 | -------------------------------------------------------------------------------- /lib/db/migrate.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/lib/env.mjs"; 2 | 3 | import { drizzle } from "drizzle-orm/postgres-js"; 4 | import { migrate } from "drizzle-orm/postgres-js/migrator"; 5 | import postgres from "postgres"; 6 | import "dotenv/config"; 7 | 8 | const runMigrate = async () => { 9 | if (!env.DATABASE_URL) { 10 | throw new Error("DATABASE_URL is not defined"); 11 | } 12 | 13 | const connection = postgres(env.DATABASE_URL, { max: 1 }); 14 | 15 | const db = drizzle(connection); 16 | 17 | console.log("⏳ Running migrations..."); 18 | 19 | const start = Date.now(); 20 | 21 | await migrate(db, { migrationsFolder: "lib/db/migrations" }); 22 | 23 | const end = Date.now(); 24 | 25 | console.log("✅ Migrations completed in", end - start, "ms"); 26 | 27 | process.exit(0); 28 | }; 29 | 30 | runMigrate().catch((err) => { 31 | console.error("❌ Migration failed"); 32 | console.error(err); 33 | process.exit(1); 34 | }); 35 | -------------------------------------------------------------------------------- /lib/db/migrations/0000_yielding_bloodaxe.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS vector; 2 | 3 | CREATE TABLE IF NOT EXISTS "resources" ( 4 | "id" varchar(191) PRIMARY KEY NOT NULL, 5 | "content" text NOT NULL, 6 | "created_at" timestamp DEFAULT now() NOT NULL, 7 | "updated_at" timestamp DEFAULT now() NOT NULL 8 | ); 9 | 10 | CREATE TABLE IF NOT EXISTS "embeddings" ( 11 | "id" varchar(191) PRIMARY KEY NOT NULL, 12 | "resource_id" varchar(191), 13 | "content" text NOT NULL, 14 | "embedding" vector(1536) NOT NULL 15 | ); 16 | --> statement-breakpoint 17 | DO $$ BEGIN 18 | ALTER TABLE "embeddings" ADD CONSTRAINT "embeddings_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE cascade ON UPDATE no action; 19 | EXCEPTION 20 | WHEN duplicate_object THEN null; 21 | END $$; 22 | --> statement-breakpoint 23 | CREATE INDEX IF NOT EXISTS "embeddingIndex" ON "embeddings" USING hnsw ("embedding" vector_cosine_ops); -------------------------------------------------------------------------------- /lib/db/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "ab270498-0532-4efe-aac3-bb80ffc07ce2", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.resources": { 8 | "name": "resources", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "varchar(191)", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "content": { 18 | "name": "content", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "created_at": { 24 | "name": "created_at", 25 | "type": "timestamp", 26 | "primaryKey": false, 27 | "notNull": true, 28 | "default": "now()" 29 | }, 30 | "updated_at": { 31 | "name": "updated_at", 32 | "type": "timestamp", 33 | "primaryKey": false, 34 | "notNull": true, 35 | "default": "now()" 36 | } 37 | }, 38 | "indexes": {}, 39 | "foreignKeys": {}, 40 | "compositePrimaryKeys": {}, 41 | "uniqueConstraints": {} 42 | } 43 | }, 44 | "enums": {}, 45 | "schemas": {}, 46 | "_meta": { 47 | "columns": {}, 48 | "schemas": {}, 49 | "tables": {} 50 | } 51 | } -------------------------------------------------------------------------------- /lib/db/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | {"version":"7","dialect":"postgresql","entries":[{"idx":0,"version":"7","when":1719997972944,"tag":"0000_yielding_bloodaxe","breakpoints":true}]} -------------------------------------------------------------------------------- /lib/db/schema/embeddings.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from "@/lib/utils"; 2 | import { index, pgTable, text, varchar, vector } from "drizzle-orm/pg-core"; 3 | import { resources } from "./resources"; 4 | 5 | export const embeddings = pgTable( 6 | "embeddings", 7 | { 8 | id: varchar("id", { length: 191 }) 9 | .primaryKey() 10 | .$defaultFn(() => nanoid()), 11 | resourceId: varchar("resource_id", { length: 191 }).references( 12 | () => resources.id, 13 | { onDelete: "cascade" }, 14 | ), 15 | content: text("content").notNull(), 16 | embedding: vector("embedding", { dimensions: 1536 }).notNull(), 17 | }, 18 | (table) => ({ 19 | embeddingIndex: index("embeddingIndex").using( 20 | "hnsw", 21 | table.embedding.op("vector_cosine_ops"), 22 | ), 23 | }), 24 | ); 25 | -------------------------------------------------------------------------------- /lib/db/schema/resources.ts: -------------------------------------------------------------------------------- 1 | import { sql } from "drizzle-orm"; 2 | import { text, varchar, timestamp, pgTable } from "drizzle-orm/pg-core"; 3 | import { createSelectSchema } from "drizzle-zod"; 4 | import { z } from "zod"; 5 | 6 | import { nanoid } from "@/lib/utils"; 7 | 8 | export const resources = pgTable("resources", { 9 | id: varchar("id", { length: 191 }) 10 | .primaryKey() 11 | .$defaultFn(() => nanoid()), 12 | content: text("content").notNull(), 13 | 14 | createdAt: timestamp("created_at") 15 | .notNull() 16 | .default(sql`now()`), 17 | updatedAt: timestamp("updated_at") 18 | .notNull() 19 | .default(sql`now()`), 20 | }); 21 | 22 | // Schema for resources - used to validate API requests 23 | export const insertResourceSchema = createSelectSchema(resources) 24 | .extend({}) 25 | .omit({ 26 | id: true, 27 | createdAt: true, 28 | updatedAt: true, 29 | }); 30 | 31 | // Type for resources - used to type API request params and within Components 32 | export type NewResourceParams = z.infer; 33 | -------------------------------------------------------------------------------- /lib/env.mjs: -------------------------------------------------------------------------------- 1 | import { createEnv } from "@t3-oss/env-nextjs"; 2 | import { z } from "zod"; 3 | import "dotenv/config"; 4 | 5 | export const env = createEnv({ 6 | server: { 7 | NODE_ENV: z 8 | .enum(["development", "test", "production"]) 9 | .default("development"), 10 | DATABASE_URL: z.string().min(1), 11 | }, 12 | client: { 13 | // NEXT_PUBLIC_PUBLISHABLE_KEY: z.string().min(1), 14 | }, 15 | // If you're using Next.js < 13.4.4, you'll need to specify the runtimeEnv manually 16 | runtimeEnv: { 17 | DATABASE_URL: process.env.DATABASE_URL, 18 | // NEXT_PUBLIC_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY, 19 | }, 20 | // For Next.js >= 13.4.4, you only need to destructure client variables: 21 | experimental__runtimeEnv: { 22 | // NEXT_PUBLIC_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY, 23 | DATABASE_URL: process.env.DATABASE_URL, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from "nanoid"; 2 | import { clsx, type ClassValue } from "clsx"; 3 | import { twMerge } from "tailwind-merge"; 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)); 7 | } 8 | 9 | export const nanoid = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789"); 10 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-application", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "tsx lib/db/migrate.ts && next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "db:generate": "drizzle-kit generate", 11 | "db:migrate": "tsx lib/db/migrate.ts", 12 | "db:drop": "drizzle-kit drop", 13 | "db:pull": "drizzle-kit introspect", 14 | "db:push": "drizzle-kit push", 15 | "db:studio": "drizzle-kit studio", 16 | "db:check": "drizzle-kit check" 17 | }, 18 | "dependencies": { 19 | "@ai-sdk/openai": "^1.0.8", 20 | "@radix-ui/react-avatar": "^1.1.1", 21 | "@radix-ui/react-dropdown-menu": "^2.1.2", 22 | "@radix-ui/react-label": "^2.1.0", 23 | "@radix-ui/react-slot": "^1.1.0", 24 | "@t3-oss/env-nextjs": "^0.11.1", 25 | "ai": "^4.0.16", 26 | "class-variance-authority": "^0.7.1", 27 | "clsx": "^2.1.1", 28 | "drizzle-orm": "^0.38.1", 29 | "drizzle-zod": "^0.6.0", 30 | "framer-motion": "^11.14.1", 31 | "geist": "^1.3.1", 32 | "lucide-react": "^0.468.0", 33 | "nanoid": "^5.0.9", 34 | "next": "15.1.0", 35 | "postgres": "^3.4.5", 36 | "react": "^19.0.0", 37 | "react-dom": "^19.0.0", 38 | "react-markdown": "^9.0.1", 39 | "sonner": "^1.7.1", 40 | "tailwind-merge": "^2.5.5", 41 | "tailwindcss-animate": "^1.0.7", 42 | "zod": "^3.24.1" 43 | }, 44 | "devDependencies": { 45 | "@types/node": "^22.10.2", 46 | "@types/react": "^19.0.1", 47 | "@types/react-dom": "^19.0.2", 48 | "dotenv": "^16.4.7", 49 | "drizzle-kit": "^0.30.0", 50 | "eslint": "^9.16.0", 51 | "eslint-config-next": "15.1.0", 52 | "pg": "^8.13.1", 53 | "postcss": "^8.4.49", 54 | "tailwindcss": "^3.4.16", 55 | "tsx": "^4.19.2", 56 | "typescript": "^5.7.2" 57 | } 58 | } -------------------------------------------------------------------------------- /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/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | const { fontFamily } = require("tailwindcss/defaultTheme") 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | content: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"], 6 | theme: { 7 | container: { 8 | center: true, 9 | padding: "2rem", 10 | screens: { 11 | "2xl": "1400px", 12 | }, 13 | }, 14 | extend: { 15 | colors: { 16 | border: "hsl(var(--border))", 17 | input: "hsl(var(--input))", 18 | ring: "hsl(var(--ring))", 19 | background: "hsl(var(--background))", 20 | foreground: "hsl(var(--foreground))", 21 | primary: { 22 | DEFAULT: "hsl(var(--primary))", 23 | foreground: "hsl(var(--primary-foreground))", 24 | }, 25 | secondary: { 26 | DEFAULT: "hsl(var(--secondary))", 27 | foreground: "hsl(var(--secondary-foreground))", 28 | }, 29 | destructive: { 30 | DEFAULT: "hsl(var(--destructive))", 31 | foreground: "hsl(var(--destructive-foreground))", 32 | }, 33 | muted: { 34 | DEFAULT: "hsl(var(--muted))", 35 | foreground: "hsl(var(--muted-foreground))", 36 | }, 37 | accent: { 38 | DEFAULT: "hsl(var(--accent))", 39 | foreground: "hsl(var(--accent-foreground))", 40 | }, 41 | popover: { 42 | DEFAULT: "hsl(var(--popover))", 43 | foreground: "hsl(var(--popover-foreground))", 44 | }, 45 | card: { 46 | DEFAULT: "hsl(var(--card))", 47 | foreground: "hsl(var(--card-foreground))", 48 | }, 49 | }, 50 | borderRadius: { 51 | lg: `var(--radius)`, 52 | md: `calc(var(--radius) - 2px)`, 53 | sm: "calc(var(--radius) - 4px)", 54 | }, 55 | fontFamily: { 56 | sans: ["var(--font-sans)", ...fontFamily.sans], 57 | }, 58 | keyframes: { 59 | "accordion-down": { 60 | from: { height: 0 }, 61 | to: { height: "var(--radix-accordion-content-height)" }, 62 | }, 63 | "accordion-up": { 64 | from: { height: "var(--radix-accordion-content-height)" }, 65 | to: { height: 0 }, 66 | }, 67 | }, 68 | animation: { 69 | "accordion-down": "accordion-down 0.2s ease-out", 70 | "accordion-up": "accordion-up 0.2s ease-out", 71 | }, 72 | }, 73 | }, 74 | plugins: [require("tailwindcss-animate")], 75 | } 76 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "bundler", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "incremental": true, 19 | "plugins": [ 20 | { 21 | "name": "next" 22 | } 23 | ], 24 | "paths": { 25 | "@/*": [ 26 | "./*" 27 | ] 28 | }, 29 | "target": "esnext", 30 | "baseUrl": "./" 31 | }, 32 | "include": [ 33 | "next-env.d.ts", 34 | "**/*.ts", 35 | "**/*.tsx", 36 | ".next/types/**/*.ts" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } --------------------------------------------------------------------------------