Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
33d9a41
fix: update environment variables for Upstash Search
v0[bot] Feb 5, 2026
99dbcdf
Merge pull request #1 from savoo12/upstash-token-error
vercel[bot] Feb 5, 2026
ffb097d
fix: switch to Upstash Vector API
v0[bot] Feb 5, 2026
486a6fe
revert to Upstash Search integration
v0[bot] Feb 5, 2026
2963ee2
fix: correct Upstash Search environment variable names
v0[bot] Feb 5, 2026
8d3dd30
fix: add debug logs to search component error handling
v0[bot] Feb 5, 2026
235cebc
feat: enhance AI description prompt for better search results
v0[bot] Feb 5, 2026
3c44aca
fix: improve search relevance and filtering
v0[bot] Feb 5, 2026
1d3e63e
fix: resolve aspect ratio warning for Image component
v0[bot] Feb 5, 2026
69f5421
fix: lower search score threshold and add debug logging
v0[bot] Feb 5, 2026
5f6c7ba
fix: add type check before substring call
v0[bot] Feb 5, 2026
d9ce5fa
fix: fix TypeScript error with content conversion
v0[bot] Feb 5, 2026
5230c33
fix: improve image search relevance and filtering
v0[bot] Feb 5, 2026
8d9c6f6
fix: improve search accuracy with absolute minimum score
v0[bot] Feb 5, 2026
b3a2b6e
revert: restore simple search logic without threshold filtering
v0[bot] Feb 5, 2026
e3ed11c
feat: rebrand from vectr.store to Picsearch
v0[bot] Feb 5, 2026
4e172d1
chore: remove unused header buttons and deploy file
v0[bot] Feb 5, 2026
b8cd7fc
fix: resolve accessibility issues in search bar
v0[bot] Feb 5, 2026
ac24484
fix: resolve hydration mismatch with icon buttons
v0[bot] Feb 5, 2026
f7fdd34
fix: resolve Sonner Toaster accessibility issue
v0[bot] Feb 5, 2026
89ea954
fix: resolve Turbopack HMR stale cache issue
v0[bot] Feb 5, 2026
2c82202
fix: invalidate Turbopack cache for sonner.tsx module
v0[bot] Feb 5, 2026
faec189
Merge pull request #3 from savoo12/image-search-results
vercel[bot] Feb 5, 2026
e127389
fix: add search submit button for mobile
v0[bot] Feb 5, 2026
6eb4293
fix: enforce eager loading for high-priority images
v0[bot] Feb 6, 2026
af566cc
feat: enhance search relevance with score threshold and reranking
v0[bot] Feb 6, 2026
ca831e4
fix: correct empty state display on Picsearch homepage
v0[bot] Feb 6, 2026
0796166
feat: adjust search threshold and debug logging
v0[bot] Feb 6, 2026
89db417
fix: improve search relevance with dynamic threshold and reranking
v0[bot] Feb 6, 2026
790832b
fix: improve search results filtering and error handling
v0[bot] Feb 6, 2026
f1d0ed2
fix: debug and fix search index issues
v0[bot] Feb 6, 2026
070ffff
fix: resolve build error in debug route
v0[bot] Feb 6, 2026
53898d1
fix: raise search result threshold and remove debug elements
v0[bot] Feb 6, 2026
50e5343
fix: resolve Turbopack server-rendering transient error
v0[bot] Feb 6, 2026
4f509a9
fix: enhance search relevance with combined filtering approach
v0[bot] Feb 6, 2026
50698be
feat: implement AI verification for accurate search results
v0[bot] Feb 6, 2026
e228fbb
Merge pull request #4 from savoo12/image-lcp-error
vercel[bot] Feb 6, 2026
1411bab
fix: resolve LCP warning by adjusting image loading props
v0[bot] Feb 6, 2026
1090d8d
fix: use explicit props for Next.js Image
v0[bot] Feb 6, 2026
835d796
feat: add rate limiting to upload and search endpoints
v0[bot] Feb 6, 2026
821225c
Merge pull request #5 from savoo12/v0/savoo12-917e6025
vercel[bot] Feb 6, 2026
13adb4a
feat: add Open Graph image and metadata
v0[bot] Feb 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,8 @@
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"files.readonlyInclude": {
"**/*": true
}
}
141 changes: 130 additions & 11 deletions app/actions/search.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,35 @@
/** biome-ignore-all lint/suspicious/noConsole: "Handy for debugging" */

"use server";

import { generateObject } from "ai";
import { Search } from "@upstash/search";
import type { PutBlobResult } from "@vercel/blob";
import { headers } from "next/headers";
import { z } from "zod";

// Simple in-memory sliding window rate limiter for server actions.
// Limits each IP to a max number of searches per time window.
const SEARCH_RATE_LIMIT = 10; // max searches
const SEARCH_WINDOW_MS = 60 * 1000; // per 60 seconds
const searchRateMap = new Map<string, number[]>();

