1
0
Fork 0
docsearch/packages/docsearch-react/src/DocSearchAskAiModal.tsx
2026-08-06 14:40:33 -04:00

676 lines
19 KiB
TypeScript

import { createAutocomplete } from '@algolia/autocomplete-core';
import type { InitialAskAiMessage, OnAskAiToggle } from '@docsearch/core';
import type { ChatRequestOptions } from 'ai';
import React, { type JSX } from 'react';
import type { AskAiScreenStateTranslations } from './AskAiScreenState';
import { AskAiScreenState } from './AskAiScreenState';
import type { AskAiSearchBoxTranslations } from './components/AskAiSearchBox';
import { AskAiSearchBox } from './components/AskAiSearchBox';
import type { FacetBarTranslations } from './components/FacetBar';
import { FacetBar } from './components/FacetBar';
import { ModalShell } from './components/ui/ModalShell';
import type { DocSearchAIProps } from './DocSearchAI';
import type { FooterTranslations } from './Footer';
import { Footer } from './Footer';
import { Hit } from './Hit';
import { useDocSearchFacets } from './hooks/useDocSearchFacets';
import { useSendItemClickEvent } from './hooks/useDocSearchInsights';
import { useInitialModalQuery } from './hooks/useInitialModalQuery';
import { useModalEnvironment } from './hooks/useModalEnvironment';
import { useModalRefs } from './hooks/useModalRefs';
import { useRefreshOnInitialQuery } from './hooks/useRefreshOnInitialQuery';
import { useSaveRecentSearch } from './hooks/useSaveRecentSearch';
import { useStoredDocSearches } from './hooks/useStoredDocSearches';
import type { NewConversationTranslations } from './NewConversationScreen';
import type {
DocSearchState,
InternalDocSearchHit,
OnAskAiFeedback,
StoredAskAiMessage,
StoredAskAiState,
SuggestedQuestionHit,
} from './types';
import { type AskAiState } from './types/AskiAi';
import { useAskAi } from './useAskAi';
import { useSearchClient } from './useSearchClient';
import { useSuggestedQuestions } from './useSuggestedQuestions';
import {
identity,
isModifierEvent,
isQueryEmpty,
noop,
scrollTo as scrollToUtils,
SOURCE_IDS,
} from './utils';
import {
buildDummyAskAiHit,
isAgentStudioTokenOutputLimitError,
isAskAiPromptBlockingError,
isThreadDepthError,
EMPTY_TOOLS,
} from './utils/ai';
import {
buildAskAiActionSources,
buildRecentConversationSources,
} from './utils/createAskAiSources';
import {
buildNoQuerySources,
buildQuerySources,
type BuildQuerySourcesState,
} from './utils/createDocSearchSources';
import { normalizeDocSearchIndexes } from './utils/normalizeDocSearchIndexes';
export type DocSearchAskAiModalTranslations = AskAiScreenStateTranslations &
Partial<{
searchBox: AskAiSearchBoxTranslations;
newConversation: NewConversationTranslations;
footer: FooterTranslations;
facets: FacetBarTranslations;
}>;
export type DocSearchAskAiModalProps = DocSearchAIProps & {
initialScrollY: number;
onAskAiToggle: OnAskAiToggle;
onClose?: () => void;
isAskAiActive?: boolean;
translations?: DocSearchAskAiModalTranslations;
isHybridModeSupported?: boolean;
};
export function DocSearchAskAiModal({
appId,
apiKey,
askAi,
maxResultsPerGroup,
theme,
onClose = noop,
transformItems = identity,
hitComponent = Hit,
resultsFooterComponent = (): JSX.Element | null => null,
navigator,
initialScrollY = 0,
transformSearchClient = identity,
disableUserPersonalization = false,
initialQuery: initialQueryFromProp = '',
translations = {},
getMissingResultsUrl,
insights = false,
onAskAiToggle,
interceptAskAiEvent,
isAskAiActive = false,
recentSearchesLimit = 7,
recentSearchesWithFavoritesLimit = 4,
indices,
facets,
isHybridModeSupported = false,
footerAction,
...props
}: DocSearchAskAiModalProps): JSX.Element {
const {
footer: footerTranslations,
searchBox: searchBoxTranslations,
facets: facetBarTranslations,
...screenStateTranslations
} = translations;
const [state, setState] = React.useState<
DocSearchState<InternalDocSearchHit>
>({
query: '',
collections: [],
completion: null,
context: {},
isOpen: false,
activeItemId: null,
status: 'idle',
});
// check if the instance is configured to handle ask ai
const canHandleAskAi = Boolean(askAi);
let placeholder =
translations?.searchBox?.placeholderText ||
props.placeholder ||
'Search docs';
if (canHandleAskAi) {
placeholder =
translations?.searchBox?.placeholderText ||
'Search docs or ask AI a question';
}
if (isAskAiActive) {
placeholder =
translations?.searchBox?.placeholderTextAskAi ||
'Ask another question...';
}
const { containerRef, modalRef, formElementRef, dropdownRef, inputRef } =
useModalRefs();
const promptBlockingErrorId = React.useId();
const { initialQuery, initialQueryFromSelection } =
useInitialModalQuery(initialQueryFromProp);
const searchClient = useSearchClient(appId, apiKey, transformSearchClient);
const askAiConfig = typeof askAi === 'object' ? askAi : null;
const askAiConfigurationId = askAiConfig
? askAiConfig.agentId
: (askAi as string);
const askAiSearchParameters = askAiConfig?.searchParameters;
const [askAiState, setAskAiState] = React.useState<AskAiState>('initial');
const suggestedQuestions = useSuggestedQuestions({
agentId: askAiConfigurationId,
searchClient,
suggestedQuestionsEnabled: askAiConfig?.suggestedQuestions,
});
const memoryEnabled = askAiConfig?.memory?.enabled ?? false;
const tools = askAiConfig?.tools ?? EMPTY_TOOLS;
const indexes = React.useMemo(
() =>
normalizeDocSearchIndexes({
indices,
}),
[indices]
);
const defaultIndexName = indexes[0].name;
const autocompleteRef =
React.useRef<
ReturnType<
typeof createAutocomplete<
InternalDocSearchHit,
React.FormEvent<HTMLFormElement>,
React.MouseEvent,
React.KeyboardEvent
>
>
>(undefined);
const {
visibleFacets,
facetSelections,
facetSelectionsRef,
handleFacetSelectionChange,
clearFacetSelections,
} = useDocSearchFacets({
facets,
indexes,
searchClient,
onSelectionsChange: () => autocompleteRef.current?.refresh(),
});
const { favoriteSearches, recentSearches } = useStoredDocSearches({
defaultIndexName,
recentSearchesLimit,
recentSearchesWithFavoritesLimit,
});
const [stoppedStream, setStoppedStream] = React.useState(false);
const {
chatId,
messages,
status,
setMessages,
sendMessage,
stopAskAiStreaming,
askAiError,
sendFeedback,
conversations,
startNewConversation,
restoreConversation,
} = useAskAi({
agentId: askAiConfigurationId,
apiKey: askAiConfig?.apiKey || apiKey,
appId: askAiConfig?.appId || appId,
searchParameters: askAiSearchParameters,
tools,
memory: askAiConfig?.memory,
indices: askAiConfig?.indices,
});
const prevStatus = React.useRef(status);
React.useEffect(() => {
if (disableUserPersonalization) {
return;
}
// if we just transitioned from "streaming" → "ready", persist
if (prevStatus.current === 'streaming' && status === 'ready') {
// if we stopped the stream, store it on the most recent message
if (stoppedStream && messages.at(-1)) {
messages.at(-1)!.metadata = {
stopped: true,
};
}
for (const part of messages[0].parts) {
if (part.type === 'text') {
conversations.add(buildDummyAskAiHit(part.text, messages, chatId));
}
}
}
prevStatus.current = status;
}, [
status,
messages,
conversations,
disableUserPersonalization,
stoppedStream,
chatId,
]);
const hasPromptBlockingError = React.useMemo(() => {
return (
status === 'error' &&
isAskAiPromptBlockingError(askAiError as Error | undefined)
);
}, [status, askAiError]);
const shouldBlockPrompt =
hasPromptBlockingError &&
(!isThreadDepthError(askAiError) ||
messages.some((message) => message.role === 'assistant'));
const promptBlockingChrome = React.useMemo(() => {
if (
!shouldBlockPrompt ||
askAiState === 'new-conversation' ||
askAiState === 'conversation-history'
) {
return undefined;
}
return isAgentStudioTokenOutputLimitError(askAiError)
? ('minimal' as const)
: ('full' as const);
}, [askAiError, askAiState, shouldBlockPrompt]);
const saveRecentSearch = useSaveRecentSearch({
favoriteSearches,
recentSearches,
disableUserPersonalization,
});
const sendItemClickEvent = useSendItemClickEvent(state);
const handleSelectAskAiQuestion = React.useCallback(
(
toggle: boolean,
query: string,
suggestedQuestion: SuggestedQuestionHit | undefined = undefined
) => {
if (toggle) {
const initialMessage: InitialAskAiMessage = {
query,
suggestedQuestionId: suggestedQuestion?.objectID,
};
if (interceptAskAiEvent?.(initialMessage)) {
// Consumer handled it. Avoid *all* default Ask AI behavior.
if (autocompleteRef.current) {
autocompleteRef.current.setQuery('');
}
return;
}
}
if (toggle && askAiState === 'new-conversation') {
setAskAiState('initial');
}
onAskAiToggle(toggle, {
query,
suggestedQuestionId: suggestedQuestion?.objectID,
});
// If we're in hybrid mode, we don't need to send the message,
// it will be handled by the Sidepanel.
if (isHybridModeSupported) return;
setStoppedStream(false);
const messageOptions: ChatRequestOptions = {};
if (suggestedQuestion) {
messageOptions.body = {
suggestedQuestionId: suggestedQuestion.objectID,
};
}
sendMessage(
{
role: 'user',
parts: [
{
type: 'text',
text: query,
},
],
},
messageOptions
);
if (dropdownRef.current) {
// some test environments (like jsdom) don't implement element.scrollTo
const el = dropdownRef.current;
if (typeof (el as any).scrollTo === 'function') {
el.scrollTo({ top: 0, behavior: 'smooth' });
} else {
// fallback for environments without scrollTo support
el.scrollTop = 0;
}
}
// clear the query
if (autocompleteRef.current) {
autocompleteRef.current.setQuery('');
}
},
[
askAiState,
onAskAiToggle,
isHybridModeSupported,
sendMessage,
dropdownRef,
interceptAskAiEvent,
]
);
// feedback handler
const handleFeedbackSubmit = React.useCallback<OnAskAiFeedback>(
async (messageId, feedback): Promise<void> => {
if (!askAiConfigurationId || !appId) return;
await sendFeedback(messageId, feedback);
},
[askAiConfigurationId, appId, sendFeedback]
);
if (!autocompleteRef.current) {
autocompleteRef.current = createAutocomplete({
id: 'docsearch',
defaultActiveItemId: 0,
openOnFocus: true,
initialState: {
query: initialQuery,
context: {
searchSuggestions: [],
},
},
insights: Boolean(insights),
navigator,
onStateChange(changes) {
setState(changes.state);
},
async getSources({ query, state: sourcesState, setContext, setStatus }) {
if (isQueryEmpty(query)) {
const noQuerySources = buildNoQuerySources({
recentSearches,
favoriteSearches,
saveRecentSearch,
onClose,
disableUserPersonalization,
});
const recentConversationSource = canHandleAskAi
? buildRecentConversationSources({
conversations,
disableUserPersonalization,
setMessages,
onAskAiToggle,
})
: [];
return [...recentConversationSource, ...noQuerySources];
}
const querySourcesState: BuildQuerySourcesState = {
context: sourcesState.context,
};
const algoliaSourcesPromise = buildQuerySources({
query,
state: querySourcesState,
setContext,
setStatus,
searchClient,
indexes,
insights: Boolean(insights),
appId,
apiKey,
maxResultsPerGroup,
transformItems,
saveRecentSearch,
onClose,
facetSelections: facetSelectionsRef,
});
const askAiSourcesPromise = canHandleAskAi
? buildAskAiActionSources({
query,
handleSelectAskAiQuestion,
promptSuggestionsOptions: askAiConfig?.promptSuggestions,
searchClient,
})
: Promise.resolve([]);
const [askAiSources, algoliaSources] = await Promise.all([
askAiSourcesPromise,
algoliaSourcesPromise,
]);
// Combine Algolia results (once resolved) with the Ask AI source
return [...askAiSources, ...algoliaSources];
},
});
}
const autocomplete = autocompleteRef.current;
const { getEnvironmentProps, getRootProps, refresh } = autocomplete;
useModalEnvironment({
getEnvironmentProps,
containerRef,
dropdownRef,
formElementRef,
inputRef,
initialScrollY,
modalRef,
theme,
});
React.useEffect(() => {
if (dropdownRef.current && !isAskAiActive) {
scrollToUtils(dropdownRef.current);
}
}, [state.query, isAskAiActive, dropdownRef]);
useRefreshOnInitialQuery({ initialQuery, inputRef, refresh });
const hasCurrentMessages = messages.length > 0;
// 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();
if (hasCurrentMessages) {
startNewConversation();
}
}
}, [isAskAiActive, autocomplete, startNewConversation, hasCurrentMessages]);
// Track external state in order to manage internal askAiState
React.useEffect(() => {
setAskAiState('initial');
}, [isAskAiActive, setAskAiState]);
const onStopAskAiStreaming = async (): Promise<void> => {
setStoppedStream(true);
await stopAskAiStreaming();
};
const handleNewConversation = (): void => {
startNewConversation();
setAskAiState('new-conversation');
};
const handleViewConversationHistory = (): void => {
setAskAiState('conversation-history');
};
const selectSuggestedQuestion = (
suggestedQuestion: SuggestedQuestionHit
): void => {
handleSelectAskAiQuestion(
true,
suggestedQuestion.question,
suggestedQuestion
);
};
// hide the dropdown on idle and no collections
let showDocsearchDropdown = true;
const hasCollections = state.collections.some(
(collection) =>
collection.source.sourceId !== SOURCE_IDS.askAI &&
collection.items.length > 0
);
if (
state.status === 'idle' &&
hasCollections === false &&
isQueryEmpty(state.query) &&
!isAskAiActive
) {
showDocsearchDropdown = false;
}
return (
<ModalShell
state={state}
containerRef={containerRef}
modalRef={modalRef}
formElementRef={formElementRef}
dropdownRef={dropdownRef}
getRootProps={getRootProps}
showDropdown={showDocsearchDropdown}
searchBox={
<AskAiSearchBox
{...autocomplete}
state={state}
placeholder={placeholder || 'Search docs'}
autoFocus={isQueryEmpty(initialQuery)}
inputRef={inputRef}
isFromSelection={
Boolean(initialQuery) && initialQuery === initialQueryFromSelection
}
translations={searchBoxTranslations}
isAskAiActive={isAskAiActive}
askAiStatus={status}
askAiError={askAiError}
askAiState={askAiState}
setAskAiState={setAskAiState}
promptBlockingChrome={promptBlockingChrome}
promptBlockingErrorId={promptBlockingErrorId}
onClose={onClose}
onAskAiToggle={onAskAiToggle}
onAskAgain={(query) => {
handleSelectAskAiQuestion(true, query);
}}
onStopAskAiStreaming={onStopAskAiStreaming}
onNewConversation={handleNewConversation}
onViewConversationHistory={handleViewConversationHistory}
/>
}
filterBar={
!isAskAiActive && !isQueryEmpty(state.query) ? (
<FacetBar
facets={visibleFacets}
selections={facetSelections}
translations={facetBarTranslations}
clearSelections={clearFacetSelections}
onSelectionChange={handleFacetSelectionChange}
/>
) : null
}
screenState={
<AskAiScreenState
{...autocomplete}
indexName={defaultIndexName}
state={state}
hitComponent={hitComponent}
resultsFooterComponent={resultsFooterComponent}
disableUserPersonalization={disableUserPersonalization}
recentSearches={recentSearches}
favoriteSearches={favoriteSearches}
conversations={conversations}
inputRef={inputRef}
translations={screenStateTranslations}
getMissingResultsUrl={getMissingResultsUrl}
isAskAiActive={isAskAiActive}
canHandleAskAi={canHandleAskAi}
messages={messages}
tools={tools}
askAiError={askAiError}
status={status}
hasCollections={hasCollections}
askAiState={askAiState}
selectAskAiQuestion={handleSelectAskAiQuestion}
suggestedQuestions={suggestedQuestions}
selectSuggestedQuestion={selectSuggestedQuestion}
memoryEnabled={memoryEnabled}
resultBadgeKey={props.resultBadgeKey}
promptBlockingErrorId={promptBlockingErrorId}
onAskAiToggle={onAskAiToggle}
onNewConversation={handleNewConversation}
onItemClick={(item, event) => {
if (item.type === 'askAI' && item.query) {
if (item.anchor === 'stored' && 'messages' in item) {
const hitMessages = item.messages as StoredAskAiMessage[];
restoreConversation(
hitMessages,
(item as StoredAskAiState).chatId
);
const initialMessage: InitialAskAiMessage = {
query: item.query,
messageId: hitMessages[0].id,
};
if (interceptAskAiEvent?.(initialMessage)) {
if (autocompleteRef.current) {
autocompleteRef.current.setQuery('');
}
event.preventDefault();
return;
}
onAskAiToggle(true, initialMessage);
} else {
handleSelectAskAiQuestion(true, item.query);
}
setAskAiState('initial');
event.preventDefault();
return;
}
sendItemClickEvent(item);
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
}
}}
onFeedback={handleFeedbackSubmit}
/>
}
footer={
<Footer
translations={footerTranslations}
isAskAiActive={isAskAiActive}
footerAction={footerAction}
/>
}
onClose={onClose}
/>
);
}