feat(agentStudio): surface thread depth error (#2877)
* feat(agentStudio): surface thread depth error * feat(agentStudio): new conversation fixes * feat(agentStudio): Sidepanel improvements * feat(agentStudio): ussage of service error message
This commit is contained in:
parent
b686914cd2
commit
cf42ab519b
18 changed files with 623 additions and 92 deletions
|
|
@ -47,6 +47,7 @@
|
|||
"@rollup/plugin-terser": "0.4.4",
|
||||
"@stylistic/eslint-plugin": "2.13.0",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.20.0",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export interface DocSearchContext {
|
|||
isModalActive: boolean;
|
||||
onAskAiToggle: OnAskAiToggle;
|
||||
initialAskAiMessage: InitialAskAiMessage | undefined;
|
||||
clearInitialAskAiMessage: () => void;
|
||||
registerView: (view: View) => void;
|
||||
isHybridModeSupported: boolean;
|
||||
}
|
||||
|
|
@ -183,6 +184,10 @@ function DocSearchInner(
|
|||
[setDocsearchState, setInitialQuery],
|
||||
);
|
||||
|
||||
const clearInitialAskAiMessage = React.useCallback((): void => {
|
||||
setInitialAskAiMessage(undefined);
|
||||
}, []);
|
||||
|
||||
const registerView = React.useCallback(
|
||||
(view: View): void => {
|
||||
if (registeredViews.has(view)) return;
|
||||
|
|
@ -246,6 +251,7 @@ function DocSearchInner(
|
|||
isModalActive,
|
||||
onAskAiToggle,
|
||||
initialAskAiMessage,
|
||||
clearInitialAskAiMessage,
|
||||
registerView,
|
||||
isHybridModeSupported,
|
||||
}),
|
||||
|
|
@ -260,6 +266,7 @@ function DocSearchInner(
|
|||
isModalActive,
|
||||
onAskAiToggle,
|
||||
initialAskAiMessage,
|
||||
clearInitialAskAiMessage,
|
||||
registerView,
|
||||
isHybridModeSupported,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -87,7 +87,8 @@
|
|||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--docsearch-text-color);
|
||||
flex: 1;
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
font-size: 1.2em;
|
||||
font-weight: 300;
|
||||
|
|
@ -95,12 +96,16 @@
|
|||
outline: none;
|
||||
padding-block-start: 0px;
|
||||
padding-inline-start: 8px;
|
||||
width: 80%;
|
||||
line-height: 1.4;
|
||||
resize: none;
|
||||
overflow-y: hidden; /* js toggles to auto when exceeding max */
|
||||
}
|
||||
|
||||
.DocSearch-Input {
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.DocSearch-Input::placeholder {
|
||||
color: var(--docsearch-muted-color);
|
||||
opacity: 1; /* Firefox */
|
||||
|
|
@ -114,7 +119,8 @@
|
|||
}
|
||||
|
||||
.DocSearch-Actions {
|
||||
width: var(--docsearch-actions-width);
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
height: var(--docsearch-actions-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -929,6 +935,10 @@ assistive tech users */
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.DocSearch-AskAiScreen-Error--ThreadDepth .DocSearch-AskAiScreen-Error-Title {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
|
|
|||
|
|
@ -19,12 +19,16 @@
|
|||
--docsearch-sidepanel-hit-highlight-color: var(--docsearch-hit-highlight-color);
|
||||
--docsearch-sidepanel-button-background: var(--docsearch-sidepanel-background);
|
||||
--docsearch-sidepanel-button-background-dark: var(--docsearch-sidepanel-background);
|
||||
--docsearch-sidepanel-thread-depth-banner-bg: #fff1f2;
|
||||
--docsearch-sidepanel-thread-depth-banner-border: #fecdd3;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] {
|
||||
--docsearch-sidepanel-text-base: var(--docsearch-text-color);
|
||||
--docsearch-sidepanel-primary-disabled: rgba(1, 45, 186, 0.60);
|
||||
--docsearch-sidepanel-button-background-dark: rgba(4, 4, 8, 1);
|
||||
--docsearch-sidepanel-thread-depth-banner-bg: rgb(239 83 80 / 12%);
|
||||
--docsearch-sidepanel-thread-depth-banner-border: rgb(254 205 211 / 35%);
|
||||
}
|
||||
|
||||
.DocSearch-SidepanelButton {
|
||||
|
|
@ -623,6 +627,45 @@ html[data-theme="dark"] .DocSearch-Sidepanel-Prompt--stop:hover {
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
@keyframes docsearch-sidepanel-thread-depth-banner-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.DocSearch-Sidepanel .DocSearch-Sidepanel-ThreadDepthBanner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--docsearch-sidepanel-thread-depth-banner-bg);
|
||||
border: 1px solid var(--docsearch-sidepanel-thread-depth-banner-border);
|
||||
box-sizing: border-box;
|
||||
color: var(--docsearch-sidepanel-text-base);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
font-weight: 400;
|
||||
width: 100%;
|
||||
animation: docsearch-sidepanel-thread-depth-banner-enter 0.3s ease-out;
|
||||
}
|
||||
|
||||
.DocSearch-Sidepanel .DocSearch-Sidepanel-ThreadDepthBanner p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.DocSearch-Sidepanel .DocSearch-Sidepanel-ThreadDepthBanner-apiMessage {
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 769px) {
|
||||
.DocSearch-Sidepanel-ConversationScreen {
|
||||
padding: 1rem 0;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@ import type { StoredSearchPlugin } from './stored-searches';
|
|||
import { ToolCall } from './ToolCall';
|
||||
import type { InternalDocSearchHit, StoredAskAiState } from './types';
|
||||
import type { AIMessage } from './types/AskiAi';
|
||||
import { extractLinksFromMessage, getMessageContent, isThreadDepthError } from './utils/ai';
|
||||
import {
|
||||
extractLinksFromMessage,
|
||||
filterExchangesForThreadDepthError,
|
||||
getMessageContent,
|
||||
getThreadDepthErrorUserFacingMessage,
|
||||
isThreadDepthError,
|
||||
} from './utils/ai';
|
||||
import { groupConsecutiveToolResults } from './utils/groupConsecutiveToolResults';
|
||||
|
||||
export type AskAiScreenTranslations = Partial<{
|
||||
|
|
@ -378,6 +384,8 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps):
|
|||
return status === 'error' && isThreadDepthError(askAiError);
|
||||
}, [status, askAiError]);
|
||||
|
||||
const threadDepthApiMessage = useMemo(() => getThreadDepthErrorUserFacingMessage(askAiError), [askAiError]);
|
||||
|
||||
// Group messages into exchanges (user + assistant pairs)
|
||||
const exchanges: Exchange[] = useMemo(() => {
|
||||
const grouped: Exchange[] = [];
|
||||
|
|
@ -392,17 +400,7 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps):
|
|||
}
|
||||
}
|
||||
|
||||
// If there's a thread depth error, remove the last exchange (the one that triggered the error)
|
||||
// We only want to show successful exchanges
|
||||
if (hasThreadDepthError && grouped.length > 0) {
|
||||
// Check if the last exchange has no assistant message (failed to complete)
|
||||
const lastExchange = grouped[grouped.length - 1];
|
||||
if (!lastExchange.assistantMessage) {
|
||||
grouped.pop();
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
return filterExchangesForThreadDepthError(grouped, hasThreadDepthError);
|
||||
}, [messages, hasThreadDepthError]);
|
||||
|
||||
const handleSearchQueryClick = (query: string): void => {
|
||||
|
|
@ -419,6 +417,9 @@ export function AskAiScreen({ translations = {}, ...props }: AskAiScreenProps):
|
|||
{showThreadDepthError && (
|
||||
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error DocSearch-AskAiScreen-Error--ThreadDepth">
|
||||
<div className="DocSearch-AskAiScreen-Error-Content">
|
||||
{threadDepthApiMessage ? (
|
||||
<p className="DocSearch-AskAiScreen-Error-Title">{threadDepthApiMessage}</p>
|
||||
) : null}
|
||||
<p>
|
||||
{threadDepthExceededMessage}{' '}
|
||||
<button type="button" className="DocSearch-ThreadDepthError-Link" onClick={props.onNewConversation}>
|
||||
|
|
|
|||
|
|
@ -398,16 +398,26 @@ export function DocSearchModal({
|
|||
|
||||
const [stoppedStream, setStoppedStream] = React.useState(false);
|
||||
|
||||
const { messages, status, setMessages, sendMessage, stopAskAiStreaming, askAiError, sendFeedback, conversations } =
|
||||
useAskAi({
|
||||
assistantId: askAiConfigurationId,
|
||||
apiKey: askAiConfig?.apiKey || apiKey,
|
||||
appId: askAiConfig?.appId || appId,
|
||||
indexName: askAiConfig?.indexName || defaultIndexName,
|
||||
searchParameters: askAiSearchParameters,
|
||||
useStagingEnv: askAiUseStagingEnv,
|
||||
agentStudio,
|
||||
});
|
||||
const {
|
||||
messages,
|
||||
status,
|
||||
sendMessage,
|
||||
stopAskAiStreaming,
|
||||
askAiError,
|
||||
sendFeedback,
|
||||
conversations,
|
||||
clearError,
|
||||
resetAskAiAbortScope,
|
||||
resetAskAiChatSession,
|
||||
} = useAskAi({
|
||||
assistantId: askAiConfigurationId,
|
||||
apiKey: askAiConfig?.apiKey || apiKey,
|
||||
appId: askAiConfig?.appId || appId,
|
||||
indexName: askAiConfig?.indexName || defaultIndexName,
|
||||
searchParameters: askAiSearchParameters,
|
||||
useStagingEnv: askAiUseStagingEnv,
|
||||
agentStudio,
|
||||
});
|
||||
|
||||
const prevStatus = React.useRef(status);
|
||||
React.useEffect(() => {
|
||||
|
|
@ -423,9 +433,12 @@ export function DocSearchModal({
|
|||
};
|
||||
}
|
||||
|
||||
for (const part of messages[0].parts) {
|
||||
if (part.type === 'text') {
|
||||
conversations.add(buildDummyAskAiHit(part.text, messages));
|
||||
const first = messages[0];
|
||||
if (first?.parts) {
|
||||
for (const part of first.parts) {
|
||||
if (part.type === 'text') {
|
||||
conversations.add(buildDummyAskAiHit(part.text, messages));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -533,6 +546,8 @@ export function DocSearchModal({
|
|||
if (isHybridModeSupported) return;
|
||||
|
||||
setStoppedStream(false);
|
||||
resetAskAiAbortScope();
|
||||
clearError();
|
||||
|
||||
const messageOptions: ChatRequestOptions = {};
|
||||
|
||||
|
|
@ -571,7 +586,16 @@ export function DocSearchModal({
|
|||
autocompleteRef.current.setQuery('');
|
||||
}
|
||||
},
|
||||
[onAskAiToggle, interceptAskAiEvent, sendMessage, askAiState, setAskAiState, isHybridModeSupported],
|
||||
[
|
||||
onAskAiToggle,
|
||||
interceptAskAiEvent,
|
||||
askAiState,
|
||||
setAskAiState,
|
||||
isHybridModeSupported,
|
||||
clearError,
|
||||
resetAskAiAbortScope,
|
||||
sendMessage,
|
||||
],
|
||||
);
|
||||
|
||||
// feedback handler
|
||||
|
|
@ -623,7 +647,10 @@ export function DocSearchModal({
|
|||
},
|
||||
onSelect({ item }): void {
|
||||
if (item.messages) {
|
||||
setMessages(item.messages as any);
|
||||
resetAskAiChatSession({
|
||||
kind: 'setMessages',
|
||||
messages: item.messages as AIMessage[],
|
||||
});
|
||||
onAskAiToggle(true);
|
||||
}
|
||||
},
|
||||
|
|
@ -793,14 +820,18 @@ export function DocSearchModal({
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Refresh the autocomplete results when ask ai is toggled off
|
||||
// helps return to the previous ac state and start screen
|
||||
// Refresh autocomplete and rotate the chat session only when Ask AI is turned off — not on every
|
||||
// mount while inactive. `clearError` from `useChat` can change when `chatSessionId` changes; listing
|
||||
// it (and re-running `resetAskAiChatSession` on that) caused an infinite update loop.
|
||||
const prevIsAskAiActiveRef = React.useRef(isAskAiActive);
|
||||
React.useEffect(() => {
|
||||
if (!isAskAiActive) {
|
||||
if (prevIsAskAiActiveRef.current && !isAskAiActive) {
|
||||
autocomplete.refresh();
|
||||
setMessages([]);
|
||||
clearError();
|
||||
resetAskAiChatSession();
|
||||
}
|
||||
}, [isAskAiActive, autocomplete, setMessages]);
|
||||
prevIsAskAiActiveRef.current = isAskAiActive;
|
||||
}, [isAskAiActive, autocomplete, clearError, resetAskAiChatSession]);
|
||||
|
||||
// Track external state in order to manage internal askAiState
|
||||
React.useEffect(() => {
|
||||
|
|
@ -814,7 +845,8 @@ export function DocSearchModal({
|
|||
};
|
||||
|
||||
const handleNewConversation = (): void => {
|
||||
setMessages([]);
|
||||
clearError();
|
||||
resetAskAiChatSession();
|
||||
setAskAiState('new-conversation');
|
||||
};
|
||||
|
||||
|
|
@ -913,7 +945,10 @@ export function DocSearchModal({
|
|||
if (item.type === 'askAI' && item.query) {
|
||||
// if the item is askAI and the anchor is stored
|
||||
if (item.anchor === 'stored' && 'messages' in item) {
|
||||
setMessages(item.messages as any);
|
||||
resetAskAiChatSession({
|
||||
kind: 'setMessages',
|
||||
messages: item.messages as AIMessage[],
|
||||
});
|
||||
const initialMessage: InitialAskAiMessage = {
|
||||
query: item.query,
|
||||
messageId: (item.messages as StoredAskAiMessage[])[0].id,
|
||||
|
|
|
|||
|
|
@ -115,13 +115,21 @@ function DocSearchSidepanelComp({
|
|||
panel: { portalContainer, ...panelProps } = {},
|
||||
...rootProps
|
||||
}: DocSearchSidepanelProps): JSX.Element {
|
||||
const { docsearchState, setDocsearchState, keyboardShortcuts, registerView, initialAskAiMessage } = useDocSearch();
|
||||
const {
|
||||
docsearchState,
|
||||
setDocsearchState,
|
||||
keyboardShortcuts,
|
||||
registerView,
|
||||
initialAskAiMessage,
|
||||
clearInitialAskAiMessage,
|
||||
} = useDocSearch();
|
||||
|
||||
const toggleSidepanelState = React.useCallback(() => {
|
||||
setDocsearchState(docsearchState === 'sidepanel' ? 'ready' : 'sidepanel');
|
||||
}, [docsearchState, setDocsearchState]);
|
||||
|
||||
const handleClose = (): void => {
|
||||
clearInitialAskAiMessage();
|
||||
setDocsearchState('ready');
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type { StoredSearchPlugin } from '../stored-searches';
|
|||
import { ToolCall, type ToolCallTranslations } from '../ToolCall';
|
||||
import type { StoredAskAiState } from '../types';
|
||||
import { isAIToolPart, type AIMessage } from '../types/AskiAi';
|
||||
import { extractLinksFromMessage, getMessageContent } from '../utils/ai';
|
||||
import { extractLinksFromMessage, getMessageContent, isThreadDepthError } from '../utils/ai';
|
||||
import { groupConsecutiveToolResults } from '../utils/groupConsecutiveToolResults';
|
||||
|
||||
import { AggregatedSearchBlock } from './AggregatedSearchBlock';
|
||||
|
|
@ -60,7 +60,15 @@ export type ConversationScreenTranslations = Partial<
|
|||
/**
|
||||
* Error title shown if there is an error while chatting.
|
||||
*/
|
||||
errorTitleText;
|
||||
errorTitleText: string;
|
||||
/**
|
||||
* Message shown when thread depth limit is exceeded (AI-217).
|
||||
*/
|
||||
threadDepthExceededMessage: string;
|
||||
/**
|
||||
* Button label to start a new conversation after a thread depth error.
|
||||
*/
|
||||
startNewConversationButtonText: string;
|
||||
}
|
||||
>;
|
||||
|
||||
|
|
@ -105,6 +113,8 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
|
|||
errorTitleText = 'Chat error',
|
||||
} = translations;
|
||||
|
||||
const isThreadDepth = isThreadDepthError(streamError);
|
||||
|
||||
const assistantContent = useMemo(() => getMessageContent(assistantMessage), [assistantMessage]);
|
||||
const userContent = useMemo(() => getMessageContent(userMessage), [userMessage]);
|
||||
|
||||
|
|
@ -130,7 +140,7 @@ const ConversationExchange = React.forwardRef<HTMLDivElement, ConversationnExcha
|
|||
</div>
|
||||
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--assistant">
|
||||
<div className="DocSearch-AskAiScreen-MessageContent">
|
||||
{status === 'error' && streamError && isLastExchange && (
|
||||
{status === 'error' && streamError && isLastExchange && !isThreadDepth && (
|
||||
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error">
|
||||
<AlertIcon />
|
||||
<div className="DocSearch-AskAiScreen-Error-Content">
|
||||
|
|
|
|||
|
|
@ -22,8 +22,30 @@ export type PromptFormTranslations = Partial<{
|
|||
* Disclaimer text displayed beneath the prompt form.
|
||||
**/
|
||||
promptDisclaimerText: string;
|
||||
/**
|
||||
* Visually hidden label text (`aria-labelledby`); usually keyboard hints for the textarea.
|
||||
**/
|
||||
promptLabelText: string;
|
||||
/**
|
||||
* Accessible name for the textarea (`aria-label`).
|
||||
**/
|
||||
promptAriaLabelText: string;
|
||||
/**
|
||||
* Placeholder when the conversation hit the thread depth limit (AI-217).
|
||||
**/
|
||||
threadDepthErrorPlaceholder: string;
|
||||
/**
|
||||
* Message shown in the thread-depth banner above the prompt (AI-217).
|
||||
**/
|
||||
threadDepthExceededMessage: string;
|
||||
/**
|
||||
* Button label in the thread-depth banner to start a new conversation.
|
||||
**/
|
||||
startNewConversationButtonText: string;
|
||||
/**
|
||||
* Trailing sentence fragment after the button in the thread-depth banner (e.g. "to continue.").
|
||||
**/
|
||||
threadDepthBannerContinueText: string;
|
||||
}>;
|
||||
|
||||
type Props = {
|
||||
|
|
@ -32,12 +54,27 @@ type Props = {
|
|||
translations?: PromptFormTranslations;
|
||||
onSend: (prompt: string) => void;
|
||||
onStopStreaming: () => void;
|
||||
showThreadDepthBanner: boolean;
|
||||
threadDepthApiMessage?: string;
|
||||
onStartNewConversation: () => void;
|
||||
};
|
||||
|
||||
const MAX_PROMPT_ROWS = 8;
|
||||
|
||||
export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
||||
({ exchanges, isStreaming, translations = {}, onSend, onStopStreaming }, ref): JSX.Element => {
|
||||
(
|
||||
{
|
||||
exchanges,
|
||||
isStreaming,
|
||||
translations = {},
|
||||
onSend,
|
||||
onStopStreaming,
|
||||
showThreadDepthBanner,
|
||||
threadDepthApiMessage,
|
||||
onStartNewConversation,
|
||||
},
|
||||
ref,
|
||||
): JSX.Element => {
|
||||
const isMobile = useIsMobile();
|
||||
const [userPrompt, setUserPrompt] = React.useState('');
|
||||
const promptRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
|
|
@ -51,6 +88,10 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
|||
promptDisclaimerText = 'Answers are generated with AI which can make mistakes.',
|
||||
promptLabelText = 'Press Enter to send, or Shift and Enter for new line.',
|
||||
promptAriaLabelText = 'Prompt input',
|
||||
threadDepthErrorPlaceholder = 'Conversation limit reached',
|
||||
threadDepthExceededMessage = 'This conversation is now closed to keep responses accurate.',
|
||||
startNewConversationButtonText = 'Start a new conversation',
|
||||
threadDepthBannerContinueText = 'to continue.',
|
||||
} = translations;
|
||||
|
||||
const managePromptHeight = (): void => {
|
||||
|
|
@ -74,7 +115,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
|||
};
|
||||
|
||||
const handleSend = (): void => {
|
||||
if (isStreaming) return;
|
||||
if (isStreaming || showThreadDepthBanner) return;
|
||||
|
||||
const prompt = userPrompt.trim();
|
||||
|
||||
|
|
@ -93,7 +134,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
|||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
// Allow Enter to work normally (new line) when streaming
|
||||
if (isStreaming) return;
|
||||
if (isStreaming || showThreadDepthBanner) return;
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
|
@ -108,7 +149,9 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
|||
|
||||
let promptPlaceholder = promptPlaceholderText;
|
||||
|
||||
if (isStreaming) {
|
||||
if (showThreadDepthBanner) {
|
||||
promptPlaceholder = threadDepthErrorPlaceholder;
|
||||
} else if (isStreaming) {
|
||||
promptPlaceholder = promptAnsweringText;
|
||||
} else if (exchanges.length > 0) {
|
||||
promptPlaceholder = promptAskAnotherQuestionText;
|
||||
|
|
@ -116,12 +159,26 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
|||
|
||||
return (
|
||||
<div className="DocSearch-Sidepanel-Prompt">
|
||||
{showThreadDepthBanner ? (
|
||||
<div className="DocSearch-Sidepanel-ThreadDepthBanner">
|
||||
{threadDepthApiMessage ? (
|
||||
<p className="DocSearch-Sidepanel-ThreadDepthBanner-apiMessage">{threadDepthApiMessage}</p>
|
||||
) : null}
|
||||
<p>
|
||||
{threadDepthExceededMessage}{' '}
|
||||
<button type="button" className="DocSearch-ThreadDepthError-Link" onClick={onStartNewConversation}>
|
||||
{startNewConversationButtonText}
|
||||
</button>{' '}
|
||||
{threadDepthBannerContinueText}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<form
|
||||
className="DocSearch-Sidepanel-Prompt--form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isStreaming) return;
|
||||
if (isStreaming || showThreadDepthBanner) return;
|
||||
|
||||
handleSend();
|
||||
}}
|
||||
|
|
@ -136,6 +193,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
|||
autoComplete="off"
|
||||
translate="no"
|
||||
rows={isMobile ? 1 : 2}
|
||||
disabled={showThreadDepthBanner}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={managePromptHeight}
|
||||
onChange={(e) => setUserPrompt(e.target.value)}
|
||||
|
|
@ -154,7 +212,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
|||
<StopIcon />
|
||||
</button>
|
||||
)}
|
||||
{!isStreaming && (
|
||||
{!isStreaming && !showThreadDepthBanner && (
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Send question"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@ import { useAskAi } from '../useAskAi';
|
|||
import { useIsMobile } from '../useIsMobile';
|
||||
import { useSearchClient } from '../useSearchClient';
|
||||
import { useSuggestedQuestions } from '../useSuggestedQuestions';
|
||||
import { buildDummyAskAiHit } from '../utils/ai';
|
||||
import {
|
||||
buildDummyAskAiHit,
|
||||
filterExchangesForThreadDepthError,
|
||||
getThreadDepthErrorUserFacingMessage,
|
||||
isThreadDepthError,
|
||||
} from '../utils/ai';
|
||||
|
||||
import { ConversationHistoryScreen } from './ConversationHistoryScreen';
|
||||
import type { ConversationScreenTranslations } from './ConversationScreen';
|
||||
|
|
@ -182,7 +187,8 @@ function SidepanelInner(
|
|||
stopAskAiStreaming,
|
||||
isStreaming,
|
||||
exchanges,
|
||||
setMessages,
|
||||
clearError,
|
||||
resetAskAiChatSession,
|
||||
conversations,
|
||||
messages,
|
||||
sendFeedback,
|
||||
|
|
@ -203,31 +209,62 @@ function SidepanelInner(
|
|||
searchClient,
|
||||
});
|
||||
|
||||
const hasThreadDepthError = React.useMemo(
|
||||
() => status === 'error' && isThreadDepthError(askAiError),
|
||||
[status, askAiError],
|
||||
);
|
||||
|
||||
const displayExchanges = React.useMemo(
|
||||
() => filterExchangesForThreadDepthError(exchanges, hasThreadDepthError),
|
||||
[exchanges, hasThreadDepthError],
|
||||
);
|
||||
|
||||
const showThreadDepthBanner =
|
||||
sidepanelState === 'conversation' && hasThreadDepthError && messages.some((m) => m.role === 'assistant');
|
||||
|
||||
const threadDepthApiMessage = React.useMemo(() => getThreadDepthErrorUserFacingMessage(askAiError), [askAiError]);
|
||||
|
||||
const promptFormTranslations = React.useMemo(
|
||||
() => ({
|
||||
...translations.promptForm,
|
||||
...(translations.conversationScreen?.threadDepthExceededMessage !== undefined
|
||||
? { threadDepthExceededMessage: translations.conversationScreen.threadDepthExceededMessage }
|
||||
: {}),
|
||||
...(translations.conversationScreen?.startNewConversationButtonText !== undefined
|
||||
? { startNewConversationButtonText: translations.conversationScreen.startNewConversationButtonText }
|
||||
: {}),
|
||||
}),
|
||||
[translations.promptForm, translations.conversationScreen],
|
||||
);
|
||||
|
||||
const prevStatus = React.useRef(status);
|
||||
|
||||
const handleSend = (prompt: string): void => {
|
||||
setStoppedStreaming(false);
|
||||
clearError();
|
||||
|
||||
sendMessage({ text: prompt });
|
||||
setSidepanelState('conversation');
|
||||
};
|
||||
|
||||
const handleStartNewConversation = (): void => {
|
||||
setMessages([]);
|
||||
clearError();
|
||||
resetAskAiChatSession();
|
||||
setSidepanelState('new-conversation');
|
||||
};
|
||||
|
||||
const handleSelectQuestion = (question: SuggestedQuestionHit): void => {
|
||||
setStoppedStreaming(false);
|
||||
setMessages([]);
|
||||
sendMessage(
|
||||
{ text: question.question },
|
||||
{
|
||||
clearError();
|
||||
resetAskAiChatSession({
|
||||
kind: 'sendText',
|
||||
text: question.question,
|
||||
requestOptions: {
|
||||
body: {
|
||||
suggestedQuestionId: question.objectID,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
setSidepanelState('conversation');
|
||||
};
|
||||
|
||||
|
|
@ -238,15 +275,16 @@ function SidepanelInner(
|
|||
|
||||
const handleSelectConversation = React.useCallback(
|
||||
(conversation: StoredAskAiState): void => {
|
||||
clearError();
|
||||
if (conversation.messages) {
|
||||
setMessages(conversation.messages);
|
||||
resetAskAiChatSession({ kind: 'setMessages', messages: conversation.messages });
|
||||
} else if (conversation.query) {
|
||||
sendMessage({ text: conversation.query });
|
||||
resetAskAiChatSession({ kind: 'sendText', text: conversation.query });
|
||||
}
|
||||
|
||||
setSidepanelState('conversation');
|
||||
},
|
||||
[sendMessage, setMessages],
|
||||
[clearError, resetAskAiChatSession],
|
||||
);
|
||||
|
||||
useManageSidepanelLayout({
|
||||
|
|
@ -288,9 +326,12 @@ function SidepanelInner(
|
|||
};
|
||||
}
|
||||
|
||||
for (const part of messages[0].parts) {
|
||||
if (part.type === 'text') {
|
||||
conversations.add(buildDummyAskAiHit(part.text, messages));
|
||||
const first = messages[0];
|
||||
if (first?.parts) {
|
||||
for (const part of first.parts) {
|
||||
if (part.type === 'text') {
|
||||
conversations.add(buildDummyAskAiHit(part.text, messages));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -315,34 +356,57 @@ function SidepanelInner(
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Only re-run when `initialMessage` changes. Other handlers (e.g. `sendMessage`) can change every
|
||||
// render; listing them here was re-firing the effect and repeatedly clearing the chat.
|
||||
const initialMessageHandlingRef = React.useRef({
|
||||
clearError,
|
||||
resetAskAiChatSession,
|
||||
conversations,
|
||||
handleSelectConversation,
|
||||
});
|
||||
initialMessageHandlingRef.current = {
|
||||
clearError,
|
||||
resetAskAiChatSession,
|
||||
conversations,
|
||||
handleSelectConversation,
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!initialMessage) return;
|
||||
|
||||
const {
|
||||
clearError: clr,
|
||||
resetAskAiChatSession: resetSession,
|
||||
conversations: convs,
|
||||
handleSelectConversation: selectConv,
|
||||
} = initialMessageHandlingRef.current;
|
||||
|
||||
let selectedConversation: StoredAskAiState | undefined;
|
||||
|
||||
if (initialMessage.messageId) {
|
||||
selectedConversation = conversations.getConversation?.(initialMessage.messageId);
|
||||
selectedConversation = convs.getConversation?.(initialMessage.messageId);
|
||||
}
|
||||
|
||||
if (selectedConversation) {
|
||||
handleSelectConversation(selectedConversation);
|
||||
selectConv(selectedConversation);
|
||||
} else {
|
||||
setMessages([]);
|
||||
sendMessage(
|
||||
{
|
||||
text: initialMessage.query,
|
||||
},
|
||||
clr();
|
||||
resetSession(
|
||||
initialMessage.suggestedQuestionId
|
||||
? {
|
||||
body: {
|
||||
suggestedQuestionId: initialMessage.suggestedQuestionId,
|
||||
kind: 'sendText',
|
||||
text: initialMessage.query,
|
||||
requestOptions: {
|
||||
body: {
|
||||
suggestedQuestionId: initialMessage.suggestedQuestionId,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {},
|
||||
: { kind: 'sendText', text: initialMessage.query },
|
||||
);
|
||||
setSidepanelState('conversation');
|
||||
}
|
||||
}, [initialMessage, sendMessage, conversations, handleSelectConversation, setMessages]);
|
||||
}, [initialMessage]);
|
||||
|
||||
// Autofocus the prompt input when the sidepanel opens and blur it when
|
||||
// it closes. Disabled on mobile because focusing the textarea triggers the
|
||||
|
|
@ -373,7 +437,7 @@ function SidepanelInner(
|
|||
<aside id="docsearch-sidepanel" className={`DocSearch-Sidepanel ${sidepanelState}`}>
|
||||
<SidepanelHeader
|
||||
sidepanelState={sidepanelState}
|
||||
exchanges={exchanges}
|
||||
exchanges={displayExchanges}
|
||||
setSidepanelState={setSidepanelState}
|
||||
hasConversations={conversations.getAll().length > 0}
|
||||
isStreaming={isStreaming}
|
||||
|
|
@ -391,7 +455,7 @@ function SidepanelInner(
|
|||
)}
|
||||
{sidepanelState === 'conversation' && (
|
||||
<ConversationScreen
|
||||
exchanges={exchanges}
|
||||
exchanges={displayExchanges}
|
||||
status={status}
|
||||
conversations={conversations}
|
||||
handleFeedback={sendFeedback}
|
||||
|
|
@ -406,10 +470,13 @@ function SidepanelInner(
|
|||
</div>
|
||||
<PromptForm
|
||||
ref={promptInputRef}
|
||||
exchanges={exchanges}
|
||||
exchanges={displayExchanges}
|
||||
isStreaming={isStreaming}
|
||||
translations={translations.promptForm}
|
||||
showThreadDepthBanner={showThreadDepthBanner}
|
||||
threadDepthApiMessage={threadDepthApiMessage}
|
||||
translations={promptFormTranslations}
|
||||
onSend={handleSend}
|
||||
onStartNewConversation={handleStartNewConversation}
|
||||
onStopStreaming={handleStopStreaming}
|
||||
/>
|
||||
<footer className="DocSearch-Sidepanel-Footer">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { render } from '@testing-library/react';
|
||||
import { render, within } from '@testing-library/react';
|
||||
import type { UIMessage } from 'ai';
|
||||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
|
@ -42,4 +42,44 @@ describe('AskAiScreen', () => {
|
|||
|
||||
expect(getByText('oh no')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces thread depth (AI-217) with a banner and hides the generic chat error', () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: '1',
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'first' }],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text: 'answer' }],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'follow-up' }],
|
||||
},
|
||||
];
|
||||
|
||||
const onNewConversation = (): void => {};
|
||||
|
||||
const { container, getByText } = render(
|
||||
<AskAiScreen
|
||||
{...baseProps}
|
||||
messages={messages}
|
||||
status="error"
|
||||
askAiError={new Error('AI-217 - Thread depth exceeded')}
|
||||
onNewConversation={onNewConversation}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByText('This conversation is now closed to keep responses accurate.', { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(getByText('Start a new conversation')).toBeInTheDocument();
|
||||
// Bound queries from `render()` use `baseElement` (often `document.body`), so a prior test can still
|
||||
// match. Restrict to this instance's root so we only assert on this tree.
|
||||
expect(within(container).queryByText('Chat error')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
export {
|
||||
filterExchangesForThreadDepthError,
|
||||
getThreadDepthErrorUserFacingMessage,
|
||||
isThreadDepthError,
|
||||
} from './utils/ai';
|
||||
|
||||
export * from './DocSearch';
|
||||
export * from './DocSearchButton';
|
||||
export * from './DocSearchModal';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import type { UseChatHelpers } from '@ai-sdk/react';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { ChatRequestOptions } from 'ai';
|
||||
import { DefaultChatTransport, generateId, lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
|
||||
import {
|
||||
agentStudioBaseUrl,
|
||||
|
|
@ -20,6 +22,21 @@ import type { AgentStudioSearchParameters, AskAiSearchParameters, StoredAskAiSta
|
|||
|
||||
type UseChat = UseChatHelpers<AIMessage>;
|
||||
|
||||
/**
|
||||
* After rotating `useChat` `id`, run this on the **new** chat (see `resetAskAiChatSession`).
|
||||
*/
|
||||
export type AskAiPendingAfterSessionReset =
|
||||
| {
|
||||
kind: 'sendUserMessage';
|
||||
message: {
|
||||
role: 'user';
|
||||
parts: Array<{ type: 'text'; text: string }>;
|
||||
};
|
||||
requestOptions?: ChatRequestOptions;
|
||||
}
|
||||
| { kind: 'sendText'; text: string; requestOptions?: ChatRequestOptions }
|
||||
| { kind: 'setMessages'; messages: AIMessage[] };
|
||||
|
||||
type UseAskAiParams = {
|
||||
assistantId?: string | null;
|
||||
apiKey: string;
|
||||
|
|
@ -44,6 +61,9 @@ type UseAskAiReturn = {
|
|||
status: UseChat['status'];
|
||||
sendMessage: UseChat['sendMessage'];
|
||||
setMessages: UseChat['setMessages'];
|
||||
clearError: UseChat['clearError'];
|
||||
resetAskAiAbortScope: () => void;
|
||||
resetAskAiChatSession: (pending?: AskAiPendingAfterSessionReset) => void;
|
||||
stopAskAiStreaming: UseChat['stop'];
|
||||
askAiError?: Error;
|
||||
isStreaming: boolean;
|
||||
|
|
@ -80,10 +100,10 @@ const getAskAiTransport = ({
|
|||
indexName,
|
||||
searchParameters,
|
||||
appId,
|
||||
abortController,
|
||||
abortControllerRef,
|
||||
useStagingEnv,
|
||||
}: Pick<UseAskAiParams, 'apiKey' | 'appId' | 'assistantId' | 'indexName' | 'searchParameters' | 'useStagingEnv'> & {
|
||||
abortController: AbortController;
|
||||
abortControllerRef: MutableRefObject<AbortController>;
|
||||
}): DefaultChatTransport<AIMessage> => {
|
||||
return new DefaultChatTransport({
|
||||
api: useStagingEnv ? BETA_ASK_AI_API_URL : ASK_AI_API_URL,
|
||||
|
|
@ -94,7 +114,7 @@ const getAskAiTransport = ({
|
|||
|
||||
const token = await getValidToken({
|
||||
assistantId,
|
||||
abortSignal: abortController.signal,
|
||||
abortSignal: abortControllerRef.current.signal,
|
||||
useStagingEnv,
|
||||
});
|
||||
|
||||
|
|
@ -113,6 +133,10 @@ const getAskAiTransport = ({
|
|||
|
||||
export const useAskAi: UseAskAi = ({ assistantId, apiKey, appId, indexName, useStagingEnv = false, ...params }) => {
|
||||
const abortControllerRef = useRef(new AbortController());
|
||||
const [chatSessionId, setChatSessionId] = useState(() => generateId());
|
||||
const pendingAfterChatSessionResetRef = useRef<AskAiPendingAfterSessionReset | null>(null);
|
||||
const sendMessageRef = useRef<UseChat['sendMessage'] | null>(null);
|
||||
const setMessagesRef = useRef<UseChat['setMessages'] | null>(null);
|
||||
|
||||
const askAiTransport = useMemo(
|
||||
() =>
|
||||
|
|
@ -129,16 +153,25 @@ export const useAskAi: UseAskAi = ({ assistantId, apiKey, appId, indexName, useS
|
|||
appId,
|
||||
indexName,
|
||||
searchParameters: params.searchParameters,
|
||||
abortController: abortControllerRef.current,
|
||||
abortControllerRef,
|
||||
useStagingEnv,
|
||||
}),
|
||||
[apiKey, appId, assistantId, indexName, useStagingEnv, params],
|
||||
);
|
||||
|
||||
const { messages, sendMessage, status, setMessages, error, stop } = useChat({
|
||||
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
||||
transport: askAiTransport,
|
||||
});
|
||||
const chatOptions = useMemo(
|
||||
() => ({
|
||||
id: chatSessionId,
|
||||
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
||||
transport: askAiTransport,
|
||||
}),
|
||||
[chatSessionId, askAiTransport],
|
||||
);
|
||||
|
||||
const { messages, sendMessage, status, setMessages, error, stop, clearError } = useChat(chatOptions);
|
||||
|
||||
sendMessageRef.current = sendMessage;
|
||||
setMessagesRef.current = setMessages;
|
||||
|
||||
const conversations = useRef(
|
||||
createStoredConversations<StoredAskAiState>({
|
||||
|
|
@ -175,9 +208,48 @@ export const useAskAi: UseAskAi = ({ assistantId, apiKey, appId, indexName, useS
|
|||
[assistantId, params.agentStudio, appId, apiKey, useStagingEnv, conversations],
|
||||
);
|
||||
|
||||
const resetAskAiAbortScope = useCallback((): void => {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = new AbortController();
|
||||
}, []);
|
||||
|
||||
const resetAskAiChatSession = useCallback(
|
||||
(pending?: AskAiPendingAfterSessionReset): void => {
|
||||
resetAskAiAbortScope();
|
||||
pendingAfterChatSessionResetRef.current = pending ?? null;
|
||||
setChatSessionId(generateId());
|
||||
},
|
||||
[resetAskAiAbortScope],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = pendingAfterChatSessionResetRef.current;
|
||||
if (pending === null) return;
|
||||
|
||||
const send = sendMessageRef.current;
|
||||
const setMsgs = setMessagesRef.current;
|
||||
|
||||
if (pending.kind === 'sendText') {
|
||||
if (!send) return;
|
||||
pendingAfterChatSessionResetRef.current = null;
|
||||
send({ text: pending.text }, pending.requestOptions ?? {});
|
||||
return;
|
||||
}
|
||||
if (pending.kind === 'sendUserMessage') {
|
||||
if (!send) return;
|
||||
pendingAfterChatSessionResetRef.current = null;
|
||||
send(pending.message, pending.requestOptions ?? {});
|
||||
return;
|
||||
}
|
||||
if (!setMsgs) return;
|
||||
pendingAfterChatSessionResetRef.current = null;
|
||||
setMsgs(pending.messages);
|
||||
}, [chatSessionId]);
|
||||
|
||||
const onStopStreaming = async (): Promise<void> => {
|
||||
abortControllerRef.current.abort();
|
||||
await stop();
|
||||
abortControllerRef.current = new AbortController();
|
||||
};
|
||||
|
||||
const exchanges = useMemo(() => {
|
||||
|
|
@ -212,6 +284,9 @@ export const useAskAi: UseAskAi = ({ assistantId, apiKey, appId, indexName, useS
|
|||
sendMessage,
|
||||
status,
|
||||
setMessages,
|
||||
clearError,
|
||||
resetAskAiAbortScope,
|
||||
resetAskAiChatSession,
|
||||
askAiError,
|
||||
stopAskAiStreaming: onStopStreaming,
|
||||
isStreaming,
|
||||
|
|
|
|||
71
packages/docsearch-react/src/utils/__tests__/ai.test.ts
Normal file
71
packages/docsearch-react/src/utils/__tests__/ai.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { AIMessage } from '../../types/AskiAi';
|
||||
import { filterExchangesForThreadDepthError, getThreadDepthErrorUserFacingMessage, isThreadDepthError } from '../ai';
|
||||
|
||||
describe('isThreadDepthError', () => {
|
||||
it('detects AI-217 in message (any case)', () => {
|
||||
expect(isThreadDepthError(new Error('ai-217: limit'))).toBe(true);
|
||||
expect(isThreadDepthError(new Error('prefix AI-217 suffix'))).toBe(true);
|
||||
});
|
||||
|
||||
it('detects thread depth phrasing without code', () => {
|
||||
expect(isThreadDepthError(new Error('Thread depth exceeded'))).toBe(true);
|
||||
expect(isThreadDepthError(new Error('thread depth limit reached'))).toBe(true);
|
||||
});
|
||||
|
||||
it('detects AI-217 in JSON-shaped error bodies', () => {
|
||||
expect(isThreadDepthError(new Error(JSON.stringify({ code: 'AI-217', message: 'Too deep' })))).toBe(true);
|
||||
expect(isThreadDepthError(new Error(JSON.stringify({ errorCode: 'ai-217' })))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for unrelated errors', () => {
|
||||
expect(isThreadDepthError(undefined)).toBe(false);
|
||||
expect(isThreadDepthError(new Error('Network failed'))).toBe(false);
|
||||
expect(isThreadDepthError(new Error(JSON.stringify({ code: 'AI-214' })))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterExchangesForThreadDepthError', () => {
|
||||
const userOnly = (id: string): { id: string; userMessage: AIMessage; assistantMessage: null } => ({
|
||||
id,
|
||||
userMessage: { id: `${id}-u`, role: 'user', parts: [{ type: 'text', text: 'q' }] },
|
||||
assistantMessage: null,
|
||||
});
|
||||
|
||||
const withAssistant = (id: string): { id: string; userMessage: AIMessage; assistantMessage: AIMessage } => ({
|
||||
id,
|
||||
userMessage: { id: `${id}-u`, role: 'user', parts: [{ type: 'text', text: 'q' }] },
|
||||
assistantMessage: { id: `${id}-a`, role: 'assistant', parts: [{ type: 'text', text: 'a' }] },
|
||||
});
|
||||
|
||||
it('keeps trailing user-only exchange when thread depth failed so the last prompt stays visible', () => {
|
||||
const exchanges = [withAssistant('1'), userOnly('2')];
|
||||
expect(filterExchangesForThreadDepthError(exchanges, true)).toEqual(exchanges);
|
||||
});
|
||||
|
||||
it('leaves exchanges unchanged when not a thread depth error', () => {
|
||||
const exchanges = [withAssistant('1'), userOnly('2')];
|
||||
expect(filterExchangesForThreadDepthError(exchanges, false)).toEqual(exchanges);
|
||||
});
|
||||
|
||||
it('does not remove exchanges when an assistant reply exists', () => {
|
||||
const exchanges = [withAssistant('1'), withAssistant('2')];
|
||||
expect(filterExchangesForThreadDepthError(exchanges, true)).toEqual(exchanges);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThreadDepthErrorUserFacingMessage', () => {
|
||||
it('returns nested message from JSON-shaped thread depth errors', () => {
|
||||
const body = JSON.stringify({
|
||||
message: 'Conversation has reached its maximum thread depth of 3 messages. Please start a new conversation.',
|
||||
});
|
||||
expect(getThreadDepthErrorUserFacingMessage(new Error(body))).toBe(
|
||||
'Conversation has reached its maximum thread depth of 3 messages. Please start a new conversation.',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined when not a thread depth error', () => {
|
||||
expect(getThreadDepthErrorUserFacingMessage(new Error('Network failed'))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -99,11 +99,74 @@ export const buildDummyAskAiHit = (query: string, messages: AIMessage[]): Stored
|
|||
export const getMessageContent = (message: AIMessage | null): TextUIPart | undefined =>
|
||||
message?.parts.find((part) => part.type === 'text');
|
||||
|
||||
type ExchangeWithOptionalAssistant = {
|
||||
assistantMessage: AIMessage | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to check if error is a thread depth error (AI-217).
|
||||
* Pass-through: keep all exchanges when thread depth fails so the last user message stays visible
|
||||
* (there is often no assistant reply for that turn).
|
||||
*/
|
||||
export function filterExchangesForThreadDepthError<T extends ExchangeWithOptionalAssistant>(
|
||||
exchanges: T[],
|
||||
_hasThreadDepthError: boolean,
|
||||
): T[] {
|
||||
return exchanges;
|
||||
}
|
||||
|
||||
function threadDepthFromPlainText(message: string): boolean {
|
||||
if (!message) return false;
|
||||
if (message.toUpperCase().includes('AI-217')) return true;
|
||||
return /thread\s+depth/i.test(message);
|
||||
}
|
||||
|
||||
function messageLooksLikeThreadDepth(message: string): boolean {
|
||||
if (threadDepthFromPlainText(message)) return true;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(message) as {
|
||||
code?: string;
|
||||
errorCode?: string;
|
||||
message?: string;
|
||||
};
|
||||
const code = parsed.code ?? parsed.errorCode;
|
||||
if (typeof code === 'string' && code.toUpperCase() === 'AI-217') {
|
||||
return true;
|
||||
}
|
||||
const nested = typeof parsed.message === 'string' ? parsed.message : '';
|
||||
return threadDepthFromPlainText(nested);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the error is thread depth exceeded (AI-217), including JSON-shaped Agent Studio payloads.
|
||||
*/
|
||||
export function isThreadDepthError(error?: Error): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
return error.message?.includes('AI-217') || false;
|
||||
return messageLooksLikeThreadDepth(error.message ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the API `message` field when the error body is JSON; otherwise the thrown message string.
|
||||
* Only meaningful when {@link isThreadDepthError} is true.
|
||||
*/
|
||||
export function getThreadDepthErrorUserFacingMessage(error?: Error): string | undefined {
|
||||
if (!error || !isThreadDepthError(error)) return undefined;
|
||||
|
||||
const raw = error.message ?? '';
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { message?: string };
|
||||
if (typeof parsed.message === 'string' && parsed.message.trim() !== '') {
|
||||
return parsed.message.trim();
|
||||
}
|
||||
} catch {
|
||||
// not JSON — fall through
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
return trimmed !== '' ? trimmed : undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,15 +13,23 @@ export type SidepanelProps = DocSearchSidepanelProps['panel'] &
|
|||
SidepanelSearchParameters;
|
||||
|
||||
export function Sidepanel({ portalContainer, ...props }: SidepanelProps): JSX.Element {
|
||||
const { docsearchState, setDocsearchState, keyboardShortcuts, registerView, initialAskAiMessage } = useDocSearch();
|
||||
const {
|
||||
docsearchState,
|
||||
setDocsearchState,
|
||||
keyboardShortcuts,
|
||||
registerView,
|
||||
initialAskAiMessage,
|
||||
clearInitialAskAiMessage,
|
||||
} = useDocSearch();
|
||||
|
||||
const handleOpen = React.useCallback((): void => {
|
||||
setDocsearchState('sidepanel');
|
||||
}, [setDocsearchState]);
|
||||
|
||||
const handleClose = React.useCallback((): void => {
|
||||
clearInitialAskAiMessage();
|
||||
setDocsearchState('ready');
|
||||
}, [setDocsearchState]);
|
||||
}, [setDocsearchState, clearInitialAskAiMessage]);
|
||||
|
||||
const containerElement = React.useMemo(() => portalContainer ?? document.body, [portalContainer]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { cleanup } from '@testing-library/react';
|
||||
import type { Mock } from 'vitest';
|
||||
import { vi } from 'vitest';
|
||||
import { afterEach, vi } from 'vitest';
|
||||
|
||||
// Vitest does not expose a global `afterEach`, so @testing-library/react never registers its
|
||||
// automatic cleanup (see RTL dist/index.js). Without this, DOM from earlier tests leaks.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
type MatchMediaProps = Partial<{
|
||||
matches: boolean;
|
||||
|
|
|
|||
21
yarn.lock
21
yarn.lock
|
|
@ -2493,6 +2493,7 @@ __metadata:
|
|||
"@rollup/plugin-terser": "npm:0.4.4"
|
||||
"@stylistic/eslint-plugin": "npm:2.13.0"
|
||||
"@testing-library/dom": "npm:10.4.0"
|
||||
"@testing-library/react": "npm:^16.3.2"
|
||||
"@types/react": "npm:^19.0.0"
|
||||
"@types/react-dom": "npm:^19.0.0"
|
||||
"@typescript-eslint/eslint-plugin": "npm:8.20.0"
|
||||
|
|
@ -7175,6 +7176,26 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@testing-library/react@npm:^16.3.2":
|
||||
version: 16.3.2
|
||||
resolution: "@testing-library/react@npm:16.3.2"
|
||||
dependencies:
|
||||
"@babel/runtime": "npm:^7.12.5"
|
||||
peerDependencies:
|
||||
"@testing-library/dom": ^10.0.0
|
||||
"@types/react": ^18.0.0 || ^19.0.0
|
||||
"@types/react-dom": ^18.0.0 || ^19.0.0
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
"@types/react":
|
||||
optional: true
|
||||
"@types/react-dom":
|
||||
optional: true
|
||||
checksum: 10c0/f9c7f0915e1b5f7b750e6c7d8b51f091b8ae7ea99bacb761d7b8505ba25de9cfcb749a0f779f1650fb268b499dd79165dc7e1ee0b8b4cb63430d3ddc81ffe044
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tootallnate/once@npm:2":
|
||||
version: 2.0.0
|
||||
resolution: "@tootallnate/once@npm:2.0.0"
|
||||
|
|
|
|||
Loading…
Reference in a new issue