feat(v5): Back port cost control errors (#2965)
This commit is contained in:
parent
8c84c4d823
commit
ee9fddb152
17 changed files with 810 additions and 97 deletions
6
.changeset/bright-errors-block.md
Normal file
6
.changeset/bright-errors-block.md
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
'@docsearch/css': patch
|
||||||
|
'@docsearch/react': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Surface Agent Studio cost-control errors and block prompts until the user can recover. [#2878](https://github.com/algolia/docsearch/pull/2878)
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "packages/docsearch-css/dist/style.css",
|
"path": "packages/docsearch-css/dist/style.css",
|
||||||
"maxSize": "8.1 kB"
|
"maxSize": "8.2 kB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "packages/docsearch-react/dist/umd/index.js",
|
"path": "packages/docsearch-react/dist/umd/index.js",
|
||||||
|
|
|
||||||
|
|
@ -537,10 +537,15 @@
|
||||||
color: var(--docsearch-text-color);
|
color: var(--docsearch-text-color);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
border: 1px solid var(--docsearch-error-soft-color);
|
border: 1px solid var(--docsearch-error-soft-color);
|
||||||
animation: slide-down 0.3s ease-out;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.DocSearch-AskAiScreen-Error--ThreadDepth {
|
||||||
|
animation: slide-down 0.3s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes slide-down {
|
@keyframes slide-down {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
@ -572,6 +577,11 @@
|
||||||
color: rgb(153 27 27);
|
color: rgb(153 27 27);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.DocSearch-ThreadDepthError-Link:focus-visible {
|
||||||
|
outline: 2px solid var(--docsearch-focus-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.DocSearch-AskAiScreen-FeedbackText {
|
.DocSearch-AskAiScreen-FeedbackText {
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,28 @@
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.DocSearch-Sidepanel-PromptBlockingBanner {
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: var(--docsearch-sidepanel-text-base);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid var(--docsearch-error-soft-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--docsearch-sidepanel-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.DocSearch-Sidepanel-PromptBlockingBanner p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.DocSearch-Sidepanel-PromptBlockingBanner p + p {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.DocSearch-Sidepanel-PromptBlockingBanner .DocSearch-ThreadDepthError-Link:focus-visible {
|
||||||
|
outline: 2px solid var(--docsearch-sidepanel-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.DocSearch-Sidepanel-Prompt--form:focus-within {
|
.DocSearch-Sidepanel-Prompt--form:focus-within {
|
||||||
border-color: var(--docsearch-sidepanel-primary);
|
border-color: var(--docsearch-sidepanel-primary);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,11 @@ import type {
|
||||||
import { type AIMessage, type ToolCalls } from './types/AskiAi';
|
import { type AIMessage, type ToolCalls } from './types/AskiAi';
|
||||||
import {
|
import {
|
||||||
extractLinksFromMessage,
|
extractLinksFromMessage,
|
||||||
|
getAskAiBlockingBannerMessage,
|
||||||
getMessageContent,
|
getMessageContent,
|
||||||
|
isAskAiPromptBlockingError,
|
||||||
isThreadDepthError,
|
isThreadDepthError,
|
||||||
|
showAskAiBlockingBannerNewConversationLink,
|
||||||
isAIToolPart,
|
isAIToolPart,
|
||||||
getAgentPromptSuggestions,
|
getAgentPromptSuggestions,
|
||||||
} from './utils/ai';
|
} from './utils/ai';
|
||||||
|
|
@ -199,7 +202,7 @@ function AskAiExchangeCard({
|
||||||
[translations]
|
[translations]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isThreadDepth = isThreadDepthError(askAiError);
|
const isPromptBlockingError = isAskAiPromptBlockingError(askAiError);
|
||||||
|
|
||||||
const assistantContent = useMemo(
|
const assistantContent = useMemo(
|
||||||
() => getMessageContent(assistantMessage),
|
() => getMessageContent(assistantMessage),
|
||||||
|
|
@ -252,7 +255,7 @@ function AskAiExchangeCard({
|
||||||
{loadingStatus === 'error' &&
|
{loadingStatus === 'error' &&
|
||||||
askAiError &&
|
askAiError &&
|
||||||
isLastExchange &&
|
isLastExchange &&
|
||||||
!isThreadDepth && (
|
!isPromptBlockingError && (
|
||||||
<div className="DocSearch-AskAiScreen-Error" role="alert">
|
<div className="DocSearch-AskAiScreen-Error" role="alert">
|
||||||
<AlertIcon aria-hidden="true" />
|
<AlertIcon aria-hidden="true" />
|
||||||
<div className="DocSearch-AskAiScreen-Error-Content">
|
<div className="DocSearch-AskAiScreen-Error-Content">
|
||||||
|
|
@ -398,10 +401,15 @@ export function AskAiScreen({
|
||||||
|
|
||||||
const { messages, tools, askAiError, status, memoryEnabled } = props;
|
const { messages, tools, askAiError, status, memoryEnabled } = props;
|
||||||
|
|
||||||
// Check if there's a thread depth error
|
const hasPromptBlockingError = useMemo(() => {
|
||||||
const hasThreadDepthError = useMemo(() => {
|
return status === 'error' && isAskAiPromptBlockingError(askAiError);
|
||||||
return status === 'error' && isThreadDepthError(askAiError);
|
|
||||||
}, [status, askAiError]);
|
}, [status, askAiError]);
|
||||||
|
const blockingMessage = useMemo(
|
||||||
|
() => getAskAiBlockingBannerMessage(askAiError),
|
||||||
|
[askAiError]
|
||||||
|
);
|
||||||
|
const showNewConversationLink =
|
||||||
|
showAskAiBlockingBannerNewConversationLink(askAiError);
|
||||||
|
|
||||||
// Group messages into exchanges (user + assistant pairs)
|
// Group messages into exchanges (user + assistant pairs)
|
||||||
const exchanges: Exchange[] = useMemo(() => {
|
const exchanges: Exchange[] = useMemo(() => {
|
||||||
|
|
@ -418,48 +426,47 @@ export function AskAiScreen({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 grouped;
|
||||||
}, [messages, hasThreadDepthError]);
|
}, [messages]);
|
||||||
|
|
||||||
const handleSearchQueryClick = (query: string): void => {
|
const handleSearchQueryClick = (query: string): void => {
|
||||||
props.onAskAiToggle(false);
|
props.onAskAiToggle(false);
|
||||||
props.setQuery(query);
|
props.setQuery(query);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only show the thread depth error if we have assistant messages
|
const showBlockingBanner =
|
||||||
const showThreadDepthError =
|
hasPromptBlockingError &&
|
||||||
hasThreadDepthError && messages.some((m) => m.role === 'assistant');
|
(!isThreadDepthError(askAiError) ||
|
||||||
|
messages.some((message) => message.role === 'assistant'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="DocSearch-AskAiScreen DocSearch-AskAiScreen-Container">
|
<div className="DocSearch-AskAiScreen DocSearch-AskAiScreen-Container">
|
||||||
{/* Thread Depth Error */}
|
<div id={props.promptBlockingErrorId} role="alert">
|
||||||
{showThreadDepthError && (
|
{showBlockingBanner && (
|
||||||
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error DocSearch-AskAiScreen-Error--ThreadDepth">
|
<div className="DocSearch-AskAiScreen-MessageContent DocSearch-AskAiScreen-Error DocSearch-AskAiScreen-Error--ThreadDepth">
|
||||||
<div className="DocSearch-AskAiScreen-Error-Content">
|
<div className="DocSearch-AskAiScreen-Error-Content">
|
||||||
<p>
|
<p className="DocSearch-AskAiScreen-Error-Title">
|
||||||
{threadDepthExceededMessage}{' '}
|
{blockingMessage ??
|
||||||
<button
|
(isThreadDepthError(askAiError)
|
||||||
type="button"
|
? threadDepthExceededMessage
|
||||||
className="DocSearch-ThreadDepthError-Link"
|
: 'This conversation cannot continue.')}
|
||||||
onClick={props.onNewConversation}
|
</p>
|
||||||
>
|
{showNewConversationLink && (
|
||||||
{startNewConversationButtonText}
|
<p>
|
||||||
</button>{' '}
|
<button
|
||||||
to continue.
|
type="button"
|
||||||
</p>
|
className="DocSearch-ThreadDepthError-Link"
|
||||||
|
onClick={props.onNewConversation}
|
||||||
|
>
|
||||||
|
{startNewConversationButtonText}
|
||||||
|
</button>{' '}
|
||||||
|
to continue.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<AskAiScreenDisclaimer disclaimerText={disclaimerText} />
|
<AskAiScreenDisclaimer disclaimerText={disclaimerText} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ export interface AskAiScreenStateProps<
|
||||||
onNewConversation: () => void;
|
onNewConversation: () => void;
|
||||||
memoryEnabled?: boolean;
|
memoryEnabled?: boolean;
|
||||||
resultBadgeKey?: string;
|
resultBadgeKey?: string;
|
||||||
|
promptBlockingErrorId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AskAiScreenState = React.memo(
|
export const AskAiScreenState = React.memo(
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ import {
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import {
|
import {
|
||||||
buildDummyAskAiHit,
|
buildDummyAskAiHit,
|
||||||
|
isAgentStudioTokenOutputLimitError,
|
||||||
|
isAskAiPromptBlockingError,
|
||||||
isThreadDepthError,
|
isThreadDepthError,
|
||||||
EMPTY_TOOLS,
|
EMPTY_TOOLS,
|
||||||
} from './utils/ai';
|
} from './utils/ai';
|
||||||
|
|
@ -145,6 +147,7 @@ export function DocSearchAskAiModal({
|
||||||
|
|
||||||
const { containerRef, modalRef, formElementRef, dropdownRef, inputRef } =
|
const { containerRef, modalRef, formElementRef, dropdownRef, inputRef } =
|
||||||
useModalRefs();
|
useModalRefs();
|
||||||
|
const promptBlockingErrorId = React.useId();
|
||||||
const { initialQuery, initialQueryFromSelection } =
|
const { initialQuery, initialQueryFromSelection } =
|
||||||
useInitialModalQuery(initialQueryFromProp);
|
useInitialModalQuery(initialQueryFromProp);
|
||||||
|
|
||||||
|
|
@ -258,12 +261,30 @@ export function DocSearchAskAiModal({
|
||||||
chatId,
|
chatId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check if there's a thread depth error (AI-217)
|
const hasPromptBlockingError = React.useMemo(() => {
|
||||||
const hasThreadDepthError = React.useMemo(() => {
|
|
||||||
return (
|
return (
|
||||||
status === 'error' && isThreadDepthError(askAiError as Error | undefined)
|
status === 'error' &&
|
||||||
|
isAskAiPromptBlockingError(askAiError as Error | undefined)
|
||||||
);
|
);
|
||||||
}, [status, askAiError]);
|
}, [status, askAiError]);
|
||||||
|
const shouldBlockPrompt =
|
||||||
|
hasPromptBlockingError &&
|
||||||
|
(!isThreadDepthError(askAiError) ||
|
||||||
|
messages.some((message) => message.role === 'assistant'));
|
||||||
|
|
||||||
|
const promptBlockingChrome = React.useMemo(() => {
|
||||||
|
if (
|
||||||
|
!shouldBlockPrompt ||
|
||||||
|
askAiState === 'new-conversation' ||
|
||||||
|
askAiState === 'conversation-history'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAgentStudioTokenOutputLimitError(askAiError)
|
||||||
|
? ('minimal' as const)
|
||||||
|
: ('full' as const);
|
||||||
|
}, [askAiError, askAiState, shouldBlockPrompt]);
|
||||||
|
|
||||||
const saveRecentSearch = useSaveRecentSearch({
|
const saveRecentSearch = useSaveRecentSearch({
|
||||||
favoriteSearches,
|
favoriteSearches,
|
||||||
|
|
@ -550,9 +571,8 @@ export function DocSearchAskAiModal({
|
||||||
askAiError={askAiError}
|
askAiError={askAiError}
|
||||||
askAiState={askAiState}
|
askAiState={askAiState}
|
||||||
setAskAiState={setAskAiState}
|
setAskAiState={setAskAiState}
|
||||||
isThreadDepthError={
|
promptBlockingChrome={promptBlockingChrome}
|
||||||
hasThreadDepthError && askAiState !== 'new-conversation'
|
promptBlockingErrorId={promptBlockingErrorId}
|
||||||
}
|
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onAskAiToggle={onAskAiToggle}
|
onAskAiToggle={onAskAiToggle}
|
||||||
onAskAgain={(query) => {
|
onAskAgain={(query) => {
|
||||||
|
|
@ -601,6 +621,7 @@ export function DocSearchAskAiModal({
|
||||||
selectSuggestedQuestion={selectSuggestedQuestion}
|
selectSuggestedQuestion={selectSuggestedQuestion}
|
||||||
memoryEnabled={memoryEnabled}
|
memoryEnabled={memoryEnabled}
|
||||||
resultBadgeKey={props.resultBadgeKey}
|
resultBadgeKey={props.resultBadgeKey}
|
||||||
|
promptBlockingErrorId={promptBlockingErrorId}
|
||||||
onAskAiToggle={onAskAiToggle}
|
onAskAiToggle={onAskAiToggle}
|
||||||
onNewConversation={handleNewConversation}
|
onNewConversation={handleNewConversation}
|
||||||
onItemClick={(item, event) => {
|
onItemClick={(item, event) => {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
EMPTY_TOOLS,
|
EMPTY_TOOLS,
|
||||||
isAIToolPart,
|
isAIToolPart,
|
||||||
getAgentPromptSuggestions,
|
getAgentPromptSuggestions,
|
||||||
|
isAskAiPromptBlockingError,
|
||||||
} from '../utils/ai';
|
} from '../utils/ai';
|
||||||
import { groupConsecutiveToolResults } from '../utils/groupConsecutiveToolResults';
|
import { groupConsecutiveToolResults } from '../utils/groupConsecutiveToolResults';
|
||||||
|
|
||||||
|
|
@ -178,22 +179,25 @@ const ConversationExchange = React.forwardRef<
|
||||||
</div>
|
</div>
|
||||||
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--assistant">
|
<div className="DocSearch-AskAiScreen-Message DocSearch-AskAiScreen-Message--assistant">
|
||||||
<div className="DocSearch-AskAiScreen-MessageContent">
|
<div className="DocSearch-AskAiScreen-MessageContent">
|
||||||
{status === 'error' && streamError && isLastExchange && (
|
{status === 'error' &&
|
||||||
<div className="DocSearch-AskAiScreen-Error" role="alert">
|
streamError &&
|
||||||
<AlertIcon aria-hidden="true" />
|
isLastExchange &&
|
||||||
<div className="DocSearch-AskAiScreen-Error-Content">
|
!isAskAiPromptBlockingError(streamError) && (
|
||||||
<h4 className="DocSearch-AskAiScreen-Error-Title">
|
<div className="DocSearch-AskAiScreen-Error" role="alert">
|
||||||
{errorTitleText}
|
<AlertIcon aria-hidden="true" />
|
||||||
</h4>
|
<div className="DocSearch-AskAiScreen-Error-Content">
|
||||||
<MemoizedMarkdown
|
<h4 className="DocSearch-AskAiScreen-Error-Title">
|
||||||
content={streamError.message}
|
{errorTitleText}
|
||||||
copyButtonText=""
|
</h4>
|
||||||
copyButtonCopiedText=""
|
<MemoizedMarkdown
|
||||||
isStreaming={false}
|
content={streamError.message}
|
||||||
/>
|
copyButtonText=""
|
||||||
|
copyButtonCopiedText=""
|
||||||
|
isStreaming={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{assistantParts.map((part, idx) => {
|
{assistantParts.map((part, idx) => {
|
||||||
const index = idx;
|
const index = idx;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ export type PromptFormTranslations = Partial<{
|
||||||
promptDisclaimerText: string;
|
promptDisclaimerText: string;
|
||||||
promptLabelText: string;
|
promptLabelText: string;
|
||||||
promptAriaLabelText: string;
|
promptAriaLabelText: string;
|
||||||
|
startNewConversationButtonText: string;
|
||||||
|
blockingErrorContinueText: string;
|
||||||
|
blockingErrorFallbackText: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -24,18 +27,33 @@ type Props = {
|
||||||
translations?: PromptFormTranslations;
|
translations?: PromptFormTranslations;
|
||||||
onSend: (prompt: string) => void;
|
onSend: (prompt: string) => void;
|
||||||
onStopStreaming: () => void;
|
onStopStreaming: () => void;
|
||||||
|
blockingErrorMessage?: string;
|
||||||
|
showBlockingError?: boolean;
|
||||||
|
showNewConversationLink?: boolean;
|
||||||
|
onStartNewConversation: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_PROMPT_ROWS = 8;
|
const MAX_PROMPT_ROWS = 8;
|
||||||
|
|
||||||
export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
||||||
(
|
(
|
||||||
{ exchanges, isStreaming, translations = {}, onSend, onStopStreaming },
|
{
|
||||||
|
exchanges,
|
||||||
|
isStreaming,
|
||||||
|
translations = {},
|
||||||
|
onSend,
|
||||||
|
onStopStreaming,
|
||||||
|
blockingErrorMessage,
|
||||||
|
showBlockingError = false,
|
||||||
|
showNewConversationLink = true,
|
||||||
|
onStartNewConversation,
|
||||||
|
},
|
||||||
ref
|
ref
|
||||||
): JSX.Element => {
|
): JSX.Element => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [userPrompt, setUserPrompt] = React.useState('');
|
const [userPrompt, setUserPrompt] = React.useState('');
|
||||||
const promptRef = React.useRef<HTMLTextAreaElement>(null);
|
const promptRef = React.useRef<HTMLTextAreaElement>(null);
|
||||||
|
const blockingErrorId = React.useId();
|
||||||
|
|
||||||
React.useImperativeHandle(
|
React.useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
|
|
@ -49,6 +67,9 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
||||||
promptDisclaimerText = 'Answers are generated with AI which can make mistakes.',
|
promptDisclaimerText = 'Answers are generated with AI which can make mistakes.',
|
||||||
promptLabelText = 'Press Enter to send, or Shift and Enter for new line.',
|
promptLabelText = 'Press Enter to send, or Shift and Enter for new line.',
|
||||||
promptAriaLabelText = 'Prompt input',
|
promptAriaLabelText = 'Prompt input',
|
||||||
|
startNewConversationButtonText = 'Start a new conversation',
|
||||||
|
blockingErrorContinueText = 'to continue.',
|
||||||
|
blockingErrorFallbackText = 'This conversation cannot continue.',
|
||||||
} = translations;
|
} = translations;
|
||||||
|
|
||||||
const managePromptHeight = (): void => {
|
const managePromptHeight = (): void => {
|
||||||
|
|
@ -72,7 +93,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSend = (): void => {
|
const handleSend = (): void => {
|
||||||
if (isStreaming) return;
|
if (isStreaming || showBlockingError) return;
|
||||||
|
|
||||||
const prompt = userPrompt.trim();
|
const prompt = userPrompt.trim();
|
||||||
|
|
||||||
|
|
@ -93,7 +114,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
||||||
e: React.KeyboardEvent<HTMLTextAreaElement>
|
e: React.KeyboardEvent<HTMLTextAreaElement>
|
||||||
): void => {
|
): void => {
|
||||||
// Allow Enter to work normally (new line) when streaming
|
// Allow Enter to work normally (new line) when streaming
|
||||||
if (isStreaming) return;
|
if (isStreaming || showBlockingError) return;
|
||||||
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -116,12 +137,31 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="DocSearch-Sidepanel-Prompt">
|
<div className="DocSearch-Sidepanel-Prompt">
|
||||||
|
<div id={blockingErrorId} role="alert">
|
||||||
|
{showBlockingError && (
|
||||||
|
<div className="DocSearch-Sidepanel-PromptBlockingBanner">
|
||||||
|
<p>{blockingErrorMessage ?? blockingErrorFallbackText}</p>
|
||||||
|
{showNewConversationLink && (
|
||||||
|
<p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="DocSearch-ThreadDepthError-Link"
|
||||||
|
onClick={onStartNewConversation}
|
||||||
|
>
|
||||||
|
{startNewConversationButtonText}
|
||||||
|
</button>{' '}
|
||||||
|
{blockingErrorContinueText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<form
|
<form
|
||||||
className="DocSearch-Sidepanel-Prompt--form"
|
className="DocSearch-Sidepanel-Prompt--form"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (isStreaming) return;
|
if (isStreaming || showBlockingError) return;
|
||||||
|
|
||||||
handleSend();
|
handleSend();
|
||||||
}}
|
}}
|
||||||
|
|
@ -133,6 +173,9 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
||||||
value={userPrompt}
|
value={userPrompt}
|
||||||
aria-label={promptAriaLabelText}
|
aria-label={promptAriaLabelText}
|
||||||
aria-labelledby="prompt-label"
|
aria-labelledby="prompt-label"
|
||||||
|
aria-describedby={showBlockingError ? blockingErrorId : undefined}
|
||||||
|
aria-disabled={showBlockingError}
|
||||||
|
readOnly={showBlockingError}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
translate="no"
|
translate="no"
|
||||||
rows={isMobile ? 1 : 2}
|
rows={isMobile ? 1 : 2}
|
||||||
|
|
@ -161,6 +204,7 @@ export const PromptForm = React.forwardRef<HTMLTextAreaElement, Props>(
|
||||||
title="Send question"
|
title="Send question"
|
||||||
className="DocSearch-Sidepanel-Prompt--submit"
|
className="DocSearch-Sidepanel-Prompt--submit"
|
||||||
aria-disabled={userPrompt === ''}
|
aria-disabled={userPrompt === ''}
|
||||||
|
disabled={showBlockingError}
|
||||||
>
|
>
|
||||||
<SendIcon />
|
<SendIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,14 @@ import { useAskAi } from '../useAskAi';
|
||||||
import { useIsMobile } from '../useIsMobile';
|
import { useIsMobile } from '../useIsMobile';
|
||||||
import { useSearchClient } from '../useSearchClient';
|
import { useSearchClient } from '../useSearchClient';
|
||||||
import { useSuggestedQuestions } from '../useSuggestedQuestions';
|
import { useSuggestedQuestions } from '../useSuggestedQuestions';
|
||||||
import { EMPTY_TOOLS, buildDummyAskAiHit } from '../utils/ai';
|
import {
|
||||||
|
EMPTY_TOOLS,
|
||||||
|
buildDummyAskAiHit,
|
||||||
|
getAskAiBlockingBannerMessage,
|
||||||
|
isAskAiPromptBlockingError,
|
||||||
|
isThreadDepthError,
|
||||||
|
showAskAiBlockingBannerNewConversationLink,
|
||||||
|
} from '../utils/ai';
|
||||||
|
|
||||||
import { ConversationHistoryScreen } from './ConversationHistoryScreen';
|
import { ConversationHistoryScreen } from './ConversationHistoryScreen';
|
||||||
import type { ConversationScreenTranslations } from './ConversationScreen';
|
import type { ConversationScreenTranslations } from './ConversationScreen';
|
||||||
|
|
@ -208,6 +215,12 @@ function SidepanelInner(
|
||||||
});
|
});
|
||||||
|
|
||||||
const prevStatus = React.useRef(status);
|
const prevStatus = React.useRef(status);
|
||||||
|
const showPromptBlockingError =
|
||||||
|
sidepanelState === 'conversation' &&
|
||||||
|
status === 'error' &&
|
||||||
|
isAskAiPromptBlockingError(askAiError) &&
|
||||||
|
(!isThreadDepthError(askAiError) ||
|
||||||
|
messages.some((message) => message.role === 'assistant'));
|
||||||
|
|
||||||
const handleSend = React.useCallback(
|
const handleSend = React.useCallback(
|
||||||
(prompt: string): void => {
|
(prompt: string): void => {
|
||||||
|
|
@ -434,6 +447,12 @@ function SidepanelInner(
|
||||||
translations={translations.promptForm}
|
translations={translations.promptForm}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onStopStreaming={handleStopStreaming}
|
onStopStreaming={handleStopStreaming}
|
||||||
|
blockingErrorMessage={getAskAiBlockingBannerMessage(askAiError)}
|
||||||
|
showBlockingError={showPromptBlockingError}
|
||||||
|
showNewConversationLink={showAskAiBlockingBannerNewConversationLink(
|
||||||
|
askAiError
|
||||||
|
)}
|
||||||
|
onStartNewConversation={handleStartNewConversation}
|
||||||
/>
|
/>
|
||||||
<footer className="DocSearch-Sidepanel-Footer">
|
<footer className="DocSearch-Sidepanel-Footer">
|
||||||
<span className="DocSearch-Logo DocSearch-Sidepanel--powered-by">
|
<span className="DocSearch-Logo DocSearch-Sidepanel--powered-by">
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||||
import type { UIMessage } from 'ai';
|
import type { UIMessage } from 'ai';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
import { AskAiScreen } from '../AskAiScreen';
|
import { AskAiScreen } from '../AskAiScreen';
|
||||||
|
import { PromptForm } from '../Sidepanel/PromptForm';
|
||||||
|
|
||||||
const baseProps = {
|
const baseProps = {
|
||||||
indexName: 'idx',
|
indexName: 'idx',
|
||||||
|
|
@ -90,4 +91,126 @@ describe('AskAiScreen', () => {
|
||||||
'Let me look that up.\n\nDocusaurus is a static site generator.'
|
'Let me look that up.\n\nDocusaurus is a static site generator.'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows cost-control errors in a blocking banner', () => {
|
||||||
|
const messages: UIMessage[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ type: 'text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const onNewConversation = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<AskAiScreen
|
||||||
|
{...baseProps}
|
||||||
|
messages={messages}
|
||||||
|
status="error"
|
||||||
|
askAiError={new Error('Too many requests (AI-205)')}
|
||||||
|
onNewConversation={onNewConversation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
within(container).getByText('Too many requests')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(within(container).queryByText('Chat error')).not.toBeInTheDocument();
|
||||||
|
expect(within(container).getByText('hello')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
within(container).getByRole('button', {
|
||||||
|
name: 'Start a new conversation',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(onNewConversation).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer a new conversation for token output errors', () => {
|
||||||
|
const messages: UIMessage[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ type: 'text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const error = new Error(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Could not complete response due to token output limits',
|
||||||
|
type: 'TokenOutputLimitError',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<AskAiScreen
|
||||||
|
{...baseProps}
|
||||||
|
messages={messages}
|
||||||
|
status="error"
|
||||||
|
askAiError={error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
within(container).getByText(
|
||||||
|
'Could not complete response due to token output limits'
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(container).queryByRole('button', {
|
||||||
|
name: 'Start a new conversation',
|
||||||
|
})
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sidepanel PromptForm', () => {
|
||||||
|
it('shows a blocking error and recovery action', () => {
|
||||||
|
const onStartNewConversation = vi.fn();
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<PromptForm
|
||||||
|
exchanges={[]}
|
||||||
|
isStreaming={false}
|
||||||
|
blockingErrorMessage="Rate limit exceeded"
|
||||||
|
showBlockingError={true}
|
||||||
|
onSend={vi.fn()}
|
||||||
|
onStopStreaming={vi.fn()}
|
||||||
|
onStartNewConversation={onStartNewConversation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(within(container).getByRole('alert')).toHaveTextContent(
|
||||||
|
'Rate limit exceeded'
|
||||||
|
);
|
||||||
|
expect(within(container).getByRole('textbox')).toHaveAttribute('readonly');
|
||||||
|
expect(within(container).getByRole('textbox')).toHaveAttribute(
|
||||||
|
'aria-disabled',
|
||||||
|
'true'
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
within(container).getByRole('button', {
|
||||||
|
name: 'Start a new conversation',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(onStartNewConversation).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not submit a draft while blocked', () => {
|
||||||
|
const onSend = vi.fn();
|
||||||
|
const props = {
|
||||||
|
exchanges: [],
|
||||||
|
isStreaming: false,
|
||||||
|
onSend,
|
||||||
|
onStopStreaming: vi.fn(),
|
||||||
|
onStartNewConversation: vi.fn(),
|
||||||
|
};
|
||||||
|
const { container, rerender } = render(<PromptForm {...props} />);
|
||||||
|
const input = within(container).getByRole('textbox');
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: 'draft question' } });
|
||||||
|
rerender(<PromptForm {...props} showBlockingError={true} />);
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(onSend).not.toHaveBeenCalled();
|
||||||
|
expect(input).toHaveValue('draft question');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -249,19 +249,38 @@ describe('AskAiSearchBox', () => {
|
||||||
expect(onAskAgain).not.toHaveBeenCalled();
|
expect(onAskAgain).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses thread-depth behavior in Ask AI mode', () => {
|
it('uses prompt-blocking behavior in Ask AI mode', () => {
|
||||||
const onNewConversation = vi.fn();
|
const onNewConversation = vi.fn();
|
||||||
|
const onAskAgain = vi.fn();
|
||||||
|
|
||||||
renderAskAiSearchBox({ isThreadDepthError: true, onNewConversation });
|
renderAskAiSearchBox({
|
||||||
|
promptBlockingChrome: 'full',
|
||||||
|
promptBlockingErrorId: 'blocking-error',
|
||||||
|
onNewConversation,
|
||||||
|
onAskAgain,
|
||||||
|
});
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText('Conversation limit reached');
|
const input = screen.getByPlaceholderText('Conversation limit reached');
|
||||||
|
|
||||||
expect(input).toBeDisabled();
|
expect(input).toHaveAttribute('readonly');
|
||||||
|
expect(input).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
expect(input).toHaveAttribute('aria-describedby', 'blocking-error');
|
||||||
|
expect(fireEvent.keyDown(input, { key: 'Tab' })).toBe(true);
|
||||||
|
expect(fireEvent.keyDown(input, { key: 'Enter' })).toBe(false);
|
||||||
|
expect(onAskAgain).not.toHaveBeenCalled();
|
||||||
|
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole('button', { name: 'Back to keyword search' })
|
screen.getAllByRole('button', { name: 'Start a new conversation' })[0]
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(onNewConversation).toHaveBeenCalledTimes(1);
|
expect(onNewConversation).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the regular placeholder with minimal blocking chrome', () => {
|
||||||
|
renderAskAiSearchBox({ promptBlockingChrome: 'minimal' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText('Ask another question...')
|
||||||
|
).toHaveAttribute('readonly');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { readStringField } from './utils/askAiBlockingMatchers';
|
||||||
|
|
||||||
export const agentStudioBaseUrl = (appId: string): string =>
|
export const agentStudioBaseUrl = (appId: string): string =>
|
||||||
`https://${appId}.algolia.net/agent-studio/1`;
|
`https://${appId}.algolia.net/agent-studio/1`;
|
||||||
|
|
||||||
|
|
@ -8,26 +10,64 @@ interface AgentStudioValidationError extends Error {
|
||||||
|
|
||||||
// Parse Agent Studio errors as they are returned as JSON rather than Markdown/text
|
// Parse Agent Studio errors as they are returned as JSON rather than Markdown/text
|
||||||
export const getAgentStudioErrorMessage = (error: Error): Error => {
|
export const getAgentStudioErrorMessage = (error: Error): Error => {
|
||||||
let errorMessage = error.message;
|
const raw = error.message;
|
||||||
|
let parsed: unknown;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsedError = JSON.parse(error.message) as Error;
|
parsed = JSON.parse(raw);
|
||||||
|
|
||||||
// Check for known errors that we know how to parse
|
|
||||||
if (parsedError.name === 'ValidationError') {
|
|
||||||
const validationError = parsedError as AgentStudioValidationError;
|
|
||||||
|
|
||||||
if (validationError.detail && validationError.detail.length > 0) {
|
|
||||||
const { msg, loc } = validationError.detail[0];
|
|
||||||
const field = loc.at(-1);
|
|
||||||
|
|
||||||
errorMessage = `${msg}: ${field}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
errorMessage = parsedError.message;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// We don't care about this catch, we default to the error.message above
|
return new Error(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
let iterations = 0;
|
||||||
|
|
||||||
|
while (typeof parsed === 'string' && iterations < 10) {
|
||||||
|
iterations += 1;
|
||||||
|
const serializedError = parsed.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(serializedError);
|
||||||
|
} catch {
|
||||||
|
return new Error(serializedError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
return new Error(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedRecord = parsed as Record<string, unknown>;
|
||||||
|
const parsedError = parsed as Error & {
|
||||||
|
code?: string;
|
||||||
|
errorCode?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
let errorMessage = raw;
|
||||||
|
|
||||||
|
if (parsedError.name === 'ValidationError') {
|
||||||
|
const validationError = parsedError as AgentStudioValidationError;
|
||||||
|
|
||||||
|
if (validationError.detail && validationError.detail.length > 0) {
|
||||||
|
const { msg, loc } = validationError.detail[0];
|
||||||
|
const field = loc.at(-1);
|
||||||
|
|
||||||
|
errorMessage = `${msg}: ${field}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorMessage =
|
||||||
|
readStringField(parsedRecord, 'message') ??
|
||||||
|
readStringField(parsedRecord, 'error') ??
|
||||||
|
raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = parsedError.code ?? parsedError.errorCode;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof code === 'string' &&
|
||||||
|
code.trim() &&
|
||||||
|
!errorMessage.toUpperCase().includes(code.trim().toUpperCase())
|
||||||
|
) {
|
||||||
|
errorMessage = `${errorMessage} (${code.trim()})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Error(errorMessage);
|
return new Error(errorMessage);
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,8 @@ interface AskAiSearchBoxProps extends AutocompleteApi<
|
||||||
setAskAiState: (state: AskAiState) => void;
|
setAskAiState: (state: AskAiState) => void;
|
||||||
onNewConversation: () => void;
|
onNewConversation: () => void;
|
||||||
onViewConversationHistory: () => void;
|
onViewConversationHistory: () => void;
|
||||||
isThreadDepthError?: boolean;
|
promptBlockingChrome?: 'minimal' | 'full';
|
||||||
|
promptBlockingErrorId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AskAiSearchBox({
|
export function AskAiSearchBox({
|
||||||
|
|
@ -111,16 +112,14 @@ export function AskAiSearchBox({
|
||||||
const renderMoreOptions =
|
const renderMoreOptions =
|
||||||
props.isAskAiActive && askAiState !== 'conversation-history';
|
props.isAskAiActive && askAiState !== 'conversation-history';
|
||||||
|
|
||||||
// Use the thread depth error state passed from parent
|
const isPromptBlocked = Boolean(props.promptBlockingChrome);
|
||||||
const isThreadDepthError = props.isThreadDepthError || false;
|
|
||||||
let searchPlaceholder = props.placeholder;
|
let searchPlaceholder = props.placeholder;
|
||||||
|
|
||||||
if (askAiState === 'new-conversation') {
|
if (askAiState === 'new-conversation') {
|
||||||
searchPlaceholder = newConversationPlaceholder;
|
searchPlaceholder = newConversationPlaceholder;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override placeholder when thread depth error occurs (only in Ask AI mode)
|
if (props.promptBlockingChrome === 'full' && props.isAskAiActive) {
|
||||||
if (isThreadDepthError && props.isAskAiActive) {
|
|
||||||
searchPlaceholder = threadDepthErrorPlaceholder;
|
searchPlaceholder = threadDepthErrorPlaceholder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,6 +157,12 @@ export function AskAiSearchBox({
|
||||||
? ('enter' as const)
|
? ('enter' as const)
|
||||||
: ('search' as const),
|
: ('search' as const),
|
||||||
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||||
|
if (isPromptBlocked && props.isAskAiActive && blockedKeys.has(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let askAiHandled = false;
|
let askAiHandled = false;
|
||||||
// block these up, down, enter listeners when Ask AI is active
|
// block these up, down, enter listeners when Ask AI is active
|
||||||
if (props.isAskAiActive && blockedKeys.has(e.key)) {
|
if (props.isAskAiActive && blockedKeys.has(e.key)) {
|
||||||
|
|
@ -182,6 +187,11 @@ export function AskAiSearchBox({
|
||||||
origOnKeyDown?.(e);
|
origOnKeyDown?.(e);
|
||||||
},
|
},
|
||||||
onChange: (e: React.ChangeEvent<HTMLInputElement>): void => {
|
onChange: (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
if (isPromptBlocked && props.isAskAiActive) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (props.isAskAiActive) {
|
if (props.isAskAiActive) {
|
||||||
props.setQuery(e.currentTarget.value);
|
props.setQuery(e.currentTarget.value);
|
||||||
// block search when Ask AI is active
|
// block search when Ask AI is active
|
||||||
|
|
@ -193,12 +203,17 @@ export function AskAiSearchBox({
|
||||||
}
|
}
|
||||||
origOnChange?.(e);
|
origOnChange?.(e);
|
||||||
},
|
},
|
||||||
disabled: isAskAiStreaming || (isThreadDepthError && props.isAskAiActive),
|
disabled: isAskAiStreaming,
|
||||||
|
readOnly: isPromptBlocked && props.isAskAiActive,
|
||||||
|
'aria-disabled': isPromptBlocked && props.isAskAiActive,
|
||||||
|
'aria-describedby':
|
||||||
|
isPromptBlocked && props.isAskAiActive
|
||||||
|
? props.promptBlockingErrorId
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAskAiBackClick = React.useCallback((): void => {
|
const handleAskAiBackClick = React.useCallback((): void => {
|
||||||
// If there's a thread depth error, start a new conversation instead of exiting
|
if (isPromptBlocked) {
|
||||||
if (isThreadDepthError) {
|
|
||||||
props.onNewConversation();
|
props.onNewConversation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -210,15 +225,23 @@ export function AskAiSearchBox({
|
||||||
}
|
}
|
||||||
|
|
||||||
onAskAiToggle(false);
|
onAskAiToggle(false);
|
||||||
}, [askAiState, isThreadDepthError, onAskAiToggle, setAskAiState, props]);
|
}, [askAiState, isPromptBlocked, onAskAiToggle, setAskAiState, props]);
|
||||||
|
|
||||||
const leadingElement = props.isAskAiActive ? (
|
const leadingElement = props.isAskAiActive ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="DocSearch-Action DocSearch-AskAi-Return"
|
className="DocSearch-Action DocSearch-AskAi-Return"
|
||||||
title={backToKeywordSearchButtonText}
|
title={
|
||||||
aria-label={backToKeywordSearchButtonAriaLabel}
|
isPromptBlocked
|
||||||
|
? startNewConversationText
|
||||||
|
: backToKeywordSearchButtonText
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
isPromptBlocked
|
||||||
|
? startNewConversationText
|
||||||
|
: backToKeywordSearchButtonAriaLabel
|
||||||
|
}
|
||||||
onClick={handleAskAiBackClick}
|
onClick={handleAskAiBackClick}
|
||||||
>
|
>
|
||||||
<BackIcon />
|
<BackIcon />
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import { getAgentStudioErrorMessage } from '../../askai';
|
||||||
import type {
|
import type {
|
||||||
AIMessage,
|
AIMessage,
|
||||||
AIMessagePart,
|
AIMessagePart,
|
||||||
SearchToolPart,
|
SearchToolPart,
|
||||||
} from '../../types/AskiAi';
|
} from '../../types/AskiAi';
|
||||||
import {
|
import {
|
||||||
|
getAskAiBlockingBannerMessage,
|
||||||
getAgentPromptSuggestions,
|
getAgentPromptSuggestions,
|
||||||
getSearchToolQueries,
|
getSearchToolQueries,
|
||||||
isAIToolPart,
|
isAIToolPart,
|
||||||
isAlgoliaMCPSearchOutputPart,
|
isAlgoliaMCPSearchOutputPart,
|
||||||
isThreadDepthError,
|
isThreadDepthError,
|
||||||
|
isAskAiPromptBlockingError,
|
||||||
|
showAskAiBlockingBannerNewConversationLink,
|
||||||
sanitizeMessagesForRequest,
|
sanitizeMessagesForRequest,
|
||||||
getMessageContent,
|
getMessageContent,
|
||||||
} from '../ai';
|
} from '../ai';
|
||||||
|
|
@ -51,6 +55,101 @@ describe('isThreadDepthError', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Agent Studio prompt-blocking errors', () => {
|
||||||
|
it.each(['AI-203', 'AI-205', 'AI-224', 'AI-225'])(
|
||||||
|
'blocks error code %s',
|
||||||
|
(code) => {
|
||||||
|
expect(isAskAiPromptBlockingError(new Error(`Failed (${code})`))).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'Rate limit exceeded',
|
||||||
|
'Domain is not whitelisted',
|
||||||
|
'Maximum token limit reached',
|
||||||
|
'Maximum agent steps exceeded',
|
||||||
|
])('blocks matching message: %s', (errorMessage) => {
|
||||||
|
expect(isAskAiPromptBlockingError(new Error(errorMessage))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not block unrelated errors', () => {
|
||||||
|
expect(isAskAiPromptBlockingError(new Error('Network failed'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides recovery when a new conversation cannot resolve the error', () => {
|
||||||
|
expect(
|
||||||
|
showAskAiBlockingBannerNewConversationLink(
|
||||||
|
new Error('Request blocked for this domain')
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
showAskAiBlockingBannerNewConversationLink(
|
||||||
|
new Error('Could not complete response due to token output limits')
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
showAskAiBlockingBannerNewConversationLink(
|
||||||
|
new Error('Rate limit exceeded')
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the human message from JSON errors', () => {
|
||||||
|
const error = new Error(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'TOO_MANY_REQUESTS',
|
||||||
|
message: 'Rate limit exceeded. Retry after 60 seconds.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getAskAiBlockingBannerMessage(error)).toBe(
|
||||||
|
'Rate limit exceeded. Retry after 60 seconds.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches retry-after messages and case-insensitive JSON fields', () => {
|
||||||
|
const error = new Error(
|
||||||
|
JSON.stringify({ Message: 'Please retry after 60 seconds' })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isAskAiPromptBlockingError(error)).toBe(true);
|
||||||
|
expect(getAskAiBlockingBannerMessage(error)).toBe(
|
||||||
|
'Please retry after 60 seconds'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides a fallback for code-only conversation limits', () => {
|
||||||
|
const error = new Error(JSON.stringify({ code: 'AI-217' }));
|
||||||
|
|
||||||
|
expect(getAskAiBlockingBannerMessage(error)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes nested JSON while preserving the error code', () => {
|
||||||
|
const error = getAgentStudioErrorMessage(
|
||||||
|
new Error(
|
||||||
|
JSON.stringify(
|
||||||
|
JSON.stringify({ message: 'Too many requests', code: 'AI-205' })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.message).toBe('Too many requests (AI-205)');
|
||||||
|
expect(isAskAiPromptBlockingError(error)).toBe(true);
|
||||||
|
expect(getAskAiBlockingBannerMessage(error)).toBe('Too many requests');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a fallback for type-only token output errors', () => {
|
||||||
|
const error = new Error(JSON.stringify({ type: 'TokenOutputLimitError' }));
|
||||||
|
|
||||||
|
expect(isAskAiPromptBlockingError(error)).toBe(true);
|
||||||
|
expect(getAskAiBlockingBannerMessage(error)).toBe(
|
||||||
|
'Could not complete response due to token output limits'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function message(id: string, parts: AIMessagePart[]): AIMessage {
|
function message(id: string, parts: AIMessagePart[]): AIMessage {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ import type {
|
||||||
ToolCalls,
|
ToolCalls,
|
||||||
} from '../types/AskiAi';
|
} from '../types/AskiAi';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isTokenOutputLimitError,
|
||||||
|
readStringField,
|
||||||
|
resolvePromptBlockingError,
|
||||||
|
} from './askAiBlockingMatchers';
|
||||||
import { sanitizeUrl, sanitizeUserInput } from './sanitize';
|
import { sanitizeUrl, sanitizeUserInput } from './sanitize';
|
||||||
|
|
||||||
export interface ExtractedLink {
|
export interface ExtractedLink {
|
||||||
|
|
@ -129,6 +134,106 @@ export function isThreadDepthError(error?: Error): boolean {
|
||||||
return /(?:ai-217|conversation\s+depth)/i.test(error.message ?? '');
|
return /(?:ai-217|conversation\s+depth)/i.test(error.message ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAskAiPromptBlockingError(error?: Error): boolean {
|
||||||
|
return Boolean(
|
||||||
|
error &&
|
||||||
|
(isThreadDepthError(error) || resolvePromptBlockingError(error).blocking)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAgentStudioTokenOutputLimitError(error?: Error): boolean {
|
||||||
|
return isTokenOutputLimitError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showAskAiBlockingBannerNewConversationLink(
|
||||||
|
error?: Error
|
||||||
|
): boolean {
|
||||||
|
if (!error || isThreadDepthError(error)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvePromptBlockingError(error).showNewConversationLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAgentStudioErrorFieldMessage(raw: string): string | undefined {
|
||||||
|
let value = raw.trim();
|
||||||
|
|
||||||
|
for (let iteration = 0; iteration < 10 && value; iteration++) {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(value);
|
||||||
|
|
||||||
|
if (typeof parsed === 'string') {
|
||||||
|
value = parsed.trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
const record = parsed as Record<string, unknown>;
|
||||||
|
const message = readStringField(record, 'message');
|
||||||
|
const error = readStringField(record, 'error');
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldMatch =
|
||||||
|
/"message"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(value) ??
|
||||||
|
/"error"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(value);
|
||||||
|
|
||||||
|
return fieldMatch?.[1]
|
||||||
|
?.replace(/\\"/g, '"')
|
||||||
|
.replace(/\\\\/g, '\\')
|
||||||
|
.trim();
|
||||||
|
} catch {
|
||||||
|
if (value.includes('\\"')) {
|
||||||
|
value = value.replace(/\\"/g, '"').replace(/\\\\/g, '\\').trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAskAiBlockingBannerMessage(
|
||||||
|
error?: Error
|
||||||
|
): string | undefined {
|
||||||
|
if (!error) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extracted = extractAgentStudioErrorFieldMessage(error.message ?? '');
|
||||||
|
const isCodeOnlyThreadDepthError =
|
||||||
|
isThreadDepthError(error) &&
|
||||||
|
(/^\s*AI-217\s*$/i.test(error.message) ||
|
||||||
|
(/^\s*\{.*\}\s*$/.test(error.message) && !extracted));
|
||||||
|
|
||||||
|
if (isCodeOnlyThreadDepthError) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = (
|
||||||
|
extracted ?? error.message.replace(/\s*\(AI-\d{3}\)\s*$/i, '')
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
if (message && !(message.startsWith('{') && message.endsWith('}'))) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAgentStudioTokenOutputLimitError(error)) {
|
||||||
|
return 'Could not complete response due to token output limits';
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export const EMPTY_TOOLS: Readonly<ToolCalls> = Object.freeze({});
|
export const EMPTY_TOOLS: Readonly<ToolCalls> = Object.freeze({});
|
||||||
|
|
||||||
export function isAIToolPart(
|
export function isAIToolPart(
|
||||||
|
|
|
||||||
170
packages/docsearch-react/src/utils/askAiBlockingMatchers.ts
Normal file
170
packages/docsearch-react/src/utils/askAiBlockingMatchers.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
interface BlockingMatchContext {
|
||||||
|
message: string;
|
||||||
|
messageLower: string;
|
||||||
|
parsedJson: Record<string, unknown> | null;
|
||||||
|
code: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BlockingMatcher {
|
||||||
|
matches: (context: BlockingMatchContext) => boolean;
|
||||||
|
showNewConversationLink?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROMPT_BLOCKING_CODES = new Set(['AI-203', 'AI-205', 'AI-224', 'AI-225']);
|
||||||
|
|
||||||
|
export function readStringField(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
key: string
|
||||||
|
): string | undefined {
|
||||||
|
for (const [field, fieldValue] of Object.entries(value)) {
|
||||||
|
if (
|
||||||
|
field.toLowerCase() === key.toLowerCase() &&
|
||||||
|
typeof fieldValue === 'string' &&
|
||||||
|
fieldValue.trim() !== ''
|
||||||
|
) {
|
||||||
|
return fieldValue.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorCode(message: string): string | undefined {
|
||||||
|
const directMatch = /\b(AI-\d{3})\b/i.exec(message);
|
||||||
|
|
||||||
|
if (directMatch) {
|
||||||
|
return directMatch[1].toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(message: string): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(message);
|
||||||
|
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Plain-text errors are handled by the other matchers.
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesDomainBlock(message: string): boolean {
|
||||||
|
return (
|
||||||
|
/\brequest blocked for this domain\b/.test(message) ||
|
||||||
|
/\bblocked for this domain\b/.test(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesRateLimit(message: string): boolean {
|
||||||
|
return (
|
||||||
|
/\b429\b/.test(message) ||
|
||||||
|
/\brate\s*limit/.test(message) ||
|
||||||
|
/\bretry\s+after\s+\d+/.test(message) ||
|
||||||
|
/\btoo\s+many\s+attempts\b/.test(message) ||
|
||||||
|
/\btoo_many_requests\b/.test(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesTokenOutputLimit(message: string): boolean {
|
||||||
|
return (
|
||||||
|
/\btokenoutputlimiterror\b/.test(message) ||
|
||||||
|
/could not complete response due to token output limits/.test(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesAccessOrLimit(message: string): boolean {
|
||||||
|
return (
|
||||||
|
/\bwhitelist(?:ed)?\b/.test(message) ||
|
||||||
|
/\bnot\s+allowed\s+for\s+this\s+domain\b/.test(message) ||
|
||||||
|
/\bcontext\s+length\b/.test(message) ||
|
||||||
|
/\b(?:max|maximum)\s+tokens?\b/.test(message) ||
|
||||||
|
/\btoken\s+limit\b/.test(message) ||
|
||||||
|
/\btoken\s+output\b/.test(message) ||
|
||||||
|
/\boutput\s+limits?\b/.test(message) ||
|
||||||
|
/\bstep\s+limit\b/.test(message) ||
|
||||||
|
/\b(?:max|maximum)(?:\s+agent)?\s+steps?\b/.test(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonImpliesCostControl(value: Record<string, unknown>): boolean {
|
||||||
|
const type = readStringField(value, 'type') ?? '';
|
||||||
|
const error = readStringField(value, 'error') ?? '';
|
||||||
|
const message = readStringField(value, 'message') ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
/tokenoutput|outputlimit|steplimit|maxstep|ratelimit|domainnotallowed/i.test(
|
||||||
|
type
|
||||||
|
) ||
|
||||||
|
error.toUpperCase() === 'TOO_MANY_REQUESTS' ||
|
||||||
|
matchesRateLimit(`${error} ${message}`.toLowerCase()) ||
|
||||||
|
matchesAccessOrLimit(`${error} ${message}`.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCKING_MATCHERS: BlockingMatcher[] = [
|
||||||
|
{
|
||||||
|
matches: ({ code }) => Boolean(code && PROMPT_BLOCKING_CODES.has(code)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matches: ({ parsedJson }) =>
|
||||||
|
Boolean(
|
||||||
|
parsedJson &&
|
||||||
|
matchesDomainBlock(
|
||||||
|
(readStringField(parsedJson, 'message') ?? '').toLowerCase()
|
||||||
|
)
|
||||||
|
),
|
||||||
|
showNewConversationLink: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matches: ({ parsedJson }) =>
|
||||||
|
Boolean(parsedJson && jsonImpliesCostControl(parsedJson)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matches: ({ messageLower }) => matchesTokenOutputLimit(messageLower),
|
||||||
|
showNewConversationLink: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matches: ({ messageLower }) => matchesRateLimit(messageLower),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matches: ({ messageLower }) => matchesDomainBlock(messageLower),
|
||||||
|
showNewConversationLink: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matches: ({ messageLower }) => matchesAccessOrLimit(messageLower),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function resolvePromptBlockingError(error: Error): {
|
||||||
|
blocking: boolean;
|
||||||
|
showNewConversationLink: boolean;
|
||||||
|
} {
|
||||||
|
const message = error.message ?? '';
|
||||||
|
const context: BlockingMatchContext = {
|
||||||
|
message,
|
||||||
|
messageLower: message.toLowerCase(),
|
||||||
|
parsedJson: parseJson(message),
|
||||||
|
code: extractErrorCode(message),
|
||||||
|
};
|
||||||
|
const matches = BLOCKING_MATCHERS.filter((matcher) =>
|
||||||
|
matcher.matches(context)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
blocking: matches.length > 0,
|
||||||
|
showNewConversationLink: matches.every(
|
||||||
|
(matcher) => matcher.showNewConversationLink !== false
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTokenOutputLimitError(error?: Error): boolean {
|
||||||
|
return Boolean(
|
||||||
|
error && matchesTokenOutputLimit((error.message ?? '').toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue