├── .env.example ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── app ├── favicon.ico ├── globals.css ├── layout.tsx ├── opengraph-image.png └── page.tsx ├── components.json ├── components ├── card-grid-skeleton.tsx ├── deploy-button.tsx ├── error.tsx ├── image-card.tsx ├── image-search.tsx ├── loading-spinner.tsx ├── match-badge.tsx ├── no-images-found.tsx ├── search-box.tsx ├── suspended-image-search.tsx └── ui │ ├── alert.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── input.tsx │ └── skeleton.tsx ├── drizzle.config.ts ├── lib ├── ai │ ├── 0-upload.ts │ ├── 1-generate-metadata.ts │ ├── 2-embed-and-save.ts │ └── utils.ts ├── db │ ├── api.ts │ ├── index.ts │ └── schema.ts ├── hooks │ └── use-shared-transition.tsx └── utils.ts ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── public ├── next.svg └── vercel.svg ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY= 2 | 3 | BLOB_READ_WRITE_TOKEN= 4 | 5 | POSTGRES_URL= 6 | POSTGRES_PRISMA_URL= 7 | POSTGRES_URL_NO_SSL= 8 | POSTGRES_URL_NON_POOLING= 9 | POSTGRES_USER= 10 | POSTGRES_HOST= 11 | POSTGRES_PASSWORD= 12 | POSTGRES_DATABASE= 13 | 14 | KV_URL= 15 | KV_REST_API_URL= 16 | KV_REST_API_TOKEN= 17 | KV_REST_API_READ_ONLY_TOKEN= 18 | -------------------------------------------------------------------------------- /.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 | 39 | /images-to-index 40 | images-with-metadata.json 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Vercel, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Next.js 14 and App Router Semantic Search. 3 |

Semantic Image Search

4 |
5 | 6 |

7 | An open-source AI semantic image search app template built with Next.js, the Vercel AI SDK, OpenAI, Vercel Postgres, Vercel Blob and Vercel KV. 8 |

9 | 10 |

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

