forked from Goutam/lynkeduppro-crm
60 lines
2.1 KiB
TypeScript
60 lines
2.1 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect } from 'react';
|
|
|
|
import { cosineSimilarity, type AIProvider } from '../ai/types';
|
|
import { useGallery, useGalleryStoreApi } from '../store/context';
|
|
import { liveMedia } from '../store/selectors';
|
|
|
|
const DEBOUNCE_MS = 350;
|
|
const MIN_QUERY_LEN = 2;
|
|
/** Cosine-similarity floor for a photo to count as a semantic match. CLIP ViT-B/16
|
|
* scores strong matches ~0.25+, unrelated ~0.15 — 0.22 keeps it crisp. */
|
|
const MATCH_THRESHOLD = 0.22;
|
|
const MAX_RESULTS = 60;
|
|
|
|
/**
|
|
* Headless worker: when the user types a query, embeds it with the provider's
|
|
* text encoder (CLIP) and ranks every photo that has an image embedding by
|
|
* cosine similarity, writing the ordered ids to the store. The selector blends
|
|
* these "looks like" matches with the keyword results. Runs only if the provider
|
|
* supports embedText; otherwise search stays purely keyword-based.
|
|
*/
|
|
export function SemanticSearch({ provider }: { provider: AIProvider }) {
|
|
const api = useGalleryStoreApi();
|
|
const query = useGallery((s) => s.searchQuery);
|
|
|
|
useEffect(() => {
|
|
if (!provider.embedText) return;
|
|
const q = query.trim();
|
|
if (q.length < MIN_QUERY_LEN) {
|
|
api.getState().setSemanticResults(null);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
const timer = setTimeout(async () => {
|
|
const embedded = liveMedia(api.getState().media).filter((m) => (m.embedding?.length ?? 0) > 0);
|
|
if (embedded.length === 0) {
|
|
if (!cancelled) api.getState().setSemanticResults(null);
|
|
return;
|
|
}
|
|
const qvec = await provider.embedText!(q).catch(() => [] as number[]);
|
|
if (cancelled || qvec.length === 0) return;
|
|
const ranked = embedded
|
|
.map((m) => ({ id: m.id, score: cosineSimilarity(qvec, m.embedding!) }))
|
|
.filter((r) => r.score >= MATCH_THRESHOLD)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, MAX_RESULTS)
|
|
.map((r) => r.id);
|
|
if (!cancelled) api.getState().setSemanticResults(ranked);
|
|
}, DEBOUNCE_MS);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
clearTimeout(timer);
|
|
};
|
|
}, [query, provider, api]);
|
|
|
|
return null;
|
|
}
|