Skip to content

Commit 7524332

Browse files
committed
feat(search): add Typesense query sources to the modal
1 parent b542705 commit 7524332

1 file changed

Lines changed: 160 additions & 28 deletions

File tree

packages/docsearch-react/src/DocSearchModal.tsx

Lines changed: 160 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useTheme } from '@docsearch/core/useTheme';
99
import type { ChatRequestOptions } from 'ai';
1010
import type { SearchResponse } from 'algoliasearch/lite';
1111
import React, { type JSX } from 'react';
12+
import type { MultiSearchRequestSchema } from 'typesense/lib/Typesense/Types';
1213

1314
import { MAX_QUERY_SIZE } from './constants';
1415
import type { DocSearchIndex, DocSearchProps } from './DocSearch';
@@ -283,9 +284,125 @@ const buildQuerySources = async ({
283284
}
284285
};
285286

287+
const buildTypesenseQuerySources = async ({
288+
query,
289+
state: sourcesState,
290+
setContext,
291+
setStatus,
292+
searchClient,
293+
typesenseCollectionName,
294+
typesenseSearchParameters,
295+
maxResultsPerGroup,
296+
transformItems = identity,
297+
saveRecentSearch,
298+
onClose,
299+
}: {
300+
query: string;
301+
state: BuildQuerySourcesState;
302+
setContext: (context: Partial<DocSearchState<InternalDocSearchHit>['context']>) => void;
303+
setStatus: (status: DocSearchState<InternalDocSearchHit>['status']) => void;
304+
searchClient: ReturnType<typeof useSearchClient>;
305+
typesenseCollectionName: string;
306+
typesenseSearchParameters?: DocSearchProps['typesenseSearchParameters'];
307+
maxResultsPerGroup?: number;
308+
transformItems?: DocSearchProps['transformItems'];
309+
saveRecentSearch: (item: InternalDocSearchHit) => void;
310+
onClose: () => void;
311+
}): Promise<Array<AutocompleteSource<InternalDocSearchHit>>> => {
312+
try {
313+
const typesenseRequest: MultiSearchRequestSchema<DocSearchHit, string> = {
314+
collection: typesenseCollectionName,
315+
q: query,
316+
query_by:
317+
'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,hierarchy.lvl6,content',
318+
include_fields:
319+
'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,hierarchy.lvl6,content,anchor,url,type,id',
320+
highlight_full_fields:
321+
'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,hierarchy.lvl6,content',
322+
group_by: 'url',
323+
group_limit: 3,
324+
sort_by: 'item_priority:desc',
325+
snippet_threshold: 8,
326+
highlight_affix_num_tokens: 4,
327+
...(typesenseSearchParameters ?? {}),
328+
};
329+
330+
const { results } = await searchClient.search<DocSearchHit>({
331+
requests: [typesenseRequest],
332+
});
333+
334+
const result = results[0] as SearchResponse<DocSearchHit>;
335+
const { hits, nbHits } = result;
336+
const transformedHits = transformItems(hits);
337+
const sources = groupBy<DocSearchHit>(transformedHits, (hit) => removeHighlightTags(hit), maxResultsPerGroup);
338+
339+
// We store the `lvl0`s to display them as search suggestions
340+
// in the "no results" screen.
341+
if ((sourcesState.context.searchSuggestions as any[]).length < Object.keys(sources).length) {
342+
setContext({
343+
searchSuggestions: {
344+
...(sourcesState.context.searchSuggestions ?? []),
345+
...Object.keys(sources),
346+
},
347+
});
348+
}
349+
350+
if (nbHits) {
351+
const currentNbHits = sourcesState.context.nbHits as number | undefined;
352+
setContext({
353+
nbHits: (currentNbHits ?? 0) + nbHits,
354+
});
355+
}
356+
357+
return Object.values<DocSearchHit[]>(sources).map((items, index) => {
358+
return {
359+
sourceId: `hits_${typesenseCollectionName}_${index}`,
360+
onSelect({ item, event }): void {
361+
saveRecentSearch(item);
362+
if (!isModifierEvent(event)) {
363+
onClose();
364+
}
365+
},
366+
getItemUrl({ item }): string {
367+
return item.url;
368+
},
369+
getItems(): InternalDocSearchHit[] {
370+
return Object.values(groupBy(items, (item) => item.hierarchy.lvl1, maxResultsPerGroup))
371+
.map((groupedHits) =>
372+
groupedHits.map((item) => {
373+
let parent: InternalDocSearchHit | null = null;
374+
375+
const potentialParent = groupedHits.find(
376+
(siblingItem) => siblingItem.type === 'lvl1' && siblingItem.hierarchy.lvl1 === item.hierarchy.lvl1,
377+
) as InternalDocSearchHit | undefined;
378+
379+
if (item.type !== 'lvl1' && potentialParent) {
380+
parent = potentialParent;
381+
}
382+
383+
return {
384+
...item,
385+
__docsearch_parent: parent,
386+
};
387+
}),
388+
)
389+
.flat();
390+
},
391+
};
392+
});
393+
} catch (error) {
394+
// The Algolia `RetryError` happens when all the servers have
395+
// failed, meaning that there's no chance the response comes
396+
// back. This is the right time to display an error.
397+
// See https://github.com/algolia/algoliasearch-client-javascript/blob/2ffddf59bc765cd1b664ee0346b28f00229d6e12/packages/transporter/src/errors/createRetryError.ts#L5
398+
if ((error as Error).name === 'RetryError') {
399+
setStatus('error');
400+
}
401+
throw error;
402+
}
403+
};
404+
286405
export function DocSearchModal({
287-
appId,
288-
apiKey,
289406
askAi,
290407
maxResultsPerGroup,
291408
theme,
@@ -307,7 +424,9 @@ export function DocSearchModal({
307424
recentSearchesLimit = 7,
308425
recentSearchesWithFavoritesLimit = 4,
309426
indices = [],
310-
indexName,
427+
typesenseCollectionName,
428+
typesenseServerConfig,
429+
typesenseSearchParameters,
311430
searchParameters,
312431
isHybridModeSupported = false,
313432
...props
@@ -347,7 +466,7 @@ export function DocSearchModal({
347466
).current;
348467
const initialQuery = React.useRef(initialQueryFromProp || initialQueryFromSelection).current;
349468

350-
const searchClient = useSearchClient(appId, apiKey, transformSearchClient);
469+
const searchClient = useSearchClient(transformSearchClient, typesenseServerConfig);
351470

352471
const askAiConfig = typeof askAi === 'object' ? askAi : null;
353472
const askAiConfigurationId = typeof askAi === 'string' ? askAi : askAiConfig?.assistantId || null;
@@ -364,9 +483,9 @@ export function DocSearchModal({
364483
// Format the `indexes` to be used until `indexName` and `searchParameters` props are fully removed.
365484
const indexes: DocSearchIndex[] = [];
366485

367-
if (indexName && indexName !== '') {
486+
if (typesenseCollectionName && typesenseCollectionName !== '') {
368487
indexes.push({
369-
name: indexName,
488+
name: typesenseCollectionName,
370489
searchParameters,
371490
});
372491
}
@@ -407,8 +526,8 @@ export function DocSearchModal({
407526

408527
const { messages, status, setMessages, sendMessage, stopAskAiStreaming, askAiError, sendFeedback } = useAskAi({
409528
assistantId: askAiConfigurationId,
410-
apiKey: askAiConfig?.apiKey || apiKey,
411-
appId: askAiConfig?.appId || appId,
529+
apiKey: askAiConfig?.apiKey ?? 'testkey',
530+
appId: askAiConfig?.appId ?? 'testappid',
412531
indexName: askAiConfig?.indexName || defaultIndexName,
413532
searchParameters: askAiSearchParameters,
414533
useStagingEnv: askAiUseStagingEnv,
@@ -583,10 +702,10 @@ export function DocSearchModal({
583702
// feedback handler
584703
const handleFeedbackSubmit = React.useCallback(
585704
async (messageId: string, thumbs: 0 | 1): Promise<void> => {
586-
if (!askAiConfigurationId || !appId) return;
705+
if (!askAiConfigurationId) return;
587706
await sendFeedback(messageId, thumbs);
588707
},
589-
[askAiConfigurationId, appId, sendFeedback],
708+
[askAiConfigurationId, sendFeedback],
590709
);
591710

592711
if (!autocompleteRef.current) {
@@ -643,22 +762,35 @@ export function DocSearchModal({
643762
context: sourcesState.context,
644763
};
645764

646-
const algoliaSourcesPromise = buildQuerySources({
647-
query,
648-
state: querySourcesState,
649-
setContext,
650-
setStatus,
651-
searchClient,
652-
indexes,
653-
snippetLength,
654-
insights: Boolean(insights),
655-
appId,
656-
apiKey,
657-
maxResultsPerGroup,
658-
transformItems,
659-
saveRecentSearch,
660-
onClose,
661-
});
765+
const querySourcesPromise =
766+
typesenseCollectionName && typesenseCollectionName !== ''
767+
? buildTypesenseQuerySources({
768+
query,
769+
state: querySourcesState,
770+
setContext,
771+
setStatus,
772+
searchClient,
773+
typesenseCollectionName,
774+
typesenseSearchParameters,
775+
maxResultsPerGroup,
776+
transformItems,
777+
saveRecentSearch,
778+
onClose,
779+
})
780+
: buildQuerySources({
781+
query,
782+
state: querySourcesState,
783+
setContext,
784+
setStatus,
785+
searchClient,
786+
indexes,
787+
snippetLength,
788+
insights: Boolean(insights),
789+
maxResultsPerGroup,
790+
transformItems,
791+
saveRecentSearch,
792+
onClose,
793+
});
662794

663795
// Ask AI source
664796
const askAiSource: Array<AutocompleteSource<InternalDocSearchHit>> = canHandleAskAi
@@ -700,8 +832,8 @@ export function DocSearchModal({
700832
]
701833
: [];
702834
// Combine Algolia results (once resolved) with the Ask AI source
703-
return algoliaSourcesPromise.then((algoliaSources) => {
704-
return [...askAiSource, ...algoliaSources];
835+
return querySourcesPromise.then((querySources) => {
836+
return [...askAiSource, ...querySources];
705837
});
706838
},
707839
});

0 commit comments

Comments
 (0)