diff --git a/examples/js/getting-started/src/app.js b/examples/js/getting-started/src/app.js index 61d747171db..8515a98ae0b 100644 --- a/examples/js/getting-started/src/app.js +++ b/examples/js/getting-started/src/app.js @@ -1,47 +1,33 @@ import { liteClient as algoliasearch } from 'algoliasearch/lite'; import instantsearch from 'instantsearch.js'; -import { carousel } from 'instantsearch.js/es/templates'; import { - configure, hits, - pagination, + searchBox, + experience, + configure, panel, refinementList, - searchBox, - trendingItems, - chat, + pagination, } from 'instantsearch.js/es/widgets'; import 'instantsearch.css/themes/satellite.css'; const searchClient = algoliasearch( - 'latency', - '6be0576ff61c053d5f9a3225e2a90f76' + 'F4T6CUV2AH', + '4ce25fa46f7de67117fc1b787742e0f3' ); const search = instantsearch({ - indexName: 'instant_search', + indexName: 'spencer_and_williams', searchClient, insights: true, + future: { enableExperience: { env: 'beta' } }, }); -const productItemTemplate = (item, { html }) => html` - -`; - search.addWidgets([ + experience({ + id: 'agent-ui-7354b616-d29e-4f47-b339-205f3c8f0222', + }), searchBox({ container: '#searchbox', }), @@ -73,21 +59,6 @@ search.addWidgets([ pagination({ container: '#pagination', }), - trendingItems({ - container: '#trending', - limit: 6, - templates: { - item: productItemTemplate, - layout: carousel(), - }, - }), - chat({ - container: '#chat', - agentId: '7c2f6816-bfdb-46e9-a51f-9cb8e5fc9628', - templates: { - item: productItemTemplate, - }, - }), ]); search.start(); diff --git a/packages/algolia-experiences/rollup.config.js b/packages/algolia-experiences/rollup.config.js index 258242f17de..341a8d55084 100644 --- a/packages/algolia-experiences/rollup.config.js +++ b/packages/algolia-experiences/rollup.config.js @@ -1,5 +1,6 @@ import path from 'path'; +import alias from 'rollup-plugin-alias'; import babel from 'rollup-plugin-babel'; import commonjs from 'rollup-plugin-commonjs'; import resolve from 'rollup-plugin-node-resolve'; @@ -17,6 +18,38 @@ const link = 'https://github.com/algolia/instantsearch'; const license = `/*! algolia-experiences ${version} | ${algolia} | ${link} */`; const plugins = [ + alias({ + entries: [ + { + find: /^zod.*/, + replacement: path.join( + __dirname, + '../instantsearch.js/scripts/rollup/emptyModule.js' + ), + }, + { + find: /^react.*/, + replacement: path.join( + __dirname, + '../instantsearch.js/scripts/rollup/emptyModule.js' + ), + }, + { + find: /^instantsearch\.css\/.*\.css$/, + replacement: path.join( + __dirname, + '../instantsearch.js/scripts/rollup/emptyModule.js' + ), + }, + { + find: 'eventsource-parser/stream', + replacement: path.join( + __dirname, + '../../node_modules/eventsource-parser/dist/stream.js' + ), + }, + ], + }), { /** * This plugin is a workaround for the fact that the `algoliasearch/lite` diff --git a/packages/instantsearch-ui-components/src/components/autocomplete/createAutocompletePropGetters.ts b/packages/instantsearch-ui-components/src/components/autocomplete/createAutocompletePropGetters.ts index bd4c5910875..87459b0885c 100644 --- a/packages/instantsearch-ui-components/src/components/autocomplete/createAutocompletePropGetters.ts +++ b/packages/instantsearch-ui-components/src/components/autocomplete/createAutocompletePropGetters.ts @@ -225,7 +225,7 @@ export function createAutocompletePropGetters({ event.preventDefault(); return; default: - setActiveDescendant(undefined); + setActiveDescendant(itemsIds[0] || undefined); break; } }, diff --git a/packages/instantsearch.css/src/components/autocomplete.scss b/packages/instantsearch.css/src/components/autocomplete.scss index cbfcf622adc..dcc6a356f81 100644 --- a/packages/instantsearch.css/src/components/autocomplete.scss +++ b/packages/instantsearch.css/src/components/autocomplete.scss @@ -231,6 +231,90 @@ } } +// Dialog +.ais-AutocompleteDialog { + &--active { + overflow: hidden !important; + } + + .ais-AutocompleteDialog-Button { + cursor: pointer; + display: flex; + align-items: center; + appearance: none; + margin: 0; + padding: 0; + width: 100%; + height: var(--ais-autocomplete-search-input-height); + font: inherit; + color: rgba(var(--ais-text-color-rgb), var(--ais-text-color-alpha)); + background-color: rgba(var(--ais-background-color-rgb), var(--ais-background-color-alpha)); + border: 1px solid rgba(var(--ais-border-color-rgb), .8); + border-radius: var(--ais-border-radius-sm); + line-height: 1em; + } + + .ais-AutocompleteDialog-Button-Icon { + display: flex; + align-items: center; + padding-left: calc(var(--ais-spacing) * .75 - 1px); + padding-right: calc(var(--ais-spacing) / 2); + height: 100%; + width: calc(var(--ais-spacing) * 1.75 + var(--ais-icon-size) - 1px); + + svg { + fill: rgba(var(--ais-primary-color-rgb), 1); + height: auto; + max-height: var(--ais-icon-size); + stroke-width: var(--ais-icon-stroke-width); + width: var(--ais-icon-size); + } + + span { + color: rgba(var(--ais-text-color-rgb), .8); + } + } + + .ais-AutocompleteDialog-Container { + position: fixed; + inset-block-start: 0; + inset-inline-start: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + z-index: 400; + } + + .ais-AutocompleteDialog-Content { + position: relative; + margin: 100px auto auto; + max-width: 800px; + } +} + +// Agent +.ais-Autocomplete-AgentPrompt { + border-radius: 4px; + display: flex; + flex-direction: row; + align-items: center; + padding: 0 8px; + height: 56px; + + svg { + width: calc(var(--ais-icon-size) * 1.4); + margin-right: 8px; + color: rgb(var(--ais-primary-color-rgb), .8); + } + + span { + position: relative; + color: rgb(var(--ais-primary-color-rgb), .8); + border-bottom: 1px solid rgb(var(--ais-primary-color-rgb), .8); + } +} + // Source .ais-AutocompleteIndex { margin: 0; @@ -347,6 +431,49 @@ font-style: normal; font-weight: var(--ais-font-weight-bold); } + + @at-root .ais-AutocompleteItemContentBody { + display: grid; + gap: calc(var(--ais-spacing) / 2); + } + @at-root .ais-AutocompleteItemContentTitle { + display: inline-block; + margin: 0 0.5em 0 0; + max-width: 100%; + overflow: hidden; + padding: 0; + text-overflow: ellipsis; + white-space: nowrap; + } + @at-root .ais-AutocompleteItemContentDescription { + color: rgba(var(--ais-text-color-rgb), var(--ais-text-color-alpha)); + font-size: 0.85em; + max-width: 100%; + overflow-x: hidden; + text-overflow: ellipsis; + &:empty { + display: none; + } + mark { + // background: rgba( + // var(--ais-description-highlight-background-color-rgb), + // var(--ais-description-highlight-background-color-alpha) + // ); + color: rgba(var(--ais-text-color-rgb), var(--ais-text-color-alpha)); + font-style: normal; + font-weight: var(--ais-font-weight-medium); + } + } + + @at-root .ais-AutocompleteItemContentTag { + background-color: rgba( + var(--ais-muted-color-rgb), + .15 + ); + border-radius: 3px; + margin: 0 0.4em 0 0; + padding: 0.08em 0.3em; + } } @at-root .ais-AutocompleteItemIcon { @@ -362,10 +489,32 @@ stroke-width: var(--ais-icon-stroke-width); text-align: center; width: calc(var(--ais-icon-size) + calc(var(--ais-spacing) / 2)); + img { + height: auto; + max-height: calc(var(--ais-icon-size) + calc(var(--ais-spacing) / 2) - 8px); + max-width: calc(var(--ais-icon-size) + calc(var(--ais-spacing) / 2) - 8px); + width: auto; + } svg { height: var(--ais-icon-size); width: var(--ais-icon-size); } + @at-root .ais-AutocompleteItemIcon--alignTop { + align-self: flex-start; + } + @at-root .ais-AutocompleteItemIcon--noBorder { + background: none; + box-shadow: none; + } + @at-root .ais-AutocompleteItemIcon--picture { + height: 96px; + width: 96px; + img { + max-height: 100%; + max-width: 100%; + padding: calc(var(--ais-spacing) / 2); + } + } } @at-root .ais-AutocompleteItemActions { display: grid; diff --git a/packages/instantsearch.css/src/components/chat.scss b/packages/instantsearch.css/src/components/chat.scss index 141836490cd..d02b89e8674 100644 --- a/packages/instantsearch.css/src/components/chat.scss +++ b/packages/instantsearch.css/src/components/chat.scss @@ -11,3 +11,51 @@ @use 'chat/chat-prompt'; @use 'chat/chat-carousel'; @use 'chat/chat-suggestions'; + +.ais-Chat-ToolCard { + margin-bottom: calc(var(--ais-spacing) * 1.5); + max-width: calc(var(--ais-chat-width) - var(--ais-spacing) * 4); + + &--loading { + position: relative; + padding: calc(var(--ais-spacing) * .75); + width: fit-content; + display: flex; + color: rgba(var(--ais-muted-color-rgb), var(--ais-muted-color-alpha)); + background-color: rgba(var(--ais-muted-color-rgb), .1); + border-radius: var(--ais-border-radius-md); + overflow: hidden; + + > * { + visibility: hidden; + } + + &::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.55) 50%, + rgba(255, 255, 255, 0) 100% + ); + animation: ais-card-shine 1.2s ease-in-out infinite; + pointer-events: none; + } + } +} + +@keyframes ais-card-shine { + to { + transform: translateX(100%); + } +} + +@media (prefers-reduced-motion: reduce) { + .ais-Chat-ToolCard--loading::after { + animation: none; + display: none; + } +} diff --git a/packages/instantsearch.js/scripts/rollup/emptyModule.js b/packages/instantsearch.js/scripts/rollup/emptyModule.js new file mode 100644 index 00000000000..d21c714bc0b --- /dev/null +++ b/packages/instantsearch.js/scripts/rollup/emptyModule.js @@ -0,0 +1,97 @@ +// Placeholder export for 'zod' module +export const ZodFirstPartyTypeKind = {}; +export const toJSONSchema = {}; +export const safeParseAsync = {}; +export const z = { + string() { + return this; + }, + instanceof() { + return this; + }, + custom() { + return this; + }, + object() { + return this; + }, + array() { + return this; + }, + union() { + return this; + }, + literal() { + return this; + }, + lazy() { + return this; + }, + null() { + return this; + }, + number() { + return this; + }, + boolean() { + return this; + }, + record() { + return this; + }, + optional() { + return this; + }, + unknown() { + return this; + }, + discriminatedUnion() { + return this; + }, + strictObject() { + return this; + }, + startsWith() { + return this; + }, + looseObject() { + return this; + }, + loose() { + return this; + }, + base64() { + return this; + }, + extend() { + return this; + }, + default() { + return this; + }, + or() { + return this; + }, + int() { + return this; + }, + merge() { + return this; + }, + strict() { + return this; + }, + enum() { + return this; + }, + never() { + return this; + }, + '~standard': { + validate: (value) => Promise.resolve({ issues: null, value }), + }, +}; + +// Placeholder export for 'react' module +export const cloneElement = {}; +export const createElement = {}; diff --git a/packages/instantsearch.js/scripts/rollup/rollup.config.js b/packages/instantsearch.js/scripts/rollup/rollup.config.js index fc29baf117b..cb79d416635 100644 --- a/packages/instantsearch.js/scripts/rollup/rollup.config.js +++ b/packages/instantsearch.js/scripts/rollup/rollup.config.js @@ -1,3 +1,6 @@ +import path from 'path'; + +import alias from 'rollup-plugin-alias'; import babel from 'rollup-plugin-babel'; import commonjs from 'rollup-plugin-commonjs'; import filesize from 'rollup-plugin-filesize'; @@ -16,6 +19,29 @@ const link = 'https://github.com/algolia/instantsearch'; const license = `/*! InstantSearch.js ${version} | ${algolia} | ${link} */`; const plugins = [ + alias({ + entries: [ + { + find: /^zod.*/, + replacement: path.join(__dirname, './emptyModule.js'), + }, + { + find: /^react.*/, + replacement: path.join(__dirname, './emptyModule.js'), + }, + { + find: /^instantsearch\.css\/.*\.css$/, + replacement: path.join(__dirname, './emptyModule.js'), + }, + { + find: 'eventsource-parser/stream', + replacement: path.join( + __dirname, + '../../../../node_modules/eventsource-parser/dist/stream.js' + ), + }, + ], + }), resolve({ browser: true, preferBuiltins: false, diff --git a/packages/instantsearch.js/src/lib/InstantSearch.ts b/packages/instantsearch.js/src/lib/InstantSearch.ts index 19baddd252a..7061e67be90 100644 --- a/packages/instantsearch.js/src/lib/InstantSearch.ts +++ b/packages/instantsearch.js/src/lib/InstantSearch.ts @@ -1,6 +1,7 @@ import EventEmitter from '@algolia/events'; import algoliasearchHelper from 'algoliasearch-helper'; +import { createExperienceMiddleware } from '../middlewares/createExperienceMiddleware'; import { createInsightsMiddleware } from '../middlewares/createInsightsMiddleware'; import { createMetadataMiddleware, @@ -23,6 +24,7 @@ import { } from './utils'; import version from './version'; +import type { ExperienceProps } from '../middlewares/createExperienceMiddleware'; import type { InsightsEvent, InsightsProps, @@ -191,6 +193,8 @@ export type InstantSearchOptions< */ // @MAJOR: Remove legacy behaviour here and in algoliasearch-helper persistHierarchicalRootCount?: boolean; + + enableExperience?: boolean | ExperienceProps; }; }; @@ -201,6 +205,7 @@ export const INSTANTSEARCH_FUTURE_DEFAULTS: Required< > = { preserveSharedStateOnUnmount: false, persistHierarchicalRootCount: false, + enableExperience: false, }; /** @@ -731,6 +736,18 @@ See documentation: ${createDocumentationLink({ instance.started(); }); + // This is the automatic Managed Ui middleware, + // added when `future.managedUi` is set. + if (this.future.enableExperience) { + this.use( + createExperienceMiddleware( + typeof this.future.enableExperience !== 'boolean' + ? this.future.enableExperience + : {} + ) + ); + } + // This is the automatic Insights middleware, // added when `insights` is unset and the initial results possess `queryID`. // Any user-provided middleware will be added later and override this one. diff --git a/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts b/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts new file mode 100644 index 00000000000..4fa597dbd67 --- /dev/null +++ b/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts @@ -0,0 +1,138 @@ +import { getAppIdAndApiKey, walkIndex, warning } from '../lib/utils'; + +import type { InternalMiddleware } from '../types'; +import type { + ExperienceApiResponse, + ExperienceWidget, +} from '../widgets/experience/types'; + +export type ExperienceProps = { + env?: 'prod' | 'beta'; +}; + +const API_BASE = { + beta: 'https://experiences-beta.algolia.com/1', + prod: 'https://experiences.algolia.com/1', +}; + +export function createExperienceMiddleware( + props: ExperienceProps = {} +): InternalMiddleware { + const { env = 'prod' } = props; + + return ({ instantSearchInstance }) => { + return { + $$type: 'ais.experience', + $$internal: true, + onStateChange: () => {}, + subscribe() { + const experienceWidgets: ExperienceWidget[] = []; + walkIndex(instantSearchInstance.mainIndex, (index) => { + const widgets = index.getWidgets(); + + widgets.forEach((widget) => { + if (widget.$$type === 'ais.experience') { + experienceWidgets.push(widget as ExperienceWidget); + } + }); + }); + + const [appId, apiKey] = getAppIdAndApiKey(instantSearchInstance.client); + if (!(appId && apiKey)) { + warning( + false, + 'Could not retrieve credentials from the Algolia client.' + ); + return; + } + + Promise.all( + experienceWidgets.map((widget) => + buildExperienceRequest({ + appId, + apiKey, + env, + experienceId: widget.$$widgetParams.id, + }) + ) + ).then((configs) => { + configs.forEach((config, index) => { + const widget = experienceWidgets[index]; + const parent = widget.parent!; + + parent.removeWidgets([widget]); + + config.blocks.forEach((block) => { + const { type, parameters } = block; + + const cssVariablesKeys = Object.keys(parameters.cssVariables); + if (cssVariablesKeys.length > 0) { + injectStyleElement(` + :root { + ${cssVariablesKeys + .map((key) => { + return `--ais-${key}: ${parameters.cssVariables[key]};`; + }) + .join(';')} + } + `); + } + + const newWidget = widget.$$supportedWidgets[type].widget; + widget.$$supportedWidgets[type] + .transformParams(parameters, { env, instantSearchInstance }) + .then((transformedParams) => { + if ( + newWidget && + document.querySelector(parameters.container) !== null + ) { + parent.addWidgets([newWidget(transformedParams)]); + } + }); + }); + }); + }); + }, + started: () => {}, + unsubscribe: () => {}, + }; + }; +} + +type BuildExperienceRequestParams = { + appId: string; + apiKey: string; + env: NonNullable; + experienceId: string; +}; + +export function buildExperienceRequest({ + appId, + apiKey, + env, + experienceId, +}: BuildExperienceRequestParams) { + return fetch(`${API_BASE[env]}/experiences/${experienceId}`, { + method: 'GET', + headers: { + 'X-Algolia-Application-ID': appId, + 'X-Algolia-API-Key': apiKey, + }, + }) + .then((res) => { + if (!res.ok) { + throw new Error(res.statusText); + } + + return res; + }) + .then((res) => res.json() as Promise); +} + +export function injectStyleElement(textContent: string) { + const style = document.createElement('style'); + + style.textContent = textContent; + + document.head.appendChild(style); +} diff --git a/packages/instantsearch.js/src/middlewares/index.ts b/packages/instantsearch.js/src/middlewares/index.ts index a44215a78bc..62a2948b24b 100644 --- a/packages/instantsearch.js/src/middlewares/index.ts +++ b/packages/instantsearch.js/src/middlewares/index.ts @@ -1,3 +1,4 @@ +export * from './createExperienceMiddleware'; export * from './createInsightsMiddleware'; export * from './createRouterMiddleware'; export * from './createMetadataMiddleware'; diff --git a/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx b/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx index f7884d5668c..16cf73dbd18 100644 --- a/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx +++ b/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx @@ -9,7 +9,9 @@ import { createAutocompleteSearchComponent, createAutocompleteStorage, createAutocompleteSuggestionComponent, + createChatMessagesComponent, cx, + SparklesIcon, } from 'instantsearch-ui-components'; import { Fragment, h, render } from 'preact'; import { useEffect, useId, useMemo, useRef, useState } from 'preact/hooks'; @@ -28,6 +30,8 @@ import { getContainerNode, walkIndex, } from '../../lib/utils'; +import { createDefaultTools } from '../chat/chat'; +import { makeChatInstance } from '../chat/makeChat'; import configure from '../configure/configure'; import index from '../index/index'; @@ -37,6 +41,8 @@ import type { AutocompleteWidgetDescription, TransformItemsIndicesConfig, } from '../../connectors/autocomplete/connectAutocomplete'; +import type { ChatTransport } from '../../connectors/chat/connectChat'; +import type { Chat, UIMessage } from '../../lib/chat'; import type { PreparedTemplateProps } from '../../lib/templating'; import type { BaseHit, @@ -54,6 +60,7 @@ import type { AutocompleteIndexClassNames, AutocompleteIndexConfig, AutocompleteIndexProps, + ChatStatus, } from 'instantsearch-ui-components'; let autocompleteInstanceId = 0; @@ -90,6 +97,11 @@ const AutocompleteRecentSearch = createAutocompleteRecentSearchComponent({ Fragment, }); +const ChatMessages = createChatMessagesComponent({ + createElement: h, + Fragment, +}); + const usePropGetters = createAutocompletePropGetters({ useEffect, useId, @@ -121,10 +133,16 @@ type RendererParams = { recentSearchHeaderComponent: | typeof AutocompleteIndex['prototype']['props']['HeaderComponent'] | undefined; + chatInstance: Chat | undefined; }; } & Pick< AutocompleteWidgetParams, - 'getSearchPageURL' | 'onSelect' | 'showSuggestions' | 'placeholder' + | 'getSearchPageURL' + | 'onSelect' + | 'agent' + | 'display' + | 'showSuggestions' + | 'placeholder' > & { showRecent: | Exclude['showRecent'], boolean> @@ -248,6 +266,8 @@ type AutocompleteWrapperProps = Pick< | 'cssClasses' | 'templates' | 'renderState' + | 'agent' + | 'display' | 'showRecent' | 'showSuggestions' | 'placeholder' @@ -264,6 +284,8 @@ function AutocompleteWrapper({ cssClasses, renderState, instantSearchInstance, + agent, + display, showRecent, showSuggestions, templates, @@ -323,11 +345,100 @@ function AutocompleteWrapper({ query.length > 0 && storage.onAdd(query); }; + const inputRef = useRef(null); + + const [showUi, setShowUi] = useState(false); + const [showConversation, setShowConversation] = useState(false); + const [agentMessages, setAgentMessages] = useState([]); + const [agentStatus, setAgentStatus] = useState('ready'); + const agentTools = createDefaultTools({ + item: (item, { html }) => { + return html`
${JSON.stringify(item)}
`; + }, + }); + const disableTools = true; // Temporarily disabling tools + + const sendMessage = (message: string) => { + if (agent && !renderState.chatInstance) { + renderState.chatInstance = makeChatInstance( + instantSearchInstance, + agent, + disableTools ? undefined : agentTools + ); + renderState.chatInstance.messages = []; // Temporarily clearing history on load + renderState.chatInstance['~registerMessagesCallback'](() => { + setAgentMessages(renderState.chatInstance!.messages); + }); + renderState.chatInstance['~registerStatusCallback'](() => { + setAgentStatus(renderState.chatInstance!.status); + }); + } + + renderState.chatInstance!.sendMessage({ text: message }); + }; + + useEffect(() => { + document.body.classList.toggle('ais-AutocompleteDialog--active', showUi); + if (showUi) { + inputRef.current?.focus(); + } + + return () => { + document.body.classList.remove('ais-AutocompleteDialog--active'); + }; + }, [showUi]); + + const indicesWithAgent = ( + _indices: Parameters[0]['indices'] + ) => { + if (!agent) { + return _indices; + } + + return [ + { + indexName: 'ais-autocomplete-agent', + indexId: 'ais-autocomplete-agent', + hits: searchboxQuery ? [{ query: searchboxQuery }] : [], + }, + ..._indices, + ]; + }; + + const indicesConfigWithAgent = ( + _indicesConfig: Array> + ) => { + if (!agent) { + return _indicesConfig; + } + + return [ + { + indexName: 'ais-autocomplete-agent', + getQuery: (item) => item.query, + onSelect: ({ query }) => { + sendMessage(query); + inputRef.current!.select(); + setShowConversation(true); + }, + }, + ..._indicesConfig, + ]; + }; + const { getInputProps, getItemProps, getPanelProps, getRootProps } = usePropGetters({ - indices: indicesForPropGetters, - indicesConfig: indicesConfigForPropGetters, - onRefine, + indices: indicesWithAgent(indicesForPropGetters), + indicesConfig: indicesConfigWithAgent(indicesConfigForPropGetters), + onRefine: (query) => { + if (agent && showConversation) { + sendMessage(query); + inputRef.current!.select(); + return; + } + + onRefine(query); + }, onSelect: userOnSelect ?? (({ query, setQuery, url }) => { @@ -348,10 +459,41 @@ function AutocompleteWrapper({ onApply: (query: string) => { refineAutocomplete(query); }, - placeholder, + placeholder: showConversation ? 'Ask another question…' : placeholder, }); const elements: PanelElements = {}; + if (agent) { + elements.agent = ( + { + return ( +
+
+ +
+ {item.query && ( +
+ Ask Agent: {`"${item.query}"`} +
+ )} + {!item.query &&
Type something to ask a question…
} +
+ ); + }} + items={[ + { + objectID: 'ais-autocomplete-agent', + __indexName: 'ais-autocomplete-agent', + query: searchboxQuery, + }, + ]} + getItemProps={getItemProps} + /> + ); + } + if (showRecent) { elements.recent = ( ({ ); }); + const agentToolsWithLayoutComponent = Object.entries(agentTools).reduce( + (acc, [key, tool]) => { + return { + ...acc, + [key]: { + ...tool, + layoutComponent: (layoutComponentProps: any) => ( + + ), + }, + }; + }, + {} + ); + return ( - - - refineAutocomplete((event.currentTarget as HTMLInputElement).value), - }} - onClear={() => { - onRefine(''); - }} - isSearchStalled={instantSearchInstance.status === 'stalled'} - /> - - {templates.panel ? ( - + + + + refineAutocomplete( + (event.currentTarget as HTMLInputElement).value + ), + }} + onClear={() => { + onRefine(''); + }} + isSearchStalled={instantSearchInstance.status === 'stalled'} + /> + {!showConversation ? ( + + {templates.panel ? ( + + ) : ( + Object.keys(elements).map((elementId) => elements[elementId]) + )} + ) : ( - Object.keys(elements).map((elementId) => elements[elementId]) +
+
+ +
+
)} -
-
+ + + ); +} + +type AutocompleteDialogWrapperProps = { + display?: 'inline' | 'dialog'; + showUi: boolean; + setShowUi: (showUi: boolean) => void; + setShowConversation: (showConversation: boolean) => void; + placeholder?: string; + children: any; +}; + +function AutocompleteDialogWrapper({ + display, + showUi, + setShowUi, + setShowConversation, + placeholder, + children, +}: AutocompleteDialogWrapperProps) { + if (display !== 'dialog') { + return children; + } + + return ( +
+ + {showUi && ( +
{ + if (event.target === event.currentTarget) { + setShowUi(false); + setShowConversation(false); + } + }} + > +
{children}
+
+ )} +
); } @@ -520,7 +762,7 @@ type IndexConfig = AutocompleteIndexConfig & { type PanelElements = Partial< // eslint-disable-next-line @typescript-eslint/ban-types - Record<'recent' | 'suggestions' | (string & {}), preact.JSX.Element> + Record<'agent' | 'recent' | 'suggestions' | (string & {}), preact.JSX.Element> >; type AutocompleteWidgetParams = { @@ -529,6 +771,10 @@ type AutocompleteWidgetParams = { */ container: string | HTMLElement; + agent?: ChatTransport; + + display?: 'inline' | 'dialog'; + /** * Indices to use in the Autocomplete. */ @@ -611,6 +857,8 @@ export function EXPERIMENTAL_autocomplete( escapeHTML, indices = [], showSuggestions, + agent, + display = 'inline', showRecent, searchParameters: userSearchParameters, getSearchPageURL, @@ -699,6 +947,8 @@ export function EXPERIMENTAL_autocomplete( getSearchPageURL, onSelect, cssClasses, + agent, + display, showRecent: showRecentOptions, showSuggestions, placeholder, @@ -709,6 +959,7 @@ export function EXPERIMENTAL_autocomplete( templateProps: undefined, RecentSearchComponent: AutocompleteRecentSearch, recentSearchHeaderComponent: undefined, + chatInstance: undefined, }, templates, }); diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index 599bb462ba3..40a1045a9a0 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -261,7 +261,7 @@ function createCarouselTool< }; } -function createDefaultTools< +export function createDefaultTools< THit extends RecordWithObjectID = RecordWithObjectID >( templates: ChatTemplates, @@ -273,6 +273,7 @@ function createDefaultTools< templates, getSearchPageURL ), + search_index: createCarouselTool(true, templates, getSearchPageURL), [RecommendToolType]: createCarouselTool(false, templates, getSearchPageURL), }; } diff --git a/packages/instantsearch.js/src/widgets/chat/makeChat.ts b/packages/instantsearch.js/src/widgets/chat/makeChat.ts new file mode 100644 index 00000000000..2ab7ee1b4c8 --- /dev/null +++ b/packages/instantsearch.js/src/widgets/chat/makeChat.ts @@ -0,0 +1,144 @@ +import { + DefaultChatTransport, + lastAssistantMessageIsCompleteWithToolCalls, +} from 'ai'; + +import { Chat } from '../../lib/chat/chat'; +import { getAlgoliaAgent, getAppIdAndApiKey } from '../../lib/utils'; + +import type { ChatTransport } from '../../connectors/chat/connectChat'; +import type { UIMessage } from '../../lib/chat/chat'; +import type { InstantSearch } from '../../types'; +import type { + AddToolResultWithOutput, + UserClientSideTool, +} from 'instantsearch-ui-components'; + +export function makeChatInstance( + instantSearchInstance: InstantSearch, + options: ChatTransport, + tools?: Record> +): Chat { + let transport; + const [appId, apiKey] = getAppIdAndApiKey(instantSearchInstance.client); + + // Filter out custom data parts (like data-suggestions) that the backend doesn't accept + const filterDataParts = (messages: UIMessage[]): UIMessage[] => + messages.map((message) => ({ + ...message, + parts: message.parts?.filter( + (part) => !('type' in part && part.type.startsWith('data-')) + ), + })); + + const agentId = 'agentId' in options ? options.agentId : undefined; + + if ('transport' in options && options.transport) { + const originalPrepare = options.transport.prepareSendMessagesRequest; + transport = new DefaultChatTransport({ + ...options.transport, + prepareSendMessagesRequest: (params) => { + // Call the original prepareSendMessagesRequest if it exists, + // otherwise construct the default body + const preparedOrPromise = originalPrepare + ? originalPrepare(params) + : { body: { ...params } }; + // Then filter out data-* parts + const applyFilter = (prepared: { body: object }) => ({ + ...prepared, + body: { + ...prepared.body, + messages: filterDataParts( + (prepared.body as { messages: UIMessage[] }).messages + ), + }, + }); + + // Handle both sync and async cases + if (preparedOrPromise && 'then' in preparedOrPromise) { + return preparedOrPromise.then(applyFilter); + } + return applyFilter(preparedOrPromise); + }, + }); + } + if ('agentId' in options && options.agentId) { + if (!appId || !apiKey) { + throw new Error( + 'Could not extract Algolia credentials from the search client.' + ); + } + const baseApi = `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/completions?compatibilityMode=ai-sdk-5`; + transport = new DefaultChatTransport({ + api: baseApi, + headers: { + 'x-algolia-application-id': appId, + 'x-algolia-api-Key': apiKey, + 'x-algolia-agent': getAlgoliaAgent(instantSearchInstance.client), + }, + prepareSendMessagesRequest: ({ messages, trigger, ...rest }) => { + return { + // Bypass cache when regenerating to ensure fresh responses + api: + trigger === 'regenerate-message' + ? `${baseApi}&cache=false` + : baseApi, + body: { + ...rest, + messages: filterDataParts(messages), + }, + }; + }, + }); + } + if (!transport) { + throw new Error( + 'You need to provide either an `agentId` or a `transport`.' + ); + } + + const _chatInstance: Chat = new Chat({ + ...options, + transport, + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, + onToolCall({ toolCall }) { + if (!tools) { + return Promise.resolve(); + } + + const tool = tools[toolCall.toolName]; + + if (!tool) { + if (__DEV__) { + throw new Error( + `No tool implementation found for "${toolCall.toolName}". Please provide a tool implementation in the \`tools\` prop.` + ); + } + + return _chatInstance.addToolResult({ + output: `No tool implemented for "${toolCall.toolName}".`, + tool: toolCall.toolName, + toolCallId: toolCall.toolCallId, + }); + } + + if (tool.onToolCall) { + const addToolResult: AddToolResultWithOutput = ({ output }) => + _chatInstance.addToolResult({ + output, + tool: toolCall.toolName, + toolCallId: toolCall.toolCallId, + }); + + return tool.onToolCall({ + ...toolCall, + addToolResult, + }); + } + + return Promise.resolve(); + }, + }); + + return _chatInstance; +} diff --git a/packages/instantsearch.js/src/widgets/experience/experience.tsx b/packages/instantsearch.js/src/widgets/experience/experience.tsx new file mode 100644 index 00000000000..3572abde8da --- /dev/null +++ b/packages/instantsearch.js/src/widgets/experience/experience.tsx @@ -0,0 +1,135 @@ +import { + createDocumentationMessageGenerator, + getAppIdAndApiKey, +} from '../../lib/utils'; +import { buildExperienceRequest } from '../../middlewares/createExperienceMiddleware'; +import { EXPERIMENTAL_autocomplete } from '../autocomplete/autocomplete'; +import chat from '../chat/chat'; + +import { renderTemplate, renderTool } from './render'; +import { ExperienceWidget } from './types'; + +import type { ChatTransport } from '../../connectors/chat/connectChat'; +import type { InstantSearch } from '../../types'; +import type { TemplateChild } from './render'; +import type { ExperienceWidgetParams } from './types'; + +import 'instantsearch.css/components/autocomplete.css'; +import 'instantsearch.css/components/chat.css'; + +const withUsage = createDocumentationMessageGenerator({ name: 'experience' }); + +export default (function experience(widgetParams: ExperienceWidgetParams) { + const { id } = widgetParams || {}; + + if (!id) { + throw new Error(withUsage('The `id` option is required.')); + } + + return { + $$type: 'ais.experience', + $$widgetType: 'ais.experience', + $$widgetParams: widgetParams, + $$supportedWidgets: { + 'ais.autocomplete': { + widget: EXPERIMENTAL_autocomplete, + transformParams(params, { env, instantSearchInstance }) { + const { agentId, indices, querySuggestionIndexName, ...rest } = + params as typeof params & { + indices: Array<{ + indexName: string; + itemTemplate: TemplateChild[]; + }>; + querySuggestionIndexName?: string; + }; + return Promise.resolve({ + agent: createAgentConfig( + instantSearchInstance, + env, + agentId as string + ), + indices: indices.map((index) => ({ + indexName: index.indexName, + templates: { + item: ({ item }, itemParams) => + renderTemplate(index.itemTemplate)(item, itemParams), + }, + })), + showSuggestions: querySuggestionIndexName + ? { indexName: querySuggestionIndexName } + : undefined, + ...rest, + }); + }, + }, + 'ais.chat': { + widget: chat, + // eslint-disable-next-line no-restricted-syntax + async transformParams(params, { env, instantSearchInstance }) { + const { + itemTemplate, + agentId, + toolRenderings = {}, + ...rest + } = params as typeof params & { + toolRenderings: { [key: string]: string }; + }; + + const [appId, apiKey] = getAppIdAndApiKey( + instantSearchInstance.client + ) as [string, string]; + const tools = ( + await Promise.all( + Object.entries(toolRenderings).map(([toolName, experienceId]) => { + return buildExperienceRequest({ + appId, + apiKey, + env, + experienceId, + }).then((toolExperience) => + renderTool({ name: toolName, experience: toolExperience }) + ); + }) + ) + ).reduce((acc, tool) => ({ ...acc, ...tool }), {}); + + return Promise.resolve({ + ...createAgentConfig(instantSearchInstance, env, agentId as string), + ...rest, + templates: { + ...(rest.templates as Record), + ...(itemTemplate + ? { item: renderTemplate(itemTemplate as TemplateChild[]) } + : {}), + }, + tools, + }); + }, + }, + }, + render: () => {}, + dispose: () => {}, + } satisfies ExperienceWidget; +}); + +function createAgentConfig( + instantSearchInstance: InstantSearch, + env: 'beta' | 'prod', + agentId: string +): ChatTransport { + if (env === 'prod') { + return { agentId }; + } + + const [appId, apiKey] = getAppIdAndApiKey(instantSearchInstance.client); + + return { + transport: { + api: `https://agent-studio-staging.eu.algolia.com/1/agents/${agentId}/completions?compatibilityMode=ai-sdk-5`, + headers: { + 'x-algolia-application-id': appId!, + 'x-algolia-api-key': apiKey!, + }, + }, + }; +} diff --git a/packages/instantsearch.js/src/widgets/experience/experience.umd.tsx b/packages/instantsearch.js/src/widgets/experience/experience.umd.tsx new file mode 100644 index 00000000000..37b96e19495 --- /dev/null +++ b/packages/instantsearch.js/src/widgets/experience/experience.umd.tsx @@ -0,0 +1,33 @@ +import { createDocumentationMessageGenerator } from '../../lib/utils'; + +import { ExperienceWidget } from './types'; + +import type { ExperienceWidgetParams } from './types'; + +const withUsage = createDocumentationMessageGenerator({ name: 'experience' }); + +export default (function experience(widgetParams: ExperienceWidgetParams) { + const { id } = widgetParams || {}; + + if (!id) { + throw new Error(withUsage('The `id` option is required.')); + } + + return { + $$type: 'ais.experience', + $$widgetType: 'ais.experience', + $$widgetParams: widgetParams, + $$supportedWidgets: { + 'ais.chat': () => { + throw new Error( + `"chat" is not available from the UMD build. + +Please use InstantSearch.js with a packaging system: +https://www.algolia.com/doc/guides/building-search-ui/installation/js/#with-a-packaging-system` + ); + }, + }, + render: () => {}, + dispose: () => {}, + } satisfies ExperienceWidget; +}); diff --git a/packages/instantsearch.js/src/widgets/experience/render.tsx b/packages/instantsearch.js/src/widgets/experience/render.tsx new file mode 100644 index 00000000000..841e6a9bb9c --- /dev/null +++ b/packages/instantsearch.js/src/widgets/experience/render.tsx @@ -0,0 +1,211 @@ +/** @jsx h */ +import { h, Fragment } from 'preact'; + +import { getPropertyByPath } from '../../lib/utils'; +import { Tool } from '../chat/chat'; + +import type { BaseHit, TemplateParams } from '../../types'; +import type { ExperienceApiResponse } from './types'; +import type { ComponentChildren } from 'preact'; + +type StaticString = { type: 'string'; value: string }; +type Attribute = { type: 'attribute'; path: string[] }; +type Highlight = { type: 'highlight' | 'snippet'; path: string[] }; +export type TemplateText = Array; +export type TemplateAttribute = Array; +type RegularParameters = { + class?: TemplateAttribute; +}; +export type TemplateChild = + | { + type: 'p' | 'paragraph' | 'span' | 'h2'; + parameters: { + text: TemplateText; + } & RegularParameters; + } + | { + type: 'div' | 'svg' | 'path' | 'circle' | 'line' | 'polyline'; + parameters: RegularParameters; + children: TemplateChild[]; + } + | { + type: 'image'; + parameters: { + src: TemplateAttribute; + alt: TemplateAttribute; + } & RegularParameters; + } + | { + type: 'link'; + parameters: { + href: TemplateAttribute; + } & RegularParameters; + children: TemplateChild[]; + }; + +const tagNames = new Map( + Object.entries({ + paragraph: 'p', + p: 'p', + span: 'span', + h2: 'h2', + div: 'div', + link: 'a', + image: 'img', + svg: 'svg', + path: 'path', + circle: 'circle', + line: 'line', + polyline: 'polyline', + }) +); + +function renderText(text: TemplateText[number], hit: any, components: any) { + if (text.type === 'string') { + return text.value; + } + + if (text.type === 'attribute') { + return getPropertyByPath(hit, text.path); + } + + if (text.type === 'highlight') { + return components.Highlight({ + hit, + attribute: text.path, + }); + } + + if (text.type === 'snippet') { + return components.Snippet({ + hit, + attribute: text.path, + }); + } + + // Custom 'tags' type for rendering arrays as tag elements + if ((text as unknown as { type: 'tags'; path: string[] }).type === 'tags') { + const value = getPropertyByPath(hit, (text as { path: string[] }).path); + + if (Array.isArray(value)) { + return ( +
+ {value.map((item, i) => ( + + {String(item)} + + ))} +
+ ); + } + + // If not an array, render as plain text + return String(value ?? ''); + } + + return null; +} + +function renderAttribute(text: TemplateAttribute[number], hit: any) { + if (text.type === 'string') { + return text.value; + } + + if (text.type === 'attribute') { + return getPropertyByPath(hit, text.path); + } + + return null; +} + +export function renderTemplate( + template: TemplateChild[] +): (hit: BaseHit, params?: TemplateParams) => any { + function renderChild(child: TemplateChild, hit: any, components: any) { + const Tag = tagNames.get(child.type) as keyof JSX.IntrinsicElements; + if (!Tag) { + return ; + } + + let children: ComponentChildren = null; + if ('text' in child.parameters) { + children = child.parameters.text.map((text) => + renderText(text, hit, components) + ); + } else if ('children' in child) { + children = child.children.map((grandChild) => + renderChild(grandChild, hit, components) + ); + } + + const attributes = Object.fromEntries( + Object.entries(child.parameters) + .filter( + (tuple): tuple is [string, TemplateAttribute] => tuple[0] !== 'text' + ) + .map(([key, value]) => [ + key, + value.map((item) => renderAttribute(item, hit)).join(''), + ]) + ); + + // @ts-ignore + return {children}; + } + + return (hit, params) => + template.map((child) => renderChild(child, hit, params?.components)); +} + +type RenderToolParams = { + name: string; + experience: ExperienceApiResponse; +}; + +export function renderTool({ name, experience }: RenderToolParams) { + const { template, webhook } = experience.blocks[0].parameters as unknown as { + template: TemplateChild[]; + webhook?: string; + }; + + return { + [name]: { + templates: { + layout: ({ message }) => { + return message.output ? ( +
+ {renderTemplate(template)(message.output as BaseHit)} +
+ ) : ( +
+ Loading… +
+ ); + }, + }, + onToolCall: ({ addToolResult, input }) => { + if (!webhook) { + addToolResult({ output: { success: true, data: input } }); + return; + } + + fetch(webhook, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + }) + .then((res) => res.json()) + .then((data) => + addToolResult({ + output: { + success: true, + ...data, + }, + }) + ); + }, + }, + } satisfies { [key: string]: Tool }; +} diff --git a/packages/instantsearch.js/src/widgets/experience/types.ts b/packages/instantsearch.js/src/widgets/experience/types.ts new file mode 100644 index 00000000000..43ded2d7e96 --- /dev/null +++ b/packages/instantsearch.js/src/widgets/experience/types.ts @@ -0,0 +1,46 @@ +import type { IndexWidget, InstantSearch, Widget } from '../../types'; +import type { AutocompleteWidget } from '../autocomplete/autocomplete'; +import type { ChatWidget } from '../chat/chat'; + +type ExperienceApiBlockParameters = { + container: string; + cssVariables: Record; +} & Record< + // eslint-disable-next-line @typescript-eslint/ban-types + 'container' | 'cssVariables' | (string & {}), + unknown +>; + +export type ExperienceApiResponse = { + blocks: Array<{ + type: string; + parameters: ExperienceApiBlockParameters; + }>; +}; + +export type ExperienceWidgetParams = { + id: string; +}; + +type SupportedWidget< + TWidgetParameters = unknown, + TApiParameters = ExperienceApiBlockParameters +> = { + widget: (...args: any[]) => Widget | Array; + transformParams: ( + params: TApiParameters, + options: { + env: 'beta' | 'prod'; + instantSearchInstance: InstantSearch; + } + ) => Promise; +}; + +export type ExperienceWidget = Widget & { + $$widgetParams: ExperienceWidgetParams; + $$supportedWidgets: { + 'ais.autocomplete': SupportedWidget[0]>; + 'ais.chat': SupportedWidget[0]>; + // eslint-disable-next-line @typescript-eslint/ban-types + } & Record<'ais.chat' | (string & {}), SupportedWidget>; +}; diff --git a/packages/instantsearch.js/src/widgets/index.ts b/packages/instantsearch.js/src/widgets/index.ts index eefc00690a0..c2b29ccb433 100644 --- a/packages/instantsearch.js/src/widgets/index.ts +++ b/packages/instantsearch.js/src/widgets/index.ts @@ -60,4 +60,5 @@ 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 experience } from './experience/experience'; export { default as filterSuggestions } from './filter-suggestions/filter-suggestions'; diff --git a/packages/instantsearch.js/src/widgets/index.umd.ts b/packages/instantsearch.js/src/widgets/index.umd.ts index 8fe2b973990..7e6fa6a944b 100644 --- a/packages/instantsearch.js/src/widgets/index.umd.ts +++ b/packages/instantsearch.js/src/widgets/index.umd.ts @@ -68,4 +68,6 @@ Please use InstantSearch.js with a packaging system: https://www.algolia.com/doc/guides/building-search-ui/installation/js/#with-a-packaging-system` ); }; + +export { default as experience } from './experience/experience.umd'; export { default as filterSuggestions } from './filter-suggestions/filter-suggestions'; diff --git a/packages/react-instantsearch/src/widgets/Autocomplete.tsx b/packages/react-instantsearch/src/widgets/Autocomplete.tsx index a7d63503130..86c96c4190f 100644 --- a/packages/react-instantsearch/src/widgets/Autocomplete.tsx +++ b/packages/react-instantsearch/src/widgets/Autocomplete.tsx @@ -2,12 +2,14 @@ import { createAutocompleteComponent, createAutocompleteIndexComponent, createAutocompletePanelComponent, - createAutocompletePropGetters, createAutocompleteSuggestionComponent, createAutocompleteRecentSearchComponent, createAutocompleteStorage, cx, + createChatMessagesComponent, + SparklesIcon, } from 'instantsearch-ui-components'; +import { makeChatInstance } from 'instantsearch.js/es/widgets/chat/makeChat'; import React, { createElement, Fragment, @@ -22,11 +24,14 @@ import { Index, useAutocomplete, useInstantSearch, + useInstantSearchContext, useSearchBox, } from 'react-instantsearch-core'; import { AutocompleteSearch } from '../components/AutocompleteSearch'; +import { createDefaultTools } from './Chat'; +import { createAutocompletePropGetters } from './createAutocompletePropGetters'; import { ReverseHighlight } from './ReverseHighlight'; import type { PlainSearchParameters } from 'algoliasearch-helper'; @@ -36,8 +41,10 @@ import type { Pragma, AutocompleteClassNames, AutocompleteIndexProps, + ChatStatus, } from 'instantsearch-ui-components'; import type { BaseHit, Hit, IndexUiState } from 'instantsearch.js'; +import type { ChatTransport } from 'instantsearch.js/es/connectors/chat/connectChat'; import type { TransformItemsIndicesConfig } from 'instantsearch.js/es/connectors/autocomplete/connectAutocomplete'; import type { ComponentProps } from 'react'; @@ -66,6 +73,11 @@ const AutocompleteRecentSearch = createAutocompleteRecentSearchComponent({ Fragment, }); +const ChatMessages = createChatMessagesComponent({ + createElement: createElement as Pragma, + Fragment, +}); + const usePropGetters = createAutocompletePropGetters({ useEffect, useId, @@ -96,6 +108,10 @@ type PanelElements = Partial< export type AutocompleteProps = ComponentProps<'div'> & { indices?: Array>; + + agent?: ChatTransport; + display?: 'inline' | 'dialog'; + showSuggestions?: Partial< Pick< IndexConfig<{ query: string }>, @@ -302,6 +318,8 @@ function InnerAutocomplete({ indexUiState, isSearchPage, panelComponent: PanelComponent, + agent, + display, showRecent, recentSearchConfig, showSuggestions, @@ -309,6 +327,7 @@ function InnerAutocomplete({ placeholder, ...props }: InnerAutocompleteProps) { + const instantSearchInstance = useInstantSearchContext(); const { indices, refine: refineAutocomplete, @@ -330,11 +349,96 @@ function InnerAutocomplete({ suggestionsIndexName: showSuggestions?.indexName, }); + const inputRef = useRef(null); + const [showUi, setShowUi] = useState(false); + const [showConversation, setShowConversation] = useState(false); + const [agentMessages, setAgentMessages] = useState([]); + const [agentStatus, setAgentStatus] = useState('ready'); + + // @ts-ignore + const agentTools = createDefaultTools(({ item }) =>
{item.name}
); + const disableTools = true; // Temporarily disabling tools + const chatInstance = useMemo(() => { + if (!agent) { + return undefined; + } + + const instance = makeChatInstance( + instantSearchInstance, + agent, + disableTools ? undefined : agentTools + ); + instance.messages = []; // Temporarily clearing history on load + instance['~registerMessagesCallback'](() => + setAgentMessages(instance.messages) + ); + instance['~registerStatusCallback'](() => setAgentStatus(instance.status)); + + return instance; + }, [agent, instantSearchInstance, agentTools, disableTools]); + + useEffect(() => { + document.body.classList.toggle('ais-AutocompleteDialog--active', showUi); + if (showUi) { + setTimeout(() => inputRef.current?.focus(), 0); + } + + return () => { + document.body.classList.remove('ais-AutocompleteDialog--active'); + }; + }, [showUi]); + + const indicesWithAgent = ( + _indices: Parameters[0]['indices'] + ) => { + if (!agent) { + return _indices; + } + + return [ + { + indexName: 'ais-autocomplete-agent', + indexId: 'ais-autocomplete-agent', + hits: currentRefinement ? [{ query: currentRefinement }] : [], + }, + ..._indices, + ]; + }; + + const indicesConfigWithAgent = ( + _indicesConfig: Array> + ) => { + if (!agent) { + return _indicesConfig; + } + + return [ + { + indexName: 'ais-autocomplete-agent', + // @ts-ignore + getQuery: (item) => item.query, + // @ts-ignore + onSelect: ({ query }) => { + chatInstance!.sendMessage({ text: query }); + inputRef.current!.select(); + setShowConversation(true); + }, + }, + ..._indicesConfig, + ]; + }; + const { getInputProps, getItemProps, getPanelProps, getRootProps } = usePropGetters({ - indices: indicesForPropGetters, - indicesConfig: indicesConfigForPropGetters, + indices: indicesWithAgent(indicesForPropGetters), + indicesConfig: indicesConfigWithAgent(indicesConfigForPropGetters), onRefine: (query) => { + if (agent && showConversation) { + chatInstance!.sendMessage({ text: query }); + inputRef.current!.select(); + return; + } + refineAutocomplete(query); refineSearchBox(query); storage.onAdd(query); @@ -357,10 +461,41 @@ function InnerAutocomplete({ setQuery(query); }), - placeholder, + placeholder: showConversation ? 'Ask another question…' : placeholder, }); const elements: PanelElements = {}; + if (agent) { + elements.agent = ( + ( +
+
+ {/* @ts-ignore */} + +
+ {item.query ? ( +
+ Ask Agent: {`"${item.query}"`} +
+ ) : ( +
Type something to ask a question…
+ )} +
+ )} + items={[ + { + objectID: 'ais-autocomplete-agent', + __indexName: 'ais-autocomplete-agent', + query: currentRefinement, + }, + ]} + getItemProps={getItemProps} + key="ais-autocomplete-agent" + /> + ); + } + if (showRecent && recentSearchConfig) { const RecentSearchItemComponent = recentSearchConfig.itemComponent; elements.recent = ( @@ -429,22 +564,97 @@ function InnerAutocomplete({ }); return ( - - { - refineSearchBox(''); - refineAutocomplete(''); - }} - /> - - {PanelComponent ? ( - + + + { + refineSearchBox(''); + refineAutocomplete(''); + }} + /> + {!showConversation ? ( + + {PanelComponent ? ( + + ) : ( + Object.keys(elements).map((elementId) => elements[elementId]) + )} + ) : ( - Object.keys(elements).map((elementId) => elements[elementId]) +
+
+ +
+
)} -
-
+ + + ); +} + +type AutocompleteDialogWrapperProps = { + display?: 'inline' | 'dialog'; + showUi: boolean; + setShowUi: (showUi: boolean) => void; + setShowConversation: (showConversation: boolean) => void; + placeholder?: string; + children: any; +}; + +function AutocompleteDialogWrapper({ + display, + showUi, + setShowUi, + setShowConversation, + placeholder, + children, +}: AutocompleteDialogWrapperProps) { + if (display !== 'dialog') { + return children; + } + + return ( +
+ + {showUi && ( +
{ + if (event.target === event.currentTarget) { + setShowUi(false); + setShowConversation(false); + } + }} + > +
{children}
+
+ )} +
); } diff --git a/packages/react-instantsearch/src/widgets/createAutocompletePropGetters.ts b/packages/react-instantsearch/src/widgets/createAutocompletePropGetters.ts new file mode 100644 index 00000000000..fb9b20ac89f --- /dev/null +++ b/packages/react-instantsearch/src/widgets/createAutocompletePropGetters.ts @@ -0,0 +1,306 @@ +import type { ComponentProps, MutableRef } from 'instantsearch-ui-components'; + +type BaseHit = Record; + +export type AutocompleteIndexConfig = { + indexName: string; + getQuery?: (item: TItem) => string; + getURL?: (item: TItem) => string; + onSelect?: (params: { + item: TItem; + query: string; + setQuery: (query: string) => void; + url?: string; + }) => void; +}; + +type GetInputProps = () => ComponentProps<'input'>; + +type ValidAriaRole = 'combobox' | 'row' | 'grid'; + +type GetItemProps = ( + item: { __indexName: string } & Record, + index: number +) => { + id?: string; + role?: ValidAriaRole; + 'aria-selected'?: boolean; +} & { + onSelect: () => void; + onApply: () => void; +}; + +type GetPanelProps = () => { + id?: string; + hidden?: boolean; + role?: ValidAriaRole; + 'aria-labelledby'?: string; +}; + +type GetRootProps = () => { ref?: MutableRef }; + +type CreateAutocompletePropGettersParams = { + useEffect: (effect: () => void, inputs?: readonly unknown[]) => void; + useId: () => string; + useMemo: (factory: () => TType, inputs: readonly unknown[]) => TType; + useRef: (initialValue: TType | null) => { current: TType | null }; + useState: ( + initialState: TType + ) => [TType, (newState: TType) => unknown]; +}; + +export type UsePropGetters = (params: { + indices: Array<{ + indexName: string; + indexId: string; + hits: Array<{ [key: string]: unknown }>; + }>; + indicesConfig: Array>; + onRefine: (query: string) => void; + onSelect: NonNullable['onSelect']>; + onApply: (query: string) => void; + placeholder?: string; +}) => { + getInputProps: GetInputProps; + getItemProps: GetItemProps; + getPanelProps: GetPanelProps; + getRootProps: GetRootProps; +}; + +export function createAutocompletePropGetters({ + useEffect, + useId, + useMemo, + useRef, + useState, +}: CreateAutocompletePropGettersParams) { + return function usePropGetters({ + indices, + indicesConfig, + onRefine, + onSelect: globalOnSelect, + onApply, + placeholder, + }: Parameters>[0]): ReturnType> { + const getElementId = createGetElementId(useId()); + const inputRef = useRef(null); + const rootRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [activeDescendant, setActiveDescendant] = useState< + string | undefined + >(undefined); + + const { items, itemsIds } = useMemo( + () => buildItems({ indices, indicesConfig, getElementId }), + [indices, indicesConfig, getElementId] + ); + + useEffect(() => { + const onBodyClick = (event: MouseEvent) => { + if (unwrapRef(rootRef)?.contains(event.target as HTMLElement)) { + return; + } + + setIsOpen(false); + }; + + document.body.addEventListener('click', onBodyClick); + + return () => { + document.body.removeEventListener('click', onBodyClick); + }; + }, [rootRef]); + + const getNextActiveDescendant = (key: string): string | undefined => { + switch (key) { + case 'ArrowLeft': + case 'ArrowUp': { + const prevIndex = itemsIds.indexOf(activeDescendant || '') - 1; + return itemsIds[prevIndex] || itemsIds[itemsIds.length - 1]; + } + case 'ArrowRight': + case 'ArrowDown': { + const nextIndex = itemsIds.indexOf(activeDescendant || '') + 1; + return itemsIds[nextIndex] || itemsIds[0]; + } + default: + return undefined; + } + }; + + const submit = ( + override: { + query?: string; + activeDescendant?: string; + } = {} + ) => { + if (isOpen) { + setIsOpen(false); + } else { + inputRef.current?.blur(); + } + + const actualDescendant = override.activeDescendant ?? activeDescendant; + + if (!actualDescendant && override.query) { + onRefine(override.query); + } + + if (actualDescendant && items.has(actualDescendant)) { + const { + item, + config: { onSelect: indexOnSelect, getQuery, getURL }, + } = items.get(actualDescendant)!; + const actualOnSelect = indexOnSelect ?? globalOnSelect; + actualOnSelect({ + item, + query: getQuery?.(item) ?? '', + url: getURL?.(item), + setQuery: (query) => onRefine(query), + }); + setActiveDescendant(undefined); + } + }; + + return { + getInputProps: () => ({ + id: getElementId('input'), + ref: inputRef, + role: 'combobox', + 'aria-autocomplete': 'list', + 'aria-expanded': isOpen, + 'aria-haspopup': 'grid', + 'aria-controls': getElementId('panel'), + 'aria-activedescendant': activeDescendant, + placeholder, + onFocus: () => setIsOpen(true), + // @ts-ignore + onKeyDown: (event) => { + switch (event.key) { + case 'Escape': { + if (isOpen) { + setIsOpen(false); + event.preventDefault(); + } else { + setActiveDescendant(undefined); + } + break; + } + case 'ArrowLeft': + case 'ArrowUp': + case 'ArrowRight': + case 'ArrowDown': { + setIsOpen(true); + + const nextActiveDescendant = getNextActiveDescendant(event.key)!; + setActiveDescendant(nextActiveDescendant); + document + .getElementById(nextActiveDescendant) + ?.scrollIntoView(false); + + event.preventDefault(); + break; + } + case 'Enter': { + submit({ query: (event.target as HTMLInputElement).value }); + break; + } + case 'Tab': + setIsOpen(false); + break; + default: + setIsOpen(true); + return; + } + }, + // @ts-ignore + onKeyUp: (event: KeyboardEvent) => { + switch (event.key) { + case 'ArrowLeft': + case 'ArrowUp': + case 'ArrowRight': + case 'ArrowDown': + case 'Escape': + case 'Return': + event.preventDefault(); + return; + default: + setActiveDescendant(itemsIds[0] || undefined); + break; + } + }, + }), + getItemProps: (item, index) => { + const id = getElementId('item', item.__indexName, index); + + return { + id, + role: 'row', + 'aria-selected': id === activeDescendant, + onSelect: () => submit({ activeDescendant: id }), + onApply: () => { + const { + item: currentItem, + config: { getQuery }, + } = items.get(id)!; + onApply(getQuery?.(currentItem) ?? ''); + }, + }; + }, + getPanelProps: () => ({ + hidden: !isOpen, + id: getElementId('panel'), + role: 'grid', + 'aria-labelledby': getElementId('input'), + }), + getRootProps: () => ({ + ref: rootRef, + }), + }; + }; +} + +function buildItems({ + indices, + indicesConfig, + getElementId, +}: Pick>[0], 'indices' | 'indicesConfig'> & { + getElementId: ReturnType; +}) { + const itemsIds = []; + const items = new Map< + string, + { item: TItem; config: AutocompleteIndexConfig } + >(); + + for (let i = 0; i < indicesConfig.length; i++) { + const config = indicesConfig[i]; + const hits = indices[i]?.hits || []; + + for (let position = 0; position < hits.length; position++) { + const itemId = getElementId('item', config.indexName, position); + items.set(itemId, { + item: hits[position] as TItem, + config, + }); + itemsIds.push(itemId); + } + } + return { items, itemsIds }; +} + +function createGetElementId(autocompleteId: string) { + return function getElementId(...suffixes: Array) { + const prefix = 'autocomplete'; + return `${prefix}${autocompleteId}${suffixes.join(':')}`; + }; +} + +/** + * Returns the framework-agnostic value of a ref. + */ +function unwrapRef(ref: { current: TType | null }): TType | null { + return ref.current && typeof ref.current === 'object' && 'base' in ref.current + ? (ref.current.base as TType) // Preact + : ref.current; // React +}