From 40636a0c6b616fc01a233645043ed98eb20522e2 Mon Sep 17 00:00:00 2001 From: Fabien Motte <662153+FabienMotte@users.noreply.github.com> Date: Wed, 23 Jul 2025 14:33:42 +0200 Subject: [PATCH 01/90] feat(ui-components): introduce chat components --- examples/js/getting-started/index.html | 4 +- examples/js/getting-started/src/app.css | 22 + .../getting-started/src/{app.js => app.tsx} | 43 ++ examples/react/getting-started/src/App.css | 22 + examples/react/getting-started/src/App.tsx | 74 +- .../getting-started/src/useStickToBottom.ts | 652 +++++++++++++++++ .../src/components/chat/ChatMessage.tsx | 251 +++++++ .../src/components/chat/ChatMessages.tsx | 301 ++++++++ .../src/components/chat/ChatPrompt.tsx | 281 ++++++++ .../src/components/chat/types.ts | 1 + .../src/components/index.ts | 4 + packages/instantsearch.css/src/chat.scss | 663 ++++++++++++++++++ .../src/themes/satellite.scss | 1 + yarn.lock | 85 ++- 14 files changed, 2396 insertions(+), 8 deletions(-) rename examples/js/getting-started/src/{app.js => app.tsx} (56%) create mode 100644 examples/react/getting-started/src/useStickToBottom.ts create mode 100644 packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx create mode 100644 packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx create mode 100644 packages/instantsearch-ui-components/src/components/chat/ChatPrompt.tsx create mode 100644 packages/instantsearch-ui-components/src/components/chat/types.ts create mode 100644 packages/instantsearch.css/src/chat.scss diff --git a/examples/js/getting-started/index.html b/examples/js/getting-started/index.html index 8ecc72dceb..5495638e43 100644 --- a/examples/js/getting-started/index.html +++ b/examples/js/getting-started/index.html @@ -42,8 +42,10 @@

