1
0
Fork 0

feat(askai): display conversation summary on new conversation when requested

This commit is contained in:
Lorris Saint-Genez 2026-01-12 11:25:28 -08:00
parent f3b4b50a40
commit 2dc371930d
9 changed files with 240 additions and 27 deletions

View file

@ -1485,6 +1485,23 @@ assistive tech users */
cursor: pointer;
}
/* GeneratingSummaryScreen */
.DocSearch-GeneratingSummaryScreen {
padding: 3em var(--docsearch-spacing);
}
.DocSearch-GeneratingSummaryScreen-Title {
font-weight: 600;
font-size: 26px;
color: var(--docsearch-text-color);
margin-block-end: 0.15em;
}
.DocSearch-GeneratingSummaryScreen-Description {
font-size: 14px;
color: var(--docsearch-muted-color);
}
.DocSearch-Menu {
position: relative;
}
@ -1525,3 +1542,35 @@ assistive tech users */
.DocSearch-Menu-item:hover {
background-color: var(--docsearch-dropdown-menu-item-hover-background);
}
/* Conversation Summary */
.DocSearch-ConversationSummary {
background: rgba(var(--docsearch-highlight-color-rgb, 187, 209, 255), 0.1);
border: 1px solid var(--docsearch-subtle-color);
border-radius: 8px;
padding: 16px;
margin: 0 0 16px;
animation: slideDown 0.3s ease-out;
}
.DocSearch-ConversationSummary-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
color: var(--docsearch-text-color);
margin-bottom: 8px;
}
.DocSearch-ConversationSummary-title svg {
width: 16px;
height: 16px;
color: var(--docsearch-highlight-color);
}
.DocSearch-ConversationSummary-content {
font-size: 14px;
line-height: 1.5;
color: var(--docsearch-muted-color);
}

View file

@ -2,6 +2,7 @@ import type { UseChatHelpers } from '@ai-sdk/react';
import React, { type JSX, useMemo, useState, useEffect } from 'react';
import { AggregatedSearchBlock } from './AggregatedSearchBlock';
import { ConversationSummary } from './ConversationSummary';
import { AlertIcon, LoadingIcon, SearchIcon } from './icons';
import { MemoizedMarkdown } from './MemoizedMarkdown';
import type { ScreenStateProps } from './ScreenState';
@ -60,6 +61,10 @@ export type AskAiScreenTranslations = Partial<{
* Button text for starting a new conversation after thread depth error.
*/
startNewConversationButtonText: string;
/**
* Button text for generating a summary.
*/
generateSummaryButtonText: string;
}>;
type AskAiScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
@ -68,6 +73,7 @@ type AskAiScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translatio
askAiError?: Error;
translations?: AskAiScreenTranslations;
onNewConversation: () => void;
onGenerateSummary: () => void;
};
interface AskAiScreenHeaderProps {
@ -390,10 +396,19 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps):
disclaimerText = 'Answers are generated with AI which can make mistakes. Verify responses.',
threadDepthExceededMessage = 'This conversation is now closed to keep responses accurate.',
startNewConversationButtonText = 'Start a new conversation',
generateSummaryButtonText = 'generate a summary',
} = translations;
const { messages, askAiError, status } = props;
const summary = useMemo(() => {
const summaryMessage = messages.find((m) => m.id.startsWith('summary-') && m.role === 'assistant');
if (summaryMessage) {
return summaryMessage.parts.find((part) => part.type === 'text')?.text || null;
}
return null;
}, [messages]);
// Check if there's a thread depth error
const hasThreadDepthError = useMemo(() => {
return status === 'error' && isThreadDepthError(askAiError);
@ -445,6 +460,10 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps):
<button type="button" className="DocSearch-ThreadDepthError-Link" onClick={props.onNewConversation}>
{startNewConversationButtonText}
</button>{' '}
or{' '}
<button type="button" className="DocSearch-ThreadDepthError-Link" onClick={props.onGenerateSummary}>
{generateSummaryButtonText}
</button>{' '}
to continue.
</p>
</div>
@ -454,24 +473,27 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps):
<AskAiScreenHeader disclaimerText={disclaimerText} />
<div className="DocSearch-AskAiScreen-Body">
<div className="DocSearch-AskAiScreen-ExchangesList">
{exchanges
.slice()
.reverse()
.map((exchange, index) => (
<AskAiExchangeCard
key={exchange.id}
exchange={exchange}
askAiError={props.askAiError}
isLastExchange={index === 0}
loadingStatus={props.status}
translations={translations}
conversations={props.conversations}
onSearchQueryClick={handleSearchQueryClick}
onFeedback={props.onFeedback}
/>
))}
</div>
{exchanges.length > 0 && (
<div className="DocSearch-AskAiScreen-ExchangesList">
{exchanges
.slice()
.reverse()
.map((exchange, index) => (
<AskAiExchangeCard
key={exchange.id}
exchange={exchange}
askAiError={props.askAiError}
isLastExchange={index === 0}
loadingStatus={props.status}
translations={translations}
conversations={props.conversations}
onSearchQueryClick={handleSearchQueryClick}
onFeedback={props.onFeedback}
/>
))}
</div>
)}
{summary && <ConversationSummary summary={summary} translations={null} />}
</div>
</div>
);

