Repository: vercel-labs/semantic-image-search Branch: main Commit: 94eaed28799e Files: 39 Total size: 45.3 KB Directory structure: gitextract_opj8cugc/ ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── app/ │ ├── globals.css │ ├── layout.tsx │ └── page.tsx ├── 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 ├── components.json ├── 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 ├── tailwind.config.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.json ================================================ { "extends": "next/core-web-vitals" } ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js .yarn/install-state.gz # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env*.local .env # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts /images-to-index images-with-metadata.json ================================================ FILE: LICENSE ================================================ Copyright 2024 Vercel, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ Next.js 14 and App Router Semantic Search.

Semantic Image Search

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.

Features · Model Providers · Deploy Your Own · Running locally · Authors


## Features - [Next.js](https://nextjs.org) App Router - React Server Components (RSCs), Suspense, and Server Actions - [Vercel AI SDK](https://sdk.vercel.ai/docs) for multimodal prompting, generating & embedding image metadata, and streaming images from Server to Client - Support for OpenAI (default), Gemini, Anthropic, Cohere, or custom AI chat models - [shadcn/ui](https://ui.shadcn.com) - Styling with [Tailwind CSS](https://tailwindcss.com) - [Radix UI](https://radix-ui.com) for headless component primitives - Query caching with [Vercel KV](https://vercel.com/storage/kv) - 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/) - File (image) storage with [Vercel Blob](https://vercel.com/storage/blob) ## Model Providers 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. ## Deploy Your Own You can deploy your own version of the Semantic Image Search App to Vercel with one click: [![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) ## Setup ### Creating a KV Database Instance 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. 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. ### Creating a Postgres Database Instance 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. Once you have instantiated your Vercel Postgres instance, run the following code to enable `pgvector`: ```bash CREATE EXTENSION vector; ``` 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. ### Creating a Blob Instance 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. Remember to update your environment variable (`BLOB_READ_WRITE_TOKEN`) in the `.env` file with the appropriate credentials provided during the Blob setup. ## Running locally 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. > 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. 1. Install Vercel CLI: `npm i -g vercel` 2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link` 3. Download your environment variables: `vercel env pull` ```bash pnpm install ``` ## Add OpenAI API Key Be sure to add your OpenAI API Key to your `.env`. ## Database Setup To push your schema changes to your Vercel Postgres database, run the following command. ```bash pnpm run db:generate pnpm run db:push ``` ## Prepare your Images (Indexing Step) To get your application ready for Semantic search, you will have to complete three steps. 1. Upload Images to storage 2. Send Images to a Large Language Model to generate metadata (title, description) 3. Iterate over each image, embed the metadata, and then save to the database ### Upload Images 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. ```bash pnpm run upload ``` This script will upload the images to your Vercel Blob store. Depending on how many photos you are uploading, this step could take a while. ### Generate Metadata Run the following command. ```bash pnpm run generate-metadata ``` This script will generate metadata for each of the images you uploaded in the previous step. Depending on how many photos you are uploading, this step could take a while. ### Embed Metadata and Save to Database Run the following command. ```bash pnpm run embed-and-save ``` 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. ## Starting the Server Run the following command ```bash pnpm run dev ``` Your app template should now be running on [localhost:3000](http://localhost:3000/). ## Authors This library is created by [Vercel](https://vercel.com) and [Next.js](https://nextjs.org) team members, with contributions from: - Jared Palmer ([@jaredpalmer](https://twitter.com/jaredpalmer)) - [Vercel](https://vercel.com) - Shu Ding ([@shuding\_](https://twitter.com/shuding_)) - [Vercel](https://vercel.com) - shadcn ([@shadcn](https://twitter.com/shadcn)) - [Vercel](https://vercel.com) - Lars Grammel ([@lgrammel](https://twitter.com/lgrammel)) - [Vercel](https://vercel.com) - Nico Albanese ([@nicoalbanese10](https://twitter.com/nicoalbanese10)) - [Vercel](https://vercel.com) ================================================ FILE: app/globals.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --background: 0 0% 100%; --foreground: 240 10% 3.9%; --card: 0 0% 100%; --card-foreground: 240 10% 3.9%; --popover: 0 0% 100%; --popover-foreground: 240 10% 3.9%; --primary: 240 5.9% 10%; --primary-foreground: 0 0% 98%; --secondary: 240 4.8% 95.9%; --secondary-foreground: 240 5.9% 10%; --muted: 240 4.8% 95.9%; --muted-foreground: 240 3.8% 46.1%; --accent: 240 4.8% 95.9%; --accent-foreground: 240 5.9% 10%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; --border: 240 5.9% 90%; --input: 240 5.9% 90%; --ring: 240 10% 3.9%; --radius: 0.5rem; } .dark { --background: 240 10% 3.9%; --foreground: 0 0% 98%; --card: 240 10% 3.9%; --card-foreground: 0 0% 98%; --popover: 240 10% 3.9%; --popover-foreground: 0 0% 98%; --primary: 0 0% 98%; --primary-foreground: 240 5.9% 10%; --secondary: 240 3.7% 15.9%; --secondary-foreground: 0 0% 98%; --muted: 240 3.7% 15.9%; --muted-foreground: 240 5% 64.9%; --accent: 240 3.7% 15.9%; --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 0 0% 98%; --border: 240 3.7% 15.9%; --input: 240 3.7% 15.9%; --ring: 240 4.9% 83.9%; } } @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; } } body { overflow-y: scroll; } ================================================ FILE: app/layout.tsx ================================================ import type { Metadata } from "next"; import { GeistSans } from "geist/font/sans"; import "./globals.css"; import { cn } from "@/lib/utils"; import { TransitionProvider } from "@/lib/hooks/use-shared-transition"; export const metadata: Metadata = { title: "Semantic Image Search Demo", description: "Semantic Image Search Demo built with the Vercel AI SDK.", metadataBase: process.env.VERCEL_URL ? new URL(`https://${process.env.VERCEL_URL}`) : undefined, }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( {children} ); } ================================================ FILE: app/page.tsx ================================================ import { CardGridSkeleton } from "@/components/card-grid-skeleton"; import { DeployButton } from "@/components/deploy-button"; import { SearchBox } from "@/components/search-box"; import { SuspendedImageSearch } from "@/components/suspended-image-search"; import Link from "next/link"; import { Suspense } from "react"; export default async function Home({ searchParams, }: { searchParams: Promise<{ q?: string }>; }) { const query = (await searchParams).q; return (

