From d0df487d4d5a69850e372d0872e0526a2add5958 Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Tue, 6 May 2025 11:07:39 +0200 Subject: [PATCH] feat: recently asked & conversations (#2590) --- packages/docsearch-css/src/modal.css | 36 +- packages/docsearch-react/src/AskAiScreen.tsx | 125 +++---- .../docsearch-react/src/DocSearchModal.tsx | 331 ++++++++++-------- .../docsearch-react/src/MemoizedMarkdown.tsx | 4 +- packages/docsearch-react/src/Results.tsx | 53 +-- packages/docsearch-react/src/ScreenState.tsx | 7 +- packages/docsearch-react/src/StartScreen.tsx | 39 ++- .../src/icons/SparklesIcon.tsx | 23 ++ packages/docsearch-react/src/icons/index.ts | 1 + .../docsearch-react/src/lib/genAiClient.ts | 3 + .../docsearch-react/src/stored-searches.ts | 78 +++-- .../src/types/DocSearchState.ts | 1 - .../src/types/StoredDocSearchHit.ts | 3 + packages/docsearch-react/src/useAskAi.ts | 117 +++++-- packages/docsearch-react/src/utils/storage.ts | 89 +++++ 15 files changed, 565 insertions(+), 345 deletions(-) create mode 100644 packages/docsearch-react/src/icons/SparklesIcon.tsx create mode 100644 packages/docsearch-react/src/utils/storage.ts diff --git a/packages/docsearch-css/src/modal.css b/packages/docsearch-css/src/modal.css index 7b99ccde..99cfb641 100644 --- a/packages/docsearch-css/src/modal.css +++ b/packages/docsearch-css/src/modal.css @@ -783,14 +783,13 @@ assistive tech users */ flex-direction: column; gap: 24px; width: 100%; - padding: 6px; - overflow-y: auto; } .DocSearch-AskAiScreen-Response-Container { display: flex; flex-direction: row; - gap: 8px; + gap: 16px; + margin-bottom: 16px; } .DocSearch-AskAiScreen-Response { @@ -799,6 +798,7 @@ assistive tech users */ width: 70%; gap: 16px; font-size: 0.8em; + margin-bottom: 8px; background: var(--docsearch-hit-background); padding: 24px; color: var(--docsearch-text-color); @@ -823,6 +823,13 @@ assistive tech users */ animation: fade-in 0.3s ease-in-out; } +.DocSearch-AskAiScreen-ThinkingDots { + font-size: 0.7em; + font-weight: 400; + color: var(--docsearch-secondary-text-color); + margin: 0; +} + .DocSearch-AskAiScreen-Answer-Footer { display: flex; flex-direction: row; @@ -878,20 +885,35 @@ assistive tech users */ display: flex; flex-direction: column; width: 30%; - gap: 8px; + gap: 4px; } .DocSearch-AskAiScreen-RelatedSources-Title { font-size: 0.7em; font-weight: 400; color: var(--docsearch-text-color); + padding: 6px 0; margin: 0; } +.DocSearch-AskAiScreen-RelatedSources-NoResults { + font-size: 0.8rem; + font-weight: 400; + margin: 0; + color: var(--docsearch-text-color); +} + +.DocSearch-AskAiScreen-RelatedSources-Error { + font-size: 0.8rem; + font-weight: 400; + margin: 0; + color: var(--docsearch-error-color); +} + .DocSearch-AskAiScreen-RelatedSources-Item-Link { display: flex; align-items: center; - gap: 4px; + gap: 6px; padding: 12px 6px; background: var(--docsearch-hit-background); border-radius: 4px; @@ -1026,10 +1048,10 @@ assistive tech users */ @keyframes pulse { 0%, 100% { - opacity: 0.4; + opacity: 0.3; } 50% { - opacity: 0.8; + opacity: 0.6; } } diff --git a/packages/docsearch-react/src/AskAiScreen.tsx b/packages/docsearch-react/src/AskAiScreen.tsx index 592595cf..1204c25e 100644 --- a/packages/docsearch-react/src/AskAiScreen.tsx +++ b/packages/docsearch-react/src/AskAiScreen.tsx @@ -3,36 +3,31 @@ import React, { type JSX, useState, useEffect } from 'react'; import { MemoizedMarkdown } from './MemoizedMarkdown'; import type { ScreenStateProps } from './ScreenState'; import type { InternalDocSearchHit } from './types'; -import { useAskAi } from './useAskAi'; export type AskAiScreenTranslations = Partial<{ titleText: string; disclaimerText: string; relatedSourcesText: string; + thinkingText: string; }>; type AskAiScreenProps = Omit, 'translations'> & { translations?: AskAiScreenTranslations; + conversationId?: string | null; }; -export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): JSX.Element { +export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): JSX.Element | null { + if (!props.askAiState) { + return null; + } + const { disclaimerText = 'Answers are generated using artificial intelligence. This is an experimental technology, and information may occasionally be incorrect or misleading.', relatedSourcesText = 'Related Sources', + thinkingText = 'Thinking', } = translations; - const genAiClient = props.genAiClient; - if (!genAiClient) { - // @todo: add a link to the documentation - throw new Error('You have to provide credentials to use the Ask AI feature.\nSee documentation:'); - } - - const { ask, messages, currentResponse, loadingStatus, context, error } = useAskAi({ genAiClient }); - - // if we have no messages and a query, and are not loading/streaming, we can use it as the initial query - if (messages.length === 0 && props.state.query && loadingStatus === 'idle') { - ask({ query: props.state.query }); - } + const { messages, currentResponse, loadingStatus, context, error } = props.askAiState; // determine the initial query to display const displayedQuery = messages.find((m) => m.role === 'user')?.content || 'No query provided'; @@ -81,7 +76,7 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): )} {loadingStatus === 'loading' && (
- +
)} @@ -112,6 +107,14 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): {source.title || source.url || source.objectID} ))} + {context.length === 0 && loadingStatus === 'idle' && ( +

No related sources found

+ )} + {context.length === 0 && loadingStatus === 'error' && ( +

+ Error loading related sources. Please try again. +

+ )} @@ -119,6 +122,28 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): ); } +function ThinkingDots({ thinkingText }: { thinkingText: string }): JSX.Element { + const [dots, setDots] = useState(''); + + useEffect(() => { + const interval = setInterval(() => { + setDots((prevDots) => { + if (prevDots === '...') return ''; + return prevDots + '.'; + }); + }, 500); + + return (): void => clearInterval(interval); + }, []); + + return ( +

+ {thinkingText} + {dots} +

+ ); +} + function SkeletonSource(): JSX.Element { return (
@@ -148,76 +173,6 @@ function RelatedSourceIcon(): JSX.Element { ); } -function PulseLoader(): JSX.Element { - return ( - - - - - - - - - - - - - - - ); -} - function CopyButton({ onClick }: { onClick: () => void }): JSX.Element { const [isCopied, setIsCopied] = useState(false); diff --git a/packages/docsearch-react/src/DocSearchModal.tsx b/packages/docsearch-react/src/DocSearchModal.tsx index 0a81b301..d0e2f18f 100644 --- a/packages/docsearch-react/src/DocSearchModal.tsx +++ b/packages/docsearch-react/src/DocSearchModal.tsx @@ -16,9 +16,10 @@ import type { ScreenStateTranslations } from './ScreenState'; import { ScreenState } from './ScreenState'; import type { SearchBoxTranslations } from './SearchBox'; import { SearchBox } from './SearchBox'; -import { createStoredSearches } from './stored-searches'; -import type { DocSearchHit, DocSearchState, InternalDocSearchHit, StoredDocSearchHit } from './types'; -import { useGenAiClient } from './useAskAi'; +import { createStoredConversations, createStoredSearches } from './stored-searches'; +import type { DocSearchHit, DocSearchState, InternalDocSearchHit, StoredAskAiState, StoredDocSearchHit } from './types'; +import type { AskAiState } from './useAskAi'; +import { useAskAi, useGenAiClient } from './useAskAi'; import { useSearchClient } from './useSearchClient'; import { useTouchEvents } from './useTouchEvents'; import { useTrapFocus } from './useTrapFocus'; @@ -43,18 +44,27 @@ export type DocSearchModalProps = DocSearchProps & { * Helper function to build sources when there is no query * useful for recent searches and favorite searches. */ -const buildNoQuerySources = ( - recentSearches: ReturnType, - favoriteSearches: ReturnType, - saveRecentSearch: (item: InternalDocSearchHit) => void, - onClose: () => void, - disableUserPersonalization: boolean, -): Array> => { +type BuildNoQuerySourcesOptions = { + recentSearches: ReturnType; + favoriteSearches: ReturnType; + saveRecentSearch: (item: InternalDocSearchHit) => void; + onClose: () => void; + disableUserPersonalization: boolean; + canHandleAskAi: boolean; +}; + +const buildNoQuerySources = ({ + recentSearches, + favoriteSearches, + saveRecentSearch, + onClose, + disableUserPersonalization, +}: BuildNoQuerySourcesOptions): Array> => { if (disableUserPersonalization) { return []; } - return [ + const sources: Array> = [ { sourceId: 'recentSearches', onSelect({ item, event }): void { @@ -86,6 +96,8 @@ const buildNoQuerySources = ( }, }, ]; + + return sources; }; type BuildQuerySourcesState = Pick, 'context'>; @@ -107,7 +119,7 @@ const buildQuerySources = async ({ appId, apiKey, maxResultsPerGroup, - transformItems = identity, // default to identity if not provided + transformItems = identity, saveRecentSearch, onClose, }: { @@ -119,15 +131,15 @@ const buildQuerySources = async ({ indexName: string; searchParameters: DocSearchProps['searchParameters']; snippetLength: React.MutableRefObject; - insights: boolean; // ensure boolean + insights: boolean; appId?: string; apiKey?: string; maxResultsPerGroup?: number; - transformItems?: DocSearchProps['transformItems']; // prop can be undefined + transformItems?: DocSearchProps['transformItems']; saveRecentSearch: (item: InternalDocSearchHit) => void; onClose: () => void; }): Promise>> => { - const insightsActive = insights; // already boolean + const insightsActive = insights; try { const { results } = await searchClient.search({ @@ -277,7 +289,6 @@ export function DocSearchModal({ isOpen: false, activeItemId: null, status: 'idle', - isAskAiActive, }); const containerRef = React.useRef(null); @@ -296,6 +307,17 @@ export function DocSearchModal({ dataSourceId, promptId, }); + if (!genAiClient && canHandleAskAi) { + throw new Error('Something went wrong while initializing the Ask AI feature.'); + } + + // storage + const conversations = React.useRef( + createStoredConversations({ + key: `__DOCSEARCH_ASKAI_CONVERSATIONS__${indexName}`, + limit: 10, + }), + ).current; const favoriteSearches = React.useRef( createStoredSearches({ key: `__DOCSEARCH_FAVORITE_SEARCHES__${indexName}`, @@ -311,6 +333,21 @@ export function DocSearchModal({ }), ).current; + // askAI + const askAiState = useAskAi({ + genAiClient: genAiClient!, + conversations, + }); + + const handleAskAiToggle = React.useCallback( + (toggle: boolean, query: string) => { + onAskAiToggle(toggle); + askAiState.ask?.({ query }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [onAskAiToggle], + ); + const saveRecentSearch = React.useCallback( function saveRecentSearch(item: InternalDocSearchHit) { if (disableUserPersonalization) { @@ -347,145 +384,145 @@ export function DocSearchModal({ [state.context.algoliaInsightsPlugin], ); - const autocomplete = React.useMemo( - () => - createAutocomplete, React.MouseEvent, React.KeyboardEvent>( - { - id: 'docsearch', - // we don't want to focus on the AskAI hit by default - defaultActiveItemId: canHandleAskAi ? 1 : 0, - placeholder, - openOnFocus: true, - initialState: { - query: initialQuery, - context: { - searchSuggestions: [], - }, - }, - insights: Boolean(insights), - navigator, - onStateChange(props) { - const nextState = props.state; - setState((prevState) => { - // to avoid flickering, we ignore the update from autocomplete-core - // when the query just went empty, status is idle, collections are empty, - // and we weren't already loading/stalled. - const queryWentEmpty = prevState.query !== '' && nextState.query === ''; - const statusIsIdle = nextState.status === 'idle'; - const collectionsAreEmpty = !(nextState.collections?.some((c) => c.items.length > 0) ?? false); - const wasNotLoading = prevState.status !== 'loading' && prevState.status !== 'stalled'; + const autocompleteRef = + React.useRef< + ReturnType< + typeof createAutocomplete< + InternalDocSearchHit, + React.FormEvent, + React.MouseEvent, + React.KeyboardEvent + > + > + >(undefined); - if (queryWentEmpty && statusIsIdle && collectionsAreEmpty && wasNotLoading) { - return prevState; - } + if (!autocompleteRef.current) { + autocompleteRef.current = createAutocomplete< + InternalDocSearchHit, + React.FormEvent, + React.MouseEvent, + React.KeyboardEvent + >({ + id: 'docsearch', + // we don't want to focus on the AskAI hit by default + defaultActiveItemId: canHandleAskAi ? 1 : 0, + placeholder, + openOnFocus: true, + initialState: { + query: initialQuery, + context: { + searchSuggestions: [], + }, + }, + insights: Boolean(insights), + navigator, + onStateChange(props) { + setState(props.state); + }, + getSources({ query, state: sourcesState, setContext, setStatus }) { + if (isAskAiActive) { + // when Ask AI screen is active, don't render any autocomplete sources + return []; + } + if (!query) { + const noQuerySources = buildNoQuerySources({ + recentSearches, + favoriteSearches, + saveRecentSearch, + onClose, + disableUserPersonalization, + canHandleAskAi, + }); - // otherwise, merge state as usual - return { - ...prevState, - ...nextState, - }; - }); - }, - getSources({ query, state: sourcesState, setContext, setStatus }) { - if (!query) { - return buildNoQuerySources( - recentSearches, - favoriteSearches, - saveRecentSearch, - onClose, - disableUserPersonalization, - ); - } - - const querySourcesState: BuildQuerySourcesState = { context: sourcesState.context }; - - // Algolia sources - const algoliaSourcesPromise = buildQuerySources({ - query, - state: querySourcesState, - setContext, - setStatus, - searchClient, - indexName, - searchParameters, - snippetLength, - insights: Boolean(insights), - appId, - apiKey, - maxResultsPerGroup, - transformItems, - saveRecentSearch, - onClose, - }); - - // AskAI source - const askAiSource: Array> = canHandleAskAi + const recentConversationSource: Array> = + canHandleAskAi ? [ { - sourceId: 'askAI', + sourceId: 'recentConversations', getItems(): InternalDocSearchHit[] { - // return a single item representing the Ask AI action - // placeholder data matching the InternalDocSearchHit structure - const askItem: InternalDocSearchHit = { - type: 'askAI', - query, - url_without_anchor: '', - objectID: `ask-ai-button`, - content: null, - url: '', - anchor: null, - hierarchy: { - lvl0: 'Ask AI', // Or contextually relevant - lvl1: query, - lvl2: null, - lvl3: null, - lvl4: null, - lvl5: null, - lvl6: null, - }, - _highlightResult: {} as any, - _snippetResult: {} as any, - __docsearch_parent: null, - }; - return [askItem]; + return conversations.getAll() as unknown as InternalDocSearchHit[]; }, onSelect({ item }): void { - if (item.type === 'askAI') { - onAskAiToggle(true); + if (item.askState) { + handleAskAiToggle(true, item.askState.query); } }, }, ] : []; + return [...noQuerySources, ...recentConversationSource]; + } - // Combine Algolia results (once resolved) with the Ask AI source - return algoliaSourcesPromise.then((algoliaSources) => { - return [...askAiSource, ...algoliaSources]; - }); - }, - }, - ), - [ - indexName, - searchParameters, - maxResultsPerGroup, - searchClient, - onClose, - saveRecentSearch, - initialQuery, - placeholder, - navigator, - transformItems, - disableUserPersonalization, - insights, - appId, - apiKey, - favoriteSearches, - recentSearches, - canHandleAskAi, - onAskAiToggle, - ], - ); + const querySourcesState: BuildQuerySourcesState = { context: sourcesState.context }; + + // Algolia sources + const algoliaSourcesPromise = buildQuerySources({ + query, + state: querySourcesState, + setContext, + setStatus, + searchClient, + indexName, + searchParameters, + snippetLength, + insights: Boolean(insights), + appId, + apiKey, + maxResultsPerGroup, + transformItems, + saveRecentSearch, + onClose, + }); + + // AskAI source + const askAiSource: Array> = canHandleAskAi + ? [ + { + sourceId: 'askAI', + getItems(): InternalDocSearchHit[] { + // return a single item representing the Ask AI action + // placeholder data matching the InternalDocSearchHit structure + const askItem: InternalDocSearchHit = { + type: 'askAI', + query, + url_without_anchor: '', + objectID: `ask-ai-button`, + content: null, + url: '', + anchor: null, + hierarchy: { + lvl0: 'Ask AI', // Or contextually relevant + lvl1: query, + lvl2: null, + lvl3: null, + lvl4: null, + lvl5: null, + lvl6: null, + }, + _highlightResult: {} as any, + _snippetResult: {} as any, + __docsearch_parent: null, + }; + return [askItem]; + }, + onSelect({ item }): void { + if (item.type === 'askAI' && item.query) { + handleAskAiToggle(true, item.query); + } + }, + }, + ] + : []; + + // Combine Algolia results (once resolved) with the Ask AI source + return algoliaSourcesPromise.then((algoliaSources) => { + return [...askAiSource, ...algoliaSources]; + }); + }, + }); + } + + const autocomplete = autocompleteRef.current; const { getEnvironmentProps, getRootProps, refresh } = autocomplete; @@ -618,17 +655,19 @@ export function DocSearchModal({ disableUserPersonalization={disableUserPersonalization} recentSearches={recentSearches} favoriteSearches={favoriteSearches} + conversations={conversations} inputRef={inputRef} translations={screenStateTranslations} getMissingResultsUrl={getMissingResultsUrl} isAskAiActive={isAskAiActive} canHandleAskAi={canHandleAskAi} - genAiClient={genAiClient} + askAiState={askAiState} onAskAiToggle={onAskAiToggle} onItemClick={(item, event) => { - // if the item is askAI, do nothing - if (item.type === 'askAI') { - onAskAiToggle(true); + // if the item is askAI toggle the screen + if (item.type === 'askAI' && item.query) { + handleAskAiToggle(true, item.query); + event.preventDefault(); return; } diff --git a/packages/docsearch-react/src/MemoizedMarkdown.tsx b/packages/docsearch-react/src/MemoizedMarkdown.tsx index b8b0c155..a2dd10dd 100644 --- a/packages/docsearch-react/src/MemoizedMarkdown.tsx +++ b/packages/docsearch-react/src/MemoizedMarkdown.tsx @@ -11,9 +11,7 @@ function parseMarkdownIntoHTMLBlocks(md: string): string[] { ); } -const HTMLBlock: FC<{ html: string; key: string }> = ({ html, key }) => ( -
-); +const HTMLBlock: FC<{ html: string }> = ({ html }) =>
; const MemoizedHTMLBlock = memo(HTMLBlock, (prev, next) => prev.html === next.html); MemoizedHTMLBlock.displayName = 'MemoizedHTMLBlock'; diff --git a/packages/docsearch-react/src/Results.tsx b/packages/docsearch-react/src/Results.tsx index d7717a38..f28e0659 100644 --- a/packages/docsearch-react/src/Results.tsx +++ b/packages/docsearch-react/src/Results.tsx @@ -2,6 +2,7 @@ import type { AutocompleteApi, AutocompleteState, BaseItem } from '@algolia/auto import React, { type JSX } from 'react'; import type { DocSearchProps } from './DocSearch'; +import { SparklesIcon } from './icons/SparklesIcon'; import { Snippet } from './Snippet'; import type { InternalDocSearchHit, StoredDocSearchHit } from './types'; @@ -32,7 +33,20 @@ export function Results(props: ResultsProps
    - + +
+ + ); + } + + if (props.collection.source.sourceId === 'recentConversations') { + return ( +
+
{props.title}
+
    + {props.collection.items.map((item, index) => { + return ; + })}
); @@ -115,6 +129,12 @@ function Result({
)} + {item.type === 'askAI' && ( +
+ +
+ )} + {item.hierarchy[item.type] && (item.type === 'lvl2' || item.type === 'lvl3' || @@ -141,41 +161,20 @@ function Result({ ); } -interface AskAiResultProps extends ResultsProps { +interface AskAiButtonProps extends ResultsProps { item: TItem; translations?: ResultsTranslations; } -function AskAiResult({ +function AskAiButton({ item, getItemProps, onItemClick, translations, collection, -}: AskAiResultProps): JSX.Element { +}: AskAiButtonProps): JSX.Element { const { askAiPlaceholder = 'Ask AI: ' } = translations || {}; - const icon = ( - - - - - - - - ); - return (
  • ({ >
    -
    {icon}
    +
    + +
    {askAiPlaceholder} "{item.query || ''}" diff --git a/packages/docsearch-react/src/ScreenState.tsx b/packages/docsearch-react/src/ScreenState.tsx index 0e710ba9..a727dbb2 100644 --- a/packages/docsearch-react/src/ScreenState.tsx +++ b/packages/docsearch-react/src/ScreenState.tsx @@ -6,7 +6,6 @@ import { AskAiScreen } from './AskAiScreen'; import type { DocSearchProps } from './DocSearch'; import type { ErrorScreenTranslations } from './ErrorScreen'; import { ErrorScreen } from './ErrorScreen'; -import type { GenAiClient } from './lib/genAiClient'; import type { NoResultsScreenTranslations } from './NoResultsScreen'; import { NoResultsScreen } from './NoResultsScreen'; import type { ResultsScreenTranslations } from './ResultsScreen'; @@ -14,7 +13,8 @@ import { ResultsScreen } from './ResultsScreen'; import type { StartScreenTranslations } from './StartScreen'; import { StartScreen } from './StartScreen'; import type { StoredSearchPlugin } from './stored-searches'; -import type { InternalDocSearchHit, StoredDocSearchHit } from './types'; +import type { InternalDocSearchHit, StoredAskAiState, StoredDocSearchHit } from './types'; +import type { AskAiState } from './useAskAi'; export type ScreenStateTranslations = Partial<{ errorScreen: ErrorScreenTranslations; @@ -29,6 +29,7 @@ export interface ScreenStateProps state: AutocompleteState; recentSearches: StoredSearchPlugin; favoriteSearches: StoredSearchPlugin; + conversations: StoredSearchPlugin; onItemClick: (item: InternalDocSearchHit, event: KeyboardEvent | MouseEvent) => void; onAskAiToggle: (toggle: boolean) => void; isAskAiActive: boolean; @@ -37,7 +38,7 @@ export interface ScreenStateProps hitComponent: DocSearchProps['hitComponent']; indexName: DocSearchProps['indexName']; disableUserPersonalization: boolean; - genAiClient: GenAiClient | null; + askAiState?: AskAiState; resultsFooterComponent: DocSearchProps['resultsFooterComponent']; translations: ScreenStateTranslations; getMissingResultsUrl?: DocSearchProps['getMissingResultsUrl']; diff --git a/packages/docsearch-react/src/StartScreen.tsx b/packages/docsearch-react/src/StartScreen.tsx index ec73865c..16ec5b4d 100644 --- a/packages/docsearch-react/src/StartScreen.tsx +++ b/packages/docsearch-react/src/StartScreen.tsx @@ -1,6 +1,6 @@ import React, { type JSX } from 'react'; -import { RecentIcon, CloseIcon, StarIcon, SearchIcon } from './icons'; +import { RecentIcon, CloseIcon, StarIcon, SearchIcon, SparklesIcon } from './icons'; import { Results } from './Results'; import type { ScreenStateProps } from './ScreenState'; import type { InternalDocSearchHit } from './types'; @@ -12,6 +12,8 @@ export type StartScreenTranslations = Partial<{ removeRecentSearchButtonTitle: string; favoriteSearchesTitle: string; removeFavoriteSearchButtonTitle: string; + recentConversationsTitle: string; + removeRecentConversationButtonTitle: string; }>; type StartScreenProps = Omit, 'translations'> & { @@ -22,12 +24,15 @@ type StartScreenProps = Omit, 'translatio export function StartScreen({ translations = {}, ...props }: StartScreenProps): JSX.Element | null { const { recentSearchesTitle = 'Recent', - noRecentSearchesText = 'Make a search to see results', + noRecentSearchesText = 'Search results will appear here', saveRecentSearchButtonTitle = 'Save this search', removeRecentSearchButtonTitle = 'Remove this search from history', favoriteSearchesTitle = 'Favorite', removeFavoriteSearchButtonTitle = 'Remove this search from favorites', + recentConversationsTitle = 'Recently asked', + removeRecentConversationButtonTitle = 'Remove this conversation from history', } = translations; + if (props.state.status === 'idle' && props.hasCollections === false) { if (props.disableUserPersonalization) { return null; @@ -128,6 +133,36 @@ export function StartScreen({ translations = {}, ...props }: StartScreenProps):
    )} /> + + ( +
    + +
    + )} + renderAction={({ item, runDeleteTransition }) => ( +
    + +
    + )} + />
    ); } diff --git a/packages/docsearch-react/src/icons/SparklesIcon.tsx b/packages/docsearch-react/src/icons/SparklesIcon.tsx new file mode 100644 index 00000000..dac9ef26 --- /dev/null +++ b/packages/docsearch-react/src/icons/SparklesIcon.tsx @@ -0,0 +1,23 @@ +import React, { type JSX } from 'react'; + +export function SparklesIcon(): JSX.Element { + return ( + + + + + + + + ); +} diff --git a/packages/docsearch-react/src/icons/index.ts b/packages/docsearch-react/src/icons/index.ts index b3886e02..e3df831a 100644 --- a/packages/docsearch-react/src/icons/index.ts +++ b/packages/docsearch-react/src/icons/index.ts @@ -1,5 +1,6 @@ export * from './GoToExternalIcon'; export * from './LoadingIcon'; +export * from './SparklesIcon'; export * from './RecentIcon'; export * from './CloseIcon'; export * from './SearchIcon'; diff --git a/packages/docsearch-react/src/lib/genAiClient.ts b/packages/docsearch-react/src/lib/genAiClient.ts index 824deada..6081a392 100644 --- a/packages/docsearch-react/src/lib/genAiClient.ts +++ b/packages/docsearch-react/src/lib/genAiClient.ts @@ -43,6 +43,7 @@ export function algoliaGenAiToolkit(appId: string, apiKey: string, options: GenA export interface FetchAskAiResponseParams { query: string; genAiClient: GenAiClient; + conversationId?: string | null; additionalFilters?: Record; onUpdate: (chunk: AskAiResponse) => void; onComplete?: () => void; @@ -53,6 +54,7 @@ async function fetchAskAiResponseFunction({ query, genAiClient, additionalFilters, + conversationId, onUpdate, onComplete, onError, @@ -97,6 +99,7 @@ async function fetchAskAiResponseFunction({ dataSourceId, promptId, additionalFilters, + conversationId, stream: true, }), }); diff --git a/packages/docsearch-react/src/stored-searches.ts b/packages/docsearch-react/src/stored-searches.ts index 0cfe28f4..57cf2fdc 100644 --- a/packages/docsearch-react/src/stored-searches.ts +++ b/packages/docsearch-react/src/stored-searches.ts @@ -1,40 +1,5 @@ -import type { DocSearchHit, StoredDocSearchHit } from './types'; - -function isLocalStorageSupported(): boolean { - const key = '__TEST_KEY__'; - - try { - localStorage.setItem(key, ''); - localStorage.removeItem(key); - - return true; - } catch { - return false; - } -} - -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -function createStorage(key: string) { - if (isLocalStorageSupported() === false) { - return { - setItem(): void {}, - getItem(): TItem[] { - return []; - }, - }; - } - - return { - setItem(item: TItem[]): void { - return window.localStorage.setItem(key, JSON.stringify(item)); - }, - getItem(): TItem[] { - const item = window.localStorage.getItem(key); - - return item ? JSON.parse(item) : []; - }, - }; -} +import type { DocSearchHit, StoredAskAiState, StoredDocSearchHit } from './types'; +import { createStorage } from './utils/storage'; type CreateStoredSearchesOptions = { key: string; @@ -79,3 +44,42 @@ export function createStoredSearches({ }, }; } + +export function createStoredConversations({ + key, + limit = 5, +}: CreateStoredSearchesOptions): StoredSearchPlugin { + const storage = createStorage(key); + let items = storage.getItem().slice(0, limit); + + return { + add(item: TItem): void { + const { askState } = item; + + // check if this query is already saved + // @todo: this is a bit of a hack, we should be able to use + // conversationId to identify. + const isQueryAlreadySaved = items.findIndex( + (x) => + x.objectID === askState?.conversationId || x.askState?.messages[0].content === askState?.messages[0].content, + ); + + if (isQueryAlreadySaved > -1) { + items[isQueryAlreadySaved] = item; + } else { + items.unshift(item); + items = items.slice(0, limit); + } + + storage.setItem(items); + }, + getAll(): TItem[] { + return items; + }, + remove(item: TItem): void { + items = items.filter((x) => x.objectID !== item.objectID); + + storage.setItem(items); + }, + }; +} diff --git a/packages/docsearch-react/src/types/DocSearchState.ts b/packages/docsearch-react/src/types/DocSearchState.ts index 291d989b..c48d0943 100644 --- a/packages/docsearch-react/src/types/DocSearchState.ts +++ b/packages/docsearch-react/src/types/DocSearchState.ts @@ -13,5 +13,4 @@ interface DocSearchContext extends AutocompleteContext { export interface DocSearchState extends AutocompleteState { context: DocSearchContext; - isAskAiActive: boolean; } diff --git a/packages/docsearch-react/src/types/StoredDocSearchHit.ts b/packages/docsearch-react/src/types/StoredDocSearchHit.ts index fecd7941..3b34f8dc 100644 --- a/packages/docsearch-react/src/types/StoredDocSearchHit.ts +++ b/packages/docsearch-react/src/types/StoredDocSearchHit.ts @@ -1,3 +1,6 @@ +import type { AskAiState } from '../useAskAi'; + import type { DocSearchHit } from './DocSearchHit'; export type StoredDocSearchHit = Omit; +export type StoredAskAiState = Omit & { askState?: AskAiState }; diff --git a/packages/docsearch-react/src/useAskAi.ts b/packages/docsearch-react/src/useAskAi.ts index 5bc6cdb0..ba2a800b 100644 --- a/packages/docsearch-react/src/useAskAi.ts +++ b/packages/docsearch-react/src/useAskAi.ts @@ -1,6 +1,8 @@ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, useRef } from 'react'; import { algoliaGenAiToolkit, type AskAiResponse, type GenAiClient, type GenAiClientOptions } from './lib/genAiClient'; +import type { StoredSearchPlugin } from './stored-searches'; +import type { StoredAskAiState } from './types'; type LoadingStatus = 'error' | 'idle' | 'loading' | 'streaming'; @@ -10,18 +12,24 @@ interface Message { content: string; } -interface UseAskAiState { +export interface AskAiState { messages: Message[]; currentResponse: string; + query: string; additionalFilters: string[]; context: AskAiResponse['context']; - conversationID: string | null; + conversationId: string | null; loadingStatus: LoadingStatus; error: Error | null; + // optional just to make the type flexible + ask?: (params: AskParams) => Promise; + reset?: () => void; + restoreConversation?: (conversation: StoredAskAiState) => void; } interface UseAskAiParams { genAiClient: GenAiClient; + conversations: StoredSearchPlugin; } interface AskParams { @@ -29,51 +37,60 @@ interface AskParams { additionalFilters?: Record; } -interface UseAskAiReturn { - messages: Message[]; - currentResponse: string; - additionalFilters: string[]; - context: AskAiResponse['context']; - conversationID: string | null; - loadingStatus: LoadingStatus; - error: Error | null; - ask: (params: AskParams) => Promise; - resetState: () => void; -} - /** * Hook for interacting with Algolia's Generative AI API. * * @param params - Configuration options. * @param params.genAiClient - The GenAI client instance. + * @param params.conversations - The conversations storage ref to store the AI responses. * @returns State and functions for interacting with the AI. */ -export function useAskAi({ genAiClient }: UseAskAiParams): UseAskAiReturn { - const initialState = useMemo( +export function useAskAi({ genAiClient, conversations }: UseAskAiParams): AskAiState { + const initialState = useMemo( () => ({ messages: [], currentResponse: '', + query: '', additionalFilters: [], context: [], - conversationID: null, - loadingStatus: 'idle', + conversationId: null, + loadingStatus: 'idle' as const, error: null, }), [], ); - const [state, setState] = useState(initialState); + const [state, setState] = useState>(initialState); + const didAddConversationRef = useRef(false); - // reset state - const resetState = useCallback(() => { + // reset state function + const reset = useCallback(() => { setState(initialState); + didAddConversationRef.current = false; }, [initialState]); + const restoreConversation = useCallback( + (conversation: StoredAskAiState) => { + setState(conversation.askState ?? initialState); + didAddConversationRef.current = true; + }, + [initialState], + ); + // ask ai request const ask = useCallback( async ({ query, additionalFilters }: AskParams) => { + // if there's no conversationid, empty the messages + if (!state.conversationId) { + setState((prevState) => ({ + ...prevState, + messages: [], + })); + } + // generate a unique id for the user message const userMessageId = crypto.randomUUID(); + const newConversationId = state.conversationId ?? crypto.randomUUID(); // Add user message to the conversation setState((prevState) => ({ @@ -82,6 +99,7 @@ export function useAskAi({ genAiClient }: UseAskAiParams): UseAskAiReturn { currentResponse: '', additionalFilters: [], context: [], + query, loadingStatus: 'loading', error: null, })); @@ -90,6 +108,7 @@ export function useAskAi({ genAiClient }: UseAskAiParams): UseAskAiReturn { await genAiClient.fetchAskAiResponse({ query, additionalFilters, + // conversationId: newConversationId, onUpdate: (chunk) => { // update state incrementally as data streams in setState((prevState) => ({ @@ -97,23 +116,50 @@ export function useAskAi({ genAiClient }: UseAskAiParams): UseAskAiReturn { currentResponse: chunk.response, additionalFilters: chunk.additionalFilters, context: chunk.context, - conversationID: chunk.conversationID, loadingStatus: 'streaming', })); }, onComplete: () => { - // generate a unique id for the assistant message const assistantMessageId = crypto.randomUUID(); - // add the completed assistant message to the conversation - setState((prevState) => ({ - ...prevState, - messages: [ - ...prevState.messages, - { role: 'assistant', content: prevState.currentResponse, id: assistantMessageId }, - ], - loadingStatus: 'idle', // stream finished successfully - })); + setState((prevState) => { + const newState = { + ...prevState, + messages: [ + ...prevState.messages, + { role: 'assistant' as const, content: prevState.currentResponse, id: assistantMessageId }, + ], + loadingStatus: 'idle' as const, + conversationId: prevState.conversationId ?? newConversationId, + }; + + if (!didAddConversationRef.current) { + conversations.add({ + query: newState.messages[0].content, + objectID: newConversationId, + + // dummy content to make it a valid hit + content: null, + hierarchy: { + lvl0: 'askAI', + lvl1: newState.messages[0].content, + lvl2: null, + lvl3: null, + lvl4: null, + lvl5: null, + lvl6: null, + }, + type: 'askAI', + url: '', + url_without_anchor: '', + anchor: '', + askState: newState, + }); + didAddConversationRef.current = true; + } + + return newState; + }); }, onError: (error) => { // handle errors during the stream @@ -132,13 +178,14 @@ export function useAskAi({ genAiClient }: UseAskAiParams): UseAskAiReturn { })); } }, - [genAiClient], + [genAiClient, conversations, state], ); return { ...state, ask, - resetState, + reset, + restoreConversation, }; } diff --git a/packages/docsearch-react/src/utils/storage.ts b/packages/docsearch-react/src/utils/storage.ts new file mode 100644 index 00000000..b941f440 --- /dev/null +++ b/packages/docsearch-react/src/utils/storage.ts @@ -0,0 +1,89 @@ +/** + * Checks if local storage is available and usable. + */ +export function isLocalStorageSupported(): boolean { + const key = '__TEST_KEY__'; + try { + localStorage.setItem(key, ''); + localStorage.removeItem(key); + return true; + } catch { + return false; + } +} + +/** + * Creates a simple storage interface for arrays using localstorage. + * Provides basic getitem and setitem functionality. + * Falls back to a no-op implementation if localstorage is not supported.. + * + * @template titem The type of items to store. + * @param key - The localstorage key to use. + * @returns An object with setitem and getitem methods. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createStorage(key: string) { + if (isLocalStorageSupported() === false) { + return { + setItem(): void {}, + getItem(): TItem[] { + return []; + }, + }; + } + + return { + setItem(item: TItem[]): void { + return window.localStorage.setItem(key, JSON.stringify(item)); + }, + getItem(): TItem[] { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : []; + }, + }; +} + +/** + * Creates a simple storage interface for a single object using localstorage. + * Provides basic getitem, setitem, and removeitem functionality. + * Falls back to a no-op implementation if localstorage is not supported. + * + * @template titem The type of the object to store. + * @param key - The localstorage key to use. + * @returns An object with setitem, getitem, and removeitem methods. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createObjectStorage(key: string) { + if (isLocalStorageSupported() === false) { + return { + setItem(_item: TItem | null): void {}, + getItem(): TItem | null { + return null; + }, + removeItem(): void {}, + }; + } + + return { + setItem(item: TItem | null): void { + if (item === null) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(item)); + } + }, + getItem(): TItem | null { + const item = window.localStorage.getItem(key); + try { + return item ? (JSON.parse(item) as TItem) : null; + } catch { + // handle potential JSON parsing errors, e.g., corrupted data + window.localStorage.removeItem(key); // clear corrupted data + return null; + } + }, + removeItem(): void { + window.localStorage.removeItem(key); + }, + }; +}