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
================================================
<a href="https://semantic-search.vercel.app">
<img alt="Next.js 14 and App Router Semantic Search." src="https://semantic-image-search.vercel.app/opengraph-image.png">
<h1 align="center">Semantic Image Search</h1>
</a>
<p align="center">
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.
</p>
<p align="center">
<a href="#features"><strong>Features</strong></a> ·
<a href="#model-providers"><strong>Model Providers</strong></a> ·
<a href="#deploy-your-own"><strong>Deploy Your Own</strong></a> ·
<a href="#running-locally"><strong>Running locally</strong></a> ·
<a href="#authors"><strong>Authors</strong></a>
</p>
<br/>
## 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:
[](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 (
<html lang="en">
<body className={cn("font-sans antialiased", GeistSans.variable)}>
<TransitionProvider>{children}</TransitionProvider>
</body>
</html>
);
}
================================================
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 (
<main className="p-8 space-y-4">
<div className="flex justify-between items-center">
<div>
<h1 className="font-semibold text-2xl">Semantic Search</h1>
</div>
<DeployButton />
</div>
<div>
<p>
This demo showcases how to use the{" "}
<Link
href="https://sdk.vercel.ai/docs"
className="text-blue-600 hover:underline"
target="_blank"
>
AI SDK
</Link>{" "}
to build semantic search applications. Try searching for something
semantically, like "tasty food".
</p>
</div>
<div className="">
<div className="pt-2">
<SearchBox query={query} />
</div>
<Suspense fallback={<CardGridSkeleton />} key={query}>
<SuspendedImageSearch query={query} />
</Suspense>
</div>
</main>
);
}
================================================
FILE: components/card-grid-skeleton.tsx
================================================
import { Skeleton } from "@/components/ui/skeleton";
export function CardGridSkeleton() {
return (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-2">
{new Array(16).fill("").map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
);
}
export function SkeletonCard() {
return (
<div className="flex flex-col space-y-3">
<Skeleton className="h-[250px] sm:h-[450px] rounded-xl" />
</div>
);
}
================================================
FILE: components/deploy-button.tsx
================================================
import { cn } from "@/lib/utils";
import { buttonVariants } from "./ui/button";
export const DeployButton = () => {
return (
<div className="flex items-center justify-end space-x-2">
<a
href="https://vercel.com/templates/next.js/semantic-image-search"
target="_blank"
className={cn(buttonVariants())}
>
<IconVercel className="mr-2" />
<span className="hidden sm:block">Deploy to Vercel</span>
<span className="sm:hidden">Deploy</span>
</a>
</div>
);
};
function IconVercel({ className, ...props }: React.ComponentProps<"svg">) {
return (
<svg
aria-label="Vercel logomark"
role="img"
viewBox="0 0 74 64"
className={cn("size-4", className)}
{...props}
>
<path
d="M37.5896 0.25L74.5396 64.25H0.639648L37.5896 0.25Z"
fill="currentColor"
></path>
</svg>
);
}
================================================
FILE: components/error.tsx
================================================
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
export function ErrorComponent({ error }: { error: Error }) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{error.message ?? "An error occured. Please try again later."}
</AlertDescription>
</Alert>
);
}
================================================
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 (
<Card
key={image.id}
className="h-[250px] md:h-[450px] relative group rounded-lg overflow-hidden"
>
<div className="absolute inset-0 z-10">
<span className="sr-only">View image</span>
</div>
<Image
src={image.path}
alt={image.title}
width={300}
height={450}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
<div className="absolute inset-0 bg-zinc-900/70 group-hover:opacity-100 opacity-0 transition-opacity flex flex-col items-center justify-center p-6 text-white text-center">
<h3 className="text-xl font-semibold">{image.title}</h3>
<p className="hidden md:block text-sm mt-2 overflow-y-hidden">
{image.description}
</p>
<p className="hidden md:block text-xs font-medium text-gray-100 italic mt-4">
Metadata Generated by GPT-4o
</p>
</div>
{similarity ? (
<div className="py-2 z-10 absolute bottom-2 left-2">
<MatchBadge
type={similarity === 1 ? "direct" : "semantic"}
similarity={similarity}
/>
</div>
) : null}
</Card>
);
}
================================================
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 <CardGridSkeleton />;
if (images.length === 0) {
return <NoImagesFound query={query ?? ""} />;
}
return <ImageGrid images={images} />;
};
const ImageGrid = ({ images }: { images: DBImage[] }) => {
return (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-2 relative">
{images.map((image) => (
<ImageCard
key={"image_" + image.id}
image={image}
similarity={image.similarity}
/>
))}
</div>
);
};
================================================
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 (
<div className="absolute h-full w-full bg-white z-10 top-0 flex items-start justify-center">
<div className="flex flex-col items-center space-y-4 pt-16">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-gray-900 border-t-transparent dark:border-gray-50 dark:border-t-transparent" />
<p className="text-gray-500 dark:text-gray-400">
Searching
{status
? status?.regular
? " for direct matches"
: " for semantic results"
: ""}
...
</p>
</div>
</div>
);
}
================================================
FILE: components/match-badge.tsx
================================================
import { Badge } from "@/components/ui/badge";
export const MatchBadge = ({
type,
similarity,
}: {
type: "direct" | "semantic";
similarity?: number;
}) => {
return (
<div className="">
{type === "semantic" ? (
<>
<Badge
variant={"default"}
className="block sm:hidden bg-green-100 text-green-700"
>
Similarity: {similarity?.toFixed(3)}
</Badge>
<Badge
variant={"default"}
className="hidden sm:block bg-green-100 text-green-700"
>
Semantic Match: {similarity?.toFixed(3)}
</Badge>
</>
) : (
<Badge variant={"secondary"}>Direct Match</Badge>
)}
</div>
);
};
================================================
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 (
<div className="flex flex-col items-center justify-start p-16 h-[50vh]">
<div className="text-center space-y-2">
<h3 className="text-lg font-medium text-gray-700 dark:text-gray-300">
No images found
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
There were no results (semantic or direct) found for the query '
{query}'.
</p>
</div>
</div>
);
}
================================================
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<HTMLInputElement>(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 (
<div className="flex flex-col">
<div className="w-full mx-auto mb-4">
<div className="relative flex items-center space-x-2">
<div className="relative w-full flex items-center">
<SearchIcon className="absolute left-4 w-5 h-5 text-gray-500" />
<Input
disabled={disabled}
ref={inputRef}
defaultValue={query ?? ""}
minLength={3}
onChange={(e) => {
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 ? (
<Button
className="absolute right-2 text-gray-400 rounded-full h-8 w-8"
variant="ghost"
type="reset"
size={"icon"}
onClick={resetQuery}
>
<X height="20" width="20" />
</Button>
) : null}
</div>
</div>
{!isValid ? (
<div className="text-xs pt-2 text-destructive">
Query must be 3 characters or longer
</div>
) : (
<div className="h-6" />
)}
</div>
</div>
);
}
================================================
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 <ErrorComponent error={error} />;
}
return <ImageSearch images={images} query={query} />;
};
================================================
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<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
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<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
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<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
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<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
================================================
FILE: components/ui/skeleton.tsx
================================================
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
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<string[]> {
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<ImageMetadata[]> {
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<number[]> => {
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<number[]>`ARRAY[]::integer[]`,
};
export const findSimilarContent = async (description: string) => {
const embedding = await generateEmbedding(description);
const similarity = sql<number>`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<number>`1` })
.from(images)
.where(
or(
sql`title ILIKE ${"%" + query + "%"}`,
sql`description ILIKE ${"%" + query + "%"}`,
),
);
return result;
};
function uniqueItemsByObject(items: DBImage[]): DBImage[] {
const seenObjects = new Set<string>();
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<DBImage[]>(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<typeof dbImageSchema>;
================================================
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 (
<TransitionContext.Provider value={{ isPending, startTransition }}>
{children}
</TransitionContext.Provider>
);
};
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"
]
}
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
SYMBOL INDEX (26 symbols across 20 files)
FILE: app/layout.tsx
function RootLayout (line 15) | function RootLayout({
FILE: app/page.tsx
function Home (line 8) | async function Home({
FILE: components/card-grid-skeleton.tsx
function CardGridSkeleton (line 3) | function CardGridSkeleton() {
function SkeletonCard (line 13) | function SkeletonCard() {
FILE: components/deploy-button.tsx
function IconVercel (line 19) | function IconVercel({ className, ...props }: React.ComponentProps<"svg">) {
FILE: components/error.tsx
function ErrorComponent (line 5) | function ErrorComponent({ error }: { error: Error }) {
FILE: components/image-card.tsx
function ImageCard (line 8) | function ImageCard({
FILE: components/loading-spinner.tsx
function LoadingSpinner (line 10) | function LoadingSpinner({ status }: { status?: ImageStreamStatus }) {
FILE: components/no-images-found.tsx
function NoImagesFound (line 20) | function NoImagesFound({ query }: { query: string }) {
FILE: components/search-box.tsx
function SearchBox (line 17) | function SearchBox({
FILE: components/ui/badge.tsx
type BadgeProps (line 24) | interface BadgeProps
function Badge (line 28) | function Badge({ className, variant, ...props }: BadgeProps) {
FILE: components/ui/button.tsx
type ButtonProps (line 36) | interface ButtonProps
FILE: components/ui/input.tsx
type InputProps (line 5) | interface InputProps
FILE: components/ui/skeleton.tsx
function Skeleton (line 3) | function Skeleton({
FILE: lib/ai/0-upload.ts
function main (line 8) | async function main() {
FILE: lib/ai/1-generate-metadata.ts
function main (line 10) | async function main() {
FILE: lib/ai/2-embed-and-save.ts
function main (line 24) | async function main() {
FILE: lib/ai/utils.ts
type ImageMetadata (line 6) | type ImageMetadata = {
function getJpgFiles (line 22) | async function getJpgFiles(dir: string): Promise<string[]> {
function writeAllMetadataToFile (line 41) | async function writeAllMetadataToFile(
function getMetadataFile (line 57) | async function getMetadataFile(path: string): Promise<ImageMetadata[]> {
FILE: lib/db/api.ts
function uniqueItemsByObject (line 48) | function uniqueItemsByObject(items: DBImage[]): DBImage[] {
FILE: lib/db/schema.ts
type DBImage (line 33) | type DBImage = z.infer<typeof dbImageSchema>;
FILE: lib/utils.ts
function cn (line 4) | function cn(...inputs: ClassValue[]) {
type ImageStreamStatus (line 8) | type ImageStreamStatus = {
Condensed preview — 39 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (51K chars).
[
{
"path": ".eslintrc.json",
"chars": 40,
"preview": "{\n \"extends\": \"next/core-web-vitals\"\n}\n"
},
{
"path": ".gitignore",
"chars": 440,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": "LICENSE",
"chars": 552,
"preview": "Copyright 2024 Vercel, Inc.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file e"
},
{
"path": "README.md",
"chars": 7004,
"preview": "<a href=\"https://semantic-search.vercel.app\">\n <img alt=\"Next.js 14 and App Router Semantic Search.\" src=\"https://seman"
},
{
"path": "app/globals.css",
"chars": 1627,
"preview": "@tailwind base;\n @tailwind components;\n @tailwind utilities;\n\n @layer base {\n :root {\n --background: 0 0% 100"
},
{
"path": "app/layout.tsx",
"chars": 771,
"preview": "import type { Metadata } from \"next\";\nimport { GeistSans } from \"geist/font/sans\";\nimport \"./globals.css\";\nimport { cn }"
},
{
"path": "app/page.tsx",
"chars": 1409,
"preview": "import { CardGridSkeleton } from \"@/components/card-grid-skeleton\";\nimport { DeployButton } from \"@/components/deploy-bu"
},
{
"path": "components/card-grid-skeleton.tsx",
"chars": 458,
"preview": "import { Skeleton } from \"@/components/ui/skeleton\";\n\nexport function CardGridSkeleton() {\n return (\n <div className"
},
{
"path": "components/deploy-button.tsx",
"chars": 907,
"preview": "import { cn } from \"@/lib/utils\";\nimport { buttonVariants } from \"./ui/button\";\n\nexport const DeployButton = () => {\n r"
},
{
"path": "components/error.tsx",
"chars": 451,
"preview": "import { AlertCircle } from \"lucide-react\";\n\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\""
},
{
"path": "components/image-card.tsx",
"chars": 1489,
"preview": "\"use client\";\n\nimport { DBImage } from \"@/lib/db/schema\";\nimport Image from \"next/image\";\nimport { MatchBadge } from \"./"
},
{
"path": "components/image-search.tsx",
"chars": 949,
"preview": "\"use client\";\nimport { ImageCard } from \"./image-card\";\nimport { DBImage } from \"@/lib/db/schema\";\nimport { NoImagesFoun"
},
{
"path": "components/loading-spinner.tsx",
"chars": 900,
"preview": "/**\n * v0 by Vercel.\n * @see https://v0.dev/t/tm9EX5NO8KN\n * Documentation: https://v0.dev/docs#integrating-generated-co"
},
{
"path": "components/match-badge.tsx",
"chars": 750,
"preview": "import { Badge } from \"@/components/ui/badge\";\n\nexport const MatchBadge = ({\n type,\n similarity,\n}: {\n type: \"direct\""
},
{
"path": "components/no-images-found.tsx",
"chars": 1102,
"preview": "/**\n * This code was generated by v0 by Vercel.\n * @see https://v0.dev/t/Q2jvX35BnWA\n * Documentation: https://v0.dev/do"
},
{
"path": "components/search-box.tsx",
"chars": 3445,
"preview": "\"use client\";\n/**\n * This code was generated by v0 by Vercel.\n * @see https://v0.dev/t/GHXhZDO4KL4\n * Documentation: htt"
},
{
"path": "components/suspended-image-search.tsx",
"chars": 387,
"preview": "import { getImages } from \"@/lib/db/api\";\nimport { ErrorComponent } from \"./error\";\nimport { ImageSearch } from \"./image"
},
{
"path": "components/ui/alert.tsx",
"chars": 1584,
"preview": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/"
},
{
"path": "components/ui/badge.tsx",
"chars": 1075,
"preview": "import * as React from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \""
},
{
"path": "components/ui/button.tsx",
"chars": 1835,
"preview": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class"
},
{
"path": "components/ui/card.tsx",
"chars": 1877,
"preview": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Card = React.forwardRef<\n HTMLDivElement,\n Rea"
},
{
"path": "components/ui/input.tsx",
"chars": 824,
"preview": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface InputProps\n extends React.InputHTMLA"
},
{
"path": "components/ui/skeleton.tsx",
"chars": 261,
"preview": "import { cn } from \"@/lib/utils\"\n\nfunction Skeleton({\n className,\n ...props\n}: React.HTMLAttributes<HTMLDivElement>) {"
},
{
"path": "components.json",
"chars": 340,
"preview": "{\n \"$schema\": \"https://ui.shadcn.com/schema.json\",\n \"style\": \"default\",\n \"rsc\": true,\n \"tsx\": true,\n \"tailwind\": {\n"
},
{
"path": "drizzle.config.ts",
"chars": 228,
"preview": "import type { Config } from \"drizzle-kit\";\n\nexport default {\n schema: \"./lib/db/schema.ts\",\n out: \"./lib/db/migrations"
},
{
"path": "lib/ai/0-upload.ts",
"chars": 971,
"preview": "import dotenv from \"dotenv\";\nimport { getJpgFiles } from \"./utils\";\nimport { list, put } from \"@vercel/blob\";\nimport fs "
},
{
"path": "lib/ai/1-generate-metadata.ts",
"chars": 1435,
"preview": "import { openai } from \"@ai-sdk/openai\";\nimport { generateObject } from \"ai\";\nimport dotenv from \"dotenv\";\nimport { z } "
},
{
"path": "lib/ai/2-embed-and-save.ts",
"chars": 1738,
"preview": "import dotenv from \"dotenv\";\nimport { embeddingModel, getMetadataFile } from \"./utils\";\nimport { embed } from \"ai\";\nimpo"
},
{
"path": "lib/ai/utils.ts",
"chars": 2085,
"preview": "import { openai } from \"@ai-sdk/openai\";\nimport { embed } from \"ai\";\nimport fs from \"fs\";\nimport path from \"path\";\n\nexpo"
},
{
"path": "lib/db/api.ts",
"chars": 2834,
"preview": "\"use server\";\n\nimport {\n cosineDistance,\n desc,\n getTableColumns,\n gt,\n or,\n sql,\n} from \"drizzle-orm\";\nimport { d"
},
{
"path": "lib/db/index.ts",
"chars": 128,
"preview": "import { sql } from \"@vercel/postgres\";\nimport { drizzle } from \"drizzle-orm/vercel-postgres\";\n\nexport const db = drizzl"
},
{
"path": "lib/db/schema.ts",
"chars": 868,
"preview": "import { varchar, index, pgTable, vector, text } from \"drizzle-orm/pg-core\";\nimport { nanoid } from \"nanoid\";\nimport { z"
},
{
"path": "lib/hooks/use-shared-transition.tsx",
"chars": 629,
"preview": "\"use client\";\n\nimport React, { createContext, useContext, useTransition } from \"react\";\n\nconst defaultValue: {\n isPendi"
},
{
"path": "lib/utils.ts",
"chars": 248,
"preview": "import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: C"
},
{
"path": "next.config.mjs",
"chars": 295,
"preview": "/** @type {import('next').NextConfig} */\nconst nextConfig = {\n images: {\n remotePatterns: [\n {\n protocol"
},
{
"path": "package.json",
"chars": 1465,
"preview": "{\n \"name\": \"semantic-image-search\",\n \"version\": \"0.0.1\",\n \"scripts\": {\n \"dev\": \"next dev\",\n \"build\": \"next buil"
},
{
"path": "postcss.config.mjs",
"chars": 135,
"preview": "/** @type {import('postcss-load-config').Config} */\nconst config = {\n plugins: {\n tailwindcss: {},\n },\n};\n\nexport d"
},
{
"path": "tailwind.config.ts",
"chars": 2179,
"preview": "import type { Config } from \"tailwindcss\"\n\nconst config = {\n darkMode: [\"class\"],\n content: [\n './pages/**/*.{ts,ts"
},
{
"path": "tsconfig.json",
"chars": 666,
"preview": "{\n \"compilerOptions\": {\n \"lib\": [\n \"dom\",\n \"dom.iterable\",\n \"esnext\"\n ],\n \"allowJs\": true,\n "
}
]
About this extraction
This page contains the full source code of the vercel-labs/semantic-image-search GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 39 files (45.3 KB), approximately 13.0k tokens, and a symbol index with 26 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.