From 92a80e16064c08363508b9b2889b62756d8eb910 Mon Sep 17 00:00:00 2001 From: mbeaulne Date: Thu, 18 Jun 2026 14:18:10 -0400 Subject: [PATCH] add client-side embeddings cached in IndexedDB --- .../Dashboard/DashboardComponentsV2View.tsx | 79 +++++- .../Editor/hooks/useComponentSearchV2State.ts | 92 ++++++- .../componentSearchEmbeddings.test.ts | 168 ++++++++++++ src/services/componentSearchEmbeddings.ts | 257 ++++++++++++++++++ 4 files changed, 581 insertions(+), 15 deletions(-) create mode 100644 src/services/componentSearchEmbeddings.test.ts create mode 100644 src/services/componentSearchEmbeddings.ts diff --git a/src/routes/Dashboard/DashboardComponentsV2View.tsx b/src/routes/Dashboard/DashboardComponentsV2View.tsx index e8d1c2cb6..7bda95495 100644 --- a/src/routes/Dashboard/DashboardComponentsV2View.tsx +++ b/src/routes/Dashboard/DashboardComponentsV2View.tsx @@ -20,6 +20,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { QuickTooltip } from "@/components/ui/tooltip"; import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import { useAiProviderSettings } from "@/hooks/useAiProviderSettings"; import { getComponentQueryKey } from "@/hooks/useHydrateComponentReference"; import { useComponentAiDescription, @@ -38,6 +39,7 @@ import { LibraryDB, type StoredLibrary, } from "@/providers/ComponentLibraryProvider/libraries/storage"; +import { rankComponentMatchesByEmbeddings } from "@/services/componentSearchEmbeddings"; import { buildSearchIndex, type ComponentSearchSource, @@ -437,6 +439,23 @@ function sampleEvenly(entries: T[], limit: number): T[] { return sampled; } +function mergeUniqueMatches( + primary: LexicalMatch[], + secondary: LexicalMatch[], + fallback: LexicalMatch[], + limit: number, +): LexicalMatch[] { + const merged: LexicalMatch[] = []; + const seen = new Set(); + for (const match of [...primary, ...secondary, ...fallback]) { + if (seen.has(match.digest)) continue; + seen.add(match.digest); + merged.push(match); + if (merged.length >= limit) return merged; + } + return merged; +} + function collectAllSourcedReferences({ standardLibrary, publishedRefs, @@ -485,6 +504,7 @@ export const DashboardComponentsV2View = () => { "component-search-v2-ai-descriptions", ); const { backendUrl, configured, available } = useBackend(); + const { config: aiConfig } = useAiProviderSettings(); const [query, setQuery] = useState(""); const [disabledSourceKeys, setDisabledSourceKeys] = useState([]); @@ -770,6 +790,8 @@ export const DashboardComponentsV2View = () => { const [rerankBaseMatches, setRerankBaseMatches] = useState( [], ); + const [isEmbeddingSearchPending, setIsEmbeddingSearchPending] = + useState(false); const handleQueryChange = (event: ChangeEvent) => { setQuery(event.target.value); @@ -780,27 +802,57 @@ export const DashboardComponentsV2View = () => { } }; - const startAiSearch = (matches: LexicalMatch[]) => { + const buildEmbeddingMatches = async ( + trimmed: string, + limit: number, + ): Promise => { + if (!aiConfig.apiBase.trim()) return []; + setIsEmbeddingSearchPending(true); + try { + return await rankComponentMatchesByEmbeddings( + filteredIndex, + trimmed, + { apiBase: aiConfig.apiBase, apiKey: aiConfig.apiKey }, + { limit }, + ); + } catch { + return []; + } finally { + setIsEmbeddingSearchPending(false); + } + }; + + const startAiSearch = async (matches: LexicalMatch[], limit: number) => { const trimmed = query.trim(); if (trimmed.length === 0 || matches.length === 0) return; - const candidates = matches + const embeddingMatches = aiConfig.apiBase.trim() + ? await buildEmbeddingMatches(trimmed, limit) + : []; + const rerankMatches = mergeUniqueMatches( + matches.slice(0, 60), + embeddingMatches, + matches, + limit, + ); + + const candidates = rerankMatches .map((m) => componentReferenceToCandidate(m.reference, m.source)) .filter((c): c is NonNullable => c !== null); if (candidates.length === 0) return; - setRerankBaseMatches(matches); + setRerankBaseMatches(rerankMatches); setRerankedFor(trimmed); rerank({ query: trimmed, candidates }); }; const handleSmartSearch = () => { - startAiSearch(aiCandidateMatches); + void startAiSearch(aiCandidateMatches, aiCandidateMatches.length); }; const handleDeepAiSearch = () => { - startAiSearch(deepAiCandidateMatches); + void startAiSearch(deepAiCandidateMatches, deepAiCandidateMatches.length); }; const handleSourceToggle = (sourceKey: string) => { @@ -845,7 +897,8 @@ export const DashboardComponentsV2View = () => { rerankData !== undefined && rerankData.matches.length > 0 && rerankBaseMatches.length > 0 && - !isReranking; + !isReranking && + !isEmbeddingSearchPending; // What we actually render. Rerank wins when active; otherwise lexical. const displayedResults = rerankActive @@ -1033,20 +1086,30 @@ export const DashboardComponentsV2View = () => { onClick={handleSmartSearch} disabled={ isReranking || + isEmbeddingSearchPending || isEmpty || aiCandidateMatches.length === 0 || !isConfigured } - aria-label={isReranking ? "AI search in progress" : "AI search"} + aria-label={ + isReranking || isEmbeddingSearchPending + ? "AI search in progress" + : "AI search" + } title="AI search — rerank these results with an LLM" > - {isReranking ? : } + {isReranking || isEmbeddingSearchPending ? ( + + ) : ( + + )}