Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ ok: true });
}
38 changes: 38 additions & 0 deletions app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// app/api/search/route.ts
import { NextRequest, NextResponse } from "next/server";
import { vectorSearch } from "@/app/lib/vectorIndex";
import { embedById } from "@/app/lib/embeddings";

type Body = { imageId?: string; vector?: unknown; topK?: unknown };

function isNumberArray(x: unknown): x is number[] {
return Array.isArray(x) && x.every((n) => typeof n === "number");
}

export async function POST(req: NextRequest) {
try {
const body: Body = await req.json();

// topK: default 10, clamp to [1, 100]
let topK = 10;
if (typeof body.topK === "number") {
topK = Math.min(Math.max(Math.floor(body.topK), 1), 100);
}

// Building the query vector
let query: number[];
if (typeof body.imageId === "string" && body.imageId.length > 0) {
query = await embedById(body.imageId); // normalized inside
} else if (isNumberArray(body.vector)) {
query = body.vector;
} else {
return NextResponse.json({ error: "imageId or vector required" }, { status: 400 });
}

const results = await vectorSearch(query, topK);
return NextResponse.json({ results });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "search_failed";
return NextResponse.json({ error: message }, { status: 500 });
}
}
65 changes: 36 additions & 29 deletions app/imageGallery/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useState, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import axios from 'axios';
import { AppConfig } from '@/types/config';
import { getImageFromIndexedDB } from '../utils/indexedDbHelpers'; // Using helper

interface SimilarImage {
src: string;
Expand All @@ -20,6 +21,7 @@ function ImageGalleryContent() {
const [imageData, setImageData] = useState<string | null>(null);
const [loading, setLoading] = useState(true);

// Loading app config
useEffect(() => {
fetch('./setup.json')
.then((response) => response.json())
Expand All @@ -29,43 +31,48 @@ function ImageGalleryContent() {
});
}, []);

// When we have config + imageId, retrieve from IndexedDB and kick off search
useEffect(() => {
if (config && imageId) {
retrieveImageAndSearch(imageId, config);
if (!config) return;

// If there's no imageId, nothing to load — stopping the spinner and keeping UI clean
if (!imageId) {
setLoading(false); // Nothing to load, so stopping the spinner
setSimilarImages([]); // Keeping UI clean
return;
}

retrieveImageAndSearch(imageId, config);
}, [imageId, config]);

const retrieveImageAndSearch = (id: string, config: AppConfig) => {
const request = indexedDB.open('ImageStorageDB', 1);

request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction('images', 'readonly');
const store = transaction.objectStore('images');
const getRequest = store.get(id);

getRequest.onsuccess = async () => {
if (getRequest.result) {
const base64Image = getRequest.result.data;
setImageData(base64Image);
setLoading(false);
await sendPhotoToAPI(base64Image, config);
} else {
console.warn('No image found in IndexedDB with ID:', id);
setSimilarImages([]);
}
};
// Getting one image by id using helper, then calling API
const retrieveImageAndSearch = async (id: string, config: AppConfig) => {
try {
// Reading from IndexedDB via the shared helper
const base64 = await getImageFromIndexedDB(id);

getRequest.onerror = () => {
console.error('Error retrieving image from IndexedDB.');
};
};
if (!base64) {
console.warn('No image found in IndexedDb with ID:', id);
setImageData(null);
setSimilarImages([]);
setLoading(false);
return;
}

// Setting the image and stopping the loading spinner
setImageData(base64);
setLoading(false);

request.onerror = () => {
console.error('Failed to access IndexedDB.');
};
await sendPhotoToAPI(base64, config);
} catch (e) {
console.error('Error retrieving image from indexedDB', e);
setImageData(null);
setSimilarImages([]);
setLoading(false);
}
};

// Sending base64 to the external API and collecting similar images
const sendPhotoToAPI = async (base64Image: string, config: AppConfig) => {
setIsSearching(true);
setSimilarImages([]);
Expand Down
23 changes: 23 additions & 0 deletions app/lib/embeddings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Small helper around Universal Model Adapter
// Ensures the vector is L2-normalized (cosine works correctly)

import { getImageEmbeddings } from "@/app/lib/modelClient";

/**
* get embedding vector for an image id (file path, url, or blob)
* ensures result is 1-D array of floats, L2-normalized
*/
export async function embedById(image: Blob | string): Promise<number[]> {
// Run through modelClient
const raw = await getImageEmbeddings(image);

// Flattern nested arrays if pipeline returned [ [ [ ... ] ] ]
let v: number[] = [];
if (Array.isArray(raw)) {
v = raw.flat(Infinity) as number[];
} else {
throw new Error("embedding result is not array");
}

return v;
}
62 changes: 33 additions & 29 deletions app/lib/modelClient.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,37 @@
// // lib/modelClient.ts
// "use client";
// app/lib/modelClient.ts

// import { pipeline } from "@xenova/transformers";
export type EmbeddingRequest = { imageId?: string; dataUrl?: string };
export type EmbeddingResponse = { vector: number[] };

// /** Singleton references to loaded pipelines */
// let imageEmbedder: any = null;
function isEmbeddingResponse(x: unknown): x is EmbeddingResponse {
const r = x as { vector?: unknown };
return Array.isArray(r?.vector) && r.vector.every((n) => typeof n === "number");
}

// /**
// * loading a CLIP embedding pipeline for image search (example).
// * this will make it easy to use other models
// */
// export async function loadEmbeddingPipeline() {
// if (!imageEmbedder) {
// // Using CLIP for image embeddings, just as an example
// imageEmbedder = await pipeline(
// "feature-extraction",
// "Xenova/clip-vit-base-patch32"
// );
// }
// return imageEmbedder;
// }
/** Getying an embedding for an image by its ID (e.g., S3 key) via your embed API. */
export async function getEmbeddingForImageId(id: string): Promise<number[]> {
const res = await fetch("/api/model/embed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageId: id } as EmbeddingRequest),
cache: "no-store",
});

// /**
// * Extract embeddings from an image.
// * Returns a vector (array of floats) we can then compare with our dataset.
// */
// export async function getImageEmbeddings(image: Blob | string) {
// const embedder = await loadEmbeddingPipeline();
// // The pipeline returns a nested array. We'll flatten or keep it nested as needed.
// const result = await embedder(image);
// return result;
// }
const json: unknown = await res.json();
if (!isEmbeddingResponse(json)) throw new Error("Bad embed response (imageId)");
return json.vector;
}

/** Getting an embedding for raw image data (e.g., data URL) via your embed API. */
export async function getEmbeddingForImageData(dataUrl: string): Promise<number[]> {
const res = await fetch("/api/model/embed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dataUrl } as EmbeddingRequest),
cache: "no-store",
});

const json: unknown = await res.json();
if (!isEmbeddingResponse(json)) throw new Error("Bad embed response (dataUrl)");
return json.vector;
}
96 changes: 96 additions & 0 deletions app/lib/vectorIndex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// FAISS-only client used by Next.js API routes
// Endpoints expected on the FAISS service: /health, /upsert, /delete, /search

export type SearchHit = {
id: string; // image id (e.g., S3 key)
score: number; // similarity score (cosine via inner product)
metadata?: Record<string, unknown>;
};

type UpsertItem = {
id: string;
vector: number[]; // raw embedding
metadata?: Record<string, unknown>;
};

// Config
function num(env: string | undefined, fallback: number): number {
const n = Number(env);
return Number.isFinite(n) && n > 0 ? n : fallback;
}

// Reading from env
const FAISS_URL = (process.env.FAISS_URL || "http://127.0.0.1:8000").replace(/\/+$/, "");
const EMBEDDING_DIM = num(process.env.EMBEDDING_DIM, 384);
const MAX_TOPK = num(process.env.MAX_TOPK, 100);

// Small helper to fail fast with readable message
async function ok(res: Response, label: string) {
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(`${label} failed (${res.status}) ${txt}`.trim());
}
return res;
}