17 |
18 | 19 | ## Features 20 | 21 | - [Next.js](https://nextjs.org) App Router 22 | - React Server Components (RSCs), Suspense, and Server Actions 23 | - [Vercel AI SDK](https://sdk.vercel.ai/docs) for multimodal prompting, generating & embedding image metadata, and streaming images from Server to Client 24 | - Support for OpenAI (default), Gemini, Anthropic, Cohere, or custom AI chat models 25 | - [shadcn/ui](https://ui.shadcn.com) 26 | - Styling with [Tailwind CSS](https://tailwindcss.com) 27 | - [Radix UI](https://radix-ui.com) for headless component primitives 28 | - Query caching with [Vercel KV](https://vercel.com/storage/kv) 29 | - Embeddings powered by [Vercel Postgres](https://vercel.com/storage/kv), [pgvector](https://github.com/pgvector/pgvector-node#drizzle-orm), and [Drizzle ORM](https://orm.drizzle.team/) 30 | - File (image) storage with [Vercel Blob](https://vercel.com/storage/blob) 31 | 32 | ## Model Providers 33 | 34 | This template ships with OpenAI `GPT-4o` as the default. However, thanks to the [Vercel AI SDK](https://sdk.vercel.ai/docs), you can switch LLM providers to [Gemini](https://gemini.google.com/), [Anthropic](https://anthropic.com), [Cohere](https://cohere.com/), and [more](https://sdk.vercel.ai/providers/ai-sdk-providers) with just a few lines of code. 35 | 36 | ## Deploy Your Own 37 | 38 | You can deploy your own version of the Semantic Image Search App to Vercel with one click: 39 | 40 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fsemantic-image-search&env=OPENAI_API_KEY&envDescription=OpenAI%20key%20needed&envLink=https%3A%2F%2Fplatform.openai.com%2Fdocs%2Foverview) 41 | 42 | ## Setup 43 | ### Creating a KV Database Instance 44 | 45 | Follow the steps outlined in the [quick start guide](https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database) provided by Vercel. This guide will assist you in creating and configuring your KV database instance on Vercel, enabling your application to interact with it. 46 | 47 | Remember to update your environment variables (`KV_URL`, `KV_REST_API_URL`, `KV_REST_API_TOKEN`, `KV_REST_API_READ_ONLY_TOKEN`) in the `.env` file with the appropriate credentials provided during the KV database setup. 48 | 49 | ### Creating a Postgres Database Instance 50 | 51 | Follow the steps outlined in the [quick start guide](https://vercel.com/docs/storage/vercel-postgres/quickstart) provided by Vercel. This guide will assist you in creating and configuring your Postgres database instance on Vercel, enabling your application to interact with it. 52 | 53 | Once you have instantiated your Vercel Postgres instance, run the following code to enable `pgvector`: 54 | ```bash 55 | CREATE EXTENSION vector; 56 | ``` 57 | 58 | Remember to update your environment variables (`POSTGRES_URL`, `POSTGRES_PRISMA_URL`, `POSTGRES_URL_NON_POOLING`, `POSTGRES_USER`, `POSTGRES_HOST`, `POSTGRES_PASSWORD`, `POSTGRES_DATABASE`) in the `.env` file with the appropriate credentials provided during the Postgres database setup. 59 | 60 | ### Creating a Blob Instance 61 | 62 | Follow the steps outlined in the [quick start guide](https://vercel.com/docs/storage/vercel-blob) provided by Vercel. This guide will assist you in creating and configuring your Blob instance on Vercel, enabling your application to interact with it. 63 | 64 | Remember to update your environment variable (`BLOB_READ_WRITE_TOKEN`) in the `.env` file with the appropriate credentials provided during the Blob setup. 65 | 66 | 67 | ## Running locally 68 | 69 | You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js Semantic Image Search. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/projects/environment-variables) for this, but a `.env` file is all that is necessary. 70 | 71 | > Note: You should not commit your `.env` file or it will expose secrets that will allow others to control access to your various OpenAI and authentication provider accounts. 72 | 73 | 1. Install Vercel CLI: `npm i -g vercel` 74 | 2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link` 75 | 3. Download your environment variables: `vercel env pull` 76 | 77 | ```bash 78 | pnpm install 79 | ``` 80 | 81 | ## Add OpenAI API Key 82 | Be sure to add your OpenAI API Key to your `.env`. 83 | 84 | ## Database Setup 85 | To push your schema changes to your Vercel Postgres database, run the following command. 86 | ```bash 87 | pnpm run db:generate 88 | pnpm run db:push 89 | ``` 90 | 91 | ## Prepare your Images (Indexing Step) 92 | To get your application ready for Semantic search, you will have to complete three steps. 93 | 1. Upload Images to storage 94 | 2. Send Images to a Large Language Model to generate metadata (title, description) 95 | 3. Iterate over each image, embed the metadata, and then save to the database 96 | 97 | ### Upload Images 98 | Put the images you want to upload in the `images-to-index` directory (.jpg format) at the root of your application. Run the following command. 99 | ```bash 100 | pnpm run upload 101 | ``` 102 | This script will upload the images to your Vercel Blob store. 103 | Depending on how many photos you are uploading, this step could take a while. 104 | 105 | ### Generate Metadata 106 | Run the following command. 107 | ```bash 108 | pnpm run generate-metadata 109 | ``` 110 | This script will generate metadata for each of the images you uploaded in the previous step. 111 | Depending on how many photos you are uploading, this step could take a while. 112 | 113 | ### Embed Metadata and Save to Database 114 | Run the following command. 115 | ```bash 116 | pnpm run embed-and-save 117 | ``` 118 | Depending on how many photos you are uploading, this step could take a while. This script will embed the descriptions generated in the previous step and save them to your Vercel Postgres instance. 119 | 120 | ## Starting the Server 121 | Run the following command 122 | ```bash 123 | pnpm run dev 124 | ``` 125 | Your app template should now be running on [localhost:3000](http://localhost:3000/). 126 | 127 | ## Authors 128 | 129 | This library is created by [Vercel](https://vercel.com) and [Next.js](https://nextjs.org) team members, with contributions from: 130 | 131 | - Jared Palmer ([@jaredpalmer](https://twitter.com/jaredpalmer)) - [Vercel](https://vercel.com) 132 | - Shu Ding ([@shuding\_](https://twitter.com/shuding_)) - [Vercel](https://vercel.com) 133 | - shadcn ([@shadcn](https://twitter.com/shadcn)) - [Vercel](https://vercel.com) 134 | - Lars Grammel ([@lgrammel](https://twitter.com/lgrammel)) - [Vercel](https://vercel.com) 135 | - Nico Albanese ([@nicoalbanese10](https://twitter.com/nicoalbanese10)) - [Vercel](https://vercel.com) 136 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/semantic-image-search/a88d9f1d56e79e4eba027e8e875b339966aa4b60/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 240 10% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 240 10% 3.9%; 15 | 16 | --primary: 240 5.9% 10%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 240 4.8% 95.9%; 20 | --secondary-foreground: 240 5.9% 10%; 21 | 22 | --muted: 240 4.8% 95.9%; 23 | --muted-foreground: 240 3.8% 46.1%; 24 | 25 | --accent: 240 4.8% 95.9%; 26 | --accent-foreground: 240 5.9% 10%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 240 5.9% 90%; 32 | --input: 240 5.9% 90%; 33 | --ring: 240 10% 3.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 240 10% 3.9%; 40 | --foreground: 0 0% 98%; 41 | 42 | --card: 240 10% 3.9%; 43 | --card-foreground: 0 0% 98%; 44 | 45 | --popover: 240 10% 3.9%; 46 | --popover-foreground: 0 0% 98%; 47 | 48 | --primary: 0 0% 98%; 49 | --primary-foreground: 240 5.9% 10%; 50 | 51 | --secondary: 240 3.7% 15.9%; 52 | --secondary-foreground: 0 0% 98%; 53 | 54 | --muted: 240 3.7% 15.9%; 55 | --muted-foreground: 240 5% 64.9%; 56 | 57 | --accent: 240 3.7% 15.9%; 58 | --accent-foreground: 0 0% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 0 0% 98%; 62 | 63 | --border: 240 3.7% 15.9%; 64 | --input: 240 3.7% 15.9%; 65 | --ring: 240 4.9% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | 78 | body { 79 | overflow-y: scroll; 80 | } 81 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { GeistSans } from "geist/font/sans"; 3 | import "./globals.css"; 4 | import { cn } from "@/lib/utils"; 5 | import { TransitionProvider } from "@/lib/hooks/use-shared-transition"; 6 | 7 | export const metadata: Metadata = { 8 | title: "Semantic Image Search Demo", 9 | description: "Semantic Image Search Demo built with the Vercel AI SDK.", 10 | metadataBase: process.env.VERCEL_URL 11 | ? new URL(`https://${process.env.VERCEL_URL}`) 12 | : undefined, 13 | }; 14 | 15 | export default function RootLayout({ 16 | children, 17 | }: Readonly<{ 18 | children: React.ReactNode; 19 | }>) { 20 | return ( 21 | 22 | 23 | {children} 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/semantic-image-search/a88d9f1d56e79e4eba027e8e875b339966aa4b60/app/opengraph-image.png -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { CardGridSkeleton } from "@/components/card-grid-skeleton"; 2 | import { DeployButton } from "@/components/deploy-button"; 3 | import { SearchBox } from "@/components/search-box"; 4 | import { SuspendedImageSearch } from "@/components/suspended-image-search"; 5 | import Link from "next/link"; 6 | import { Suspense } from "react"; 7 | 8 | export default async function Home({ 9 | searchParams, 10 | }: { 11 | searchParams: Promise<{ q?: string }>; 12 | }) { 13 | const query = (await searchParams).q; 14 | return ( 15 |
16 |
17 |
18 |

Semantic Search

19 |
20 | 21 |
22 |
23 |

24 | This demo showcases how to use the{" "} 25 | 30 | AI SDK 31 | {" "} 32 | to build semantic search applications. Try searching for something 33 | semantically, like "tasty food". 34 |

35 |
36 |
37 |
38 | 39 |
40 | } key={query}> 41 | 42 | 43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/card-grid-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | 3 | export function CardGridSkeleton() { 4 | return ( 5 |
6 | {new Array(16).fill("").map((_, i) => ( 7 | 8 | ))} 9 |
10 | ); 11 | } 12 | 13 | export function SkeletonCard() { 14 | return ( 15 |
16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /components/deploy-button.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { buttonVariants } from "./ui/button"; 3 | 4 | export const DeployButton = () => { 5 | return ( 6 |
7 | 12 | 13 | Deploy to Vercel 14 | Deploy 15 | 16 |
17 | ); 18 | }; 19 | function IconVercel({ className, ...props }: React.ComponentProps<"svg">) { 20 | return ( 21 | 28 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /components/error.tsx: -------------------------------------------------------------------------------- 1 | import { AlertCircle } from "lucide-react"; 2 | 3 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 4 | 5 | export function ErrorComponent({ error }: { error: Error }) { 6 | return ( 7 | 8 | 9 | Error 10 | 11 | {error.message ?? "An error occured. Please try again later."} 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /components/image-card.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { DBImage } from "@/lib/db/schema"; 4 | import Image from "next/image"; 5 | import { MatchBadge } from "./match-badge"; 6 | import { Card } from "./ui/card"; 7 | 8 | export function ImageCard({ 9 | image, 10 | similarity, 11 | }: { 12 | image: DBImage; 13 | similarity?: number; 14 | }) { 15 | return ( 16 | 20 |
21 | View image 22 |
23 | {image.title} 30 |
31 |

{image.title}

32 |

33 | {image.description} 34 |

35 |

36 | Metadata Generated by GPT-4o 37 |

38 |
39 | {similarity ? ( 40 |
41 | 45 |
46 | ) : null} 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /components/image-search.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { ImageCard } from "./image-card"; 3 | import { DBImage } from "@/lib/db/schema"; 4 | import { NoImagesFound } from "./no-images-found"; 5 | import { useSharedTransition } from "@/lib/hooks/use-shared-transition"; 6 | import { CardGridSkeleton } from "./card-grid-skeleton"; 7 | 8 | export const ImageSearch = ({ 9 | images, 10 | query, 11 | }: { 12 | images: DBImage[]; 13 | query?: string; 14 | }) => { 15 | const { isPending } = useSharedTransition(); 16 | 17 | if (isPending) return ; 18 | 19 | if (images.length === 0) { 20 | return ; 21 | } 22 | 23 | return ; 24 | }; 25 | 26 | const ImageGrid = ({ images }: { images: DBImage[] }) => { 27 | return ( 28 |
29 | {images.map((image) => ( 30 | 35 | ))} 36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /components/loading-spinner.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * v0 by Vercel. 3 | * @see https://v0.dev/t/tm9EX5NO8KN 4 | * Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app 5 | */ 6 | "use client"; 7 | 8 | import { ImageStreamStatus } from "@/lib/utils"; 9 | 10 | export function LoadingSpinner({ status }: { status?: ImageStreamStatus }) { 11 | return ( 12 |
13 |
14 |
15 |

16 | Searching 17 | {status 18 | ? status?.regular 19 | ? " for direct matches" 20 | : " for semantic results" 21 | : ""} 22 | ... 23 |

24 |
25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /components/match-badge.tsx: -------------------------------------------------------------------------------- 1 | import { Badge } from "@/components/ui/badge"; 2 | 3 | export const MatchBadge = ({ 4 | type, 5 | similarity, 6 | }: { 7 | type: "direct" | "semantic"; 8 | similarity?: number; 9 | }) => { 10 | return ( 11 |
12 | {type === "semantic" ? ( 13 | <> 14 | 18 | Similarity: {similarity?.toFixed(3)} 19 | 20 | 24 | Semantic Match: {similarity?.toFixed(3)} 25 | 26 | 27 | ) : ( 28 | Direct Match 29 | )} 30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /components/no-images-found.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This code was generated by v0 by Vercel. 3 | * @see https://v0.dev/t/Q2jvX35BnWA 4 | * Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app 5 | */ 6 | 7 | /** Add fonts into your Next.js project: 8 | 9 | import { Inter } from 'next/font/google' 10 | 11 | inter({ 12 | subsets: ['latin'], 13 | display: 'swap', 14 | }) 15 | 16 | To read more about using these font, please visit the Next.js documentation: 17 | - App Directory: https://nextjs.org/docs/app/building-your-application/optimizing/fonts 18 | - Pages Directory: https://nextjs.org/docs/pages/building-your-application/optimizing/fonts 19 | **/ 20 | export function NoImagesFound({ query }: { query: string }) { 21 | return ( 22 |
23 |
24 |

25 | No images found 26 |

27 |

28 | There were no results (semantic or direct) found for the query ' 29 | {query}'. 30 |

31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /components/search-box.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | /** 3 | * This code was generated by v0 by Vercel. 4 | * @see https://v0.dev/t/GHXhZDO4KL4 5 | * Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app 6 | */ 7 | 8 | import { Input } from "@/components/ui/input"; 9 | import { usePathname, useRouter, useSearchParams } from "next/navigation"; 10 | import { SearchIcon } from "lucide-react"; 11 | import { useRef, useState } from "react"; 12 | import { Button } from "./ui/button"; 13 | import { X } from "lucide-react"; 14 | import { useDebouncedCallback } from "use-debounce"; 15 | import { useSharedTransition } from "@/lib/hooks/use-shared-transition"; 16 | 17 | export function SearchBox({ 18 | query, 19 | disabled, 20 | }: { 21 | query?: string | null; 22 | disabled?: boolean; 23 | }) { 24 | const { startTransition } = useSharedTransition(); 25 | const inputRef = useRef(null); 26 | const [isValid, setIsValid] = useState(true); 27 | 28 | const searchParams = useSearchParams(); 29 | const q = searchParams.get("q")?.toString() ?? ""; 30 | const pathname = usePathname(); 31 | 32 | const router = useRouter(); 33 | 34 | const handleSearch = useDebouncedCallback((term: string) => { 35 | const params = new URLSearchParams(searchParams); 36 | 37 | if (term) { 38 | params.set("q", term); 39 | } else { 40 | params.delete("q"); 41 | } 42 | startTransition && 43 | startTransition(() => { 44 | router.push(`${pathname}?${params.toString()}`); 45 | }); 46 | }, 300); 47 | 48 | const resetQuery = () => { 49 | startTransition && 50 | startTransition(() => { 51 | router.push("/"); 52 | if (inputRef.current) { 53 | inputRef.current.value = ""; 54 | inputRef.current?.focus(); 55 | } 56 | }); 57 | }; 58 | 59 | return ( 60 |
61 |
62 |
63 |
64 | 65 | { 71 | const newValue = e.target.value; 72 | if (newValue.length > 2) { 73 | setIsValid(true); 74 | handleSearch(newValue); 75 | } else if (newValue.length === 0) { 76 | handleSearch(newValue); 77 | setIsValid(false); 78 | } else { 79 | setIsValid(false); 80 | } 81 | }} 82 | className={ 83 | "text-base w-full pl-12 pr-4 py-3 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:focus:ring-blue-500" 84 | } 85 | placeholder="Search..." 86 | /> 87 | {q.length > 0 ? ( 88 | 97 | ) : null} 98 |
99 |
100 | {!isValid ? ( 101 |
102 | Query must be 3 characters or longer 103 |
104 | ) : ( 105 |
106 | )} 107 |
108 |
109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /components/suspended-image-search.tsx: -------------------------------------------------------------------------------- 1 | import { getImages } from "@/lib/db/api"; 2 | import { ErrorComponent } from "./error"; 3 | import { ImageSearch } from "./image-search"; 4 | 5 | export const SuspendedImageSearch = async ({ query }: { query?: string }) => { 6 | const { images, error } = await getImages(query); 7 | 8 | if (error) { 9 | return ; 10 | } 11 | 12 | return ; 13 | }; 14 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: "border-transparent bg-primary text-primary-foreground ", 12 | secondary: "border-transparent bg-secondary text-secondary-foreground ", 13 | destructive: 14 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 15 | outline: "text-foreground", 16 | }, 17 | }, 18 | defaultVariants: { 19 | variant: "default", 20 | }, 21 | }, 22 | ); 23 | 24 | export interface BadgeProps 25 | extends React.HTMLAttributes, 26 | VariantProps {} 27 | 28 | function Badge({ className, variant, ...props }: BadgeProps) { 29 | return ( 30 |
31 | ); 32 | } 33 | 34 | export { Badge, badgeVariants }; 35 | -------------------------------------------------------------------------------- /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/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /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/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | 3 | export default { 4 | schema: "./lib/db/schema.ts", 5 | out: "./lib/db/migrations", 6 | dialect: "postgresql", 7 | dbCredentials: { 8 | url: process.env.POSTGRES_URL!, 9 | }, 10 | } satisfies Config; 11 | -------------------------------------------------------------------------------- /lib/ai/0-upload.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import { getJpgFiles } from "./utils"; 3 | import { list, put } from "@vercel/blob"; 4 | import fs from "fs"; 5 | 6 | dotenv.config(); 7 | 8 | async function main() { 9 | const basePath = "images-to-index"; 10 | const files = await getJpgFiles(basePath); 11 | const { blobs } = await list(); 12 | 13 | for (const file of files) { 14 | const exists = blobs.some((blob) => blob.pathname === file); 15 | if (exists) { 16 | console.log(`File (${file}) already exists in Blob store`); 17 | continue; 18 | } 19 | const filePath = basePath + "/" + file; 20 | const fileContent = fs.readFileSync(filePath); 21 | 22 | console.clear(); 23 | console.log( 24 | `Uploading ${file} (${files.indexOf(file) + 1}/${files.length}) to Blob storage`, 25 | ); 26 | try { 27 | await put(file, fileContent, { access: "public" }); 28 | console.log(`Uploaded ${file}`); 29 | } catch (e) { 30 | console.error(e); 31 | } 32 | } 33 | console.log("All images uploaded!"); 34 | process.exit(0); 35 | } 36 | 37 | main().catch(console.error); 38 | -------------------------------------------------------------------------------- /lib/ai/1-generate-metadata.ts: -------------------------------------------------------------------------------- 1 | import { openai } from "@ai-sdk/openai"; 2 | import { generateObject } from "ai"; 3 | import dotenv from "dotenv"; 4 | import { z } from "zod"; 5 | import { ImageMetadata, writeAllMetadataToFile } from "./utils"; 6 | import { list } from "@vercel/blob"; 7 | 8 | dotenv.config(); 9 | 10 | async function main() { 11 | const blobs = await list(); 12 | const files = blobs.blobs.map((b) => b.url); 13 | 14 | console.log("files to process:\n", files); 15 | 16 | const images: ImageMetadata[] = []; 17 | 18 | for (const file of files) { 19 | console.clear(); 20 | console.log( 21 | `Generating description for ${file} (${files.indexOf(file) + 1}/${files.length})`, 22 | ); 23 | const result = await generateObject({ 24 | model: openai("gpt-4o"), 25 | schema: z.object({ 26 | image: z.object({ 27 | title: z.string().describe("an artistic title for the image"), 28 | description: z 29 | .string() 30 | .describe("A one sentence description of the image"), 31 | }), 32 | }), 33 | maxTokens: 512, 34 | messages: [ 35 | { 36 | role: "user", 37 | content: [ 38 | { type: "text", text: "Describe the image in detail." }, 39 | { 40 | type: "image", 41 | image: file, 42 | }, 43 | ], 44 | }, 45 | ], 46 | }); 47 | images.push({ path: file, metadata: result.object.image }); 48 | } 49 | await writeAllMetadataToFile(images, "images-with-metadata.json"); 50 | console.log("All images processed!"); 51 | } 52 | 53 | main().catch(console.error); 54 | -------------------------------------------------------------------------------- /lib/ai/2-embed-and-save.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import { embeddingModel, getMetadataFile } from "./utils"; 3 | import { embed } from "ai"; 4 | import { nanoid } from "nanoid"; 5 | import { drizzle } from "drizzle-orm/postgres-js"; 6 | import postgres from "postgres"; 7 | import { DBImage, dbImageSchema, images } from "../db/schema"; 8 | 9 | dotenv.config(); 10 | 11 | export const client = postgres(process.env.POSTGRES_URL!); 12 | export const db = drizzle(client); 13 | 14 | const saveImage = async (image: DBImage) => { 15 | try { 16 | const safeImage = dbImageSchema.parse(image); 17 | const [savedImage] = await db.insert(images).values(safeImage); 18 | return savedImage; 19 | } catch (e) { 20 | console.error(e); 21 | } 22 | }; 23 | 24 | async function main() { 25 | // read metadata json file 26 | const imagesWithMetadata = await getMetadataFile("images-with-metadata.json"); 27 | 28 | // map over it and embed each .metadata key 29 | for (const image of imagesWithMetadata) { 30 | console.clear(); 31 | console.log( 32 | `Generating embedding for ${image.path} (${imagesWithMetadata.indexOf(image) + 1}/${imagesWithMetadata.length})`, 33 | ); 34 | 35 | // create embedding 36 | const { embedding } = await embed({ 37 | model: embeddingModel, 38 | value: image.metadata.title + "\n" + image.metadata.description, 39 | }); 40 | // 41 | 42 | console.log( 43 | `Saving ${image.path} to the DB (${imagesWithMetadata.indexOf(image) + 1}/${imagesWithMetadata.length})`, 44 | ); 45 | // push to db 46 | try { 47 | await saveImage({ 48 | title: image.metadata.title, 49 | description: image.metadata.description, 50 | id: nanoid(), 51 | path: image.path, 52 | embedding, 53 | }); 54 | } catch (e) { 55 | console.error(e); 56 | } 57 | } 58 | console.log("Successfully embedded and saved all images!"); 59 | process.exit(0); 60 | } 61 | 62 | main().catch(console.error); 63 | -------------------------------------------------------------------------------- /lib/ai/utils.ts: -------------------------------------------------------------------------------- 1 | import { openai } from "@ai-sdk/openai"; 2 | import { embed } from "ai"; 3 | import fs from "fs"; 4 | import path from "path"; 5 | 6 | export type ImageMetadata = { 7 | path: string; 8 | metadata: { 9 | title: string; 10 | description: string; 11 | }; 12 | }; 13 | 14 | export const embeddingModel = openai.embedding("text-embedding-3-small"); 15 | 16 | /** 17 | * Asynchronously gets all `.jpg` files in the specified directory. 18 | * 19 | * @param dir The directory to search within. 20 | * @returns A promise that resolves to an array of filenames. 21 | */ 22 | export async function getJpgFiles(dir: string): Promise { 23 | try { 24 | const files = await fs.promises.readdir(dir); 25 | const jpgFiles = files.filter( 26 | (file) => path.extname(file).toLowerCase() === ".jpg", 27 | ); 28 | return jpgFiles; 29 | } catch (error) { 30 | console.error("Error reading directory:", error); 31 | throw error; // Re-throw the error for further handling if necessary 32 | } 33 | } 34 | 35 | /** 36 | * Writes all metadata to a single JSON file. 37 | * 38 | * @param metadataArray An array of metadata objects. 39 | * @param outputPath The path including filename to the output JSON file. 40 | */ 41 | export async function writeAllMetadataToFile( 42 | metadataArray: ImageMetadata[], 43 | outputPath: string, 44 | ) { 45 | try { 46 | await fs.promises.writeFile( 47 | outputPath, 48 | JSON.stringify(metadataArray, null, 2), 49 | ); 50 | console.log(`All metadata written to ${outputPath}`); 51 | } catch (error) { 52 | console.error("Error writing metadata to file:", error); 53 | throw error; 54 | } 55 | } 56 | 57 | export async function getMetadataFile(path: string): Promise { 58 | try { 59 | const rawFile = await fs.promises.readFile(path, { encoding: "utf-8" }); 60 | const file = JSON.parse(rawFile) as ImageMetadata[]; 61 | return file; 62 | } catch (error) { 63 | console.error("Error reading file:", error); 64 | throw error; // Re-throw the error for further handling if necessary 65 | } 66 | } 67 | 68 | export const generateEmbedding = async (value: string): Promise => { 69 | const input = value.replaceAll("\n", " "); 70 | const { embedding } = await embed({ 71 | model: embeddingModel, 72 | value: input, 73 | }); 74 | return embedding; 75 | }; 76 | -------------------------------------------------------------------------------- /lib/db/api.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { 4 | cosineDistance, 5 | desc, 6 | getTableColumns, 7 | gt, 8 | or, 9 | sql, 10 | } from "drizzle-orm"; 11 | import { db } from "."; 12 | import { DBImage, images } from "./schema"; 13 | import { generateEmbedding } from "../ai/utils"; 14 | import { kv } from "@vercel/kv"; 15 | 16 | const { embedding: _, ...rest } = getTableColumns(images); 17 | const imagesWithoutEmbedding = { 18 | ...rest, 19 | embedding: sql`ARRAY[]::integer[]`, 20 | }; 21 | 22 | export const findSimilarContent = async (description: string) => { 23 | const embedding = await generateEmbedding(description); 24 | const similarity = sql`1 - (${cosineDistance(images.embedding, embedding)})`; 25 | const similarGuides = await db 26 | .select({ image: imagesWithoutEmbedding, similarity }) 27 | .from(images) 28 | .where(gt(similarity, 0.28)) // experiment with this value based on your embedding model 29 | .orderBy((t) => desc(t.similarity)) 30 | .limit(10); 31 | 32 | return similarGuides; 33 | }; 34 | 35 | export const findImageByQuery = async (query: string) => { 36 | const result = await db 37 | .select({ image: imagesWithoutEmbedding, similarity: sql`1` }) 38 | .from(images) 39 | .where( 40 | or( 41 | sql`title ILIKE ${"%" + query + "%"}`, 42 | sql`description ILIKE ${"%" + query + "%"}`, 43 | ), 44 | ); 45 | return result; 46 | }; 47 | 48 | function uniqueItemsByObject(items: DBImage[]): DBImage[] { 49 | const seenObjects = new Set(); 50 | const uniqueItems: DBImage[] = []; 51 | 52 | for (const item of items) { 53 | if (!seenObjects.has(item.title)) { 54 | seenObjects.add(item.title); 55 | uniqueItems.push(item); 56 | } 57 | } 58 | 59 | return uniqueItems; 60 | } 61 | 62 | export const getImages = async ( 63 | query?: string, 64 | ): Promise<{ images: DBImage[]; error?: Error }> => { 65 | try { 66 | const formattedQuery = query 67 | ? "q:" + query?.replaceAll(" ", "_") 68 | : "all_images"; 69 | 70 | const cached = await kv.get(formattedQuery); 71 | if (cached) { 72 | return { images: cached }; 73 | } else { 74 | if (query === undefined || query.length < 3) { 75 | const allImages = await db 76 | .select(imagesWithoutEmbedding) 77 | .from(images) 78 | .limit(20); 79 | await kv.set("all_images", JSON.stringify(allImages)); 80 | return { images: allImages }; 81 | } else { 82 | const directMatches = await findImageByQuery(query); 83 | const semanticMatches = await findSimilarContent(query); 84 | const allMatches = uniqueItemsByObject( 85 | [...directMatches, ...semanticMatches].map((image) => ({ 86 | ...image.image, 87 | similarity: image.similarity, 88 | })), 89 | ); 90 | 91 | await kv.set(formattedQuery, JSON.stringify(allMatches)); 92 | return { images: allMatches }; 93 | } 94 | } 95 | } catch (e) { 96 | if (e instanceof Error) return { error: e, images: [] }; 97 | return { 98 | images: [], 99 | error: { message: "Error, please try again." } as Error, 100 | }; 101 | } 102 | }; 103 | -------------------------------------------------------------------------------- /lib/db/index.ts: -------------------------------------------------------------------------------- 1 | import { sql } from "@vercel/postgres"; 2 | import { drizzle } from "drizzle-orm/vercel-postgres"; 3 | 4 | export const db = drizzle(sql); 5 | -------------------------------------------------------------------------------- /lib/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { varchar, index, pgTable, vector, text } from "drizzle-orm/pg-core"; 2 | import { nanoid } from "nanoid"; 3 | import { z } from "zod"; 4 | 5 | export const images = pgTable( 6 | "images", 7 | { 8 | id: varchar("id", { length: 191 }) 9 | .primaryKey() 10 | .$defaultFn(() => nanoid()), 11 | title: text("title").notNull(), 12 | description: text("description").notNull(), 13 | path: text("path").notNull(), 14 | embedding: vector("embedding", { dimensions: 1536 }).notNull(), 15 | }, 16 | (table) => ({ 17 | embeddingIndex: index("embeddingIndex").using( 18 | "hnsw", 19 | table.embedding.op("vector_cosine_ops"), 20 | ), 21 | }), 22 | ); 23 | 24 | export const dbImageSchema = z.object({ 25 | id: z.string(), 26 | embedding: z.array(z.number()), 27 | title: z.string(), 28 | path: z.string(), 29 | description: z.string(), 30 | similarity: z.number().optional(), 31 | }); 32 | 33 | export type DBImage = z.infer; 34 | -------------------------------------------------------------------------------- /lib/hooks/use-shared-transition.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { createContext, useContext, useTransition } from "react"; 4 | 5 | const defaultValue: { 6 | isPending: boolean; 7 | startTransition?: React.TransitionStartFunction; 8 | } = { isPending: false }; 9 | const TransitionContext = createContext(defaultValue); 10 | 11 | export const TransitionProvider = ({ 12 | children, 13 | }: { 14 | children: React.ReactNode; 15 | }) => { 16 | const [isPending, startTransition] = useTransition(); 17 | 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | }; 24 | 25 | export const useSharedTransition = () => useContext(TransitionContext); 26 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | export type ImageStreamStatus = { 9 | regular: boolean; 10 | semantic: boolean; 11 | }; 12 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: 'https', 7 | hostname: 'bodo0tgbs4falkp7.public.blob.vercel-storage.com', 8 | port: '', 9 | }, 10 | ], 11 | minimumCacheTTL: 60, 12 | }, 13 | }; 14 | 15 | export default nextConfig; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "semantic-image-search", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "start": "next start", 8 | "lint": "next lint", 9 | "db:generate": "drizzle-kit generate", 10 | "db:push": "drizzle-kit push", 11 | "db:studio": "drizzle-kit studio", 12 | "upload": "tsx lib/ai/0-upload.ts", 13 | "generate-metadata": "tsx lib/ai/1-generate-metadata.ts", 14 | "embed-and-save": "tsx lib/ai/2-embed-and-save.ts" 15 | }, 16 | "dependencies": { 17 | "@ai-sdk/openai": "^1.2.1", 18 | "@radix-ui/react-slot": "^1.1.0", 19 | "@vercel/blob": "^0.27.0", 20 | "@vercel/kv": "^3.0.0", 21 | "@vercel/postgres": "^0.10.0", 22 | "ai": "^4.1.54", 23 | "class-variance-authority": "^0.7.1", 24 | "clsx": "^2.1.1", 25 | "drizzle-orm": "^0.38.1", 26 | "geist": "^1.3.1", 27 | "lucide-react": "^0.468.0", 28 | "nanoid": "^5.0.9", 29 | "next": "15.1.0", 30 | "postgres": "^3.4.5", 31 | "react": "^19.0.0", 32 | "react-dom": "^19.0.0", 33 | "sharp": "^0.33.5", 34 | "tailwind-merge": "^2.5.5", 35 | "tailwindcss-animate": "^1.0.7", 36 | "use-debounce": "^10.0.4", 37 | "zod": "^3.24.1" 38 | }, 39 | "devDependencies": { 40 | "@types/lodash": "^4.17.13", 41 | "@types/node": "^22.10.2", 42 | "@types/react": "^19.0.1", 43 | "@types/react-dom": "^19.0.2", 44 | "dotenv": "^16.4.7", 45 | "drizzle-kit": "^0.30.0", 46 | "eslint": "^9.16.0", 47 | "eslint-config-next": "15.1.0", 48 | "postcss": "^8.4.49", 49 | "tailwindcss": "^3.4.16", 50 | "tsx": "^4.19.2", 51 | "typescript": "^5.7.2" 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/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss" 2 | 3 | const config = { 4 | darkMode: ["class"], 5 | content: [ 6 | './pages/**/*.{ts,tsx}', 7 | './components/**/*.{ts,tsx}', 8 | './app/**/*.{ts,tsx}', 9 | './src/**/*.{ts,tsx}', 10 | ], 11 | prefix: "", 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: "2rem", 16 | screens: { 17 | "2xl": "1400px", 18 | }, 19 | }, 20 | extend: { 21 | colors: { 22 | border: "hsl(var(--border))", 23 | input: "hsl(var(--input))", 24 | ring: "hsl(var(--ring))", 25 | background: "hsl(var(--background))", 26 | foreground: "hsl(var(--foreground))", 27 | primary: { 28 | DEFAULT: "hsl(var(--primary))", 29 | foreground: "hsl(var(--primary-foreground))", 30 | }, 31 | secondary: { 32 | DEFAULT: "hsl(var(--secondary))", 33 | foreground: "hsl(var(--secondary-foreground))", 34 | }, 35 | destructive: { 36 | DEFAULT: "hsl(var(--destructive))", 37 | foreground: "hsl(var(--destructive-foreground))", 38 | }, 39 | muted: { 40 | DEFAULT: "hsl(var(--muted))", 41 | foreground: "hsl(var(--muted-foreground))", 42 | }, 43 | accent: { 44 | DEFAULT: "hsl(var(--accent))", 45 | foreground: "hsl(var(--accent-foreground))", 46 | }, 47 | popover: { 48 | DEFAULT: "hsl(var(--popover))", 49 | foreground: "hsl(var(--popover-foreground))", 50 | }, 51 | card: { 52 | DEFAULT: "hsl(var(--card))", 53 | foreground: "hsl(var(--card-foreground))", 54 | }, 55 | }, 56 | borderRadius: { 57 | lg: "var(--radius)", 58 | md: "calc(var(--radius) - 2px)", 59 | sm: "calc(var(--radius) - 4px)", 60 | }, 61 | keyframes: { 62 | "accordion-down": { 63 | from: { height: "0" }, 64 | to: { height: "var(--radix-accordion-content-height)" }, 65 | }, 66 | "accordion-up": { 67 | from: { height: "var(--radix-accordion-content-height)" }, 68 | to: { height: "0" }, 69 | }, 70 | }, 71 | animation: { 72 | "accordion-down": "accordion-down 0.2s ease-out", 73 | "accordion-up": "accordion-up 0.2s ease-out", 74 | }, 75 | }, 76 | }, 77 | plugins: [require("tailwindcss-animate")], 78 | } satisfies Config 79 | 80 | export default config -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | --------------------------------------------------------------------------------