diff --git a/bundlesize.config.json b/bundlesize.config.json index 4004e4da08..8f20078ca7 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -22,7 +22,7 @@ }, { "path": "packages/react-instantsearch/dist/umd/ReactInstantSearch.min.js", - "maxSize": "66.50 kB" + "maxSize": "66.5 kB" }, { "path": "packages/vue-instantsearch/vue2/umd/index.js", @@ -58,11 +58,11 @@ }, { "path": "./packages/instantsearch.css/themes/satellite.css", - "maxSize": "5.25 kB" + "maxSize": "7 kB" }, { "path": "./packages/instantsearch.css/themes/satellite-min.css", - "maxSize": "4.75 kB" + "maxSize": "6.25 kB" } ] } diff --git a/packages/instantsearch-ui-components/package.json b/packages/instantsearch-ui-components/package.json index d6f644f93c..2c28be55ae 100644 --- a/packages/instantsearch-ui-components/package.json +++ b/packages/instantsearch-ui-components/package.json @@ -47,6 +47,8 @@ "watch:es": "yarn --silent build:es:base --watch" }, "dependencies": { - "@babel/runtime": "^7.27.6" + "@babel/runtime": "^7.27.6", + "ai": "^5.0.18", + "markdown-to-jsx": "^7.1.13" } } diff --git a/packages/instantsearch-ui-components/src/components/chat/Chat.tsx b/packages/instantsearch-ui-components/src/components/chat/Chat.tsx new file mode 100644 index 0000000000..da6687ab57 --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/chat/Chat.tsx @@ -0,0 +1,79 @@ +/** @jsx createElement */ +/** @jsxFrag Fragment */ +import { cx } from '../../lib'; + +import { createChatHeaderComponent } from './ChatHeader'; +import { createChatMessagesComponent } from './ChatMessages'; +import { createChatPromptComponent } from './ChatPrompt'; +import { createChatToggleButtonComponent } from './ChatToggleButton'; + +import type { Renderer } 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 = { + container?: string | string[]; +}; + +export type ChatProps = { + /* + * Whether the chat is open or closed. + */ + open: boolean; + /* + * Props for the ChatHeader component. + */ + headerProps: ChatHeaderProps; + /* + * Props for the ChatToggleButton component. + */ + toggleButtonProps: ChatToggleButtonProps; + /* + * Props for the ChatMessages component. + */ + messagesProps: ChatMessagesProps; + /* + * Props for the ChatPrompt component. + */ + promptProps: ChatPromptProps; + /** + * Optional class names for elements + */ + classNames?: Partial; +}; + +export function createChatComponent({ createElement, Fragment }: Renderer) { + const ChatToggleButton = createChatToggleButtonComponent({ + createElement, + Fragment, + }); + const ChatHeader = createChatHeaderComponent({ createElement, Fragment }); + const ChatMessages = createChatMessagesComponent({ createElement, Fragment }); + const ChatPrompt = createChatPromptComponent({ createElement, Fragment }); + + return function Chat({ + open, + headerProps, + toggleButtonProps, + messagesProps, + promptProps, + classNames = {}, + }: ChatProps) { + return ( + <> + {!open ? ( + + ) : ( +
+ + + + +
+ )} + + ); + }; +} diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatHeader.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatHeader.tsx new file mode 100644 index 0000000000..6f1b230989 --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/chat/ChatHeader.tsx @@ -0,0 +1,58 @@ +/** @jsx createElement */ +import { cx } from '../../lib'; + +import type { Renderer, ComponentProps } from '../../types'; + +export type ChatHeaderClassNames = { + root?: string | string[]; + title?: string | string[]; + close?: string | string[]; +}; + +export type ChatHeaderProps = Omit, 'title'> & { + /** + * The title to display in the header + */ + title?: string; + /** + * Callback when the close button is clicked + */ + onClose: () => void; + /** + * Accessible label for the close button + */ + closeLabel?: string; + /** + * Optional class names for elements + */ + classNames?: Partial; +}; + +export function createChatHeaderComponent({ createElement }: Renderer) { + return function ChatHeader({ + title = 'Chat', + onClose, + closeLabel = 'Close chat', + classNames = {}, + ...props + }: ChatHeaderProps) { + return ( +
+ + {title} + + +
+ ); + }; +} diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx new file mode 100644 index 0000000000..4ee0ae82a9 --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -0,0 +1,324 @@ +/** @jsx createElement */ +import { compiler } from 'markdown-to-jsx'; + +import { cx, find, startsWith } from '../../lib'; +import { warn } from '../../warn'; + +import type { ComponentProps, Renderer } from '../../types'; +import type { ChatInit, ChatMessageBase, ChatToolMessage } from './types'; + +export type ChatMessageSide = 'left' | 'right'; +export type ChatMessageVariant = 'neutral' | 'subtle'; + +export type ChatMessageTranslations = { + /** + * The label for the message + */ + messageLabel: string; + /** + * The label for message actions + */ + actionsLabel: string; +}; + +export type ChatMessageClassNames = { + /** + * Class names to apply to the root element + */ + root: string | string[]; + /** + * Class names to apply to the container element + */ + container: string | string[]; + /** + * Class names to apply to the leading element (avatar area) + */ + leading: string | string[]; + /** + * Class names to apply to the content wrapper + */ + content: string | string[]; + /** + * Class names to apply to the message element + */ + message: string | string[]; + /** + * Class names to apply to the actions container + */ + actions: string | string[]; + /** + * Class names to apply to the footer element + */ + footer: string | string[]; +}; + +export type ChatMessageActionProps = { + /** + * The icon to display in the action button + */ + icon?: () => JSX.Element; + /** + * The title/tooltip for the action + */ + title?: string; + /** + * Whether the action is disabled + */ + disabled?: boolean; + /** + * Click handler for the action + */ + onClick?: (message: ChatMessageBase) => void; +}; + +export type Tools = Array<{ + type: string; + component: (props: { + message: ChatToolMessage; + indexUiState: object; + setIndexUiState: (state: object) => void; + }) => JSX.Element; + onToolCall?: ChatInit['onToolCall']; +}>; + +export type ChatMessageProps = Omit, 'content'> & { + /** + * The content of the message + */ + content: JSX.Element; + /** + * The message object associated with this chat message + */ + message: ChatMessageBase; + /** + * Avatar component to render + */ + avatarComponent?: () => JSX.Element; + /** + * The side of the message + */ + side?: ChatMessageSide; + /** + * The variant of the message + */ + variant?: ChatMessageVariant; + /** + * Array of action buttons + */ + actions?: ChatMessageActionProps[]; + /** + * Whether to auto-hide actions until hover + */ + autoHideActions?: boolean; + /** + * Leading content (replaces avatar if provided) + */ + leadingComponent?: () => JSX.Element; + /** + * Custom actions renderer + */ + actionsComponent?: (props: { + actions: ChatMessageActionProps[]; + }) => JSX.Element; + /** + * Footer content + */ + footerComponent?: () => JSX.Element; + indexUiState: object; + setIndexUiState: (state: object) => void; + /** + * Array of tools available for the assistant (for tool messages) + */ + tools?: Tools; + /** + * Optional handler to refine the search query (for tool actions) + */ + handleRefine?: (value: string) => void; + /** + * Optional class names + */ + classNames?: Partial; + /** + * Optional translations + */ + translations?: Partial; +}; + +function createDefaultActionIconComponent({ + createElement, +}: Pick) { + return ( + + + + + + ); +} + +export function createChatMessageComponent({ + createElement, + Fragment, +}: Renderer) { + return function ChatMessage(userProps: ChatMessageProps) { + const { + classNames = {}, + content, + message, + avatarComponent: AvatarComponent, + side = 'left', + variant = 'subtle', + actions = [], + autoHideActions = false, + handleRefine, + leadingComponent: LeadingComponent, + actionsComponent: ActionsComponent, + footerComponent: FooterComponent, + tools = [], + indexUiState, + setIndexUiState, + translations: userTranslations, + ...props + } = userProps; + + const translations: Required = { + messageLabel: 'Message', + actionsLabel: 'Message actions', + ...userTranslations, + }; + + const hasLeading = Boolean(AvatarComponent || LeadingComponent); + const hasActions = Boolean(actions.length > 0 || ActionsComponent); + + const cssClasses: ChatMessageClassNames = { + root: cx( + '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 + ), + container: cx('ais-ChatMessage-container', classNames.container), + leading: cx('ais-ChatMessage-leading', classNames.leading), + content: cx('ais-ChatMessage-content', classNames.content), + message: cx('ais-ChatMessage-message', classNames.message), + actions: cx('ais-ChatMessage-actions', classNames.actions), + footer: cx('ais-ChatMessage-footer', classNames.footer), + }; + + const DefaultActionIcon = createDefaultActionIconComponent; + + function renderMessagePart( + part: ChatMessageBase['parts'][number], + index: number + ) { + if (part.type === 'step-start') { + return null; + } + if (part.type === 'text') { + const markdown = compiler(part.text, { + createElement: createElement as any, + disableParsingRawHTML: true, + }); + return {markdown}; + } + if (startsWith(part.type, 'tool-')) { + const tool = find(tools, (t) => t.type === part.type); + if (tool) { + const ToolComponent = tool.component; + return ( +
+ +
+ ); + } else { + warn(false, `No tool found for part type "${part.type}`); + } + } + return ( +
+          {JSON.stringify(part)}
+        
+ ); + } + + const Actions = () => { + if (ActionsComponent) { + return ; + } + + return ( + + {actions.map((action, index) => ( + + ))} + + ); + }; + + return ( +
+
+ {hasLeading && ( +
+ {LeadingComponent ? ( + + ) : ( + AvatarComponent && + )} +
+ )} + +
+
+ {message.role === 'assistant' + ? message.parts.map(renderMessagePart) + : message.parts.map(renderMessagePart)} +
+ + {hasActions && ( +
+ +
+ )} + + {FooterComponent && ( +
+ +
+ )} +
+
+
+ ); + }; +} diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx new file mode 100644 index 0000000000..dd9befc5fe --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -0,0 +1,320 @@ +/** @jsx createElement */ +import { cx } from '../../lib'; + +import { createChatMessageComponent } from './ChatMessage'; + +import type { ComponentProps, MutableRef, Renderer } from '../../types'; +import type { ChatMessageProps, Tools } from './ChatMessage'; +import type { ChatMessageBase, ChatStatus } from './types'; + +export type ChatMessagesTranslations = { + /** + * Text for the scroll to bottom button + */ + scrollToBottomText: string; + /** + * Label for the messages container + */ + messagesLabel: string; +}; + +export type ChatMessagesClassNames = { + /** + * Class names to apply to the root element + */ + root: string | string[]; + /** + * Class names to apply to the scroll container + */ + scroll: string | string[]; + /** + * Class names to apply to the content container + */ + content: string | string[]; + /** + * Class names to apply to the scroll to bottom button + */ + scrollToBottom: string | string[]; +}; + +export type ChatMessagesProps< + TMessage extends ChatMessageBase = ChatMessageBase +> = ComponentProps<'div'> & { + /** + * Array of messages to display + */ + messages: TMessage[]; + /** + * Custom message renderer + */ + messageComponent?: (props: { + message: TMessage; + isLast: boolean; + }) => JSX.Element; + /** + * Custom loader component + */ + loaderComponent?: () => JSX.Element; + /** + * Custom error component + */ + errorComponent?: () => JSX.Element; + /** + * Current UI state of the index + */ + indexUiState: object; + /** + * Function to update the UI state of the index + */ + setIndexUiState: (state: object) => void; + /** + * Tools available for the assistant + */ + tools?: Tools; + /** + * Current chat status + */ + status?: ChatStatus; + /** + * Whether to hide the scroll to bottom button + */ + hideScrollToBottom?: boolean; + /** + * Callback for reload action + */ + onReload?: () => void; + /** + * Optional class names + */ + classNames?: Partial; + /** + * Optional translations + */ + translations?: Partial; + userMessageProps?: ChatMessageProps; + assistantMessageProps?: ChatMessageProps; + scrollRef?: MutableRef; + contentRef?: MutableRef; + isScrollAtBottom?: boolean; + scrollToBottom?: () => void; +}; + +function createDefaultScrollIconComponent({ + createElement, +}: Pick) { + return ( + + + + ); +} + +function createDefaultMessageComponent({ createElement, Fragment }: Renderer) { + const ChatMessage = createChatMessageComponent({ createElement, Fragment }); + + return function DefaultMessage({ + message, + userMessageProps, + assistantMessageProps, + tools, + indexUiState, + setIndexUiState, + }: { + message: ChatMessageBase; + userMessageProps?: Omit; + assistantMessageProps?: Omit; + indexUiState: object; + setIndexUiState: (state: object) => void; + tools?: Tools; + }) { + const messageProps = + message.role === 'user' ? userMessageProps : assistantMessageProps; + + return ( + {message.parts}} + side={message.role === 'user' ? 'right' : 'left'} + variant={message.role === 'user' ? 'neutral' : 'subtle'} + message={message} + tools={tools} + indexUiState={indexUiState} + setIndexUiState={setIndexUiState} + {...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) { + return function ChatMessages< + TMessage extends ChatMessageBase = ChatMessageBase + >(userProps: ChatMessagesProps) { + const { + classNames = {}, + messages = [], + messageComponent: MessageComponent, + loaderComponent: LoaderComponent, + errorComponent: ErrorComponent, + tools, + indexUiState, + setIndexUiState, + status = 'ready', + hideScrollToBottom = false, + onReload, + translations: userTranslations, + userMessageProps, + assistantMessageProps, + scrollRef, + contentRef, + isScrollAtBottom, + scrollToBottom = handleScrollToBottom, + ...props + } = userProps; + + const translations: Required = { + scrollToBottomText: 'Scroll to bottom', + messagesLabel: 'Chat messages', + ...userTranslations, + }; + + const cssClasses: ChatMessagesClassNames = { + root: cx('ais-ChatMessages', classNames.root), + scroll: cx('ais-ChatMessages-scroll', classNames.scroll), + content: cx('ais-ChatMessages-content', classNames.content), + scrollToBottom: cx( + 'ais-ChatMessages-scrollToBottom', + classNames.scrollToBottom + ), + }; + + 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 ( +
+ +
+ ); + }; + + return ( +
+
+
+ {messages.map((message, index) => renderMessage(message, index))} + + {status === 'submitted' && ( +
+ +
+ )} + + {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 new file mode 100644 index 0000000000..c7e8e2c10d --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/chat/ChatPrompt.tsx @@ -0,0 +1,284 @@ +/** @jsx createElement */ + +import { cx } from '../../lib'; + +import type { ComponentProps, Renderer } from '../../types'; +import type { ChatStatus } from './types'; + +export type ChatPromptTranslations = { + /** + * The label for the textarea + */ + textareaLabel: string; + /** + * The placeholder text for the textarea + */ + textareaPlaceholder: string; + /** + * The tooltip for the submit button when message is empty + */ + emptyMessageTooltip: string; + /** + * The tooltip for the stop button + */ + stopResponseTooltip: string; + /** + * The tooltip for the send button + */ + sendMessageTooltip: string; + /** + * The tooltip when the chat prompt is disabled + */ + disabledTooltip: string; +}; + +export type ChatPromptClassNames = { + /** + * Class names to apply to the root element + */ + root: string | string[]; + /** + * Class names to apply to the header element + */ + header: string | string[]; + /** + * Class names to apply to the body element + */ + body: string | string[]; + /** + * Class names to apply to the textarea element + */ + textarea: string | string[]; + /** + * Class names to apply to the actions container + */ + actions: string | string[]; + /** + * Class names to apply to the submit button + */ + submit: string | string[]; + /** + * Class names to apply to the footer element + */ + footer: string | string[]; +}; + +export type ChatPromptProps = Omit< + ComponentProps<'form'>, + 'onInput' | 'onSubmit' +> & { + /** + * Content to render above the textarea + */ + headerComponent?: () => JSX.Element; + /** + * Content to render below the textarea + */ + footerComponent?: () => JSX.Element; + /** + * The current value of the textarea + */ + value?: string; + /** + * Placeholder text for the textarea + */ + placeholder?: string; + /** + * The current status of the chat prompt + */ + status?: ChatStatus; + /** + * Whether the component is disabled + */ + disabled?: boolean; + /** + * Maximum number of rows for the textarea + */ + maxRows?: number; + /** + * Optional class names + */ + classNames?: Partial; + /** + * Optional translations + */ + translations?: Partial; + /** + * Callback when the textarea value changes + */ + onInput?: (value: string) => void; + /** + * Callback when the form is submitted + */ + onSubmit?: (value: string) => void; + /** + * Callback when the stop button is clicked + */ + onStop?: () => void; +}; + +function createDefaultSubmitIconComponent({ + createElement, +}: Pick) { + return ( + + + + ); +} + +function createDefaultStopIconComponent({ + createElement, +}: Pick) { + return ( + + + + ); +} + +export function createChatPromptComponent({ createElement }: Renderer) { + return function ChatPrompt(userProps: ChatPromptProps) { + const { + classNames = {}, + headerComponent: HeaderComponent, + footerComponent: FooterComponent, + value, + placeholder, + status = 'ready', + disabled = false, + maxRows = 8, + translations: userTranslations, + onInput, + onSubmit, + onStop, + ...props + } = userProps; + + const translations: Required = { + textareaLabel: 'Type your message...', + textareaPlaceholder: 'Type your message...', + emptyMessageTooltip: 'Message is empty', + stopResponseTooltip: 'Stop response', + sendMessageTooltip: 'Send message', + disabledTooltip: 'Chat prompt is disabled', + ...userTranslations, + }; + + const cssClasses: ChatPromptClassNames = { + 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), + actions: cx( + 'ais-ChatPrompt-actions', + classNames.actions, + disabled && 'ais-ChatPrompt-actions--disabled' + ), + submit: cx('ais-ChatPrompt-submit', classNames.submit), + footer: cx('ais-ChatPrompt-footer', classNames.footer), + }; + + const hasValue = + typeof value === 'string' ? value.trim() !== '' : Boolean(value); + 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; + + return ( +
+ {HeaderComponent && ( +
+ +
+ )} + +
+