diff --git a/packages/instantsearch-ui-components/src/components/chat/PromptSuggestions.tsx b/packages/instantsearch-ui-components/src/components/chat/PromptSuggestions.tsx new file mode 100644 index 0000000000..72a39963d8 --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/chat/PromptSuggestions.tsx @@ -0,0 +1,132 @@ +/** @jsx createElement */ +import { cx } from '../../lib'; +import { createButtonComponent } from '../Button'; + +import type { Renderer } from '../../types'; +import type { ChatStatus } from './types'; + +export type PromptSuggestionsClassNames = { + /** + * Class name for the root element + */ + root?: string; + /** + * Class name for the header element + */ + header?: string; + /** + * Class name for the suggestions list + */ + suggestionsList?: string; + /** + * Class name for each suggestion item + */ + suggestionItem?: string; + /** + * Class name for the loading state + */ + loading?: string; +}; + +export type PromptSuggestionsTranslations = { + /** + * Text for the suggestions header + */ + suggestionsHeaderText?: string; +}; + +export type PromptSuggestionsProps = { + /** + * Status of the chat + */ + status: ChatStatus; + /** + * List of prompt suggestions + */ + suggestions?: string[]; + /** + * Callback when a suggestion is clicked + */ + onSuggestionClick?: (suggestion: string) => void; + /** + * Custom loading component + */ + loadingComponent?: () => JSX.Element; + /** + * Optional class names + */ + classNames?: PromptSuggestionsClassNames; + /** + * Optional translations + */ + translations?: PromptSuggestionsTranslations; +}; + +export function createPromptSuggestionsComponent({ + createElement, +}: Pick) { + const Button = createButtonComponent({ createElement }); + + return function PromptSuggestions(userProps: PromptSuggestionsProps) { + const { + status, + suggestions, + onSuggestionClick, + loadingComponent: LoadingComponent, + classNames = {}, + translations: userTranslations, + } = userProps; + + const cssClasses = { + root: cx('ais-PromptSuggestions', classNames.root), + header: cx('ais-PromptSuggestions-header', classNames.header), + suggestionsList: cx( + 'ais-PromptSuggestions-list', + classNames.suggestionsList + ), + suggestionItem: cx( + 'ais-PromptSuggestions-item', + classNames.suggestionItem + ), + loading: cx('ais-PromptSuggestions-loading', classNames.loading), + }; + + const translations: Required = { + suggestionsHeaderText: 'Suggestions', + ...userTranslations, + }; + + if (status === 'streaming') { + return LoadingComponent ? ( + + ) : ( + Loading suggestions... + ); + } + + if (status === 'ready' && suggestions && suggestions.length > 0) { + return ( +
+

+ {translations.suggestionsHeaderText} +

+ +
    + {suggestions.map((suggestion, index) => ( +
  • + +
  • + ))} +
+
+ ); + } + + return null; + }; +} diff --git a/packages/instantsearch-ui-components/src/components/index.ts b/packages/instantsearch-ui-components/src/components/index.ts index a1c9403422..5a3f9fbeee 100644 --- a/packages/instantsearch-ui-components/src/components/index.ts +++ b/packages/instantsearch-ui-components/src/components/index.ts @@ -9,6 +9,7 @@ export * from './chat/ChatMessageLoader'; export * from './chat/ChatMessageError'; export * from './chat/ChatPrompt'; export * from './chat/ChatToggleButton'; +export * from './chat/PromptSuggestions'; export * from './chat/icons'; export * from './chat/types'; export * from './FrequentlyBoughtTogether'; diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index a895454cb3..06a92e6ea4 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -78,6 +78,10 @@ export type ChatRenderState = { * Tools configuration with addToolResult bound, ready to be used by the UI. */ tools: ClientSideTools; + /** + * Suggestions received from the AI model. + */ + suggestions?: string[]; } & Pick< AbstractChat, | 'addToolResult' @@ -113,6 +117,10 @@ export type ChatConnectorParams = ( * Whether to resume an ongoing chat generation stream. */ resume?: boolean; + /** + * Whether to enable caching of chat messages. + */ + enableCaching?: boolean; /** * Configuration for client-side tools. */ @@ -146,7 +154,12 @@ export default (function connectChat( ) => { warning(false, 'Chat is not yet stable and will change in the future.'); - const { resume = false, tools = {}, ...options } = widgetParams || {}; + const { + resume = false, + enableCaching = true, + tools = {}, + ...options + } = widgetParams || {}; let _chatInstance: Chat; let input = ''; @@ -156,6 +169,7 @@ export default (function connectChat( let setInput: ChatRenderState['setInput']; let setOpen: ChatRenderState['setOpen']; let setIsClearing: (value: boolean) => void; + let suggestions: string[] | undefined; const setMessages = ( messagesParam: TUiMessage[] | ((m: TUiMessage[]) => TUiMessage[]) @@ -216,7 +230,13 @@ export default (function connectChat( return new Chat({ ...options, transport, + enableCaching, sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, + onData: ({ data }) => { + if (data && typeof data === 'object' && 'suggestions' in data) { + suggestions = (data as any).suggestions as string[] | undefined; + } + }, onToolCall({ toolCall }) { const tool = tools[toolCall.toolName]; @@ -356,6 +376,7 @@ export default (function connectChat( setInput, setOpen, setMessages, + suggestions, isClearing, clearMessages, onClearTransitionEnd, diff --git a/packages/instantsearch.js/src/lib/chat/chat.ts b/packages/instantsearch.js/src/lib/chat/chat.ts index 3c70509245..00f5d667f0 100644 --- a/packages/instantsearch.js/src/lib/chat/chat.ts +++ b/packages/instantsearch.js/src/lib/chat/chat.ts @@ -33,11 +33,26 @@ export class ChatState constructor( id: string | undefined = undefined, - initialMessages: TUiMessage[] = getDefaultInitialMessages(id) + initialMessages: TUiMessage[] = [], + enableCaching: boolean = true ) { - this._messages = initialMessages; + this._messages = + enableCaching && initialMessages.length === 0 + ? getDefaultInitialMessages(id) + : []; const saveMessagesInLocalStorage = () => { - if (this.status === 'ready') { + if (this.status === 'ready' && enableCaching) { + // We remove data-* parts before saving as it causes validation errors on the API side + this.messages.forEach((message) => { + if (message.role === 'assistant') { + const newParts = message.parts.filter( + (part) => !part.type.includes('data-') + ); + + message.parts = newParts; + } + }); + try { sessionStorage.setItem(CACHE_KEY + id, JSON.stringify(this.messages)); } catch (e) { @@ -76,6 +91,10 @@ export class ChatState this._callMessagesCallbacks(); } + get data(): unknown { + return this.data; + } + pushMessage = (message: TUiMessage) => { this._messages = this._messages.concat(message); this._callMessagesCallbacks(); @@ -143,13 +162,18 @@ export class Chat< constructor({ messages, agentId, + enableCaching = true, ...init - }: ChatInit & { agentId?: string }) { - const state = new ChatState(agentId, messages); + }: ChatInit & { agentId?: string; enableCaching?: boolean }) { + const state = new ChatState(agentId, messages, enableCaching); super({ ...init, state }); this._state = state; } + get data() { + return this._state.data; + } + '~registerMessagesCallback' = (onChange: () => void): (() => void) => this._state['~registerMessagesCallback'](onChange); diff --git a/packages/instantsearch.js/src/widgets/index.ts b/packages/instantsearch.js/src/widgets/index.ts index 35366b160f..52ad652ed8 100644 --- a/packages/instantsearch.js/src/widgets/index.ts +++ b/packages/instantsearch.js/src/widgets/index.ts @@ -60,3 +60,4 @@ export { default as voiceSearch } from './voice-search/voice-search'; export { default as frequentlyBoughtTogether } from './frequently-bought-together/frequently-bought-together'; export { default as lookingSimilar } from './looking-similar/looking-similar'; export { default as chat } from './chat/chat'; +export { default as promptSuggestions } from './prompt-suggestions/prompt-suggestions'; diff --git a/packages/instantsearch.js/src/widgets/prompt-suggestions/prompt-suggestions.tsx b/packages/instantsearch.js/src/widgets/prompt-suggestions/prompt-suggestions.tsx new file mode 100644 index 0000000000..c8843ae74a --- /dev/null +++ b/packages/instantsearch.js/src/widgets/prompt-suggestions/prompt-suggestions.tsx @@ -0,0 +1,245 @@ +/** @jsx h */ + +import { createPromptSuggestionsComponent } from 'instantsearch-ui-components'; +import { h, render } from 'preact'; + +import TemplateComponent from '../../components/Template/Template'; +import connectChat from '../../connectors/chat/connectChat'; +import { prepareTemplateProps } from '../../lib/templating'; +import { + getContainerNode, + createDocumentationMessageGenerator, +} from '../../lib/utils'; + +import type { + ChatRenderState, + ChatConnectorParams, + ChatWidgetDescription as PromptSuggestionsWidgetDescription, +} from '../../connectors/chat/connectChat'; +import type { PreparedTemplateProps } from '../../lib/templating'; +import type { + WidgetFactory, + Renderer, + TemplateWithBindEvent, + Template, +} from '../../types'; +import type { + ClientSideToolComponentProps, + PromptSuggestionsClassNames, + PromptSuggestionsTranslations, + UserClientSideTool, +} from 'instantsearch-ui-components'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'prompt-suggestions', +}); + +const PromptSuggestions = createPromptSuggestionsComponent({ + createElement: h, +}); + +const createRenderer = ({ + cssClasses, + renderState, + containerNode, + templates, +}: { + cssClasses: PromptSuggestionsCSSClasses; + containerNode: HTMLElement; + renderState: { + templateProps?: PreparedTemplateProps; + }; + templates: PromptSuggestionsTemplates; +}): Renderer> => { + const state = createLocalState(); + + return (props, isFirstRendering) => { + const { instantSearchInstance, suggestions, status, error } = props; + + const onSuggestionClick = (suggestion: string) => { + instantSearchInstance.renderState[ + instantSearchInstance.mainIndex.getIndexId() + ].chat?.setOpen(true); + instantSearchInstance.renderState[ + instantSearchInstance.mainIndex.getIndexId() + ].chat?.sendMessage({ text: suggestion }); + }; + + if (__DEV__ && error) { + throw error; + } + + if (isFirstRendering) { + renderState.templateProps = prepareTemplateProps({ + defaultTemplates: {} as unknown as PromptSuggestionsTemplates, + templatesConfig: instantSearchInstance.templatesConfig, + templates, + }); + return; + } + + const loadingComponent = templates.loading + ? () => ( + + ) + : undefined; + + const translations: PromptSuggestionsTranslations = { + suggestionsHeaderText: templates.suggestionsHeaderText, + }; + + state.subscribe(rerender); + + function rerender() { + render( + , + containerNode + ); + } + + rerender(); + }; +}; + +export type UserClientSideToolTemplates = Partial<{ + layout: TemplateWithBindEvent; +}>; + +type UserClientSideToolWithTemplate = Omit< + UserClientSideTool, + 'layoutComponent' +> & { + templates: UserClientSideToolTemplates; +}; +type UserClientSideToolsWithTemplate = Record< + string, + UserClientSideToolWithTemplate +>; + +export type Tool = UserClientSideToolWithTemplate; +export type Tools = UserClientSideToolsWithTemplate; + +export type PromptSuggestionsCSSClasses = Partial; + +export type PromptSuggestionsTemplates = Partial<{ + /** + * Template for the loading state. + */ + loading: Template; + /** + * Header text for the suggestions list. + */ + suggestionsHeaderText: string; +}>; + +type PromptSuggestionsWidgetParams = { + /** + * CSS Selector or HTMLElement to insert the widget. + */ + container: string | HTMLElement; + + /** + * Templates to use for the widget. + */ + templates?: PromptSuggestionsTemplates; + + /** + * CSS classes to add. + */ + cssClasses?: PromptSuggestionsCSSClasses; +}; + +export type PromptSuggestionsWidget = WidgetFactory< + PromptSuggestionsWidgetDescription & { + $$widgetType: 'ais.prompt-suggestions'; + }, + ChatConnectorParams, + PromptSuggestionsWidgetParams +>; + +export default (function promptSuggestions( + widgetParams: PromptSuggestionsWidgetParams & ChatConnectorParams +) { + const { + container, + templates = {}, + cssClasses = {}, + resume = false, + ...options + } = widgetParams || {}; + + if (!container) { + throw new Error(withUsage('The `container` option is required.')); + } + + const containerNode = getContainerNode(container); + + const specializedRenderer = createRenderer({ + containerNode, + cssClasses, + renderState: {}, + templates, + }); + + const makeWidget = connectChat(specializedRenderer, () => + render(null, containerNode) + ); + + return { + ...makeWidget({ + resume, + ...options, + }), + $$widgetType: 'ais.prompt-suggestions', + }; +} satisfies PromptSuggestionsWidget); + +function createLocalState() { + const state: unknown[] = []; + const subscriptions = new Set<() => void>(); + let cursor = 0; + + function use(initialValue: T): [T, (value: T) => T] { + const index = cursor++; + if (state[index] === undefined) { + state[index] = initialValue; + } + + return [ + state[index] as T, + (value: T) => { + const prev = state[index] as T; + if (prev === value) { + return prev; + } + + state[index] = value; + subscriptions.forEach((fn) => fn()); + return value; + }, + ]; + } + + return { + init() { + cursor = 0; + }, + subscribe(fn: () => void): () => void { + subscriptions.add(fn); + + return () => subscriptions.delete(fn); + }, + use, + }; +} diff --git a/packages/react-instantsearch-core/src/connectors/usePromptSuggestions.ts b/packages/react-instantsearch-core/src/connectors/usePromptSuggestions.ts new file mode 100644 index 0000000000..cd98e509a9 --- /dev/null +++ b/packages/react-instantsearch-core/src/connectors/usePromptSuggestions.ts @@ -0,0 +1,53 @@ +import connectChat from 'instantsearch.js/es/connectors/chat/connectChat'; +import { useEffect } from 'react'; + +import { useConnector } from '../hooks/useConnector'; +import { useInstantSearch } from '../index.umd'; + +import type { AdditionalWidgetProperties } from '../hooks/useConnector'; +import type { + ChatConnector, + ChatConnectorParams, + ChatInit, + ChatWidgetDescription, +} from 'instantsearch.js/es/connectors/chat/connectChat'; +import type { UIMessage } from 'instantsearch.js/es/lib/chat'; + +export type UsePromptSuggestionsProps< + TUiMessage extends UIMessage = UIMessage +> = Pick, 'agentId' | 'transport'>; + +export function usePromptSuggestions( + props: UsePromptSuggestionsProps, + additionalWidgetProperties?: AdditionalWidgetProperties +) { + const { + indexRenderState, + results: { hits }, + } = useInstantSearch(); + + const suggestionsClickCallback = (suggestion: string) => { + indexRenderState.chat?.setOpen(true); + indexRenderState.chat?.setInput(suggestion); + }; + + const { suggestions, messages, sendMessage, status } = useConnector< + ChatConnectorParams, + ChatWidgetDescription + >( + connectChat as unknown as ChatConnector, + { ...props, enableCaching: false }, + additionalWidgetProperties + ); + + useEffect(() => { + if (hits.length > 0 && messages.length === 0) { + const objectToSend = hits[0]; + sendMessage({ + text: JSON.stringify(objectToSend), + }); + } + }, [messages.length, hits, sendMessage]); + + return { suggestions, suggestionsClickCallback, status }; +} diff --git a/packages/react-instantsearch-core/src/index.ts b/packages/react-instantsearch-core/src/index.ts index 19ad024c0b..4f995e04e5 100644 --- a/packages/react-instantsearch-core/src/index.ts +++ b/packages/react-instantsearch-core/src/index.ts @@ -8,6 +8,7 @@ export * from './components/InstantSearchSSRProvider'; export * from './connectors/useAutocomplete'; export * from './connectors/useBreadcrumb'; export * from './connectors/useChat'; +export * from './connectors/usePromptSuggestions'; export * from './connectors/useClearRefinements'; export * from './connectors/useConfigure'; export * from './connectors/useCurrentRefinements'; diff --git a/packages/react-instantsearch/src/widgets/PromptSuggestions.tsx b/packages/react-instantsearch/src/widgets/PromptSuggestions.tsx new file mode 100644 index 0000000000..140aa43583 --- /dev/null +++ b/packages/react-instantsearch/src/widgets/PromptSuggestions.tsx @@ -0,0 +1,43 @@ +import { createPromptSuggestionsComponent } from 'instantsearch-ui-components'; +import React, { createElement } from 'react'; +import { usePromptSuggestions } from 'react-instantsearch-core'; + +import type { + Pragma, + PromptSuggestionsClassNames, + PromptSuggestionsTranslations, +} from 'instantsearch-ui-components'; + +const PromptSuggestionsUiComponent = createPromptSuggestionsComponent({ + createElement: createElement as Pragma, +}); + +export type PromptSuggestionsProps = { + agentId: string; + loadingComponent?: () => JSX.Element; + classNames?: Partial; + translations?: Partial; +}; + +export function PromptSuggestions({ + agentId, + loadingComponent, + classNames, + translations, +}: PromptSuggestionsProps) { + const { suggestions, status, suggestionsClickCallback } = + usePromptSuggestions({ + agentId, + }); + + return ( + + ); +} diff --git a/packages/react-instantsearch/src/widgets/index.ts b/packages/react-instantsearch/src/widgets/index.ts index 0cc3e1bcfb..0d0040028a 100644 --- a/packages/react-instantsearch/src/widgets/index.ts +++ b/packages/react-instantsearch/src/widgets/index.ts @@ -23,3 +23,4 @@ export * from './SortBy'; export * from './Stats'; export * from './ToggleRefinement'; export * from './TrendingItems'; +export * from './PromptSuggestions';