Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/react/e-commerce/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
43 changes: 38 additions & 5 deletions packages/instantsearch-ui-components/src/components/chat/Chat.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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,
{
Expand All @@ -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,
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
MaximizeIcon as MaximizeIconDefault,
MinimizeIcon as MinimizeIconDefault,
CloseIcon as CloseIconDefault,
SquarePenIcon,
} from './icons';

import type { Renderer, ComponentProps } from '../../types';
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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;
/**
Expand All @@ -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
*/
Expand All @@ -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<ChatHeaderTranslations> = {
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 ? (
<MinimizeIconDefault createElement={createElement} />
) : (
Expand All @@ -161,15 +190,26 @@ export function createChatHeaderComponent({ createElement }: Renderer) {
{translations.title}
</span>
<div className={cx('ais-ChatHeader-actions')}>
{onClear && (
{handleStartNewConversation && (
<Button
variant="ghost"
size="sm"
className={cx('ais-ChatHeader-clear', classNames.clear)}
onClick={onClear}
disabled={!canClear}
iconOnly
className={cx(
'ais-ChatHeader-newConversation',
classNames.newConversation
)}
onClick={handleStartNewConversation}
disabled={startNewConversationDisabled}
aria-label={translations.newConversationLabel}
title={translations.newConversationLabel}
type="button"
>
{translations.clearLabel}
{NewConversationIcon ? (
<NewConversationIcon />
) : (
<SquarePenIcon createElement={createElement} />
)}
</Button>
)}
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function createChatInlineLayoutComponent({ createElement }: Renderer) {
messages: _messages,
status: _status,
isClearing: _isClearing,
clearMessages: _clearMessages,
onNewConversation: _onNewConversation,
onClearTransitionEnd: _onClearTransitionEnd,
suggestions: _suggestions,
tools: _tools,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
/** @jsx createElement */
/** @jsxFrag Fragment */

import { cx } from '../../lib';
import { createButtonComponent } from '../Button';

import { ReloadIcon } from './icons';

import type { ComponentProps, Renderer } from '../../types';

export type ChatMessageErrorVariant = 'default' | 'conversationLimit';

export type ChatMessageErrorTranslations = {
/**
* Error message text
Expand All @@ -15,13 +19,29 @@ export type ChatMessageErrorTranslations = {
* Retry button text
*/
retryText: string;
/**
* Text for the conversation-limit action (link-style button).
*/
conversationLimitActionLabel: string;
};

export type ChatMessageErrorProps = ComponentProps<'article'> & {
/**
* Presentation variant; `conversationLimit` is the emphasized alert layout
* (bordered block, primary copy) for non-retryable cases: start a new chat
* (thread depth, recursion, `max_output_tokens`, …), Agent Studio allowed-domains
* / request-origin errors, etc. Optional actions depend on callbacks, not only
* on this variant.
*/
variant?: ChatMessageErrorVariant;
/**
* Callback for reload action
*/
onReload?: () => void;
/**
* When `variant` is `conversationLimit`, shown as a link-style control (e.g. same handler as header Clear).
*/
onStartNewConversation?: () => void;
/**
* Custom action buttons
*/
Expand All @@ -34,34 +54,64 @@ export type ChatMessageErrorProps = ComponentProps<'article'> & {

export function createChatMessageErrorComponent({
createElement,
}: Pick<Renderer, 'createElement'>) {
Fragment,
}: Pick<Renderer, 'createElement' | 'Fragment'>) {
const Button = createButtonComponent({ createElement });

return function ChatMessageError(userProps: ChatMessageErrorProps) {
const {
variant = 'default',
onReload,
onStartNewConversation,
actions,
translations: userTranslations,
className,
...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',
conversationLimitActionLabel: 'Start a new conversation',
...userTranslations,
};

const isConversationLimit = variant === 'conversationLimit';

return (
<article
className="ais-ChatMessageError ais-ChatMessage ais-ChatMessage--left ais-ChatMessage--subtle"
className={cx(
'ais-ChatMessageError ais-ChatMessage ais-ChatMessage--left ais-ChatMessage--subtle',
isConversationLimit && 'ais-ChatMessageError--conversationLimit',
className
)}
{...props}
>
<div className="ais-ChatMessage-container">
<div className="ais-ChatMessage-content">
<div className="ais-ChatMessage-message">
{translations.errorMessage}
</div>
{(actions || onReload) && (
{isConversationLimit ? (
<Fragment>
<p className="ais-ChatMessageError-primary">
{translations.errorMessage}
</p>
{onStartNewConversation && (
<div className="ais-ChatMessageError-hint">
<button
type="button"
className="ais-ChatMessageError-link"
onClick={() => onStartNewConversation()}
>
{translations.conversationLimitActionLabel}
</button>
</div>
)}
</Fragment>
) : (
<div className="ais-ChatMessage-message">
{translations.errorMessage}
</div>
)}
{(actions || (!isConversationLimit && onReload)) && (
<div className="ais-ChatMessage-actions">
{actions ? (
actions.map((action, index) => (
Expand Down
Loading
Loading