1
0
Fork 0

feat(v5): Add prompt suggestions to keyword search (#2912)

* feat(v5): Add prompt suggestions to keyword search

* cleanup: Move consistent object to reusable constant
This commit is contained in:
Paul Jankowski 2026-07-10 09:56:33 -04:00 committed by GitHub
parent e26256bb41
commit 907c844f83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 360 additions and 58 deletions

View file

@ -40,6 +40,9 @@ export default function BasicAskAI({ theme }: { theme: DemoTheme }): JSX.Element
},
}}
resultBadgeKey="type"
promptSuggestions={{
indexName: 'docsearch-markdown_prompt_suggestions',
}}
/>
);
}

View file

@ -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<DocSearchRef>): JSX.Element {

View file

@ -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];
},
});
}

View file

@ -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<TItem extends BaseItem>
extends AutocompleteApi<TItem, React.FormEvent, React.MouseEvent, React.KeyboardEvent> {
@ -31,6 +32,9 @@ interface ResultsProps<TItem extends BaseItem>
}
export function Results<TItem extends StoredDocSearchHit>(props: ResultsProps<TItem>): 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<TItem extends StoredDocSearchHit>(props: ResultsProps<TI
return null;
}
if (props.collection.source.sourceId === 'askAI') {
if (props.collection.source.sourceId === SOURCE_IDS.askAI) {
return (
<section className="DocSearch-Hits">
<div className="DocSearch-Hit-source">Ask AI Assistant</div>
<ul className="DocSearch-Hits-padded" {...props.getListProps({ source: props.collection.source })}>
<AskAiButton item={props.collection.items[0]} translations={props.translations} {...props} />
<h2 id={askAiResultsId} className="DocSearch-Hit-source">
{askAiResultsTitle}
</h2>
<ul
className="DocSearch-Hits-padded"
{...props.getListProps({ source: props.collection.source })}
aria-labelledby={askAiResultsId}
>
{props.collection.items.map((item) => (
<AskAiButton key={item.objectID} item={item} translations={props.translations} {...props} />
))}
</ul>
</section>
);
}
if (props.collection.source.sourceId === 'recentConversations') {
if (props.collection.source.sourceId === SOURCE_IDS.recentConversations) {
return (
<section className="DocSearch-Hits">
<div className="DocSearch-Hit-source">

View file

@ -11,6 +11,7 @@ export type ResultsScreenTranslations = Partial<{
askAiPlaceholder: string;
noResultsAskAiPlaceholder: string;
resultsSectionTitle: string;
askAiResultsTitle: string;
}> &
ResultsTranslations;

View file

@ -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<any> {
});
}
function promptSuggestionsSearch(queries: any, _requestOptions?: any): Promise<any> {
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(
<DocSearchAI
promptSuggestions={{ indexName: 'prompt-suggestions' }}
transformSearchClient={(searchClient) => ({
...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', () => {

View file

@ -16,6 +16,7 @@ export function SparklesIcon({ className = '' }: Props): JSX.Element {
strokeLinecap="round"
strokeLinejoin="round"
className={`DocSearch-Hit-icon-sparkles ${className}`}
aria-hidden="true"
>
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
<path d="M20 3v4" />

View file

@ -109,3 +109,7 @@ export type AlgoliaMCPSearchOutputPart = ToolUIPart<
}
>;
export type SearchOutputPart = AlgoliaMCPSearchOutputPart | SearchIndexOutputPart;
export interface PromptSuggestion {
prompt: string;
}

View file

@ -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' }]);
});
});

View file

@ -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<InternalDocSearchHit[]> {
try {
const { results } = await searchClient.search<PromptSuggestion>({
requests: [
{
query,
indexName,
hitsPerPage,
attributesToRetrieve: ['prompt'],
},
],
});
const res = results[0] as SearchResponse<PromptSuggestion>;
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<AutocompleteSource<InternalDocSearchHit>> {
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<Array<AutocompleteSource<InternalDocSearchHit>>> {
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) {