+ +
- + diff --git a/examples/js/getting-started/src/app.css b/examples/js/getting-started/src/app.css index 3b5f97b1d4..e281e6621e 100644 --- a/examples/js/getting-started/src/app.css +++ b/examples/js/getting-started/src/app.css @@ -155,3 +155,25 @@ rgba(255, 255, 255, 1) 100% ); } + +.ais-Chat { + display: flex; + flex-direction: column; + position: fixed; + bottom: 0; + right: 0; + background-color: #fff; + border-radius: 1rem 0 0 0; + border: 1px solid #e0e0e0; + box-shadow: 0 0 0 1px #23263b0d, 0 1px 3px #23263b26; + width: 25%; + height: 700px; +} + +.ais-ChatMessages-scroll { + padding: 1rem; +} + +.ais-ChatPrompt { + padding: 0 1rem 1rem 1rem; +} diff --git a/examples/js/getting-started/src/app.js b/examples/js/getting-started/src/app.tsx similarity index 56% rename from examples/js/getting-started/src/app.js rename to examples/js/getting-started/src/app.tsx index 88bfc54a49..d5a83d6c62 100644 --- a/examples/js/getting-started/src/app.js +++ b/examples/js/getting-started/src/app.tsx @@ -1,4 +1,11 @@ +/** @jsx h */ +// @ts-check import { liteClient as algoliasearch } from 'algoliasearch/lite'; +import { + createChatPromptComponent, + createChatMessageComponent, + createChatMessagesComponent, +} from 'instantsearch-ui-components'; import instantsearch from 'instantsearch.js'; import { carousel } from 'instantsearch.js/es/templates'; import { @@ -10,6 +17,8 @@ import { searchBox, trendingItems, } from 'instantsearch.js/es/widgets'; +import { h, render, Fragment } from 'preact'; +import { useState } from 'preact/hooks'; import 'instantsearch.css/themes/satellite.css'; @@ -24,6 +33,40 @@ const search = instantsearch({ insights: true, }); +const ChatPrompt = createChatPromptComponent({ createElement: h, Fragment }); +// const ChatMessage = createChatMessageComponent({ createElement: h, Fragment }); +const ChatMessages = createChatMessagesComponent({ + createElement: h, + Fragment, +}); + +const Chat = () => { + const [prompt, setPrompt] = useState(''); + + return ( +
+ + + +
+ ); +}; + +render(, document.getElementById('chat')!); + search.addWidgets([ searchBox({ container: '#searchbox', diff --git a/examples/react/getting-started/src/App.css b/examples/react/getting-started/src/App.css index 003ae882ba..ad3d07f445 100644 --- a/examples/react/getting-started/src/App.css +++ b/examples/react/getting-started/src/App.css @@ -166,3 +166,25 @@ em { rgba(255, 255, 255, 1) 100% ); } + +.ais-Chat { + display: flex; + flex-direction: column; + position: fixed; + bottom: 0; + right: 0; + background-color: #fff; + border-radius: 1rem 0 0 0; + border: 1px solid #e0e0e0; + box-shadow: 0 0 0 1px #23263b0d, 0 1px 3px #23263b26; + width: 25%; + height: 700px; +} + +.ais-ChatMessages-scroll { + padding: 1rem; +} + +.ais-ChatPrompt { + padding: 0 1rem 1rem 1rem; +} diff --git a/examples/react/getting-started/src/App.tsx b/examples/react/getting-started/src/App.tsx index 857391877e..9d7cdaf13e 100644 --- a/examples/react/getting-started/src/App.tsx +++ b/examples/react/getting-started/src/App.tsx @@ -1,6 +1,7 @@ +import { useChat } from '@ai-sdk/react'; import { liteClient as algoliasearch } from 'algoliasearch/lite'; import { Hit } from 'instantsearch.js'; -import React from 'react'; +import React, { Fragment, createElement, useState } from 'react'; import { Configure, Highlight, @@ -18,12 +19,82 @@ import { Panel } from './Panel'; import 'instantsearch.css/themes/satellite.css'; import './App.css'; +import { + createChatPromptComponent, + // createChatMessageComponent, + createChatMessagesComponent, + ChatMessageBase, +} from 'instantsearch-ui-components'; + +import { useStickToBottom } from './useStickToBottom'; const searchClient = algoliasearch( 'latency', '6be0576ff61c053d5f9a3225e2a90f76' ); +const ChatPrompt = createChatPromptComponent({ createElement, Fragment }); +// const ChatMessage = createChatMessageComponent({ createElement, Fragment }); +const ChatMessages = createChatMessagesComponent({ + createElement, + Fragment, +}); + +const Chat = () => { + const { + messages, + setMessages, + input, + handleSubmit, + setInput, + status, + stop, + reload, + } = useChat({ + api: 'http://localhost:8787', + }); + + const { contentRef, scrollRef, scrollToBottom, isAtBottom } = + useStickToBottom(); + + return ( +
+ { + const idx = messages.findIndex((m) => m.id === message?.id); + if (idx === -1) return; + + const history = messages.slice(0, idx + 1); + setMessages(history); + reload(); + }, + }, + ], + }} + /> + + handleSubmit()} + onStop={stop} + status={status} + /> +
+ ); +}; + export function App() { return (
@@ -40,6 +111,7 @@ export function App() {
+ ; + ignoreEscapes: boolean; + promise: Promise; + }; + lastTick?: number; + velocity: number; + accumulated: number; + + escapedFromLock: boolean; + isAtBottom: boolean; + isNearBottom: boolean; + + resizeObserver?: ResizeObserver; +} + +const DEFAULT_SPRING_ANIMATION = { + /** + * A value from 0 to 1, on how much to damp the animation. + * 0 means no damping, 1 means full damping. + * + * @default 0.7 + */ + damping: 0.7, + + /** + * The stiffness of how fast/slow the animation gets up to speed. + * + * @default 0.05 + */ + stiffness: 0.05, + + /** + * The inertial mass associated with the animation. + * Higher numbers make the animation slower. + * + * @default 1.25 + */ + mass: 1.25, +}; + +export type SpringAnimation = Partial; + +export type Animation = ScrollBehavior | SpringAnimation; + +export interface ScrollElements { + scrollElement: HTMLElement; + contentElement: HTMLElement; +} + +export type GetTargetScrollTop = ( + targetScrollTop: number, + context: ScrollElements +) => number; + +export interface StickToBottomOptions extends SpringAnimation { + resize?: Animation; + initial?: Animation | boolean; + targetScrollTop?: GetTargetScrollTop; +} + +export type ScrollToBottomOptions = + | ScrollBehavior + | { + animation?: Animation; + + /** + * Whether to wait for any existing scrolls to finish before + * performing this one. Or if a millisecond is passed, + * it will wait for that duration before performing the scroll. + * + * @default false + */ + wait?: boolean | number; + + /** + * Whether to prevent the user from escaping the scroll, + * by scrolling up with their mouse. + */ + ignoreEscapes?: boolean; + + /** + * Only scroll to the bottom if we're already at the bottom. + * + * @default false + */ + preserveScrollPosition?: boolean; + + /** + * The extra duration in ms that this scroll event should persist for. + * (in addition to the time that it takes to get to the bottom) + * + * Not to be confused with the duration of the animation - + * for that you should adjust the animation option. + * + * @default 0 + */ + duration?: number | Promise; + }; + +export type ScrollToBottom = ( + scrollOptions?: ScrollToBottomOptions +) => Promise | boolean; +export type StopScroll = () => void; + +const STICK_TO_BOTTOM_OFFSET_PX = 70; +const SIXTY_FPS_INTERVAL_MS = 1000 / 60; +const RETAIN_ANIMATION_DURATION_MS = 350; + +let mouseDown = false; + +globalThis.document?.addEventListener('mousedown', () => { + mouseDown = true; +}); + +globalThis.document?.addEventListener('mouseup', () => { + mouseDown = false; +}); + +globalThis.document?.addEventListener('click', () => { + mouseDown = false; +}); + +export const useStickToBottom = ( + options: StickToBottomOptions = {} +): StickToBottomInstance => { + const [escapedFromLock, updateEscapedFromLock] = useState(false); + const [isAtBottom, updateIsAtBottom] = useState(options.initial !== false); + const [isNearBottom, setIsNearBottom] = useState(false); + + const optionsRef = useRef(null!); + optionsRef.current = options; + + const isSelecting = useCallback(() => { + if (!mouseDown) { + return false; + } + + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) { + return false; + } + + const range = selection.getRangeAt(0); + return ( + range.commonAncestorContainer.contains(scrollRef.current) || + scrollRef.current?.contains(range.commonAncestorContainer) + ); + }, []); + + const setIsAtBottom = useCallback((isAtBottom: boolean) => { + state.isAtBottom = isAtBottom; + updateIsAtBottom(isAtBottom); + }, []); + + const setEscapedFromLock = useCallback((escapedFromLock: boolean) => { + state.escapedFromLock = escapedFromLock; + updateEscapedFromLock(escapedFromLock); + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: not needed + const state = useMemo(() => { + let lastCalculation: + | { targetScrollTop: number; calculatedScrollTop: number } + | undefined; + + return { + escapedFromLock, + isAtBottom, + resizeDifference: 0, + accumulated: 0, + velocity: 0, + listeners: new Set(), + + get scrollTop() { + return scrollRef.current?.scrollTop ?? 0; + }, + set scrollTop(scrollTop: number) { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollTop; + state.ignoreScrollToTop = scrollRef.current.scrollTop; + } + }, + + get targetScrollTop() { + if (!scrollRef.current || !contentRef.current) { + return 0; + } + + return ( + scrollRef.current.scrollHeight - 1 - scrollRef.current.clientHeight + ); + }, + get calculatedTargetScrollTop() { + if (!scrollRef.current || !contentRef.current) { + return 0; + } + + const { targetScrollTop } = this; + + if (!options.targetScrollTop) { + return targetScrollTop; + } + + if (lastCalculation?.targetScrollTop === targetScrollTop) { + return lastCalculation.calculatedScrollTop; + } + + const calculatedScrollTop = Math.max( + Math.min( + options.targetScrollTop(targetScrollTop, { + scrollElement: scrollRef.current, + contentElement: contentRef.current, + }), + targetScrollTop + ), + 0 + ); + + lastCalculation = { targetScrollTop, calculatedScrollTop }; + + requestAnimationFrame(() => { + lastCalculation = undefined; + }); + + return calculatedScrollTop; + }, + + get scrollDifference() { + return this.calculatedTargetScrollTop - this.scrollTop; + }, + + get isNearBottom() { + return this.scrollDifference <= STICK_TO_BOTTOM_OFFSET_PX; + }, + }; + }, []); + + const scrollToBottom = useCallback( + (scrollOptions = {}) => { + if (typeof scrollOptions === 'string') { + scrollOptions = { animation: scrollOptions }; + } + + if (!scrollOptions.preserveScrollPosition) { + setIsAtBottom(true); + } + + const waitElapsed = Date.now() + (Number(scrollOptions.wait) || 0); + const behavior = mergeAnimations( + optionsRef.current, + scrollOptions.animation + ); + const { ignoreEscapes = false } = scrollOptions; + + let durationElapsed: number; + let startTarget = state.calculatedTargetScrollTop; + + if (scrollOptions.duration instanceof Promise) { + scrollOptions.duration.finally(() => { + durationElapsed = Date.now(); + }); + } else { + durationElapsed = waitElapsed + (scrollOptions.duration ?? 0); + } + + const next = async (): Promise => { + const promise = new Promise(requestAnimationFrame).then(() => { + if (!state.isAtBottom) { + state.animation = undefined; + + return false; + } + + const { scrollTop } = state; + const tick = performance.now(); + const tickDelta = + (tick - (state.lastTick ?? tick)) / SIXTY_FPS_INTERVAL_MS; + state.animation ||= { behavior, promise, ignoreEscapes }; + + if (state.animation.behavior === behavior) { + state.lastTick = tick; + } + + if (isSelecting()) { + return next(); + } + + if (waitElapsed > Date.now()) { + return next(); + } + + if ( + scrollTop < Math.min(startTarget, state.calculatedTargetScrollTop) + ) { + if (state.animation?.behavior === behavior) { + if (behavior === 'instant') { + state.scrollTop = state.calculatedTargetScrollTop; + return next(); + } + + state.velocity = + (behavior.damping * state.velocity + + behavior.stiffness * state.scrollDifference) / + behavior.mass; + state.accumulated += state.velocity * tickDelta; + state.scrollTop += state.accumulated; + + if (state.scrollTop !== scrollTop) { + state.accumulated = 0; + } + } + + return next(); + } + + if (durationElapsed > Date.now()) { + startTarget = state.calculatedTargetScrollTop; + + return next(); + } + + state.animation = undefined; + + /** + * If we're still below the target, then queue + * up another scroll to the bottom with the last + * requested animatino. + */ + if (state.scrollTop < state.calculatedTargetScrollTop) { + return scrollToBottom({ + animation: mergeAnimations( + optionsRef.current, + optionsRef.current.resize + ), + ignoreEscapes, + duration: Math.max(0, durationElapsed - Date.now()) || undefined, + }); + } + + return state.isAtBottom; + }); + + return promise.then((isAtBottom) => { + requestAnimationFrame(() => { + if (!state.animation) { + state.lastTick = undefined; + state.velocity = 0; + } + }); + + return isAtBottom; + }); + }; + + if (scrollOptions.wait !== true) { + state.animation = undefined; + } + + if (state.animation?.behavior === behavior) { + return state.animation.promise; + } + + return next(); + }, + [setIsAtBottom, isSelecting, state] + ); + + const stopScroll = useCallback((): void => { + setEscapedFromLock(true); + setIsAtBottom(false); + }, [setEscapedFromLock, setIsAtBottom]); + + const handleScroll = useCallback( + ({ target }: Event) => { + if (target !== scrollRef.current) { + return; + } + + const { scrollTop, ignoreScrollToTop } = state; + let { lastScrollTop = scrollTop } = state; + + state.lastScrollTop = scrollTop; + state.ignoreScrollToTop = undefined; + + if (ignoreScrollToTop && ignoreScrollToTop > scrollTop) { + /** + * When the user scrolls up while the animation plays, the `scrollTop` may + * not come in separate events; if this happens, to make sure `isScrollingUp` + * is correct, set the lastScrollTop to the ignored event. + */ + lastScrollTop = ignoreScrollToTop; + } + + setIsNearBottom(state.isNearBottom); + + /** + * Scroll events may come before a ResizeObserver event, + * so in order to ignore resize events correctly we use a + * timeout. + * + * @see https://github.com/WICG/resize-observer/issues/25#issuecomment-248757228 + */ + setTimeout(() => { + /** + * When theres a resize difference ignore the resize event. + */ + if (state.resizeDifference || scrollTop === ignoreScrollToTop) { + return; + } + + if (isSelecting()) { + setEscapedFromLock(true); + setIsAtBottom(false); + return; + } + + const isScrollingDown = scrollTop > lastScrollTop; + const isScrollingUp = scrollTop < lastScrollTop; + + if (state.animation?.ignoreEscapes) { + state.scrollTop = lastScrollTop; + return; + } + + if (isScrollingUp) { + setEscapedFromLock(true); + setIsAtBottom(false); + } + + if (isScrollingDown) { + setEscapedFromLock(false); + } + + if (!state.escapedFromLock && state.isNearBottom) { + setIsAtBottom(true); + } + }, 1); + }, + [setEscapedFromLock, setIsAtBottom, isSelecting, state] + ); + + const handleWheel = useCallback( + ({ target, deltaY }: WheelEvent) => { + let element = target as HTMLElement; + + while (!['scroll', 'auto'].includes(getComputedStyle(element).overflow)) { + if (!element.parentElement) { + return; + } + + element = element.parentElement; + } + + /** + * The browser may cancel the scrolling from the mouse wheel + * if we update it from the animation in meantime. + * To prevent this, always escape when the wheel is scrolled up. + */ + if ( + element === scrollRef.current && + deltaY < 0 && + scrollRef.current.scrollHeight > scrollRef.current.clientHeight && + !state.animation?.ignoreEscapes + ) { + setEscapedFromLock(true); + setIsAtBottom(false); + } + }, + [setEscapedFromLock, setIsAtBottom, state] + ); + + const scrollRef = useRefCallback((scroll) => { + scrollRef.current?.removeEventListener('scroll', handleScroll); + scrollRef.current?.removeEventListener('wheel', handleWheel); + scroll?.addEventListener('scroll', handleScroll, { passive: true }); + scroll?.addEventListener('wheel', handleWheel, { passive: true }); + }, []); + + const contentRef = useRefCallback((content) => { + state.resizeObserver?.disconnect(); + + if (!content) { + return; + } + + let previousHeight: number | undefined; + + state.resizeObserver = new ResizeObserver(([entry]) => { + const { height } = entry.contentRect; + const difference = height - (previousHeight ?? height); + + state.resizeDifference = difference; + + /** + * Sometimes the browser can overscroll past the target, + * so check for this and adjust appropriately. + */ + if (state.scrollTop > state.targetScrollTop) { + state.scrollTop = state.targetScrollTop; + } + + setIsNearBottom(state.isNearBottom); + + if (difference >= 0) { + /** + * If it's a positive resize, scroll to the bottom when + * we're already at the bottom. + */ + const animation = mergeAnimations( + optionsRef.current, + previousHeight + ? optionsRef.current.resize + : optionsRef.current.initial + ); + + scrollToBottom({ + animation, + wait: true, + preserveScrollPosition: true, + duration: + animation === 'instant' ? undefined : RETAIN_ANIMATION_DURATION_MS, + }); + } else { + /** + * Else if it's a negative resize, check if we're near the bottom + * if we are want to un-escape from the lock, because the resize + * could have caused the container to be at the bottom. + */ + if (state.isNearBottom) { + setEscapedFromLock(false); + setIsAtBottom(true); + } + } + + previousHeight = height; + + /** + * Reset the resize difference after the scroll event + * has fired. Requires a rAF to wait for the scroll event, + * and a setTimeout to wait for the other timeout we have in + * resizeObserver in case the scroll event happens after the + * resize event. + */ + requestAnimationFrame(() => { + setTimeout(() => { + if (state.resizeDifference === difference) { + state.resizeDifference = 0; + } + }, 1); + }); + }); + + state.resizeObserver?.observe(content); + }, []); + + return { + contentRef, + scrollRef, + scrollToBottom, + stopScroll, + isAtBottom: isAtBottom || isNearBottom, + isNearBottom, + escapedFromLock, + state, + }; +}; + +export interface StickToBottomInstance { + contentRef: React.MutableRefObject & + React.RefCallback; + scrollRef: React.MutableRefObject & + React.RefCallback; + scrollToBottom: ScrollToBottom; + stopScroll: StopScroll; + isAtBottom: boolean; + isNearBottom: boolean; + escapedFromLock: boolean; + state: StickToBottomState; +} + +function useRefCallback any>( + callback: T, + deps: DependencyList +) { + // biome-ignore lint/correctness/useExhaustiveDependencies: not needed + const result = useCallback((ref: HTMLElement | null) => { + result.current = ref; + return callback(ref); + }, deps) as any as MutableRefObject & + RefCallback; + + return result; +} + +const animationCache = new Map>>(); + +function mergeAnimations( + ...animations: Array +) { + const result = { ...DEFAULT_SPRING_ANIMATION }; + let instant = false; + + for (const animation of animations) { + if (animation === 'instant') { + instant = true; + continue; + } + + if (typeof animation !== 'object') { + continue; + } + + instant = false; + + result.damping = animation.damping ?? result.damping; + result.stiffness = animation.stiffness ?? result.stiffness; + result.mass = animation.mass ?? result.mass; + } + + const key = JSON.stringify(result); + + if (!animationCache.has(key)) { + animationCache.set(key, Object.freeze(result)); + } + + return instant ? 'instant' : animationCache.get(key)!; +} 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..00e2fec0e3 --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -0,0 +1,251 @@ +/** @jsx createElement */ +import { cx } from '../../lib'; + +import type { ComponentProps, Renderer } 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?: (event: any, extraData?: any) => void; +}; + +export type ChatMessageProps = Omit, 'content'> & { + /** + * The content of the message + */ + content: JSX.Element; + /** + * 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; + /** + * Extra data to pass to action handlers + */ + actionsExtraData?: any; + /** + * 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, + avatarComponent: AvatarComponent, + side = 'left', + variant = 'subtle', + actions = [], + autoHideActions = false, + leadingComponent: LeadingComponent, + actionsComponent: ActionsComponent, + footerComponent: FooterComponent, + actionsExtraData, + 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; + + const Actions = () => { + if (ActionsComponent) { + return ; + } + + return ( + + {actions.map((action, index) => ( + + ))} + + ); + }; + + return ( +
+
+ {hasLeading && ( +
+ {LeadingComponent ? ( + + ) : ( + AvatarComponent && + )} +
+ )} + +
+
{content}
+ + {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..cf37789b7d --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -0,0 +1,301 @@ +/** @jsx createElement */ +import { cx } from '../../lib'; + +import { createChatMessageComponent } from './ChatMessage'; + +import type { ComponentProps, MutableRef, Renderer } from '../../types'; +import type { ChatMessageProps } from './ChatMessage'; +import type { ChatStatus } from './types'; + +export type ChatRole = 'user' | 'assistant'; + +export type ChatMessageBase = { + id: string; + role: ChatRole; + content: string; +}; + +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 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, + }: { + message: ChatMessageBase; + userMessageProps?: Omit; + assistantMessageProps?: Omit; + }) { + const messageProps = + message.role === 'user' ? userMessageProps : assistantMessageProps; + + return ( + {message.content}
} + side={message.role === 'user' ? 'right' : 'left'} + variant={message.role === 'user' ? 'neutral' : 'subtle'} + actionsExtraData={message} + {...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, + 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..f5b7730b70 --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/chat/ChatPrompt.tsx @@ -0,0 +1,281 @@ +/** @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 = ComponentProps<'form'> & { + /** + * 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 && ( +
+ +
+ )} + +
+