feat(askai): display conversation summary on new conversation when requested
This commit is contained in:
parent
f3b4b50a40
commit
2dc371930d
9 changed files with 240 additions and 27 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
26
packages/docsearch-react/src/ConversationSummary.tsx
Normal file
26
packages/docsearch-react/src/ConversationSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
24
packages/docsearch-react/src/GenerateResumeScreen.tsx
Normal file
24
packages/docsearch-react/src/GenerateResumeScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue