From 9815ec4258cc636f17ca3f08ccd3062088633373 Mon Sep 17 00:00:00 2001 From: mbeaulne Date: Thu, 28 May 2026 10:37:55 -0400 Subject: [PATCH] Component description --- .../ComponentDetail/ComponentDetail.tsx | 7 +- src/flags.ts | 8 + .../useNaturalLanguageComponentSearch.ts | 26 ++ .../DashboardComponentsV2SourceFilter.tsx | 148 +++++++ .../DashboardComponentsV2View.test.tsx | 376 +++++++++++++++++- .../Dashboard/DashboardComponentsV2View.tsx | 275 ++++++++----- .../sections/BetaFeaturesSettings.test.tsx | 77 ++++ .../sections/BetaFeaturesSettings.tsx | 10 +- ...uralLanguageComponentSearchService.test.ts | 65 +++ .../naturalLanguageComponentSearchService.ts | 160 +++++++- 10 files changed, 1027 insertions(+), 125 deletions(-) create mode 100644 src/routes/Dashboard/DashboardComponentsV2SourceFilter.tsx create mode 100644 src/routes/Settings/sections/BetaFeaturesSettings.test.tsx diff --git a/src/components/shared/ComponentDetail/ComponentDetail.tsx b/src/components/shared/ComponentDetail/ComponentDetail.tsx index 6c1609dfb..670036d57 100644 --- a/src/components/shared/ComponentDetail/ComponentDetail.tsx +++ b/src/components/shared/ComponentDetail/ComponentDetail.tsx @@ -168,12 +168,15 @@ interface ComponentDetailProps { * top nav). In `stacked` layout this caps the inline source card's height. */ sourcePanelHeight?: string; + /** Hide the source-authored description when the caller renders its own description panel. */ + hideDescription?: boolean; } export const ComponentDetail = ({ reference, layout = "split", sourcePanelHeight, + hideDescription = false, }: ComponentDetailProps) => { const hydrated = useHydrateComponentReference(reference); @@ -285,7 +288,7 @@ export const ComponentDetail = ({ // their intrinsic width, which looks broken in wide panes. {header} - {description} + {!hideDescription && description} {githubLinks} {io} {hydrated.text && ( @@ -313,7 +316,7 @@ export const ComponentDetail = ({
{header} - {description} + {!hideDescription && description} {githubLinks} {io}
diff --git a/src/flags.ts b/src/flags.ts index 46150c548..1e20184c4 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -62,4 +62,12 @@ export const ExistingFlags: ConfigFlags = { default: false, category: "beta", }, + + ["component-search-v2-ai-descriptions"]: { + name: "Auto-generate Components V2 AI descriptions", + description: + "Automatically generate an AI description when viewing a component in Components V2.", + default: false, + category: "beta", + }, }; diff --git a/src/hooks/useNaturalLanguageComponentSearch.ts b/src/hooks/useNaturalLanguageComponentSearch.ts index 37ae09db1..f0f4a219b 100644 --- a/src/hooks/useNaturalLanguageComponentSearch.ts +++ b/src/hooks/useNaturalLanguageComponentSearch.ts @@ -2,16 +2,23 @@ import { useMutation } from "@tanstack/react-query"; import { useComponentSearchSettings } from "@/hooks/useComponentSearchSettings"; import { + type ComponentDescriptionResult, + generateComponentAiDescription, type RerankCandidate, rerankComponentsByNaturalLanguage, type RerankResult, } from "@/services/naturalLanguageComponentSearchService"; +import type { ComponentReference } from "@/utils/componentSpec"; interface RerankVariables { query: string; candidates: RerankCandidate[]; } +interface DescriptionVariables { + reference: ComponentReference; +} + /** * Trigger an LLM rerank of pre-filtered candidates. Modeled as a mutation * rather than a query because rerank is **explicitly initiated** by the user @@ -36,3 +43,22 @@ export function useNaturalLanguageComponentRerank() { return { ...mutation, isConfigured }; } + +export function useComponentAiDescription() { + const { config, isConfigured } = useComponentSearchSettings(); + + const mutation = useMutation< + ComponentDescriptionResult, + Error, + DescriptionVariables + >({ + mutationFn: ({ reference }) => + generateComponentAiDescription(reference, { + model: config.model, + apiBase: config.apiBase, + apiKey: config.apiKey, + }), + }); + + return { ...mutation, isConfigured }; +} diff --git a/src/routes/Dashboard/DashboardComponentsV2SourceFilter.tsx b/src/routes/Dashboard/DashboardComponentsV2SourceFilter.tsx new file mode 100644 index 000000000..c01600096 --- /dev/null +++ b/src/routes/Dashboard/DashboardComponentsV2SourceFilter.tsx @@ -0,0 +1,148 @@ +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Paragraph, Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import type { + ComponentSource, + IndexEntry, +} from "@/services/componentSearchIndex"; +import { tracking } from "@/utils/tracking"; + +const SOURCE_ICON_TONE_BY_KIND: Record = { + standard: "text-blue-500", + published: "text-emerald-500", + registered: "text-violet-500", + user: "text-amber-500", +}; + +const SOURCE_FILTER_LABEL_BY_KIND: Record = { + standard: "Standard", + published: "Published", + registered: "Registered libraries", + user: "User generated", +}; + +export interface SourceFilterOption { + source: ComponentSource; + count: number; +} + +function sourceFilterKey(source: ComponentSource): string { + return source.kind; +} + +/** + * Filter state is keyed by source kind so multiple registered libraries behave + * as one source category instead of several per-library toggles. + */ +export function createSourceFilterOptions( + index: IndexEntry[], +): SourceFilterOption[] { + const optionsByKey = new Map(); + + for (const entry of index) { + const key = sourceFilterKey(entry.source); + const option = optionsByKey.get(key); + if (option) { + option.count += 1; + } else { + optionsByKey.set(key, { + source: { + kind: entry.source.kind, + id: entry.source.kind, + label: SOURCE_FILTER_LABEL_BY_KIND[entry.source.kind], + }, + count: 1, + }); + } + } + + return Array.from(optionsByKey.values()); +} + +/** Apply source-type filter state to indexed component results. */ +export function filterIndexByDisabledSourceKeys( + index: IndexEntry[], + disabledSourceKeys: string[], +): IndexEntry[] { + const disabled = new Set(disabledSourceKeys); + return index.filter((entry) => !disabled.has(sourceFilterKey(entry.source))); +} + +interface SourceFilterBarProps { + options: SourceFilterOption[]; + disabledSourceKeys: string[]; + onToggle: (sourceKey: string) => void; + onEnableAll: () => void; +} + +export const SourceFilterBar = ({ + options, + disabledSourceKeys, + onToggle, + onEnableAll, +}: SourceFilterBarProps) => { + const disabled = new Set(disabledSourceKeys); + const activeCount = options.filter( + (option) => !disabled.has(sourceFilterKey(option.source)), + ).length; + + if (options.length <= 1 && activeCount === options.length) return null; + + return ( + + + + Sources + + {options.map(({ source, count }) => { + const key = sourceFilterKey(source); + const active = !disabled.has(key); + return ( + + ); + })} + {activeCount < options.length && ( + + )} + + {activeCount === 0 && ( + + No sources selected. Turn on at least one source to show components. + + )} + + ); +}; diff --git a/src/routes/Dashboard/DashboardComponentsV2View.test.tsx b/src/routes/Dashboard/DashboardComponentsV2View.test.tsx index 58e694209..f77981436 100644 --- a/src/routes/Dashboard/DashboardComponentsV2View.test.tsx +++ b/src/routes/Dashboard/DashboardComponentsV2View.test.tsx @@ -1,12 +1,180 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { StoredLibrary } from "@/providers/ComponentLibraryProvider/libraries/storage"; +import type { IndexEntry } from "@/services/componentSearchIndex"; +import type { ComponentReference } from "@/utils/componentSpec"; + +interface DashboardComponentsV2Search { + component?: string; +} + +const routeMocks = vi.hoisted(() => { + const makeComponent = (digest: string, name: string): ComponentReference => ({ + digest, + name, + text: "component yaml", + spec: { + name, + description: `${name} description`, + implementation: { container: { image: "python:3.11" } }, + }, + }); + + const search: DashboardComponentsV2Search = {}; + const descriptionErrorState: { current: Error | null } = { current: null }; + + return { + standard: makeComponent("standard-digest", "Standard component"), + registered: makeComponent("registered-digest", "Registered component"), + user: makeComponent("user-digest", "User component"), + navigate: vi.fn(), + notify: vi.fn(), + generateAiDescription: vi.fn(), + resetAiDescription: vi.fn(), + descriptionErrorState, + aiDescriptionsEnabled: false, + search, + }; +}); + +vi.mock("@tanstack/react-query", () => ({ + queryOptions: (options: unknown) => options, + useQueryClient: () => ({ ensureQueryData: vi.fn() }), + useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => { + const key = queryKey[0]; + + if (key === "componentLibrary") { + return { + data: { + name: "Standard", + components: [routeMocks.standard], + folders: [], + }, + isLoading: false, + }; + } + + if (key === "userComponents") { + return { + data: { components: [routeMocks.user] }, + isLoading: false, + }; + } + + if (key === "component-search-v2" && queryKey[1] === "published") { + return { data: [], isLoading: false }; + } + + if ( + key === "component-search-v2" && + queryKey[1] === "registered-libraries" + ) { + return { + data: [ + { + reference: routeMocks.registered, + source: { + kind: "registered", + label: "GitHub library", + id: "github-lib", + }, + }, + ], + isLoading: false, + }; + } + + if (key === "component-search-v2" && queryKey[1] === "hydrate-library") { + return { + data: [routeMocks.standard, routeMocks.registered, routeMocks.user], + isLoading: false, + }; + } + + return { data: undefined, isLoading: false }; + }, +})); + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ children }: { children: React.ReactNode }) => {children}, + useNavigate: () => routeMocks.navigate, + useSearch: () => routeMocks.search, +})); + +vi.mock("dexie-react-hooks", () => ({ + useLiveQuery: () => [], +})); + +vi.mock("@/providers/BackendProvider", () => ({ + useBackend: () => ({ + backendUrl: "https://backend.example", + configured: false, + available: false, + }), +})); + +vi.mock("@/components/shared/Settings/useFlags", () => ({ + useFlagValue: () => routeMocks.aiDescriptionsEnabled, +})); + +vi.mock("@/hooks/useNaturalLanguageComponentSearch", () => ({ + useComponentAiDescription: () => ({ + mutate: routeMocks.generateAiDescription, + isPending: false, + error: routeMocks.descriptionErrorState.current, + reset: routeMocks.resetAiDescription, + isConfigured: true, + }), + useNaturalLanguageComponentRerank: () => ({ + mutate: vi.fn(), + data: undefined, + isPending: false, + error: null, + reset: vi.fn(), + isConfigured: false, + }), +})); + +vi.mock("@/hooks/useToastNotification", () => ({ + default: () => routeMocks.notify, +})); + +vi.mock("@/components/shared/ComponentDetail/ComponentDetail", () => ({ + ComponentDetail: () =>
Component detail
, + ComponentDetailSkeleton: () =>
Loading component detail
, +})); + +vi.mock("@/components/shared/SuspenseWrapper", () => ({ + SuspenseWrapper: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + withSuspenseWrapper: + (Component: React.ComponentType) => (props: Record) => ( + + ), +})); + +vi.mock("../v2/shared/clipboard/copyComponentReferenceToClipboard", () => ({ + copyComponentReferenceToClipboard: vi.fn(), +})); + +vi.mock("../router", () => ({ + APP_ROUTES: { + DASHBOARD_COMPONENTS_V2: "/dashboard/components-v2", + SETTINGS_AGENT: "/settings/agent", + }, +})); import { - createRegisteredLibrariesFingerprint, + createSourceFilterOptions, + filterIndexByDisabledSourceKeys, SourceFilterBar, type SourceFilterOption, +} from "./DashboardComponentsV2SourceFilter"; +import { + createRegisteredLibrariesFingerprint, + DashboardComponentsV2View, } from "./DashboardComponentsV2View"; const options: SourceFilterOption[] = [ @@ -15,15 +183,37 @@ const options: SourceFilterOption[] = [ count: 2, }, { - source: { kind: "registered", label: "GitHub", id: "github-lib" }, + source: { + kind: "registered", + label: "Registered libraries", + id: "registered", + }, count: 3, }, { - source: { kind: "user", label: "User", id: "user" }, + source: { kind: "user", label: "User generated", id: "user" }, count: 1, }, ]; +function createIndexEntry( + digest: string, + source: IndexEntry["source"], +): IndexEntry { + return { + digest, + source, + reference: { digest }, + name: digest, + searchable: { + name: digest, + description: "", + io: "", + implementation: "", + }, + }; +} + describe("createRegisteredLibrariesFingerprint", () => { it("excludes secret-bearing library configuration", () => { const libraries: StoredLibrary[] = [ @@ -47,17 +237,103 @@ describe("createRegisteredLibrariesFingerprint", () => { expect(fingerprint).not.toContain("ghp_secret-token"); expect(fingerprint).not.toContain("access_token"); }); + + it("changes when known digest values change without count changes", () => { + const createLibrary = (knownDigests: string[]): StoredLibrary => ({ + id: "github-lib", + name: "GitHub", + type: "github", + knownDigests, + configuration: { + repo_name: "owner/repo", + last_updated_at: "2026-01-01T00:00:00Z", + access_token: "ghp_secret-token", + auto_update: true, + }, + }); + + expect( + createRegisteredLibrariesFingerprint([createLibrary(["a", "b"])]), + ).not.toEqual( + createRegisteredLibrariesFingerprint([createLibrary(["c", "d"])]), + ); + }); +}); + +describe("createSourceFilterOptions", () => { + it("groups multiple registered libraries into one registered filter", () => { + const result = createSourceFilterOptions([ + createIndexEntry("github-component", { + kind: "registered", + label: "GitHub library", + id: "github-lib", + }), + createIndexEntry("other-registered-component", { + kind: "registered", + label: "Other registered library", + id: "other-lib", + }), + createIndexEntry("user-component", { + kind: "user", + label: "User", + id: "user", + }), + ]); + + expect(result).toEqual( + expect.arrayContaining([ + { + source: { + kind: "registered", + label: "Registered libraries", + id: "registered", + }, + count: 2, + }, + { + source: { + kind: "user", + label: "User generated", + id: "user", + }, + count: 1, + }, + ]), + ); + }); +}); + +describe("filterIndexByDisabledSourceKeys", () => { + it("removes results from disabled source types", () => { + const result = filterIndexByDisabledSourceKeys( + [ + createIndexEntry("registered-component", { + kind: "registered", + label: "Registered library", + id: "registered-lib", + }), + createIndexEntry("user-component", { + kind: "user", + label: "User", + id: "user", + }), + ], + ["registered"], + ); + + expect(result.map((entry) => entry.digest)).toEqual(["user-component"]); + }); }); describe("SourceFilterBar", () => { - it("toggles source buttons and exposes active state", () => { + it("toggles source type buttons and exposes active state", () => { const onToggle = vi.fn(); const onEnableAll = vi.fn(); render( , @@ -65,19 +341,97 @@ describe("SourceFilterBar", () => { expect( screen.getByRole("button", { - name: "Hide Standard source (2 components)", + name: "Standard source (2 components)", }), ).toHaveAttribute("aria-pressed", "true"); expect( - screen.getByRole("button", { name: "Show GitHub source (3 components)" }), + screen.getByRole("button", { + name: "Registered libraries source (3 components)", + }), ).toHaveAttribute("aria-pressed", "false"); fireEvent.click( - screen.getByRole("button", { name: "Show GitHub source (3 components)" }), + screen.getByRole("button", { + name: "Registered libraries source (3 components)", + }), ); - expect(onToggle).toHaveBeenCalledWith("registered:github-lib"); + expect(onToggle).toHaveBeenCalledWith("registered"); fireEvent.click(screen.getByRole("button", { name: "Show all" })); expect(onEnableAll).toHaveBeenCalledTimes(1); }); }); + +describe("DashboardComponentsV2View", () => { + beforeEach(() => { + routeMocks.aiDescriptionsEnabled = false; + routeMocks.descriptionErrorState.current = null; + routeMocks.search = {}; + routeMocks.generateAiDescription.mockClear(); + routeMocks.resetAiDescription.mockClear(); + }); + + it("filters visible component results by source type and restores them", () => { + render(); + + expect(screen.getByText("Registered component")).toBeInTheDocument(); + expect(screen.getByText("Standard component")).toBeInTheDocument(); + expect(screen.getByText("User component")).toBeInTheDocument(); + + fireEvent.click( + screen.getByRole("button", { + name: "Registered libraries source (1 component)", + }), + ); + + expect(screen.queryByText("Registered component")).not.toBeInTheDocument(); + expect(screen.getByText("Standard component")).toBeInTheDocument(); + expect(screen.getByText("User component")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Show all" })); + + expect(screen.getByText("Registered component")).toBeInTheDocument(); + }); + + it("shows a manual generate button when automatic descriptions are disabled", () => { + routeMocks.search = { component: "standard-digest" }; + + render(); + + expect(routeMocks.generateAiDescription).not.toHaveBeenCalled(); + + fireEvent.click( + screen.getByRole("button", { name: "Generate AI description" }), + ); + + expect(routeMocks.generateAiDescription).toHaveBeenCalledWith( + { reference: routeMocks.standard }, + expect.any(Object), + ); + }); + + it("generates component descriptions automatically when the flag is enabled", async () => { + routeMocks.aiDescriptionsEnabled = true; + routeMocks.search = { component: "standard-digest" }; + + render(); + + await waitFor(() => { + expect(routeMocks.generateAiDescription).toHaveBeenCalledWith( + { reference: routeMocks.standard }, + expect.any(Object), + ); + }); + }); + + it("does not automatically retry description generation after an error", () => { + routeMocks.aiDescriptionsEnabled = true; + routeMocks.descriptionErrorState.current = new Error("provider failed"); + routeMocks.search = { component: "standard-digest" }; + + render(); + + expect(routeMocks.generateAiDescription).not.toHaveBeenCalled(); + expect(screen.getByText(/provider failed/)).toBeInTheDocument(); + }); +}); diff --git a/src/routes/Dashboard/DashboardComponentsV2View.tsx b/src/routes/Dashboard/DashboardComponentsV2View.tsx index f9ecc5f30..31a0cfc7e 100644 --- a/src/routes/Dashboard/DashboardComponentsV2View.tsx +++ b/src/routes/Dashboard/DashboardComponentsV2View.tsx @@ -8,6 +8,7 @@ import { ComponentDetail, ComponentDetailSkeleton, } from "@/components/shared/ComponentDetail/ComponentDetail"; +import { useFlagValue } from "@/components/shared/Settings/useFlags"; import { SuspenseWrapper } from "@/components/shared/SuspenseWrapper"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -19,7 +20,10 @@ 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 { + useComponentAiDescription, + useNaturalLanguageComponentRerank, +} from "@/hooks/useNaturalLanguageComponentSearch"; import useToastNotification from "@/hooks/useToastNotification"; import { cn } from "@/lib/utils"; import { useBackend } from "@/providers/BackendProvider"; @@ -64,6 +68,11 @@ import { isRecord } from "@/utils/typeGuards"; import { APP_ROUTES } from "../router"; import { copyComponentReferenceToClipboard } from "../v2/shared/clipboard/copyComponentReferenceToClipboard"; +import { + createSourceFilterOptions, + filterIndexByDisabledSourceKeys, + SourceFilterBar, +} from "./DashboardComponentsV2SourceFilter"; // Repeated Tailwind combos extracted as named constants. const PANEL_CLASS = "p-3 rounded-lg bg-card border border-border"; @@ -100,98 +109,6 @@ const MATCH_FIELD_LABEL: Record = { implementation: "command", }; -export interface SourceFilterOption { - source: ComponentSource; - count: number; -} - -function sourceFilterKey(source: ComponentSource): string { - return `${source.kind}:${source.id}`; -} - -function createSourceFilterOptions(index: IndexEntry[]): SourceFilterOption[] { - const optionsByKey = new Map(); - - for (const entry of index) { - const key = sourceFilterKey(entry.source); - const option = optionsByKey.get(key); - if (option) { - option.count += 1; - } else { - optionsByKey.set(key, { source: entry.source, count: 1 }); - } - } - - return Array.from(optionsByKey.values()); -} - -interface SourceFilterBarProps { - options: SourceFilterOption[]; - disabledSourceKeys: string[]; - onToggle: (sourceKey: string) => void; - onEnableAll: () => void; -} - -export const SourceFilterBar = ({ - options, - disabledSourceKeys, - onToggle, - onEnableAll, -}: SourceFilterBarProps) => { - if (options.length <= 1) return null; - - const disabled = new Set(disabledSourceKeys); - const activeCount = options.filter( - (option) => !disabled.has(sourceFilterKey(option.source)), - ).length; - - return ( - - - - Sources - - {options.map(({ source, count }) => { - const key = sourceFilterKey(source); - const active = !disabled.has(key); - return ( - - ); - })} - {activeCount < options.length && ( - - )} - - {activeCount === 0 && ( - - No sources selected. Turn on at least one source to show components. - - )} - - ); -}; - // Built-in sources are constants — only registered libraries vary per row. const STANDARD_SOURCE: ComponentSource = { kind: "standard", @@ -245,7 +162,7 @@ export function createRegisteredLibrariesFingerprint( id: library.id, type: library.type, name: library.name, - knownDigestsCount: library.knownDigests.length, + knownDigests: [...library.knownDigests].sort(), configuration: registeredLibraryConfigurationFingerprint( library.configuration, ), @@ -361,6 +278,78 @@ const ComponentCard = ({ ); }; +interface ComponentDescriptionPanelProps { + prefilledDescription?: string; + generatedDescription?: string; + isGenerating: boolean; + generationError: Error | null; + isConfigured: boolean; + onGenerate: () => void; +} + +const ComponentDescriptionPanel = ({ + prefilledDescription, + generatedDescription, + isGenerating, + generationError, + isConfigured, + onGenerate, +}: ComponentDescriptionPanelProps) => { + return ( + + + + Prefilled description + + + {prefilledDescription?.trim() || "No prefilled description provided."} + + + + + + AI-generated description + + {isGenerating && } + + {!isConfigured ? ( + + Configure Agent settings to generate an AI description. + + ) : generationError ? ( + + + Couldn't generate a description: {generationError.message} + + + + ) : generatedDescription ? ( + + {generatedDescription} + + ) : isGenerating ? ( + + Generating description… + + ) : ( + + )} + + {!isConfigured && ( + + + Configure in Settings → + + + )} + + ); +}; + /** * Merge every component source the rest of the app knows about into a single * deduped, source-attributed list. @@ -413,6 +402,9 @@ function collectAllSourcedReferences({ export const DashboardComponentsV2View = () => { const queryClient = useQueryClient(); + const aiDescriptionsEnabled = useFlagValue( + "component-search-v2-ai-descriptions", + ); const { backendUrl, configured, available } = useBackend(); const [query, setQuery] = useState(""); const [disabledSourceKeys, setDisabledSourceKeys] = useState([]); @@ -607,9 +599,9 @@ export const DashboardComponentsV2View = () => { // The search index is a pure derivation. React Compiler will memoize this. const index: IndexEntry[] = buildSearchIndex(sourcedHydrated); const sourceFilterOptions = createSourceFilterOptions(index); - const disabledSourceKeySet = new Set(disabledSourceKeys); - const filteredIndex = index.filter( - (entry) => !disabledSourceKeySet.has(sourceFilterKey(entry.source)), + const filteredIndex = filterIndexByDisabledSourceKeys( + index, + disabledSourceKeys, ); const total = filteredIndex.length; const totalAcrossSources = index.length; @@ -632,11 +624,21 @@ export const DashboardComponentsV2View = () => { reset: resetRerank, isConfigured, } = useNaturalLanguageComponentRerank(); + const { + mutate: generateAiDescription, + isPending: isGeneratingDescription, + error: descriptionError, + reset: resetDescription, + isConfigured: canGenerateDescription, + } = useComponentAiDescription(); // 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 [generatedDescriptions, setGeneratedDescriptions] = useState< + Record + >({}); const handleQueryChange = (event: ChangeEvent) => { setQuery(event.target.value); @@ -721,8 +723,60 @@ export const DashboardComponentsV2View = () => { }; })(); const isDetailOpen = Boolean(selectedDigest); + const selectedGeneratedDescription = selectedDigest + ? generatedDescriptions[selectedDigest] + : undefined; const notify = useToastNotification(); + const handleGenerateDescription = () => { + if (!selectedReference?.digest || !selectedReference.spec) return; + if (!canGenerateDescription) return; + + const digest = selectedReference.digest; + resetDescription(); + generateAiDescription( + { reference: selectedReference }, + { + onSuccess: (result) => { + setGeneratedDescriptions((current) => ({ + ...current, + [digest]: result.description, + })); + }, + }, + ); + }; + + useEffect(() => { + resetDescription(); + }, [resetDescription, selectedDigest]); + + useEffect(() => { + if (!selectedReference?.digest || !selectedReference.spec) { + resetDescription(); + return; + } + if (!aiDescriptionsEnabled) return; + if (!canGenerateDescription) { + resetDescription(); + return; + } + if (isGeneratingDescription) return; + if (descriptionError) return; + if (generatedDescriptions[selectedReference.digest]) return; + + handleGenerateDescription(); + }, [ + aiDescriptionsEnabled, + canGenerateDescription, + descriptionError, + generatedDescriptions, + handleGenerateDescription, + isGeneratingDescription, + resetDescription, + selectedReference, + ]); + const handleCopyToPipeline = async () => { if (!selectedReference) return; try { @@ -961,14 +1015,27 @@ export const DashboardComponentsV2View = () => { - }> - + - + }> + + +
)} diff --git a/src/routes/Settings/sections/BetaFeaturesSettings.test.tsx b/src/routes/Settings/sections/BetaFeaturesSettings.test.tsx new file mode 100644 index 000000000..801453780 --- /dev/null +++ b/src/routes/Settings/sections/BetaFeaturesSettings.test.tsx @@ -0,0 +1,77 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Flag } from "@/types/configuration"; + +const mocks = vi.hoisted(() => { + const betaFlags: Flag[] = []; + return { + betaFlags, + handleSetFlag: vi.fn(), + track: vi.fn(), + }; +}); + +vi.mock("@/providers/AnalyticsProvider", () => ({ + useAnalytics: () => ({ track: mocks.track }), +})); + +vi.mock("../SettingsFlagsContext", () => ({ + useSettingsFlags: () => ({ + betaFlags: mocks.betaFlags, + handleSetFlag: mocks.handleSetFlag, + }), +})); + +import { BetaFeaturesSettings } from "./BetaFeaturesSettings"; + +const componentSearchFlag: Flag = { + key: "component-search-v2", + name: "Component Search V2", + description: "Show Components V2.", + default: false, + enabled: false, + category: "beta", +}; + +const aiDescriptionFlag: Flag = { + key: "component-search-v2-ai-descriptions", + name: "Auto-generate Components V2 AI descriptions", + description: "Automatically generate AI descriptions.", + default: false, + enabled: false, + category: "beta", +}; + +describe("BetaFeaturesSettings", () => { + beforeEach(() => { + mocks.betaFlags = []; + mocks.handleSetFlag.mockClear(); + mocks.track.mockClear(); + }); + + it("hides the AI descriptions flag when Components V2 is disabled", () => { + mocks.betaFlags = [componentSearchFlag, aiDescriptionFlag]; + + render(); + + expect(screen.getByText("Component Search V2")).toBeInTheDocument(); + expect( + screen.queryByText("Auto-generate Components V2 AI descriptions"), + ).not.toBeInTheDocument(); + }); + + it("shows the AI descriptions flag when Components V2 is enabled", () => { + mocks.betaFlags = [ + { ...componentSearchFlag, enabled: true }, + aiDescriptionFlag, + ]; + + render(); + + expect(screen.getByText("Component Search V2")).toBeInTheDocument(); + expect( + screen.getByText("Auto-generate Components V2 AI descriptions"), + ).toBeInTheDocument(); + }); +}); diff --git a/src/routes/Settings/sections/BetaFeaturesSettings.tsx b/src/routes/Settings/sections/BetaFeaturesSettings.tsx index f4bfc841f..0b2f3f254 100644 --- a/src/routes/Settings/sections/BetaFeaturesSettings.tsx +++ b/src/routes/Settings/sections/BetaFeaturesSettings.tsx @@ -6,6 +6,14 @@ import { useSettingsFlags } from "../SettingsFlagsContext"; export function BetaFeaturesSettings() { const { betaFlags, handleSetFlag } = useSettingsFlags(); const { track } = useAnalytics(); + const componentSearchV2Enabled = betaFlags.some( + (flag) => flag.key === "component-search-v2" && flag.enabled, + ); + const visibleBetaFlags = componentSearchV2Enabled + ? betaFlags + : betaFlags.filter( + (flag) => flag.key !== "component-search-v2-ai-descriptions", + ); const handleChange = (key: string, enabled: boolean) => { track("settings.toggle_changed", { @@ -16,5 +24,5 @@ export function BetaFeaturesSettings() { handleSetFlag(key, enabled); }; - return ; + return ; } diff --git a/src/services/naturalLanguageComponentSearchService.test.ts b/src/services/naturalLanguageComponentSearchService.test.ts index 6da0bf7b3..d8953278f 100644 --- a/src/services/naturalLanguageComponentSearchService.test.ts +++ b/src/services/naturalLanguageComponentSearchService.test.ts @@ -4,6 +4,7 @@ import type { ComponentReference } from "@/utils/componentSpec"; import { componentReferenceToCandidate, + generateComponentAiDescription, NaturalLanguageSearchConfigError, rerankComponentsByNaturalLanguage, } from "./naturalLanguageComponentSearchService"; @@ -264,3 +265,67 @@ describe("rerankComponentsByNaturalLanguage", () => { expect(body.temperature).toBe(0); }); }); + +describe("generateComponentAiDescription", () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const reference: ComponentReference = { + digest: "abc", + spec: { + name: "train_model", + description: "Trains a model.", + inputs: [{ name: "dataset", description: "Training data" }], + outputs: [{ name: "model", description: "Trained model" }], + implementation: { + container: { + image: "python:3.12", + command: ["python", "train.py"], + }, + }, + }, + }; + + it("generates a description from a component spec", async () => { + vi.mocked(global.fetch).mockResolvedValue( + mockChatResponse({ + description: + "This component trains a model from the dataset input and writes the trained model output.", + }), + ); + + const result = await generateComponentAiDescription( + reference, + VALID_OPTIONS, + ); + + expect(result.description).toContain("trains a model"); + const call = vi.mocked(global.fetch).mock.calls[0]; + const body = parseFetchBody(call); + const serializedMessages = JSON.stringify(body.messages); + expect(serializedMessages).toContain("train_model"); + expect(serializedMessages).toContain("dataset"); + }); + + it("requires a hydrated component spec", async () => { + await expect( + generateComponentAiDescription({ digest: "abc" }, VALID_OPTIONS), + ).rejects.toThrow("Component details are not loaded yet"); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("throws when the model returns an empty description", async () => { + vi.mocked(global.fetch).mockResolvedValue( + mockChatResponse({ description: "" }), + ); + + await expect( + generateComponentAiDescription(reference, VALID_OPTIONS), + ).rejects.toThrow("empty description"); + }); +}); diff --git a/src/services/naturalLanguageComponentSearchService.ts b/src/services/naturalLanguageComponentSearchService.ts index c693507c0..7a5ec9776 100644 --- a/src/services/naturalLanguageComponentSearchService.ts +++ b/src/services/naturalLanguageComponentSearchService.ts @@ -42,6 +42,12 @@ export interface RerankResult { rawContent?: string; } +export interface ComponentDescriptionResult { + description: string; + /** Raw model response, kept for debugging. */ + rawContent?: string; +} + export class NaturalLanguageSearchConfigError extends Error { constructor(message: string) { super(message); @@ -49,7 +55,7 @@ export class NaturalLanguageSearchConfigError extends Error { } } -interface RerankOptions { +interface LlmOptions { signal?: AbortSignal; /** Model id (OpenAI-compatible). Required. */ model: string; @@ -135,7 +141,7 @@ export function componentReferenceToCandidate( }; } -function buildSystemPrompt(): string { +function buildRerankSystemPrompt(): string { return [ "You are a reranker for an ML pipeline component search.", "The user gives you a natural-language query and a small list of candidate components that were already retrieved by lexical search.", @@ -152,7 +158,10 @@ function buildSystemPrompt(): string { ].join("\n"); } -function buildUserPrompt(query: string, candidates: RerankCandidate[]): string { +function buildRerankUserPrompt( + query: string, + candidates: RerankCandidate[], +): string { // No pretty-printing: indentation adds ~25-30% to the payload for no signal. return [ `Query: ${query}`, @@ -162,7 +171,7 @@ function buildUserPrompt(query: string, candidates: RerankCandidate[]): string { ].join("\n"); } -function validateConfig(options: RerankOptions): { +function validateConfig(options: LlmOptions): { base: string; key: string; model: string; @@ -191,7 +200,7 @@ function validateConfig(options: RerankOptions): { export async function rerankComponentsByNaturalLanguage( query: string, candidates: RerankCandidate[], - options: RerankOptions, + options: LlmOptions, ): Promise { const trimmed = query.trim(); if (trimmed.length === 0) return { matches: [] }; @@ -218,8 +227,8 @@ export async function rerankComponentsByNaturalLanguage( : { max_tokens: 700 }), response_format: { type: "json_object" }, messages: [ - { role: "system", content: buildSystemPrompt() }, - { role: "user", content: buildUserPrompt(trimmed, candidates) }, + { role: "system", content: buildRerankSystemPrompt() }, + { role: "user", content: buildRerankUserPrompt(trimmed, candidates) }, ], }), }); @@ -260,3 +269,140 @@ export async function rerankComponentsByNaturalLanguage( return { matches, rawContent }; } + +interface ComponentDescriptionInput { + name: string; + prefilledDescription: string; + inputs?: Array<{ + name: string; + type?: unknown; + description?: string; + optional?: boolean; + default?: unknown; + }>; + outputs?: Array<{ + name: string; + type?: unknown; + description?: string; + }>; + implementation: unknown; +} + +function componentReferenceToDescriptionInput( + reference: ComponentReference, +): ComponentDescriptionInput | null { + const spec = reference.spec; + if (!spec) return null; + + return { + name: getComponentName(reference), + prefilledDescription: spec.description?.trim() ?? "", + ...(spec.inputs && spec.inputs.length > 0 + ? { + inputs: spec.inputs.map((input) => ({ + name: input.name, + type: input.type, + description: input.description, + optional: input.optional, + default: input.default, + })), + } + : {}), + ...(spec.outputs && spec.outputs.length > 0 + ? { + outputs: spec.outputs.map((output) => ({ + name: output.name, + type: output.type, + description: output.description, + })), + } + : {}), + implementation: spec.implementation, + }; +} + +function buildDescriptionSystemPrompt(): string { + return [ + "You explain ML pipeline components for users deciding whether to add a component to a pipeline.", + "Use only the component spec provided. Do not invent behavior that is not supported by the spec.", + "Explain exactly what the component does, what it consumes, what it produces, and any important implementation detail visible in the spec.", + "Respond with a single JSON object:", + '{ "description": "<2-4 sentence precise explanation>" }', + "Rules:", + "- Be specific and concrete.", + "- If the spec is sparse, say what is known and what is not specified.", + "- Keep the description under 900 characters.", + ].join("\n"); +} + +function buildDescriptionUserPrompt(input: ComponentDescriptionInput): string { + return ["Component spec summary:", JSON.stringify(input)].join("\n"); +} + +function readDescription(rawContent: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(rawContent); + } catch { + throw new Error( + `Could not parse LLM response as JSON: ${rawContent.slice(0, 200)}`, + ); + } + + if (!isRecord(parsed)) return ""; + const description = parsed.description; + return typeof description === "string" ? description.trim() : ""; +} + +export async function generateComponentAiDescription( + reference: ComponentReference, + options: LlmOptions, +): Promise { + const input = componentReferenceToDescriptionInput(reference); + if (!input) { + throw new Error("Component details are not loaded yet."); + } + + const { base, key, model } = validateConfig(options); + + const response = await fetch(`${base}/chat/completions`, { + method: "POST", + signal: options.signal, + headers: { + "content-type": "application/json", + authorization: `Bearer ${key}`, + }, + body: JSON.stringify({ + model, + ...(usesCompletionTokensParam(model) ? {} : { temperature: 0 }), + ...(usesCompletionTokensParam(model) + ? { max_completion_tokens: 2000 } + : { max_tokens: 900 }), + response_format: { type: "json_object" }, + messages: [ + { role: "system", content: buildDescriptionSystemPrompt() }, + { role: "user", content: buildDescriptionUserPrompt(input) }, + ], + }), + }); + + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error( + `LLM proxy returned ${response.status}: ${detail.slice(0, 200) || response.statusText}`, + ); + } + + const payload: unknown = await response.json(); + const rawContent = readChatCompletionContent(payload); + if (!rawContent) { + throw new Error("LLM proxy returned an empty response"); + } + + const description = readDescription(rawContent); + if (!description) { + throw new Error("LLM proxy returned an empty description"); + } + + return { description, rawContent }; +}