feat: recently asked & conversations (#2590)
This commit is contained in:
parent
b94072a4fd
commit
d0df487d4d
15 changed files with 565 additions and 345 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
23
packages/docsearch-react/src/icons/SparklesIcon.tsx
Normal file
23
packages/docsearch-react/src/icons/SparklesIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
export * from './GoToExternalIcon';
|
||||
export * from './LoadingIcon';
|
||||
export * from './SparklesIcon';
|
||||
export * from './RecentIcon';
|
||||
export * from './CloseIcon';
|
||||
export * from './SearchIcon';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,5 +13,4 @@ interface DocSearchContext extends AutocompleteContext {
|
|||
|
||||
export interface DocSearchState<TItem extends BaseItem> extends AutocompleteState<TItem> {
|
||||
context: DocSearchContext;
|
||||
isAskAiActive: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
89
packages/docsearch-react/src/utils/storage.ts
Normal file
89
packages/docsearch-react/src/utils/storage.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue