diff --git a/bundlesize.config.json b/bundlesize.config.json index 1643c234e0..726cda7f05 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -22,7 +22,7 @@ }, { "path": "packages/react-instantsearch/dist/umd/ReactInstantSearch.min.js", - "maxSize": "77.5 kB" + "maxSize": "81.25 kB" }, { "path": "packages/vue-instantsearch/vue2/umd/index.js", @@ -42,11 +42,11 @@ }, { "path": "./packages/instantsearch.css/themes/algolia.css", - "maxSize": "4 kB" + "maxSize": "6.75 kB" }, { "path": "./packages/instantsearch.css/themes/algolia-min.css", - "maxSize": "3.75 kB" + "maxSize": "6.25 kB" }, { "path": "./packages/instantsearch.css/themes/reset.css", @@ -58,11 +58,19 @@ }, { "path": "./packages/instantsearch.css/themes/satellite.css", - "maxSize": "7 kB" + "maxSize": "7.90 kB" }, { "path": "./packages/instantsearch.css/themes/satellite-min.css", - "maxSize": "6.25 kB" + "maxSize": "7.25 kB" + }, + { + "path": "./packages/instantsearch.css/components/chat.css", + "maxSize": "3.50 kB" + }, + { + "path": "./packages/instantsearch.css/components/chat-min.css", + "maxSize": "3.25 kB" } ] } diff --git a/packages/instantsearch-ui-components/src/components/chat/Chat.tsx b/packages/instantsearch-ui-components/src/components/chat/Chat.tsx index da6687ab57..b3721c4825 100644 --- a/packages/instantsearch-ui-components/src/components/chat/Chat.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/Chat.tsx @@ -7,21 +7,26 @@ import { createChatMessagesComponent } from './ChatMessages'; import { createChatPromptComponent } from './ChatPrompt'; import { createChatToggleButtonComponent } from './ChatToggleButton'; -import type { Renderer } from '../../types'; +import type { MutableRef, Renderer, ComponentProps } from '../../types'; import type { ChatHeaderProps } from './ChatHeader'; import type { ChatMessagesProps } from './ChatMessages'; import type { ChatPromptProps } from './ChatPrompt'; import type { ChatToggleButtonProps } from './ChatToggleButton'; export type ChatClassNames = { + root?: string | string[]; container?: string | string[]; }; -export type ChatProps = { +export type ChatProps = Omit, 'onError'> & { /* * Whether the chat is open or closed. */ open: boolean; + /* + * Whether the chat is maximized or not. + */ + maximized?: boolean; /* * Props for the ChatHeader component. */ @@ -53,27 +58,50 @@ export function createChatComponent({ createElement, Fragment }: Renderer) { const ChatMessages = createChatMessagesComponent({ createElement, Fragment }); const ChatPrompt = createChatPromptComponent({ createElement, Fragment }); + const promptRef: MutableRef = { current: null }; + return function Chat({ open, + maximized = false, headerProps, toggleButtonProps, messagesProps, - promptProps, + promptProps = {}, classNames = {}, + className, + ...props }: ChatProps) { return ( - <> - {!open ? ( - - ) : ( -
- - - - -
+
+ > +
+ + + +
+ + { + toggleButtonProps.onClick?.(); + promptRef.current?.focus(); + }} + /> +
); }; } diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatHeader.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatHeader.tsx index 6f1b230989..2f18133a82 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatHeader.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatHeader.tsx @@ -1,57 +1,204 @@ /** @jsx createElement */ import { cx } from '../../lib'; +import { + SparklesIconComponent, + MaximizeIconComponent as MaximizeIconComponentDefault, + MinimizeIconComponent as MinimizeIconComponentDefault, + CloseIconComponent as CloseIconComponentDefault, +} from './icons'; + import type { Renderer, ComponentProps } from '../../types'; +export type ChatHeaderTranslations = { + /** + * The title to display in the header + */ + title: string; + /** + * Accessible label for the minimize button + */ + minimizedLabel: string; + /** + * Accessible label for the maximize button + */ + maximizeLabel: string; + /** + * Accessible label for the close button + */ + closeLabel: string; + /** + * Text for the clear button + */ + clearLabel: string; +}; + export type ChatHeaderClassNames = { + /** + * Class names to apply to the root element + */ root?: string | string[]; + /** + * Class names to apply to the title element + */ title?: string | string[]; + /** + * Class names to apply to the title icon element + */ + titleIcon?: string | string[]; + /** + * Class names to apply to the maximize button element + */ + maximize?: string | string[]; + /** + * Class names to apply to the close button element + */ close?: string | string[]; + /** + * Class names to apply to the clear button element + */ + clear?: string | string[]; }; -export type ChatHeaderProps = Omit, 'title'> & { +export type ChatHeaderProps = ComponentProps<'div'> & { /** - * The title to display in the header + * Whether the chat is maximized + */ + maximized?: boolean; + /** + * Callback when the maximize button is clicked */ - title?: string; + onToggleMaximize?: () => void; /** * Callback when the close button is clicked */ onClose: () => void; /** - * Accessible label for the close button + * Callback when the clear button is clicked + */ + onClear?: () => void; + /** + * Whether the clear button is enabled + */ + canClear?: boolean; + /** + * Optional close icon component */ - closeLabel?: string; + closeIconComponent?: () => JSX.Element; + /** + * Optional minimize icon component + */ + minimizeIconComponent?: () => JSX.Element; + /** + * Optional maximize icon component + */ + maximizeIconComponent?: (props: { maximized: boolean }) => JSX.Element; + /** + * Optional title icon component (defaults to sparkles) + */ + titleIconComponent?: () => JSX.Element; /** * Optional class names for elements */ classNames?: Partial; + /** + * Optional translations + */ + translations?: Partial; }; export function createChatHeaderComponent({ createElement }: Renderer) { return function ChatHeader({ - title = 'Chat', + maximized = false, + onToggleMaximize, onClose, - closeLabel = 'Close chat', + onClear, + canClear = false, + closeIconComponent: CloseIconComponent, + minimizeIconComponent: MinimizeIconComponent, + maximizeIconComponent: MaximizeIconComponent, + titleIconComponent: TitleIconComponent, classNames = {}, + translations: userTranslations, ...props }: ChatHeaderProps) { + const translations: Required = { + title: 'Chat', + minimizedLabel: 'Minimize chat', + maximizeLabel: 'Maximize chat', + closeLabel: 'Close chat', + clearLabel: 'Clear', + ...userTranslations, + }; + + const defaultMaximizeIcon = maximized ? ( + + ) : ( + + ); + return (
- {title} + + {TitleIconComponent ? ( + + ) : ( + + )} + + {translations.title} - +
+ {onClear && ( + + )} + + +
); }; diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx index 18129d1a92..d3e0fd7967 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -2,7 +2,8 @@ import { compiler } from 'markdown-to-jsx'; import { cx, find, startsWith } from '../../lib'; -import { warn } from '../../warn'; + +import { MenuIconComponent } from './icons'; import type { ComponentProps, Renderer } from '../../types'; import type { @@ -91,19 +92,11 @@ export type Tools = Array<{ }) => void; }>; -export type ChatMessageProps = Omit, 'content'> & { - /** - * The content of the message - */ - content: JSX.Element; +export type ChatMessageProps = ComponentProps<'article'> & { /** * The message object associated with this chat message */ message: ChatMessageBase; - /** - * Avatar component to render - */ - avatarComponent?: () => JSX.Element; /** * The side of the message */ @@ -121,7 +114,7 @@ export type ChatMessageProps = Omit, 'content'> & { */ autoHideActions?: boolean; /** - * Leading content (replaces avatar if provided) + * Leading content */ leadingComponent?: () => JSX.Element; /** @@ -129,12 +122,18 @@ export type ChatMessageProps = Omit, 'content'> & { */ actionsComponent?: (props: { actions: ChatMessageActionProps[]; - }) => JSX.Element; + }) => JSX.Element | null; /** * Footer content */ footerComponent?: () => JSX.Element; + /** + * The index UI state + */ indexUiState: object; + /** + * Set the index UI state + */ setIndexUiState: (state: object) => void; /** * Array of tools available for the assistant (for tool messages) @@ -154,28 +153,11 @@ export type ChatMessageProps = Omit, 'content'> & { translations?: Partial; }; -function createDefaultActionIconComponent({ - createElement, -}: Pick) { - return ( - - - - - - ); -} - -export function createChatMessageComponent({ - createElement, - Fragment, -}: Renderer) { +export function createChatMessageComponent({ createElement }: Renderer) { return function ChatMessage(userProps: ChatMessageProps) { const { classNames = {}, - content, message, - avatarComponent: AvatarComponent, side = 'left', variant = 'subtle', actions = [], @@ -197,7 +179,7 @@ export function createChatMessageComponent({ ...userTranslations, }; - const hasLeading = Boolean(AvatarComponent || LeadingComponent); + const hasLeading = Boolean(LeadingComponent); const hasActions = Boolean(actions.length > 0 || ActionsComponent); const cssClasses: ChatMessageClassNames = { @@ -205,8 +187,6 @@ export function createChatMessageComponent({ 'ais-ChatMessage', `ais-ChatMessage--${side}`, `ais-ChatMessage--${variant}`, - hasLeading && 'ais-ChatMessage--with-leading', - hasActions && 'ais-ChatMessage--with-actions', autoHideActions && 'ais-ChatMessage--auto-hide-actions', classNames.root ), @@ -218,8 +198,6 @@ export function createChatMessageComponent({ footer: cx('ais-ChatMessage-footer', classNames.footer), }; - const DefaultActionIcon = createDefaultActionIconComponent; - function renderMessagePart( part: ChatMessageBase['parts'][number], index: number @@ -250,8 +228,6 @@ export function createChatMessageComponent({ /> ); - } else { - warn(false, `No tool found for part type "${part.type}`); } } return ( @@ -261,33 +237,6 @@ export function createChatMessageComponent({ ); } - const Actions = () => { - if (ActionsComponent) { - return ; - } - - return ( - - {actions.map((action, index) => ( - - ))} - - ); - }; - return (
{hasLeading && (
- {LeadingComponent ? ( - - ) : ( - AvatarComponent && - )} + {LeadingComponent && }
)}
- {message.role === 'assistant' - ? message.parts.map(renderMessagePart) - : message.parts.map(renderMessagePart)} + {message.parts.map(renderMessagePart)}
{hasActions && ( @@ -317,7 +260,26 @@ export function createChatMessageComponent({ className={cx(cssClasses.actions)} aria-label={translations.actionsLabel} > - + {ActionsComponent ? ( + + ) : ( + actions.map((action, index) => ( + + )) + )}
)} diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessageError.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessageError.tsx new file mode 100644 index 0000000000..52c8db312c --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessageError.tsx @@ -0,0 +1,89 @@ +/** @jsx createElement */ + +import { ReloadIconComponent } from './icons'; + +import type { ComponentProps, Renderer } from '../../types'; + +export type ChatMessageErrorTranslations = { + /** + * Error message text + */ + errorMessage: string; + /** + * Retry button text + */ + retryText: string; +}; + +export type ChatMessageErrorProps = ComponentProps<'article'> & { + /** + * Callback for reload action + */ + onReload?: () => void; + /** + * Custom action buttons + */ + actions?: Array>; + /** + * Translations for error component texts + */ + translations?: Partial; +}; + +export function createChatMessageErrorComponent({ + createElement, +}: Pick) { + return function ChatMessageError({ + onReload, + actions, + translations: userTranslations, + ...props + }: ChatMessageErrorProps) { + const translations: Required = { + errorMessage: + 'Sorry, we are not able to generate a response at the moment. Please retry or contact support.', + retryText: 'Retry', + ...userTranslations, + }; + + return ( +
+
+
+
+ {translations.errorMessage} +
+ {(actions || onReload) && ( +
+ {actions ? ( + actions.map((action, index) => ( + + )) + ) : ( + + )} +
+ )} +
+
+
+ ); + }; +} diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx new file mode 100644 index 0000000000..aa65b15f30 --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessageLoader.tsx @@ -0,0 +1,62 @@ +/** @jsx createElement */ + +import { LoadingSpinnerIconComponent } from './icons'; + +import type { ComponentProps, Renderer } from '../../types'; + +export type ChatMessageLoaderTranslations = { + /** + * Text to display in the loader + */ + loaderText?: string; +}; + +export type ChatMessageLoaderProps = ComponentProps<'article'> & { + /** + * Translations for loader component texts + */ + translations?: Partial; +}; + +export function createChatMessageLoaderComponent({ + createElement, +}: Pick) { + return function ChatMessageLoader({ + translations: userTranslations, + ...props + }: ChatMessageLoaderProps) { + const translations: Required = { + loaderText: 'Thinking...', + ...userTranslations, + }; + + return ( +
+
+
+
+ +
+
+ +
+
+ {translations.loaderText && ( +
+ {translations.loaderText} +
+ )} +
+
+
+
+
+
+
+
+ ); + }; +} diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index dd9befc5fe..5edd01c1b4 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -1,10 +1,24 @@ /** @jsx createElement */ + import { cx } from '../../lib'; import { createChatMessageComponent } from './ChatMessage'; +import { createChatMessageErrorComponent } from './ChatMessageError'; +import { createChatMessageLoaderComponent } from './ChatMessageLoader'; +import { + ChevronDownIconComponent, + CopyIconComponent, + ReloadIconComponent, +} from './icons'; import type { ComponentProps, MutableRef, Renderer } from '../../types'; -import type { ChatMessageProps, Tools } from './ChatMessage'; +import type { + ChatMessageProps, + Tools, + ChatMessageActionProps, +} from './ChatMessage'; +import type { ChatMessageErrorProps } from './ChatMessageError'; +import type { ChatMessageLoaderProps } from './ChatMessageLoader'; import type { ChatMessageBase, ChatStatus } from './types'; export type ChatMessagesTranslations = { @@ -13,9 +27,9 @@ export type ChatMessagesTranslations = { */ scrollToBottomText: string; /** - * Label for the messages container + * Text to display in the loader */ - messagesLabel: string; + loaderText?: string; }; export type ChatMessagesClassNames = { @@ -35,6 +49,10 @@ export type ChatMessagesClassNames = { * Class names to apply to the scroll to bottom button */ scrollToBottom: string | string[]; + /** + * Class names to apply to the scroll to bottom button when hidden + */ + scrollToBottomHidden: string | string[]; }; export type ChatMessagesProps< @@ -47,24 +65,21 @@ export type ChatMessagesProps< /** * Custom message renderer */ - messageComponent?: (props: { - message: TMessage; - isLast: boolean; - }) => JSX.Element; + messageComponent?: (props: { message: TMessage }) => JSX.Element; /** * Custom loader component */ - loaderComponent?: () => JSX.Element; + loaderComponent?: (props: ChatMessageLoaderProps) => JSX.Element; /** * Custom error component */ - errorComponent?: () => JSX.Element; + errorComponent?: (props: ChatMessageErrorProps) => JSX.Element; /** - * Current UI state of the index + * The index UI state */ indexUiState: object; /** - * Function to update the UI state of the index + * Set the index UI state */ setIndexUiState: (state: object) => void; /** @@ -82,7 +97,7 @@ export type ChatMessagesProps< /** * Callback for reload action */ - onReload?: () => void; + onReload?: (messageId?: string) => void; /** * Optional class names */ @@ -91,30 +106,56 @@ export type ChatMessagesProps< * Optional translations */ translations?: Partial; - userMessageProps?: ChatMessageProps; - assistantMessageProps?: ChatMessageProps; + /** + * Optional user message props + */ + userMessageProps?: Partial>; + /** + * Optional assistant message props + */ + assistantMessageProps?: Partial>; + /** + * Optional scroll ref + */ scrollRef?: MutableRef; + /** + * Optional content ref + */ contentRef?: MutableRef; + /** + * Whether the scroll is at the bottom + */ isScrollAtBottom?: boolean; - scrollToBottom?: () => void; + /** + * Callback for scroll to bottom + */ + onScrollToBottom?: () => void; + /** + * Whether the messages are clearing (for animation) + */ + isClearing?: boolean; + /** + * Callback for when clearing transition ends + */ + onClearTransitionEnd?: () => void; }; -function createDefaultScrollIconComponent({ - createElement, -}: Pick) { - return ( - - - +const copyToClipboard = (message: ChatMessageBase) => { + navigator.clipboard.writeText( + message.parts + .map((part) => { + if ('text' in part) { + return part.text; + } + return ''; + }) + .join('') ); -} +}; -function createDefaultMessageComponent({ createElement, Fragment }: Renderer) { +function createDefaultMessageComponent< + TMessage extends ChatMessageBase = ChatMessageBase +>({ createElement, Fragment }: Renderer) { const ChatMessage = createChatMessageComponent({ createElement, Fragment }); return function DefaultMessage({ @@ -124,81 +165,64 @@ function createDefaultMessageComponent({ createElement, Fragment }: Renderer) { tools, indexUiState, setIndexUiState, + onReload, }: { - message: ChatMessageBase; - userMessageProps?: Omit; - assistantMessageProps?: Omit; + key: string; + message: TMessage; + userMessageProps?: Partial; + assistantMessageProps?: Partial; indexUiState: object; setIndexUiState: (state: object) => void; tools?: Tools; + onReload?: (messageId?: string) => void; }) { + const defaultAssistantActions: ChatMessageActionProps[] = [ + { + title: 'Copy to clipboard', + icon: () => , + onClick: copyToClipboard, + }, + { + title: 'Regenerate', + icon: () => , + onClick: (m) => onReload?.(m.id), + }, + ]; + const messageProps = message.role === 'user' ? userMessageProps : assistantMessageProps; + const defaultActions = + message.role === 'user' ? undefined : defaultAssistantActions; return ( {message.parts}} side={message.role === 'user' ? 'right' : 'left'} variant={message.role === 'user' ? 'neutral' : 'subtle'} message={message} tools={tools} indexUiState={indexUiState} setIndexUiState={setIndexUiState} + actions={defaultActions} + data-role={message.role} {...messageProps} /> ); }; } -function createDefaultLoaderComponent({ - createElement, -}: Pick) { - return function DefaultLoader() { - return ( -
-
- - - -
-
- ); - }; -} - -function createDefaultErrorComponent({ - createElement, -}: Pick) { - return function DefaultError({ onReload }: { onReload?: () => void }) { - return ( -
- Something went wrong - {onReload && ( - - )} -
- ); - }; -} - -// Simple scroll to bottom functionality -const handleScrollToBottom = () => { - const scrollContainer = document.querySelector('.ais-ChatMessages-scroll'); - if (scrollContainer) { - scrollContainer.scrollTop = scrollContainer.scrollHeight; - } -}; - export function createChatMessagesComponent({ createElement, Fragment, }: Renderer) { + const DefaultMessageComponent = + createDefaultMessageComponent({ createElement, Fragment }); + const DefaultLoaderComponent = createChatMessageLoaderComponent({ + createElement, + }); + const DefaultErrorComponent = createChatMessageErrorComponent({ + createElement, + }); + return function ChatMessages< TMessage extends ChatMessageBase = ChatMessageBase >(userProps: ChatMessagesProps) { @@ -220,62 +244,35 @@ export function createChatMessagesComponent({ scrollRef, contentRef, isScrollAtBottom, - scrollToBottom = handleScrollToBottom, + onScrollToBottom, + isClearing = false, + onClearTransitionEnd, ...props } = userProps; const translations: Required = { scrollToBottomText: 'Scroll to bottom', - messagesLabel: 'Chat messages', + loaderText: 'Thinking...', ...userTranslations, }; const cssClasses: ChatMessagesClassNames = { root: cx('ais-ChatMessages', classNames.root), - scroll: cx('ais-ChatMessages-scroll', classNames.scroll), + scroll: cx('ais-ChatMessages-scroll ais-Scrollbar', classNames.scroll), content: cx('ais-ChatMessages-content', classNames.content), scrollToBottom: cx( 'ais-ChatMessages-scrollToBottom', classNames.scrollToBottom ), + scrollToBottomHidden: cx( + 'ais-ChatMessages-scrollToBottom--hidden', + classNames.scrollToBottomHidden + ), }; - const DefaultMessage = - MessageComponent || - createDefaultMessageComponent({ createElement, Fragment }); - const DefaultLoader = - LoaderComponent || createDefaultLoaderComponent({ createElement }); - const DefaultError = - ErrorComponent || createDefaultErrorComponent({ createElement }); - const ScrollIcon = createDefaultScrollIconComponent; - - const renderMessage = (message: TMessage, index: number) => { - const isLast = index === messages.length - 1; - const isUser = message.role === 'user'; - const isAssistant = message.role === 'assistant'; - - return ( -
- -
- ); - }; + const DefaultMessage = MessageComponent || DefaultMessageComponent; + const DefaultLoader = LoaderComponent || DefaultLoaderComponent; + const DefaultError = ErrorComponent || DefaultErrorComponent; return (
-
- {messages.map((message, index) => renderMessage(message, index))} +
{ + if ( + e.target === e.currentTarget && + e.propertyName === 'opacity' && + isClearing + ) { + onClearTransitionEnd?.(); + } + }} + > + {messages.map((message) => ( + + ))} {status === 'submitted' && ( -
- -
+ )} - {status === 'error' && ( -
- -
- )} + {status === 'error' && }
- {!hideScrollToBottom && !isScrollAtBottom && ( - - )} +
); }; diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatPrompt.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatPrompt.tsx index c7e8e2c10d..213ce960da 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatPrompt.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatPrompt.tsx @@ -2,7 +2,9 @@ import { cx } from '../../lib'; -import type { ComponentProps, Renderer } from '../../types'; +import { ArrowUpIconComponent, StopIconComponent } from './icons'; + +import type { ComponentProps, MutableRef, Renderer } from '../../types'; import type { ChatStatus } from './types'; export type ChatPromptTranslations = { @@ -30,6 +32,10 @@ export type ChatPromptTranslations = { * The tooltip when the chat prompt is disabled */ disabledTooltip: string; + /** + * The disclaimer text shown in the footer + */ + disclaimer: string; }; export type ChatPromptClassNames = { @@ -64,7 +70,7 @@ export type ChatPromptClassNames = { }; export type ChatPromptProps = Omit< - ComponentProps<'form'>, + ComponentProps<'textarea'>, 'onInput' | 'onSubmit' > & { /** @@ -95,6 +101,10 @@ export type ChatPromptProps = Omit< * Maximum number of rows for the textarea */ maxRows?: number; + /** + * Whether to auto-focus the textarea when mounted + */ + autoFocus?: boolean; /** * Optional class names */ @@ -104,45 +114,74 @@ export type ChatPromptProps = Omit< */ translations?: Partial; /** - * Callback when the textarea value changes + * Callback when the stop button is clicked */ - onInput?: (value: string) => void; + onStop?: () => void; /** * Callback when the form is submitted */ - onSubmit?: (value: string) => void; + onSubmit?: ComponentProps<'textarea'>['onSubmit']; /** - * Callback when the stop button is clicked + * Callback when the textarea value changes */ - onStop?: () => void; + onInput?: ComponentProps<'textarea'>['onInput']; + /** + * Ref callback to get access to the focus function + */ + ref?: MutableRef; }; -function createDefaultSubmitIconComponent({ - createElement, -}: Pick) { - return ( - - - - ); -} +export function createChatPromptComponent({ createElement }: Renderer) { + let textAreaElement: HTMLTextAreaElement | null = null; + let lineHeight = 0; + let padding = 0; -function createDefaultStopIconComponent({ - createElement, -}: Pick) { - return ( - - - - ); -} + const adjustHeight = () => { + if (!textAreaElement) return; + + textAreaElement.style.height = 'auto'; + const fullHeight = textAreaElement.scrollHeight; + + if (textAreaElement.getAttribute('data-max-rows')) { + const maxRows = parseInt( + textAreaElement.getAttribute('data-max-rows') || '0', + 10 + ); + if (maxRows > 0) { + const maxHeight = maxRows * lineHeight + padding; + textAreaElement.style.overflowY = + fullHeight > maxHeight ? 'auto' : 'hidden'; + textAreaElement.style.height = `${Math.min(fullHeight, maxHeight)}px`; + return; + } + } + + textAreaElement.style.overflowY = 'hidden'; + textAreaElement.style.height = `${fullHeight}px`; + }; + + const setTextAreaRef = ( + element: HTMLTextAreaElement | null, + ref?: MutableRef + ) => { + textAreaElement = element; + + if (ref) { + ref.current = element; + } + + if (element) { + const styles = getComputedStyle(element); + lineHeight = parseFloat(styles.lineHeight); + + const pt = parseFloat(styles.paddingTop); + const pb = parseFloat(styles.paddingBottom); + padding = pt + pb; + + adjustHeight(); + } + }; -export function createChatPromptComponent({ createElement }: Renderer) { return function ChatPrompt(userProps: ChatPromptProps) { const { classNames = {}, @@ -152,11 +191,14 @@ export function createChatPromptComponent({ createElement }: Renderer) { placeholder, status = 'ready', disabled = false, - maxRows = 8, + maxRows = 5, + autoFocus = true, translations: userTranslations, onInput, onSubmit, + onKeyDown, onStop, + ref, ...props } = userProps; @@ -167,6 +209,7 @@ export function createChatPromptComponent({ createElement }: Renderer) { stopResponseTooltip: 'Stop response', sendMessageTooltip: 'Send message', disabledTooltip: 'Chat prompt is disabled', + disclaimer: 'AI can make mistakes. Verify responses.', ...userTranslations, }; @@ -174,7 +217,11 @@ export function createChatPromptComponent({ createElement }: Renderer) { root: cx('ais-ChatPrompt', classNames.root), header: cx('ais-ChatPrompt-header', classNames.header), body: cx('ais-ChatPrompt-body', classNames.body), - textarea: cx('ais-ChatPrompt-textarea', classNames.textarea), + textarea: cx( + 'ais-ChatPrompt-textarea ais-Scrollbar', + disabled && 'ais-ChatPrompt-textarea--disabled', + classNames.textarea + ), actions: cx( 'ais-ChatPrompt-actions', classNames.actions, @@ -189,48 +236,26 @@ export function createChatPromptComponent({ createElement }: Renderer) { const canStop = status === 'submitted' || status === 'streaming'; const buttonDisabled = (!hasValue && !canStop) || disabled; - const handleSubmit = (event: any) => { - event.preventDefault(); - - if (!hasValue || canStop || disabled) { - return; - } - - onSubmit?.(value || ''); - }; - - const handleTextareaInput = (event: any) => { - const target = event.target as HTMLTextAreaElement; - const newValue = target.value; - - onInput?.(newValue); - }; - - const handleKeyDown = (event: any) => { - if (event.key === 'Enter' && !event.shiftKey) { - handleSubmit(event); - } - if (event.key === 'Escape') { - (event.target as HTMLTextAreaElement).blur(); - } - }; - - const handleButtonClick = (event: any) => { - if (canStop) { - event.preventDefault(); - onStop?.(); - } - }; - - const SubmitIcon = canStop - ? createDefaultStopIconComponent - : createDefaultSubmitIconComponent; + const submitIcon = canStop ? ( + + ) : ( + + ); return (
{ + event.preventDefault(); + if (canStop) { + onStop?.(); + return; + } + if (!hasValue) { + return; + } + onSubmit?.(event as any); + }} > {HeaderComponent && (
@@ -238,46 +263,75 @@ export function createChatPromptComponent({ createElement }: Renderer) {
)} -
+
{ + if (e.target === textAreaElement) return; + textAreaElement?.focus(); + }} + >