1
0
Fork 0

fix: conversational flow

This commit is contained in:
Dylan Tientcheu 2025-05-12 23:01:44 +02:00
parent d0df487d4d
commit 9979d6e4d8
7 changed files with 292 additions and 143 deletions

View file

@ -824,7 +824,7 @@ assistive tech users */
}
.DocSearch-AskAiScreen-ThinkingDots {
font-size: 0.7em;
font-size: 0.8em;
font-weight: 400;
color: var(--docsearch-secondary-text-color);
margin: 0;

View file

@ -1,9 +1,26 @@
import React, { type JSX, useState, useEffect } from 'react';
import React, { type JSX, useState, useEffect, useMemo } from 'react';
import { SparklesIcon } from './icons';
import type { AskAiResponse } from './lib/genAiClient';
import { MemoizedMarkdown } from './MemoizedMarkdown';
import type { ScreenStateProps } from './ScreenState';
import type { InternalDocSearchHit } from './types';
interface Message {
id: string;
role: 'assistant' | 'user';
content: string;
context?: AskAiResponse['context'];
}
type LoadingStatus = 'error' | 'idle' | 'loading' | 'streaming';
interface AskAiStateForScreen {
messages: Message[];
loadingStatus: LoadingStatus;
error: Error | null;
}
export type AskAiScreenTranslations = Partial<{
titleText: string;
disclaimerText: string;
@ -11,111 +28,223 @@ export type AskAiScreenTranslations = Partial<{
thinkingText: string;
}>;
type AskAiScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
type AskAiScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'askAiState' | 'translations'> & {
askAiState: AskAiStateForScreen;
translations?: AskAiScreenTranslations;
conversationId?: string | null;
};
export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): JSX.Element | null {
if (!props.askAiState) {
interface AskAiScreenHeaderProps {
disclaimerText: string;
}
interface Exchange {
id: string;
userMessage: Message;
assistantMessage: Message | null;
}
function AskAiScreenHeader({ disclaimerText }: AskAiScreenHeaderProps): JSX.Element {
return (
<div className="DocSearch-AskAiScreen-Header">
<div className="DocSearch-Screen-Icon">
<SparklesIcon />
</div>
<p className="DocSearch-AskAi-Disclaimer">{disclaimerText}</p>
</div>
);
}
interface AskAiExchangeCardProps {
exchange: Exchange;
isLastExchange: boolean;
loadingStatus: LoadingStatus;
error: Error | null;
translations: Required<AskAiScreenTranslations>;
globalHasHadAssistantResponse: boolean;
}
function AskAiExchangeCard({
exchange,
isLastExchange,
loadingStatus,
error,
translations,
globalHasHadAssistantResponse,
}: AskAiExchangeCardProps): JSX.Element {
const { userMessage, assistantMessage } = exchange;
const showLoadingIndicator = isLastExchange && loadingStatus === 'loading';
const isStreaming = isLastExchange && loadingStatus === 'streaming';
const showError = isLastExchange && loadingStatus === 'error' && error;
const showActions = !isLastExchange || (isLastExchange && loadingStatus === 'idle' && Boolean(assistantMessage));
const contextToDisplay = assistantMessage?.context || [];
return (
<div className="DocSearch-AskAiScreen-Response-Container">
<div className="DocSearch-AskAiScreen-Response">
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--user">
<p className="DocSearch-AskAiScreen-Query">{userMessage.content}</p>
</div>
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--assistant">
<div className="DocSearch-AskAiScreen-MessageContent">
{showLoadingIndicator && (
<div className="DocSearch-AskAiScreen-Streaming-Loader">
<ThinkingDots thinkingText={translations.thinkingText} />
</div>
)}
{(isStreaming || assistantMessage?.content) && (
<MemoizedMarkdown
content={assistantMessage?.content || ''}
id={`ask-ai-message-${assistantMessage?.id || userMessage.id}`}
/>
)}
{showError && <p className="DocSearch-AskAiScreen-Error">{error.message}</p>}
</div>
</div>
<div className="DocSearch-AskAiScreen-Answer-Footer">
<AskAiScreenFooterActions
showActions={showActions}
latestAssistantMessageContent={assistantMessage?.content || null}
/>
</div>
</div>
{/* Sources for this exchange */}
<AskAiSourcesPanel
contextToDisplay={contextToDisplay}
loadingStatus={loadingStatus}
relatedSourcesText={translations.relatedSourcesText}
hasHadAssistantResponse={globalHasHadAssistantResponse}
isExchangeLoading={isLastExchange && (loadingStatus === 'loading' || loadingStatus === 'streaming')}
/>
</div>
);
}
interface AskAiScreenFooterActionsProps {
showActions: boolean;
latestAssistantMessageContent: string | null;
}
function AskAiScreenFooterActions({
showActions,
latestAssistantMessageContent,
}: AskAiScreenFooterActionsProps): JSX.Element | null {
if (!showActions || !latestAssistantMessageContent) {
return null;
}
return (
<div className="DocSearch-AskAiScreen-Actions">
<CopyButton onClick={() => navigator.clipboard.writeText(latestAssistantMessageContent)} />
<LikeButton />
<DislikeButton />
</div>
);
}
interface AskAiSourcesPanelProps {
contextToDisplay: AskAiResponse['context'];
loadingStatus: LoadingStatus;
relatedSourcesText: string;
hasHadAssistantResponse: boolean;
isExchangeLoading: boolean;
}
function AskAiSourcesPanel({
contextToDisplay,
loadingStatus,
relatedSourcesText,
hasHadAssistantResponse,
isExchangeLoading,
}: AskAiSourcesPanelProps): JSX.Element {
return (
<div className="DocSearch-AskAiScreen-RelatedSources">
<p className="DocSearch-AskAiScreen-RelatedSources-Title">{relatedSourcesText}</p>
{contextToDisplay.length > 0 &&
contextToDisplay.map((source) => (
<a
key={source.objectID}
href={source.url || source.objectID || '#'}
className="DocSearch-AskAiScreen-RelatedSources-Item-Link"
>
<RelatedSourceIcon />
<span>{source.title || source.url || source.objectID}</span>
</a>
))}
{contextToDisplay.length === 0 &&
loadingStatus === 'loading' &&
!hasHadAssistantResponse &&
isExchangeLoading &&
// eslint-disable-next-line react/no-array-index-key
Array.from({ length: 3 }).map((_, index) => <SkeletonSource key={index} />)}
{contextToDisplay.length === 0 &&
(loadingStatus === 'idle' || loadingStatus === 'streaming') &&
hasHadAssistantResponse &&
!isExchangeLoading && (
<p className="DocSearch-AskAiScreen-RelatedSources-NoResults">No related sources for the latest answer.</p>
)}
{contextToDisplay.length === 0 && loadingStatus === 'error' && (
<p className="DocSearch-AskAiScreen-RelatedSources-Error">Could not load related sources.</p>
)}
</div>
);
}
export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): JSX.Element | null {
const {
disclaimerText = 'Answers are generated using artificial intelligence. This is an experimental technology, and information may occasionally be incorrect or misleading.',
relatedSourcesText = 'Related Sources',
thinkingText = 'Thinking',
titleText = 'Ask AI',
} = translations;
const { messages, currentResponse, loadingStatus, context, error } = props.askAiState;
const finalTranslations: Required<AskAiScreenTranslations> = {
titleText,
disclaimerText,
relatedSourcesText,
thinkingText,
};
// determine the initial query to display
const displayedQuery = messages.find((m) => m.role === 'user')?.content || 'No query provided';
const { messages, loadingStatus, error } = props.askAiState;
// select the content to display based on the status
const displayedAnswer =
loadingStatus === 'streaming' ? currentResponse : messages.find((m) => m.role === 'assistant')?.content || '';
// Group messages into exchanges (user + assistant pairs)
const exchanges: Exchange[] = useMemo(() => {
const grouped: Exchange[] = [];
for (let i = 0; i < messages.length; i++) {
if (messages[i].role === 'user') {
const userMessage = messages[i];
const assistantMessage = messages[i + 1]?.role === 'assistant' ? messages[i + 1] : null;
grouped.push({ id: userMessage.id, userMessage, assistantMessage });
if (assistantMessage) {
i++;
}
}
}
return grouped;
}, [messages]);
const globalHasHadAssistantResponse = messages.some((m) => m.role === 'assistant');
return (
<div className="DocSearch-AskAiScreen DocSearch-AskAiScreen-Container">
<div className="DocSearch-AskAiScreen-Header">
<div className="DocSearch-Screen-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>
</div>
<p className="DocSearch-AskAi-Disclaimer">{disclaimerText}</p>
</div>
<AskAiScreenHeader disclaimerText={disclaimerText} />
<div className="DocSearch-AskAiScreen-Body">
<div className="DocSearch-AskAiScreen-Response-Container">
<div className="DocSearch-AskAiScreen-Response">
<p className="DocSearch-AskAiScreen-Query">{displayedQuery}</p>
{error && <p className="DocSearch-AskAiScreen-Error">{error.message}</p>}
<div
className={`DocSearch-AskAiScreen-Answer ${
loadingStatus === 'streaming' ? 'DocSearch-AskAiScreen-Answer--streaming' : ''
}`}
>
{(loadingStatus === 'streaming' || loadingStatus === 'idle') && (
<MemoizedMarkdown content={displayedAnswer} id="ask-ai-answer" />
)}
{loadingStatus === 'loading' && (
<div className="DocSearch-AskAiScreen-Streaming-Loader">
<ThinkingDots thinkingText={thinkingText} />
</div>
)}
</div>
<div className="DocSearch-AskAiScreen-Answer-Footer">
{loadingStatus === 'idle' && displayedAnswer.length > 0 && (
<div className="DocSearch-AskAiScreen-Actions">
<CopyButton onClick={() => navigator.clipboard.writeText(displayedAnswer)} />
<LikeButton />
<DislikeButton />
</div>
)}
</div>
</div>
<div className="DocSearch-AskAiScreen-RelatedSources">
<p className="DocSearch-AskAiScreen-RelatedSources-Title">{relatedSourcesText}</p>
{context.length === 0 &&
loadingStatus === 'loading' &&
// eslint-disable-next-line react/no-array-index-key
Array.from({ length: 3 }).map((_, index) => <SkeletonSource key={index} />)}
{context.length > 0 &&
context.map((source) => (
<a
key={source.objectID}
href={source.url || source.objectID || '#'}
className="DocSearch-AskAiScreen-RelatedSources-Item-Link"
>
<RelatedSourceIcon />
<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 className="DocSearch-AskAiScreen-ExchangesList">
{exchanges
.slice()
.reverse()
.map((exchange, index) => (
<AskAiExchangeCard
key={exchange.id}
exchange={exchange}
isLastExchange={index === 0}
loadingStatus={loadingStatus}
error={error}
translations={finalTranslations}
globalHasHadAssistantResponse={globalHasHadAssistantResponse}
/>
))}
</div>
</div>
</div>

View file

@ -339,15 +339,6 @@ export function DocSearchModal({
conversations,
});
const handleAskAiToggle = React.useCallback(
(toggle: boolean, query: string) => {
onAskAiToggle(toggle);
askAiState.ask?.({ query });
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onAskAiToggle],
);
const saveRecentSearch = React.useCallback(
function saveRecentSearch(item: InternalDocSearchHit) {
if (disableUserPersonalization) {
@ -396,17 +387,23 @@ export function DocSearchModal({
>
>(undefined);
const handleAskAiToggle = React.useCallback(
(toggle: boolean, query: string) => {
onAskAiToggle(toggle);
// clear the query
if (autocompleteRef.current) {
autocompleteRef.current.setQuery('');
}
askAiState.ask?.({ query });
},
[onAskAiToggle, askAiState],
);
if (!autocompleteRef.current) {
autocompleteRef.current = createAutocomplete<
InternalDocSearchHit,
React.FormEvent<HTMLFormElement>,
React.MouseEvent,
React.KeyboardEvent
>({
autocompleteRef.current = createAutocomplete({
id: 'docsearch',
// we don't want to focus on the AskAI hit by default
defaultActiveItemId: canHandleAskAi ? 1 : 0,
placeholder,
openOnFocus: true,
initialState: {
query: initialQuery,
@ -608,12 +605,18 @@ export function DocSearchModal({
};
}, []);
// Refresh the autocomplete results when ask ai is toggled off
// helps return to the previous ac state and start screen
React.useEffect(() => {
if (!isAskAiActive) {
autocomplete.refresh();
}
}, [isAskAiActive, autocomplete]);
return (
<div
ref={containerRef}
{...getRootProps({
'aria-expanded': true,
})}
{...getRootProps({ 'aria-expanded': true })}
className={[
'DocSearch',
'DocSearch-Container',
@ -635,6 +638,7 @@ export function DocSearchModal({
<SearchBox
{...autocomplete}
state={state}
placeholder={placeholder || 'Search docs'}
autoFocus={initialQuery.length === 0}
inputRef={inputRef}
isFromSelection={Boolean(initialQuery) && initialQuery === initialQueryFromSelection}
@ -642,6 +646,9 @@ export function DocSearchModal({
isAskAiActive={isAskAiActive}
onClose={onClose}
onAskAiToggle={onAskAiToggle}
onAskAgain={(query) => {
handleAskAiToggle(true, query);
}}
/>
</header>

View file

@ -46,8 +46,8 @@ export interface ScreenStateProps<TItem extends BaseItem>
export const ScreenState = React.memo(
({ translations = {}, ...props }: ScreenStateProps<InternalDocSearchHit>) => {
if (props.isAskAiActive && props.canHandleAskAi) {
return <AskAiScreen {...props} translations={translations?.askAiScreen} />;
if (props.isAskAiActive && props.canHandleAskAi && props.askAiState) {
return <AskAiScreen {...props} askAiState={props.askAiState} translations={translations?.askAiScreen} />;
}
if (props.state?.status === 'error') {

View file

@ -25,6 +25,8 @@ interface SearchBoxProps
inputRef: RefObject<HTMLInputElement | null>;
onClose: () => void;
onAskAiToggle: (toggle: boolean) => void;
onAskAgain: (query: string) => void;
placeholder: string;
isAskAiActive: boolean;
isFromSelection: boolean;
translations?: SearchBoxTranslations;
@ -56,6 +58,32 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
}
}, [props.isFromSelection, props.inputRef]);
const baseInputProps = props.getInputProps({
inputElement: props.inputRef.current!,
autoFocus: props.autoFocus,
maxLength: MAX_QUERY_SIZE,
});
const blockedKeys = new Set(['ArrowUp', 'ArrowDown', 'Enter']);
const origOnKeyDown = baseInputProps.onKeyDown;
const inputProps = {
...baseInputProps,
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>): void => {
// block these up, down, enter listeners when AskAI is active
if (props.isAskAiActive && blockedKeys.has(e.key)) {
// enter key asks another question
if (e.key === 'Enter' && props.state.query) {
props.onAskAgain(props.state.query);
}
e.preventDefault();
e.stopPropagation();
return;
}
origOnKeyDown?.(e);
},
};
return (
<>
<form
@ -87,15 +115,7 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
<LoadingIcon />
</div>
<input
className="DocSearch-Input"
ref={props.inputRef}
{...props.getInputProps({
inputElement: props.inputRef.current!,
autoFocus: props.autoFocus,
maxLength: MAX_QUERY_SIZE,
})}
/>
<input className="DocSearch-Input" ref={props.inputRef} {...inputProps} placeholder={props.placeholder} />
<div className="DocSearch-Actions">
<button

View file

@ -63,10 +63,7 @@ async function fetchAskAiResponseFunction({
let finalResponse: AskAiResponse | null = null;
// Helper function to process a single SSE line
function processSseLine(
line: string,
context: string, // 'chunk' or 'final chunk' for error messages
): AskAiResponse | null {
function processSseLine(line: string, context: string): AskAiResponse | null {
if (!line.startsWith('data:')) {
return null;
}
@ -98,6 +95,8 @@ async function fetchAskAiResponseFunction({
query,
dataSourceId,
promptId,
nbHits: 5,
useCache: false,
additionalFilters,
conversationId,
stream: true,

View file

@ -10,14 +10,13 @@ interface Message {
id: string;
role: 'assistant' | 'user';
content: string;
context?: AskAiResponse['context'];
}
export interface AskAiState {
messages: Message[];
currentResponse: string;
query: string;
additionalFilters: string[];
context: AskAiResponse['context'];
conversationId: string | null;
loadingStatus: LoadingStatus;
error: Error | null;
@ -49,10 +48,8 @@ export function useAskAi({ genAiClient, conversations }: UseAskAiParams): AskAiS
const initialState = useMemo(
() => ({
messages: [],
currentResponse: '',
query: '',
additionalFilters: [],
context: [],
conversationId: null,
loadingStatus: 'idle' as const,
error: null,
@ -90,17 +87,19 @@ export function useAskAi({ genAiClient, conversations }: UseAskAiParams): AskAiS
// generate a unique id for the user message
const userMessageId = crypto.randomUUID();
const assistantMessageId = crypto.randomUUID();
const newConversationId = state.conversationId ?? crypto.randomUUID();
// Add user message to the conversation
// add user message to the conversation
setState((prevState) => ({
...prevState,
messages: [...prevState.messages, { role: 'user', content: query, id: userMessageId }],
currentResponse: '',
additionalFilters: [],
context: [],
query,
messages: [
...(prevState.conversationId ? prevState.messages : []), // keep history if we have a conversationId
{ id: userMessageId, role: 'user', content: query },
{ id: assistantMessageId, role: 'assistant', content: '', context: [] },
],
loadingStatus: 'loading',
query,
error: null,
}));
@ -110,25 +109,19 @@ export function useAskAi({ genAiClient, conversations }: UseAskAiParams): AskAiS
additionalFilters,
// conversationId: newConversationId,
onUpdate: (chunk) => {
// update state incrementally as data streams in
setState((prevState) => ({
...prevState,
currentResponse: chunk.response,
additionalFilters: chunk.additionalFilters,
context: chunk.context,
messages: prevState.messages.map((m) =>
m.id === assistantMessageId ? { ...m, content: chunk.response, context: chunk.context } : m,
),
loadingStatus: 'streaming',
}));
},
onComplete: () => {
const assistantMessageId = crypto.randomUUID();
onComplete: () => {
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,
};
@ -139,6 +132,7 @@ export function useAskAi({ genAiClient, conversations }: UseAskAiParams): AskAiS
objectID: newConversationId,
// dummy content to make it a valid hit
// this is useful to show it among other hits
content: null,
hierarchy: {
lvl0: 'askAI',