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
79 changes: 71 additions & 8 deletions src/routes/Dashboard/DashboardComponentsV2View.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -38,6 +39,7 @@ import {
LibraryDB,
type StoredLibrary,
} from "@/providers/ComponentLibraryProvider/libraries/storage";
import { rankComponentMatchesByEmbeddings } from "@/services/componentSearchEmbeddings";
import {
buildSearchIndex,
type ComponentSearchSource,
Expand Down Expand Up @@ -437,6 +439,23 @@ function sampleEvenly<T>(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<string>();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This is an AI-generated code review comment.

[LOW] This mergeUniqueMatches (dedupe-by-digest) is duplicated verbatim in useComponentSearchV2State.ts (~line 53). Consider exporting it once (e.g. from componentSearchIndex.ts) and importing it in both hosts.

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,
Expand Down Expand Up @@ -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<string[]>([]);

Expand Down Expand Up @@ -770,6 +790,8 @@ export const DashboardComponentsV2View = () => {
const [rerankBaseMatches, setRerankBaseMatches] = useState<LexicalMatch[]>(
[],
);
const [isEmbeddingSearchPending, setIsEmbeddingSearchPending] =
useState(false);

const handleQueryChange = (event: ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
Expand All @@ -780,27 +802,57 @@ export const DashboardComponentsV2View = () => {
}
};

const startAiSearch = (matches: LexicalMatch[]) => {
const buildEmbeddingMatches = async (
trimmed: string,
limit: number,
): Promise<LexicalMatch[]> => {
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<typeof c> => 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);
};

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This is an AI-generated code review comment.

maxTypoDistance allows edit distance 1 on 4-char tokens, so short generic IO names collide: data<->date, path<->bath, list<->last. Fuzzy is name/io-only and scored at 0.75x so exact hits still win, but a typo-free data could pull in date-named IO. Consider raising the distance-1 floor to length >= 5.

const handleDeepAiSearch = () => {
startAiSearch(deepAiCandidateMatches);
void startAiSearch(deepAiCandidateMatches, deepAiCandidateMatches.length);
};

const handleSourceToggle = (sourceKey: string) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ? <Spinner size={16} /> : <Icon name="Sparkles" />}
{isReranking || isEmbeddingSearchPending ? (
<Spinner size={16} />
) : (
<Icon name="Sparkles" />
)}
</Button>
<Button
variant="outline"
onClick={handleDeepAiSearch}
disabled={
isReranking ||
isEmbeddingSearchPending ||
isEmpty ||
deepAiCandidateMatches.length === 0 ||
!isConfigured
Expand Down
92 changes: 85 additions & 7 deletions src/routes/v2/pages/Editor/hooks/useComponentSearchV2State.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useLiveQuery } from "dexie-react-hooks";
import { useEffect, useState } from "react";

import { listApiPublishedComponentsGet } from "@/api/sdk.gen";
import { useAiProviderSettings } from "@/hooks/useAiProviderSettings";
import { getComponentQueryKey } from "@/hooks/useHydrateComponentReference";
import { useNaturalLanguageComponentRerank } from "@/hooks/useNaturalLanguageComponentSearch";
import { useBackend } from "@/providers/BackendProvider";
Expand Down Expand Up @@ -30,8 +31,10 @@ import {
registeredSource,
rerankedMatches,
} from "@/routes/v2/pages/Editor/components/componentSearchV2Logic";
import { rankComponentMatchesByEmbeddings } from "@/services/componentSearchEmbeddings";
import {
buildSearchIndex,
type IndexEntry,
type LexicalMatch,
type SourcedReference,
} from "@/services/componentSearchIndex";
Expand All @@ -47,11 +50,29 @@ import type {
} from "@/utils/componentSpec";
import { HOURS } from "@/utils/constants";

function mergeUniqueMatches(
primary: LexicalMatch[],
secondary: LexicalMatch[],
fallback: LexicalMatch[],
limit: number,
): LexicalMatch[] {
const merged: LexicalMatch[] = [];
const seen = new Set<string>();
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;
}

export function useComponentSearchV2State(
query: string,
): ComponentSearchV2State {
const queryClient = useQueryClient();
const { backendUrl, configured, available } = useBackend();
const { config: aiConfig } = useAiProviderSettings();

const { data: standardLibrary, isLoading: isLoadingStandardLibrary } =
useQuery({
Expand Down Expand Up @@ -191,6 +212,8 @@ export function useComponentSearchV2State(
const [rerankBaseMatches, setRerankBaseMatches] = useState<LexicalMatch[]>(
[],
);
const [isEmbeddingSearchPending, setIsEmbeddingSearchPending] =
useState(false);

useEffect(() => {
setRerankedFor(null);
Expand All @@ -201,19 +224,66 @@ export function useComponentSearchV2State(
rerankedFor === trimmedQuery &&
rerankBaseMatches.length > 0 &&
!isReranking &&
!isEmbeddingSearchPending &&
rerankData !== undefined;

const displayedMatches = isRerankActive
? rerankedMatches(rerankData, rerankBaseMatches)
: lexicalMatches;

const startRerank = (
const buildEmbeddingMatches = async ({
sourceIndex,
limit,
}: {
sourceIndex: IndexEntry[];
limit: number;
}): Promise<LexicalMatch[]> => {
if (!aiConfig.apiBase.trim()) return [];
setIsEmbeddingSearchPending(true);
try {
return await rankComponentMatchesByEmbeddings(
sourceIndex,
trimmedQuery,
{
apiBase: aiConfig.apiBase,
apiKey: aiConfig.apiKey,
},
{ limit },
);
} catch {
return [];
} finally {
setIsEmbeddingSearchPending(false);
}
};

const startRerank = async (
matches: LexicalMatch[],
{ scoreAllCandidates }: { scoreAllCandidates: boolean },
{
scoreAllCandidates,
useEmbeddings,
limit,
}: {
scoreAllCandidates: boolean;
useEmbeddings: boolean;
limit: number;
},
) => {
if (!trimmedQuery || matches.length === 0 || !isConfigured) return;

const candidates = matches
const embeddingMatches =
useEmbeddings && aiConfig.apiBase.trim()
? await buildEmbeddingMatches({ sourceIndex: index, limit })
: [];
const lexicalMatchesToKeep = useEmbeddings ? matches.slice(0, 60) : matches;
const rerankMatches = mergeUniqueMatches(
lexicalMatchesToKeep,
embeddingMatches,
matches,
limit,
);

const candidates = rerankMatches
.map((match) =>
componentReferenceToCandidate(match.reference, match.source),
)
Expand All @@ -223,17 +293,25 @@ export function useComponentSearchV2State(

if (candidates.length === 0) return;

setRerankBaseMatches(matches);
setRerankBaseMatches(rerankMatches);
setRerankedFor(trimmedQuery);
mutate({ query: trimmedQuery, candidates, scoreAllCandidates });
};

const rerank = () => {
startRerank(aiCandidateMatches, { scoreAllCandidates: true });
void startRerank(aiCandidateMatches, {
scoreAllCandidates: true,
useEmbeddings: true,
limit: aiCandidateMatches.length,
});
};

const deepRerank = () => {
startRerank(deepAiCandidateMatches, { scoreAllCandidates: false });
void startRerank(deepAiCandidateMatches, {
scoreAllCandidates: false,
useEmbeddings: true,
limit: deepAiCandidateMatches.length,
});
};

const rerankScoreByDigest = buildRerankScoreByDigest(
Expand Down Expand Up @@ -262,7 +340,7 @@ export function useComponentSearchV2State(
trimmedQuery.length > 0 &&
deepAiCandidateMatches.length > 0 &&
isConfigured,
isReranking,
isReranking: isReranking || isEmbeddingSearchPending,
rerank,
deepRerank,
};
Expand Down
Loading
Loading