// Adding or replacing a batch of vectors
export async function vectorUpsert(items: UpsertItem[]): Promise<void> {
if (!items?.length) return; // Nothing to do

// Pre-validate dims to avoid server 422s
for (const it of items) {
if (!Array.isArray(it.vector) || it.vector.length !== EMBEDDING_DIM) {
throw new Error(`vectorUpsert: vector dim ${it.vector?.length} != EMBEDDING_DIM ${EMBEDDING_DIM}`);
}
}

const r = await fetch(`${FAISS_URL}/upsert`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items }),
cache: "no-store",
});
await ok(r, "faiss upsert");
}

// Searching topK nearest neighbors for a query vector
export async function vectorSearch(query: number[], topK = 10): Promise<SearchHit[]> {
if (!Array.isArray(query) || query.length === 0) return [];

// Pre-validate dims to avoid server 422s
if (query.length !== EMBEDDING_DIM) {
throw new Error(`vectorSearch: query dim ${query.length} != EMBEDDING_DIM ${EMBEDDING_DIM}`);
}

const k = Math.max(1, Math.min(Number(topK) || 10, MAX_TOPK)); // Clamp 1..MAX_TOPK
const r = await fetch(`${FAISS_URL}/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, top_k: k }),
cache: "no-store",
});
await ok(r, "faiss search");
return (await r.json()) as SearchHit[]; // [{ id, score, metadata }]
}

// Deleting by ids (dev service rebuilds index internally)
export async function vectorDelete(ids: string[]): Promise<void> {
if (!ids?.length) return;
const r = await fetch(`${FAISS_URL}/delete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
cache: "no-store",
});
await ok(r, "faiss delete");
}

// Quick readiness check
export async function vectorPing(): Promise<boolean> {
try {
const r = await fetch(`${FAISS_URL}/health`, { cache: "no-store" });
return r.ok;
} catch {
return false;
}
}
Loading