diff --git a/examples/demo-react/src/examples/basic-askai.tsx b/examples/demo-react/src/examples/basic-askai.tsx index 063cc7d1..fe0cd552 100644 --- a/examples/demo-react/src/examples/basic-askai.tsx +++ b/examples/demo-react/src/examples/basic-askai.tsx @@ -40,6 +40,9 @@ export default function BasicAskAI({ theme }: { theme: DemoTheme }): JSX.Element }, }} resultBadgeKey="type" + promptSuggestions={{ + indexName: 'docsearch-markdown_prompt_suggestions', + }} /> ); } diff --git a/packages/docsearch-react/src/DocSearchAI.tsx b/packages/docsearch-react/src/DocSearchAI.tsx index a6d3d002..6f04ba9c 100644 --- a/packages/docsearch-react/src/DocSearchAI.tsx +++ b/packages/docsearch-react/src/DocSearchAI.tsx @@ -184,6 +184,19 @@ export interface Memory { userToken?: string; } +export interface PromptSuggestions { + /** + * The name of the index where the prompt suggestions are stored. + */ + indexName: string; + /** + * The number of prompt suggestions that are retrieved and displayed. + * + * @default 3 + */ + hitsPerPage?: number; +} + export interface DocSearchAIProps extends DocSearchProps { /** * Configuration or assistant id to enable ask ai mode. Pass a string assistant id or a full config object. @@ -208,6 +221,15 @@ export interface DocSearchAIProps extends DocSearchProps { * Configuration for the Agent Studio memory feature. */ memory?: Memory; + /** + * Enables and configures prompt suggestions that are displayed during keyword search. + * + * @see https://www.algolia.com/doc/guides/algolia-ai/agent-studio/how-to/integration#prompt-suggestions + * + * @example + * { indexName: 'docsearch-markdown_prompt_suggestions', hitsPerPage: 1 } + */ + promptSuggestions?: PromptSuggestions; } function DocSearchAIComponent(props: DocSearchAIProps, ref: React.ForwardedRef): JSX.Element { diff --git a/packages/docsearch-react/src/DocSearchAskAiModal.tsx b/packages/docsearch-react/src/DocSearchAskAiModal.tsx index 0e6502d8..99d05d87 100644 --- a/packages/docsearch-react/src/DocSearchAskAiModal.tsx +++ b/packages/docsearch-react/src/DocSearchAskAiModal.tsx @@ -87,6 +87,7 @@ export function DocSearchAskAiModal({ facets, isHybridModeSupported = false, tools = EMPTY_TOOLS, + promptSuggestions, ...props }: DocSearchAskAiModalProps): JSX.Element { const { @@ -328,7 +329,7 @@ export function DocSearchAskAiModal({ onStateChange(changes) { setState(changes.state); }, - getSources({ query, state: sourcesState, setContext, setStatus }) { + async getSources({ query, state: sourcesState, setContext, setStatus }) { if (!query) { const noQuerySources = buildNoQuerySources({ recentSearches, @@ -371,11 +372,19 @@ export function DocSearchAskAiModal({ facetSelections: facetSelectionsRef, }); - const askAiSource = canHandleAskAi ? buildAskAiActionSources({ query, handleSelectAskAiQuestion }) : []; + const askAiSourcesPromise = canHandleAskAi + ? buildAskAiActionSources({ + query, + handleSelectAskAiQuestion, + promptSuggestionsOptions: promptSuggestions, + searchClient, + }) + : Promise.resolve([]); + + const [askAiSources, algoliaSources] = await Promise.all([askAiSourcesPromise, algoliaSourcesPromise]); + // Combine Algolia results (once resolved) with the Ask AI source - return algoliaSourcesPromise.then((algoliaSources) => { - return [...askAiSource, ...algoliaSources]; - }); + return [...askAiSources, ...algoliaSources]; }, }); } diff --git a/packages/docsearch-react/src/Results.tsx b/packages/docsearch-react/src/Results.tsx index 32688de4..0ac283cd 100644 --- a/packages/docsearch-react/src/Results.tsx +++ b/packages/docsearch-react/src/Results.tsx @@ -8,13 +8,14 @@ import { useRelativeFormattedDate } from './hooks/useRelativeFormattedDate'; import { SparklesIcon } from './icons/SparklesIcon'; import { Snippet } from './Snippet'; import type { InternalDocSearchHit, StoredDocSearchHit } from './types'; -import { decodeHtmlEntities, getHitItemBreadcrumbs } from './utils'; +import { decodeHtmlEntities, getHitItemBreadcrumbs, SOURCE_IDS } from './utils'; export type ResultsTranslations = HitResultBadgeTranslations & Partial<{ askAiPlaceholder: string; noResultsAskAiPlaceholder: string; recentConversationTimestampFallback: string; + askAiResultsTitle: string; }>; interface ResultsProps extends AutocompleteApi { @@ -31,6 +32,9 @@ interface ResultsProps } export function Results(props: ResultsProps): JSX.Element | null { + const { askAiResultsTitle = 'Ask AI Assistant' } = props.translations || {}; + + const askAiResultsId = React.useId(); // The collection title, decoded to handle encoded HTML entities // If there is not a title, return null to not render anything const decodedTitle = React.useMemo(() => { @@ -45,18 +49,26 @@ export function Results(props: ResultsProps -
Ask AI Assistant
-
    - +

    + {askAiResultsTitle} +

    +
      + {props.collection.items.map((item) => ( + + ))}
    ); } - if (props.collection.source.sourceId === 'recentConversations') { + if (props.collection.source.sourceId === SOURCE_IDS.recentConversations) { return (
    diff --git a/packages/docsearch-react/src/ResultsScreen.tsx b/packages/docsearch-react/src/ResultsScreen.tsx index 61f51dc5..d62c31ad 100644 --- a/packages/docsearch-react/src/ResultsScreen.tsx +++ b/packages/docsearch-react/src/ResultsScreen.tsx @@ -11,6 +11,7 @@ export type ResultsScreenTranslations = Partial<{ askAiPlaceholder: string; noResultsAskAiPlaceholder: string; resultsSectionTitle: string; + askAiResultsTitle: string; }> & ResultsTranslations; diff --git a/packages/docsearch-react/src/__tests__/api.test.tsx b/packages/docsearch-react/src/__tests__/api.test.tsx index 723e460f..a6242727 100644 --- a/packages/docsearch-react/src/__tests__/api.test.tsx +++ b/packages/docsearch-react/src/__tests__/api.test.tsx @@ -1,6 +1,6 @@ import { render, act, fireEvent, screen, cleanup } from '@testing-library/react'; import React, { type JSX } from 'react'; -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import '@testing-library/jest-dom/vitest'; @@ -38,6 +38,33 @@ function noResultSearch(_queries: any, _requestOptions?: any): Promise { }); } +function promptSuggestionsSearch(queries: any, _requestOptions?: any): Promise { + const [request] = queries.requests; + + if (request.indexName === 'prompt-suggestions') { + return Promise.resolve({ + results: [ + { + hits: [ + { objectID: 'prompt-1', prompt: 'How do I configure DocSearch?' }, + { objectID: 'prompt-2', prompt: 'How do I add facets?' }, + ], + hitsPerPage: 3, + nbHits: 2, + nbPages: 1, + page: 0, + processingTimeMS: 0, + exhaustiveNbHits: true, + params: '', + query: request.query, + }, + ], + }); + } + + return noResultSearch(queries); +} + describe('api', () => { const docSearchSelector = '.DocSearch'; @@ -300,7 +327,11 @@ describe('api', () => { }); await act(async () => { - fireEvent.click(await screen.findByText('hello', { selector: '.DocSearch-Hit-AskAIButton-title-query' })); + fireEvent.click( + await screen.findByText('hello', { + selector: '.DocSearch-Hit-AskAIButton-title-query', + }), + ); }); expect(document.querySelector('.DocSearch-AskAiScreen')).toBeInTheDocument(); @@ -308,6 +339,56 @@ describe('api', () => { // could be "Answering..." or "Ask another question..." expect(screen.getByText('Answering...')).toBeInTheDocument(); }); + + it('renders and selects prompt suggestions', async () => { + const interceptAskAiEvent = vi.fn(() => true); + + render( + ({ + ...searchClient, + search: promptSuggestionsSearch, + })} + interceptAskAiEvent={interceptAskAiEvent} + translations={{ + modal: { + resultsScreen: { + askAiResultsTitle: 'Suggested questions', + }, + }, + }} + />, + ); + + await act(async () => { + fireEvent.click(await screen.findByText('Search')); + }); + + await act(async () => { + fireEvent.input(await screen.findByPlaceholderText('Search docs or ask AI a question'), { + target: { value: 'configure' }, + }); + }); + + const heading = await screen.findByRole('heading', { + name: 'Suggested questions', + }); + const results = document.querySelector(`[aria-labelledby="${heading.id}"]`); + + expect(results).toBeInTheDocument(); + expect(await screen.findByText('How do I configure DocSearch?')).toBeInTheDocument(); + expect(screen.getByText('How do I add facets?')).toBeInTheDocument(); + + act(() => { + fireEvent.click(screen.getByText('How do I configure DocSearch?')); + }); + + expect(interceptAskAiEvent).toHaveBeenCalledWith({ + query: 'How do I configure DocSearch?', + suggestedQuestionId: undefined, + }); + }); }); describe('portalContainer', () => { diff --git a/packages/docsearch-react/src/icons/SparklesIcon.tsx b/packages/docsearch-react/src/icons/SparklesIcon.tsx index b9426690..0b96654b 100644 --- a/packages/docsearch-react/src/icons/SparklesIcon.tsx +++ b/packages/docsearch-react/src/icons/SparklesIcon.tsx @@ -16,6 +16,7 @@ export function SparklesIcon({ className = '' }: Props): JSX.Element { strokeLinecap="round" strokeLinejoin="round" className={`DocSearch-Hit-icon-sparkles ${className}`} + aria-hidden="true" > diff --git a/packages/docsearch-react/src/types/AskiAi.ts b/packages/docsearch-react/src/types/AskiAi.ts index 4cf946fe..83a54856 100644 --- a/packages/docsearch-react/src/types/AskiAi.ts +++ b/packages/docsearch-react/src/types/AskiAi.ts @@ -109,3 +109,7 @@ export type AlgoliaMCPSearchOutputPart = ToolUIPart< } >; export type SearchOutputPart = AlgoliaMCPSearchOutputPart | SearchIndexOutputPart; + +export interface PromptSuggestion { + prompt: string; +} diff --git a/packages/docsearch-react/src/utils/__tests__/createAskAiSources.test.ts b/packages/docsearch-react/src/utils/__tests__/createAskAiSources.test.ts new file mode 100644 index 00000000..c7dfc91d --- /dev/null +++ b/packages/docsearch-react/src/utils/__tests__/createAskAiSources.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { buildAskAiActionSources } from '../createAskAiSources'; + +const searchClient = { + search: vi.fn(), +} as any; + +describe('buildAskAiActionSources', () => { + it('returns only the Ask AI action when prompt suggestions are disabled', async () => { + const handleSelectAskAiQuestion = vi.fn(); + const sources = await buildAskAiActionSources({ + query: 'How do I install DocSearch?', + handleSelectAskAiQuestion, + searchClient, + }); + + expect(searchClient.search).not.toHaveBeenCalled(); + expect(sources[0].getItems({} as any)).toMatchObject([ + { + objectID: 'ask-ai-button', + query: 'How do I install DocSearch?', + }, + ]); + }); + + it('requests and returns prompt suggestions after the Ask AI action', async () => { + searchClient.search.mockResolvedValue({ + results: [ + { + hits: [ + { objectID: 'prompt-1', prompt: 'How do I configure DocSearch?' }, + { objectID: 'prompt-2', prompt: 'How do I add facets?' }, + ], + }, + ], + }); + + const sources = await buildAskAiActionSources({ + query: 'configure', + handleSelectAskAiQuestion: vi.fn(), + promptSuggestionsOptions: { + indexName: 'prompt-suggestions', + hitsPerPage: 2, + }, + searchClient, + }); + + expect(searchClient.search).toHaveBeenCalledWith({ + requests: [ + { + query: 'configure', + indexName: 'prompt-suggestions', + hitsPerPage: 2, + attributesToRetrieve: ['prompt'], + }, + ], + }); + expect(sources[0].getItems({} as any)).toMatchObject([ + { objectID: 'ask-ai-button', query: 'configure' }, + { objectID: 'prompt-1', query: 'How do I configure DocSearch?' }, + { objectID: 'prompt-2', query: 'How do I add facets?' }, + ]); + }); + + it('selects a prompt suggestion as the Ask AI query', async () => { + searchClient.search.mockResolvedValue({ + results: [ + { + hits: [{ objectID: 'prompt-1', prompt: 'How do I configure DocSearch?' }], + }, + ], + }); + const handleSelectAskAiQuestion = vi.fn(); + const sources = await buildAskAiActionSources({ + query: 'configure', + handleSelectAskAiQuestion, + promptSuggestionsOptions: { indexName: 'prompt-suggestions' }, + searchClient, + }); + const [, suggestion] = (await sources[0].getItems({} as any)) as any[]; + + sources[0].onSelect?.({ item: suggestion } as any); + + expect(handleSelectAskAiQuestion).toHaveBeenCalledWith(true, 'How do I configure DocSearch?'); + }); + + it('keeps the Ask AI action when prompt suggestion retrieval fails', async () => { + searchClient.search.mockRejectedValue(new Error('Network error')); + + const sources = await buildAskAiActionSources({ + query: 'configure', + handleSelectAskAiQuestion: vi.fn(), + promptSuggestionsOptions: { indexName: 'prompt-suggestions' }, + searchClient, + }); + + expect(sources[0].getItems({} as any)).toMatchObject([{ objectID: 'ask-ai-button', query: 'configure' }]); + }); +}); diff --git a/packages/docsearch-react/src/utils/createAskAiSources.ts b/packages/docsearch-react/src/utils/createAskAiSources.ts index 3e3e944a..2b5da55f 100644 --- a/packages/docsearch-react/src/utils/createAskAiSources.ts +++ b/packages/docsearch-react/src/utils/createAskAiSources.ts @@ -1,12 +1,40 @@ import type { AutocompleteSource } from '@algolia/autocomplete-core'; +import type { SearchResponse } from 'algoliasearch/lite'; +import type { DocSearchTransformClient } from '../DocSearch'; +import type { PromptSuggestions } from '../DocSearchAI'; import type { InternalDocSearchHit } from '../types'; -import type { AIMessage } from '../types/AskiAi'; +import type { AIMessage, PromptSuggestion } from '../types/AskiAi'; import { SOURCE_IDS } from './collections'; const MAX_RECENT_CONVERSATIONS_DISPLAYED = 3; +const EMPTY_RESULT_CONTENT = { + value: '', + matchLevel: 'none', + matchedWords: [], +} satisfies InternalDocSearchHit['_highlightResult']['content']; +const EMPTY_HIERARCHY_HIGHLIGHT_RESULT = { + lvl0: EMPTY_RESULT_CONTENT, + lvl1: EMPTY_RESULT_CONTENT, + lvl2: EMPTY_RESULT_CONTENT, + lvl3: EMPTY_RESULT_CONTENT, + lvl4: EMPTY_RESULT_CONTENT, + lvl5: EMPTY_RESULT_CONTENT, + lvl6: EMPTY_RESULT_CONTENT, +} satisfies InternalDocSearchHit['_highlightResult']['hierarchy']; +const EMPTY_HIGHLIGHT_RESULT: InternalDocSearchHit['_highlightResult'] = { + content: EMPTY_RESULT_CONTENT, + hierarchy: EMPTY_HIERARCHY_HIGHLIGHT_RESULT, + hierarchy_camel: [], +}; +const EMPTY_SNIPPET_RESULT: InternalDocSearchHit['_snippetResult'] = { + content: EMPTY_RESULT_CONTENT, + hierarchy: EMPTY_HIERARCHY_HIGHLIGHT_RESULT, + hierarchy_camel: [], +}; + export function buildRecentConversationSources({ conversations, disableUserPersonalization, @@ -40,60 +68,101 @@ export function buildRecentConversationSources({ ]; } -export function buildAskAiActionSources({ +function createAskAiHit({ + query, + objectID, + lvl0, +}: { + query: string; + objectID: string; + lvl0: string; +}): InternalDocSearchHit { + return { + type: 'askAI', + query, + url_without_anchor: '', + objectID, + content: null, + url: '', + anchor: null, + hierarchy: { + lvl0, + lvl1: query, + lvl2: null, + lvl3: null, + lvl4: null, + lvl5: null, + lvl6: null, + }, + _highlightResult: EMPTY_HIGHLIGHT_RESULT, + _snippetResult: EMPTY_SNIPPET_RESULT, + __docsearch_parent: null, + }; +} + +async function getPromptSuggestions({ + query, + indexName, + searchClient, + hitsPerPage = 3, +}: { + query: string; + indexName: string; + searchClient: DocSearchTransformClient; + hitsPerPage?: number; +}): Promise { + try { + const { results } = await searchClient.search({ + requests: [ + { + query, + indexName, + hitsPerPage, + attributesToRetrieve: ['prompt'], + }, + ], + }); + + const res = results[0] as SearchResponse; + + return res.hits.map((hit) => + createAskAiHit({ + query: hit.prompt, + objectID: hit.objectID, + lvl0: 'Prompt Suggestions', + }), + ); + // NOTE: Not checking the exception here and just returning empty array since this isn't the hot path + } catch { + return []; + } +} + +export async function buildAskAiActionSources({ query, handleSelectAskAiQuestion, + promptSuggestionsOptions, + searchClient, }: { query: string; handleSelectAskAiQuestion: (toggle: boolean, query: string) => void; -}): Array> { - const emptyHierarchyHighlightResult = { - lvl0: { value: '', matchLevel: 'none', matchedWords: [] }, - lvl1: { value: '', matchLevel: 'none', matchedWords: [] }, - lvl2: { value: '', matchLevel: 'none', matchedWords: [] }, - lvl3: { value: '', matchLevel: 'none', matchedWords: [] }, - lvl4: { value: '', matchLevel: 'none', matchedWords: [] }, - lvl5: { value: '', matchLevel: 'none', matchedWords: [] }, - lvl6: { value: '', matchLevel: 'none', matchedWords: [] }, - } satisfies InternalDocSearchHit['_highlightResult']['hierarchy']; - const emptyHighlightResult: InternalDocSearchHit['_highlightResult'] = { - content: { value: '', matchLevel: 'none', matchedWords: [] }, - hierarchy: emptyHierarchyHighlightResult, - hierarchy_camel: [], - }; - const emptySnippetResult: InternalDocSearchHit['_snippetResult'] = { - content: { value: '', matchLevel: 'none' }, - hierarchy: emptyHierarchyHighlightResult, - hierarchy_camel: [], - }; + promptSuggestionsOptions?: PromptSuggestions; + searchClient: DocSearchTransformClient; +}): Promise>> { + const promptSuggestions = promptSuggestionsOptions + ? await getPromptSuggestions({ + query, + indexName: promptSuggestionsOptions.indexName, + hitsPerPage: promptSuggestionsOptions.hitsPerPage, + searchClient, + }) + : []; return [ { sourceId: SOURCE_IDS.askAI, getItems(): InternalDocSearchHit[] { - return [ - { - type: 'askAI', - query, - url_without_anchor: '', - objectID: 'ask-ai-button', - content: null, - url: '', - anchor: null, - hierarchy: { - lvl0: 'Ask AI', - lvl1: query, - lvl2: null, - lvl3: null, - lvl4: null, - lvl5: null, - lvl6: null, - }, - _highlightResult: emptyHighlightResult, - _snippetResult: emptySnippetResult, - __docsearch_parent: null, - }, - ]; + return [createAskAiHit({ query, objectID: 'ask-ai-button', lvl0: 'Ask AI' }), ...promptSuggestions]; }, onSelect({ item }): void { if (item.type === 'askAI' && item.query) {