1
0
Fork 0

feat(v5): Add follow up prompt suggestions (#2902)

* feat(v5): Add follow up prompt suggestions

* fix: bump css bundle size limit
This commit is contained in:
Paul Jankowski 2026-07-02 12:08:23 -04:00 committed by GitHub
parent 77e7470da4
commit 2b97bab785
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 374 additions and 23 deletions

View file

@ -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",

View file

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

View file

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

View file

@ -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<AskAiScreenStateProps<InternalDocSearchHit>, 'trans
askAiError?: Error;
translations?: AskAiScreenTranslations;
onNewConversation: () => void;
onSelectPromptSuggestion: (prompt: string) => void;
memoryEnabled?: boolean;
};
@ -148,6 +157,7 @@ interface AskAiExchangeCardProps {
conversations: StoredSearchPlugin<StoredAskAiState>;
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}
/>
</div>
{promptSuggestions.length > 0 && (
<ConversationPromptSuggestions
title={suggestedPromptsTitleText}
suggestions={promptSuggestions}
onSelectPromptSuggestion={onSelectPromptSuggestion}
/>
)}
</div>
</div>
);
@ -393,6 +418,7 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps):
memoryEnabled={memoryEnabled}
onSearchQueryClick={handleSearchQueryClick}
onFeedback={props.onFeedback}
onSelectPromptSuggestion={props.onSelectPromptSuggestion}
/>
))}
</div>

View file

@ -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<TItem extends BaseItem>
}
export const AskAiScreenState = React.memo(
({ translations = {}, ...props }: AskAiScreenStateProps<InternalDocSearchHit>) => {
({ translations = {}, selectAskAiQuestion, ...props }: AskAiScreenStateProps<InternalDocSearchHit>): JSX.Element => {
const handleSelectPromptSuggestion = React.useCallback(
(prompt: string) => {
selectAskAiQuestion(true, prompt);
},
[selectAskAiQuestion],
);
if (props.canHandleAskAi && props.isAskAiActive && props.askAiState === 'conversation-history') {
return <ConversationHistoryScreen {...props} />;
return <ConversationHistoryScreen selectAskAiQuestion={selectAskAiQuestion} {...props} />;
}
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 (
<AskAiStartScreen {...props} hasCollections={props.hasCollections} translations={translations?.startScreen} />
<AskAiStartScreen
{...props}
hasCollections={props.hasCollections}
translations={translations?.startScreen}
selectAskAiQuestion={selectAskAiQuestion}
/>
);
}

View file

@ -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<HTMLDivElement, ConversationnExchangeProps>(
@ -129,6 +139,7 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
streamError,
memoryEnabled,
tools,
onSelectPromptSuggestion,
},
conversationRef,
): JSX.Element => {
@ -147,6 +158,7 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
errorTitleText = 'Chat error',
savedMemoryToolResultText = 'Saved to memory',
memoryToolResultText = 'Used memory to enhance results',
suggestedPromptsTitleText = 'Suggested prompts',
} = translations;
const assistantContent = useMemo(() => getMessageContent(assistantMessage), [assistantMessage]);
@ -157,6 +169,10 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
[assistantMessage],
);
const urlsToDisplay = React.useMemo(() => 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<HTMLDivElement, ConversationnExcha
onFeedback={onFeedback}
/>
</div>
{suggestedPrompts.length > 0 && (
<ConversationPromptSuggestions
title={suggestedPromptsTitleText}
suggestions={suggestedPrompts}
onSelectPromptSuggestion={onSelectPromptSuggestion}
/>
)}
</div>
</div>
);
@ -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}
/>
);
})}

View file

@ -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' && (

View file

@ -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<HTMLButtonElement>) => {
onSelectPromptSuggestion(e.currentTarget.value);
},
[onSelectPromptSuggestion],
);
return (
<section className="DocSearch-PromptSuggestions" aria-labelledby={titleId}>
<h3 className="DocSearch-PromptSuggestions-Title" id={titleId}>
{title}
</h3>
<div className="DocSearch-PromptSuggestions-Content">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="DocSearch-PromptSuggestions-Action"
value={suggestion}
onClick={handleSelectPromptSuggestion}
>
{suggestion}
</button>
))}
</div>
</section>
);
});

View file

@ -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<UIDataTypes, Tools>;
export type AIMessage = UIMessage<{ stopped?: boolean }, DataParts, Tools>;
export type AIMessagePart = UIMessagePart<DataParts, Tools>;
export type SearchToolPart = ToolUIPart<SearchTools>;
export type MemoryToolPart = ToolUIPart<AgentStudioMemoryTools>;

View file

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

View file

@ -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']);
});
});

View file

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