diff --git a/examples/react/e-commerce/App.tsx b/examples/react/e-commerce/App.tsx index fcf844c29d3..83381b926c6 100644 --- a/examples/react/e-commerce/App.tsx +++ b/examples/react/e-commerce/App.tsx @@ -32,6 +32,7 @@ import getRouting from './routing'; import { formatNumber } from './utils'; import 'instantsearch.css/themes/reset.css'; +import 'instantsearch.css/components/chat.css'; import './Theme.css'; import './App.css'; diff --git a/packages/instantsearch-ui-components/src/components/chat/Chat.tsx b/packages/instantsearch-ui-components/src/components/chat/Chat.tsx index d2b4ed58cbc..daaa12f95bf 100644 --- a/packages/instantsearch-ui-components/src/components/chat/Chat.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/Chat.tsx @@ -1,6 +1,11 @@ /** @jsx createElement */ /** @jsxFrag Fragment */ +import { + isRequestOriginNotAllowedError, + isStartNewConversationError, +} from '../../lib/utils/chat'; + import { createChatHeaderComponent } from './ChatHeader'; import { createChatMessagesComponent } from './ChatMessages'; import { createChatOverlayLayoutComponent } from './ChatOverlayLayout'; @@ -146,6 +151,20 @@ export function createChatComponent({ createElement, Fragment }: Renderer) { ...props } = userProps; + const startNewConversationError = + messagesProps.status === 'error' && + isStartNewConversationError(error); + + const requestOriginNotAllowedError = + messagesProps.status === 'error' && + isRequestOriginNotAllowedError(error); + + const promptBlockedByKnownChatError = + startNewConversationError || requestOriginNotAllowedError; + + const headerStartNewConversation = + headerProps.onNewConversation ?? headerProps.onClear; + const headerComponent = createElement(HeaderComponent || ChatHeader, { ...headerProps, classNames: classNames.header, @@ -157,6 +176,14 @@ export function createChatComponent({ createElement, Fragment }: Renderer) { {...messagesProps} classNames={classNames.messages} messageClassNames={classNames.message} + error={error} + onStartNewConversation={ + messagesProps.onStartNewConversation ?? + ((startNewConversationError || requestOriginNotAllowedError) && + headerStartNewConversation + ? headerStartNewConversation + : undefined) + } suggestionsElement={createElement( SuggestionsComponent || ChatPromptSuggestions, { @@ -167,10 +194,16 @@ export function createChatComponent({ createElement, Fragment }: Renderer) { /> ); - const promptComponent = createElement(PromptComponent || ChatPrompt, { - ...promptProps, - classNames: classNames.prompt, - }); + const promptComponent = promptBlockedByKnownChatError + ? null + : createElement(PromptComponent || ChatPrompt, { + ...promptProps, + classNames: classNames.prompt, + disabled: promptProps.disabled, + autoFocus: promptProps.autoFocus, + placeholder: promptProps.placeholder, + translations: promptProps.translations, + }); const toggleButtonComponent = createElement( ToggleButtonComponent || ChatToggleButton, @@ -201,7 +234,7 @@ export function createChatComponent({ createElement, Fragment }: Renderer) { status={messagesProps.status} tools={messagesProps.tools} isClearing={messagesProps.isClearing} - clearMessages={headerProps.onClear} + onNewConversation={headerStartNewConversation} onClearTransitionEnd={messagesProps.onClearTransitionEnd} suggestions={suggestionsProps.suggestions} sendMessage={sendMessage} diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatHeader.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatHeader.tsx index a54089772c7..58138be6a3b 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatHeader.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatHeader.tsx @@ -7,6 +7,7 @@ import { MaximizeIcon as MaximizeIconDefault, MinimizeIcon as MinimizeIconDefault, CloseIcon as CloseIconDefault, + SquarePenIcon, } from './icons'; import type { Renderer, ComponentProps } from '../../types'; @@ -29,9 +30,9 @@ export type ChatHeaderTranslations = { */ closeLabel: string; /** - * Text for the clear button + * Accessible label for the new-conversation control */ - clearLabel: string; + newConversationLabel: string; }; export type ChatHeaderClassNames = { @@ -56,9 +57,9 @@ export type ChatHeaderClassNames = { */ close?: string | string[]; /** - * Class names to apply to the clear button element + * Class names for the new-conversation button */ - clear?: string | string[]; + newConversation?: string | string[]; }; export type ChatHeaderOwnProps = { @@ -75,11 +76,19 @@ export type ChatHeaderOwnProps = { */ onClose: () => void; /** - * Callback when the clear button is clicked + * Callback to start a new conversation. Shown as the square-pen icon when `onStartNewConversation` is not set. + */ + onNewConversation?: () => void; + /** + * @deprecated Renamed to `onNewConversation`. */ onClear?: () => void; /** - * Whether the clear button is enabled + * Whether the new-conversation action is enabled (when `onNewConversation` is used for the icon). + */ + canStartNewConversation?: boolean; + /** + * @deprecated Renamed to `canStartNewConversation`. */ canClear?: boolean; /** @@ -98,6 +107,14 @@ export type ChatHeaderOwnProps = { * Optional title icon component (defaults to sparkles) */ titleIconComponent?: () => JSX.Element; + /** + * When set, the square-pen icon calls this instead of `onNewConversation`. + */ + onStartNewConversation?: () => void; + /** + * Optional icon for the new-conversation control + */ + newConversationIconComponent?: () => JSX.Element; /** * Optional class names for elements */ @@ -118,25 +135,37 @@ export function createChatHeaderComponent({ createElement }: Renderer) { maximized = false, onToggleMaximize, onClose, + onNewConversation, onClear, - canClear = false, + onStartNewConversation, + canStartNewConversation, + canClear, closeIconComponent: CloseIcon, minimizeIconComponent: MinimizeIcon, maximizeIconComponent: MaximizeIcon, titleIconComponent: TitleIcon, + newConversationIconComponent: NewConversationIcon, classNames = {}, translations: userTranslations, ...props } = userProps; + const t = userTranslations ?? {}; const translations: Required = { title: 'Chat', minimizeLabel: 'Minimize chat', maximizeLabel: 'Maximize chat', closeLabel: 'Close chat', - clearLabel: 'Clear', - ...userTranslations, + newConversationLabel: 'Start a new conversation', + ...t, }; + const resolvedNewConversation = onNewConversation ?? onClear; + const handleStartNewConversation = + onStartNewConversation ?? resolvedNewConversation; + const startNewConversationDisabled = + resolvedNewConversation !== undefined && + !(canStartNewConversation ?? canClear ?? false); + const defaultMaximizeIcon = maximized ? ( ) : ( @@ -161,15 +190,26 @@ export function createChatHeaderComponent({ createElement }: Renderer) { {translations.title}
- {onClear && ( + {handleStartNewConversation && ( )} +
+ )} + + ) : ( +
+ {translations.errorMessage} +
+ )} + {(actions || (!isConversationLimit && onReload)) && (
{actions ? ( actions.map((action, index) => ( diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index e59b7bd5bdc..1fcf38784ec 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -2,8 +2,11 @@ import { cx } from '../../lib'; import { + getChatErrorDisplayMessage, getTextContent, hasTextContent, + isRequestOriginNotAllowedError, + isStartNewConversationError, isPartText, } from '../../lib/utils/chat'; import { createButtonComponent } from '../Button'; @@ -65,6 +68,29 @@ export type ChatMessagesTranslations = { * Label for the feedback spinner */ sendingFeedbackLabel?: string; + /** + * Label for the “start a new conversation” action (link-style) when the error + * is non-retryable (limits, allowlist, etc.). + */ + conversationLimitActionLabel?: string; + /** + * Overrides the default message for “start a new conversation” errors + * (otherwise the API error text is shown). When omitted, + * {@link getChatErrorDisplayMessage} applies; use + * {@link registerStartNewConversationErrorDisplayResolver} for global customization. + */ + conversationLimitErrorMessage?: string; + /** + * Overrides copy for retryable / generic chat errors. + * When omitted, {@link getChatErrorDisplayMessage} applies built-in mappings. + */ + genericChatErrorMessage?: string; + /** + * Overrides copy for Agent Studio “request origin not allowed” (HTTP 403) errors. + * When omitted, the API error text is shown; this key is used instead of + * {@link genericChatErrorMessage} for that error only. + */ + requestOriginNotAllowedErrorMessage?: string; }; export type ChatMessagesClassNames = { @@ -213,6 +239,19 @@ export type ChatMessagesProps< * Map of message IDs to their feedback state. */ feedbackState?: Record; + /** + * When `status` is `error`, used to show `error.message` (e.g. API error text). + */ + error?: Error; + /** + * Current server/client conversation id (from the chat connector). Used to + * remount the error row when the thread changes so UI never shows a stale line. + */ + conversationId?: string; + /** + * When the conversation thread depth limit is hit, invoked from the in-thread action (e.g. same as header Clear). + */ + onStartNewConversation?: () => void; }; const copyToClipboard = (message: ChatMessageBase) => { @@ -360,6 +399,7 @@ export function createChatMessagesComponent({ }); const DefaultErrorComponent = createChatMessageErrorComponent({ createElement, + Fragment, }); return function ChatMessages< @@ -396,6 +436,9 @@ export function createChatMessagesComponent({ suggestionsElement, onFeedback, feedbackState, + error, + conversationId, + onStartNewConversation, ...props } = userProps; @@ -407,6 +450,7 @@ export function createChatMessagesComponent({ thumbsDownLabel: 'Dislike', feedbackThankYouText: 'Thanks for your feedback!', sendingFeedbackLabel: 'Sending feedback...', + conversationLimitActionLabel: 'Start a new conversation', ...userTranslations, }; @@ -443,6 +487,44 @@ export function createChatMessagesComponent({ const DefaultLoader = LoaderComponent || DefaultLoaderComponent; const DefaultError = ErrorComponent || DefaultErrorComponent; + const startNewConversationError = + status === 'error' && isStartNewConversationError(error); + + const requestOriginNotAllowedError = + status === 'error' && isRequestOriginNotAllowedError(error); + + const errorMessageForDisplay = + status === 'error' && error?.message + ? startNewConversationError + ? translations.conversationLimitErrorMessage ?? + getChatErrorDisplayMessage(error) ?? + error.message + : requestOriginNotAllowedError + ? translations.requestOriginNotAllowedErrorMessage ?? + getChatErrorDisplayMessage(error) ?? + error.message + : translations.genericChatErrorMessage ?? + getChatErrorDisplayMessage(error) ?? + error.message + : undefined; + + const errorComponentTranslations = + errorMessageForDisplay !== undefined || + startNewConversationError || + requestOriginNotAllowedError + ? { + ...(errorMessageForDisplay !== undefined + ? { errorMessage: errorMessageForDisplay } + : {}), + ...(startNewConversationError || requestOriginNotAllowedError + ? { + conversationLimitActionLabel: + translations.conversationLimitActionLabel, + } + : {}), + } + : undefined; + return (
)} - {status === 'error' && } + {status === 'error' && ( + + )}
diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatOverlayLayout.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatOverlayLayout.tsx index d9d6a880de2..97a3c342d95 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatOverlayLayout.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatOverlayLayout.tsx @@ -19,7 +19,7 @@ export function createChatOverlayLayoutComponent({ createElement }: Renderer) { messages: _messages, status: _status, isClearing: _isClearing, - clearMessages: _clearMessages, + onNewConversation: _onNewConversation, onClearTransitionEnd: _onClearTransitionEnd, suggestions: _suggestions, tools: _tools, diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatPrompt.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatPrompt.tsx index 7a8a467076c..c48f908f90e 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatPrompt.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatPrompt.tsx @@ -267,6 +267,9 @@ export function createChatPromptComponent({ createElement }: Renderer) {
{ + if (disabled) { + return; + } if (e.target === textAreaElement) return; textAreaElement?.focus(); }} diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatSidePanelLayout.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatSidePanelLayout.tsx index 261c030a7f6..ec7597be95a 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatSidePanelLayout.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatSidePanelLayout.tsx @@ -28,7 +28,7 @@ export function createChatSidePanelLayoutComponent({ messages: _messages, status: _status, isClearing: _isClearing, - clearMessages: _clearMessages, + onNewConversation: _onNewConversation, onClearTransitionEnd: _onClearTransitionEnd, suggestions: _suggestions, tools: _tools, diff --git a/packages/instantsearch-ui-components/src/components/chat/__tests__/Chat.test.tsx b/packages/instantsearch-ui-components/src/components/chat/__tests__/Chat.test.tsx index 26ef0faae9b..290cdbbc820 100644 --- a/packages/instantsearch-ui-components/src/components/chat/__tests__/Chat.test.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/__tests__/Chat.test.tsx @@ -238,4 +238,93 @@ describe('Chat', () => {
`); }); + + test('hides prompt when conversation-limit error is present', () => { + const { container } = render( + + ); + + expect(container.querySelector('.ais-ChatPrompt')).toBeNull(); + }); + + test('hides prompt when rate limit error is present', () => { + const { container } = render( + + ); + + expect(container.querySelector('.ais-ChatPrompt')).toBeNull(); + }); + + test('hides prompt when request origin is not allowed', () => { + const { container } = render( + + ); + + expect(container.querySelector('.ais-ChatPrompt')).toBeNull(); + }); }); diff --git a/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatHeader.test.tsx b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatHeader.test.tsx index 21a4c337451..bd58c432c73 100644 --- a/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatHeader.test.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatHeader.test.tsx @@ -109,6 +109,53 @@ describe('ChatHeader', () => { `); }); + test('renders new conversation control when onStartNewConversation is set', () => { + const onStart = jest.fn(); + const { container } = render( + + ); + + const btn = container.querySelector('.ais-ChatHeader-newConversation'); + expect(btn).not.toBeNull(); + expect(btn?.getAttribute('aria-label')).toBe('Start a new conversation'); + }); + + test('renders new conversation control when onNewConversation is set', () => { + const onNewConversation = jest.fn(); + const { container } = render( + + ); + + const btn = container.querySelector( + '.ais-ChatHeader-newConversation' + ) as HTMLButtonElement; + expect(btn).not.toBeNull(); + expect(btn.getAttribute('aria-label')).toBe('Reset thread'); + expect(btn.disabled).toBe(false); + userEvent.click(btn); + expect(onNewConversation).toHaveBeenCalledTimes(1); + }); + + test('disables new conversation when onNewConversation is set and canStartNewConversation is false', () => { + const { container } = render( + + ); + + const btn = container.querySelector( + '.ais-ChatHeader-newConversation' + ) as HTMLButtonElement; + expect(btn.disabled).toBe(true); + }); + test('calls onClose when close button is clicked', () => { const onClose = jest.fn(); const { container } = render(); diff --git a/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatInlineLayout.test.tsx b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatInlineLayout.test.tsx index f22109aa468..f1678d859d5 100644 --- a/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatInlineLayout.test.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatInlineLayout.test.tsx @@ -23,7 +23,7 @@ describe('ChatInlineLayout', () => { messages: [], status: 'ready' as const, isClearing: false, - clearMessages: jest.fn(), + onNewConversation: jest.fn(), onClearTransitionEnd: jest.fn(), tools: {}, sendMessage: jest.fn() as any, diff --git a/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessages.test.tsx b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessages.test.tsx index 7f112a519ef..75972ce96e8 100644 --- a/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessages.test.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatMessages.test.tsx @@ -2,7 +2,7 @@ * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts */ /** @jsx createElement */ -import { render } from '@testing-library/preact'; +import { fireEvent, render } from '@testing-library/preact'; import { Fragment, createElement } from 'preact'; import { createChatMessagesComponent } from '../ChatMessages'; @@ -256,6 +256,306 @@ describe('ChatMessages', () => { }); }); + test('shows API error message when status is error and error is set', () => { + const apiMessage = + 'Conversation has reached its maximum thread depth of 3 messages. Please start a new conversation.'; + + const { container } = render( + + ); + + expect( + container.querySelector('.ais-ChatMessageError-primary')?.textContent + ).toBe(apiMessage); + }); + + test('conversation depth error shows API text only, no link or retry without handler', () => { + const apiMessage = + 'Conversation has reached its maximum thread depth of 3 messages. Please start a new conversation.'; + + const { container } = render( + + ); + + expect( + container.querySelector('.ais-ChatMessageError--conversationLimit') + ).not.toBeNull(); + expect( + container.querySelector('.ais-ChatMessageError-primary')?.textContent + ).toBe(apiMessage); + expect(container.querySelector('.ais-ChatMessageError-link')).toBeNull(); + expect(container.querySelector('.ais-ChatMessage-errorAction')).toBeNull(); + }); + + test('conversation depth error renders start-new link when handler is provided', () => { + const apiMessage = + 'Conversation has reached its maximum thread depth of 3 messages. Please start a new conversation.'; + const onStartNewConversation = jest.fn(); + + const { container } = render( + + ); + + const link = container.querySelector( + '.ais-ChatMessageError-link' + ); + expect(link).not.toBeNull(); + expect(link?.textContent).toBe('Start a new conversation'); + + fireEvent.click(link!); + expect(onStartNewConversation).toHaveBeenCalledTimes(1); + }); + + test('recursion limit error surfaces API text', () => { + const long = + 'Recursion limit of 5 reached without hitting a stop condition. You can increase the limit by setting the `recursion_limit` config key.'; + + const { container } = render( + + ); + + expect( + container.querySelector('.ais-ChatMessageError-primary')?.textContent + ).toBe(long); + }); + + test('max_output_tokens error uses conversation-limit variant like thread depth', () => { + const { container } = render( + + ); + + expect( + container.querySelector('.ais-ChatMessageError--conversationLimit') + ).not.toBeNull(); + expect( + container.querySelector('.ais-ChatMessageError-primary')?.textContent + ).toBe('Response is incomplete due to: max_output_tokens'); + expect(container.querySelector('.ais-ChatMessage-errorAction')).toBeNull(); + expect(container.querySelector('.ais-ChatMessageError-link')).toBeNull(); + }); + + test('max_output_tokens error renders start-new link when handler is provided', () => { + const onStartNewConversation = jest.fn(); + const { container } = render( + + ); + + const link = container.querySelector( + '.ais-ChatMessageError-link' + ); + expect(link).not.toBeNull(); + fireEvent.click(link!); + expect(onStartNewConversation).toHaveBeenCalledTimes(1); + }); + + test('rate limit error uses conversation-limit variant and API text', () => { + const apiMessage = 'Rate limit exceeded. Retry after 60 seconds.'; + const { container } = render( + + ); + + expect( + container.querySelector('.ais-ChatMessageError--conversationLimit') + ).not.toBeNull(); + expect( + container.querySelector('.ais-ChatMessageError-primary')?.textContent + ).toBe(apiMessage); + expect(container.querySelector('.ais-ChatMessage-errorAction')).toBeNull(); + }); + + test('rate limit error renders start-new link when handler is provided', () => { + const onStartNewConversation = jest.fn(); + const { container } = render( + + ); + + const link = container.querySelector( + '.ais-ChatMessageError-link' + ); + expect(link).not.toBeNull(); + fireEvent.click(link!); + expect(onStartNewConversation).toHaveBeenCalledTimes(1); + }); + + test('request origin not allowed error uses conversation-limit layout without retry', () => { + const apiMessage = + 'Request origin is not in the allowed domains list. Add your domain in Agent Studio settings.'; + const onReload = jest.fn(); + const { container } = render( + + ); + + expect( + container.querySelector('.ais-ChatMessageError--conversationLimit') + ).not.toBeNull(); + expect( + container.querySelector('.ais-ChatMessageError-primary')?.textContent + ).toBe(apiMessage); + expect(container.querySelector('.ais-ChatMessage-errorAction')).toBeNull(); + expect(onReload).not.toHaveBeenCalled(); + }); + + test('request origin error renders start-new link when handler is provided', () => { + const apiMessage = + 'Request origin is not in the allowed domains list. Add your domain in Agent Studio settings.'; + const onStartNewConversation = jest.fn(); + const { container } = render( + + ); + + const link = container.querySelector( + '.ais-ChatMessageError-link' + ); + expect(link).not.toBeNull(); + expect(link?.textContent).toBe('Start a new conversation'); + fireEvent.click(link!); + expect(onStartNewConversation).toHaveBeenCalledTimes(1); + }); + + test('requestOriginNotAllowedErrorMessage translation overrides default copy', () => { + const apiMessage = + 'Request origin is not in the allowed domains list. Add your domain in Agent Studio settings.'; + + const { container } = render( + + ); + + expect( + container.querySelector('.ais-ChatMessageError-primary')?.textContent + ).toBe('Origin blocked — fix in Studio'); + }); + + test('conversationLimitErrorMessage translation overrides default message', () => { + const apiMessage = + 'Conversation has reached its maximum thread depth of 3 messages. Please start a new conversation.'; + + const { container } = render( + + ); + + expect( + container.querySelector('.ais-ChatMessageError-primary')?.textContent + ).toBe('Thread limit — start over'); + }); + test('renders with custom class names', () => { const { container } = render( { messages: [], status: 'ready' as const, isClearing: false, - clearMessages: jest.fn(), + onNewConversation: jest.fn(), onClearTransitionEnd: jest.fn(), tools: {}, sendMessage: jest.fn() as any, diff --git a/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatSidePanelLayout.test.tsx b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatSidePanelLayout.test.tsx index 966c2c42c96..47aa0681043 100644 --- a/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatSidePanelLayout.test.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/__tests__/ChatSidePanelLayout.test.tsx @@ -27,7 +27,7 @@ describe('ChatSidePanelLayout', () => { messages: [], status: 'ready' as const, isClearing: false, - clearMessages: jest.fn(), + onNewConversation: jest.fn(), onClearTransitionEnd: jest.fn(), tools: {}, sendMessage: jest.fn() as any, diff --git a/packages/instantsearch-ui-components/src/components/chat/icons.tsx b/packages/instantsearch-ui-components/src/components/chat/icons.tsx index f3715cad8d0..c18109291ef 100644 --- a/packages/instantsearch-ui-components/src/components/chat/icons.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/icons.tsx @@ -294,3 +294,19 @@ export function ChevronRightIcon({ createElement }: IconProps) { ); } + +export function SquarePenIcon({ createElement }: IconProps) { + return ( + + + + + ); +} diff --git a/packages/instantsearch-ui-components/src/components/chat/types.ts b/packages/instantsearch-ui-components/src/components/chat/types.ts index 5231f1ea305..b62dfefefbc 100644 --- a/packages/instantsearch-ui-components/src/components/chat/types.ts +++ b/packages/instantsearch-ui-components/src/components/chat/types.ts @@ -353,6 +353,7 @@ export interface ChatTransport { reconnectToStream: (options: { chatId: string; + abortSignal?: AbortSignal; }) => Promise | null>; } @@ -465,11 +466,11 @@ export type ChatLayoutOwnProps< maximized: boolean; headerComponent: JSX.Element; messagesComponent: JSX.Element; - promptComponent: JSX.Element; + promptComponent: JSX.Element | null; toggleButtonComponent: JSX.Element; classNames?: { root?: string | string[]; container?: string | string[] }; isClearing?: boolean; - clearMessages?: () => void; + onNewConversation?: () => void; onClearTransitionEnd?: () => void; suggestions?: string[]; tools: ClientSideTools; diff --git a/packages/instantsearch-ui-components/src/lib/utils/__tests__/chat.test.ts b/packages/instantsearch-ui-components/src/lib/utils/__tests__/chat.test.ts new file mode 100644 index 00000000000..bbc67d9ad5e --- /dev/null +++ b/packages/instantsearch-ui-components/src/lib/utils/__tests__/chat.test.ts @@ -0,0 +1,313 @@ +import { + flattenErrorMessageForMatching, + genericChatErrorDisplayResolvers, + getChatErrorDisplayMessage, + getStartNewConversationErrorDisplayMessage, + isConversationThreadDepthLimitError, + isPartText, + isRequestOriginNotAllowedError, + isStartNewConversationError, + registerGenericChatErrorDisplayResolver, + registerNewConversationErrorMatcher, + registerStartNewConversationErrorDisplayResolver, + newConversationErrorMatchers, + startNewConversationErrorDisplayResolvers, +} from '../chat'; + +describe('isConversationThreadDepthLimitError', () => { + test('returns true for Agent Studio thread depth wording', () => { + expect( + isConversationThreadDepthLimitError( + new Error( + 'Conversation has reached its maximum thread depth of 3 messages. Please start a new conversation.' + ) + ) + ).toBe(true); + }); + + test('is case-insensitive', () => { + expect( + isConversationThreadDepthLimitError( + new Error('MAXIMUM THREAD DEPTH reached') + ) + ).toBe(true); + }); + + test('returns false for other errors', () => { + expect( + isConversationThreadDepthLimitError(new Error('HTTP error: 500')) + ).toBe(false); + expect(isConversationThreadDepthLimitError(undefined)).toBe(false); + }); + + test('returns false for LangGraph recursion errors (use isStartNewConversationError)', () => { + expect( + isConversationThreadDepthLimitError( + new Error('Recursion limit of 5 reached. recursion_limit GRAPH_RECURSION_LIMIT') + ) + ).toBe(false); + }); +}); + +describe('isStartNewConversationError', () => { + test('includes thread depth', () => { + expect( + isStartNewConversationError( + new Error( + 'Conversation has reached its maximum thread depth of 3 messages.' + ) + ) + ).toBe(true); + }); + + test('detects LangGraph / stream errorText with nested JSON', () => { + const raw = + '"{\\"error\\": \\"Recursion limit of 5 reached without hitting a stop condition. \\\\nFor troubleshooting: GRAPH_RECURSION_LIMIT\\"}"'; + expect(isStartNewConversationError(new Error(raw))).toBe(true); + }); + + test('detects Recursion limit of after flattening JSON', () => { + const raw = JSON.stringify({ + error: + 'Recursion limit of 5 reached. Set recursion_limit in config. See GRAPH_RECURSION_LIMIT', + }); + expect(isStartNewConversationError(new Error(raw))).toBe(true); + }); + + test('detects max_output_tokens / incomplete response (same UX as thread depth)', () => { + expect( + isStartNewConversationError( + new Error('Response is incomplete due to: max_output_tokens') + ) + ).toBe(true); + }); + + test('detects rate limiting (phrase and HTTP 429)', () => { + expect( + isStartNewConversationError( + new Error('Rate limit exceeded. Retry after 60 seconds.') + ) + ).toBe(true); + expect( + isStartNewConversationError(new Error('HTTP error: 429 Too Many Requests')) + ).toBe(true); + }); + + test('does not match on GRAPH_RECURSION_LIMIT alone without Recursion limit of', () => { + expect( + isStartNewConversationError( + new Error('See https://docs.../GRAPH_RECURSION_LIMIT') + ) + ).toBe(false); + }); + + test('returns false for unrelated errors', () => { + expect(isStartNewConversationError(new Error('HTTP error: 500'))).toBe( + false + ); + expect(isStartNewConversationError(undefined)).toBe(false); + }); + + test('registerNewConversationErrorMatcher extends behavior', () => { + const len = newConversationErrorMatchers.length; + registerNewConversationErrorMatcher((m) => m.includes('CUSTOM_XYZ')); + expect(isStartNewConversationError(new Error('CUSTOM_XYZ'))).toBe(true); + newConversationErrorMatchers.pop(); + expect(newConversationErrorMatchers.length).toBe(len); + }); +}); + +describe('isRequestOriginNotAllowedError', () => { + test('returns true for Agent Studio 403 origin message', () => { + expect( + isRequestOriginNotAllowedError( + new Error( + 'Request origin is not in the allowed domains list. Add your domain in Agent Studio settings.' + ) + ) + ).toBe(true); + }); + + test('unwraps JSON message field', () => { + const body = JSON.stringify({ + message: + 'Request origin is not in the allowed domains list. Add your domain in Agent Studio settings.', + }); + expect(isRequestOriginNotAllowedError(new Error(body))).toBe(true); + }); + + test('returns false for unrelated errors', () => { + expect(isRequestOriginNotAllowedError(new Error('HTTP error: 403'))).toBe( + false + ); + expect(isRequestOriginNotAllowedError(undefined)).toBe(false); + }); +}); + +describe('getChatErrorDisplayMessage', () => { + test('surfaces Agent Studio request-origin API message', () => { + const api = + 'Request origin is not in the allowed domains list. Add your domain in Agent Studio settings.'; + expect(getChatErrorDisplayMessage(new Error(api))).toBe(api); + }); + + test('registerGenericChatErrorDisplayResolver overrides built-in origin copy', () => { + const len = genericChatErrorDisplayResolvers.length; + registerGenericChatErrorDisplayResolver((flat) => + flat.includes('allowed domains list') ? 'Custom origin hint' : null + ); + expect( + getChatErrorDisplayMessage( + new Error( + 'Request origin is not in the allowed domains list. Add your domain in Agent Studio settings.' + ) + ) + ).toBe('Custom origin hint'); + genericChatErrorDisplayResolvers.shift(); + expect(genericChatErrorDisplayResolvers.length).toBe(len); + }); + + test('surfaces max_output_tokens / incomplete response API text', () => { + const msg = 'Response is incomplete due to: max_output_tokens'; + expect(getChatErrorDisplayMessage(new Error(msg))).toBe(msg); + }); + + test('unwraps JSON-wrapped stream error (error field)', () => { + const inner = 'Response is incomplete due to: max_output_tokens'; + const wrapped = JSON.stringify({ + error: inner, + }); + expect(getChatErrorDisplayMessage(new Error(wrapped))).toBe(inner); + }); + + test('delegates to start-new conversation and surfaces thread depth API text', () => { + expect( + getChatErrorDisplayMessage( + new Error( + 'Conversation has reached its maximum thread depth of 3 messages.' + ) + ) + ).toBe('Conversation has reached its maximum thread depth of 3 messages.'); + }); +}); + +describe('getStartNewConversationErrorDisplayMessage', () => { + test('surfaces thread depth API text', () => { + expect( + getStartNewConversationErrorDisplayMessage( + new Error( + 'Conversation has reached its maximum thread depth of 3 messages. Please start a new conversation.' + ) + ) + ).toBe( + 'Conversation has reached its maximum thread depth of 3 messages. Please start a new conversation.' + ); + }); + + test('surfaces LangGraph recursion API text', () => { + const long = + 'Recursion limit of 5 reached without hitting a stop condition. You can increase the limit by setting the `recursion_limit` config key. For troubleshooting, visit: https://example.com'; + expect(getStartNewConversationErrorDisplayMessage(new Error(long))).toBe( + long + ); + }); + + test('prefers recursion when the same payload also mentions thread depth', () => { + const combined = + 'Recursion limit of 5 reached.\nConversation has reached its maximum thread depth of 3 messages. Please start a new conversation.'; + expect(getStartNewConversationErrorDisplayMessage(new Error(combined))).toBe( + combined + ); + }); + + test('unwraps JSON message field for thread depth display', () => { + const inner = + 'Conversation has reached its maximum thread depth of 3 messages. Please start a new conversation.'; + const body = JSON.stringify({ + message: inner, + }); + expect(getStartNewConversationErrorDisplayMessage(new Error(body))).toBe( + inner + ); + }); + + test('surfaces max_output_tokens API text', () => { + const msg = 'Response is incomplete due to: max_output_tokens'; + expect(getStartNewConversationErrorDisplayMessage(new Error(msg))).toBe( + msg + ); + }); + + test('surfaces max steps API text', () => { + const msg = 'Maximum steps (25) exceeded for this run.'; + expect(isStartNewConversationError(new Error(msg))).toBe(true); + expect(getStartNewConversationErrorDisplayMessage(new Error(msg))).toBe( + msg + ); + expect(getChatErrorDisplayMessage(new Error(msg))).toBe(msg); + }); + + test('surfaces rate limit API text', () => { + const msg = 'Rate limit exceeded. Retry after 60 seconds.'; + expect(isStartNewConversationError(new Error(msg))).toBe(true); + expect(getStartNewConversationErrorDisplayMessage(new Error(msg))).toBe(msg); + expect(getChatErrorDisplayMessage(new Error(msg))).toBe(msg); + }); + + test('thread depth API text never maps to max_output copy', () => { + const api = + 'Conversation has reached its maximum thread depth of 3 messages. Please start a new conversation.'; + expect(getStartNewConversationErrorDisplayMessage(new Error(api))).toBe( + api + ); + expect(getChatErrorDisplayMessage(new Error(api))).toBe(api); + }); + + test('returns undefined when not a start-new error', () => { + expect( + getStartNewConversationErrorDisplayMessage(new Error('HTTP 500')) + ).toBeUndefined(); + }); + + test('registerStartNewConversationErrorDisplayResolver prepends custom copy', () => { + const matcherLen = newConversationErrorMatchers.length; + const resolverLen = startNewConversationErrorDisplayResolvers.length; + registerNewConversationErrorMatcher((m) => m.includes('CUSTOM_ABC')); + registerStartNewConversationErrorDisplayResolver((flat) => + flat.includes('CUSTOM_ABC') ? 'Custom message' : null + ); + expect( + getStartNewConversationErrorDisplayMessage(new Error('CUSTOM_ABC detail')) + ).toBe('Custom message'); + startNewConversationErrorDisplayResolvers.shift(); + newConversationErrorMatchers.pop(); + expect(startNewConversationErrorDisplayResolvers.length).toBe(resolverLen); + expect(newConversationErrorMatchers.length).toBe(matcherLen); + }); +}); + +describe('flattenErrorMessageForMatching', () => { + test('unwraps stringified JSON with error field', () => { + const inner = { error: 'Recursion limit of 5 reached. recursion_limit' }; + const raw = JSON.stringify(JSON.stringify(inner)); + const flat = flattenErrorMessageForMatching(raw); + expect(flat).toContain('Recursion limit'); + expect(flat).toContain('recursion_limit'); + }); + + test('unwraps JSON with message field', () => { + const inner = { + message: + 'Conversation has reached its maximum thread depth of 3 messages.', + }; + const flat = flattenErrorMessageForMatching(JSON.stringify(inner)); + expect(flat).toContain('maximum thread depth'); + }); +}); + +describe('isPartText', () => { + test('narrows type for text parts', () => { + expect(isPartText({ type: 'text', text: 'x' })).toBe(true); + expect(isPartText({ type: 'step-start' } as any)).toBe(false); + }); +}); diff --git a/packages/instantsearch-ui-components/src/lib/utils/chat.ts b/packages/instantsearch-ui-components/src/lib/utils/chat.ts index 0e170dd5009..75f9fda2269 100644 --- a/packages/instantsearch-ui-components/src/lib/utils/chat.ts +++ b/packages/instantsearch-ui-components/src/lib/utils/chat.ts @@ -15,3 +15,384 @@ export const isPartText = ( ): part is Extract => { return part.type === 'text'; }; + +/** + * Unwraps nested JSON / double-encoded strings from streaming `error` chunks + * (e.g. LangGraph `errorText` payloads). Each segment is one unwrap step; the + * last entry is the innermost string (when wrapping exists). + */ +function collectErrorMessageSegments(raw: string): string[] { + const segments: string[] = [raw]; + let remaining = raw.trim(); + + for (let depth = 0; depth < 8; depth += 1) { + try { + const parsed: unknown = JSON.parse(remaining); + if (typeof parsed === 'string') { + segments.push(parsed); + remaining = parsed.trim(); + continue; + } + if ( + parsed && + typeof parsed === 'object' && + 'message' in parsed && + typeof (parsed as { message: unknown }).message === 'string' + ) { + const inner = (parsed as { message: string }).message; + segments.push(inner); + remaining = inner.trim(); + continue; + } + if ( + parsed && + typeof parsed === 'object' && + 'error' in parsed && + typeof (parsed as { error: unknown }).error === 'string' + ) { + const inner = (parsed as { error: string }).error; + segments.push(inner); + remaining = inner.trim(); + continue; + } + break; + } catch { + break; + } + } + + return segments; +} + +/** + * Unwraps nested JSON / double-encoded strings from streaming `error` chunks + * (e.g. LangGraph `errorText` payloads) into one searchable string. + */ +export function flattenErrorMessageForMatching(raw: string): string { + return collectErrorMessageSegments(raw).join('\n'); +} + +/** + * API / transport error text for display: {@link Error.message}, or the + * innermost unwrapped string when the message is JSON-wrapped. + */ +function getUnwrappedApiErrorDisplayMessage(error: Error): string { + const segments = collectErrorMessageSegments(error.message); + return segments.length > 1 + ? segments[segments.length - 1] + : error.message; +} + +/** + * Predicate on the flattened error string. Register more at runtime via + * {@link registerNewConversationErrorMatcher} for product-specific API errors. + */ +export type NewConversationErrorMatcher = (flatMessage: string) => boolean; + +/** + * True when the flattened text indicates thread / conversation depth limit + * (wording varies by API). + */ +function matchesThreadDepthLimitError(flatLower: string): boolean { + return ( + flatLower.includes('maximum thread depth') || + flatLower.includes('max thread depth') || + (flatLower.includes('thread depth') && flatLower.includes('conversation')) + ); +} + +/** + * True for stream/model output limit — uses `max_output` as a token/code, not + * substrings of words like “maximum”. Runs on lowercased text so casing matches + * the `includes` checks and the `max_output` token regex. + */ +function matchesMaxOutputIncompleteError(flatLower: string): boolean { + if (flatLower.includes('max_output_tokens')) { + return true; + } + if ( + flatLower.includes('response is incomplete') && + /\bmax_output\b/.test(flatLower) + ) { + return true; + } + return false; +} + +/** + * True for LangGraph-style recursion limit errors (wording varies by API). + */ +function matchesRecursionLimitError(flatLower: string): boolean { + return flatLower.includes('recursion limit of'); +} + +/** + * Agent / graph “max steps” style limits (wording varies by API). + */ +function matchesMaxStepsLimitError(flatLower: string): boolean { + return ( + flatLower.includes('max_steps') || + flatLower.includes('max steps') || + flatLower.includes('maximum steps') || + flatLower.includes('step limit') + ); +} + +/** + * HTTP / API rate limiting (429, “too many requests”, etc.). + */ +function matchesRateLimitError(flatLower: string): boolean { + if ( + flatLower.includes('rate limit') || + flatLower.includes('rate_limit') || + flatLower.includes('ratelimit') + ) { + return true; + } + if (flatLower.includes('too many requests')) { + return true; + } + if (flatLower.includes('throttl')) { + return true; + } + if (/\b429\b/.test(flatLower)) { + return true; + } + return false; +} + +/** + * Default matchers for errors where the user should start a new conversation + * (same UX: conversation-limit alert, “Start a new conversation”, hidden prompt, etc.). + */ +export const newConversationErrorMatchers: NewConversationErrorMatcher[] = [ + (m) => matchesThreadDepthLimitError(m.toLowerCase()), + (m) => matchesRecursionLimitError(m.toLowerCase()), + (m) => matchesMaxStepsLimitError(m.toLowerCase()), + (m) => matchesMaxOutputIncompleteError(m.toLowerCase()), + (m) => matchesRateLimitError(m.toLowerCase()), +]; + +/** + * Register an extra matcher (e.g. for a new Agent Studio error code) without + * forking the library. + */ +export function registerNewConversationErrorMatcher( + fn: NewConversationErrorMatcher +): void { + newConversationErrorMatchers.push(fn); +} + +/** + * Maps a flattened API error string to a short, user-facing line. Return + * `null` or `undefined` to try the next resolver; if none match, the raw + * {@link Error.message} is shown. + */ +export type StartNewConversationErrorDisplayResolver = ( + flatMessage: string, + rawMessage: string +) => string | null | undefined; + +/** Legacy shortened copy for LangGraph / agent recursion limits. */ +export const START_NEW_CONVERSATION_RECURSION_MESSAGE = + 'Recursion limit reached'; + +const defaultStartNewConversationErrorDisplayResolvers: StartNewConversationErrorDisplayResolver[] = + []; + +/** + * Resolvers run in order (index `0` first). Use + * {@link registerStartNewConversationErrorDisplayResolver} to prepend + * product-specific mappings ahead of the defaults. + */ +export const startNewConversationErrorDisplayResolvers: StartNewConversationErrorDisplayResolver[] = + [...defaultStartNewConversationErrorDisplayResolvers]; + +export function registerStartNewConversationErrorDisplayResolver( + resolver: StartNewConversationErrorDisplayResolver +): void { + startNewConversationErrorDisplayResolvers.unshift(resolver); +} + +/** + * Legacy shortened copy for thread-depth limits. Thread-depth errors now use + * the API message in the UI; this remains for backwards compatibility. + */ +export const START_NEW_CONVERSATION_THREAD_DEPTH_MESSAGE = + 'Conversation has reached its maximum thread depth'; + +/** Legacy shortened copy for `max_output_tokens` / incomplete stream output. */ +export const START_NEW_CONVERSATION_MAX_OUTPUT_MESSAGE = + "Token limit reached — the answer couldn't finish."; + +function resolveStartNewConversationErrorDisplayMessage(error: Error): string { + const flat = flattenErrorMessageForMatching(error.message); + const flatLower = flat.toLowerCase(); + + // Ordered “map”: first matching rule wins. Put strict / unambiguous signals first, + // then recursion before thread depth: some payloads still mention “thread depth” + // from an earlier turn or bundle multiple hints; when recursion is the active + // failure it must win over the broader thread-depth phrase match. + // 1) Output token / incomplete response (`max_output_tokens`, etc.). + if (matchesMaxOutputIncompleteError(flatLower)) { + return getUnwrappedApiErrorDisplayMessage(error); + } + // 2) Recursion limit (LangGraph, etc.). + if (matchesRecursionLimitError(flatLower)) { + return getUnwrappedApiErrorDisplayMessage(error); + } + // 3) Max steps (agent / graph limits). + if (matchesMaxStepsLimitError(flatLower)) { + return getUnwrappedApiErrorDisplayMessage(error); + } + // 4) Rate limiting (HTTP 429, “too many requests”, …). + if (matchesRateLimitError(flatLower)) { + return getUnwrappedApiErrorDisplayMessage(error); + } + // 5) Optional product-specific resolvers (prepend with registerStartNewConversationErrorDisplayResolver). + for (const resolver of startNewConversationErrorDisplayResolvers) { + const resolved = resolver(flat, error.message); + if (resolved != null && resolved !== '') { + return resolved; + } + } + // 6) Thread / conversation depth — API text. + if (matchesThreadDepthLimitError(flatLower)) { + return getUnwrappedApiErrorDisplayMessage(error); + } + return error.message; +} + +/** + * User-facing copy for “start a new conversation” errors (API message for + * built-in cases, plus optional display resolvers). Returns `undefined` when + * the error is not treated as a start-new-conversation error. + */ +export function getStartNewConversationErrorDisplayMessage( + error: Error | undefined +): string | undefined { + if (!error?.message || !isStartNewConversationError(error)) { + return undefined; + } + return resolveStartNewConversationErrorDisplayMessage(error); +} + +/** + * Maps a flattened API / stream error string to a short user-facing line for + * errors that are not “start a new conversation”. Prefer + * {@link registerNewConversationErrorMatcher} when the UX should match + * thread-depth / recursion (alert + new conversation). + */ +export type GenericChatErrorDisplayResolver = ( + flatMessage: string, + rawMessage: string +) => string | null | undefined; + +/** + * Legacy stable line for Agent Studio HTTP 403 when the origin is not allowlisted. + * The UI now shows the API message by default. + */ +export const REQUEST_ORIGIN_NOT_ALLOWED_MESSAGE = + 'Request origin is not in the allowed domains list. Add your domain in Agent Studio settings.'; + +function matchesRequestOriginNotAllowedError(flatLower: string): boolean { + return ( + flatLower.includes('request origin is not in the allowed domains') || + (flatLower.includes('request origin') && + flatLower.includes('allowed domains list')) + ); +} + +/** + * HTTP / Agent Studio error when the app origin is missing from allowed domains. + */ +export function isRequestOriginNotAllowedError( + error: Error | undefined +): boolean { + if (!error?.message) { + return false; + } + const flat = flattenErrorMessageForMatching(error.message); + return matchesRequestOriginNotAllowedError(flat.toLowerCase()); +} + +const defaultGenericChatErrorDisplayResolvers: GenericChatErrorDisplayResolver[] = + [ + (flat, rawMessage) => { + if (matchesRequestOriginNotAllowedError(flat.toLowerCase())) { + return getUnwrappedApiErrorDisplayMessage(new Error(rawMessage)); + } + return null; + }, + ]; + +/** + * Resolvers for generic chat errors (not “start new conversation”). Includes a + * default mapping for Agent Studio HTTP 403 “request origin not allowed”. + * Prepend custom resolvers with {@link registerGenericChatErrorDisplayResolver}. + */ +export const genericChatErrorDisplayResolvers: GenericChatErrorDisplayResolver[] = + [...defaultGenericChatErrorDisplayResolvers]; + +export function registerGenericChatErrorDisplayResolver( + resolver: GenericChatErrorDisplayResolver +): void { + genericChatErrorDisplayResolvers.unshift(resolver); +} + +function resolveGenericChatErrorDisplayMessage(error: Error): string { + const flat = flattenErrorMessageForMatching(error.message); + for (const resolver of genericChatErrorDisplayResolvers) { + const resolved = resolver(flat, error.message); + if (resolved != null && resolved !== '') { + return resolved; + } + } + return error.message; +} + +/** + * User-facing error line for chat: “start new conversation” errors (API text), + * then optional generic resolvers (e.g. request origin), then {@link Error.message}. + */ +export function getChatErrorDisplayMessage( + error: Error | undefined +): string | undefined { + if (!error?.message) { + return undefined; + } + if (isStartNewConversationError(error)) { + return getStartNewConversationErrorDisplayMessage(error) ?? error.message; + } + return resolveGenericChatErrorDisplayMessage(error); +} + +/** + * Whether this error should get the “start a new conversation” treatment + * (thread depth, recursion, max steps, `max_output_tokens` / incomplete output, + * rate limiting, and any registered matchers). + */ +export function isStartNewConversationError(error: Error | undefined): boolean { + if (!error?.message) { + return false; + } + const flat = flattenErrorMessageForMatching(error.message); + return newConversationErrorMatchers.some((fn) => fn(flat)); +} + +/** + * Detects Agent Studio (and similar) errors when the chat thread has reached + * its maximum depth. Message wording comes from the completions API. + * + * Prefer {@link isStartNewConversationError} for UI that should also cover + * recursion limits and other recoverable-by-new-chat errors. + */ +export function isConversationThreadDepthLimitError( + error: Error | undefined +): boolean { + if (!error?.message) { + return false; + } + const flat = flattenErrorMessageForMatching(error.message); + return matchesThreadDepthLimitError(flat.toLowerCase()); +} diff --git a/packages/instantsearch-ui-components/src/lib/utils/index.ts b/packages/instantsearch-ui-components/src/lib/utils/index.ts index 764893a9869..5c31c1b2cb7 100644 --- a/packages/instantsearch-ui-components/src/lib/utils/index.ts +++ b/packages/instantsearch-ui-components/src/lib/utils/index.ts @@ -1,3 +1,4 @@ +export * from './chat'; export * from './find'; export * from './promptSuggestions'; export * from './startsWith'; diff --git a/packages/instantsearch.css/src/components/chat.scss b/packages/instantsearch.css/src/components/chat.scss index 8ea8ce01076..4db0cff44f7 100644 --- a/packages/instantsearch.css/src/components/chat.scss +++ b/packages/instantsearch.css/src/components/chat.scss @@ -10,6 +10,7 @@ @use 'chat/chat-header'; @use 'chat/chat-messages'; @use 'chat/chat-message'; +@use 'chat/chat-message-error'; @use 'chat/chat-message-loader'; @use 'chat/chat-greeting'; @use 'chat/chat-prompt'; diff --git a/packages/instantsearch.css/src/components/chat/_chat-header.scss b/packages/instantsearch.css/src/components/chat/_chat-header.scss index 42b58495bae..1614ef43f50 100644 --- a/packages/instantsearch.css/src/components/chat/_chat-header.scss +++ b/packages/instantsearch.css/src/components/chat/_chat-header.scss @@ -57,8 +57,8 @@ } } -.ais-ChatHeader-clear { - font-size: calc(var(--ais-spacing) * 0.875); +.ais-ChatHeader-newConversation { + flex-shrink: 0; } @media (max-width: variables.$ais-chat-breakpoint) { diff --git a/packages/instantsearch.css/src/components/chat/_chat-message-error.scss b/packages/instantsearch.css/src/components/chat/_chat-message-error.scss new file mode 100644 index 00000000000..bcf8a94f0bf --- /dev/null +++ b/packages/instantsearch.css/src/components/chat/_chat-message-error.scss @@ -0,0 +1,34 @@ +.ais-ChatMessageError--conversationLimit .ais-ChatMessage-content { + padding: calc(var(--ais-spacing) * 1); + border-radius: var(--ais-border-radius-md); + border: 1px solid + color-mix(in srgb, rgb(202 138 4) 55%, rgba(var(--ais-border-color-rgb), 0.35)); + background: color-mix( + in srgb, + rgb(146 64 14) 12%, + rgba(var(--ais-background-color-rgb), var(--ais-background-color-alpha)) + ); + box-shadow: inset 0 1px 0 0 rgba(253, 230, 138, 0.12); +} + +.ais-ChatMessageError--conversationLimit .ais-ChatMessageError-primary { + margin: 0; + font-size: calc(var(--ais-spacing) * 0.875); + line-height: calc(var(--ais-spacing) * 1.35); + font-weight: var(--ais-font-weight-semibold); + color: rgba(var(--ais-text-color-rgb), var(--ais-text-color-alpha)); + text-wrap: pretty; +} + +.ais-ChatMessageError--conversationLimit .ais-ChatMessageError-hint { + margin: calc(var(--ais-spacing) * 0.75) 0 0; +} + +.ais-ChatMessageError--conversationLimit .ais-ChatMessageError-link { + text-align: left; + text-decoration: underline; + font-size: calc(var(--ais-spacing) * 0.875); + line-height: calc(var(--ais-spacing) * 1.25); + font-weight: var(--ais-font-weight-normal); + color: rgba(var(--ais-primary-color-rgb), var(--ais-primary-color-alpha)); +} diff --git a/packages/instantsearch.css/src/themes/reset.scss b/packages/instantsearch.css/src/themes/reset.scss index 6ab45542df8..28fe3622164 100644 --- a/packages/instantsearch.css/src/themes/reset.scss +++ b/packages/instantsearch.css/src/themes/reset.scss @@ -57,6 +57,7 @@ select[class^='ais-'] { .ais-RelevantSort-button, .ais-SearchBox-submit, .ais-SearchBox-reset, +.ais-ChatMessageError-link, .ais-VoiceSearch-button { padding: 0; overflow: visible; diff --git a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts index 3d091ff882e..e7ac5ebcd56 100644 --- a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts +++ b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts @@ -715,4 +715,54 @@ data: [DONE]`, expect(sendMessageSpy.mock.calls[0][0]).toEqual({ text: 'Hello' }); }); }); + + describe('clearMessages', () => { + it('clears error when the thread is empty', () => { + const { widget, helper } = getInitializedWidget(); + + const instantSearchInstance: Pick< + InstantSearch, + 'client' | 'getUiState' + > = { + client: createSearchClient(), + getUiState: () => ({ indexName: {} }), + }; + const parent: Pick = { + getIndexId: () => 'indexName', + setIndexUiState: () => {}, + }; + + const options = createInitOptions({ + helper, + state: helper.state, + instantSearchInstance: instantSearchInstance as InstantSearch, + parent: parent as IndexWidget, + }); + + const chat = (widget as { chatInstance: { messages: UIMessage[] } }) + .chatInstance; + chat.messages = []; + ( + widget as unknown as { + chatInstance: { + setStatus: (p: { status: string; error?: Error }) => void; + }; + } + ).chatInstance.setStatus({ + status: 'error', + error: new Error('empty thread failure'), + }); + + const before = widget.getWidgetRenderState(options); + expect(before.messages).toHaveLength(0); + expect(before.status).toBe('error'); + + before.clearMessages(); + + const after = widget.getWidgetRenderState(options); + expect(after.status).toBe('ready'); + expect(after.error).toBeUndefined(); + }); + }); + }); diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index 319dd08bf95..b4b526f8a91 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -110,6 +110,7 @@ export type ChatRenderState = { | 'id' | 'messages' | 'regenerate' + | 'regenerateChatId' | 'resumeStream' | 'sendMessage' | 'status' @@ -338,19 +339,40 @@ export default (function connectChat( }; const clearMessages = () => { - if (!_chatInstance.messages || _chatInstance.messages.length === 0) { + const noMessages = + !_chatInstance.messages || _chatInstance.messages.length === 0; + const status = _chatInstance.status; + + // Empty thread but still in error / in-flight: we must reset chat state. + // Otherwise `clearMessages` returned early and never called `clearError()`, + // so the error banner and connector `error` stayed stuck (e.g. after a + // failure before any message was stored, or empty session + error). + if (noMessages) { + if (status === 'ready' && !_chatInstance.error) { + return; + } + if (status === 'submitted' || status === 'streaming') { + _chatInstance.stop(); + } + if (status === 'error' || _chatInstance.error !== undefined) { + _chatInstance.clearError(); + } return; } - const status = _chatInstance.status; + if (status === 'submitted' || status === 'streaming') { _chatInstance.stop(); } + // Clear error immediately so UI/connectors never keep a stale Error until + // the opacity transition ends (onClearTransitionEnd may not fire in some cases). + _chatInstance.clearError(); setIsClearing(true); }; const onClearTransitionEnd = () => { setMessages([]); _chatInstance.clearError(); + _chatInstance.regenerateChatId(); feedbackState = {}; setIsClearing(false); }; @@ -715,6 +737,7 @@ export default (function connectChat( id: _chatInstance.id, messages: _chatInstance.messages, regenerate: _chatInstance.regenerate, + regenerateChatId: _chatInstance.regenerateChatId, resumeStream: _chatInstance.resumeStream, sendMessage: sendMessageWithContext, status: _chatInstance.status, diff --git a/packages/instantsearch.js/src/lib/ai-lite/__tests__/chat-error-state.test.ts b/packages/instantsearch.js/src/lib/ai-lite/__tests__/chat-error-state.test.ts new file mode 100644 index 00000000000..ed103c36af9 --- /dev/null +++ b/packages/instantsearch.js/src/lib/ai-lite/__tests__/chat-error-state.test.ts @@ -0,0 +1,121 @@ +/** + * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts + */ + +import { Chat, type UIMessage } from '../../chat/chat'; +import { DefaultChatTransport } from '../transport'; + +describe('Chat error state (setStatus)', () => { + function makeChat(): Chat { + return new Chat({ + transport: new DefaultChatTransport({ + api: 'https://test.algolia.net/agent-studio/1/agents/x/completions', + }), + }); + } + + test('clears error when status leaves error (e.g. ready / submitted)', () => { + const chat = makeChat(); + const setStatus = (chat as unknown as { setStatus: (p: unknown) => void }) + .setStatus.bind(chat); + + setStatus({ + status: 'error', + error: new Error('first failure'), + }); + expect(chat.error?.message).toBe('first failure'); + + setStatus({ status: 'ready' }); + expect(chat.error).toBeUndefined(); + + setStatus({ + status: 'error', + error: new Error('second failure'), + }); + expect(chat.error?.message).toBe('second failure'); + }); + + test('clearError after error returns ready and clears error', () => { + const chat = makeChat(); + const setStatus = (chat as unknown as { setStatus: (p: unknown) => void }) + .setStatus.bind(chat); + + setStatus({ + status: 'error', + error: new Error('thread depth'), + }); + expect(chat.status).toBe('error'); + + chat.clearError(); + expect(chat.status).toBe('ready'); + expect(chat.error).toBeUndefined(); + }); + + test('clones each Error so state never reuses the same reference across failures', () => { + const chat = makeChat(); + const setStatus = (chat as unknown as { setStatus: (p: unknown) => void }) + .setStatus.bind(chat); + + const shared = new Error('first'); + setStatus({ status: 'error', error: shared }); + const storedFirst = chat.error; + expect(storedFirst).not.toBe(shared); + expect(storedFirst?.message).toBe('first'); + + setStatus({ status: 'ready' }); + shared.message = 'second'; + setStatus({ status: 'error', error: shared }); + const storedSecond = chat.error; + expect(storedSecond).not.toBe(shared); + expect(storedSecond).not.toBe(storedFirst); + expect(storedSecond?.message).toBe('second'); + }); + + test('ignores failures tied to a previous conversation id after regenerateChatId', () => { + const chat = makeChat(); + const handleError = ( + chat as unknown as { + handleError: ( + e: Error, + o?: { requestChatId?: string; requestAbortController?: AbortController } + ) => void; + } + ).handleError.bind(chat); + + const previousId = chat.id; + chat.regenerateChatId(); + expect(chat.id).not.toBe(previousId); + + handleError(new Error('late stale failure'), { + requestChatId: previousId, + }); + expect(chat.status).toBe('ready'); + expect(chat.error).toBeUndefined(); + }); + + test('ignores handleError when the AbortController is no longer the active request', () => { + const chat = makeChat(); + const handleError = ( + chat as unknown as { + handleError: ( + e: Error, + o?: { requestChatId?: string; requestAbortController?: AbortController } + ) => void; + } + ).handleError.bind(chat); + + const staleController = new AbortController(); + ( + chat as unknown as { + activeResponse: { abortController: AbortController } | null; + } + ).activeResponse = { abortController: new AbortController() }; + + handleError(new Error('superseded request'), { + requestChatId: chat.id, + requestAbortController: staleController, + }); + expect(chat.error).toBeUndefined(); + expect(chat.status).toBe('ready'); + }); +}); diff --git a/packages/instantsearch.js/src/lib/ai-lite/__tests__/stream-parser.test.ts b/packages/instantsearch.js/src/lib/ai-lite/__tests__/stream-parser.test.ts index 0fda5e41d92..b873a0b4ec9 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/__tests__/stream-parser.test.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/__tests__/stream-parser.test.ts @@ -188,4 +188,35 @@ describe('stream-parser', () => { }); }); }); + + describe('processStream', () => { + it('invokes onError when onChunk throws synchronously', () => { + const err = new Error('sync'); + const onError = jest.fn(); + + const stream: ReadableStream = { + getReader: () => ({ + read: () => + Promise.resolve({ + done: false, + value: { type: 'start', messageId: 'x' } as UIMessageChunk, + }), + releaseLock: () => {}, + }), + } as ReadableStream; + + processStream( + stream, + () => { + throw err; + }, + () => {}, + onError + ); + + return Promise.resolve().then(() => { + expect(onError).toHaveBeenCalledWith(err); + }); + }); + }); }); diff --git a/packages/instantsearch.js/src/lib/ai-lite/__tests__/transport-http-error.test.ts b/packages/instantsearch.js/src/lib/ai-lite/__tests__/transport-http-error.test.ts new file mode 100644 index 00000000000..4e881c7d1a9 --- /dev/null +++ b/packages/instantsearch.js/src/lib/ai-lite/__tests__/transport-http-error.test.ts @@ -0,0 +1,55 @@ +import { getHttpErrorMessageFromResponse } from '../transport'; + +function mockResponse({ + status, + statusText, + json, +}: { + status: number; + statusText: string; + json: () => Promise; +}): Response { + return { status, statusText, json } as Response; +} + +describe('getHttpErrorMessageFromResponse', () => { + test('returns JSON message when present', async () => { + const response = mockResponse({ + status: 400, + statusText: 'Bad Request', + json: () => + Promise.resolve({ + message: + 'Conversation has reached its maximum thread depth of 3 messages. Please start a new conversation.', + }), + }); + + await expect(getHttpErrorMessageFromResponse(response)).resolves.toBe( + 'Conversation has reached its maximum thread depth of 3 messages. Please start a new conversation.' + ); + }); + + test('falls back to HTTP status when body is not JSON', async () => { + const response = mockResponse({ + status: 502, + statusText: 'Bad Gateway', + json: () => Promise.reject(new SyntaxError('Unexpected token')), + }); + + await expect(getHttpErrorMessageFromResponse(response)).resolves.toBe( + 'HTTP error: 502 Bad Gateway' + ); + }); + + test('falls back when JSON has no usable message', async () => { + const response = mockResponse({ + status: 500, + statusText: 'Internal Server Error', + json: () => Promise.resolve({ code: 'x' }), + }); + + await expect(getHttpErrorMessageFromResponse(response)).resolves.toBe( + 'HTTP error: 500 Internal Server Error' + ); + }); +}); diff --git a/packages/instantsearch.js/src/lib/ai-lite/__tests__/utils.test.ts b/packages/instantsearch.js/src/lib/ai-lite/__tests__/utils.test.ts new file mode 100644 index 00000000000..c617fa56869 --- /dev/null +++ b/packages/instantsearch.js/src/lib/ai-lite/__tests__/utils.test.ts @@ -0,0 +1,43 @@ +import { normalizeStreamChunkErrorText } from '../utils'; + +describe('normalizeStreamChunkErrorText', () => { + test('returns nested LangGraph-style error string', () => { + const raw = JSON.stringify({ + error: + 'Recursion limit of 5 reached. Set `recursion_limit` in config. GRAPH_RECURSION_LIMIT', + }); + expect(normalizeStreamChunkErrorText(raw)).toContain('Recursion limit of 5'); + }); + + test('passes through plain text', () => { + expect(normalizeStreamChunkErrorText('simple failure')).toBe('simple failure'); + }); + + test('unwraps nested message field (same order as UI flatten)', () => { + const raw = JSON.stringify({ + message: 'Response is incomplete due to: max_output_tokens', + }); + expect(normalizeStreamChunkErrorText(raw)).toBe( + 'Response is incomplete due to: max_output_tokens' + ); + }); + + test('unwraps double-encoded errorText like SSE error chunks', () => { + const inner = { error: 'Response is incomplete due to: max_output_tokens' }; + const errorText = JSON.stringify(JSON.stringify(inner)); + expect(normalizeStreamChunkErrorText(errorText)).toBe( + 'Response is incomplete due to: max_output_tokens' + ); + }); + + test('full data-line shape: type error + escaped errorText string', () => { + const inner = { error: 'Response is incomplete due to: max_output_tokens' }; + const innerJson = JSON.stringify(inner); + const errorText = `"${innerJson.replace(/"/g, '\\"')}"`; + const line = JSON.stringify({ type: 'error', errorText }); + const chunk = JSON.parse(line); + expect(normalizeStreamChunkErrorText(chunk.errorText)).toBe( + 'Response is incomplete due to: max_output_tokens' + ); + }); +}); diff --git a/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts b/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts index d31307101e2..f30280fedbb 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts @@ -1,6 +1,10 @@ /* eslint-disable @typescript-eslint/consistent-type-assertions */ import { processStream } from './stream-parser'; -import { generateId as defaultGenerateId, SerialJobExecutor } from './utils'; +import { + generateId as defaultGenerateId, + normalizeStreamChunkErrorText, + SerialJobExecutor, +} from './utils'; import type { ChatInit, @@ -30,7 +34,16 @@ type ActiveResponse = { * Abstract base class for chat implementations. */ export abstract class AbstractChat { - readonly id: string; + private _chatId: string; + + /** + * Identifier sent as `chatId` / `id` on transport requests. Regenerate after + * clearing the conversation so the backend starts a new thread. + */ + get id(): string { + return this._chatId; + } + readonly generateId: IdGenerator; protected state: ChatState; @@ -59,7 +72,7 @@ export abstract class AbstractChat { }: Omit, 'messages'> & { state: ChatState; }) { - this.id = id; + this._chatId = id; this.generateId = generateId; this.state = state; this.transport = transport; @@ -82,6 +95,24 @@ export abstract class AbstractChat { return this.state.status; } + /** + * Store a fresh {@link Error} instance so UI layers (e.g. React `useConnector` + + * `dequal`) never treat a new failure as “unchanged” when the same object + * reference is reused or only {@link Error#message} is mutated. + */ + private cloneErrorForState(source: Error): Error { + const cause = (source as Error & { cause?: unknown }).cause; + const clone = + cause !== undefined + ? new Error(source.message, { cause }) + : new Error(source.message); + clone.name = source.name; + if (source.stack !== undefined) { + clone.stack = source.stack; + } + return clone; + } + protected setStatus({ status, error, @@ -90,8 +121,13 @@ export abstract class AbstractChat { error?: Error; }): void { this.state.status = status; - if (error !== undefined) { - this.state.error = error; + if (status === 'error' && error !== undefined) { + this.state.error = this.cloneErrorForState(error); + } else if (status !== 'error') { + // Always drop the previous error when leaving `error` (new request, success, + // clear, etc.). Passing `error: undefined` previously did not clear because + // `undefined` was treated as “omit”, which left stale `Error` instances. + this.state.error = undefined; } } @@ -260,24 +296,59 @@ export abstract class AbstractChat { ); } + if (this.activeResponse) { + this.activeResponse.abortController.abort(); + } + + const requestChatId = this._chatId; + const requestAbortController = new AbortController(); + this.activeResponse = { abortController: requestAbortController }; + this.setStatus({ status: 'submitted' }); return this.transport .reconnectToStream({ chatId: this.id, + abortSignal: requestAbortController.signal, ...options, }) .then( (stream) => { if (stream) { - return this.processStreamWithCallbacks(stream); - } else { - this.setStatus({ status: 'ready' }); - return Promise.resolve(); + this.activeResponse!.stream = stream; + return this.processStreamWithCallbacks( + stream, + requestChatId, + requestAbortController + ); + } + + if ( + this.activeResponse?.abortController === requestAbortController + ) { + this.activeResponse = null; } + this.setStatus({ status: 'ready' }); + return Promise.resolve(); }, (error) => { - this.handleError(error as Error); + if ((error as Error).name === 'AbortError') { + if ( + this.activeResponse?.abortController === requestAbortController + ) { + this.activeResponse = null; + } + return Promise.resolve(); + } + this.handleError(error as Error, { + requestChatId, + requestAbortController, + }); + if ( + this.activeResponse?.abortController === requestAbortController + ) { + this.activeResponse = null; + } return Promise.resolve(); } ); @@ -289,10 +360,23 @@ export abstract class AbstractChat { */ clearError = (): void => { if (this.state.status === 'error') { - this.setStatus({ status: 'ready', error: undefined }); + this.setStatus({ status: 'ready' }); + } else if (this.state.error !== undefined) { + this.state.error = undefined; } }; + /** + * Assigns a new id for the next API request so the server opens a fresh + * conversation (e.g. after clearing messages or “new conversation”). + */ + regenerateChatId = (): void => { + this._chatId = this.generateId(); + // Always drop error state when the conversation id rotates so the UI + // cannot show a failure tied to the previous thread. + this.clearError(); + }; + /** * Add a tool result for a tool call. */ @@ -390,6 +474,9 @@ export abstract class AbstractChat { this.activeResponse.abortController.abort(); } + /** Binds failures from this request to the conversation id at send time (see {@link handleError}). */ + const requestChatId = this._chatId; + const abortController = new AbortController(); this.activeResponse = { abortController }; @@ -409,21 +496,30 @@ export abstract class AbstractChat { .then( (stream) => { this.activeResponse!.stream = stream; - return this.processStreamWithCallbacks(stream); + return this.processStreamWithCallbacks( + stream, + requestChatId, + abortController + ); }, (error) => { if ((error as Error).name === 'AbortError') { // Request was aborted, don't treat as error return Promise.resolve(); } - this.handleError(error as Error); + this.handleError(error as Error, { + requestChatId, + requestAbortController: abortController, + }); return Promise.resolve(); } ); } private processStreamWithCallbacks( - stream: ReadableStream + stream: ReadableStream, + requestChatId: string, + requestAbortController: AbortController ): Promise { this.setStatus({ status: 'streaming' }); @@ -441,11 +537,23 @@ export abstract class AbstractChat { // Promise chain for handling tool calls that return promises let pendingToolCall: Promise = Promise.resolve(); + /** After a mid-stream `error` chunk, ignore further deltas until the stream closes. */ + let streamHalted = false; + return new Promise((resolve) => { processStream( stream, // eslint-disable-next-line complexity (chunk) => { + if (streamHalted) { + return; + } + if ( + this.activeResponse?.abortController !== requestAbortController || + requestAbortController.signal.aborted + ) { + return; + } switch (chunk.type) { case 'start': { currentMessageId = chunk.messageId || this.generateId(); @@ -827,7 +935,31 @@ export abstract class AbstractChat { case 'error': { isError = true; - throw new Error(chunk.errorText); + streamHalted = true; + + if (currentMessage && currentMessageIndex >= 0) { + const finalizedParts = currentMessage.parts.map((p) => { + if ( + (p.type === 'text' || p.type === 'reasoning') && + 'state' in p && + p.state === 'streaming' + ) { + return { ...p, state: 'done' as const }; + } + return p; + }); + currentMessage = { + ...currentMessage, + parts: finalizedParts, + } as TUIMessage; + this.state.replaceMessage(currentMessageIndex, currentMessage); + } + + this.handleError( + new Error(normalizeStreamChunkErrorText(chunk.errorText)), + { requestChatId, requestAbortController } + ); + break; } case 'abort': { @@ -873,12 +1005,24 @@ export abstract class AbstractChat { () => { // Wait for any pending tool calls to complete pendingToolCall.then(() => { - // Stream finished successfully - this.setStatus({ status: 'ready' }); - this.activeResponse = null; + const completionStillOwnsActiveResponse = + this.activeResponse?.abortController === requestAbortController; + + // Mid-stream error chunks set status to `error` via handleError; do not overwrite with `ready`. + // Never clear `activeResponse` or set `ready` from a superseded stream (new send / resume). + if (!isError && completionStillOwnsActiveResponse) { + this.setStatus({ status: 'ready' }); + } + if (completionStillOwnsActiveResponse) { + this.activeResponse = null; + } // Trigger onFinish callback - if (this.onFinish && currentMessage) { + if ( + this.onFinish && + currentMessage && + completionStillOwnsActiveResponse + ) { this.onFinish({ message: currentMessage, messages: this.state.messages, @@ -895,16 +1039,32 @@ export abstract class AbstractChat { }); }, (error) => { + const completionStillOwnsActiveResponse = + this.activeResponse?.abortController === requestAbortController; + + if (completionStillOwnsActiveResponse) { + this.activeResponse = null; + } + if (error.name === 'AbortError') { isAbort = true; - this.setStatus({ status: 'ready' }); + if (completionStillOwnsActiveResponse) { + this.setStatus({ status: 'ready' }); + } } else { isDisconnect = true; - this.handleError(error); + this.handleError(error, { + requestChatId, + requestAbortController, + }); } // Still call onFinish even on error/abort - if (this.onFinish && currentMessage) { + if ( + this.onFinish && + currentMessage && + completionStillOwnsActiveResponse + ) { this.onFinish({ message: currentMessage, messages: this.state.messages, @@ -920,7 +1080,35 @@ export abstract class AbstractChat { }); } - private handleError(error: Error): void { + /** + * When {@link regenerateChatId} runs (e.g. after “new conversation”), late + * stream / transport callbacks from the previous id must not repopulate + * {@link AbstractChat#error} or the UI stays on the old failure text. + * + * When {@link requestAbortController} is set, errors from a superseded in-flight + * request (same {@link _chatId} but a newer {@link makeRequest} replaced + * {@link activeResponse}) are ignored as well. + */ + private handleError( + error: Error, + options?: { + requestChatId?: string; + requestAbortController?: AbortController; + } + ): void { + if (options?.requestAbortController !== undefined) { + if ( + this.activeResponse?.abortController !== options.requestAbortController + ) { + return; + } + } else if ( + options?.requestChatId !== undefined && + options.requestChatId !== this._chatId + ) { + return; + } + this.setStatus({ status: 'error', error }); if (this.onError) { diff --git a/packages/instantsearch.js/src/lib/ai-lite/index.ts b/packages/instantsearch.js/src/lib/ai-lite/index.ts index 3f69531f593..a41588f1492 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/index.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/index.ts @@ -7,12 +7,17 @@ // Classes export { AbstractChat } from './abstract-chat'; -export { DefaultChatTransport, HttpChatTransport } from './transport'; +export { + DefaultChatTransport, + HttpChatTransport, + getHttpErrorMessageFromResponse, +} from './transport'; // Utilities export { generateId, lastAssistantMessageIsCompleteWithToolCalls, + normalizeStreamChunkErrorText, SerialJobExecutor, } from './utils'; diff --git a/packages/instantsearch.js/src/lib/ai-lite/stream-parser.ts b/packages/instantsearch.js/src/lib/ai-lite/stream-parser.ts index 570536fb883..3e5f51853f0 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/stream-parser.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/stream-parser.ts @@ -124,7 +124,14 @@ export function processStream( return; } - const result = onChunk(value); + let result: void | Promise; + try { + result = onChunk(value); + } catch (error) { + reader.releaseLock(); + onError(error as Error); + return; + } if (result && typeof result.then === 'function') { result.then( () => read(), diff --git a/packages/instantsearch.js/src/lib/ai-lite/transport.ts b/packages/instantsearch.js/src/lib/ai-lite/transport.ts index a05b2eccdc1..68129be5eef 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/transport.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/transport.ts @@ -15,6 +15,27 @@ import type { Resolvable, } from './types'; +/** + * Reads a failed HTTP response and returns a human-readable message, + * preferring JSON bodies shaped like `{ message: string }` (e.g. Agent Studio). + */ +export function getHttpErrorMessageFromResponse( + response: Response +): Promise { + const fallback = `HTTP error: ${response.status} ${response.statusText}`; + + return response + .json() + .then((data) => { + const parsed = data as { message?: unknown }; + if (typeof parsed?.message === 'string' && parsed.message.trim()) { + return parsed.message; + } + return fallback; + }) + .catch(() => fallback); +} + /** * Abstract base class for HTTP-based chat transports. */ @@ -130,9 +151,9 @@ export abstract class HttpChatTransport credentials, }).then((response) => { if (!response.ok) { - throw new Error( - `HTTP error: ${response.status} ${response.statusText}` - ); + return getHttpErrorMessageFromResponse(response).then((message) => { + throw new Error(message); + }); } if (!response.body) { @@ -147,6 +168,7 @@ export abstract class HttpChatTransport reconnectToStream({ chatId, + abortSignal, headers: requestHeaders, body: requestBody, }: Parameters< @@ -207,15 +229,16 @@ export abstract class HttpChatTransport method: 'GET', headers, credentials, + signal: abortSignal, }).then((response) => { if (!response.ok) { // 404 means no stream to reconnect to, which is not an error if (response.status === 404) { return null; } - throw new Error( - `HTTP error: ${response.status} ${response.statusText}` - ); + return getHttpErrorMessageFromResponse(response).then((message) => { + throw new Error(message); + }); } if (!response.body) { diff --git a/packages/instantsearch.js/src/lib/ai-lite/types.ts b/packages/instantsearch.js/src/lib/ai-lite/types.ts index a317953ae42..e8bad83186f 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/types.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/types.ts @@ -339,6 +339,7 @@ export interface ChatTransport { reconnectToStream: ( options: { chatId: string; + abortSignal?: AbortSignal; } & ChatRequestOptions ) => Promise | null>; } diff --git a/packages/instantsearch.js/src/lib/ai-lite/utils.ts b/packages/instantsearch.js/src/lib/ai-lite/utils.ts index 40fbd4931c8..980df176551 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/utils.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/utils.ts @@ -92,3 +92,57 @@ export function resolveValue( } return Promise.resolve(value); } + +/** + * Produces a readable message string from a stream `error` chunk's `errorText` + * (may be JSON-encoded or nested, e.g. LangGraph payloads mid-response). + * Unwrapping order matches `flattenErrorMessageForMatching` in + * instantsearch-ui-components (`message` before `error`) so `Error.message` + * matches what the chat UI uses for short copy. + */ +export function normalizeStreamChunkErrorText( + raw: string | null | undefined +): string { + if (raw == null || raw === '') { + return ''; + } + + let best = raw; + let remaining = raw.trim(); + + for (let i = 0; i < 8; i += 1) { + try { + const parsed: unknown = JSON.parse(remaining); + if (typeof parsed === 'string') { + best = parsed; + remaining = parsed.trim(); + continue; + } + if ( + parsed && + typeof parsed === 'object' && + 'message' in parsed && + typeof (parsed as { message: unknown }).message === 'string' + ) { + best = (parsed as { message: string }).message; + remaining = best.trim(); + continue; + } + if ( + parsed && + typeof parsed === 'object' && + 'error' in parsed && + typeof (parsed as { error: unknown }).error === 'string' + ) { + best = (parsed as { error: string }).error; + remaining = best.trim(); + continue; + } + break; + } catch { + break; + } + } + + return best; +} diff --git a/packages/instantsearch.js/src/lib/chat/chat.ts b/packages/instantsearch.js/src/lib/chat/chat.ts index a629a8886ab..b43c297888a 100644 --- a/packages/instantsearch.js/src/lib/chat/chat.ts +++ b/packages/instantsearch.js/src/lib/chat/chat.ts @@ -64,7 +64,9 @@ export class ChatState } get error(): Error | undefined { - return this._error; + // Invariant: error is only meaningful while `status === 'error'`. Otherwise + // callers (connectors, React) can observe a stale Error after transitions. + return this._status === 'error' ? this._error : undefined; } set error(newError: Error | undefined) { diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index c83d2987353..75684c9b199 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -295,6 +295,7 @@ type ChatWrapperProps = { regenerate: ChatRenderState['regenerate']; stop: ChatRenderState['stop']; error: ChatRenderState['error']; + chatInstanceId: ChatRenderState['id']; isClearing: boolean; clearMessages: () => void; onClearTransitionEnd: () => void; @@ -369,6 +370,7 @@ function ChatWrapper({ regenerate, stop, error, + chatInstanceId, isClearing, clearMessages, onClearTransitionEnd, @@ -382,6 +384,7 @@ function ChatWrapper({ suggestionsProps, state, }: ChatWrapperProps) { + const displayError = chatStatus === 'error' ? error : undefined; const { scrollRef, contentRef, scrollToBottom, isAtBottom } = useStickToBottom({ initial: 'smooth', @@ -401,7 +404,7 @@ function ChatWrapper({ sendMessage={sendMessage} regenerate={regenerate} stop={stop} - error={error} + error={displayError} toggleButtonComponent={toggleButtonProps.layoutComponent} toggleButtonProps={{ open: chatOpen, @@ -415,8 +418,10 @@ function ChatWrapper({ onClose: () => setChatOpen(false), maximized, onToggleMaximize: () => setMaximized(!maximized), - onClear: clearMessages, - canClear: Boolean(chatMessages?.length) && !isClearing, + onNewConversation: clearMessages, + canStartNewConversation: + (Boolean(chatMessages?.length) || chatStatus === 'error') && + !isClearing, closeIconComponent: headerProps.closeIconComponent, minimizeIconComponent: headerProps.minimizeIconComponent, maximizeIconComponent: headerProps.maximizeIconComponent, @@ -449,6 +454,7 @@ function ChatWrapper({ messageTranslations: messagesProps.messageTranslations, sendMessage: messagesProps.sendMessage, setInput: messagesProps.setInput, + conversationId: chatInstanceId, }} promptProps={{ promptRef: promptProps.promptRef, @@ -518,6 +524,7 @@ const createRenderer = ({ suggestions, sendChatMessageFeedback: onFeedback, feedbackState, + id: chatInstanceId, } = props; if (__DEV__ && error) { @@ -631,7 +638,7 @@ const createRenderer = ({ minimizeLabel: templates.header?.minimizeLabelText, maximizeLabel: templates.header?.maximizeLabelText, closeLabel: templates.header?.closeLabelText, - clearLabel: templates.header?.clearLabelText, + newConversationLabel: templates.header?.newConversationLabelText, }); const messagesTemplateProps = prepareTemplateProps({ @@ -690,6 +697,9 @@ const createRenderer = ({ loaderText: templates.messages?.loaderText, copyToClipboardLabel: templates.messages?.copyToClipboardLabelText, regenerateLabel: templates.messages?.regenerateLabelText, + conversationLimitErrorMessage: + templates.messages?.conversationLimitErrorMessageText, + genericChatErrorMessage: templates.messages?.genericChatErrorMessageText, }); const assistantMessageTemplateProps = prepareTemplateProps({ @@ -923,6 +933,7 @@ const createRenderer = ({ regenerate={regenerate} stop={stop} error={error} + chatInstanceId={chatInstanceId} isClearing={isClearing} clearMessages={clearMessages} onClearTransitionEnd={onClearTransitionEnd} @@ -1081,9 +1092,9 @@ export type ChatTemplates = BaseHit> = */ closeLabelText: string; /** - * Text for the clear button + * Accessible label for the new-conversation control */ - clearLabelText: string; + newConversationLabelText: string; }>; /** @@ -1114,6 +1125,16 @@ export type ChatTemplates = BaseHit> = * Label for the regenerate action */ regenerateLabelText?: string; + /** + * Overrides the default message for “start a new conversation” errors + * (otherwise the API error text is shown). When omitted, built-in mappings apply. + */ + conversationLimitErrorMessageText?: string; + /** + * Overrides short copy for other chat errors (e.g. output limit). When + * omitted, built-in mappings from instantsearch-ui-components apply. + */ + genericChatErrorMessageText?: string; }>; /** diff --git a/packages/react-instantsearch-core/src/hooks/useConnector.ts b/packages/react-instantsearch-core/src/hooks/useConnector.ts index f6cc76b00a8..d614981d3c0 100644 --- a/packages/react-instantsearch-core/src/hooks/useConnector.ts +++ b/packages/react-instantsearch-core/src/hooks/useConnector.ts @@ -72,6 +72,49 @@ export function useConnector< const { instantSearchInstance, widgetParams, ...renderState } = connectorState; + const prev = previousRenderStateRef.current; + const prevError = + prev && typeof prev === 'object' && 'error' in prev + ? (prev as { error?: unknown }).error + : undefined; + const nextError = + renderState && + typeof renderState === 'object' && + 'error' in renderState + ? (renderState as { error?: unknown }).error + : undefined; + // Chat can transition to/from `error` while InstantSearch’s global + // `status` stays `idle`, so we must not rely only on `dequal` + IS + // `status` for syncing `renderState.error` into React. + const errorRefChanged = prevError !== nextError; + + const prevId = + prev && typeof prev === 'object' && 'id' in prev + ? (prev as { id?: unknown }).id + : undefined; + const nextId = + renderState && + typeof renderState === 'object' && + 'id' in renderState + ? (renderState as { id?: unknown }).id + : undefined; + // Chat (and similar) expose `id`; when it changes (e.g. new conversation), + // React must re-sync even if `dequal` treats the rest as unchanged. + const renderStateIdChanged = prevId !== nextId; + + const prevConnectorStatus = + prev && typeof prev === 'object' && 'status' in prev + ? (prev as { status?: unknown }).status + : undefined; + const nextConnectorStatus = + renderState && + typeof renderState === 'object' && + 'status' in renderState + ? (renderState as { status?: unknown }).status + : undefined; + const connectorLifecycleStatusChanged = + prevConnectorStatus !== nextConnectorStatus; + // We only update the state when a widget render state param changes, // except for functions. We ignore function reference changes to avoid // infinite loops. It's safe to omit them because they get updated @@ -83,7 +126,10 @@ export function useConnector< (a, b) => a?.constructor === Function && b?.constructor === Function ) || - instantSearchInstance.status !== previousStatusRef.current + instantSearchInstance.status !== previousStatusRef.current || + errorRefChanged || + renderStateIdChanged || + connectorLifecycleStatusChanged ) { // eslint-disable-next-line @typescript-eslint/no-use-before-define setState(renderState); diff --git a/packages/react-instantsearch-core/src/lib/__tests__/dequal.test.ts b/packages/react-instantsearch-core/src/lib/__tests__/dequal.test.ts index 30f9ce4b192..5ee085f07dc 100644 --- a/packages/react-instantsearch-core/src/lib/__tests__/dequal.test.ts +++ b/packages/react-instantsearch-core/src/lib/__tests__/dequal.test.ts @@ -32,4 +32,13 @@ describe('dequal', () => { true ); }); + + test('distinct Error instances are never equal (even with the same .message)', () => { + expect(dequal(new Error('x'), new Error('x'))).toEqual(false); + }); + + test('same Error reference is equal', () => { + const e = new Error('x'); + expect(dequal(e, e)).toEqual(true); + }); }); diff --git a/packages/react-instantsearch-core/src/lib/dequal.ts b/packages/react-instantsearch-core/src/lib/dequal.ts index b06c4f83b29..bad5687b572 100644 --- a/packages/react-instantsearch-core/src/lib/dequal.ts +++ b/packages/react-instantsearch-core/src/lib/dequal.ts @@ -27,6 +27,20 @@ export function dequal( let len; if (foo === bar) return true; + // `Error` instances often have no enumerable own properties, so the object + // branch below can incorrectly treat two different failures as equal. That + // skipped React updates in useConnector when a new Error had the same + // `.message` as the previous one (stale chat error UI). + if ( + foo && + bar && + foo instanceof Error && + bar instanceof Error && + foo !== bar + ) { + return false; + } + if (foo && bar && (ctor = foo.constructor) === bar.constructor) { if (ctor === Date) return foo.getTime() === bar.getTime(); if (ctor === RegExp) return foo.toString() === bar.toString(); diff --git a/packages/react-instantsearch/src/widgets/Chat.tsx b/packages/react-instantsearch/src/widgets/Chat.tsx index a8940733176..97ef8c4ba8d 100644 --- a/packages/react-instantsearch/src/widgets/Chat.tsx +++ b/packages/react-instantsearch/src/widgets/Chat.tsx @@ -1,4 +1,8 @@ -import { createChatComponent } from 'instantsearch-ui-components'; +import { + createChatComponent, + isRequestOriginNotAllowedError, + isStartNewConversationError, +} from 'instantsearch-ui-components'; import { SearchIndexToolType, RecommendToolType, @@ -236,6 +240,7 @@ function ChatInner< }); const { + id: chatInstanceId, messages, sendMessage, status, @@ -255,6 +260,8 @@ function ChatInner< feedbackState, } = chatState; + const displayError = status === 'error' ? error : undefined; + useImperativeHandle(ref, () => ({ setOpen, sendMessage: (params: { text: string }) => sendMessage(params), @@ -274,10 +281,18 @@ function ChatInner< wasOpenRef.current = open; }, [open]); - if (__DEV__ && error) { - throw error; + if ( + __DEV__ && + displayError && + !isStartNewConversationError(displayError) && + !isRequestOriginNotAllowedError(displayError) + ) { + throw displayError; } + const defaultCanStartNewConversation = + (Boolean(messages?.length) || status === 'error') && !isClearing; + return ( setOpen(false), maximized, onToggleMaximize: () => setMaximized(!maximized), - onClear: clearMessages, - canClear: Boolean(messages?.length) && !isClearing, titleIconComponent: headerTitleIconComponent, closeIconComponent: headerCloseIconComponent, minimizeIconComponent: headerMinimizeIconComponent, maximizeIconComponent: headerMaximizeIconComponent, translations: headerTranslations, ...headerProps, + onNewConversation: headerProps?.onNewConversation ?? clearMessages, + canStartNewConversation: + headerProps?.canStartNewConversation ?? defaultCanStartNewConversation, }} messagesProps={{ status, @@ -346,6 +362,7 @@ function ChatInner< translations: messagesTranslations, messageTranslations, ...messagesProps, + conversationId: chatInstanceId, }} promptProps={{ promptRef, diff --git a/tests/common/widgets/chat/options.tsx b/tests/common/widgets/chat/options.tsx index c7711226eb3..a87b9097c1d 100644 --- a/tests/common/widgets/chat/options.tsx +++ b/tests/common/widgets/chat/options.tsx @@ -350,7 +350,7 @@ export function createOptionsTests( ); }); - test('stops streaming and clears when clear is clicked during streaming', async () => { + test('stops streaming and clears when new conversation is clicked during streaming', async () => { const searchClient = createSearchClient(); const chat = new Chat({}); @@ -379,35 +379,35 @@ export function createOptionsTests( await wait(0); }); - const clearButton = document.querySelector( - '.ais-ChatHeader-clear' + const newConversationButton = document.querySelector( + '.ais-ChatHeader-newConversation' ) as HTMLButtonElement; - expect(clearButton).not.toBeDisabled(); + expect(newConversationButton).not.toBeDisabled(); - // Clear button remains enabled during submitted status + // New conversation remains enabled during submitted status await act(async () => { chat._state.status = 'submitted'; await wait(0); }); - expect(clearButton).not.toBeDisabled(); + expect(newConversationButton).not.toBeDisabled(); - // Clear button remains enabled during streaming status + // New conversation remains enabled during streaming status await act(async () => { chat._state.status = 'streaming'; await wait(0); }); - expect(clearButton).not.toBeDisabled(); + expect(newConversationButton).not.toBeDisabled(); - // Clicking clear during streaming stops the stream and begins clearing + // Clicking new conversation during streaming stops the stream and begins clearing await act(async () => { - clearButton.click(); + newConversationButton.click(); await wait(0); }); expect(chat._state.status).toBe('ready'); - expect(clearButton).toBeDisabled(); + expect(newConversationButton).toBeDisabled(); }); describe('cssClasses', () => { diff --git a/tests/common/widgets/chat/translations.tsx b/tests/common/widgets/chat/translations.tsx index 07d698c481a..3d6978bf3b7 100644 --- a/tests/common/widgets/chat/translations.tsx +++ b/tests/common/widgets/chat/translations.tsx @@ -28,7 +28,7 @@ export function createTranslationsTests( header: { titleText: 'Custom title', closeLabelText: 'Custom close button label', - clearLabelText: 'Custom clear button label', + newConversationLabelText: 'Custom new conversation label', maximizeLabelText: 'Custom maximize button label', minimizeLabelText: 'Custom minimize button label', }, @@ -40,7 +40,7 @@ export function createTranslationsTests( header: { title: 'Custom title', closeLabel: 'Custom close button label', - clearLabel: 'Custom clear button label', + newConversationLabel: 'Custom new conversation label', maximizeLabel: 'Custom maximize button label', minimizeLabel: 'Custom minimize button label', }, @@ -61,8 +61,10 @@ export function createTranslationsTests( .getAttribute('aria-label') ).toBe('Custom close button label'); expect( - document.querySelector('.ais-ChatHeader-clear')!.textContent - ).toBe('Custom clear button label'); + document + .querySelector('.ais-ChatHeader-newConversation')! + .getAttribute('aria-label') + ).toBe('Custom new conversation label'); expect( document .querySelector('.ais-ChatHeader-maximize')!