const upstash = Search.fromEnv();
function isSearchRateLimited(ip: string): boolean {
const now = Date.now();
const timestamps = searchRateMap.get(ip) || [];
// Remove expired timestamps
const recent = timestamps.filter((t) => now - t < SEARCH_WINDOW_MS);
if (recent.length >= SEARCH_RATE_LIMIT) {
searchRateMap.set(ip, recent);
return true;
}
recent.push(now);
searchRateMap.set(ip, recent);
return false;
}

const upstash = new Search({
url: process.env.UPSTASH_SEARCH_REST_URL!,
token: process.env.UPSTASH_SEARCH_REST_TOKEN!,
});
const index = upstash.index("images");

type SearchResponse =
Expand All @@ -16,27 +40,122 @@ type SearchResponse =
error: string;
};

/**
* AI-powered re-ranking: takes the top candidate results from Upstash and
* asks xAI to verify which ones actually match the query. This solves the
* problem where generic terms like "red shirt" loosely match many images.
*/
async function rerankWithAI(
query: string,
candidates: { blob: PutBlobResult; score: number }[]
): Promise<PutBlobResult[]> {
if (candidates.length === 0) return [];

// Build a list of candidates with their URLs for the AI to evaluate
const candidateList = candidates.map((c, i) => ({
index: i,
url: c.blob.downloadUrl,
}));

try {
const { object } = await generateObject({
model: "xai/grok-2-vision",
schema: z.object({
matches: z.array(
z.object({
index: z.number().describe("The index of the matching image"),
relevant: z
.boolean()
.describe("Whether this image truly matches the search query"),
})
),
}),
messages: [
{
role: "user",
content: [
{
type: "text",
text: `You are an image search judge. The user searched for: "${query}"

Below are ${candidateList.length} candidate images. For EACH one, decide if it truly matches the search query "${query}".

Be STRICT: only mark an image as relevant if it clearly and obviously matches. For example:
- "red shirt" → only images where someone is actually wearing a red shirt
- "dog" → only images that actually contain a dog
- "blue car" → only images showing a blue car

Evaluate each image:`,
},
...candidateList.map(
(c) =>
({
type: "image" as const,
image: c.url,
})
),
],
},
],
});

// Filter to only relevant results, maintaining original order
const relevantIndices = new Set(
object.matches.filter((m) => m.relevant).map((m) => m.index)
);

return candidates
.filter((_, i) => relevantIndices.has(i))
.map((c) => c.blob);
} catch {
// If AI re-ranking fails, fall back to returning all candidates
return candidates.map((c) => c.blob);
}
}

export const search = async (
_prevState: SearchResponse | undefined,
formData: FormData
): Promise<SearchResponse> => {
const headersList = await headers();
const ip =
headersList.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";

if (isSearchRateLimited(ip)) {
return { error: "Too many searches. Please wait a moment and try again." };
}

const query = formData.get("search");

if (!query || typeof query !== "string") {
return { error: "Please enter a search query" };
}

try {
console.log("Searching index for query:", query);
const results = await index.search({ query });
const results = await index.search({
query,
limit: 20,
});

const sorted = results.sort((a, b) => b.score - a.score);

// First pass: use a low threshold to get a broad set of candidates.
// The AI re-ranker will do the precise filtering.
const MIN_THRESHOLD = 0.4;
const candidates = sorted
.filter((result) => result.score >= MIN_THRESHOLD)
.map((result) => ({
blob: result.metadata as unknown as PutBlobResult,
score: result.score,
}))
.filter((c) => c.blob);

// Take top 10 candidates max to limit AI vision API calls
const topCandidates = candidates.slice(0, 10);

console.log("Results:", results);
const data = results
.sort((a, b) => b.score - a.score)
.map((result) => result.metadata)
.filter(Boolean) as unknown as PutBlobResult[];
// Second pass: AI verifies which images truly match the query
const data = await rerankWithAI(query, topCandidates);

console.log("Images found:", data);
return { data };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
Expand Down
22 changes: 21 additions & 1 deletion app/api/upload/generate-description.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,27 @@ export const generateDescription = async (blob: PutBlobResult) => {

const { text } = await generateText({
model: "xai/grok-2-vision",
system: "Describe the image in detail.",
system: `You are an image description expert. Describe the image in detail for a searchable image database.

Your response MUST have two sections:

DESCRIPTION:
Write a detailed description including:
- People: gender, approximate age, clothing (colors, types), accessories, hair, expressions
- Animals: species, breed if identifiable, color, size, what the animal is doing
- Objects: what they are, colors, sizes, brands if visible
- Setting: indoor/outdoor, location type, time of day
- Actions: what is happening, poses, activities
- Colors: mention prominent colors explicitly
- Text: any visible text or signs

Be specific and use common search terms. For example, say "man in blue t-shirt" not just "person wearing clothes". Say "golden retriever dog" not just "pet".

TAGS:
List 5-15 single-word or short-phrase keyword tags that someone might search for to find this image. Focus on the PRIMARY subjects. For example:
- A photo of a dog: dog, pet, pekingese, animal, fluffy, indoor
- A selfie: selfie, woman, portrait, mirror, phone
- A landscape: sunset, beach, ocean, sky, nature`,
messages: [
{
role: "user",
Expand Down
5 changes: 4 additions & 1 deletion app/api/upload/index-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { Search } from "@upstash/search";
import type { PutBlobResult } from "@vercel/blob";
import { FatalError, getStepMetadata, RetryableError } from "workflow";

const upstash = Search.fromEnv();
const upstash = new Search({
url: process.env.UPSTASH_SEARCH_REST_URL!,
token: process.env.UPSTASH_SEARCH_REST_TOKEN!,
});

export const indexImage = async (blob: PutBlobResult, text: string) => {
"use step";
Expand Down
10 changes: 10 additions & 0 deletions app/api/upload/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { checkRateLimit } from "@vercel/firewall";
import { NextResponse } from "next/server";
import { FatalError } from "workflow";
import { start } from "workflow/api";
import { processImage } from "./process-image";

export const POST = async (request: Request): Promise<NextResponse> => {
try {
// Rate limit uploads: configured in Vercel Firewall dashboard
const { rateLimited } = await checkRateLimit("upload-image", { request });
if (rateLimited) {
return NextResponse.json(
{ error: "Too many uploads. Please wait a moment and try again." },
{ status: 429 }
);
}

const formData = await request.formData();
const file = formData.get("file") as File | null;

Expand Down
26 changes: 24 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,30 @@ const mono = Geist_Mono({
});

export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Picsearch",
description:
"Search your photos using natural language. Upload images and find them by describing what you see.",
openGraph: {
title: "Picsearch",
description:
"Search your photos using natural language. Upload images and find them by describing what you see.",
images: [
{
url: "/og.jpg",
width: 1200,
height: 630,
alt: "Picsearch - Search your photos using natural language",
},
],
type: "website",
},
twitter: {
card: "summary_large_image",
title: "Picsearch",
description:
"Search your photos using natural language. Upload images and find them by describing what you see.",
images: ["/og.jpg"],
},
};

type RootLayoutProps = {
Expand Down
10 changes: 2 additions & 8 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { Header } from "@/components/header";
import { Results } from "@/components/results";
import { UploadedImagesProvider } from "@/components/uploaded-images-provider";

export const metadata: Metadata = {
title: "vectr",
description: "vectr",
};

const ImagesSkeleton = () => (
<div className="columns-3 gap-4">
{Array.from({ length: 9 }, (_, idx) => {
Expand All @@ -29,14 +23,14 @@ const ImagesSkeleton = () => (

const Home = () => (
<UploadedImagesProvider>
<div className="container relative mx-auto grid items-start gap-12 px-4 py-8 sm:gap-16 lg:grid-cols-[300px_1fr]">
<main className="container relative mx-auto grid items-start gap-12 px-4 py-8 sm:gap-16 lg:grid-cols-[300px_1fr]">
<div className="lg:sticky lg:top-8">
<Header />
</div>
<Suspense fallback={<ImagesSkeleton />}>
<Results />
</Suspense>
</div>
</main>
</UploadedImagesProvider>
);

Expand Down
49 changes: 0 additions & 49 deletions components/deploy.tsx

This file was deleted.

21 changes: 3 additions & 18 deletions components/header.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import { CheckCircle2Icon, ImageUpIcon } from "lucide-react";
import { DeployButton } from "./deploy";
import { Button } from "./ui/button";

export const Header = () => (
<div className="flex flex-col gap-8 sm:gap-12">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<ImageUpIcon className="size-4" />
<h1 className="font-semibold tracking-tight">vectr.store</h1>
<h1 className="font-semibold tracking-tight">Picsearch</h1>
</div>
<p className="text-balance text-muted-foreground">
A free, open-source template for building natural language image search
on the AI Cloud.
Search your photos using natural language. Just describe what you're looking for.
</p>
<p className="text-muted-foreground text-sm italic">
Try searching for "water" or "desert".
Try searching for "dog" or "blue shirt".
</p>
</div>
<ul className="flex flex-col gap-2 text-muted-foreground sm:gap-4">
Expand Down Expand Up @@ -85,17 +82,5 @@ export const Header = () => (
</p>
</li>
</ul>
<div className="flex gap-2">
<DeployButton />
<Button asChild size="sm" variant="outline">
<a
href="https://github.com/vercel/vectr"
rel="noopener noreferrer"
target="_blank"
>
Source code
</a>
</Button>
</div>
</div>
);
Loading