diff --git a/packages/runtime/src/experiences/middleware.ts b/packages/runtime/src/experiences/middleware.ts index 4b28a50..78a7266 100644 --- a/packages/runtime/src/experiences/middleware.ts +++ b/packages/runtime/src/experiences/middleware.ts @@ -1,6 +1,11 @@ import { walkIndex } from 'instantsearch.js/es/lib/utils'; +import index from 'instantsearch.js/es/widgets/index/index'; -import type { InternalMiddleware } from 'instantsearch.js/es/types'; +import type { + IndexWidget, + InternalMiddleware, + InstantSearch, +} from 'instantsearch.js/es/types'; import type { Environment, ExperienceApiResponse, @@ -12,6 +17,56 @@ export type ExperienceProps = { env?: Environment; }; +export function createExperienceMiddleware( + props: ExperienceProps +): InternalMiddleware { + const { config, env = 'prod' } = props; + + return ({ instantSearchInstance }) => { + const cleanups: Array<() => void> = []; + + return { + $$type: 'ais.experience', + $$internal: true, + onStateChange: () => {}, + subscribe() { + const experienceWidgets: ExperienceWidget[] = []; + walkIndex(instantSearchInstance.mainIndex, (index) => { + const widgets = index.getWidgets(); + + widgets.forEach((widget) => { + if (widget.$$type === 'ais.experience') { + experienceWidgets.push(widget as ExperienceWidget); + } + }); + }); + + experienceWidgets.forEach((widget) => { + const parent = widget.parent!; + + parent.removeWidgets([widget]); + + processBlocks({ + blocks: config.blocks, + parent, + widget, + env, + instantSearchInstance, + cleanups, + }); + }); + }, + started: () => {}, + unsubscribe: () => { + cleanups.forEach((cleanup) => { + return cleanup(); + }); + cleanups.length = 0; + }, + }; + }; +} + type Placement = 'inside' | 'before' | 'after' | 'replace' | 'body'; type ResolvedContainer = { @@ -74,102 +129,118 @@ function resolveContainer( }; } -export function createExperienceMiddleware( - props: ExperienceProps -): InternalMiddleware { - const { config, env = 'prod' } = props; - - return ({ instantSearchInstance }) => { - const cleanups: Array<() => void> = []; - - return { - $$type: 'ais.experience', - $$internal: true, - onStateChange: () => {}, - subscribe() { - const experienceWidgets: ExperienceWidget[] = []; - walkIndex(instantSearchInstance.mainIndex, (index) => { - const widgets = index.getWidgets(); +type ProcessBlocksOptions = { + blocks: ExperienceApiResponse['blocks']; + parent: IndexWidget; + widget: ExperienceWidget; + env: Environment; + instantSearchInstance: InstantSearch; + cleanups: Array<() => void>; +}; - widgets.forEach((widget) => { - if (widget.$$type === 'ais.experience') { - experienceWidgets.push(widget as ExperienceWidget); - } - }); +function processBlocks({ + blocks, + parent, + widget, + env, + instantSearchInstance, + cleanups, +}: ProcessBlocksOptions) { + blocks.forEach((block) => { + const { type, parameters } = block; + + if (type === 'ais.index') { + const { indexName, indexId } = parameters; + + if (!indexName) { + console.warn( + '[Algolia Experiences] ais.index block is missing required "indexName" parameter, skipping.' + ); + return; + } + + const indexWidget = index({ + indexName, + indexId: indexId as string | undefined, + }); + + parent.addWidgets([indexWidget]); + cleanups.push(() => { + return parent.removeWidgets([indexWidget]); + }); + + if (block.blocks) { + processBlocks({ + blocks: block.blocks, + parent: indexWidget, + widget, + env, + instantSearchInstance, + cleanups, }); + } - experienceWidgets.forEach((widget) => { - const parent = widget.parent!; - - parent.removeWidgets([widget]); - - config.blocks.forEach((block) => { - const { type, parameters } = block; - - const cssVariables = parameters.cssVariables ?? {}; - const cssVariablesKeys = Object.keys(cssVariables); - if (cssVariablesKeys.length > 0) { - injectStyleElement(` - :root { - ${cssVariablesKeys - .map((key) => { - return `--ais-${key}: ${cssVariables[key]};`; - }) - .join(';')} - } - `); - } + return; + } - const supportedWidget = widget.$$supportedWidgets[type]; - if (!supportedWidget) { - return; - } + const cssVariables = parameters.cssVariables ?? {}; + const cssVariablesKeys = Object.keys(cssVariables); - const { placement, ...widgetParams } = parameters; - const resolved = resolveContainer( - widgetParams.container, - placement as Placement | undefined - ); - - if (!resolved) { - return; - } - - cleanups.push(resolved.cleanup); - - const newWidget = supportedWidget.widget; - supportedWidget - .transformParams(widgetParams, { env, instantSearchInstance }) - .then((transformedParams) => { - if (newWidget) { - const params = { - ...(transformedParams as Record), - container: resolved.container, - }; - const widgets = newWidget(params); - parent.addWidgets( - Array.isArray(widgets) ? widgets : [widgets] - ); - } + if (cssVariablesKeys.length > 0) { + const style = injectStyleElement(` + :root { + ${cssVariablesKeys + .map((key) => { + return `--ais-${key}: ${cssVariables[key]};`; }) - .catch((error) => { - console.error( - `[Algolia Experiences] Failed to mount widget "${type}":`, - error - ); - }); - }); - }); - }, - started: () => {}, - unsubscribe: () => { - cleanups.forEach((cleanup) => { - return cleanup(); - }); - cleanups.length = 0; - }, - }; - }; + .join(';')} + } + `); + cleanups.push(() => { + return style.remove(); + }); + } + + const supportedWidget = widget.$$supportedWidgets[type]; + + if (!supportedWidget) { + return; + } + + const { placement, ...widgetParams } = parameters; + + const resolved = resolveContainer( + widgetParams.container as string | undefined, + placement as Placement | undefined + ); + + if (!resolved) { + return; + } + + cleanups.push(resolved.cleanup); + + const newWidget = supportedWidget.widget; + + supportedWidget + .transformParams(widgetParams, { env, instantSearchInstance }) + .then((transformedParams) => { + if (newWidget) { + const params = { + ...(transformedParams as Record), + container: resolved.container, + }; + const widgets = newWidget(params); + parent.addWidgets(Array.isArray(widgets) ? widgets : [widgets]); + } + }) + .catch((error) => { + console.error( + `[Algolia Experiences] Failed to mount widget "${type}":`, + error + ); + }); + }); } export function injectStyleElement(textContent: string) { @@ -178,4 +249,6 @@ export function injectStyleElement(textContent: string) { style.textContent = textContent; document.head.appendChild(style); + + return style; } diff --git a/packages/runtime/src/experiences/types.ts b/packages/runtime/src/experiences/types.ts index 5652165..57b2522 100644 --- a/packages/runtime/src/experiences/types.ts +++ b/packages/runtime/src/experiences/types.ts @@ -12,13 +12,20 @@ type ExperienceApiBlockParameters = { placement?: 'inside' | 'before' | 'after' | 'replace' | 'body'; cssVariables?: Record; indexName?: string; -} & Record<'container' | 'cssVariables' | 'indexName' | (string & {}), unknown>; + indexId?: string; +} & Record< + 'container' | 'cssVariables' | 'indexName' | 'indexId' | (string & {}), + unknown +>; + +type ExperienceApiBlock = { + type: string; + parameters: ExperienceApiBlockParameters; + blocks?: ExperienceApiBlock[]; +}; export type ExperienceApiResponse = { - blocks: Array<{ - type: string; - parameters: ExperienceApiBlockParameters; - }>; + blocks: ExperienceApiBlock[]; }; export type ExperienceWidgetParams = { diff --git a/packages/toolbar/__tests__/ai-tools.test.ts b/packages/toolbar/__tests__/ai-tools.test.ts index 104d293..54eebff 100644 --- a/packages/toolbar/__tests__/ai-tools.test.ts +++ b/packages/toolbar/__tests__/ai-tools.test.ts @@ -118,6 +118,73 @@ describe('describeExperience', () => { expect(result).not.toContain('[before ]'); }); + it('renders ais.index blocks with fallback label and indexName parameter', () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.index', + parameters: { container: '', indexName: 'products' }, + }, + ], + }; + + const result = describeExperience(experience); + expect(result).toContain('[0] ais.index (ais.index)'); + expect(result).toContain('indexName="products"'); + }); + + it('does not describe nested blocks inside ais.index', () => { + // The toolbar type does not support nested blocks, so describeExperience + // only iterates top-level blocks. Nested children inside ais.index are + // invisible to the AI tools layer. This documents the current gap. + const experience = { + blocks: [ + { + type: 'ais.index', + parameters: { container: '', indexName: 'products' }, + blocks: [ + { + type: 'ais.hits', + parameters: { container: '#hits' }, + }, + ], + }, + ], + } as ExperienceApiResponse; + + const result = describeExperience(experience); + // Only the top-level ais.index block is described + expect(result).toContain('[0] ais.index (ais.index)'); + // The nested ais.hits block is not described + expect(result).not.toContain('ais.hits'); + expect(result).not.toContain('#hits'); + }); + + it('renders ais.index alongside regular widgets', () => { + const experience: ExperienceApiResponse = { + blocks: [ + { + type: 'ais.autocomplete', + parameters: { container: '#search' }, + }, + { + type: 'ais.index', + parameters: { container: '', indexName: 'suggestions' }, + }, + { + type: 'ais.chat', + parameters: { container: '#chat', placement: 'body' }, + }, + ], + }; + + const result = describeExperience(experience); + expect(result).toContain('[0] Autocomplete (ais.autocomplete)'); + expect(result).toContain('[1] ais.index (ais.index)'); + expect(result).toContain('indexName="suggestions"'); + expect(result).toContain('[2] Chat (ais.chat)'); + }); + it('uses default placement from widget config when not in parameters', () => { const experience: ExperienceApiResponse = { blocks: [ @@ -380,7 +447,11 @@ describe('getTools', () => { type: 'ais.autocomplete', container: '#search', placement: 'before', - parameters: { container: '#other', placement: 'after', showRecent: true }, + parameters: { + container: '#other', + placement: 'after', + showRecent: true, + }, }, { toolCallId: 'tc1', messages: [] } ); @@ -656,7 +727,10 @@ describe('getTools', () => { expect(result).toMatchObject({ success: true, - applied: ['cssVariables.primary-color-rgb', 'cssVariables.secondary-color'], + applied: [ + 'cssVariables.primary-color-rgb', + 'cssVariables.secondary-color', + ], }); expect(callbacks.onCssVariableChange).toHaveBeenCalledTimes(2); });