From 466b1b66ba3cc1d5da693d9b8a14d71d5cc25d61 Mon Sep 17 00:00:00 2001 From: Sarah Dayan <5370675+sarahdayan@users.noreply.github.com> Date: Tue, 30 Dec 2025 18:08:16 +0100 Subject: [PATCH 01/20] feat: bootstrap managed widget mechanism --- examples/js/getting-started/src/app.js | 43 +------- .../instantsearch.js/src/lib/InstantSearch.ts | 100 ++++++++++++++++++ .../src/widgets/chat/chat.tsx | 1 + 3 files changed, 105 insertions(+), 39 deletions(-) diff --git a/examples/js/getting-started/src/app.js b/examples/js/getting-started/src/app.js index 61d747171db..c8368de78d2 100644 --- a/examples/js/getting-started/src/app.js +++ b/examples/js/getting-started/src/app.js @@ -15,8 +15,8 @@ import { import 'instantsearch.css/themes/satellite.css'; const searchClient = algoliasearch( - 'latency', - '6be0576ff61c053d5f9a3225e2a90f76' + 'F4T6CUV2AH', + '4ce25fa46f7de67117fc1b787742e0f3' ); const search = instantsearch({ @@ -47,46 +47,11 @@ search.addWidgets([ }), hits({ container: '#hits', - templates: { - item: (hit, { html, components }) => html` -
-

- ${components.Highlight({ hit, attribute: 'name' })} -

-

${components.Highlight({ hit, attribute: 'description' })}

- See product -
- `, - }, - }), - configure({ - hitsPerPage: 8, - }), - panel({ - templates: { header: 'brand' }, - })(refinementList)({ - container: '#brand-list', - attribute: 'brand', - }), - 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, - }, + agentId: 'toto', + experienceId: '87d8f696-dc75-4421-a44a-255693f6b310', }), ]); diff --git a/packages/instantsearch.js/src/lib/InstantSearch.ts b/packages/instantsearch.js/src/lib/InstantSearch.ts index 19baddd252a..cb2a0a1e5d1 100644 --- a/packages/instantsearch.js/src/lib/InstantSearch.ts +++ b/packages/instantsearch.js/src/lib/InstantSearch.ts @@ -20,6 +20,8 @@ import { warning, setIndexHelperState, isIndexWidget, + walkIndex, + getAppIdAndApiKey, } from './utils'; import version from './version'; @@ -42,6 +44,7 @@ import type { CompositionClient, } from '../types'; import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; +import { chat } from '../widgets'; const withUsage = createDocumentationMessageGenerator({ name: 'instantsearch', @@ -612,6 +615,60 @@ See documentation: ${createDocumentationLink({ return mainHelper; }; + const managedWidgets = []; + + walkIndex(this.mainIndex, (index) => { + const widgets = index.getWidgets(); + + widgets.forEach((widget) => { + if (widget.$$widgetParams?.experienceId) { + managedWidgets.push(widget); + } + }); + }); + + const [appId, apiKey] = getAppIdAndApiKey(this.client); + + const requests = managedWidgets.map((widget) => { + return buildExperienceRequest({ + appId, + apiKey, + endpoint: `experiences/${widget.$$widgetParams.experienceId}`, + }); + }); + + Promise.all(requests).then((r) => { + const chatParams = managedWidgets[0].$$widgetParams; + const { cssVars, ...fetchedParams } = + r[0].blocks[1].children[0].children[0].parameters; + + const cssVarsEntries = Object.entries(cssVars); + + if (cssVarsEntries.length > 0) { + injectStyleElement(` + :root { + ${cssVarsEntries + .map(([key, value]) => { + const { r, g, b } = hexToRgb(value); + + return `${key}: ${r}, ${g}, ${b}`; + }) + .join(';')} + } + `); + } + + managedWidgets[0].parent + .removeWidgets([managedWidgets[0]]) + .addWidgets([chat({ ...chatParams, ...fetchedParams })]); + + // walkIndex(this.mainIndex, (index) => { + // console.log(index.getWidgets()); + // }); + }); + + //return; + if (this._searchFunction) { // this client isn't used to actually search, but required for the helper // to not throw errors @@ -900,3 +957,46 @@ See documentation: ${createDocumentationLink({ } export default InstantSearch; + +function buildExperienceRequest({ + appId, + apiKey, + endpoint, + method = 'GET', + data, +}) { + return fetch(`https://experiences-beta.algolia.com/1/${endpoint}`, { + method, + headers: { + 'X-Algolia-Application-ID': appId, + 'X-Algolia-API-Key': apiKey, + }, + body: data ? JSON.stringify(data) : undefined, + }) + .then((res) => { + if (!res.ok) { + throw new Error(res.statusText); + } + + return res; + }) + .then((res) => res.json()); +} + +export function hexToRgb(hex: string) { + const cleanHex = hex.replace(/^#/, ''); + + const r = parseInt(cleanHex.substring(0, 2), 16); + const g = parseInt(cleanHex.substring(2, 4), 16); + const b = parseInt(cleanHex.substring(4, 6), 16); + + return { r, g, b }; +} + +export function injectStyleElement(textContent: string) { + const style = document.createElement('style'); + + style.textContent = textContent; + + document.head.appendChild(style); +} diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index b7d94fde2ce..751745bfa2f 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -1152,6 +1152,7 @@ export default (function chat< ...options, }), $$widgetType: 'ais.chat', + $$widgetParams: widgetParams, }; } satisfies ChatWidget); From 855b9a17bb0664546de04edb0bba90d410943220 Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Tue, 6 Jan 2026 14:41:17 +0100 Subject: [PATCH 02/20] move implementation to flag-activated middleware - supports user-provided env for api - name changes (still tbc): - `experienceId` -> `configId` - experiences -> managed ui - do not render widgets with `configId` --- examples/js/getting-started/src/app.js | 4 +- .../instantsearch.js/src/lib/InstantSearch.ts | 117 ++------------ .../middlewares/createManagedUiMiddleware.ts | 146 ++++++++++++++++++ .../instantsearch.js/src/middlewares/index.ts | 1 + packages/instantsearch.js/src/types/widget.ts | 1 + .../src/widgets/chat/chat.tsx | 14 ++ 6 files changed, 181 insertions(+), 102 deletions(-) create mode 100644 packages/instantsearch.js/src/middlewares/createManagedUiMiddleware.ts diff --git a/examples/js/getting-started/src/app.js b/examples/js/getting-started/src/app.js index c8368de78d2..f9d96bb8e0d 100644 --- a/examples/js/getting-started/src/app.js +++ b/examples/js/getting-started/src/app.js @@ -23,6 +23,7 @@ const search = instantsearch({ indexName: 'instant_search', searchClient, insights: true, + future: { managedUi: { env: 'beta' } }, }); const productItemTemplate = (item, { html }) => html` @@ -50,8 +51,7 @@ search.addWidgets([ }), chat({ container: '#chat', - agentId: 'toto', - experienceId: '87d8f696-dc75-4421-a44a-255693f6b310', + configId: '87d8f696-dc75-4421-a44a-255693f6b310', }), ]); diff --git a/packages/instantsearch.js/src/lib/InstantSearch.ts b/packages/instantsearch.js/src/lib/InstantSearch.ts index cb2a0a1e5d1..f35edecbb2f 100644 --- a/packages/instantsearch.js/src/lib/InstantSearch.ts +++ b/packages/instantsearch.js/src/lib/InstantSearch.ts @@ -2,6 +2,7 @@ import EventEmitter from '@algolia/events'; import algoliasearchHelper from 'algoliasearch-helper'; import { createInsightsMiddleware } from '../middlewares/createInsightsMiddleware'; +import { createManagedUiMiddleware } from '../middlewares/createManagedUiMiddleware'; import { createMetadataMiddleware, isMetadataEnabled, @@ -20,8 +21,6 @@ import { warning, setIndexHelperState, isIndexWidget, - walkIndex, - getAppIdAndApiKey, } from './utils'; import version from './version'; @@ -29,6 +28,7 @@ import type { InsightsEvent, InsightsProps, } from '../middlewares/createInsightsMiddleware'; +import type { ManagedUiProps } from '../middlewares/createManagedUiMiddleware'; import type { RouterProps } from '../middlewares/createRouterMiddleware'; import type { InsightsClient as AlgoliaInsightsClient, @@ -44,7 +44,6 @@ import type { CompositionClient, } from '../types'; import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; -import { chat } from '../widgets'; const withUsage = createDocumentationMessageGenerator({ name: 'instantsearch', @@ -194,6 +193,8 @@ export type InstantSearchOptions< */ // @MAJOR: Remove legacy behaviour here and in algoliasearch-helper persistHierarchicalRootCount?: boolean; + + managedUi?: boolean | ManagedUiProps; }; }; @@ -204,6 +205,7 @@ export const INSTANTSEARCH_FUTURE_DEFAULTS: Required< > = { preserveSharedStateOnUnmount: false, persistHierarchicalRootCount: false, + managedUi: false, }; /** @@ -615,60 +617,6 @@ See documentation: ${createDocumentationLink({ return mainHelper; }; - const managedWidgets = []; - - walkIndex(this.mainIndex, (index) => { - const widgets = index.getWidgets(); - - widgets.forEach((widget) => { - if (widget.$$widgetParams?.experienceId) { - managedWidgets.push(widget); - } - }); - }); - - const [appId, apiKey] = getAppIdAndApiKey(this.client); - - const requests = managedWidgets.map((widget) => { - return buildExperienceRequest({ - appId, - apiKey, - endpoint: `experiences/${widget.$$widgetParams.experienceId}`, - }); - }); - - Promise.all(requests).then((r) => { - const chatParams = managedWidgets[0].$$widgetParams; - const { cssVars, ...fetchedParams } = - r[0].blocks[1].children[0].children[0].parameters; - - const cssVarsEntries = Object.entries(cssVars); - - if (cssVarsEntries.length > 0) { - injectStyleElement(` - :root { - ${cssVarsEntries - .map(([key, value]) => { - const { r, g, b } = hexToRgb(value); - - return `${key}: ${r}, ${g}, ${b}`; - }) - .join(';')} - } - `); - } - - managedWidgets[0].parent - .removeWidgets([managedWidgets[0]]) - .addWidgets([chat({ ...chatParams, ...fetchedParams })]); - - // walkIndex(this.mainIndex, (index) => { - // console.log(index.getWidgets()); - // }); - }); - - //return; - if (this._searchFunction) { // this client isn't used to actually search, but required for the helper // to not throw errors @@ -788,6 +736,18 @@ See documentation: ${createDocumentationLink({ instance.started(); }); + // This is the automatic Managed Ui middleware, + // added when `future.managedUi` is set. + if (this.future.managedUi) { + this.use( + createManagedUiMiddleware( + typeof this.future.managedUi !== 'boolean' + ? this.future.managedUi + : {} + ) + ); + } + // 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. @@ -957,46 +917,3 @@ See documentation: ${createDocumentationLink({ } export default InstantSearch; - -function buildExperienceRequest({ - appId, - apiKey, - endpoint, - method = 'GET', - data, -}) { - return fetch(`https://experiences-beta.algolia.com/1/${endpoint}`, { - method, - headers: { - 'X-Algolia-Application-ID': appId, - 'X-Algolia-API-Key': apiKey, - }, - body: data ? JSON.stringify(data) : undefined, - }) - .then((res) => { - if (!res.ok) { - throw new Error(res.statusText); - } - - return res; - }) - .then((res) => res.json()); -} - -export function hexToRgb(hex: string) { - const cleanHex = hex.replace(/^#/, ''); - - const r = parseInt(cleanHex.substring(0, 2), 16); - const g = parseInt(cleanHex.substring(2, 4), 16); - const b = parseInt(cleanHex.substring(4, 6), 16); - - return { r, g, b }; -} - -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/createManagedUiMiddleware.ts b/packages/instantsearch.js/src/middlewares/createManagedUiMiddleware.ts new file mode 100644 index 00000000000..e9889b14e1a --- /dev/null +++ b/packages/instantsearch.js/src/middlewares/createManagedUiMiddleware.ts @@ -0,0 +1,146 @@ +import { getAppIdAndApiKey, walkIndex, warning } from '../lib/utils'; +import chat from '../widgets/chat/chat'; + +import type { InternalMiddleware, Widget } from '../types'; + +export type ManagedUiProps = { + env?: 'prod' | 'beta'; +}; + +const API_BASE = { + beta: 'https://experiences-beta.algolia.com/1', + prod: 'https://experiences.algolia.com/1', +}; + +// FIXME: Proper typing +const SUPPORTED_WIDGETS: Record Widget> = { + 'ais.chat': chat, +}; + +export function createManagedUiMiddleware( + props: ManagedUiProps = {} +): InternalMiddleware { + const { env = 'prod' } = props; + + return ({ instantSearchInstance }) => { + return { + $$type: 'ais.managedUi', + $$internal: true, + onStateChange: () => {}, + subscribe() { + const managedWidgets: Widget[] = []; + + walkIndex(instantSearchInstance.mainIndex, (index) => { + const widgets = index.getWidgets(); + + widgets.forEach((widget) => { + if (widget.$$widgetParams?.configId) { + managedWidgets.push(widget); + } + }); + }); + + const [appId, apiKey] = getAppIdAndApiKey(instantSearchInstance.client); + if (!(appId && apiKey)) { + warning( + false, + 'Could not retrieve credentials from the Algolia client.' + ); + return; + } + + // TODO: Provide final typed block structure + Promise.all( + managedWidgets.map((widget) => + buildExperienceRequest({ + appId, + apiKey, + env, + configId: widget.$$widgetParams.configId, + }) + ) + ).then((configs) => { + configs.forEach((config, index) => { + const widget = managedWidgets[index]; + const { configId, ...widgetParams } = widget.$$widgetParams; + const { cssVars, ...fetchedParams } = + config.blocks[1].children[0].children[0].parameters; + + const cssVarsEntries = Object.entries(cssVars); + if (cssVarsEntries.length > 0) { + injectStyleElement(` + :root { + ${cssVarsEntries + .map(([key, value]) => { + const { r, g, b } = hexToRgb(value); + + return `${key}: ${r}, ${g}, ${b}`; + }) + .join(';')} + } + `); + } + + const widgetParent = widget.parent!; + widgetParent.removeWidgets([widget]).addWidgets([ + SUPPORTED_WIDGETS[widget.$$type]({ + ...widgetParams, + ...fetchedParams, + }), + ]); + }); + }); + }, + started: () => {}, + unsubscribe: () => {}, + }; + }; +} + +type BuildExperienceRequestParams = { + appId: string; + apiKey: string; + env: NonNullable; + configId: string; +}; + +function buildExperienceRequest({ + appId, + apiKey, + env, + configId, +}: BuildExperienceRequestParams) { + return fetch(`${API_BASE[env]}/experiences/${configId}`, { + 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()); +} + +export function hexToRgb(hex: string) { + const cleanHex = hex.replace(/^#/, ''); + + const r = parseInt(cleanHex.substring(0, 2), 16); + const g = parseInt(cleanHex.substring(2, 4), 16); + const b = parseInt(cleanHex.substring(4, 6), 16); + + return { r, g, b }; +} + +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..d5756f9499a 100644 --- a/packages/instantsearch.js/src/middlewares/index.ts +++ b/packages/instantsearch.js/src/middlewares/index.ts @@ -1,3 +1,4 @@ export * from './createInsightsMiddleware'; export * from './createRouterMiddleware'; export * from './createMetadataMiddleware'; +export * from './createManagedUiMiddleware'; diff --git a/packages/instantsearch.js/src/types/widget.ts b/packages/instantsearch.js/src/types/widget.ts index 5904e24002b..1d1bb0a5103 100644 --- a/packages/instantsearch.js/src/types/widget.ts +++ b/packages/instantsearch.js/src/types/widget.ts @@ -145,6 +145,7 @@ export type WidgetParams = { export type WidgetDescription = { $$type: string; $$widgetType?: string; + $$widgetParams?: UnknownWidgetParams; renderState?: Record; indexRenderState?: Record; indexUiState?: Record; diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index 751745bfa2f..78db55ed405 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -1091,6 +1091,8 @@ type ChatWidgetParams = { * CSS classes to add. */ cssClasses?: ChatCSSClasses; + + configId?: string; }; export type ChatWidget = WidgetFactory< @@ -1110,6 +1112,7 @@ export default (function chat< >(widgetParams: ChatWidgetParams & ChatConnectorParams) { const { container, + configId, templates: userTemplates = {}, cssClasses = {}, resume = false, @@ -1122,6 +1125,17 @@ export default (function chat< throw new Error(withUsage('The `container` option is required.')); } + if (configId) { + // FIXME: does not satisfy ChatWidget + return { + $$type: 'ais.chat', + $$widgetType: 'ais.chat', + $$widgetParams: widgetParams, + render: () => {}, + dispose: () => {}, + }; + } + const containerNode = getContainerNode(container); const templates: ChatTemplates = { From a9ea9af512cf18d8b7d4b04e1ab945d4c9bf25c4 Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Wed, 7 Jan 2026 10:48:51 +0100 Subject: [PATCH 03/20] add dedicated experience widget --- .../instantsearch.js/src/lib/InstantSearch.ts | 16 +++--- ...eware.ts => createExperienceMiddleware.ts} | 57 ++++++++++--------- .../instantsearch.js/src/middlewares/index.ts | 2 +- .../src/widgets/chat/chat.tsx | 15 ----- .../src/widgets/experience/experience.tsx | 23 ++++++++ .../instantsearch.js/src/widgets/index.ts | 1 + .../instantsearch.js/src/widgets/index.umd.ts | 2 + 7 files changed, 64 insertions(+), 52 deletions(-) rename packages/instantsearch.js/src/middlewares/{createManagedUiMiddleware.ts => createExperienceMiddleware.ts} (71%) create mode 100644 packages/instantsearch.js/src/widgets/experience/experience.tsx diff --git a/packages/instantsearch.js/src/lib/InstantSearch.ts b/packages/instantsearch.js/src/lib/InstantSearch.ts index f35edecbb2f..7061e67be90 100644 --- a/packages/instantsearch.js/src/lib/InstantSearch.ts +++ b/packages/instantsearch.js/src/lib/InstantSearch.ts @@ -1,8 +1,8 @@ import EventEmitter from '@algolia/events'; import algoliasearchHelper from 'algoliasearch-helper'; +import { createExperienceMiddleware } from '../middlewares/createExperienceMiddleware'; import { createInsightsMiddleware } from '../middlewares/createInsightsMiddleware'; -import { createManagedUiMiddleware } from '../middlewares/createManagedUiMiddleware'; import { createMetadataMiddleware, isMetadataEnabled, @@ -24,11 +24,11 @@ import { } from './utils'; import version from './version'; +import type { ExperienceProps } from '../middlewares/createExperienceMiddleware'; import type { InsightsEvent, InsightsProps, } from '../middlewares/createInsightsMiddleware'; -import type { ManagedUiProps } from '../middlewares/createManagedUiMiddleware'; import type { RouterProps } from '../middlewares/createRouterMiddleware'; import type { InsightsClient as AlgoliaInsightsClient, @@ -194,7 +194,7 @@ export type InstantSearchOptions< // @MAJOR: Remove legacy behaviour here and in algoliasearch-helper persistHierarchicalRootCount?: boolean; - managedUi?: boolean | ManagedUiProps; + enableExperience?: boolean | ExperienceProps; }; }; @@ -205,7 +205,7 @@ export const INSTANTSEARCH_FUTURE_DEFAULTS: Required< > = { preserveSharedStateOnUnmount: false, persistHierarchicalRootCount: false, - managedUi: false, + enableExperience: false, }; /** @@ -738,11 +738,11 @@ See documentation: ${createDocumentationLink({ // This is the automatic Managed Ui middleware, // added when `future.managedUi` is set. - if (this.future.managedUi) { + if (this.future.enableExperience) { this.use( - createManagedUiMiddleware( - typeof this.future.managedUi !== 'boolean' - ? this.future.managedUi + createExperienceMiddleware( + typeof this.future.enableExperience !== 'boolean' + ? this.future.enableExperience : {} ) ); diff --git a/packages/instantsearch.js/src/middlewares/createManagedUiMiddleware.ts b/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts similarity index 71% rename from packages/instantsearch.js/src/middlewares/createManagedUiMiddleware.ts rename to packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts index e9889b14e1a..9aecb6586a3 100644 --- a/packages/instantsearch.js/src/middlewares/createManagedUiMiddleware.ts +++ b/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts @@ -3,7 +3,7 @@ import chat from '../widgets/chat/chat'; import type { InternalMiddleware, Widget } from '../types'; -export type ManagedUiProps = { +export type ExperienceProps = { env?: 'prod' | 'beta'; }; @@ -17,25 +17,26 @@ const SUPPORTED_WIDGETS: Record Widget> = { 'ais.chat': chat, }; -export function createManagedUiMiddleware( - props: ManagedUiProps = {} +export function createExperienceMiddleware( + props: ExperienceProps = {} ): InternalMiddleware { const { env = 'prod' } = props; return ({ instantSearchInstance }) => { return { - $$type: 'ais.managedUi', + $$type: 'ais.experience', $$internal: true, onStateChange: () => {}, subscribe() { - const managedWidgets: Widget[] = []; + const experienceWidgets: Widget[] = []; + // TODO: Recursion walkIndex(instantSearchInstance.mainIndex, (index) => { const widgets = index.getWidgets(); widgets.forEach((widget) => { - if (widget.$$widgetParams?.configId) { - managedWidgets.push(widget); + if (widget.$$type === 'ais.experience') { + experienceWidgets.push(widget); } }); }); @@ -51,35 +52,35 @@ export function createManagedUiMiddleware( // TODO: Provide final typed block structure Promise.all( - managedWidgets.map((widget) => + experienceWidgets.map((widget) => buildExperienceRequest({ appId, apiKey, env, - configId: widget.$$widgetParams.configId, + experienceId: widget.$$widgetParams.id, }) ) ).then((configs) => { configs.forEach((config, index) => { - const widget = managedWidgets[index]; + const widget = experienceWidgets[index]; const { configId, ...widgetParams } = widget.$$widgetParams; const { cssVars, ...fetchedParams } = config.blocks[1].children[0].children[0].parameters; - const cssVarsEntries = Object.entries(cssVars); - if (cssVarsEntries.length > 0) { - injectStyleElement(` - :root { - ${cssVarsEntries - .map(([key, value]) => { - const { r, g, b } = hexToRgb(value); - - return `${key}: ${r}, ${g}, ${b}`; - }) - .join(';')} - } - `); - } + // const cssVarsEntries = Object.entries(cssVars); + // if (cssVarsEntries.length > 0) { + // injectStyleElement(` + // :root { + // ${cssVarsEntries + // .map(([key, value]) => { + // const { r, g, b } = hexToRgb(value); + + // return `${key}: ${r}, ${g}, ${b}`; + // }) + // .join(';')} + // } + // `); + // } const widgetParent = widget.parent!; widgetParent.removeWidgets([widget]).addWidgets([ @@ -100,17 +101,17 @@ export function createManagedUiMiddleware( type BuildExperienceRequestParams = { appId: string; apiKey: string; - env: NonNullable; - configId: string; + env: NonNullable; + experienceId: string; }; function buildExperienceRequest({ appId, apiKey, env, - configId, + experienceId, }: BuildExperienceRequestParams) { - return fetch(`${API_BASE[env]}/experiences/${configId}`, { + return fetch(`${API_BASE[env]}/experiences/${experienceId}`, { method: 'GET', headers: { 'X-Algolia-Application-ID': appId, diff --git a/packages/instantsearch.js/src/middlewares/index.ts b/packages/instantsearch.js/src/middlewares/index.ts index d5756f9499a..62a2948b24b 100644 --- a/packages/instantsearch.js/src/middlewares/index.ts +++ b/packages/instantsearch.js/src/middlewares/index.ts @@ -1,4 +1,4 @@ +export * from './createExperienceMiddleware'; export * from './createInsightsMiddleware'; export * from './createRouterMiddleware'; export * from './createMetadataMiddleware'; -export * from './createManagedUiMiddleware'; diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index 78db55ed405..b7d94fde2ce 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -1091,8 +1091,6 @@ type ChatWidgetParams = { * CSS classes to add. */ cssClasses?: ChatCSSClasses; - - configId?: string; }; export type ChatWidget = WidgetFactory< @@ -1112,7 +1110,6 @@ export default (function chat< >(widgetParams: ChatWidgetParams & ChatConnectorParams) { const { container, - configId, templates: userTemplates = {}, cssClasses = {}, resume = false, @@ -1125,17 +1122,6 @@ export default (function chat< throw new Error(withUsage('The `container` option is required.')); } - if (configId) { - // FIXME: does not satisfy ChatWidget - return { - $$type: 'ais.chat', - $$widgetType: 'ais.chat', - $$widgetParams: widgetParams, - render: () => {}, - dispose: () => {}, - }; - } - const containerNode = getContainerNode(container); const templates: ChatTemplates = { @@ -1166,7 +1152,6 @@ export default (function chat< ...options, }), $$widgetType: 'ais.chat', - $$widgetParams: widgetParams, }; } satisfies ChatWidget); 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..4200fc5c892 --- /dev/null +++ b/packages/instantsearch.js/src/widgets/experience/experience.tsx @@ -0,0 +1,23 @@ +import { createDocumentationMessageGenerator } from '../../lib/utils'; + +type ExperienceWidgetParams = { + id: string; +}; + +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, + render: () => {}, + dispose: () => {}, + }; +}); diff --git a/packages/instantsearch.js/src/widgets/index.ts b/packages/instantsearch.js/src/widgets/index.ts index 35366b160f0..e8eb47758e6 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 experience } from './experience/experience'; diff --git a/packages/instantsearch.js/src/widgets/index.umd.ts b/packages/instantsearch.js/src/widgets/index.umd.ts index 9907428eff1..4109692ffb1 100644 --- a/packages/instantsearch.js/src/widgets/index.umd.ts +++ b/packages/instantsearch.js/src/widgets/index.umd.ts @@ -68,3 +68,5 @@ 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'; From 205bfd5117d5d1eb8f02fcddefdc4d07735d478f Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:34:31 +0100 Subject: [PATCH 04/20] handle issues with umd --- examples/js/getting-started/src/app.js | 12 ++-- .../middlewares/createExperienceMiddleware.ts | 67 +++++++++---------- .../src/widgets/experience/experience.tsx | 12 ++-- .../src/widgets/experience/experience.umd.tsx | 33 +++++++++ .../src/widgets/experience/types.ts | 10 +++ .../instantsearch.js/src/widgets/index.umd.ts | 2 +- 6 files changed, 93 insertions(+), 43 deletions(-) create mode 100644 packages/instantsearch.js/src/widgets/experience/experience.umd.tsx create mode 100644 packages/instantsearch.js/src/widgets/experience/types.ts diff --git a/examples/js/getting-started/src/app.js b/examples/js/getting-started/src/app.js index f9d96bb8e0d..ccfc1ef191f 100644 --- a/examples/js/getting-started/src/app.js +++ b/examples/js/getting-started/src/app.js @@ -10,6 +10,7 @@ import { searchBox, trendingItems, chat, + experience, } from 'instantsearch.js/es/widgets'; import 'instantsearch.css/themes/satellite.css'; @@ -23,7 +24,7 @@ const search = instantsearch({ indexName: 'instant_search', searchClient, insights: true, - future: { managedUi: { env: 'beta' } }, + future: { enableExperience: { env: 'beta' } }, }); const productItemTemplate = (item, { html }) => html` @@ -49,9 +50,12 @@ search.addWidgets([ hits({ container: '#hits', }), - chat({ - container: '#chat', - configId: '87d8f696-dc75-4421-a44a-255693f6b310', + // chat({ + // container: '#chat', + // configId: '87d8f696-dc75-4421-a44a-255693f6b310', + // }), + experience({ + id: '87d8f696-dc75-4421-a44a-255693f6b310', }), ]); diff --git a/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts b/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts index 9aecb6586a3..43032dba3c1 100644 --- a/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts +++ b/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts @@ -1,7 +1,7 @@ import { getAppIdAndApiKey, walkIndex, warning } from '../lib/utils'; -import chat from '../widgets/chat/chat'; -import type { InternalMiddleware, Widget } from '../types'; +import type { InternalMiddleware } from '../types'; +import type { ExperienceWidget } from '../widgets/experience/types'; export type ExperienceProps = { env?: 'prod' | 'beta'; @@ -12,11 +12,6 @@ const API_BASE = { prod: 'https://experiences.algolia.com/1', }; -// FIXME: Proper typing -const SUPPORTED_WIDGETS: Record Widget> = { - 'ais.chat': chat, -}; - export function createExperienceMiddleware( props: ExperienceProps = {} ): InternalMiddleware { @@ -28,7 +23,7 @@ export function createExperienceMiddleware( $$internal: true, onStateChange: () => {}, subscribe() { - const experienceWidgets: Widget[] = []; + const experienceWidgets: ExperienceWidget[] = []; // TODO: Recursion walkIndex(instantSearchInstance.mainIndex, (index) => { @@ -36,7 +31,7 @@ export function createExperienceMiddleware( widgets.forEach((widget) => { if (widget.$$type === 'ais.experience') { - experienceWidgets.push(widget); + experienceWidgets.push(widget as ExperienceWidget); } }); }); @@ -50,7 +45,6 @@ export function createExperienceMiddleware( return; } - // TODO: Provide final typed block structure Promise.all( experienceWidgets.map((widget) => buildExperienceRequest({ @@ -63,32 +57,37 @@ export function createExperienceMiddleware( ).then((configs) => { configs.forEach((config, index) => { const widget = experienceWidgets[index]; - const { configId, ...widgetParams } = widget.$$widgetParams; - const { cssVars, ...fetchedParams } = - config.blocks[1].children[0].children[0].parameters; - - // const cssVarsEntries = Object.entries(cssVars); - // if (cssVarsEntries.length > 0) { - // injectStyleElement(` - // :root { - // ${cssVarsEntries - // .map(([key, value]) => { - // const { r, g, b } = hexToRgb(value); - - // return `${key}: ${r}, ${g}, ${b}`; - // }) - // .join(';')} - // } - // `); - // } + // TODO: Handle multiple config blocks for a single experience id + const { type, parameters } = + config.blocks[1].children[0].children[0]; + const { cssVars, ...fetchedParams } = parameters; + + const cssVarsKeys = Object.keys(cssVars); + if (cssVarsKeys.length > 0) { + injectStyleElement(` + :root { + ${cssVarsKeys + .map((key) => { + const { r, g, b } = hexToRgb(cssVars[key]); + + return `${key}: ${r}, ${g}, ${b}`; + }) + .join(';')} + } + `); + } const widgetParent = widget.parent!; - widgetParent.removeWidgets([widget]).addWidgets([ - SUPPORTED_WIDGETS[widget.$$type]({ - ...widgetParams, - ...fetchedParams, - }), - ]); + const newWidget = widget.$$supportedWidgets[type]; + widgetParent.removeWidgets([widget]); + if (newWidget) { + widgetParent.addWidgets([ + newWidget({ + ...fetchedParams, + container: '#chat', // TODO: Get from API + }), + ]); + } }); }); }, diff --git a/packages/instantsearch.js/src/widgets/experience/experience.tsx b/packages/instantsearch.js/src/widgets/experience/experience.tsx index 4200fc5c892..2b5657f19a3 100644 --- a/packages/instantsearch.js/src/widgets/experience/experience.tsx +++ b/packages/instantsearch.js/src/widgets/experience/experience.tsx @@ -1,8 +1,9 @@ import { createDocumentationMessageGenerator } from '../../lib/utils'; +import chat from '../chat/chat'; -type ExperienceWidgetParams = { - id: string; -}; +import { ExperienceWidget } from './types'; + +import type { ExperienceWidgetParams } from './types'; const withUsage = createDocumentationMessageGenerator({ name: 'experience' }); @@ -17,7 +18,10 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { $$type: 'ais.experience', $$widgetType: 'ais.experience', $$widgetParams: widgetParams, + $$supportedWidgets: { + 'ais.chat': chat, + }, render: () => {}, dispose: () => {}, - }; + } satisfies ExperienceWidget; }); 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/types.ts b/packages/instantsearch.js/src/widgets/experience/types.ts new file mode 100644 index 00000000000..03e65a06e9a --- /dev/null +++ b/packages/instantsearch.js/src/widgets/experience/types.ts @@ -0,0 +1,10 @@ +import type { Widget } from '../../types'; + +export type ExperienceWidgetParams = { + id: string; +}; + +export type ExperienceWidget = Widget & { + $$widgetParams: ExperienceWidgetParams; + $$supportedWidgets: Record Widget>; +}; diff --git a/packages/instantsearch.js/src/widgets/index.umd.ts b/packages/instantsearch.js/src/widgets/index.umd.ts index 4109692ffb1..c05435af398 100644 --- a/packages/instantsearch.js/src/widgets/index.umd.ts +++ b/packages/instantsearch.js/src/widgets/index.umd.ts @@ -69,4 +69,4 @@ https://www.algolia.com/doc/guides/building-search-ui/installation/js/#with-a-pa ); }; -export { default as experience } from './experience/experience'; +export { default as experience } from './experience/experience.umd'; From dd6ae5ecd273549a11955b0b4d6a5b3c3b2ce719 Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:44:17 +0100 Subject: [PATCH 05/20] remove extraneous type --- examples/js/getting-started/src/app.js | 48 +++++++------------ packages/instantsearch.js/src/types/widget.ts | 1 - 2 files changed, 17 insertions(+), 32 deletions(-) diff --git a/examples/js/getting-started/src/app.js b/examples/js/getting-started/src/app.js index ccfc1ef191f..0e59a8fc3ae 100644 --- a/examples/js/getting-started/src/app.js +++ b/examples/js/getting-started/src/app.js @@ -1,17 +1,7 @@ import { liteClient as algoliasearch } from 'algoliasearch/lite'; import instantsearch from 'instantsearch.js'; -import { carousel } from 'instantsearch.js/es/templates'; -import { - configure, - hits, - pagination, - panel, - refinementList, - searchBox, - trendingItems, - chat, - experience, -} from 'instantsearch.js/es/widgets'; +// import { carousel } from 'instantsearch.js/es/templates'; +import { hits, searchBox, experience } from 'instantsearch.js/es/widgets'; import 'instantsearch.css/themes/satellite.css'; @@ -27,21 +17,21 @@ const search = instantsearch({ future: { enableExperience: { env: 'beta' } }, }); -const productItemTemplate = (item, { html }) => html` - -`; +// const productItemTemplate = (item, { html }) => html` +// +// `; search.addWidgets([ searchBox({ @@ -50,10 +40,6 @@ search.addWidgets([ hits({ container: '#hits', }), - // chat({ - // container: '#chat', - // configId: '87d8f696-dc75-4421-a44a-255693f6b310', - // }), experience({ id: '87d8f696-dc75-4421-a44a-255693f6b310', }), diff --git a/packages/instantsearch.js/src/types/widget.ts b/packages/instantsearch.js/src/types/widget.ts index 1d1bb0a5103..5904e24002b 100644 --- a/packages/instantsearch.js/src/types/widget.ts +++ b/packages/instantsearch.js/src/types/widget.ts @@ -145,7 +145,6 @@ export type WidgetParams = { export type WidgetDescription = { $$type: string; $$widgetType?: string; - $$widgetParams?: UnknownWidgetParams; renderState?: Record; indexRenderState?: Record; indexUiState?: Record; From 6589f123bcd64b39bdf29ad90e3e50078a2fcc15 Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:47:10 +0100 Subject: [PATCH 06/20] clean up example --- examples/js/getting-started/src/app.js | 56 +++++++++++++++++--------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/examples/js/getting-started/src/app.js b/examples/js/getting-started/src/app.js index 0e59a8fc3ae..29dbb77f801 100644 --- a/examples/js/getting-started/src/app.js +++ b/examples/js/getting-started/src/app.js @@ -1,7 +1,14 @@ import { liteClient as algoliasearch } from 'algoliasearch/lite'; import instantsearch from 'instantsearch.js'; -// import { carousel } from 'instantsearch.js/es/templates'; -import { hits, searchBox, experience } from 'instantsearch.js/es/widgets'; +import { + hits, + searchBox, + experience, + configure, + panel, + refinementList, + pagination, +} from 'instantsearch.js/es/widgets'; import 'instantsearch.css/themes/satellite.css'; @@ -17,31 +24,40 @@ const search = instantsearch({ future: { enableExperience: { env: 'beta' } }, }); -// const productItemTemplate = (item, { html }) => html` -// -// `; - search.addWidgets([ + experience({ + id: '87d8f696-dc75-4421-a44a-255693f6b310', + }), searchBox({ container: '#searchbox', }), hits({ container: '#hits', + templates: { + item: (hit, { html, components }) => html` + + `, + }, }), - experience({ - id: '87d8f696-dc75-4421-a44a-255693f6b310', + configure({ + hitsPerPage: 8, + }), + panel({ + templates: { header: 'brand' }, + })(refinementList)({ + container: '#brand-list', + attribute: 'brand', + }), + pagination({ + container: '#pagination', }), ]); From 0e559ea19e5d1c25e68b3feb275c930851a5a139 Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:21:46 +0100 Subject: [PATCH 07/20] update experience payload structure --- examples/js/getting-started/src/app.js | 2 +- .../middlewares/createExperienceMiddleware.ts | 32 ++++--------------- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/examples/js/getting-started/src/app.js b/examples/js/getting-started/src/app.js index 29dbb77f801..d853d1e37d3 100644 --- a/examples/js/getting-started/src/app.js +++ b/examples/js/getting-started/src/app.js @@ -26,7 +26,7 @@ const search = instantsearch({ search.addWidgets([ experience({ - id: '87d8f696-dc75-4421-a44a-255693f6b310', + id: 'agent-ui-c37cac85-5291-47e7-aea3-5a8e704afa7b', }), searchBox({ container: '#searchbox', diff --git a/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts b/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts index 43032dba3c1..d34f8000050 100644 --- a/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts +++ b/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts @@ -58,19 +58,16 @@ export function createExperienceMiddleware( configs.forEach((config, index) => { const widget = experienceWidgets[index]; // TODO: Handle multiple config blocks for a single experience id - const { type, parameters } = - config.blocks[1].children[0].children[0]; - const { cssVars, ...fetchedParams } = parameters; + const { type, parameters } = config.blocks[0]; + const { cssVariables, ...fetchedParams } = parameters; - const cssVarsKeys = Object.keys(cssVars); - if (cssVarsKeys.length > 0) { + const cssVariablesKeys = Object.keys(cssVariables); + if (cssVariablesKeys.length > 0) { injectStyleElement(` :root { - ${cssVarsKeys + ${cssVariablesKeys .map((key) => { - const { r, g, b } = hexToRgb(cssVars[key]); - - return `${key}: ${r}, ${g}, ${b}`; + return `--ais-${key}: ${cssVariables[key]};`; }) .join(';')} } @@ -81,12 +78,7 @@ export function createExperienceMiddleware( const newWidget = widget.$$supportedWidgets[type]; widgetParent.removeWidgets([widget]); if (newWidget) { - widgetParent.addWidgets([ - newWidget({ - ...fetchedParams, - container: '#chat', // TODO: Get from API - }), - ]); + widgetParent.addWidgets([newWidget(fetchedParams)]); } }); }); @@ -127,16 +119,6 @@ function buildExperienceRequest({ .then((res) => res.json()); } -export function hexToRgb(hex: string) { - const cleanHex = hex.replace(/^#/, ''); - - const r = parseInt(cleanHex.substring(0, 2), 16); - const g = parseInt(cleanHex.substring(2, 4), 16); - const b = parseInt(cleanHex.substring(4, 6), 16); - - return { r, g, b }; -} - export function injectStyleElement(textContent: string) { const style = document.createElement('style'); From 0494e08b11640266b277810420ec31feedd9d8cf Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Wed, 7 Jan 2026 18:02:22 +0100 Subject: [PATCH 08/20] better types and support multiple widget blocks in an experience --- .../middlewares/createExperienceMiddleware.ts | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts b/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts index d34f8000050..da43e6873cd 100644 --- a/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts +++ b/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts @@ -7,6 +7,20 @@ export type ExperienceProps = { env?: 'prod' | 'beta'; }; +type ExperienceApiResponse = { + blocks: Array<{ + type: string; + parameters: { + container: string; + cssVariables: Record; + } & Record< + // eslint-disable-next-line @typescript-eslint/ban-types + 'container' | 'cssVariables' | (string & {}), + unknown + >; + }>; +}; + const API_BASE = { beta: 'https://experiences-beta.algolia.com/1', prod: 'https://experiences.algolia.com/1', @@ -24,8 +38,6 @@ export function createExperienceMiddleware( onStateChange: () => {}, subscribe() { const experienceWidgets: ExperienceWidget[] = []; - - // TODO: Recursion walkIndex(instantSearchInstance.mainIndex, (index) => { const widgets = index.getWidgets(); @@ -57,13 +69,17 @@ export function createExperienceMiddleware( ).then((configs) => { configs.forEach((config, index) => { const widget = experienceWidgets[index]; - // TODO: Handle multiple config blocks for a single experience id - const { type, parameters } = config.blocks[0]; - const { cssVariables, ...fetchedParams } = parameters; + const parent = widget.parent!; + + parent.removeWidgets([widget]); - const cssVariablesKeys = Object.keys(cssVariables); - if (cssVariablesKeys.length > 0) { - injectStyleElement(` + config.blocks.forEach((block) => { + const { type, parameters } = block; + const { cssVariables, ...fetchedParams } = parameters; + + const cssVariablesKeys = Object.keys(cssVariables); + if (cssVariablesKeys.length > 0) { + injectStyleElement(` :root { ${cssVariablesKeys .map((key) => { @@ -72,14 +88,16 @@ export function createExperienceMiddleware( .join(';')} } `); - } - - const widgetParent = widget.parent!; - const newWidget = widget.$$supportedWidgets[type]; - widgetParent.removeWidgets([widget]); - if (newWidget) { - widgetParent.addWidgets([newWidget(fetchedParams)]); - } + } + + const newWidget = widget.$$supportedWidgets[type]; + if ( + newWidget && + document.querySelector(fetchedParams.container) !== null + ) { + parent.addWidgets([newWidget(fetchedParams)]); + } + }); }); }); }, @@ -116,7 +134,7 @@ function buildExperienceRequest({ return res; }) - .then((res) => res.json()); + .then((res) => res.json() as Promise); } export function injectStyleElement(textContent: string) { From 4773c741a1f4663b550b90d36b19ecca7be6ab76 Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:23:56 +0100 Subject: [PATCH 09/20] render templates from experience --- examples/js/getting-started/src/app.js | 4 +- .../middlewares/createExperienceMiddleware.ts | 33 ++--- .../src/widgets/experience/experience.tsx | 39 +++++- .../src/widgets/experience/render.tsx | 132 ++++++++++++++++++ .../src/widgets/experience/types.ts | 42 +++++- 5 files changed, 223 insertions(+), 27 deletions(-) create mode 100644 packages/instantsearch.js/src/widgets/experience/render.tsx diff --git a/examples/js/getting-started/src/app.js b/examples/js/getting-started/src/app.js index d853d1e37d3..8515a98ae0b 100644 --- a/examples/js/getting-started/src/app.js +++ b/examples/js/getting-started/src/app.js @@ -18,7 +18,7 @@ const searchClient = algoliasearch( ); const search = instantsearch({ - indexName: 'instant_search', + indexName: 'spencer_and_williams', searchClient, insights: true, future: { enableExperience: { env: 'beta' } }, @@ -26,7 +26,7 @@ const search = instantsearch({ search.addWidgets([ experience({ - id: 'agent-ui-c37cac85-5291-47e7-aea3-5a8e704afa7b', + id: 'agent-ui-7354b616-d29e-4f47-b339-205f3c8f0222', }), searchBox({ container: '#searchbox', diff --git a/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts b/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts index da43e6873cd..149f7c7fe51 100644 --- a/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts +++ b/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts @@ -1,26 +1,15 @@ import { getAppIdAndApiKey, walkIndex, warning } from '../lib/utils'; import type { InternalMiddleware } from '../types'; -import type { ExperienceWidget } from '../widgets/experience/types'; +import type { + ExperienceApiResponse, + ExperienceWidget, +} from '../widgets/experience/types'; export type ExperienceProps = { env?: 'prod' | 'beta'; }; -type ExperienceApiResponse = { - blocks: Array<{ - type: string; - parameters: { - container: string; - cssVariables: Record; - } & Record< - // eslint-disable-next-line @typescript-eslint/ban-types - 'container' | 'cssVariables' | (string & {}), - unknown - >; - }>; -}; - const API_BASE = { beta: 'https://experiences-beta.algolia.com/1', prod: 'https://experiences.algolia.com/1', @@ -75,27 +64,29 @@ export function createExperienceMiddleware( config.blocks.forEach((block) => { const { type, parameters } = block; - const { cssVariables, ...fetchedParams } = parameters; - const cssVariablesKeys = Object.keys(cssVariables); + const cssVariablesKeys = Object.keys(parameters.cssVariables); if (cssVariablesKeys.length > 0) { injectStyleElement(` :root { ${cssVariablesKeys .map((key) => { - return `--ais-${key}: ${cssVariables[key]};`; + return `--ais-${key}: ${parameters.cssVariables[key]};`; }) .join(';')} } `); } - const newWidget = widget.$$supportedWidgets[type]; + const newWidget = widget.$$supportedWidgets[type].widget; + const transformedParams = widget.$$supportedWidgets[ + type + ].transformParams(parameters, { env, instantSearchInstance }); if ( newWidget && - document.querySelector(fetchedParams.container) !== null + document.querySelector(parameters.container) !== null ) { - parent.addWidgets([newWidget(fetchedParams)]); + parent.addWidgets([newWidget(transformedParams)]); } }); }); diff --git a/packages/instantsearch.js/src/widgets/experience/experience.tsx b/packages/instantsearch.js/src/widgets/experience/experience.tsx index 2b5657f19a3..a31b1298472 100644 --- a/packages/instantsearch.js/src/widgets/experience/experience.tsx +++ b/packages/instantsearch.js/src/widgets/experience/experience.tsx @@ -1,10 +1,17 @@ -import { createDocumentationMessageGenerator } from '../../lib/utils'; +import { + createDocumentationMessageGenerator, + getAppIdAndApiKey, +} from '../../lib/utils'; import chat from '../chat/chat'; +import { renderTemplate } from './render'; import { ExperienceWidget } from './types'; +import type { TemplateChild } from './render'; import type { ExperienceWidgetParams } from './types'; +import 'instantsearch.css/components/chat.css'; + const withUsage = createDocumentationMessageGenerator({ name: 'experience' }); export default (function experience(widgetParams: ExperienceWidgetParams) { @@ -19,7 +26,35 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { $$widgetType: 'ais.experience', $$widgetParams: widgetParams, $$supportedWidgets: { - 'ais.chat': chat, + 'ais.chat': { + widget: chat, + transformParams(params, { env, instantSearchInstance }) { + const { itemTemplate, agentId, ...rest } = params; + const [appId, apiKey] = getAppIdAndApiKey( + instantSearchInstance.client + ); + return { + ...(env === 'prod' + ? { agentId: agentId as string } + : { + 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!, + }, + }, + }), + ...rest, + templates: { + ...(rest.templates as Record), + ...(itemTemplate + ? { item: renderTemplate(itemTemplate as TemplateChild[]) } + : {}), + }, + }; + }, + }, }, render: () => {}, dispose: () => {}, 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..249a03b3ad4 --- /dev/null +++ b/packages/instantsearch.js/src/widgets/experience/render.tsx @@ -0,0 +1,132 @@ +/** @jsx h */ +import { h, Fragment } from 'preact'; + +import { getPropertyByPath } from '../../lib/utils'; + +import type { AlgoliaHit, TemplateParams } 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: 'paragraph' | 'span' | 'h2'; + parameters: { + text: TemplateText; + } & RegularParameters; + } + | { + type: 'div'; + 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', + span: 'span', + h2: 'h2', + div: 'div', + link: 'a', + image: 'img', + }) +); + +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 getPropertyByPath(hit, text.path); + // FIXME: Not working right now + // return components.Highlight({ + // hit, + // attribute: text.path, + // }); + } + + if (text.type === 'snippet') { + return components.Snippet({ + hit, + attribute: text.path, + }); + } + + 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: AlgoliaHit, 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, { components }) => + template.map((child) => renderChild(child, hit, components)); +} diff --git a/packages/instantsearch.js/src/widgets/experience/types.ts b/packages/instantsearch.js/src/widgets/experience/types.ts index 03e65a06e9a..e6f220da31c 100644 --- a/packages/instantsearch.js/src/widgets/experience/types.ts +++ b/packages/instantsearch.js/src/widgets/experience/types.ts @@ -1,10 +1,48 @@ -import type { Widget } from '../../types'; +import type { InstantSearch, Widget } from '../../types'; +import type { ChatWidget } from '../chat/chat'; +import type { TemplateChild } from './render'; + +export type ExperienceApiResponse = { + blocks: Array<{ + type: string; + parameters: { + container: string; + cssVariables: Record; + } & Record< + // eslint-disable-next-line @typescript-eslint/ban-types + 'container' | 'cssVariables' | (string & {}), + unknown + >; + }>; +}; export type ExperienceWidgetParams = { id: string; }; +type SupportedWidget< + TWidgetParameters = unknown, + TApiParameters = ExperienceApiResponse['blocks'][0]['parameters'] +> = { + widget: (...args: any[]) => Widget; + transformParams: ( + params: TApiParameters, + options: { + env: 'beta' | 'prod'; + instantSearchInstance: InstantSearch; + } + ) => TWidgetParameters; +}; + export type ExperienceWidget = Widget & { $$widgetParams: ExperienceWidgetParams; - $$supportedWidgets: Record Widget>; + $$supportedWidgets: { + 'ais.chat': SupportedWidget< + Parameters[0], + ExperienceApiResponse['blocks'][0]['parameters'] & { + itemTemplate?: TemplateChild[]; + } + >; + // eslint-disable-next-line @typescript-eslint/ban-types + } & Record<'ais.chat' | (string & {}), SupportedWidget>; }; From 89ff690e1c0efeea8659021c0eecfde0a45aad4c Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Wed, 14 Jan 2026 20:57:46 +0100 Subject: [PATCH 10/20] implement agent and dialog display in autocomplete --- .../createAutocompletePropGetters.ts | 2 +- .../src/components/autocomplete.scss | 83 +++++ .../src/widgets/autocomplete/autocomplete.tsx | 304 ++++++++++++++++-- .../src/widgets/chat/chat.tsx | 2 +- .../src/widgets/chat/makeChat.ts | 148 +++++++++ 5 files changed, 507 insertions(+), 32 deletions(-) create mode 100644 packages/instantsearch.js/src/widgets/chat/makeChat.ts diff --git a/packages/instantsearch-ui-components/src/components/autocomplete/createAutocompletePropGetters.ts b/packages/instantsearch-ui-components/src/components/autocomplete/createAutocompletePropGetters.ts index 4993bcfd43a..d9f797c35d4 100644 --- a/packages/instantsearch-ui-components/src/components/autocomplete/createAutocompletePropGetters.ts +++ b/packages/instantsearch-ui-components/src/components/autocomplete/createAutocompletePropGetters.ts @@ -223,7 +223,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..7ab84e183bb 100644 --- a/packages/instantsearch.css/src/components/autocomplete.scss +++ b/packages/instantsearch.css/src/components/autocomplete.scss @@ -231,6 +231,89 @@ } } +// 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 { + 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; diff --git a/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx b/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx index 315a724ccef..d7ae1b2f05a 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'; @@ -36,6 +40,8 @@ import type { AutocompleteRenderState, AutocompleteWidgetDescription, } 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, @@ -53,6 +59,7 @@ import type { AutocompleteIndexClassNames, AutocompleteIndexConfig, AutocompleteIndexProps, + ChatStatus, } from 'instantsearch-ui-components'; let autocompleteInstanceId = 0; @@ -89,6 +96,11 @@ const AutocompleteRecentSearch = createAutocompleteRecentSearchComponent({ Fragment, }); +const ChatMessages = createChatMessagesComponent({ + createElement: h, + Fragment, +}); + const usePropGetters = createAutocompletePropGetters({ useEffect, useId, @@ -120,10 +132,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> @@ -247,6 +265,8 @@ type AutocompleteWrapperProps = Pick< | 'cssClasses' | 'templates' | 'renderState' + | 'agent' + | 'display' | 'showRecent' | 'showSuggestions' | 'placeholder' @@ -263,6 +283,8 @@ function AutocompleteWrapper({ cssClasses, renderState, instantSearchInstance, + agent, + display, showRecent, showSuggestions, templates, @@ -321,11 +343,93 @@ 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`
${item.name}
`; + }, + }); + if (agent && !renderState.chatInstance) { + renderState.chatInstance = makeChatInstance( + instantSearchInstance, + agent, + agentTools + ); + renderState.chatInstance['~registerMessagesCallback'](() => { + setAgentMessages(renderState.chatInstance!.messages); + }); + renderState.chatInstance['~registerStatusCallback'](() => { + setAgentStatus(renderState.chatInstance!.status); + }); + } + + 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 }) => { + renderState.chatInstance!.sendMessage({ text: 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) { + renderState.chatInstance!.sendMessage({ text: query }); + inputRef.current!.select(); + return; + } + + onRefine(query); + }, onSelect: userOnSelect ?? (({ query, setQuery, url }) => { @@ -346,10 +450,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}
+
+ )} +
); } @@ -508,7 +743,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 = { @@ -517,6 +752,10 @@ type AutocompleteWidgetParams = { */ container: string | HTMLElement; + agent?: ChatTransport; + + display?: 'inline' | 'dialog'; + /** * Indices to use in the Autocomplete. */ @@ -595,6 +834,8 @@ export function EXPERIMENTAL_autocomplete( escapeHTML, indices = [], showSuggestions, + agent, + display = 'inline', showRecent, searchParameters: userSearchParameters, getSearchPageURL, @@ -682,6 +923,8 @@ export function EXPERIMENTAL_autocomplete( getSearchPageURL, onSelect, cssClasses, + agent, + display, showRecent: showRecentOptions, showSuggestions, placeholder, @@ -692,6 +935,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..fa6ab4cb0f5 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, 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..41662f99e31 --- /dev/null +++ b/packages/instantsearch.js/src/widgets/chat/makeChat.ts @@ -0,0 +1,148 @@ +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 { + // type AbstractChat, + // type ChatInit as ChatInitAi, + 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`.' + ); + } + + // if ('chat' in options) { + // return options.chat; + // } + + const _chatInstance: Chat = new Chat({ + ...options, + transport, + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, + onToolCall({ toolCall }) { + 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; +} From eaca4980969107608b52eb9734bd8369a543f276 Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Wed, 14 Jan 2026 20:58:37 +0100 Subject: [PATCH 11/20] support experience with agent-enabled autocomplete --- .../scripts/rollup/rollup.config.js | 4 ++ .../src/widgets/experience/experience.tsx | 55 ++++++++++++++----- .../src/widgets/experience/types.ts | 6 +- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/packages/instantsearch.js/scripts/rollup/rollup.config.js b/packages/instantsearch.js/scripts/rollup/rollup.config.js index c926d26f9ca..3f0b3c01b4b 100644 --- a/packages/instantsearch.js/scripts/rollup/rollup.config.js +++ b/packages/instantsearch.js/scripts/rollup/rollup.config.js @@ -29,6 +29,10 @@ const plugins = [ 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( diff --git a/packages/instantsearch.js/src/widgets/experience/experience.tsx b/packages/instantsearch.js/src/widgets/experience/experience.tsx index a31b1298472..eb5ca30e8d2 100644 --- a/packages/instantsearch.js/src/widgets/experience/experience.tsx +++ b/packages/instantsearch.js/src/widgets/experience/experience.tsx @@ -2,14 +2,18 @@ import { createDocumentationMessageGenerator, getAppIdAndApiKey, } from '../../lib/utils'; +import { EXPERIMENTAL_autocomplete } from '../autocomplete/autocomplete'; import chat from '../chat/chat'; import { renderTemplate } 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' }); @@ -26,25 +30,26 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { $$widgetType: 'ais.experience', $$widgetParams: widgetParams, $$supportedWidgets: { + 'ais.autocomplete': { + widget: EXPERIMENTAL_autocomplete, + transformParams(params, { env, instantSearchInstance }) { + const { agentId, ...rest } = params; + return { + agent: createAgentConfig( + instantSearchInstance, + env, + agentId as string + ), + ...rest, + }; + }, + }, 'ais.chat': { widget: chat, transformParams(params, { env, instantSearchInstance }) { const { itemTemplate, agentId, ...rest } = params; - const [appId, apiKey] = getAppIdAndApiKey( - instantSearchInstance.client - ); return { - ...(env === 'prod' - ? { agentId: agentId as string } - : { - 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!, - }, - }, - }), + ...createAgentConfig(instantSearchInstance, env, agentId as string), ...rest, templates: { ...(rest.templates as Record), @@ -60,3 +65,25 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { 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/types.ts b/packages/instantsearch.js/src/widgets/experience/types.ts index e6f220da31c..6209e92e929 100644 --- a/packages/instantsearch.js/src/widgets/experience/types.ts +++ b/packages/instantsearch.js/src/widgets/experience/types.ts @@ -1,4 +1,5 @@ -import type { InstantSearch, Widget } from '../../types'; +import type { IndexWidget, InstantSearch, Widget } from '../../types'; +import type { AutocompleteWidget } from '../autocomplete/autocomplete'; import type { ChatWidget } from '../chat/chat'; import type { TemplateChild } from './render'; @@ -24,7 +25,7 @@ type SupportedWidget< TWidgetParameters = unknown, TApiParameters = ExperienceApiResponse['blocks'][0]['parameters'] > = { - widget: (...args: any[]) => Widget; + widget: (...args: any[]) => Widget | Array; transformParams: ( params: TApiParameters, options: { @@ -37,6 +38,7 @@ type SupportedWidget< export type ExperienceWidget = Widget & { $$widgetParams: ExperienceWidgetParams; $$supportedWidgets: { + 'ais.autocomplete': SupportedWidget[0]>; 'ais.chat': SupportedWidget< Parameters[0], ExperienceApiResponse['blocks'][0]['parameters'] & { From f3c2c34d816cbb1c8e71cbff4b07537d0953e0bb Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:10:56 +0100 Subject: [PATCH 12/20] replicate rollup aliases for algolia-experiences --- packages/algolia-experiences/rollup.config.js | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) 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` From 40088a9bd17d6267e05b9a19bc5abde31b4e38d1 Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:06:06 +0100 Subject: [PATCH 13/20] port autocomplete additions to react --- .../src/widgets/Autocomplete.tsx | 238 ++++++++++++++++-- 1 file changed, 221 insertions(+), 17 deletions(-) diff --git a/packages/react-instantsearch/src/widgets/Autocomplete.tsx b/packages/react-instantsearch/src/widgets/Autocomplete.tsx index e41aa2fec46..ef2a679f05b 100644 --- a/packages/react-instantsearch/src/widgets/Autocomplete.tsx +++ b/packages/react-instantsearch/src/widgets/Autocomplete.tsx @@ -7,7 +7,10 @@ import { createAutocompleteRecentSearchComponent, createAutocompleteStorage, cx, + createChatMessagesComponent, + SparklesIcon, } from 'instantsearch-ui-components'; +import { makeChatInstance } from 'instantsearch.js/es/widgets/chat/makeChat'; import React, { createElement, Fragment, @@ -22,11 +25,13 @@ import { Index, useAutocomplete, useInstantSearch, + useInstantSearchContext, useSearchBox, } from 'react-instantsearch-core'; import { AutocompleteSearch } from '../components/AutocompleteSearch'; +import { createDefaultTools } from './Chat'; 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 { ComponentProps } from 'react'; const Autocomplete = createAutocompleteComponent({ @@ -65,6 +72,11 @@ const AutocompleteRecentSearch = createAutocompleteRecentSearchComponent({ Fragment, }); +const ChatMessages = createChatMessagesComponent({ + createElement: createElement as Pragma, + Fragment, +}); + const usePropGetters = createAutocompletePropGetters({ useEffect, useId, @@ -95,6 +107,10 @@ type PanelElements = Partial< export type AutocompleteProps = ComponentProps<'div'> & { indices?: Array>; + + agent?: ChatTransport; + display?: 'inline' | 'dialog'; + showSuggestions?: Partial< Pick< IndexConfig<{ query: string }>, @@ -298,12 +314,15 @@ function InnerAutocomplete({ indexUiState, isSearchPage, panelComponent: PanelComponent, + agent, + display, showRecent, recentSearchConfig, showSuggestions, placeholder, ...props }: InnerAutocompleteProps) { + const instantSearchInstance = useInstantSearchContext(); const { indices, refine: refineAutocomplete, @@ -322,11 +341,90 @@ function InnerAutocomplete({ indicesConfig, }); + 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 chatInstance = useMemo(() => { + if (!agent) { + return undefined; + } + + const instance = makeChatInstance(instantSearchInstance, agent, agentTools); + instance['~registerMessagesCallback'](() => + setAgentMessages(instance.messages) + ); + instance['~registerStatusCallback'](() => setAgentStatus(instance.status)); + + return instance; + }, [agent, instantSearchInstance, agentTools]); + + 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: 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); @@ -349,10 +447,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 = ( @@ -413,22 +542,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}
+
+ )} +
); } From acaa14018ff3876ac09ef56aaa9f112be74acd87 Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:13:20 +0100 Subject: [PATCH 14/20] rebuild From b50379ffce651e1da1b22db710a75b226eddf212 Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:45:19 +0100 Subject: [PATCH 15/20] additional autocomplete classes --- .../src/components/autocomplete.scss | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/instantsearch.css/src/components/autocomplete.scss b/packages/instantsearch.css/src/components/autocomplete.scss index 7ab84e183bb..3e32d3e32e4 100644 --- a/packages/instantsearch.css/src/components/autocomplete.scss +++ b/packages/instantsearch.css/src/components/autocomplete.scss @@ -430,6 +430,39 @@ 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-AutocompleteItemIcon { @@ -445,10 +478,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; From a2dc36253960c024317907d38665827904a4d7c8 Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Fri, 16 Jan 2026 10:55:50 +0100 Subject: [PATCH 16/20] misc fixes --- .../src/widgets/autocomplete/autocomplete.tsx | 40 +++++++++++-------- .../src/widgets/chat/chat.tsx | 1 + .../src/widgets/chat/makeChat.ts | 16 +++----- .../src/widgets/experience/experience.tsx | 14 ++++++- .../src/widgets/experience/render.tsx | 14 +++---- .../src/widgets/experience/types.ts | 28 ++++++------- .../src/widgets/Autocomplete.tsx | 2 +- 7 files changed, 62 insertions(+), 53 deletions(-) diff --git a/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx b/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx index d7ae1b2f05a..65c01c5dd1e 100644 --- a/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx +++ b/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx @@ -351,22 +351,28 @@ function AutocompleteWrapper({ const [agentStatus, setAgentStatus] = useState('ready'); const agentTools = createDefaultTools({ item: (item, { html }) => { - return html`
${item.name}
`; + return html`
${JSON.stringify(item)}
`; }, }); - if (agent && !renderState.chatInstance) { - renderState.chatInstance = makeChatInstance( - instantSearchInstance, - agent, - agentTools - ); - renderState.chatInstance['~registerMessagesCallback'](() => { - setAgentMessages(renderState.chatInstance!.messages); - }); - renderState.chatInstance['~registerStatusCallback'](() => { - setAgentStatus(renderState.chatInstance!.status); - }); - } + const disableTools = indices.some(({ indexName }) => indexName === 'faq'); + + const sendMessage = (message: string) => { + if (agent && !renderState.chatInstance) { + renderState.chatInstance = makeChatInstance( + instantSearchInstance, + agent, + disableTools ? undefined : agentTools + ); + 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); @@ -408,7 +414,7 @@ function AutocompleteWrapper({ indexName: 'ais-autocomplete-agent', getQuery: (item) => item.query, onSelect: ({ query }) => { - renderState.chatInstance!.sendMessage({ text: query }); + sendMessage(query); inputRef.current!.select(); setShowConversation(true); }, @@ -423,7 +429,7 @@ function AutocompleteWrapper({ indicesConfig: indicesConfigWithAgent(indicesConfigForPropGetters), onRefine: (query) => { if (agent && showConversation) { - renderState.chatInstance!.sendMessage({ text: query }); + sendMessage(query); inputRef.current!.select(); return; } @@ -645,7 +651,7 @@ function AutocompleteWrapper({ messages={agentMessages} status={agentStatus} hideScrollToBottom={true} - tools={agentToolsWithLayoutComponent} + tools={disableTools ? undefined : agentToolsWithLayoutComponent} translations={{ loaderText: 'Thinking…' }} /> diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index fa6ab4cb0f5..40a1045a9a0 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -273,6 +273,7 @@ export 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 index 41662f99e31..2ab7ee1b4c8 100644 --- a/packages/instantsearch.js/src/widgets/chat/makeChat.ts +++ b/packages/instantsearch.js/src/widgets/chat/makeChat.ts @@ -7,11 +7,7 @@ import { Chat } from '../../lib/chat/chat'; import { getAlgoliaAgent, getAppIdAndApiKey } from '../../lib/utils'; import type { ChatTransport } from '../../connectors/chat/connectChat'; -import type { - // type AbstractChat, - // type ChatInit as ChatInitAi, - UIMessage, -} from '../../lib/chat/chat'; +import type { UIMessage } from '../../lib/chat/chat'; import type { InstantSearch } from '../../types'; import type { AddToolResultWithOutput, @@ -21,7 +17,7 @@ import type { export function makeChatInstance( instantSearchInstance: InstantSearch, options: ChatTransport, - tools: Record> = {} + tools?: Record> ): Chat { let transport; const [appId, apiKey] = getAppIdAndApiKey(instantSearchInstance.client); @@ -101,15 +97,15 @@ export function makeChatInstance( ); } - // if ('chat' in options) { - // return options.chat; - // } - const _chatInstance: Chat = new Chat({ ...options, transport, sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, onToolCall({ toolCall }) { + if (!tools) { + return Promise.resolve(); + } + const tool = tools[toolCall.toolName]; if (!tool) { diff --git a/packages/instantsearch.js/src/widgets/experience/experience.tsx b/packages/instantsearch.js/src/widgets/experience/experience.tsx index eb5ca30e8d2..df4656ad62e 100644 --- a/packages/instantsearch.js/src/widgets/experience/experience.tsx +++ b/packages/instantsearch.js/src/widgets/experience/experience.tsx @@ -33,13 +33,25 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { 'ais.autocomplete': { widget: EXPERIMENTAL_autocomplete, transformParams(params, { env, instantSearchInstance }) { - const { agentId, ...rest } = params; + const { agentId, indices, ...rest } = params; return { agent: createAgentConfig( instantSearchInstance, env, agentId as string ), + indices: ( + indices as Array<{ + indexName: string; + itemTemplate: TemplateChild[]; + }> + ).map((index) => ({ + indexName: index.indexName, + templates: { + item: ({ item }, itemParams) => + renderTemplate(index.itemTemplate)(item, itemParams), + }, + })), ...rest, }; }, diff --git a/packages/instantsearch.js/src/widgets/experience/render.tsx b/packages/instantsearch.js/src/widgets/experience/render.tsx index 249a03b3ad4..c73a20050dc 100644 --- a/packages/instantsearch.js/src/widgets/experience/render.tsx +++ b/packages/instantsearch.js/src/widgets/experience/render.tsx @@ -3,7 +3,7 @@ import { h, Fragment } from 'preact'; import { getPropertyByPath } from '../../lib/utils'; -import type { AlgoliaHit, TemplateParams } from '../../types'; +import type { BaseHit, TemplateParams } from '../../types'; import type { ComponentChildren } from 'preact'; type StaticString = { type: 'string'; value: string }; @@ -62,12 +62,10 @@ function renderText(text: TemplateText[number], hit: any, components: any) { } if (text.type === 'highlight') { - return getPropertyByPath(hit, text.path); - // FIXME: Not working right now - // return components.Highlight({ - // hit, - // attribute: text.path, - // }); + return components.Highlight({ + hit, + attribute: text.path, + }); } if (text.type === 'snippet') { @@ -94,7 +92,7 @@ function renderAttribute(text: TemplateAttribute[number], hit: any) { export function renderTemplate( template: TemplateChild[] -): (hit: AlgoliaHit, params: TemplateParams) => any { +): (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) { diff --git a/packages/instantsearch.js/src/widgets/experience/types.ts b/packages/instantsearch.js/src/widgets/experience/types.ts index 6209e92e929..71141628ed4 100644 --- a/packages/instantsearch.js/src/widgets/experience/types.ts +++ b/packages/instantsearch.js/src/widgets/experience/types.ts @@ -1,19 +1,20 @@ import type { IndexWidget, InstantSearch, Widget } from '../../types'; import type { AutocompleteWidget } from '../autocomplete/autocomplete'; import type { ChatWidget } from '../chat/chat'; -import type { TemplateChild } from './render'; + +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: { - container: string; - cssVariables: Record; - } & Record< - // eslint-disable-next-line @typescript-eslint/ban-types - 'container' | 'cssVariables' | (string & {}), - unknown - >; + parameters: ExperienceApiBlockParameters; }>; }; @@ -23,7 +24,7 @@ export type ExperienceWidgetParams = { type SupportedWidget< TWidgetParameters = unknown, - TApiParameters = ExperienceApiResponse['blocks'][0]['parameters'] + TApiParameters = ExperienceApiBlockParameters > = { widget: (...args: any[]) => Widget | Array; transformParams: ( @@ -39,12 +40,7 @@ export type ExperienceWidget = Widget & { $$widgetParams: ExperienceWidgetParams; $$supportedWidgets: { 'ais.autocomplete': SupportedWidget[0]>; - 'ais.chat': SupportedWidget< - Parameters[0], - ExperienceApiResponse['blocks'][0]['parameters'] & { - itemTemplate?: TemplateChild[]; - } - >; + 'ais.chat': SupportedWidget[0]>; // eslint-disable-next-line @typescript-eslint/ban-types } & Record<'ais.chat' | (string & {}), SupportedWidget>; }; diff --git a/packages/react-instantsearch/src/widgets/Autocomplete.tsx b/packages/react-instantsearch/src/widgets/Autocomplete.tsx index ef2a679f05b..80b5b242108 100644 --- a/packages/react-instantsearch/src/widgets/Autocomplete.tsx +++ b/packages/react-instantsearch/src/widgets/Autocomplete.tsx @@ -366,7 +366,7 @@ function InnerAutocomplete({ useEffect(() => { document.body.classList.toggle('ais-AutocompleteDialog--active', showUi); if (showUi) { - inputRef.current?.focus(); + setTimeout(() => inputRef.current?.focus(), 0); } return () => { From 6db2a5d0acdb3f682f6403436dc69ecd4ed6ac26 Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Fri, 16 Jan 2026 11:27:24 +0100 Subject: [PATCH 17/20] add tag to autocomplete css --- .../instantsearch.css/src/components/autocomplete.scss | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/instantsearch.css/src/components/autocomplete.scss b/packages/instantsearch.css/src/components/autocomplete.scss index 3e32d3e32e4..9f2dd911e45 100644 --- a/packages/instantsearch.css/src/components/autocomplete.scss +++ b/packages/instantsearch.css/src/components/autocomplete.scss @@ -463,6 +463,16 @@ 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 { From c6df080703837679f3b972af4513af38ddbb3ec8 Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:45:40 +0100 Subject: [PATCH 18/20] handle tags and bring back propgetters for react preview --- .../src/components/autocomplete.scss | 1 + .../src/widgets/autocomplete/autocomplete.tsx | 3 +- .../src/widgets/experience/experience.tsx | 19 +- .../src/widgets/experience/render.tsx | 20 ++ .../src/widgets/Autocomplete.tsx | 14 +- .../widgets/createAutocompletePropGetters.ts | 306 ++++++++++++++++++ 6 files changed, 351 insertions(+), 12 deletions(-) create mode 100644 packages/react-instantsearch/src/widgets/createAutocompletePropGetters.ts diff --git a/packages/instantsearch.css/src/components/autocomplete.scss b/packages/instantsearch.css/src/components/autocomplete.scss index 9f2dd911e45..dcc6a356f81 100644 --- a/packages/instantsearch.css/src/components/autocomplete.scss +++ b/packages/instantsearch.css/src/components/autocomplete.scss @@ -287,6 +287,7 @@ } .ais-AutocompleteDialog-Content { + position: relative; margin: 100px auto auto; max-width: 800px; } diff --git a/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx b/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx index 65c01c5dd1e..6f4d476c7cf 100644 --- a/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx +++ b/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx @@ -354,7 +354,7 @@ function AutocompleteWrapper({ return html`
${JSON.stringify(item)}
`; }, }); - const disableTools = indices.some(({ indexName }) => indexName === 'faq'); + const disableTools = true; // Temporarily disabling tools const sendMessage = (message: string) => { if (agent && !renderState.chatInstance) { @@ -363,6 +363,7 @@ function AutocompleteWrapper({ agent, disableTools ? undefined : agentTools ); + renderState.chatInstance.messages = []; // Temporarily clearing history on load renderState.chatInstance['~registerMessagesCallback'](() => { setAgentMessages(renderState.chatInstance!.messages); }); diff --git a/packages/instantsearch.js/src/widgets/experience/experience.tsx b/packages/instantsearch.js/src/widgets/experience/experience.tsx index df4656ad62e..abd56fafc55 100644 --- a/packages/instantsearch.js/src/widgets/experience/experience.tsx +++ b/packages/instantsearch.js/src/widgets/experience/experience.tsx @@ -33,25 +33,30 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { 'ais.autocomplete': { widget: EXPERIMENTAL_autocomplete, transformParams(params, { env, instantSearchInstance }) { - const { agentId, indices, ...rest } = params; + const { agentId, indices, querySuggestionIndexName, ...rest } = + params as typeof params & { + indices: Array<{ + indexName: string; + itemTemplate: TemplateChild[]; + }>; + querySuggestionIndexName?: string; + }; return { agent: createAgentConfig( instantSearchInstance, env, agentId as string ), - indices: ( - indices as Array<{ - indexName: string; - itemTemplate: TemplateChild[]; - }> - ).map((index) => ({ + indices: indices.map((index) => ({ indexName: index.indexName, templates: { item: ({ item }, itemParams) => renderTemplate(index.itemTemplate)(item, itemParams), }, })), + showSuggestions: querySuggestionIndexName + ? { indexName: querySuggestionIndexName } + : undefined, ...rest, }; }, diff --git a/packages/instantsearch.js/src/widgets/experience/render.tsx b/packages/instantsearch.js/src/widgets/experience/render.tsx index c73a20050dc..d06db02e3fb 100644 --- a/packages/instantsearch.js/src/widgets/experience/render.tsx +++ b/packages/instantsearch.js/src/widgets/experience/render.tsx @@ -75,6 +75,26 @@ function renderText(text: TemplateText[number], hit: any, components: any) { }); } + // 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; } diff --git a/packages/react-instantsearch/src/widgets/Autocomplete.tsx b/packages/react-instantsearch/src/widgets/Autocomplete.tsx index 80b5b242108..b5af0536475 100644 --- a/packages/react-instantsearch/src/widgets/Autocomplete.tsx +++ b/packages/react-instantsearch/src/widgets/Autocomplete.tsx @@ -2,7 +2,6 @@ import { createAutocompleteComponent, createAutocompleteIndexComponent, createAutocompletePanelComponent, - createAutocompletePropGetters, createAutocompleteSuggestionComponent, createAutocompleteRecentSearchComponent, createAutocompleteStorage, @@ -32,6 +31,7 @@ import { import { AutocompleteSearch } from '../components/AutocompleteSearch'; import { createDefaultTools } from './Chat'; +import { createAutocompletePropGetters } from './createAutocompletePropGetters'; import { ReverseHighlight } from './ReverseHighlight'; import type { PlainSearchParameters } from 'algoliasearch-helper'; @@ -349,19 +349,25 @@ function InnerAutocomplete({ // @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, agentTools); + 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]); + }, [agent, instantSearchInstance, agentTools, disableTools]); useEffect(() => { document.body.classList.toggle('ais-AutocompleteDialog--active', showUi); @@ -574,7 +580,7 @@ function InnerAutocomplete({ status={agentStatus} hideScrollToBottom={true} // @ts-ignore - tools={agentTools} + tools={disableTools ? undefined : agentTools} translations={{ loaderText: 'Thinking…' }} /> 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 +} From d7793f499b7f76eaa81828c8d5d4fc892c34202c Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:26:46 +0100 Subject: [PATCH 19/20] fetch and render tool layouts from experience --- .../middlewares/createExperienceMiddleware.ts | 21 ++--- .../src/widgets/experience/experience.tsx | 43 +++++++-- .../src/widgets/experience/render.tsx | 88 +++++++++++++++++-- .../src/widgets/experience/types.ts | 2 +- 4 files changed, 131 insertions(+), 23 deletions(-) diff --git a/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts b/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts index 149f7c7fe51..4fa597dbd67 100644 --- a/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts +++ b/packages/instantsearch.js/src/middlewares/createExperienceMiddleware.ts @@ -79,15 +79,16 @@ export function createExperienceMiddleware( } const newWidget = widget.$$supportedWidgets[type].widget; - const transformedParams = widget.$$supportedWidgets[ - type - ].transformParams(parameters, { env, instantSearchInstance }); - if ( - newWidget && - document.querySelector(parameters.container) !== null - ) { - parent.addWidgets([newWidget(transformedParams)]); - } + widget.$$supportedWidgets[type] + .transformParams(parameters, { env, instantSearchInstance }) + .then((transformedParams) => { + if ( + newWidget && + document.querySelector(parameters.container) !== null + ) { + parent.addWidgets([newWidget(transformedParams)]); + } + }); }); }); }); @@ -105,7 +106,7 @@ type BuildExperienceRequestParams = { experienceId: string; }; -function buildExperienceRequest({ +export function buildExperienceRequest({ appId, apiKey, env, diff --git a/packages/instantsearch.js/src/widgets/experience/experience.tsx b/packages/instantsearch.js/src/widgets/experience/experience.tsx index abd56fafc55..3572abde8da 100644 --- a/packages/instantsearch.js/src/widgets/experience/experience.tsx +++ b/packages/instantsearch.js/src/widgets/experience/experience.tsx @@ -2,10 +2,11 @@ import { createDocumentationMessageGenerator, getAppIdAndApiKey, } from '../../lib/utils'; +import { buildExperienceRequest } from '../../middlewares/createExperienceMiddleware'; import { EXPERIMENTAL_autocomplete } from '../autocomplete/autocomplete'; import chat from '../chat/chat'; -import { renderTemplate } from './render'; +import { renderTemplate, renderTool } from './render'; import { ExperienceWidget } from './types'; import type { ChatTransport } from '../../connectors/chat/connectChat'; @@ -41,7 +42,7 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { }>; querySuggestionIndexName?: string; }; - return { + return Promise.resolve({ agent: createAgentConfig( instantSearchInstance, env, @@ -58,14 +59,41 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { ? { indexName: querySuggestionIndexName } : undefined, ...rest, - }; + }); }, }, 'ais.chat': { widget: chat, - transformParams(params, { env, instantSearchInstance }) { - const { itemTemplate, agentId, ...rest } = params; - return { + // 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: { @@ -74,7 +102,8 @@ export default (function experience(widgetParams: ExperienceWidgetParams) { ? { item: renderTemplate(itemTemplate as TemplateChild[]) } : {}), }, - }; + tools, + }); }, }, }, diff --git a/packages/instantsearch.js/src/widgets/experience/render.tsx b/packages/instantsearch.js/src/widgets/experience/render.tsx index d06db02e3fb..0508302db42 100644 --- a/packages/instantsearch.js/src/widgets/experience/render.tsx +++ b/packages/instantsearch.js/src/widgets/experience/render.tsx @@ -1,9 +1,11 @@ /** @jsx h */ import { h, Fragment } from 'preact'; +import { useState } from 'preact/hooks'; import { getPropertyByPath } from '../../lib/utils'; import type { BaseHit, TemplateParams } from '../../types'; +import type { ExperienceApiResponse } from './types'; import type { ComponentChildren } from 'preact'; type StaticString = { type: 'string'; value: string }; @@ -16,13 +18,13 @@ type RegularParameters = { }; export type TemplateChild = | { - type: 'paragraph' | 'span' | 'h2'; + type: 'p' | 'paragraph' | 'span' | 'h2'; parameters: { text: TemplateText; } & RegularParameters; } | { - type: 'div'; + type: 'div' | 'svg' | 'path' | 'circle' | 'line' | 'polyline'; parameters: RegularParameters; children: TemplateChild[]; } @@ -44,11 +46,17 @@ export type 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', }) ); @@ -112,7 +120,7 @@ function renderAttribute(text: TemplateAttribute[number], hit: any) { export function renderTemplate( template: TemplateChild[] -): (hit: BaseHit, params: TemplateParams) => any { +): (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) { @@ -145,6 +153,76 @@ export function renderTemplate( return {children}; } - return (hit, { components }) => - template.map((child) => renderChild(child, hit, components)); + 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 }) => ( + + ), + }, + }, + }; +} + +type RemoteToolRendererProps = { + template: TemplateChild[]; + input: Record; + webhook?: string; +}; + +function RemoteToolRenderer({ + template, + input, + webhook, +}: RemoteToolRendererProps) { + const [query, setQuery] = useState<{ + status: 'idle' | 'pending' | 'error' | 'success'; + data: typeof input | null; + }>({ status: 'idle', data: null }); + + if (!webhook) { + setQuery({ status: 'success', data: input }); + } + if (webhook && query.status === 'idle') { + setQuery({ status: 'pending', data: null }); + fetch(webhook, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + }) + .then((res) => res.json()) + .then((fetchedData) => { + setQuery({ status: 'success', data: fetchedData }); + }) + .catch((error) => { + setQuery({ status: 'error', data: error }); + }); + } + + if (query.status === 'success') { + return renderTemplate(template)(query.data as BaseHit); + } + + return
Loading…
; } diff --git a/packages/instantsearch.js/src/widgets/experience/types.ts b/packages/instantsearch.js/src/widgets/experience/types.ts index 71141628ed4..43ded2d7e96 100644 --- a/packages/instantsearch.js/src/widgets/experience/types.ts +++ b/packages/instantsearch.js/src/widgets/experience/types.ts @@ -33,7 +33,7 @@ type SupportedWidget< env: 'beta' | 'prod'; instantSearchInstance: InstantSearch; } - ) => TWidgetParameters; + ) => Promise; }; export type ExperienceWidget = Widget & { From 3eec46ae82336246a0757b902a89d1fc3a522c9f Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:20:16 +0100 Subject: [PATCH 20/20] fetch from ontoolcall to allow agent to continue conversation --- .../src/components/chat.scss | 48 ++++++++++ .../src/widgets/experience/render.tsx | 89 ++++++++----------- 2 files changed, 84 insertions(+), 53 deletions(-) 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/src/widgets/experience/render.tsx b/packages/instantsearch.js/src/widgets/experience/render.tsx index 0508302db42..841e6a9bb9c 100644 --- a/packages/instantsearch.js/src/widgets/experience/render.tsx +++ b/packages/instantsearch.js/src/widgets/experience/render.tsx @@ -1,8 +1,8 @@ /** @jsx h */ import { h, Fragment } from 'preact'; -import { useState } from 'preact/hooks'; import { getPropertyByPath } from '../../lib/utils'; +import { Tool } from '../chat/chat'; import type { BaseHit, TemplateParams } from '../../types'; import type { ExperienceApiResponse } from './types'; @@ -171,58 +171,41 @@ export function renderTool({ name, experience }: RenderToolParams) { return { [name]: { templates: { - layout: ({ message }) => ( - - ), + layout: ({ message }) => { + return message.output ? ( +
+ {renderTemplate(template)(message.output as BaseHit)} +
+ ) : ( +
+ Loading… +
+ ); + }, }, - }, - }; -} - -type RemoteToolRendererProps = { - template: TemplateChild[]; - input: Record; - webhook?: string; -}; - -function RemoteToolRenderer({ - template, - input, - webhook, -}: RemoteToolRendererProps) { - const [query, setQuery] = useState<{ - status: 'idle' | 'pending' | 'error' | 'success'; - data: typeof input | null; - }>({ status: 'idle', data: null }); - - if (!webhook) { - setQuery({ status: 'success', data: input }); - } - if (webhook && query.status === 'idle') { - setQuery({ status: 'pending', data: null }); - fetch(webhook, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', + 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, + }, + }) + ); }, - body: JSON.stringify(input), - }) - .then((res) => res.json()) - .then((fetchedData) => { - setQuery({ status: 'success', data: fetchedData }); - }) - .catch((error) => { - setQuery({ status: 'error', data: error }); - }); - } - - if (query.status === 'success') { - return renderTemplate(template)(query.data as BaseHit); - } - - return
Loading…
; + }, + } satisfies { [key: string]: Tool }; }