View file

@ -0,0 +1,26 @@
import type { JSX } from 'react';
import React from 'react';
import { SparklesIcon } from './icons';
import { MemoizedMarkdown } from './MemoizedMarkdown';
export type ConversationSummaryProps = {
summary: string;
translations?: {
summaryTitle?: string;
} | null;
};
export function ConversationSummary({ summary, translations }: ConversationSummaryProps): JSX.Element {
return (
<div className="DocSearch-ConversationSummary">
<div className="DocSearch-ConversationSummary-title">
<SparklesIcon />
<span>{translations?.summaryTitle || 'Summary'}</span>
</div>
<div className="DocSearch-ConversationSummary-content">
<MemoizedMarkdown content={summary} copyButtonText="" copyButtonCopiedText="" isStreaming={false} />
</div>
</div>
);
}

View file

@ -414,12 +414,41 @@ export function DocSearchModal({
});
const prevStatus = React.useRef(status);
const prevAskAiState = React.useRef(askAiState);
React.useEffect(() => {
// Handle summary generation completion → initial state with summary as context
if (prevStatus.current === 'streaming' && status === 'ready' && prevAskAiState.current === 'generating-summary') {
// Extract the summary from the last assistant message
const lastMessage = messages.at(-1);
if (lastMessage && lastMessage.role === 'assistant') {
const summaryText = lastMessage.parts.find((part) => part.type === 'text');
if (summaryText && summaryText.type === 'text') {
// Create a new conversation with the summary as the first system/context message
const summaryMessage: AIMessage = {
id: `summary-${Date.now()}`,
role: 'assistant',
parts: [
{
type: 'text',
text: summaryText.text,
},
],
};
setMessages([summaryMessage]);
setAskAiState('initial');
}
}
}
if (disableUserPersonalization) {
prevStatus.current = status;
prevAskAiState.current = askAiState;
return;
}
// if we just transitioned from "streaming" → "ready", persist
if (prevStatus.current === 'streaming' && status === 'ready') {
if (prevStatus.current === 'streaming' && status === 'ready' && prevAskAiState.current !== 'generating-summary') {
// if we stopped the stream, store it on the most recent message
if (stoppedStream && messages.at(-1)) {
messages.at(-1)!.metadata = {
@ -427,14 +456,41 @@ export function DocSearchModal({
};
}
for (const part of messages[0].parts) {
if (part.type === 'text') {
conversations.add(buildDummyAskAiHit(part.text, messages));
// If the first message is assistant (summary), use the second message (user) as query/objectID
if (
messages.length > 1 &&
messages[0]?.role === 'assistant' &&
messages[0].id.startsWith('summary-') &&
messages[1]?.role === 'user'
) {
const userMessage = messages[1];
for (const part of userMessage.parts) {
if (part.type === 'text') {
conversations.add(buildDummyAskAiHit(part.text, messages, 1));
}
}
} else {
// Normal case: first message is user
for (const part of messages[0].parts) {
if (part.type === 'text') {
conversations.add(buildDummyAskAiHit(part.text, messages));
}
}
}
}
prevStatus.current = status;
}, [status, messages, conversations, disableUserPersonalization, stoppedStream]);
prevAskAiState.current = askAiState;
}, [
status,
askAiState,
messages,
setMessages,
setAskAiState,
conversations,
disableUserPersonalization,
stoppedStream,
]);
// Check if there's a thread depth error (AI-217)
const hasThreadDepthError = React.useMemo(() => {
@ -822,6 +878,19 @@ export function DocSearchModal({
setAskAiState('new-conversation');
};
const handleGenerateSummary = (): void => {
setAskAiState('generating-summary');
sendMessage({
role: 'user',
parts: [
{
type: 'text',
text: 'Summarize this conversation in 2-3 concise sentences, capturing the main question and key points of the answer.',
},
],
});
};
const handleViewConversationHistory = (): void => {
setAskAiState('conversation-history');
};
@ -880,6 +949,7 @@ export function DocSearchModal({
}}
onStopAskAiStreaming={onStopAskAiStreaming}
onNewConversation={handleNewConversation}
onGenerateSummary={handleGenerateSummary}
onViewConversationHistory={handleViewConversationHistory}
/>
</header>
@ -911,6 +981,7 @@ export function DocSearchModal({
selectSuggestedQuestion={selectSuggestedQuestion}
onAskAiToggle={onAskAiToggle}
onNewConversation={handleNewConversation}
onGenerateSummary={handleGenerateSummary}
onItemClick={(item, event) => {
// if the item is askAI toggle the screen
if (item.type === 'askAI' && item.query) {

View file

@ -0,0 +1,24 @@
import React, { type JSX } from 'react';
export type GeneratingSummaryTranslations = Partial<{
generatingSummaryTitle: string;
generatingSummaryDescription: string;
}>;
interface GeneratingSummaryProps {
translations?: GeneratingSummaryTranslations;
}
export function GeneratingSummaryScreen({ translations = {} }: GeneratingSummaryProps): JSX.Element {
const {
generatingSummaryTitle = 'Generating summary...',
generatingSummaryDescription = 'We are generating a summary of your conversation. This may take a few seconds.',
} = translations;
return (
<div className="DocSearch-GeneratingSummaryScreen">
<h3 className="DocSearch-GeneratingSummaryScreen-Title">{generatingSummaryTitle}</h3>
<p className="DocSearch-GeneratingSummaryScreen-Description shimmer">{generatingSummaryDescription}</p>
</div>
);
}

View file

@ -8,6 +8,8 @@ import { ConversationHistoryScreen } from './ConversationHistoryScreen';
import type { DocSearchProps } from './DocSearch';
import type { ErrorScreenTranslations } from './ErrorScreen';
import { ErrorScreen } from './ErrorScreen';
import type { GeneratingSummaryTranslations } from './GenerateResumeScreen';
import { GeneratingSummaryScreen } from './GenerateResumeScreen';
import type { NewConversationTranslations } from './NewConversationScreen';
import { NewConversationScreen } from './NewConversationScreen';
import type { NoResultsScreenTranslations } from './NoResultsScreen';
@ -27,6 +29,7 @@ export type ScreenStateTranslations = Partial<{
resultsScreen: ResultsScreenTranslations;
askAiScreen: AskAiScreenTranslations;
newConversation: NewConversationTranslations;
generatingSummary: GeneratingSummaryTranslations;
}>;
export interface ScreenStateProps<TItem extends BaseItem>
@ -56,6 +59,7 @@ export interface ScreenStateProps<TItem extends BaseItem>
suggestedQuestions: SuggestedQuestionHit[];
selectSuggestedQuestion: (question: SuggestedQuestionHit) => void;
onNewConversation: () => void;
onGenerateSummary: () => void;
}
export const ScreenState = React.memo(
@ -74,6 +78,10 @@ export const ScreenState = React.memo(
);
}
if (props.canHandleAskAi && props.isAskAiActive && props.askAiState === 'generating-summary') {
return <GeneratingSummaryScreen translations={translations?.generatingSummary} />;
}
if (props.isAskAiActive && props.canHandleAskAi) {
return (
<AskAiScreen

View file

@ -11,6 +11,7 @@ import {
MoreVerticalIcon,
NewConversationIcon,
ConversationHistoryIcon,
SparklesIcon,
} from './icons';
import { BackIcon } from './icons/BackIcon';
import { Menu } from './Menu';
@ -36,6 +37,7 @@ export type SearchBoxTranslations = Partial<{
startNewConversationText: string;
viewConversationHistoryText: string;
threadDepthErrorPlaceholder: string;
generateSummaryText: string;
}>;
interface SearchBoxProps
@ -56,6 +58,7 @@ interface SearchBoxProps
askAiState: AskAiState;
setAskAiState: (state: AskAiState) => void;
onNewConversation: () => void;
onGenerateSummary: () => void;
onViewConversationHistory: () => void;
isThreadDepthError?: boolean;
}
@ -79,6 +82,7 @@ export function SearchBox({
newConversationPlaceholder = 'Ask a question',
conversationHistoryTitle = 'My conversation history',
startNewConversationText = 'Start a new conversation',
generateSummaryText = 'Generate summary',
viewConversationHistoryText = 'Conversation history',
threadDepthErrorPlaceholder = 'Conversation limit reached',
} = translations;
@ -186,7 +190,7 @@ export function SearchBox({
}
origOnChange?.(e);
},
disabled: isAskAiStreaming || (isThreadDepthError && props.isAskAiActive),
disabled: isAskAiStreaming || (isThreadDepthError && props.isAskAiActive) || askAiState === 'generating-summary',
};
const handleAskAiBackClick = React.useCallback((): void => {
@ -292,6 +296,10 @@ export function SearchBox({
<NewConversationIcon />
{startNewConversationText}
</Menu.Item>
<Menu.Item onClick={props.onGenerateSummary}>
<SparklesIcon />
{generateSummaryText}
</Menu.Item>
{hasRecentConversations && (
<Menu.Item onClick={props.onViewConversationHistory}>
<ConversationHistoryIcon />

View file

@ -1,7 +1,12 @@
import type { UIMessage } from '@ai-sdk/react';
import type { UIDataTypes, UIMessagePart } from 'ai';
export type AskAiState = 'conversation-history' | 'conversation' | 'initial' | 'new-conversation';
export type AskAiState =
| 'conversation-history'
| 'conversation'
| 'generating-summary'
| 'initial'
| 'new-conversation';
export interface SearchIndexTool {
input: {

View file

@ -69,8 +69,8 @@ export function extractLinksFromMessage(message: AIMessage | null): ExtractedLin
return links;
}
export const buildDummyAskAiHit = (query: string, messages: AIMessage[]): StoredAskAiState => {
const textPart = messages[0].parts.find((part) => part.type === 'text');
export const buildDummyAskAiHit = (query: string, messages: AIMessage[], index: number = 0): StoredAskAiState => {
const textPart = messages[index].parts.find((part) => part.type === 'text');
const sanitizedText = textPart?.text ? sanitizeUserInput(textPart.text) : '';
return {