Skip to content
6 changes: 3 additions & 3 deletions bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,19 @@
},
{
"path": "./packages/instantsearch.js/dist/instantsearch.production.min.js",
"maxSize": "126.5 kB"
"maxSize": "127 kB"
},
{
"path": "./packages/instantsearch.js/dist/instantsearch.development.js",
"maxSize": "262.75 kB"
"maxSize": "263.75 kB"
},
{
"path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js",
"maxSize": "61.5 kB"
},
{
"path": "packages/react-instantsearch/dist/umd/ReactInstantSearch.min.js",
"maxSize": "99.25 kB"
"maxSize": "99.5 kB"
},
{
"path": "packages/vue-instantsearch/vue2/umd/index.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export function createChatComponent({ createElement, Fragment }: Renderer) {
const messagesComponent = (
<ChatMessages
{...messagesProps}
error={error}
classNames={classNames.messages}
messageClassNames={classNames.message}
suggestionsElement={createElement(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,20 @@ export type ChatMessageErrorTranslations = {
/**
* Error message text
*/
errorMessage: string;
errorMessage:
| string
| ((params: { errorMessage?: string }) => string);
/**
* Retry button text
*/
retryText: string;
};

export type ChatMessageErrorProps = ComponentProps<'article'> & {
/**
* Raw error message received from the API/transport layer.
*/
errorMessage?: string;
/**
* Callback for reload action
*/
Expand All @@ -39,17 +45,21 @@ export function createChatMessageErrorComponent({

return function ChatMessageError(userProps: ChatMessageErrorProps) {
const {
errorMessage,
onReload,
actions,
translations: userTranslations,
...props
} = userProps;
const translations: Required<ChatMessageErrorTranslations> = {
errorMessage:
'Sorry, we are not able to generate a response at the moment. Please retry or contact support.',
retryText: 'Retry',
...userTranslations,
};
const defaultErrorMessage =
'Sorry, we are not able to generate a response at the moment. Please retry or contact support.';
const defaultRetryText = 'Retry';
const errorMessageTranslation = userTranslations?.errorMessage;
const resolvedErrorMessage =
typeof errorMessageTranslation === 'function'
? errorMessageTranslation({ errorMessage })
: errorMessageTranslation ?? defaultErrorMessage;
const retryText = userTranslations?.retryText ?? defaultRetryText;

return (
<article
Expand All @@ -59,7 +69,7 @@ export function createChatMessageErrorComponent({
<div className="ais-ChatMessage-container">
<div className="ais-ChatMessage-content">
<div className="ais-ChatMessage-message">
{translations.errorMessage}
{resolvedErrorMessage}
</div>
{(actions || onReload) && (
<div className="ais-ChatMessage-actions">
Expand All @@ -82,7 +92,7 @@ export function createChatMessageErrorComponent({
onClick={() => onReload?.()}
>
<ReloadIcon createElement={createElement} />
{translations.retryText}
{retryText}
</Button>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ export type ChatMessagesProps<
* Current chat status
*/
status?: ChatStatus;
/**
* Error from the last failed request, if any. When set, its `message` is
* available to custom error components or translation functions (for example
* API `message` fields on 403 responses).
*/
error?: Error;
/**
* Whether to hide the scroll to bottom button
*/
Expand Down Expand Up @@ -381,6 +387,7 @@ export function createChatMessagesComponent({
indexUiState,
setIndexUiState,
status = 'ready',
error,
hideScrollToBottom = false,
onReload,
onClose,
Expand Down Expand Up @@ -504,7 +511,12 @@ export function createChatMessagesComponent({
/>
)}

{status === 'error' && <DefaultError onReload={onReload} />}
{status === 'error' && (
<DefaultError
onReload={onReload}
errorMessage={error?.message}
/>
)}
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@
* @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts
*/
/** @jsx createElement */
import { render } from '@testing-library/preact';
import { render, screen } from '@testing-library/preact';
import { Fragment, createElement } from 'preact';

import { createChatMessageErrorComponent } from '../ChatMessageError';
import { createChatMessagesComponent } from '../ChatMessages';

import type { ChatMessageErrorProps } from '../ChatMessageError';

const ChatMessages = createChatMessagesComponent({
createElement,
Fragment,
});
const ChatMessageError = createChatMessageErrorComponent({ createElement });

describe('ChatMessages', () => {
test('renders with default props', () => {
Expand Down Expand Up @@ -256,6 +260,85 @@ describe('ChatMessages', () => {
});
});

test('does not expose raw API error message by default', () => {
render(
<ChatMessages
messages={[]}
indexUiState={{}}
setIndexUiState={jest.fn()}
tools={{}}
onReload={jest.fn()}
onClose={jest.fn()}
status="error"
error={new Error('Request blocked for this domain')}
/>
);

expect(
screen.getByText(
'Sorry, we are not able to generate a response at the moment. Please retry or contact support.'
)
).toBeInTheDocument();
expect(
screen.queryByText('Request blocked for this domain')
).not.toBeInTheDocument();
});

test('passes raw error message to custom error component', () => {
const ErrorComponent = jest.fn(() => <span>Custom error</span>);

render(
<ChatMessages
messages={[]}
indexUiState={{}}
setIndexUiState={jest.fn()}
tools={{}}
onReload={jest.fn()}
onClose={jest.fn()}
status="error"
error={new Error('Request blocked for this domain')}
errorComponent={ErrorComponent}
/>
);

expect(ErrorComponent).toHaveBeenCalledWith(
expect.objectContaining({
errorMessage: 'Request blocked for this domain',
}),
{}
);
});

test('allows error translation to use raw error message', () => {
const CustomError = (props: ChatMessageErrorProps) => (
<ChatMessageError
{...props}
translations={{
errorMessage: ({ errorMessage }) =>
errorMessage ? `Friendly: ${errorMessage}` : 'Friendly fallback',
}}
/>
);

render(
<ChatMessages
messages={[]}
indexUiState={{}}
setIndexUiState={jest.fn()}
tools={{}}
onReload={jest.fn()}
onClose={jest.fn()}
status="error"
error={new Error('Request blocked for this domain')}
errorComponent={CustomError}
/>
);

expect(
screen.getByText('Friendly: Request blocked for this domain')
).toBeInTheDocument();
});

test('renders with custom class names', () => {
const { container } = render(
<ChatMessages
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,8 @@ export interface AbstractChat<TUIMessage extends UIMessage> {
body?: object;
}) => Promise<void>;

resetConversationId: () => void;

clearError: () => void;

addToolResult: <TTool extends keyof InferUIMessageTools<TUIMessage>>(params: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ describe('connectChat', () => {
const { getRenderState } = getInitializedWidget();

const renderState = getRenderState();
const conversationIdBeforeClear = renderState.id;

const message: UIMessage = {
id: '1',
Expand All @@ -352,12 +353,14 @@ describe('connectChat', () => {

let updatedRenderState = getRenderState();
expect(updatedRenderState.isClearing).toBe(true);
expect(updatedRenderState.id).toBe(conversationIdBeforeClear);

renderState.onClearTransitionEnd();

updatedRenderState = getRenderState();
expect(updatedRenderState.isClearing).toBe(false);
expect(updatedRenderState.messages).toHaveLength(0);
expect(updatedRenderState.id).not.toBe(conversationIdBeforeClear);
});

it('regenerates the chat id on transition end so the server starts a fresh conversation', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ export default (function connectChat<TWidgetParams extends UnknownWidgetParams>(
const onClearTransitionEnd = () => {
setMessages([]);
_chatInstance.clearError();
_chatInstance.regenerateId();
_chatInstance.resetConversationId();
feedbackState = {};
setIsClearing(false);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,4 +188,31 @@ describe('stream-parser', () => {
});
});
});

describe('processStream', () => {
it('routes synchronous throws from onChunk to onError', () => {
const stream = new ReadableStream<number>({
start(controller) {
controller.enqueue(1);
controller.close();
},
});

return new Promise<void>((resolve, reject) => {
processStream(
stream,
(value) => {
if (value === 1) {
throw new Error('stream chunk error');
}
},
() => reject(new Error('expected onError, not onDone')),
(error) => {
expect(error.message).toBe('stream chunk error');
resolve();
}
);
});
});
});
});
39 changes: 39 additions & 0 deletions packages/instantsearch.js/src/lib/ai-lite/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts
*/
import { tryParseErrorMessage } from '../utils';

describe('tryParseErrorMessage', () => {
it('returns the trimmed `message` from a JSON object', () => {
expect(
tryParseErrorMessage('{"message":" Something went wrong "}')
).toBe('Something went wrong');
});

it('returns the `message` from a full ErrorResponse payload', () => {
expect(
tryParseErrorMessage(
'{"message":"Max steps per completion limit was reached","type":"MaxStepsPerCompletionError","statusCode":400}'
)
).toBe('Max steps per completion limit was reached');
});

it('returns undefined for non-JSON input', () => {
expect(tryParseErrorMessage('plain failure')).toBeUndefined();
});

it('returns undefined for JSON without a string `message`', () => {
expect(tryParseErrorMessage('{"type":"CustomError"}')).toBeUndefined();
expect(tryParseErrorMessage('{"message":123}')).toBeUndefined();
expect(tryParseErrorMessage('{"message":" "}')).toBeUndefined();
});

it('returns undefined for arrays and primitives', () => {
expect(tryParseErrorMessage('[{"message":"nope"}]')).toBeUndefined();
expect(tryParseErrorMessage('"just a string"')).toBeUndefined();
});

it('returns undefined for empty input', () => {
expect(tryParseErrorMessage('')).toBeUndefined();
});
});
Loading
Loading