diff --git a/bundlesize.config.json b/bundlesize.config.json index b884de51..6e798155 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -2,7 +2,7 @@ "files": [ { "path": "packages/docsearch-css/dist/style.css", - "maxSize": "7.80 kB" + "maxSize": "8 kB" }, { "path": "packages/docsearch-react/dist/umd/index.js", diff --git a/packages/docsearch-css/src/modal.css b/packages/docsearch-css/src/modal.css index f656fa37..4a8924cb 100644 --- a/packages/docsearch-css/src/modal.css +++ b/packages/docsearch-css/src/modal.css @@ -1234,7 +1234,7 @@ assistive tech users */ display: flex; flex-direction: column; width: 100%; - gap: 0.75em; + gap: 0.8125rem; font-size: 0.8em; background: var(--docsearch-hit-background); padding: 16px; @@ -1465,6 +1465,68 @@ assistive tech users */ background: var(--docsearch-subtle-color); } +/* Prompt Suggestions */ + +.DocSearch-PromptSuggestions { + display: flex; + flex-direction: column; + gap: 0.625rem; +} + +.DocSearch-PromptSuggestions-Title { + color: var(--docsearch-text-color); + font-size: 0.875em; + line-height: 1.25em; + font-weight: 400; +} + +.DocSearch-PromptSuggestions-Content { + display: flex; + flex-direction: column; + align-items: start; + justify-content: start; + gap: 0.625rem; + + @media screen and (min-width: 768px) { + align-items: unset; + justify-content: unset; + flex-direction: row; + flex-wrap: wrap; + } +} + +.DocSearch-PromptSuggestions-Action { + display: inline-flex; + border: 1px solid var(--docsearch-subtle-color); + padding-inline: 0.625rem; + padding-block: 0.4375rem; + border-radius: 100vw; + background-color: var(--docsearch-modal-background); + font-size: 0.875em; + color: var(--docsearch-text-color); + font-weight: 400; + min-height: 1.875rem; + cursor: pointer; + transition-property: background-color, color, border-color; + transition-duration: 0.2s; + transition-timing-function: ease; + + @media screen and (prefers-reduced-motion: reduce) { + transition-property: none; + transition-duration: 0s; + } + + &:focus-visible { + outline: 2px solid var(--docsearch-primary-color); + outline-offset: 2px; + } + + &:hover { + background-color: var(--docsearch-soft-primary-color); + color: var(--docsearch-highlight-color); + border-color: var(--docsearch-soft-primary-color); + } +} .DocSearch-AskAiScreen-ActionButton { display: flex; @@ -2081,10 +2143,21 @@ html[data-theme='dark'] .DocSearch-AskAiScreen-MessageContent-Thinking { padding-block: 0.375em; cursor: pointer; user-select: none; + color: var(--docsearch-text-color); + transition: all 0.2s ease; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + + &:hover { + background: var(--docsearch-soft-primary-color); + color: var(--docsearch-highlight-color); + border-color: var(--docsearch-soft-primary-color); + } } .DocSearch-AskAiScreen-Sources-Action-icon { - color: var(--docsearch-secondary-text-color); display: inline-flex; align-items: center; justify-content: center; @@ -2105,7 +2178,6 @@ html[data-theme='dark'] .DocSearch-AskAiScreen-MessageContent-Thinking { font-size: 1em; line-height: 1.15em; white-space: nowrap; - color: var(--docsearch-secondary-text-color); font-weight: 300; } diff --git a/packages/docsearch-css/src/sidepanel.css b/packages/docsearch-css/src/sidepanel.css index d8e12121..e4054ac4 100644 --- a/packages/docsearch-css/src/sidepanel.css +++ b/packages/docsearch-css/src/sidepanel.css @@ -645,6 +645,10 @@ html[data-theme="dark"] .DocSearch-Sidepanel-Prompt--stop:hover { } } +.DocSearch-Sidepanel .DocSearch-AskAiScreen-Response-Container { + margin-block: 1rem; +} + .DocSearch-Sidepanel .DocSearch-AskAiScreen-Response { padding: 0; font-size: 0.95em; @@ -690,6 +694,17 @@ html[data-theme="dark"] .DocSearch-Sidepanel-Prompt--stop:hover { right: -4rem; } +.DocSearch-Sidepanel { + @media screen and (min-width: 768px) { + .DocSearch-PromptSuggestions-Content { + align-items: start; + justify-content: start; + flex-direction: column; + flex-wrap: nowrap; + } + } +} + .sr-only { clip: rect(0, 0, 0, 0); white-space: nowrap; diff --git a/packages/docsearch-react/src/AskAiScreen.tsx b/packages/docsearch-react/src/AskAiScreen.tsx index 08f89752..038f93bc 100644 --- a/packages/docsearch-react/src/AskAiScreen.tsx +++ b/packages/docsearch-react/src/AskAiScreen.tsx @@ -3,6 +3,7 @@ import React, { type JSX, useMemo } from 'react'; import { AggregatedSearchBlock } from './AggregatedSearchBlock'; import type { AskAiScreenStateProps } from './AskAiScreenState'; +import { ConversationPromptSuggestions } from './components/ConversationPromptSuggestions'; import { FeedbackActions } from './components/FeedbackActions'; import { SourcesPanel } from './components/SourcesPanel'; import { ToolCall, type ToolCallTranslations } from './components/ToolCall'; @@ -11,7 +12,13 @@ import { MemoizedMarkdown } from './MemoizedMarkdown'; import type { StoredSearchPlugin } from './stored-searches'; import type { InternalDocSearchHit, OnAskAiFeedback, StoredAskAiState } from './types'; import { type AIMessage, type ToolCalls } from './types/AskiAi'; -import { extractLinksFromMessage, getMessageContent, isThreadDepthError, isAIToolPart } from './utils/ai'; +import { + extractLinksFromMessage, + getMessageContent, + isThreadDepthError, + isAIToolPart, + getAgentPromptSuggestions, +} from './utils/ai'; import { groupConsecutiveToolResults } from './utils/groupConsecutiveToolResults'; export type AskAiScreenTranslations = Partial< @@ -84,6 +91,7 @@ export type AskAiScreenTranslations = Partial< * Button text for starting a new conversation after thread depth error. */ startNewConversationButtonText: string; + suggestedPromptsTitleText: string; } >; @@ -116,6 +124,7 @@ type AskAiScreenProps = Omit, 'trans askAiError?: Error; translations?: AskAiScreenTranslations; onNewConversation: () => void; + onSelectPromptSuggestion: (prompt: string) => void; memoryEnabled?: boolean; }; @@ -148,6 +157,7 @@ interface AskAiExchangeCardProps { conversations: StoredSearchPlugin; onFeedback?: OnAskAiFeedback; memoryEnabled?: boolean; + onSelectPromptSuggestion: (prompt: string) => void; } function AskAiExchangeCard({ @@ -161,6 +171,7 @@ function AskAiExchangeCard({ conversations, onFeedback, memoryEnabled, + onSelectPromptSuggestion, }: AskAiExchangeCardProps): JSX.Element { const { userMessage, assistantMessage } = exchange; @@ -168,6 +179,7 @@ function AskAiExchangeCard({ stoppedStreamingText = 'You stopped this response', errorTitleText = 'Chat error', relatedSourcesText, + suggestedPromptsTitleText = 'Suggested prompts', } = translations; const toolCallTranslations = useMemo(() => toToolCallTranslations(translations), [translations]); @@ -183,6 +195,11 @@ function AskAiExchangeCard({ return groupConsecutiveToolResults(assistantMessage?.parts || []); }, [assistantMessage]); + const promptSuggestions = useMemo(() => { + if (!isLastExchange) return []; + return getAgentPromptSuggestions(assistantMessage?.parts || []); + }, [assistantMessage, isLastExchange]); + const wasStopped = userMessage.metadata?.stopped || assistantMessage?.metadata?.stopped; const showActions = @@ -302,6 +319,14 @@ function AskAiExchangeCard({ onFeedback={onFeedback} /> + + {promptSuggestions.length > 0 && ( + + )} ); @@ -393,6 +418,7 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps): memoryEnabled={memoryEnabled} onSearchQueryClick={handleSearchQueryClick} onFeedback={props.onFeedback} + onSelectPromptSuggestion={props.onSelectPromptSuggestion} /> ))} diff --git a/packages/docsearch-react/src/AskAiScreenState.tsx b/packages/docsearch-react/src/AskAiScreenState.tsx index 1e64abf9..3356d85a 100644 --- a/packages/docsearch-react/src/AskAiScreenState.tsx +++ b/packages/docsearch-react/src/AskAiScreenState.tsx @@ -1,5 +1,6 @@ import type { UseChatHelpers } from '@ai-sdk/react'; import type { AutocompleteApi, AutocompleteState, BaseItem } from '@algolia/autocomplete-core'; +import type { JSX } from 'react'; import React from 'react'; import type { AskAiScreenTranslations } from './AskAiScreen'; @@ -68,9 +69,16 @@ export interface AskAiScreenStateProps } export const AskAiScreenState = React.memo( - ({ translations = {}, ...props }: AskAiScreenStateProps) => { + ({ translations = {}, selectAskAiQuestion, ...props }: AskAiScreenStateProps): JSX.Element => { + const handleSelectPromptSuggestion = React.useCallback( + (prompt: string) => { + selectAskAiQuestion(true, prompt); + }, + [selectAskAiQuestion], + ); + if (props.canHandleAskAi && props.isAskAiActive && props.askAiState === 'conversation-history') { - return ; + return ; } if (props.canHandleAskAi && props.isAskAiActive && props.askAiState === 'new-conversation') { @@ -93,6 +101,8 @@ export const AskAiScreenState = React.memo( askAiError={props.askAiError} translations={translations?.askAiScreen} memoryEnabled={props.memoryEnabled} + selectAskAiQuestion={selectAskAiQuestion} + onSelectPromptSuggestion={handleSelectPromptSuggestion} /> ); } @@ -103,7 +113,12 @@ export const AskAiScreenState = React.memo( if (!props.state.query) { return ( - + ); } diff --git a/packages/docsearch-react/src/Sidepanel/ConversationScreen.tsx b/packages/docsearch-react/src/Sidepanel/ConversationScreen.tsx index f73d7872..ff743d7f 100644 --- a/packages/docsearch-react/src/Sidepanel/ConversationScreen.tsx +++ b/packages/docsearch-react/src/Sidepanel/ConversationScreen.tsx @@ -3,6 +3,7 @@ import type { JSX } from 'react'; import React, { memo, useMemo } from 'react'; import { type Exchange } from '../AskAiScreen'; +import { ConversationPromptSuggestions } from '../components/ConversationPromptSuggestions'; import { FeedbackActions } from '../components/FeedbackActions'; import { SourcesPanel } from '../components/SourcesPanel'; import { ToolCall, type ToolCallTranslations } from '../components/ToolCall'; @@ -11,7 +12,13 @@ import { MemoizedMarkdown } from '../MemoizedMarkdown'; import type { StoredSearchPlugin } from '../stored-searches'; import type { OnAskAiFeedback, StoredAskAiState } from '../types'; import { type AIMessage, type ToolCalls } from '../types/AskiAi'; -import { extractLinksFromMessage, getMessageContent, EMPTY_TOOLS, isAIToolPart } from '../utils/ai'; +import { + extractLinksFromMessage, + getMessageContent, + EMPTY_TOOLS, + isAIToolPart, + getAgentPromptSuggestions, +} from '../utils/ai'; import { groupConsecutiveToolResults } from '../utils/groupConsecutiveToolResults'; import { AggregatedSearchBlock } from './AggregatedSearchBlock'; @@ -91,6 +98,7 @@ export type ConversationScreenTranslations = Partial< * Error title shown if there is an error while chatting. */ errorTitleText; + suggestedPromptsTitleText: string; } >; @@ -103,6 +111,7 @@ export type ConversationScreenProps = { streamError?: Error; memoryEnabled?: boolean; tools?: ToolCalls; + onSelectPromptSuggestion: (prompt: string) => void; }; type ConversationnExchangeProps = { @@ -115,6 +124,7 @@ type ConversationnExchangeProps = { streamError?: ConversationScreenProps['streamError']; memoryEnabled?: boolean; tools: ToolCalls; + onSelectPromptSuggestion: (prompt: string) => void; }; const ConversationExchange = React.forwardRef( @@ -129,6 +139,7 @@ const ConversationExchange = React.forwardRef { @@ -147,6 +158,7 @@ const ConversationExchange = React.forwardRef getMessageContent(assistantMessage), [assistantMessage]); @@ -157,6 +169,10 @@ const ConversationExchange = React.forwardRef extractLinksFromMessage(assistantMessage), [assistantMessage]); + const suggestedPrompts = React.useMemo(() => { + if (!isLastExchange) return []; + return getAgentPromptSuggestions(assistantMessage?.parts || []); + }, [assistantMessage, isLastExchange]); const wasStopped = userMessage.metadata?.stopped || assistantMessage?.metadata?.stopped; const isThinking = @@ -274,6 +290,14 @@ const ConversationExchange = React.forwardRef + + {suggestedPrompts.length > 0 && ( + + )} ); @@ -286,7 +310,11 @@ export const ConversationScreen = memo( translations = {}, handleFeedback, tools = EMPTY_TOOLS, - ...props + status, + conversations, + streamError, + memoryEnabled, + onSelectPromptSuggestion, }: ConversationScreenProps): JSX.Element => { const { conversationDisclaimer = 'Answers are generated with AI which can make mistakes. Verify responses.' } = translations; @@ -318,8 +346,12 @@ export const ConversationScreen = memo( isLastExchange={isLastExchange} ref={isLastExchange ? mostRecentExchangeRef : null} tools={tools} + status={status} + conversations={conversations} + streamError={streamError} + memoryEnabled={memoryEnabled} onFeedback={handleFeedback} - {...props} + onSelectPromptSuggestion={onSelectPromptSuggestion} /> ); })} diff --git a/packages/docsearch-react/src/Sidepanel/Sidepanel.tsx b/packages/docsearch-react/src/Sidepanel/Sidepanel.tsx index 66955445..34f6c058 100644 --- a/packages/docsearch-react/src/Sidepanel/Sidepanel.tsx +++ b/packages/docsearch-react/src/Sidepanel/Sidepanel.tsx @@ -207,12 +207,15 @@ function SidepanelInner( const prevStatus = React.useRef(status); - const handleSend = (prompt: string): void => { - setStoppedStreaming(false); + const handleSend = React.useCallback( + (prompt: string): void => { + setStoppedStreaming(false); - sendMessage({ text: prompt }); - setSidepanelState('conversation'); - }; + sendMessage({ text: prompt }); + setSidepanelState('conversation'); + }, + [sendMessage], + ); const handleStartNewConversation = (): void => { startNewConversation(); @@ -401,6 +404,7 @@ function SidepanelInner( streamError={askAiError} memoryEnabled={memory?.enabled ?? false} tools={tools} + onSelectPromptSuggestion={handleSend} /> )} {sidepanelState === 'conversation-history' && ( diff --git a/packages/docsearch-react/src/components/ConversationPromptSuggestions.tsx b/packages/docsearch-react/src/components/ConversationPromptSuggestions.tsx new file mode 100644 index 00000000..306437e5 --- /dev/null +++ b/packages/docsearch-react/src/components/ConversationPromptSuggestions.tsx @@ -0,0 +1,43 @@ +import type { JSX } from 'react'; +import React from 'react'; + +interface ConversationPromptSuggestionsProps { + title: string; + onSelectPromptSuggestion: (prompt: string) => void; + suggestions: string[]; +} +export const ConversationPromptSuggestions = React.memo(function ConversationPromptSuggestions({ + title, + suggestions, + onSelectPromptSuggestion, +}: ConversationPromptSuggestionsProps): JSX.Element { + const titleId = React.useId(); + + const handleSelectPromptSuggestion = React.useCallback( + (e: React.MouseEvent) => { + onSelectPromptSuggestion(e.currentTarget.value); + }, + [onSelectPromptSuggestion], + ); + + return ( +
+

+ {title} +

+
+ {suggestions.map((suggestion) => ( + + ))} +
+
+ ); +}); diff --git a/packages/docsearch-react/src/types/AskiAi.ts b/packages/docsearch-react/src/types/AskiAi.ts index 8e438f57..4cf946fe 100644 --- a/packages/docsearch-react/src/types/AskiAi.ts +++ b/packages/docsearch-react/src/types/AskiAi.ts @@ -1,5 +1,5 @@ import type { UIMessage } from '@ai-sdk/react'; -import { type ToolUIPart, type UIDataTypes, type UIMessagePart } from 'ai'; +import { type ToolUIPart, type UIMessagePart } from 'ai'; export type AskAiState = 'conversation-history' | 'conversation' | 'initial' | 'new-conversation'; @@ -79,9 +79,15 @@ type CustomTools = { }; type Tools = AgentStudioMemoryTools & CustomTools & SearchTools; -export type AIMessage = UIMessage<{ stopped?: boolean }, UIDataTypes, Tools>; +type DataParts = { + suggestions: { + suggestions: string[]; + }; +}; -export type AIMessagePart = UIMessagePart; +export type AIMessage = UIMessage<{ stopped?: boolean }, DataParts, Tools>; + +export type AIMessagePart = UIMessagePart; export type SearchToolPart = ToolUIPart; export type MemoryToolPart = ToolUIPart; diff --git a/packages/docsearch-react/src/useAskAi.ts b/packages/docsearch-react/src/useAskAi.ts index 7b15c349..f25bb19e 100644 --- a/packages/docsearch-react/src/useAskAi.ts +++ b/packages/docsearch-react/src/useAskAi.ts @@ -10,7 +10,7 @@ import type { StoredSearchPlugin } from './stored-searches'; import { createStoredConversations } from './stored-searches'; import { type AIMessage, type ToolCalls } from './types/AskiAi'; import type { OnAskAiFeedback } from './types/Feedback'; -import { EMPTY_TOOLS } from './utils/ai'; +import { EMPTY_TOOLS, sanitizeMessagesForRequest } from './utils/ai'; import type { AgentStudioIndices, AgentStudioSearchParameters, Memory, StoredAskAiState } from '.'; @@ -85,7 +85,22 @@ const getAgentStudioTransport = ({ 'x-algolia-api-key': apiKey, ...(userToken ? { 'x-algolia-secure-user-token': userToken } : {}), }, - body: { algolia: algoliaParams }, + body: { + algolia: algoliaParams, + }, + prepareSendMessagesRequest({ id, messages, body, ...rest }) { + // Filter out `data-*` part types since Agent Studio does not currently support them on the request + const sanitizedMessages = sanitizeMessagesForRequest(messages); + + return { + ...rest, + body: { + id, + messages: sanitizedMessages, + ...body, + }, + }; + }, }); }; diff --git a/packages/docsearch-react/src/utils/__tests__/ai.test.ts b/packages/docsearch-react/src/utils/__tests__/ai.test.ts index 30ca778e..114789f9 100644 --- a/packages/docsearch-react/src/utils/__tests__/ai.test.ts +++ b/packages/docsearch-react/src/utils/__tests__/ai.test.ts @@ -1,7 +1,20 @@ import { describe, it, expect } from 'vitest'; -import type { AIMessagePart } from '../../types/AskiAi'; -import { isAIToolPart, isAlgoliaMCPSearchOutputPart } from '../ai'; +import type { AIMessage, AIMessagePart } from '../../types/AskiAi'; +import { + getAgentPromptSuggestions, + isAIToolPart, + isAlgoliaMCPSearchOutputPart, + sanitizeMessagesForRequest, +} from '../ai'; + +function message(id: string, parts: AIMessagePart[]): AIMessage { + return { + id, + role: 'assistant', + parts, + }; +} describe('isAIToolPart', () => { it.each([ @@ -79,3 +92,80 @@ describe('isAlgoliaMCPSearchOutputPart', () => { }, ); }); + +describe('sanitizeMessagesForRequest', () => { + it('returns the original messages array when there are no data parts', () => { + const messages = [message('message-1', [{ type: 'text', text: 'Hello' }])]; + + expect(sanitizeMessagesForRequest(messages)).toBe(messages); + }); + + it('removes data parts from messages', () => { + const textPart: AIMessagePart = { type: 'text', text: 'Hello' }; + const reasoningPart: AIMessagePart = { type: 'reasoning', state: 'done', text: 'Thinking...' }; + const messages = [ + message('message-1', [ + textPart, + { + type: 'data-suggestions', + data: { suggestions: ['How do I configure DocSearch?'] }, + }, + reasoningPart, + ]), + ]; + + expect(sanitizeMessagesForRequest(messages)).toEqual([message('message-1', [textPart, reasoningPart])]); + }); + + it('keeps unchanged messages by reference when a later message is sanitized', () => { + const unchangedMessage = message('message-1', [{ type: 'text', text: 'Hello' }]); + const sanitizedMessage = message('message-2', [ + { type: 'text', text: 'Hi' }, + { + type: 'data-suggestions', + data: { suggestions: ['What is DocSearch?'] }, + }, + ]); + + const result = sanitizeMessagesForRequest([unchangedMessage, sanitizedMessage]); + + expect(result[0]).toBe(unchangedMessage); + expect(result[1]).not.toBe(sanitizedMessage); + expect(result[1].parts).toEqual([{ type: 'text', text: 'Hi' }]); + }); +}); + +describe('getAgentPromptSuggestions', () => { + it('returns an empty array when there is no suggestions part', () => { + expect(getAgentPromptSuggestions([{ type: 'text', text: 'Hello' }])).toEqual([]); + }); + + it('returns suggestions from the data suggestions part', () => { + expect( + getAgentPromptSuggestions([ + { type: 'text', text: 'Hello' }, + { + type: 'data-suggestions', + data: { + suggestions: ['How do I install DocSearch?', 'How do I configure facets?'], + }, + }, + ]), + ).toEqual(['How do I install DocSearch?', 'How do I configure facets?']); + }); + + it('returns suggestions from the first suggestions part', () => { + expect( + getAgentPromptSuggestions([ + { + type: 'data-suggestions', + data: { suggestions: ['First suggestion'] }, + }, + { + type: 'data-suggestions', + data: { suggestions: ['Second suggestion'] }, + }, + ]), + ).toEqual(['First suggestion']); + }); +}); diff --git a/packages/docsearch-react/src/utils/ai.ts b/packages/docsearch-react/src/utils/ai.ts index 7fd549ac..e96b827b 100644 --- a/packages/docsearch-react/src/utils/ai.ts +++ b/packages/docsearch-react/src/utils/ai.ts @@ -148,3 +148,36 @@ export function isAlgoliaMCPSearchOutputPart(part: AIMessagePart): part is Algol export function isSearchOutputPart(part: AIMessagePart): part is SearchOutputPart { return isAIToolPart(part) && isSearchToolPart(part) && part.state === 'output-available'; } + +export function sanitizeMessagesForRequest(messages: AIMessage[]): AIMessage[] { + let sanitizedMessages: AIMessage[] | undefined; + + messages.forEach((message, index) => { + // Filter out `data-*` part types since Agent Studio does not currently support them on the request + const parts = message.parts.filter((part) => !part.type.startsWith('data-')); + + if (parts.length === message.parts.length) { + sanitizedMessages?.push(message); + return; + } + + if (!sanitizedMessages) { + sanitizedMessages = messages.slice(0, index); + } + + sanitizedMessages.push({ + ...message, + parts, + }); + }); + + return sanitizedMessages ?? messages; +} + +export function getAgentPromptSuggestions(parts: AIMessagePart[]): string[] { + const suggestionsPart = parts.find((part) => part.type === 'data-suggestions'); + + if (!suggestionsPart) return []; + + return suggestionsPart.data.suggestions; +}