Semantic Search

This demo showcases how to use the{" "} AI SDK {" "} to build semantic search applications. Try searching for something semantically, like "tasty food".

} key={query}>
); } ================================================ FILE: components/card-grid-skeleton.tsx ================================================ import { Skeleton } from "@/components/ui/skeleton"; export function CardGridSkeleton() { return (
{new Array(16).fill("").map((_, i) => ( ))}
); } export function SkeletonCard() { return (
); } ================================================ FILE: components/deploy-button.tsx ================================================ import { cn } from "@/lib/utils"; import { buttonVariants } from "./ui/button"; export const DeployButton = () => { return (
Deploy to Vercel Deploy
); }; function IconVercel({ className, ...props }: React.ComponentProps<"svg">) { return ( ); } ================================================ FILE: components/error.tsx ================================================ import { AlertCircle } from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; export function ErrorComponent({ error }: { error: Error }) { return ( Error {error.message ?? "An error occured. Please try again later."} ); } ================================================ FILE: components/image-card.tsx ================================================ "use client"; import { DBImage } from "@/lib/db/schema"; import Image from "next/image"; import { MatchBadge } from "./match-badge"; import { Card } from "./ui/card"; export function ImageCard({ image, similarity, }: { image: DBImage; similarity?: number; }) { return (
View image
{image.title}

{image.title}

{image.description}

Metadata Generated by GPT-4o

{similarity ? (
) : null}
); } ================================================ FILE: components/image-search.tsx ================================================ "use client"; import { ImageCard } from "./image-card"; import { DBImage } from "@/lib/db/schema"; import { NoImagesFound } from "./no-images-found"; import { useSharedTransition } from "@/lib/hooks/use-shared-transition"; import { CardGridSkeleton } from "./card-grid-skeleton"; export const ImageSearch = ({ images, query, }: { images: DBImage[]; query?: string; }) => { const { isPending } = useSharedTransition(); if (isPending) return ; if (images.length === 0) { return ; } return ; }; const ImageGrid = ({ images }: { images: DBImage[] }) => { return (
{images.map((image) => ( ))}
); }; ================================================ FILE: components/loading-spinner.tsx ================================================ /** * v0 by Vercel. * @see https://v0.dev/t/tm9EX5NO8KN * Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app */ "use client"; import { ImageStreamStatus } from "@/lib/utils"; export function LoadingSpinner({ status }: { status?: ImageStreamStatus }) { return (

Searching {status ? status?.regular ? " for direct matches" : " for semantic results" : ""} ...

); } ================================================ FILE: components/match-badge.tsx ================================================ import { Badge } from "@/components/ui/badge"; export const MatchBadge = ({ type, similarity, }: { type: "direct" | "semantic"; similarity?: number; }) => { return (
{type === "semantic" ? ( <> Similarity: {similarity?.toFixed(3)} Semantic Match: {similarity?.toFixed(3)} ) : ( Direct Match )}
); }; ================================================ FILE: components/no-images-found.tsx ================================================ /** * This code was generated by v0 by Vercel. * @see https://v0.dev/t/Q2jvX35BnWA * Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app */ /** Add fonts into your Next.js project: import { Inter } from 'next/font/google' inter({ subsets: ['latin'], display: 'swap', }) To read more about using these font, please visit the Next.js documentation: - App Directory: https://nextjs.org/docs/app/building-your-application/optimizing/fonts - Pages Directory: https://nextjs.org/docs/pages/building-your-application/optimizing/fonts **/ export function NoImagesFound({ query }: { query: string }) { return (

No images found

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

); } ================================================ FILE: components/search-box.tsx ================================================ "use client"; /** * This code was generated by v0 by Vercel. * @see https://v0.dev/t/GHXhZDO4KL4 * Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app */ import { Input } from "@/components/ui/input"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { SearchIcon } from "lucide-react"; import { useRef, useState } from "react"; import { Button } from "./ui/button"; import { X } from "lucide-react"; import { useDebouncedCallback } from "use-debounce"; import { useSharedTransition } from "@/lib/hooks/use-shared-transition"; export function SearchBox({ query, disabled, }: { query?: string | null; disabled?: boolean; }) { const { startTransition } = useSharedTransition(); const inputRef = useRef(null); const [isValid, setIsValid] = useState(true); const searchParams = useSearchParams(); const q = searchParams.get("q")?.toString() ?? ""; const pathname = usePathname(); const router = useRouter(); const handleSearch = useDebouncedCallback((term: string) => { const params = new URLSearchParams(searchParams); if (term) { params.set("q", term); } else { params.delete("q"); } startTransition && startTransition(() => { router.push(`${pathname}?${params.toString()}`); }); }, 300); const resetQuery = () => { startTransition && startTransition(() => { router.push("/"); if (inputRef.current) { inputRef.current.value = ""; inputRef.current?.focus(); } }); }; return (
{ const newValue = e.target.value; if (newValue.length > 2) { setIsValid(true); handleSearch(newValue); } else if (newValue.length === 0) { handleSearch(newValue); setIsValid(false); } else { setIsValid(false); } }} className={ "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" } placeholder="Search..." /> {q.length > 0 ? ( ) : null}
{!isValid ? (
Query must be 3 characters or longer
) : (
)}
); } ================================================ FILE: components/suspended-image-search.tsx ================================================ import { getImages } from "@/lib/db/api"; import { ErrorComponent } from "./error"; import { ImageSearch } from "./image-search"; export const SuspendedImageSearch = async ({ query }: { query?: string }) => { const { images, error } = await getImages(query); if (error) { return ; } return ; }; ================================================ FILE: components/ui/alert.tsx ================================================ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const alertVariants = cva( "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", { variants: { variant: { default: "bg-background text-foreground", destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", }, }, defaultVariants: { variant: "default", }, } ) const Alert = React.forwardRef< HTMLDivElement, React.HTMLAttributes & VariantProps >(({ className, variant, ...props }, ref) => (
)) Alert.displayName = "Alert" const AlertTitle = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) AlertTitle.displayName = "AlertTitle" const AlertDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) AlertDescription.displayName = "AlertDescription" export { Alert, AlertTitle, AlertDescription } ================================================ FILE: components/ui/badge.tsx ================================================ import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const badgeVariants = cva( "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", { variants: { variant: { default: "border-transparent bg-primary text-primary-foreground ", secondary: "border-transparent bg-secondary text-secondary-foreground ", destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", outline: "text-foreground", }, }, defaultVariants: { variant: "default", }, }, ); export interface BadgeProps extends React.HTMLAttributes, VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { return (
); } export { Badge, badgeVariants }; ================================================ FILE: components/ui/button.tsx ================================================ import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( "inline-flex items-center justify-center 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", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-10 px-4 py-2", sm: "h-9 rounded-md px-3", lg: "h-11 rounded-md px-8", icon: "h-10 w-10", }, }, defaultVariants: { variant: "default", size: "default", }, } ) export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean } const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button" return ( ) } ) Button.displayName = "Button" export { Button, buttonVariants } ================================================ FILE: components/ui/card.tsx ================================================ import * as React from "react" import { cn } from "@/lib/utils" const Card = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) Card.displayName = "Card" const CardHeader = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) CardHeader.displayName = "CardHeader" const CardTitle = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => (

)) CardTitle.displayName = "CardTitle" const CardDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => (

)) CardDescription.displayName = "CardDescription" const CardContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (

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