diff --git a/packages/docsearch-css/src/modal.css b/packages/docsearch-css/src/modal.css index 99cfb641..9341fd14 100644 --- a/packages/docsearch-css/src/modal.css +++ b/packages/docsearch-css/src/modal.css @@ -824,7 +824,7 @@ assistive tech users */ } .DocSearch-AskAiScreen-ThinkingDots { - font-size: 0.7em; + font-size: 0.8em; font-weight: 400; color: var(--docsearch-secondary-text-color); margin: 0; diff --git a/packages/docsearch-react/src/AskAiScreen.tsx b/packages/docsearch-react/src/AskAiScreen.tsx index 1204c25e..81b896e9 100644 --- a/packages/docsearch-react/src/AskAiScreen.tsx +++ b/packages/docsearch-react/src/AskAiScreen.tsx @@ -1,9 +1,26 @@ -import React, { type JSX, useState, useEffect } from 'react'; +import React, { type JSX, useState, useEffect, useMemo } from 'react'; +import { SparklesIcon } from './icons'; +import type { AskAiResponse } from './lib/genAiClient'; import { MemoizedMarkdown } from './MemoizedMarkdown'; import type { ScreenStateProps } from './ScreenState'; import type { InternalDocSearchHit } from './types'; +interface Message { + id: string; + role: 'assistant' | 'user'; + content: string; + context?: AskAiResponse['context']; +} + +type LoadingStatus = 'error' | 'idle' | 'loading' | 'streaming'; + +interface AskAiStateForScreen { + messages: Message[]; + loadingStatus: LoadingStatus; + error: Error | null; +} + export type AskAiScreenTranslations = Partial<{ titleText: string; disclaimerText: string; @@ -11,111 +28,223 @@ export type AskAiScreenTranslations = Partial<{ thinkingText: string; }>; -type AskAiScreenProps = Omit, 'translations'> & { +type AskAiScreenProps = Omit, 'askAiState' | 'translations'> & { + askAiState: AskAiStateForScreen; translations?: AskAiScreenTranslations; - conversationId?: string | null; }; -export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): JSX.Element | null { - if (!props.askAiState) { +interface AskAiScreenHeaderProps { + disclaimerText: string; +} + +interface Exchange { + id: string; + userMessage: Message; + assistantMessage: Message | null; +} + +function AskAiScreenHeader({ disclaimerText }: AskAiScreenHeaderProps): JSX.Element { + return ( +
+
+ +
+

{disclaimerText}

+
+ ); +} + +interface AskAiExchangeCardProps { + exchange: Exchange; + isLastExchange: boolean; + loadingStatus: LoadingStatus; + error: Error | null; + translations: Required; + globalHasHadAssistantResponse: boolean; +} + +function AskAiExchangeCard({ + exchange, + isLastExchange, + loadingStatus, + error, + translations, + globalHasHadAssistantResponse, +}: AskAiExchangeCardProps): JSX.Element { + const { userMessage, assistantMessage } = exchange; + + const showLoadingIndicator = isLastExchange && loadingStatus === 'loading'; + const isStreaming = isLastExchange && loadingStatus === 'streaming'; + const showError = isLastExchange && loadingStatus === 'error' && error; + const showActions = !isLastExchange || (isLastExchange && loadingStatus === 'idle' && Boolean(assistantMessage)); + const contextToDisplay = assistantMessage?.context || []; + + return ( +
+
+
+

{userMessage.content}

+
+
+
+ {showLoadingIndicator && ( +
+ +
+ )} + {(isStreaming || assistantMessage?.content) && ( + + )} + {showError &&

{error.message}

} +
+
+
+ +
+
+ + {/* Sources for this exchange */} + +
+ ); +} + +interface AskAiScreenFooterActionsProps { + showActions: boolean; + latestAssistantMessageContent: string | null; +} + +function AskAiScreenFooterActions({ + showActions, + latestAssistantMessageContent, +}: AskAiScreenFooterActionsProps): JSX.Element | null { + if (!showActions || !latestAssistantMessageContent) { return null; } + return ( +
+ navigator.clipboard.writeText(latestAssistantMessageContent)} /> + + +
+ ); +} +interface AskAiSourcesPanelProps { + contextToDisplay: AskAiResponse['context']; + loadingStatus: LoadingStatus; + relatedSourcesText: string; + hasHadAssistantResponse: boolean; + isExchangeLoading: boolean; +} + +function AskAiSourcesPanel({ + contextToDisplay, + loadingStatus, + relatedSourcesText, + hasHadAssistantResponse, + isExchangeLoading, +}: AskAiSourcesPanelProps): JSX.Element { + return ( +
+

{relatedSourcesText}

+ {contextToDisplay.length > 0 && + contextToDisplay.map((source) => ( + + + {source.title || source.url || source.objectID} + + ))} + {contextToDisplay.length === 0 && + loadingStatus === 'loading' && + !hasHadAssistantResponse && + isExchangeLoading && + // eslint-disable-next-line react/no-array-index-key + Array.from({ length: 3 }).map((_, index) => )} + + {contextToDisplay.length === 0 && + (loadingStatus === 'idle' || loadingStatus === 'streaming') && + hasHadAssistantResponse && + !isExchangeLoading && ( +

No related sources for the latest answer.

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

Could not load related sources.

+ )} +
+ ); +} + +export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): JSX.Element | 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', + titleText = 'Ask AI', } = translations; - const { messages, currentResponse, loadingStatus, context, error } = props.askAiState; + const finalTranslations: Required = { + titleText, + disclaimerText, + relatedSourcesText, + thinkingText, + }; - // determine the initial query to display - const displayedQuery = messages.find((m) => m.role === 'user')?.content || 'No query provided'; + const { messages, loadingStatus, error } = props.askAiState; - // select the content to display based on the status - const displayedAnswer = - loadingStatus === 'streaming' ? currentResponse : messages.find((m) => m.role === 'assistant')?.content || ''; + // Group messages into exchanges (user + assistant pairs) + const exchanges: Exchange[] = useMemo(() => { + const grouped: Exchange[] = []; + for (let i = 0; i < messages.length; i++) { + if (messages[i].role === 'user') { + const userMessage = messages[i]; + const assistantMessage = messages[i + 1]?.role === 'assistant' ? messages[i + 1] : null; + grouped.push({ id: userMessage.id, userMessage, assistantMessage }); + if (assistantMessage) { + i++; + } + } + } + return grouped; + }, [messages]); + + const globalHasHadAssistantResponse = messages.some((m) => m.role === 'assistant'); return (
-
-
- - - - - - - -
-

{disclaimerText}

-
+
-
-
-

{displayedQuery}

- {error &&

{error.message}

} -
- {(loadingStatus === 'streaming' || loadingStatus === 'idle') && ( - - )} - {loadingStatus === 'loading' && ( -
- -
- )} -
-
- {loadingStatus === 'idle' && displayedAnswer.length > 0 && ( -
- navigator.clipboard.writeText(displayedAnswer)} /> - - -
- )} -
-
-
-

{relatedSourcesText}

- {context.length === 0 && - loadingStatus === 'loading' && - // eslint-disable-next-line react/no-array-index-key - Array.from({ length: 3 }).map((_, index) => )} - {context.length > 0 && - context.map((source) => ( - - - {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. -

- )} -
+
+ {exchanges + .slice() + .reverse() + .map((exchange, index) => ( + + ))}
diff --git a/packages/docsearch-react/src/DocSearchModal.tsx b/packages/docsearch-react/src/DocSearchModal.tsx index d0e2f18f..2be38163 100644 --- a/packages/docsearch-react/src/DocSearchModal.tsx +++ b/packages/docsearch-react/src/DocSearchModal.tsx @@ -339,15 +339,6 @@ export function DocSearchModal({ 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) { @@ -396,17 +387,23 @@ export function DocSearchModal({ > >(undefined); + const handleAskAiToggle = React.useCallback( + (toggle: boolean, query: string) => { + onAskAiToggle(toggle); + // clear the query + if (autocompleteRef.current) { + autocompleteRef.current.setQuery(''); + } + askAiState.ask?.({ query }); + }, + [onAskAiToggle, askAiState], + ); + if (!autocompleteRef.current) { - autocompleteRef.current = createAutocomplete< - InternalDocSearchHit, - React.FormEvent, - React.MouseEvent, - React.KeyboardEvent - >({ + autocompleteRef.current = createAutocomplete({ id: 'docsearch', // we don't want to focus on the AskAI hit by default defaultActiveItemId: canHandleAskAi ? 1 : 0, - placeholder, openOnFocus: true, initialState: { query: initialQuery, @@ -608,12 +605,18 @@ export function DocSearchModal({ }; }, []); + // Refresh the autocomplete results when ask ai is toggled off + // helps return to the previous ac state and start screen + React.useEffect(() => { + if (!isAskAiActive) { + autocomplete.refresh(); + } + }, [isAskAiActive, autocomplete]); + return (
{ + handleAskAiToggle(true, query); + }} /> diff --git a/packages/docsearch-react/src/ScreenState.tsx b/packages/docsearch-react/src/ScreenState.tsx index a727dbb2..9e5cbbef 100644 --- a/packages/docsearch-react/src/ScreenState.tsx +++ b/packages/docsearch-react/src/ScreenState.tsx @@ -46,8 +46,8 @@ export interface ScreenStateProps export const ScreenState = React.memo( ({ translations = {}, ...props }: ScreenStateProps) => { - if (props.isAskAiActive && props.canHandleAskAi) { - return ; + if (props.isAskAiActive && props.canHandleAskAi && props.askAiState) { + return ; } if (props.state?.status === 'error') { diff --git a/packages/docsearch-react/src/SearchBox.tsx b/packages/docsearch-react/src/SearchBox.tsx index cd3e602b..f363e4f9 100644 --- a/packages/docsearch-react/src/SearchBox.tsx +++ b/packages/docsearch-react/src/SearchBox.tsx @@ -25,6 +25,8 @@ interface SearchBoxProps inputRef: RefObject; onClose: () => void; onAskAiToggle: (toggle: boolean) => void; + onAskAgain: (query: string) => void; + placeholder: string; isAskAiActive: boolean; isFromSelection: boolean; translations?: SearchBoxTranslations; @@ -56,6 +58,32 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX. } }, [props.isFromSelection, props.inputRef]); + const baseInputProps = props.getInputProps({ + inputElement: props.inputRef.current!, + autoFocus: props.autoFocus, + maxLength: MAX_QUERY_SIZE, + }); + + const blockedKeys = new Set(['ArrowUp', 'ArrowDown', 'Enter']); + const origOnKeyDown = baseInputProps.onKeyDown; + + const inputProps = { + ...baseInputProps, + onKeyDown: (e: React.KeyboardEvent): void => { + // block these up, down, enter listeners when AskAI is active + if (props.isAskAiActive && blockedKeys.has(e.key)) { + // enter key asks another question + if (e.key === 'Enter' && props.state.query) { + props.onAskAgain(props.state.query); + } + e.preventDefault(); + e.stopPropagation(); + return; + } + origOnKeyDown?.(e); + }, + }; + return ( <>
- +