diff --git a/src/hooks/useNaturalLanguageComponentSearch.ts b/src/hooks/useNaturalLanguageComponentSearch.ts new file mode 100644 index 000000000..37ae09db1 --- /dev/null +++ b/src/hooks/useNaturalLanguageComponentSearch.ts @@ -0,0 +1,38 @@ +import { useMutation } from "@tanstack/react-query"; + +import { useComponentSearchSettings } from "@/hooks/useComponentSearchSettings"; +import { + type RerankCandidate, + rerankComponentsByNaturalLanguage, + type RerankResult, +} from "@/services/naturalLanguageComponentSearchService"; + +interface RerankVariables { + query: string; + candidates: RerankCandidate[]; +} + +/** + * Trigger an LLM rerank of pre-filtered candidates. Modeled as a mutation + * rather than a query because rerank is **explicitly initiated** by the user + * ("Smart Search" button), not automatic on every keystroke — that would + * burn tokens and add latency to the typeahead experience. + * + * The lexical index (see `componentSearchIndex.ts`) is what powers live + * search. Rerank is the optional, opt-in step when judgment matters more + * than literal matching. + */ +export function useNaturalLanguageComponentRerank() { + const { config, isConfigured } = useComponentSearchSettings(); + + const mutation = useMutation({ + mutationFn: ({ query, candidates }) => + rerankComponentsByNaturalLanguage(query, candidates, { + model: config.model, + apiBase: config.apiBase, + apiKey: config.apiKey, + }), + }); + + return { ...mutation, isConfigured }; +} diff --git a/src/routes/Dashboard/DashboardComponentsV2View.tsx b/src/routes/Dashboard/DashboardComponentsV2View.tsx index 25758ac67..f9ecc5f30 100644 --- a/src/routes/Dashboard/DashboardComponentsV2View.tsx +++ b/src/routes/Dashboard/DashboardComponentsV2View.tsx @@ -1,5 +1,5 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useNavigate, useSearch } from "@tanstack/react-router"; +import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import { useLiveQuery } from "dexie-react-hooks"; import { type ChangeEvent, useEffect, useState } from "react"; @@ -15,9 +15,12 @@ import { Icon } from "@/components/ui/icon"; import { Input } from "@/components/ui/input"; import { BlockStack, InlineStack } from "@/components/ui/layout"; 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 { getComponentQueryKey } from "@/hooks/useHydrateComponentReference"; +import { useNaturalLanguageComponentRerank } from "@/hooks/useNaturalLanguageComponentSearch"; +import useToastNotification from "@/hooks/useToastNotification"; import { cn } from "@/lib/utils"; import { useBackend } from "@/providers/BackendProvider"; import { @@ -43,6 +46,11 @@ import { fetchAndStoreComponentLibrary, hydrateComponentReference, } from "@/services/componentService"; +import { + componentReferenceToCandidate, + NaturalLanguageSearchConfigError, + type RerankedMatch, +} from "@/services/naturalLanguageComponentSearchService"; import type { ComponentFolder } from "@/types/componentLibrary"; import type { ComponentReference, @@ -55,6 +63,7 @@ import { tracking } from "@/utils/tracking"; import { isRecord } from "@/utils/typeGuards"; import { APP_ROUTES } from "../router"; +import { copyComponentReferenceToClipboard } from "../v2/shared/clipboard/copyComponentReferenceToClipboard"; // Repeated Tailwind combos extracted as named constants. const PANEL_CLASS = "p-3 rounded-lg bg-card border border-border"; @@ -81,7 +90,7 @@ const SOURCE_ICON_TONE_BY_KIND: Record = { user: "text-amber-500", }; -/** How many lexical hits to display. */ +/** How many lexical hits to display (and to feed into rerank). */ const LEXICAL_RESULT_LIMIT = 20; const MATCH_FIELD_LABEL: Record = { @@ -615,8 +624,40 @@ export const DashboardComponentsV2View = () => { limit: LEXICAL_RESULT_LIMIT, }); + const { + mutate: rerank, + data: rerankData, + isPending: isReranking, + error: rerankError, + reset: resetRerank, + isConfigured, + } = useNaturalLanguageComponentRerank(); + + // Reranked results are tied to the exact query that triggered them. If the + // user types more, we drop the rerank rather than show results for an old + // query. Tracked here so we can clear on input change. + const [rerankedFor, setRerankedFor] = useState(null); + const handleQueryChange = (event: ChangeEvent) => { setQuery(event.target.value); + if (rerankedFor !== null) { + setRerankedFor(null); + resetRerank(); + } + }; + + const handleSmartSearch = () => { + const trimmed = query.trim(); + if (trimmed.length === 0 || lexicalMatches.length === 0) return; + + const candidates = lexicalMatches + .map((m) => componentReferenceToCandidate(m.reference)) + .filter((c): c is NonNullable => c !== null); + + if (candidates.length === 0) return; + + setRerankedFor(trimmed); + rerank({ query: trimmed, candidates }); }; const handleSourceToggle = (sourceKey: string) => { @@ -625,10 +666,18 @@ export const DashboardComponentsV2View = () => { ? current.filter((key) => key !== sourceKey) : [...current, sourceKey], ); + if (rerankedFor !== null) { + setRerankedFor(null); + resetRerank(); + } }; const handleEnableAllSources = () => { setDisabledSourceKeys([]); + if (rerankedFor !== null) { + setRerankedFor(null); + resetRerank(); + } }; const isLoadingLibrary = @@ -640,6 +689,18 @@ export const DashboardComponentsV2View = () => { const noLibraryData = !isLoadingLibrary && totalAcrossSources === 0; const trimmedQuery = query.trim(); const isEmpty = trimmedQuery.length === 0; + const isConfigError = rerankError instanceof NaturalLanguageSearchConfigError; + const rerankActive = + rerankedFor !== null && + rerankedFor === trimmedQuery && + rerankData !== undefined && + !isReranking; + + // What we actually render. Rerank wins when active; otherwise lexical. + const displayedResults = rerankActive + ? mergeRerankIntoLexical(rerankData.matches, lexicalMatches) + : lexicalMatches.map((m) => ({ ...m, reason: undefined })); + // Resolve the full reference for the selected digest. Prefer the already- // hydrated copy (no extra network), fall back to the un-hydrated index // entry, then to a backend stub. The shared ComponentDetail will suspend on @@ -660,6 +721,23 @@ export const DashboardComponentsV2View = () => { }; })(); const isDetailOpen = Boolean(selectedDigest); + const notify = useToastNotification(); + + const handleCopyToPipeline = async () => { + if (!selectedReference) return; + try { + await copyComponentReferenceToClipboard(selectedReference); + notify( + "Component copied. Paste (Cmd/Ctrl+V) into a pipeline to add it.", + "success", + ); + } catch { + notify( + "Couldn't copy to clipboard. Check browser permissions and try again.", + "error", + ); + } + }; // Render helpers — keeps the JSX below tidy. These read the closed-over // state from the surrounding component; React Compiler memoises them. @@ -719,15 +797,17 @@ export const DashboardComponentsV2View = () => { return ( - {lexicalMatches.length} result{lexicalMatches.length === 1 ? "" : "s"}{" "} - for “{trimmedQuery}” + {rerankActive + ? `AI-reranked ${displayedResults.length} result${displayedResults.length === 1 ? "" : "s"} for “${trimmedQuery}”` + : `${displayedResults.length} result${displayedResults.length === 1 ? "" : "s"} for “${trimmedQuery}”`} - {lexicalMatches.map((result, idx) => ( + {displayedResults.map((result, idx) => ( { Type to search across every component source — standard library, your published components, registered libraries, and local user components. Results match on name, description, inputs/outputs, - and container command. + and container command. Use AI search to rerank with an LLM when + literal matching isn't enough. - + + + + { : "flex-1", )} > + {/* AI-search-unavailable banner and rerank error live in the + results column — they describe what just happened to the search + the user is looking at. */} + {!isConfigured && !isEmpty && lexicalMatches.length > 0 && ( + + + AI search unavailable + + + Configure an OpenAI-compatible API key to use AI search. Lexical + results above are unaffected. + + + + Configure in Settings → + + + + )} + {rerankError && !isConfigError && rerankError instanceof Error && ( + + AI search failed: {rerankError.message} + + )} {renderResults()} @@ -802,15 +924,43 @@ export const DashboardComponentsV2View = () => { aria-label="Component details" className="flex-1 min-w-0 overflow-y-auto px-8 py-4 relative" > - + {/* Sticky action row: copy + close. `float-right` here is + intentional — it lets the row sit above the content without + taking flow space, and the detail's first heading flows up + next to it. Wrapping in a sticky inline-block keeps both + buttons pinned together. */} +
+ + + + +
}> { ); }; + +/** + * Merge LLM rerank results back into the lexical match metadata so the UI + * can still show "matched: name" badges alongside the rerank reason. Items + * the LLM dropped are appended after the reranked ones (the lexical layer + * thought they were relevant, even if the LLM disagreed — surfacing them + * builds trust by not silently hiding lexical hits). + */ +function mergeRerankIntoLexical( + reranked: RerankedMatch[], + lexical: LexicalMatch[], +): Array { + const lexicalByDigest = new Map(lexical.map((m) => [m.digest, m])); + const out: Array = []; + + for (const r of reranked) { + const lex = lexicalByDigest.get(r.id); + if (!lex) continue; + out.push({ ...lex, reason: r.reason }); + lexicalByDigest.delete(r.id); + } + // Tail: lexical hits the LLM didn't rank. + for (const lex of lexicalByDigest.values()) { + out.push({ ...lex }); + } + return out; +} diff --git a/src/services/naturalLanguageComponentSearchService.ts b/src/services/naturalLanguageComponentSearchService.ts index 0aa2fe841..c693507c0 100644 --- a/src/services/naturalLanguageComponentSearchService.ts +++ b/src/services/naturalLanguageComponentSearchService.ts @@ -29,7 +29,7 @@ export interface RerankCandidate { outputs?: string[]; } -interface RerankedMatch { +export interface RerankedMatch { id: string; /** Model-provided relevance, clamped to [0, 1]. */ score: number;