1
0
Fork 0

feat: recently asked & conversations (#2590)

This commit is contained in:
Dylan Tientcheu 2025-05-06 11:07:39 +02:00 committed by GitHub
parent b94072a4fd
commit d0df487d4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 565 additions and 345 deletions

View file

@ -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;
}
}

View file

@ -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<ScreenStateProps<InternalDocSearchHit>, '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' && (
<div className="DocSearch-AskAiScreen-Streaming-Loader">
<PulseLoader />
<ThinkingDots thinkingText={thinkingText} />
</div>
)}
</div>
@ -112,6 +107,14 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps):
<span>{source.title || source.url || source.objectID}</span>
</a>
))}
{context.length === 0 && loadingStatus === 'idle' && (
<p className="DocSearch-AskAiScreen-RelatedSources-NoResults">No related sources found</p>
)}
{context.length === 0 && loadingStatus === 'error' && (
<p className="DocSearch-AskAiScreen-RelatedSources-Error">
Error loading related sources. Please try again.
</p>
)}
</div>
</div>
</div>
@ -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 (
<p className="DocSearch-AskAiScreen-ThinkingDots">
{thinkingText}
{dots}
</p>
);
}
function SkeletonSource(): JSX.Element {
return (
<div className="DocSearch-AskAiScreen-SkeletonSource">
@ -148,76 +173,6 @@ function RelatedSourceIcon(): JSX.Element {
);
}
function PulseLoader(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="0" fill="currentColor">
<animate
id="svgSpinnersPulseMultiple0"
fill="freeze"
attributeName="r"
begin="0;svgSpinnersPulseMultiple2.end"
calcMode="spline"
dur="1.2s"
keySplines=".52,.6,.25,.99"
values="0;11"
></animate>
<animate
fill="freeze"
attributeName="opacity"
begin="0;svgSpinnersPulseMultiple2.end"
calcMode="spline"
dur="1.2s"
keySplines=".52,.6,.25,.99"
values="1;0"
></animate>
</circle>
<circle cx="12" cy="12" r="0" fill="currentColor">
<animate
id="svgSpinnersPulseMultiple1"
fill="freeze"
attributeName="r"
begin="svgSpinnersPulseMultiple0.begin+0.2s"
calcMode="spline"
dur="1.2s"
keySplines=".52,.6,.25,.99"
values="0;11"
></animate>
<animate
fill="freeze"
attributeName="opacity"
begin="svgSpinnersPulseMultiple0.begin+0.2s"
calcMode="spline"
dur="1.2s"
keySplines=".52,.6,.25,.99"
values="1;0"
></animate>
</circle>
<circle cx="12" cy="12" r="0" fill="currentColor">
<animate
id="svgSpinnersPulseMultiple2"
fill="freeze"
attributeName="r"
begin="svgSpinnersPulseMultiple0.begin+0.4s"
calcMode="spline"
dur="1.2s"
keySplines=".52,.6,.25,.99"
values="0;11"
></animate>
<animate
fill="freeze"
attributeName="opacity"
begin="svgSpinnersPulseMultiple0.begin+0.4s"
calcMode="spline"
dur="1.2s"
keySplines=".52,.6,.25,.99"
values="1;0"
></animate>
</circle>
</svg>
);
}
function CopyButton({ onClick }: { onClick: () => void }): JSX.Element {
const [isCopied, setIsCopied] = useState(false);

View file

@ -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<typeof createStoredSearches>,
favoriteSearches: ReturnType<typeof createStoredSearches>,
saveRecentSearch: (item: InternalDocSearchHit) => void,
onClose: () => void,
disableUserPersonalization: boolean,
): Array<AutocompleteSource<InternalDocSearchHit>> => {
type BuildNoQuerySourcesOptions = {
recentSearches: ReturnType<typeof createStoredSearches>;
favoriteSearches: ReturnType<typeof createStoredSearches>;
saveRecentSearch: (item: InternalDocSearchHit) => void;
onClose: () => void;
disableUserPersonalization: boolean;
canHandleAskAi: boolean;
};
const buildNoQuerySources = ({
recentSearches,
favoriteSearches,
saveRecentSearch,
onClose,
disableUserPersonalization,
}: BuildNoQuerySourcesOptions): Array<AutocompleteSource<InternalDocSearchHit>> => {
if (disableUserPersonalization) {
return [];
}
return [
const sources: Array<AutocompleteSource<InternalDocSearchHit & { askState?: StoredAskAiState }>> = [
{
sourceId: 'recentSearches',
onSelect({ item, event }): void {
@ -86,6 +96,8 @@ const buildNoQuerySources = (
},
},
];
return sources;
};
type BuildQuerySourcesState = Pick<AutocompleteState<InternalDocSearchHit>, '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<number>;
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<Array<AutocompleteSource<InternalDocSearchHit>>> => {
const insightsActive = insights; // already boolean
const insightsActive = insights;
try {
const { results } = await searchClient.search<DocSearchHit>({
@ -277,7 +289,6 @@ export function DocSearchModal({
isOpen: false,
activeItemId: null,
status: 'idle',
isAskAiActive,
});
const containerRef = React.useRef<HTMLDivElement | null>(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<StoredAskAiState>({
key: `__DOCSEARCH_ASKAI_CONVERSATIONS__${indexName}`,
limit: 10,
}),
).current;
const favoriteSearches = React.useRef(
createStoredSearches<StoredDocSearchHit>({
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<InternalDocSearchHit, React.FormEvent<HTMLFormElement>, 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<HTMLFormElement>,
React.MouseEvent,
React.KeyboardEvent
>
>
>(undefined);
if (queryWentEmpty && statusIsIdle && collectionsAreEmpty && wasNotLoading) {
return prevState;
}
if (!autocompleteRef.current) {
autocompleteRef.current = createAutocomplete<
InternalDocSearchHit,
React.FormEvent<HTMLFormElement>,
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<AutocompleteSource<InternalDocSearchHit>> = canHandleAskAi
const recentConversationSource: Array<AutocompleteSource<InternalDocSearchHit & { askState?: AskAiState }>> =
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<AutocompleteSource<InternalDocSearchHit>> = 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;
}

View file

@ -11,9 +11,7 @@ function parseMarkdownIntoHTMLBlocks(md: string): string[] {
);
}
const HTMLBlock: FC<{ html: string; key: string }> = ({ html, key }) => (
<div dangerouslySetInnerHTML={{ __html: html }} key={key} />
);
const HTMLBlock: FC<{ html: string }> = ({ html }) => <div dangerouslySetInnerHTML={{ __html: html }} />;
const MemoizedHTMLBlock = memo(HTMLBlock, (prev, next) => prev.html === next.html);
MemoizedHTMLBlock.displayName = 'MemoizedHTMLBlock';

View file

@ -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<TItem extends StoredDocSearchHit>(props: ResultsProps<TI
return (
<section className="DocSearch-AskAi-Section">
<ul {...props.getListProps({ source: props.collection.source })}>
<AskAiResult item={props.collection.items[0]} translations={props.translations} {...props} />
<AskAiButton item={props.collection.items[0]} translations={props.translations} {...props} />
</ul>
</section>
);
}
if (props.collection.source.sourceId === 'recentConversations') {
return (
<section className="DocSearch-Hits">
<div className="DocSearch-Hit-source">{props.title}</div>
<ul {...props.getListProps({ source: props.collection.source })}>
{props.collection.items.map((item, index) => {
return <Result key={[props.title, item.objectID].join(':')} item={item} index={index} {...props} />;
})}
</ul>
</section>
);
@ -115,6 +129,12 @@ function Result<TItem extends StoredDocSearchHit>({
</div>
)}
{item.type === 'askAI' && (
<div className="DocSearch-Hit-content-wrapper">
<Snippet className="DocSearch-Hit-title" hit={item} attribute="hierarchy.lvl1" />
</div>
)}
{item.hierarchy[item.type] &&
(item.type === 'lvl2' ||
item.type === 'lvl3' ||
@ -141,41 +161,20 @@ function Result<TItem extends StoredDocSearchHit>({
);
}
interface AskAiResultProps<TItem extends BaseItem> extends ResultsProps<TItem> {
interface AskAiButtonProps<TItem extends BaseItem> extends ResultsProps<TItem> {
item: TItem;
translations?: ResultsTranslations;
}
function AskAiResult<TItem extends StoredDocSearchHit>({
function AskAiButton<TItem extends StoredDocSearchHit>({
item,
getItemProps,
onItemClick,
translations,
collection,
}: AskAiResultProps<TItem>): JSX.Element {
}: AskAiButtonProps<TItem>): JSX.Element {
const { askAiPlaceholder = 'Ask AI: ' } = translations || {};
const icon = (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-sparkles-icon lucide-sparkles"
>
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
<path d="M20 3v4" />
<path d="M22 5h-4" />
<path d="M4 17v2" />
<path d="M5 18H3" />
</svg>
);
return (
<li
className="DocSearch-Hit"
@ -189,7 +188,9 @@ function AskAiResult<TItem extends StoredDocSearchHit>({
>
<div className="DocSearch-Hit--AskAI">
<div className="DocSearch-Hit-AskAIButton DocSearch-Hit-Container">
<div className=" DocSearch-Hit-AskAIButton-icon DocSearch-Hit-icon">{icon}</div>
<div className=" DocSearch-Hit-AskAIButton-icon DocSearch-Hit-icon">
<SparklesIcon />
</div>
<div className="DocSearch-Hit-AskAIButton-title">
<span className="DocSearch-Hit-AskAIButton-title-highlight">{askAiPlaceholder}</span>
<span className="DocSearch-Hit-AskAIButton-title-query">"{item.query || ''}"</span>

View file

@ -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<TItem extends BaseItem>
state: AutocompleteState<TItem>;
recentSearches: StoredSearchPlugin<StoredDocSearchHit>;
favoriteSearches: StoredSearchPlugin<StoredDocSearchHit>;
conversations: StoredSearchPlugin<StoredAskAiState>;
onItemClick: (item: InternalDocSearchHit, event: KeyboardEvent | MouseEvent) => void;
onAskAiToggle: (toggle: boolean) => void;
isAskAiActive: boolean;
@ -37,7 +38,7 @@ export interface ScreenStateProps<TItem extends BaseItem>
hitComponent: DocSearchProps['hitComponent'];
indexName: DocSearchProps['indexName'];
disableUserPersonalization: boolean;
genAiClient: GenAiClient | null;
askAiState?: AskAiState;
resultsFooterComponent: DocSearchProps['resultsFooterComponent'];
translations: ScreenStateTranslations;
getMissingResultsUrl?: DocSearchProps['getMissingResultsUrl'];

View file

@ -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<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
@ -22,12 +24,15 @@ type StartScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, '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):
</div>
)}
/>
<Results
{...props}
title={recentConversationsTitle}
collection={props.state.collections[2]}
renderIcon={() => (
<div className="DocSearch-Hit-icon">
<SparklesIcon />
</div>
)}
renderAction={({ item, runDeleteTransition }) => (
<div className="DocSearch-Hit-action">
<button
className="DocSearch-Hit-action-button"
title={removeRecentConversationButtonTitle}
type="submit"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
runDeleteTransition(() => {
props.conversations.remove(item);
props.refresh();
});
}}
>
<CloseIcon />
</button>
</div>
)}
/>
</div>
);
}

View file

@ -0,0 +1,23 @@
import React, { type JSX } from 'react';
export function SparklesIcon(): JSX.Element {
return (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="DocSearch-Hit-icon-sparkles"
>
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
<path d="M20 3v4" />
<path d="M22 5h-4" />
<path d="M4 17v2" />
<path d="M5 18H3" />
</svg>
);
}

View file

@ -1,5 +1,6 @@
export * from './GoToExternalIcon';
export * from './LoadingIcon';
export * from './SparklesIcon';
export * from './RecentIcon';
export * from './CloseIcon';
export * from './SearchIcon';

View file

@ -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<string, any>;
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,
}),
});

View file

@ -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<TItem>(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<TItem extends StoredDocSearchHit>({
},
};
}
export function createStoredConversations<TItem extends StoredAskAiState>({
key,
limit = 5,
}: CreateStoredSearchesOptions): StoredSearchPlugin<TItem> {
const storage = createStorage<TItem>(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);
},
};
}

View file

@ -13,5 +13,4 @@ interface DocSearchContext extends AutocompleteContext {
export interface DocSearchState<TItem extends BaseItem> extends AutocompleteState<TItem> {
context: DocSearchContext;
isAskAiActive: boolean;
}

View file

@ -1,3 +1,6 @@
import type { AskAiState } from '../useAskAi';
import type { DocSearchHit } from './DocSearchHit';
export type StoredDocSearchHit = Omit<DocSearchHit, '_highlightResult' | '_snippetResult'>;
export type StoredAskAiState = Omit<DocSearchHit, '_highlightResult' | '_snippetResult'> & { askState?: AskAiState };

View file

@ -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<void>;
reset?: () => void;
restoreConversation?: (conversation: StoredAskAiState) => void;
}
interface UseAskAiParams {
genAiClient: GenAiClient;
conversations: StoredSearchPlugin<StoredAskAiState>;
}
interface AskParams {
@ -29,51 +37,60 @@ interface AskParams {
additionalFilters?: Record<string, any>;
}
interface UseAskAiReturn {
messages: Message[];
currentResponse: string;
additionalFilters: string[];
context: AskAiResponse['context'];
conversationID: string | null;
loadingStatus: LoadingStatus;
error: Error | null;
ask: (params: AskParams) => Promise<void>;
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<UseAskAiState>(
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<UseAskAiState>(initialState);
const [state, setState] = useState<Omit<AskAiState, 'ask' | 'reset'>>(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,
};
}

View file

@ -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<TItem>(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<TItem>(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);
},
};
}