feat: Modal and AskAI UX tweaks (#2765)
* feat: UX tweaks for Modal * Prepare new conversation screen to accept suggested questions * Add conversation history screen * Reset basic askai config * Reset askai api url * Document new translations * Remove unused CSS * Add translation for stopped streaming text * Add rest of translations * Bump allowed bundle size * Bump allowed bundle size
This commit is contained in:
parent
2ff6353cb2
commit
0267f55453
23 changed files with 597 additions and 47 deletions
|
|
@ -6,11 +6,11 @@
|
|||
},
|
||||
{
|
||||
"path": "packages/docsearch-react/dist/umd/index.js",
|
||||
"maxSize": "109 kB"
|
||||
"maxSize": "115 kB"
|
||||
},
|
||||
{
|
||||
"path": "packages/docsearch-js/dist/umd/index.js",
|
||||
"maxSize": "123 kB"
|
||||
"maxSize": "128 kB"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,8 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.DocSearch-Input {
|
||||
.DocSearch-Input,
|
||||
.DocSearch-Modal-heading {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
|
|
@ -119,7 +120,7 @@
|
|||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 2px;
|
||||
gap: 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.DocSearch-Divider {
|
||||
|
|
@ -127,11 +128,20 @@
|
|||
border-left: 1px solid var(--docsearch-subtle-color);
|
||||
}
|
||||
|
||||
.DocSearch-Action {
|
||||
min-height: 24px;
|
||||
min-width: 24px;
|
||||
align-items: center;
|
||||
color: var(--docsearch-highlight-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.DocSearch-LoadingIndicator,
|
||||
.DocSearch-MagnifierLabel,
|
||||
.DocSearch-AskAi-Return,
|
||||
.DocSearch-Close,
|
||||
.DocSearch-StreamingIndicator {
|
||||
.DocSearch-AskAi-Return {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -141,9 +151,7 @@
|
|||
}
|
||||
|
||||
.DocSearch-MagnifierLabel,
|
||||
.DocSearch-AskAi-Return,
|
||||
.DocSearch-Close,
|
||||
.DocSearch-StreamingIndicator {
|
||||
.DocSearch-AskAi-Return {
|
||||
align-items: center;
|
||||
color: var(--docsearch-highlight-color);
|
||||
display: flex;
|
||||
|
|
@ -158,8 +166,7 @@
|
|||
}
|
||||
|
||||
@media screen and (prefers-reduced-motion: reduce) {
|
||||
.DocSearch-Close,
|
||||
.DocSearch-StreamingIndicator {
|
||||
.DocSearch-Action {
|
||||
animation: none;
|
||||
appearance: none;
|
||||
background: none;
|
||||
|
|
@ -172,8 +179,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.DocSearch-Close,
|
||||
.DocSearch-StreamingIndicator,
|
||||
.DocSearch-Action,
|
||||
.DocSearch-AskAi-Return {
|
||||
animation: fade-in 0.1s ease-in forwards;
|
||||
appearance: none;
|
||||
|
|
@ -189,12 +195,12 @@
|
|||
|
||||
.DocSearch-Close[hidden],
|
||||
.DocSearch-StreamingIndicator[hidden],
|
||||
.DocSearch-AskAi-Return[hidden] {
|
||||
.DocSearch-AskAi-Return[hidden],
|
||||
.DocSearch-Input[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.DocSearch-Close:hover,
|
||||
.DocSearch-StreamingIndicator:hover,
|
||||
.DocSearch-Action:hover,
|
||||
.DocSearch-AskAi-Return:hover {
|
||||
color: var(--docsearch-highlight-color);
|
||||
background: var(--docsearch-soft-primary-color);
|
||||
|
|
@ -460,7 +466,11 @@
|
|||
|
||||
.DocSearch-Hit[aria-selected='true'] a,
|
||||
.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit--AskAI {
|
||||
background-color: var(--docsearch-hit-highlight-color) !important;
|
||||
background-color: var(--docsearch-hit-highlight-color);
|
||||
}
|
||||
|
||||
.DocSearch-Conversation-History .DocSearch-Hit[aria-selected='true'] a {
|
||||
background-color: var(--docsearch-hit-background);
|
||||
}
|
||||
|
||||
.DocSearch-Hit mark {
|
||||
|
|
@ -1268,6 +1278,12 @@ assistive tech users */
|
|||
color: var(--docsearch-highlight-color);
|
||||
}
|
||||
|
||||
.DocSearck-AskAiScreen-MessageContent-Stopped {
|
||||
color: var(--docsearch-muted-color);
|
||||
font-style: italic;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.DocSearch-AskAiScreen-SmallerLoadingIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
|
@ -1441,3 +1457,79 @@ assistive tech users */
|
|||
.DocSearch-Markdown-Content--streaming .DocSearch-CodeSnippet-CopyButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* NewConversationScreen */
|
||||
.DocSearch-NewConversationScreen {
|
||||
padding: 3em var(--docsearch-spacing);
|
||||
}
|
||||
|
||||
.DocSearch-NewConversationScreen-Title {
|
||||
font-weight: 600;
|
||||
font-size: 26px;
|
||||
color: var(--docsearch-text-color);
|
||||
margin-bottom: 0.15em;
|
||||
}
|
||||
|
||||
.DocSearch-NewConversationScreen-Description {
|
||||
font-size: 14px;
|
||||
color: var(--docsearch-muted-color);
|
||||
}
|
||||
|
||||
.DocSearch-NewConversationScreen-SuggestedQuestions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--docsearch-spacing);
|
||||
align-items: start;
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
.DocSearch-NewConversationScreen-SuggestedQuestion {
|
||||
border-radius: var(--docsearch-border-radius);
|
||||
padding: 12px;
|
||||
border: 1px solid #D6D6E7;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #fff;
|
||||
color: var(--docsearch-text-color);
|
||||
}
|
||||
|
||||
.DocSearch-Menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.DocSearch-Menu-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
padding: 8px 0;
|
||||
flex-direction: column;
|
||||
border-radius: var(--docsearch-border-radius);
|
||||
background-color: #fff;
|
||||
box-shadow: 0px 0px 0px 1px #21243D0D, 0px 8px 16px -4px #21243D40;
|
||||
min-width: 195px;
|
||||
top: calc(100% + 12px);
|
||||
z-index: 422;
|
||||
}
|
||||
|
||||
.DocSearch-Menu-content.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.DocSearch-Menu-item {
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--docsearch-text-color);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.DocSearch-Menu-item:hover {
|
||||
background-color: #F5F5FA;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ export type AskAiScreenTranslations = Partial<{
|
|||
lastSeparator?: string;
|
||||
after?: string;
|
||||
};
|
||||
/**
|
||||
* Message that's shown when user has stopped the streaming of a message.
|
||||
*/
|
||||
stoppedStreamingText: string;
|
||||
}>;
|
||||
|
||||
type AskAiScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
|
||||
|
|
@ -91,7 +95,7 @@ function AskAiExchangeCard({
|
|||
}: AskAiExchangeCardProps): JSX.Element {
|
||||
const { userMessage, assistantMessage } = exchange;
|
||||
|
||||
const showActions = !isLastExchange || (isLastExchange && loadingStatus === 'ready' && Boolean(assistantMessage));
|
||||
const { stoppedStreamingText = 'You stopped this response' } = translations;
|
||||
|
||||
const assistantContent = useMemo(() => getMessageContent(assistantMessage), [assistantMessage]);
|
||||
const userContent = useMemo(() => getMessageContent(userMessage), [userMessage]);
|
||||
|
|
@ -102,6 +106,11 @@ function AskAiExchangeCard({
|
|||
return groupConsecutiveToolResults(assistantMessage?.parts || []);
|
||||
}, [assistantMessage]);
|
||||
|
||||
const wasStopped = userMessage.metadata?.stopped || assistantMessage?.metadata?.stopped;
|
||||
|
||||
const showActions =
|
||||
!wasStopped && (!isLastExchange || (isLastExchange && loadingStatus === 'ready' && Boolean(assistantMessage)));
|
||||
|
||||
const isThinking =
|
||||
['submitted', 'streaming'].includes(loadingStatus) &&
|
||||
isLastExchange &&
|
||||
|
|
@ -228,6 +237,8 @@ function AskAiExchangeCard({
|
|||
return null;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{wasStopped && <p className="DocSearck-AskAiScreen-MessageContent-Stopped">{stoppedStreamingText}</p>}
|
||||
</div>
|
||||
<div className="DocSearch-AskAiScreen-Answer-Footer">
|
||||
<AskAiScreenFooterActions
|
||||
|
|
|
|||
58
packages/docsearch-react/src/ConversationHistoryScreen.tsx
Normal file
58
packages/docsearch-react/src/ConversationHistoryScreen.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { JSX } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { CloseIcon, SparklesIcon } from './icons';
|
||||
import { Results } from './Results';
|
||||
import type { ResultsScreenTranslations } from './ResultsScreen';
|
||||
import type { ScreenStateProps } from './ScreenState';
|
||||
import type { InternalDocSearchHit } from './types';
|
||||
|
||||
type ConversationHistoryScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
|
||||
translations?: ResultsScreenTranslations;
|
||||
};
|
||||
|
||||
export function ConversationHistoryScreen({ onAskAiToggle, ...props }: ConversationHistoryScreenProps): JSX.Element {
|
||||
const collection = React.useMemo(() => props.state.collections[2], [props.state]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!collection || collection.items.length === 0) {
|
||||
onAskAiToggle(true);
|
||||
}
|
||||
}, [collection, onAskAiToggle]);
|
||||
|
||||
return (
|
||||
<div className="DocSearch-Dropdown-Container DocSearch-Conversation-History">
|
||||
<Results
|
||||
{...props}
|
||||
key={collection.source.sourceId}
|
||||
title=""
|
||||
translations={props.translations}
|
||||
collection={collection}
|
||||
renderIcon={() => (
|
||||
<div className="DocSearch-Hit-icon">
|
||||
<SparklesIcon />
|
||||
</div>
|
||||
)}
|
||||
renderAction={({ item, runDeleteTransition }) => (
|
||||
<div className="DocSearch-Hit-action">
|
||||
<button
|
||||
type="button"
|
||||
className="DocSearch-Hit-action-button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
runDeleteTransition(() => {
|
||||
props.conversations.remove(item);
|
||||
props.refresh();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
KeyboardShortcuts,
|
||||
StoredDocSearchHit,
|
||||
} from './types';
|
||||
import type { AskAiState } from './types/AskiAi';
|
||||
import { useDocSearchKeyboardEvents } from './useDocSearchKeyboardEvents';
|
||||
import { useTheme } from './useTheme';
|
||||
|
||||
|
|
@ -171,7 +172,8 @@ export function DocSearch(props: DocSearchProps): JSX.Element {
|
|||
const searchButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [initialQuery, setInitialQuery] = React.useState<string | undefined>(props?.initialQuery || undefined);
|
||||
const [isAskAiActive, setIsAskAiActive] = React.useState(false);
|
||||
const [askAiState, setAskAiState] = React.useState<AskAiState>('initial');
|
||||
const isAskAiActive = askAiState !== 'initial';
|
||||
|
||||
let currentPlaceholder =
|
||||
props?.translations?.modal?.searchBox?.placeholderText || props?.placeholder || 'Search docs';
|
||||
|
|
@ -189,9 +191,9 @@ export function DocSearch(props: DocSearchProps): JSX.Element {
|
|||
|
||||
const onAskAiToggle = React.useCallback(
|
||||
(askAitoggle: boolean) => {
|
||||
setIsAskAiActive(askAitoggle);
|
||||
setAskAiState(askAitoggle ? 'conversation' : 'initial');
|
||||
},
|
||||
[setIsAskAiActive],
|
||||
[setAskAiState],
|
||||
);
|
||||
|
||||
const onOpen = React.useCallback(() => {
|
||||
|
|
@ -202,9 +204,9 @@ export function DocSearch(props: DocSearchProps): JSX.Element {
|
|||
setIsOpen(false);
|
||||
setInitialQuery(props?.initialQuery);
|
||||
if (isAskAiActive) {
|
||||
setIsAskAiActive(false);
|
||||
setAskAiState('initial');
|
||||
}
|
||||
}, [setIsOpen, props.initialQuery, isAskAiActive, setIsAskAiActive]);
|
||||
}, [setIsOpen, props.initialQuery, isAskAiActive, setAskAiState]);
|
||||
|
||||
const onInput = React.useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
|
|
@ -245,6 +247,8 @@ export function DocSearch(props: DocSearchProps): JSX.Element {
|
|||
translations={props?.translations?.modal}
|
||||
isAskAiActive={isAskAiActive}
|
||||
canHandleAskAi={canHandleAskAi}
|
||||
askAiState={askAiState}
|
||||
setAskAiState={setAskAiState}
|
||||
onAskAiToggle={onAskAiToggle}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
|
|
|
|||
|
|
@ -15,13 +15,14 @@ import type { DocSearchIndex, DocSearchProps } from './DocSearch';
|
|||
import type { FooterTranslations } from './Footer';
|
||||
import { Footer } from './Footer';
|
||||
import { Hit } from './Hit';
|
||||
import type { NewConversationTranslations } from './NewConversationScreen';
|
||||
import type { ScreenStateTranslations } from './ScreenState';
|
||||
import { ScreenState } from './ScreenState';
|
||||
import type { SearchBoxTranslations } from './SearchBox';
|
||||
import { SearchBox } from './SearchBox';
|
||||
import { createStoredConversations, createStoredSearches } from './stored-searches';
|
||||
import type { DocSearchHit, DocSearchState, InternalDocSearchHit, StoredAskAiState, StoredDocSearchHit } from './types';
|
||||
import type { AIMessage } from './types/AskiAi';
|
||||
import type { AIMessage, AskAiState } from './types/AskiAi';
|
||||
import { useSearchClient } from './useSearchClient';
|
||||
import { useTheme } from './useTheme';
|
||||
import { useTouchEvents } from './useTouchEvents';
|
||||
|
|
@ -32,6 +33,7 @@ import { manageLocalStorageQuota } from './utils/storage';
|
|||
|
||||
export type ModalTranslations = Partial<{
|
||||
searchBox: SearchBoxTranslations;
|
||||
newConversation: NewConversationTranslations;
|
||||
footer: FooterTranslations;
|
||||
}> &
|
||||
ScreenStateTranslations;
|
||||
|
|
@ -43,6 +45,8 @@ export type DocSearchModalProps = DocSearchProps & {
|
|||
isAskAiActive?: boolean;
|
||||
canHandleAskAi?: boolean;
|
||||
translations?: ModalTranslations;
|
||||
askAiState: AskAiState;
|
||||
setAskAiState: (state: AskAiState) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -298,6 +302,8 @@ export function DocSearchModal({
|
|||
indices = [],
|
||||
indexName,
|
||||
searchParameters,
|
||||
askAiState,
|
||||
setAskAiState,
|
||||
}: DocSearchModalProps): JSX.Element {
|
||||
const { footer: footerTranslations, searchBox: searchBoxTranslations, ...screenStateTranslations } = translations;
|
||||
const [state, setState] = React.useState<DocSearchState<InternalDocSearchHit>>({
|
||||
|
|
@ -370,6 +376,7 @@ export function DocSearchModal({
|
|||
).current;
|
||||
|
||||
const [askAiStreamError, setAskAiStreamError] = React.useState<Error | null>(null);
|
||||
const [stoppedStream, setStoppedStream] = React.useState(false);
|
||||
|
||||
const {
|
||||
messages,
|
||||
|
|
@ -377,6 +384,7 @@ export function DocSearchModal({
|
|||
status,
|
||||
setMessages,
|
||||
error: askAiFetchError,
|
||||
stop: stopAskAiStreaming,
|
||||
} = useChat<AIMessage>({
|
||||
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
||||
transport: new DefaultChatTransport({
|
||||
|
|
@ -418,6 +426,13 @@ export function DocSearchModal({
|
|||
}
|
||||
// if we just transitioned from "streaming" → "ready", persist
|
||||
if (prevStatus.current === 'streaming' && status === 'ready') {
|
||||
// if we stopped the stream, store it on the most recent message
|
||||
if (stoppedStream && messages.at(-1)) {
|
||||
messages.at(-1)!.metadata = {
|
||||
stopped: true,
|
||||
};
|
||||
}
|
||||
|
||||
for (const part of messages[0].parts) {
|
||||
if (part.type === 'text') {
|
||||
conversations.add(buildDummyAskAiHit(part.text, messages));
|
||||
|
|
@ -425,7 +440,7 @@ export function DocSearchModal({
|
|||
}
|
||||
}
|
||||
prevStatus.current = status;
|
||||
}, [status, messages, conversations, disableUserPersonalization]);
|
||||
}, [status, messages, conversations, disableUserPersonalization, stoppedStream]);
|
||||
|
||||
const createSyntheticParent = React.useCallback(function createSyntheticParent(
|
||||
item: InternalDocSearchHit,
|
||||
|
|
@ -495,6 +510,7 @@ export function DocSearchModal({
|
|||
const handleAskAiToggle = React.useCallback(
|
||||
(toggle: boolean, query: string) => {
|
||||
onAskAiToggle(toggle);
|
||||
setStoppedStream(false);
|
||||
sendMessage({
|
||||
role: 'user',
|
||||
parts: [
|
||||
|
|
@ -759,6 +775,21 @@ export function DocSearchModal({
|
|||
}
|
||||
}, [isAskAiActive, autocomplete, setMessages]);
|
||||
|
||||
const onStopAskAiStreaming = async (): Promise<void> => {
|
||||
setStoppedStream(true);
|
||||
|
||||
await stopAskAiStreaming();
|
||||
};
|
||||
|
||||
const handleNewConversation = (): void => {
|
||||
setMessages([]);
|
||||
setAskAiState('new-conversation');
|
||||
};
|
||||
|
||||
const handleViewConversationHistory = (): void => {
|
||||
setAskAiState('conversation-history');
|
||||
};
|
||||
|
||||
// hide the dropdown on idle and no collections
|
||||
let showDocsearchDropdown = true;
|
||||
const hasCollections = state.collections.some((collection) => collection.items.length > 0);
|
||||
|
|
@ -798,11 +829,15 @@ export function DocSearchModal({
|
|||
translations={searchBoxTranslations}
|
||||
isAskAiActive={isAskAiActive}
|
||||
askAiStatus={status}
|
||||
askAiState={askAiState}
|
||||
onClose={onClose}
|
||||
onAskAiToggle={onAskAiToggle}
|
||||
onAskAgain={(query) => {
|
||||
handleAskAiToggle(true, query);
|
||||
}}
|
||||
onStopAskAiStreaming={onStopAskAiStreaming}
|
||||
onNewConversation={handleNewConversation}
|
||||
onViewConversationHistory={handleViewConversationHistory}
|
||||
/>
|
||||
</header>
|
||||
|
||||
|
|
@ -828,6 +863,8 @@ export function DocSearchModal({
|
|||
askAiFetchError={askAiFetchError}
|
||||
status={status}
|
||||
hasCollections={hasCollections}
|
||||
askAiState={askAiState}
|
||||
selectAskAiQuestion={handleAskAiToggle}
|
||||
onAskAiToggle={onAskAiToggle}
|
||||
onItemClick={(item, event) => {
|
||||
// if the item is askAI toggle the screen
|
||||
|
|
|
|||
82
packages/docsearch-react/src/Menu.tsx
Normal file
82
packages/docsearch-react/src/Menu.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import type { PropsWithChildren, JSX, ButtonHTMLAttributes } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
const MenuContext = React.createContext({
|
||||
open: false,
|
||||
setOpen: (_open: boolean) => {},
|
||||
});
|
||||
|
||||
export function Menu({ children }: PropsWithChildren): JSX.Element {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
function close(e): void {
|
||||
if (!menuRef.current?.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
window.addEventListener('click', close);
|
||||
}
|
||||
|
||||
return function removeListener(): void {
|
||||
window.removeEventListener('click', close);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<MenuContext.Provider value={{ open, setOpen }}>
|
||||
<div ref={menuRef} className="DocSearch-Menu">
|
||||
{children}
|
||||
</div>
|
||||
</MenuContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type MenuTriggerProps = ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
function MenuTrigger({ children, className = '' }: PropsWithChildren<MenuTriggerProps>): JSX.Element {
|
||||
const { open, setOpen } = React.useContext(MenuContext);
|
||||
|
||||
function toggleOpen(): void {
|
||||
setOpen(!open);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className={`DocSearch-Menu-trigger ${className}`} onClick={() => toggleOpen()}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Menu.Trigger = MenuTrigger;
|
||||
|
||||
function MenuContent({ children }: PropsWithChildren): JSX.Element {
|
||||
const { open } = React.useContext(MenuContext);
|
||||
|
||||
return <div className={`DocSearch-Menu-content${open ? ' open' : ''}`}>{children}</div>;
|
||||
}
|
||||
|
||||
Menu.Content = MenuContent;
|
||||
|
||||
type MenuItemProps = ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
function MenuItem({ children, className = '', onClick, ...props }: PropsWithChildren<MenuItemProps>): JSX.Element {
|
||||
const { setOpen } = React.useContext(MenuContext);
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e): void => {
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<button type="button" className={`DocSearch-Menu-item ${className}`} onClick={handleClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Menu.Item = MenuItem;
|
||||
11
packages/docsearch-react/src/ModalHeading.tsx
Normal file
11
packages/docsearch-react/src/ModalHeading.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import React from 'react';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
type ModalHeadingProps = {
|
||||
heading: string;
|
||||
shimmer?: boolean;
|
||||
};
|
||||
|
||||
export function ModalHeading({ heading, shimmer = false }: ModalHeadingProps): JSX.Element {
|
||||
return <span className={`DocSearch-Modal-heading${shimmer ? ' shimmer' : ''}`}>{heading}</span>;
|
||||
}
|
||||
46
packages/docsearch-react/src/NewConversationScreen.tsx
Normal file
46
packages/docsearch-react/src/NewConversationScreen.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { Hit } from 'algoliasearch/lite';
|
||||
import React, { type JSX } from 'react';
|
||||
|
||||
export type NewConversationTranslations = Partial<{
|
||||
newConversationTitle: string;
|
||||
newConversationDescription: string;
|
||||
}>;
|
||||
|
||||
type SuggestedQuestion = Hit<{ question: string }>;
|
||||
|
||||
interface NewConversationProps {
|
||||
suggestedQuestions?: SuggestedQuestion[];
|
||||
selectSuggestedQuestion: (query: string) => void;
|
||||
translations?: NewConversationTranslations;
|
||||
}
|
||||
|
||||
export function NewConversationScreen({
|
||||
translations = {},
|
||||
suggestedQuestions = [],
|
||||
selectSuggestedQuestion,
|
||||
}: NewConversationProps): JSX.Element {
|
||||
const {
|
||||
newConversationTitle = 'How can I help you today?',
|
||||
newConversationDescription = 'I search through your documentation to help you find setup guides, feature details and troubleshooting tips, fast.',
|
||||
} = translations;
|
||||
|
||||
return (
|
||||
<div className="DocSearch-NewConversationScreen">
|
||||
<h3 className="DocSearch-NewConversationScreen-Title">{newConversationTitle}</h3>
|
||||
<p className="DocSearch-NewConversationScreen-Description">{newConversationDescription}</p>
|
||||
|
||||
<div className="DocSearch-NewConversationScreen-SuggestedQuestions">
|
||||
{suggestedQuestions.map((question) => (
|
||||
<button
|
||||
key={question.objectID}
|
||||
type="button"
|
||||
className="DocSearch-NewConversationScreen-SuggestedQuestion"
|
||||
onClick={() => selectSuggestedQuestion(question.question)}
|
||||
>
|
||||
{question.question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import type { InternalDocSearchHit, StoredDocSearchHit } from './types';
|
|||
|
||||
export type ResultsTranslations = Partial<{
|
||||
askAiPlaceholder: string;
|
||||
noResultsAskAiPlaceholder: string;
|
||||
}>;
|
||||
interface ResultsProps<TItem extends BaseItem>
|
||||
extends AutocompleteApi<TItem, React.FormEvent, React.MouseEvent, React.KeyboardEvent> {
|
||||
|
|
@ -22,6 +23,7 @@ interface ResultsProps<TItem extends BaseItem>
|
|||
}) => React.ReactNode;
|
||||
onItemClick: (item: TItem, event: KeyboardEvent | MouseEvent) => void;
|
||||
hitComponent: DocSearchProps['hitComponent'];
|
||||
state: AutocompleteState<TItem>;
|
||||
}
|
||||
|
||||
export function Results<TItem extends StoredDocSearchHit>(props: ResultsProps<TItem>): JSX.Element | null {
|
||||
|
|
@ -165,6 +167,7 @@ function Result<TItem extends StoredDocSearchHit>({
|
|||
interface AskAiButtonProps<TItem extends BaseItem> extends ResultsProps<TItem> {
|
||||
item: TItem;
|
||||
translations?: ResultsTranslations;
|
||||
state: AutocompleteState<TItem>;
|
||||
}
|
||||
|
||||
function AskAiButton<TItem extends StoredDocSearchHit>({
|
||||
|
|
@ -173,8 +176,13 @@ function AskAiButton<TItem extends StoredDocSearchHit>({
|
|||
onItemClick,
|
||||
translations,
|
||||
collection,
|
||||
...props
|
||||
}: AskAiButtonProps<TItem>): JSX.Element {
|
||||
const { askAiPlaceholder = 'Ask AI: ' } = translations || {};
|
||||
const { askAiPlaceholder = 'Ask AI: ', noResultsAskAiPlaceholder = "Didn't find it in the docs? Ask AI to help: " } =
|
||||
translations || {};
|
||||
const noKeywordResults = props.state.collections.length === 1;
|
||||
|
||||
const placeholder = noKeywordResults ? noResultsAskAiPlaceholder : askAiPlaceholder;
|
||||
|
||||
return (
|
||||
<li
|
||||
|
|
@ -193,7 +201,7 @@ function AskAiButton<TItem extends StoredDocSearchHit>({
|
|||
<SparklesIcon />
|
||||
</div>
|
||||
<div className="DocSearch-Hit-AskAIButton-title">
|
||||
<span className="DocSearch-Hit-AskAIButton-title-highlight">{askAiPlaceholder}</span>
|
||||
<span className="DocSearch-Hit-AskAIButton-title-highlight">{placeholder}</span>
|
||||
<mark className="DocSearch-Hit-AskAIButton-title-query">{item.query || ''}</mark>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { removeHighlightTags } from './utils';
|
|||
|
||||
export type ResultsScreenTranslations = Partial<{
|
||||
askAiPlaceholder: string;
|
||||
noResultsAskAiPlaceholder: string;
|
||||
}>;
|
||||
|
||||
type ResultsScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ import React from 'react';
|
|||
|
||||
import type { AskAiScreenTranslations } from './AskAiScreen';
|
||||
import { AskAiScreen } from './AskAiScreen';
|
||||
import { ConversationHistoryScreen } from './ConversationHistoryScreen';
|
||||
import type { DocSearchProps } from './DocSearch';
|
||||
import type { ErrorScreenTranslations } from './ErrorScreen';
|
||||
import { ErrorScreen } from './ErrorScreen';
|
||||
import type { NewConversationTranslations } from './NewConversationScreen';
|
||||
import { NewConversationScreen } from './NewConversationScreen';
|
||||
import type { NoResultsScreenTranslations } from './NoResultsScreen';
|
||||
import { NoResultsScreen } from './NoResultsScreen';
|
||||
import type { ResultsScreenTranslations } from './ResultsScreen';
|
||||
|
|
@ -15,7 +18,7 @@ import type { StartScreenTranslations } from './StartScreen';
|
|||
import { StartScreen } from './StartScreen';
|
||||
import type { StoredSearchPlugin } from './stored-searches';
|
||||
import type { InternalDocSearchHit, StoredAskAiState, StoredDocSearchHit } from './types';
|
||||
import type { AIMessage } from './types/AskiAi';
|
||||
import type { AIMessage, AskAiState } from './types/AskiAi';
|
||||
|
||||
export type ScreenStateTranslations = Partial<{
|
||||
errorScreen: ErrorScreenTranslations;
|
||||
|
|
@ -23,6 +26,7 @@ export type ScreenStateTranslations = Partial<{
|
|||
noResultsScreen: NoResultsScreenTranslations;
|
||||
resultsScreen: ResultsScreenTranslations;
|
||||
askAiScreen: AskAiScreenTranslations;
|
||||
newConversation: NewConversationTranslations;
|
||||
}>;
|
||||
|
||||
export interface ScreenStateProps<TItem extends BaseItem>
|
||||
|
|
@ -48,10 +52,27 @@ export interface ScreenStateProps<TItem extends BaseItem>
|
|||
getMissingResultsUrl?: DocSearchProps['getMissingResultsUrl'];
|
||||
hasCollections: boolean;
|
||||
onFeedback?: (messageId: string, thumbs: 0 | 1) => Promise<void>;
|
||||
askAiState: AskAiState;
|
||||
selectAskAiQuestion: (toggle: boolean, query: string) => void;
|
||||
}
|
||||
|
||||
export const ScreenState = React.memo(
|
||||
({ translations = {}, ...props }: ScreenStateProps<InternalDocSearchHit>) => {
|
||||
if (props.canHandleAskAi && props.askAiState === 'conversation-history') {
|
||||
return <ConversationHistoryScreen {...props} />;
|
||||
}
|
||||
|
||||
if (props.canHandleAskAi && props.askAiState === 'new-conversation') {
|
||||
return (
|
||||
<NewConversationScreen
|
||||
translations={translations?.newConversation}
|
||||
selectSuggestedQuestion={(query: string) => {
|
||||
props.selectAskAiQuestion(true, query);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.isAskAiActive && props.canHandleAskAi) {
|
||||
return (
|
||||
<AskAiScreen
|
||||
|
|
|
|||
|
|
@ -3,10 +3,20 @@ import type { AutocompleteApi, AutocompleteState } from '@algolia/autocomplete-c
|
|||
import React, { type JSX, type RefObject } from 'react';
|
||||
|
||||
import { MAX_QUERY_SIZE } from './constants';
|
||||
import { LoadingIcon, CloseIcon, SearchIcon, SparklesIcon } from './icons';
|
||||
import {
|
||||
LoadingIcon,
|
||||
CloseIcon,
|
||||
SearchIcon,
|
||||
StopIcon,
|
||||
MoreVerticalIcon,
|
||||
NewConversationIcon,
|
||||
ConversationHistoryIcon,
|
||||
} from './icons';
|
||||
import { BackIcon } from './icons/BackIcon';
|
||||
import { Menu } from './Menu';
|
||||
import { ModalHeading } from './ModalHeading';
|
||||
import type { InternalDocSearchHit } from './types';
|
||||
import type { AIMessage } from './types/AskiAi';
|
||||
import type { AIMessage, AskAiState } from './types/AskiAi';
|
||||
|
||||
export type SearchBoxTranslations = Partial<{
|
||||
clearButtonTitle: string;
|
||||
|
|
@ -21,6 +31,10 @@ export type SearchBoxTranslations = Partial<{
|
|||
searchInputLabel: string;
|
||||
backToKeywordSearchButtonText: string;
|
||||
backToKeywordSearchButtonAriaLabel: string;
|
||||
newConversationPlaceholder: string;
|
||||
conversationHistoryTitle: string;
|
||||
startNewConversationText: string;
|
||||
viewConversationHistoryText: string;
|
||||
}>;
|
||||
|
||||
interface SearchBoxProps
|
||||
|
|
@ -31,14 +45,18 @@ interface SearchBoxProps
|
|||
onClose: () => void;
|
||||
onAskAiToggle: (toggle: boolean) => void;
|
||||
onAskAgain: (query: string) => void;
|
||||
onStopAskAiStreaming: () => Promise<void>;
|
||||
placeholder: string;
|
||||
isAskAiActive: boolean;
|
||||
askAiStatus: UseChatHelpers<AIMessage>['status'];
|
||||
isFromSelection: boolean;
|
||||
translations?: SearchBoxTranslations;
|
||||
askAiState: AskAiState;
|
||||
onNewConversation: () => void;
|
||||
onViewConversationHistory: () => void;
|
||||
}
|
||||
|
||||
export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.Element {
|
||||
export function SearchBox({ translations = {}, askAiState, onAskAiToggle, ...props }: SearchBoxProps): JSX.Element {
|
||||
const {
|
||||
clearButtonTitle = 'Clear',
|
||||
clearButtonAriaLabel = 'Clear the query',
|
||||
|
|
@ -48,6 +66,10 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
|
|||
backToKeywordSearchButtonText = 'Back to keyword search',
|
||||
backToKeywordSearchButtonAriaLabel = 'Back to keyword search',
|
||||
placeholderTextAskAiStreaming = 'Answering...',
|
||||
newConversationPlaceholder = 'Ask a question',
|
||||
conversationHistoryTitle = 'My conversation history',
|
||||
startNewConversationText = 'Start a new conversation',
|
||||
viewConversationHistoryText = 'Conversation history',
|
||||
} = translations;
|
||||
const { onReset } = props.getFormProps({
|
||||
inputElement: props.inputRef.current,
|
||||
|
|
@ -65,6 +87,16 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
|
|||
}
|
||||
}, [props.isFromSelection, props.inputRef]);
|
||||
|
||||
const hasRecentConversations = React.useMemo(() => {
|
||||
const askAiSource = props.state.collections[2];
|
||||
|
||||
if (!askAiSource) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return askAiSource.items.length > 0;
|
||||
}, [props.state.collections]);
|
||||
|
||||
const baseInputProps = props.getInputProps({
|
||||
inputElement: props.inputRef.current!,
|
||||
autoFocus: props.autoFocus,
|
||||
|
|
@ -76,6 +108,22 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
|
|||
const origOnChange = baseInputProps.onChange;
|
||||
const isAskAiStreaming = props.askAiStatus === 'streaming' || props.askAiStatus === 'submitted';
|
||||
const isKeywordSearchLoading = props.state.status === 'stalled';
|
||||
const renderMoreOptions = props.isAskAiActive && askAiState !== 'conversation-history';
|
||||
let searchPlaceholder = props.placeholder;
|
||||
|
||||
if (askAiState === 'new-conversation') {
|
||||
searchPlaceholder = newConversationPlaceholder;
|
||||
}
|
||||
|
||||
let heading: string | null = null;
|
||||
|
||||
if (isAskAiStreaming) {
|
||||
heading = placeholderTextAskAiStreaming;
|
||||
}
|
||||
|
||||
if (askAiState === 'conversation-history') {
|
||||
heading = conversationHistoryTitle;
|
||||
}
|
||||
|
||||
// when returning to another status than streaming or submitted, we focus on the input
|
||||
React.useEffect(() => {
|
||||
|
|
@ -122,6 +170,15 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
|
|||
disabled: isAskAiStreaming,
|
||||
};
|
||||
|
||||
const handleAskAiBackClick = React.useCallback((): void => {
|
||||
if (askAiState === 'conversation-history') {
|
||||
onAskAiToggle(true);
|
||||
return;
|
||||
}
|
||||
|
||||
onAskAiToggle(false);
|
||||
}, [askAiState, onAskAiToggle]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
|
|
@ -136,10 +193,10 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
|
|||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className="DocSearch-AskAi-Return"
|
||||
className="DocSearch-Action DocSearch-AskAi-Return"
|
||||
title={backToKeywordSearchButtonText}
|
||||
aria-label={backToKeywordSearchButtonAriaLabel}
|
||||
onClick={() => props.onAskAiToggle(false)}
|
||||
onClick={handleAskAiBackClick}
|
||||
>
|
||||
<BackIcon />
|
||||
</button>
|
||||
|
|
@ -160,20 +217,17 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
|
|||
</>
|
||||
)}
|
||||
|
||||
{heading && <ModalHeading heading={heading} shimmer={isAskAiStreaming} />}
|
||||
|
||||
<input
|
||||
className="DocSearch-Input"
|
||||
ref={props.inputRef}
|
||||
{...inputProps}
|
||||
placeholder={isAskAiStreaming ? placeholderTextAskAiStreaming : props.placeholder}
|
||||
placeholder={searchPlaceholder}
|
||||
hidden={Boolean(heading)}
|
||||
/>
|
||||
|
||||
<div className="DocSearch-Actions">
|
||||
{isAskAiStreaming && (
|
||||
<button type="button" className="DocSearch-StreamingIndicator" onClick={() => props.onAskAiToggle(true)}>
|
||||
<SparklesIcon />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="DocSearch-Clear"
|
||||
type="reset"
|
||||
|
|
@ -185,12 +239,50 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
|
|||
{clearButtonTitle}
|
||||
</button>
|
||||
|
||||
{(isAskAiStreaming || props.state.query) && <div className="DocSearch-Divider" />}
|
||||
{props.state.query && <div className="DocSearch-Divider" />}
|
||||
|
||||
{isAskAiStreaming && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="DocSearch-Action DocSearch-StopStreaming"
|
||||
onClick={props.onStopAskAiStreaming}
|
||||
>
|
||||
<StopIcon />
|
||||
</button>
|
||||
|
||||
<div className="DocSearch-Divider" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderMoreOptions && (
|
||||
<>
|
||||
<Menu>
|
||||
<Menu.Trigger className="DocSearch-Action">
|
||||
<MoreVerticalIcon />
|
||||
</Menu.Trigger>
|
||||
<Menu.Content>
|
||||
<Menu.Item onClick={props.onNewConversation}>
|
||||
<NewConversationIcon />
|
||||
{startNewConversationText}
|
||||
</Menu.Item>
|
||||
{hasRecentConversations && (
|
||||
<Menu.Item onClick={props.onViewConversationHistory}>
|
||||
<ConversationHistoryIcon />
|
||||
{viewConversationHistoryText}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Content>
|
||||
</Menu>
|
||||
|
||||
<div className="DocSearch-Divider" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title={closeButtonText}
|
||||
className="DocSearch-Close"
|
||||
className="DocSearch-Action DocSearch-Close"
|
||||
aria-label={closeButtonAriaLabel}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -295,13 +295,13 @@ describe('api', () => {
|
|||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(await screen.findByText(/Ask AI:/));
|
||||
fireEvent.click(await screen.findByText(/Ask AI to help:/));
|
||||
});
|
||||
|
||||
expect(document.querySelector('.DocSearch-AskAiScreen')).toBeInTheDocument();
|
||||
|
||||
// could be "Answering..." or "Ask another question..."
|
||||
expect(screen.getByPlaceholderText('Answering...')).toBeInTheDocument();
|
||||
expect(screen.getByText('Answering...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import React, { type JSX } from 'react';
|
||||
|
||||
export function ConversationHistoryIcon(): JSX.Element {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path
|
||||
d="M9 0.875C10.6068 0.875 12.1776 1.35149 13.5137 2.24414C14.8498 3.13693 15.8919 4.40598 16.5068 5.89062C17.1218 7.37522 17.2822 9.00892 16.9688 10.585C16.6552 12.1611 15.8814 13.6088 14.7451 14.7451C13.6088 15.8814 12.1611 16.6552 10.585 16.9688C9.00892 17.2822 7.37523 17.1218 5.89062 16.5068C4.40598 15.8919 3.13693 14.8498 2.24414 13.5137C1.35149 12.1776 0.875 10.6068 0.875 9C0.875 8.65482 1.15482 8.375 1.5 8.375C1.84518 8.375 2.125 8.65482 2.125 9C2.125 10.3596 2.52792 11.6888 3.2832 12.8193C4.03864 13.9499 5.1129 14.8312 6.36914 15.3516C7.62523 15.8718 9.00736 16.0083 10.3408 15.7432C11.6744 15.4779 12.8998 14.8228 13.8613 13.8613C14.8228 12.8998 15.4779 11.6744 15.7432 10.3408C16.0083 9.00736 15.8718 7.62523 15.3516 6.36914C14.8312 5.1129 13.9499 4.03864 12.8193 3.2832C11.6888 2.52792 10.3596 2.125 9 2.125C7.06829 2.125 5.21604 2.89096 3.82129 4.22949L3.00879 5.04199H5.66699C6.01202 5.04217 6.29199 5.32192 6.29199 5.66699C6.29182 6.01191 6.01191 6.29182 5.66699 6.29199H1.5C1.45939 6.29199 1.41889 6.28716 1.37891 6.2793C1.36148 6.27587 1.345 6.26949 1.32812 6.26465C1.267 6.24714 1.20782 6.22205 1.15332 6.18555C1.08536 6.14005 1.02604 6.08256 0.980469 6.01465C0.970244 5.99942 0.962841 5.98268 0.954102 5.9668C0.904937 5.87756 0.875056 5.77606 0.875 5.66699V1.5C0.875 1.15482 1.15482 0.875002 1.5 0.875C1.84518 0.875 2.125 1.15482 2.125 1.5V4.15723L2.94141 3.3418L2.9502 3.33301C4.57158 1.77433 6.73388 0.875 9 0.875ZM9.33301 4.20801C9.67808 4.20801 9.95783 4.48798 9.95801 4.83301V9.44727L12.9463 10.9414C13.2548 11.0958 13.3798 11.4707 13.2256 11.7793C13.0712 12.088 12.6955 12.213 12.3867 12.0586L9.05371 10.3926C8.84198 10.2867 8.70801 10.0697 8.70801 9.83301V4.83301C8.70818 4.48809 8.98809 4.20819 9.33301 4.20801Z"
|
||||
fill="currentcolor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
14
packages/docsearch-react/src/icons/MoreVerticalIcon.tsx
Normal file
14
packages/docsearch-react/src/icons/MoreVerticalIcon.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import React, { type JSX } from 'react';
|
||||
|
||||
export function MoreVerticalIcon(): JSX.Element {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={4} height={16} viewBox="0 0 4 16" fill="none">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M1.99984 1.95898C1.88478 1.95898 1.7915 2.05226 1.7915 2.16732C1.7915 2.28238 1.88478 2.37565 1.99984 2.37565C2.1149 2.37565 2.20817 2.28238 2.20817 2.16732C2.20817 2.05226 2.1149 1.95898 1.99984 1.95898ZM0.541504 2.16732C0.541504 1.3619 1.19442 0.708984 1.99984 0.708984C2.80525 0.708984 3.45817 1.3619 3.45817 2.16732C3.45817 2.97273 2.80525 3.62565 1.99984 3.62565C1.19442 3.62565 0.541504 2.97273 0.541504 2.16732ZM1.99984 7.79232C1.88478 7.79232 1.7915 7.88559 1.7915 8.00065C1.7915 8.11571 1.88478 8.20898 1.99984 8.20898C2.1149 8.20898 2.20817 8.11571 2.20817 8.00065C2.20817 7.88559 2.1149 7.79232 1.99984 7.79232ZM0.541504 8.00065C0.541504 7.19524 1.19442 6.54232 1.99984 6.54232C2.80525 6.54232 3.45817 7.19524 3.45817 8.00065C3.45817 8.80607 2.80525 9.45898 1.99984 9.45898C1.19442 9.45898 0.541504 8.80607 0.541504 8.00065ZM1.99984 13.6257C1.88478 13.6257 1.7915 13.7189 1.7915 13.834C1.7915 13.949 1.88478 14.0423 1.99984 14.0423C2.1149 14.0423 2.20817 13.949 2.20817 13.834C2.20817 13.7189 2.1149 13.6257 1.99984 13.6257ZM0.541504 13.834C0.541504 13.0286 1.19442 12.3757 1.99984 12.3757C2.80525 12.3757 3.45817 13.0286 3.45817 13.834C3.45817 14.6394 2.80525 15.2923 1.99984 15.2923C1.19442 15.2923 0.541504 14.6394 0.541504 13.834Z"
|
||||
fill="currentcolor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
21
packages/docsearch-react/src/icons/NewConversationIcon.tsx
Normal file
21
packages/docsearch-react/src/icons/NewConversationIcon.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { JSX } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
export function NewConversationIcon(): JSX.Element {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 19 19" fill="none">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0.712717 3.38039C1.14249 2.95062 1.72538 2.70917 2.33317 2.70917H8.1665C8.51168 2.70917 8.7915 2.989 8.7915 3.33417C8.7915 3.67935 8.51168 3.95917 8.1665 3.95917H2.33317C2.0569 3.95917 1.79195 4.06892 1.5966 4.26427C1.40125 4.45962 1.2915 4.72457 1.2915 5.00084V16.6675C1.2915 16.9438 1.40125 17.2087 1.5966 17.4041C1.79195 17.5994 2.0569 17.7092 2.33317 17.7092H13.9998C14.2761 17.7092 14.5411 17.5994 14.7364 17.4041C14.9318 17.2087 15.0415 16.9438 15.0415 16.6675V10.8342C15.0415 10.489 15.3213 10.2092 15.6665 10.2092C16.0117 10.2092 16.2915 10.489 16.2915 10.8342V16.6675C16.2915 17.2753 16.0501 17.8582 15.6203 18.288C15.1905 18.7177 14.6076 18.9592 13.9998 18.9592H2.33317C1.72538 18.9592 1.14249 18.7177 0.712717 18.288C0.282947 17.8582 0.0415039 17.2753 0.0415039 16.6675V5.00084C0.0415039 4.39305 0.282947 3.81016 0.712717 3.38039Z"
|
||||
fill="currentcolor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M15.6665 2.19141C15.3634 2.19141 15.0728 2.3118 14.8584 2.52611L7.06419 10.3204L6.52548 12.4752L8.68031 11.9365L16.4746 4.14223C16.6889 3.92792 16.8093 3.63725 16.8093 3.33417C16.8093 3.03109 16.6889 2.74043 16.4746 2.52611C16.2603 2.3118 15.9696 2.19141 15.6665 2.19141ZM13.9746 1.64223C14.4233 1.1935 15.0319 0.941406 15.6665 0.941406C16.3011 0.941406 16.9097 1.1935 17.3584 1.64223C17.8072 2.09096 18.0593 2.69957 18.0593 3.33417C18.0593 3.96877 17.8072 4.57738 17.3584 5.02611L9.44178 12.9428C9.36168 13.0229 9.26132 13.0797 9.15142 13.1072L5.81809 13.9405C5.6051 13.9938 5.3798 13.9314 5.22456 13.7761C5.06932 13.6209 5.00692 13.3956 5.06016 13.1826L5.8935 9.84925C5.92097 9.73936 5.9778 9.639 6.0579 9.5589L13.9746 1.64223Z"
|
||||
fill="currentcolor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
14
packages/docsearch-react/src/icons/StopIcon.tsx
Normal file
14
packages/docsearch-react/src/icons/StopIcon.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import React, { type JSX } from 'react';
|
||||
|
||||
export function StopIcon(): JSX.Element {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={18} height={18} viewBox="0 0 18 18" fill="none">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8.99984 1.29102C4.74264 1.29102 1.2915 4.74215 1.2915 8.99935C1.2915 13.2565 4.74264 16.7077 8.99984 16.7077C13.257 16.7077 16.7082 13.2565 16.7082 8.99935C16.7082 4.74215 13.257 1.29102 8.99984 1.29102ZM0.0415039 8.99935C0.0415039 4.0518 4.05229 0.0410156 8.99984 0.0410156C13.9474 0.0410156 17.9582 4.0518 17.9582 8.99935C17.9582 13.9469 13.9474 17.9577 8.99984 17.9577C4.05229 17.9577 0.0415039 13.9469 0.0415039 8.99935ZM5.87484 6.49935C5.87484 6.15417 6.15466 5.87435 6.49984 5.87435H11.4998C11.845 5.87435 12.1248 6.15417 12.1248 6.49935V11.4993C12.1248 11.8445 11.845 12.1243 11.4998 12.1243H6.49984C6.15466 12.1243 5.87484 11.8445 5.87484 11.4993V6.49935ZM7.12484 7.12435V10.8743H10.8748V7.12435H7.12484Z"
|
||||
fill="currentcolor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,5 +8,9 @@ export * from './AlertIcon';
|
|||
export * from './SelectIcon';
|
||||
export * from './SourceIcon';
|
||||
export * from './StarIcon';
|
||||
export * from './StopIcon';
|
||||
export * from './ErrorIcon';
|
||||
export * from './NoResultsIcon';
|
||||
export * from './MoreVerticalIcon';
|
||||
export * from './ConversationHistoryIcon';
|
||||
export * from './NewConversationIcon';
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type StoredSearchPlugin<TItem> = {
|
|||
getAll: () => TItem[];
|
||||
addFeedback?: (messageId: string, feedback: 'dislike' | 'like') => void;
|
||||
getOne?: (messageId: string) => StoredAskAiMessage | undefined;
|
||||
getConversation?: (messageId: string) => TItem | undefined;
|
||||
};
|
||||
|
||||
export function createStoredSearches<TItem extends StoredDocSearchHit>({
|
||||
|
|
@ -94,5 +95,12 @@ export function createStoredConversations<TItem extends StoredAskAiState>({
|
|||
|
||||
storage.setItem(items);
|
||||
},
|
||||
getConversation(messageId: string): TItem | undefined {
|
||||
const conv = items.find((c) => c.messages?.some((m) => m.id === messageId));
|
||||
|
||||
if (!conv || !conv.messages) return undefined;
|
||||
|
||||
return conv;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import type { UIMessage } from '@ai-sdk/react';
|
||||
import type { UIDataTypes, UIMessagePart } from 'ai';
|
||||
|
||||
export type AskAiState = 'conversation-history' | 'conversation' | 'initial' | 'new-conversation';
|
||||
|
||||
export interface SearchIndexTool {
|
||||
input: {
|
||||
query: string;
|
||||
|
|
@ -12,7 +14,7 @@ export interface SearchIndexTool {
|
|||
}
|
||||
|
||||
export type AIMessage = UIMessage<
|
||||
unknown,
|
||||
{ stopped?: boolean },
|
||||
UIDataTypes,
|
||||
{
|
||||
searchIndex: SearchIndexTool;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export type StoredAskAiMessage = AIMessage & {
|
|||
feedback?: 'dislike' | 'like';
|
||||
};
|
||||
|
||||
export type StoredAskAiState = Omit<DocSearchHit, '_highlightResult' | '_snippetResult'> & {
|
||||
export type StoredAskAiState = StoredDocSearchHit & {
|
||||
stopped?: boolean;
|
||||
messages?: StoredAskAiMessage[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -350,6 +350,10 @@ const translations: DocSearchTranslations = {
|
|||
searchInputLabel: 'Search',
|
||||
backToKeywordSearchButtonText: 'Back to keyword search',
|
||||
backToKeywordSearchButtonAriaLabel: 'Back to keyword search',
|
||||
newConversationPlaceholder: 'Ask a question',
|
||||
conversationHistoryTitle: 'My conversation history',
|
||||
startNewConversationText: 'Start a new conversation',
|
||||
viewConversationHistoryText: 'Conversation history'
|
||||
},
|
||||
startScreen: {
|
||||
recentSearchesTitle: 'Recent',
|
||||
|
|
@ -374,6 +378,7 @@ const translations: DocSearchTranslations = {
|
|||
},
|
||||
resultsScreen: {
|
||||
askAiPlaceholder: 'Ask AI: ',
|
||||
noResultsAskAiPlaceholder: 'Didn't find it in the docs? Ask AI to help: ',
|
||||
},
|
||||
askAiScreen: {
|
||||
disclaimerText:
|
||||
|
|
@ -392,6 +397,8 @@ const translations: DocSearchTranslations = {
|
|||
// If provided, these override the default rendering of aggregated tool calls:
|
||||
aggregatedToolCallNode: undefined, // (queries: string[], onSearchQueryClick: (query: string) => void) => React.ReactNode
|
||||
aggregatedToolCallText: undefined, // (queries: string[]) => { before?: string; separator?: string; lastSeparator?: string; after?: string }
|
||||
// Text to show when user has stopped streaming a message
|
||||
stoppedStreamingText: 'You stopped this response',
|
||||
},
|
||||
footer: {
|
||||
selectText: 'Select',
|
||||
|
|
@ -405,6 +412,10 @@ const translations: DocSearchTranslations = {
|
|||
closeKeyAriaLabel: 'Escape key',
|
||||
poweredByText: 'Powered by',
|
||||
},
|
||||
newConversation: {
|
||||
newConversationTitle: 'How can I help you today?',
|
||||
newConversationDescription: 'I search through your documentation to help you find setup guides, feature details and troubleshooting tips, fast.'
|
||||
}
|
||||
},
|
||||
};
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in a new issue