import type { UseChatHelpers } from '@ai-sdk/react'; import type { AutocompleteApi, AutocompleteState } from '@algolia/autocomplete-core'; import React, { type JSX, type RefObject } from 'react'; import { MAX_QUERY_SIZE } from './constants'; import { LoadingIcon, CloseIcon, SearchIcon, StopIcon, MoreVerticalIcon, NewConversationIcon, ConversationHistoryIcon, } from './icons'; import { BackIcon } from './icons/BackIcon'; import { Menu } from './Menu'; import { ModalHeading } from './ModalHeading'; import type { InternalDocSearchHit } from './types'; import type { AIMessage, AskAiState } from './types/AskiAi'; export type SearchBoxTranslations = Partial<{ clearButtonTitle: string; clearButtonAriaLabel: string; closeButtonText: string; closeButtonAriaLabel: string; placeholderText: string; placeholderTextAskAi: string; placeholderTextAskAiStreaming: string; enterKeyHint: string; enterKeyHintAskAi: string; searchInputLabel: string; backToKeywordSearchButtonText: string; backToKeywordSearchButtonAriaLabel: string; newConversationPlaceholder: string; conversationHistoryTitle: string; startNewConversationText: string; viewConversationHistoryText: string; threadDepthErrorPlaceholder: string; }>; interface SearchBoxProps extends AutocompleteApi { state: AutocompleteState; autoFocus: boolean; inputRef: RefObject; onClose: () => void; onAskAiToggle: (toggle: boolean) => void; onAskAgain: (query: string) => void; onStopAskAiStreaming: () => Promise; placeholder: string; isAskAiActive: boolean; askAiStatus: UseChatHelpers['status']; askAiError?: Error; isFromSelection: boolean; translations?: SearchBoxTranslations; askAiState: AskAiState; setAskAiState: (state: AskAiState) => void; onNewConversation: () => void; onViewConversationHistory: () => void; isThreadDepthError?: boolean; } export function SearchBox({ translations = {}, askAiState, onAskAiToggle, setAskAiState, ...props }: SearchBoxProps): JSX.Element { const { clearButtonTitle = 'Clear', clearButtonAriaLabel = 'Clear the query', closeButtonText = 'Close', closeButtonAriaLabel = 'Close', searchInputLabel = 'Search', backToKeywordSearchButtonText = 'Back to keyword search', backToKeywordSearchButtonAriaLabel = 'Back to keyword search', placeholderTextAskAiStreaming = 'Answering...', newConversationPlaceholder = 'Ask a question', conversationHistoryTitle = 'My conversation history', startNewConversationText = 'Start a new conversation', viewConversationHistoryText = 'Conversation history', threadDepthErrorPlaceholder = 'Conversation limit reached', } = translations; const { onReset } = props.getFormProps({ inputElement: props.inputRef.current, }); React.useEffect(() => { if (props.autoFocus && props.inputRef.current) { props.inputRef.current.focus(); } }, [props.autoFocus, props.inputRef]); React.useEffect(() => { if (props.isFromSelection && props.inputRef.current) { props.inputRef.current.select(); } }, [props.isFromSelection, props.inputRef]); const hasRecentConversations = React.useMemo(() => { const askAiSource = props.state.collections[2]; if (!askAiSource) { return false; } return askAiSource.items.length > 0; }, [props.state.collections]); 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 origOnChange = baseInputProps.onChange; const isAskAiStreaming = props.askAiStatus === 'streaming' || props.askAiStatus === 'submitted'; const isKeywordSearchLoading = props.state.status === 'stalled'; const renderMoreOptions = props.isAskAiActive && askAiState !== 'conversation-history'; // Use the thread depth error state passed from parent const isThreadDepthError = props.isThreadDepthError || false; let searchPlaceholder = props.placeholder; if (askAiState === 'new-conversation') { searchPlaceholder = newConversationPlaceholder; } // Override placeholder when thread depth error occurs (only in Ask AI mode) if (isThreadDepthError && props.isAskAiActive) { searchPlaceholder = threadDepthErrorPlaceholder; } let heading: string | null = null; if (isAskAiStreaming) { heading = placeholderTextAskAiStreaming; } if (askAiState === 'conversation-history') { heading = conversationHistoryTitle; } // when returning to another status than streaming or submitted, we focus on the input React.useEffect(() => { if (props.askAiStatus !== 'streaming' && props.askAiStatus !== 'submitted' && props.inputRef.current) { props.inputRef.current.focus(); } }, [props.askAiStatus, props.inputRef]); /** * We need to block the default behavior of the input when Ask AI is active. * This is because the input is used to ask another question when the user presses enter. * * Learn more on default autocomplete behavior: * https://github.com/algolia/autocomplete/blob/next/packages/autocomplete-core/src/getDefaultProps.ts. */ const inputProps = { ...baseInputProps, enterKeyHint: props.isAskAiActive ? ('enter' as const) : ('search' as const), onKeyDown: (e: React.KeyboardEvent): void => { // block these up, down, enter listeners when Ask AI is active if (props.isAskAiActive && blockedKeys.has(e.key)) { // enter key asks another question if (e.key === 'Enter' && !isAskAiStreaming && props.state.query) { props.onAskAgain(props.state.query); } e.preventDefault(); e.stopPropagation(); return; } origOnKeyDown?.(e); }, onChange: (e: React.ChangeEvent): void => { if (props.isAskAiActive) { props.setQuery(e.currentTarget.value); // block search when Ask AI is active // we don't want to trigger the search when the user types // we already know they are asking a question e.preventDefault(); e.stopPropagation(); return; } origOnChange?.(e); }, disabled: isAskAiStreaming || (isThreadDepthError && props.isAskAiActive), }; const handleAskAiBackClick = React.useCallback((): void => { // If there's a thread depth error, start a new conversation instead of exiting if (isThreadDepthError) { props.onNewConversation(); return; } if (askAiState === 'conversation-history') { onAskAiToggle(true); setAskAiState('initial'); return; } onAskAiToggle(false); }, [askAiState, isThreadDepthError, onAskAiToggle, setAskAiState, props]); return ( <>
{ event.preventDefault(); }} onReset={onReset} > {props.isAskAiActive ? ( <> ) : ( <> {isKeywordSearchLoading && (
)} {!isKeywordSearchLoading && ( )} )} {heading && }
{props.state.query &&
} {isAskAiStreaming && ( <>
)} {renderMoreOptions && ( <> {startNewConversationText} {hasRecentConversations && ( {viewConversationHistoryText} )}
)}
); }