@@ -9,6 +9,7 @@ import { useTheme } from '@docsearch/core/useTheme';
99import type { ChatRequestOptions } from 'ai' ;
1010import type { SearchResponse } from 'algoliasearch/lite' ;
1111import React , { type JSX } from 'react' ;
12+ import type { MultiSearchRequestSchema } from 'typesense/lib/Typesense/Types' ;
1213
1314import { MAX_QUERY_SIZE } from './constants' ;
1415import 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+
286405export 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