diff --git a/bundlesize.config.json b/bundlesize.config.json index d81f9f6e54a..ad2a2ef2c55 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,19 +10,19 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "130.5 kB" + "maxSize": "133.5 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", - "maxSize": "280 kB" + "maxSize": "282.5 kB" }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", - "maxSize": "61.5 kB" + "maxSize": "63.75 kB" }, { "path": "packages/react-instantsearch/dist/umd/ReactInstantSearch.min.js", - "maxSize": "103.25 kB" + "maxSize": "106.25 kB" }, { "path": "packages/vue-instantsearch/vue2/umd/index.js", @@ -46,7 +46,7 @@ }, { "path": "./packages/instantsearch.css/themes/algolia-min.css", - "maxSize": "10 kB" + "maxSize": "10.25 kB" }, { "path": "./packages/instantsearch.css/themes/reset.css", diff --git a/packages/instantsearch-ui-components/src/components/__tests__/OnPageSuggestions.test.tsx b/packages/instantsearch-ui-components/src/components/__tests__/OnPageSuggestions.test.tsx new file mode 100644 index 00000000000..229d5e5d7ad --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/__tests__/OnPageSuggestions.test.tsx @@ -0,0 +1,184 @@ +/** + * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts + */ +/** @jsx createElement */ +import { render } from '@testing-library/preact'; +import { Fragment, createElement } from 'preact'; + +import { createOnPageSuggestionsComponent } from '../chat/OnPageSuggestions'; + +describe('OnPageSuggestions', () => { + const OnPageSuggestions = createOnPageSuggestionsComponent({ + createElement, + Fragment, + }); + + test('renders an empty root when there are no suggestions and not loading', () => { + const { container } = render( + + ); + + const root = container.querySelector('.ais-OnPageSuggestions'); + expect(root).toBeInTheDocument(); + expect(root).toBeEmptyDOMElement(); + expect( + container.querySelector('.ais-OnPageSuggestions-skeleton') + ).not.toBeInTheDocument(); + }); + + test('forwards HTML attributes and merges className onto the root', () => { + const { container } = render( + + ); + + const root = container.querySelector( + '.ais-OnPageSuggestions' + )!; + expect(root.classList.contains('CUSTOM')).toBe(true); + expect(root.title).toBe('hello'); + }); + + test('renders the default header when there are suggestions', () => { + const { container } = render( + + ); + + const header = container.querySelector('.ais-OnPageSuggestions-header'); + expect(header).toBeInTheDocument(); + expect( + container.querySelector('.ais-OnPageSuggestions-headerTitle') + ).toHaveTextContent('Suggestions'); + }); + + test('translates the header title', () => { + const { container } = render( + + ); + + expect( + container.querySelector('.ais-OnPageSuggestions-headerTitle') + ).toHaveTextContent('Ideas'); + }); + + test('renders the default header while loading', () => { + const { container } = render( + + ); + + expect( + container.querySelector('.ais-OnPageSuggestions-header') + ).toBeInTheDocument(); + }); + + test('does not render the header when empty and not loading', () => { + const { container } = render( + + ); + + expect( + container.querySelector('.ais-OnPageSuggestions-header') + ).not.toBeInTheDocument(); + }); + + test('disables the header when headerComponent is false', () => { + const { container } = render( + + ); + + expect( + container.querySelector('.ais-OnPageSuggestions-header') + ).not.toBeInTheDocument(); + expect( + container.querySelectorAll('.ais-OnPageSuggestions-suggestion') + ).toHaveLength(1); + }); + + test('renders a custom header component', () => { + const { container } = render( +
Custom
} + onSuggestionClick={jest.fn()} + /> + ); + + expect( + container.querySelector('.ais-OnPageSuggestions-header') + ).not.toBeInTheDocument(); + const custom = container.querySelector('.custom-header'); + expect(custom).toBeInTheDocument(); + expect(custom).toHaveTextContent('Custom'); + }); + + test('renders the suggestion pills', () => { + const { container } = render( + + ); + + expect( + container.querySelectorAll('.ais-OnPageSuggestions-suggestion') + ).toHaveLength(2); + expect( + container.querySelector('.ais-OnPageSuggestions-skeleton') + ).not.toBeInTheDocument(); + }); + + test('renders skeletons while loading with no suggestions yet', () => { + const { container } = render( + + ); + + expect( + container.querySelector('.ais-OnPageSuggestions-skeleton') + ).toBeInTheDocument(); + expect( + container.querySelectorAll('.ais-OnPageSuggestions-skeletonItem') + ).toHaveLength(3); + }); + + test('keeps existing pills (no skeletons) while refetching', () => { + // Loading with suggestions already present must not swap the pills for + // skeletons — that would make existing suggestions disappear mid-refetch. + const { container } = render( + + ); + + expect( + container.querySelectorAll('.ais-OnPageSuggestions-suggestion') + ).toHaveLength(2); + expect( + container.querySelector('.ais-OnPageSuggestions-skeleton') + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/instantsearch-ui-components/src/components/chat/Chat.tsx b/packages/instantsearch-ui-components/src/components/chat/Chat.tsx index 5819a55a050..f9583da24d1 100644 --- a/packages/instantsearch-ui-components/src/components/chat/Chat.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/Chat.tsx @@ -5,13 +5,13 @@ import { createChatHeaderComponent } from './ChatHeader'; import { createChatMessagesComponent } from './ChatMessages'; import { createChatOverlayLayoutComponent } from './ChatOverlayLayout'; import { createChatPromptComponent } from './ChatPrompt'; -import { createChatPromptSuggestionsComponent } from './ChatPromptSuggestions'; +import { createOnPageSuggestionsComponent } from './OnPageSuggestions'; import type { Renderer, ComponentProps, Hooks } from '../../types'; import type { ChatHeaderProps, ChatHeaderOwnProps } from './ChatHeader'; import type { ChatMessagesProps } from './ChatMessages'; import type { ChatPromptProps, ChatPromptOwnProps } from './ChatPrompt'; -import type { ChatPromptSuggestionsOwnProps } from './ChatPromptSuggestions'; +import type { OnPageSuggestionsOwnProps } from './OnPageSuggestions'; import type { ChatLayoutOwnProps } from './types'; export type ChatClassNames = { @@ -21,7 +21,7 @@ export type ChatClassNames = { messages?: ChatMessagesProps['classNames']; message?: ChatMessagesProps['messageClassNames']; prompt?: ChatPromptProps['classNames']; - suggestions?: ChatPromptSuggestionsOwnProps['classNames']; + suggestions?: OnPageSuggestionsOwnProps['classNames']; }; export type ChatProps = Omit, 'onError' | 'title'> & { @@ -46,9 +46,9 @@ export type ChatProps = Omit, 'onError' | 'title'> & { */ promptProps: ChatPromptProps; /* - * Props for the ChatPromptSuggestions component. + * Props for the OnPageSuggestions component. */ - suggestionsProps: ChatPromptSuggestionsOwnProps; + suggestionsProps: OnPageSuggestionsOwnProps; /** * Optional class names for elements */ @@ -68,7 +68,7 @@ export type ChatProps = Omit, 'onError' | 'title'> & { /** * Optional suggestions component for the chat */ - suggestionsComponent?: (props: ChatPromptSuggestionsOwnProps) => JSX.Element; + suggestionsComponent?: (props: OnPageSuggestionsOwnProps) => JSX.Element; /** * Function to send a message to the chat. */ @@ -113,7 +113,7 @@ export function createChatComponent({ memo, }); const ChatPrompt = createChatPromptComponent({ createElement, Fragment }); - const ChatPromptSuggestions = createChatPromptSuggestionsComponent({ + const OnPageSuggestions = createOnPageSuggestionsComponent({ createElement, Fragment, }); @@ -191,13 +191,15 @@ export function createChatComponent({ error={error} classNames={classNames.messages} messageClassNames={classNames.message} - suggestionsElement={createElement( - SuggestionsComponent || ChatPromptSuggestions, - { - ...suggestionsProps, - classNames: classNames.suggestions, - } - )} + suggestionsElement={ + suggestionsProps.suggestions?.length || suggestionsProps.isLoading + ? createElement(SuggestionsComponent || OnPageSuggestions, { + headerComponent: false, + ...suggestionsProps, + classNames: classNames.suggestions, + }) + : undefined + } /> ); diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatPromptSuggestions.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatPromptSuggestions.tsx deleted file mode 100644 index 359770b9af3..00000000000 --- a/packages/instantsearch-ui-components/src/components/chat/ChatPromptSuggestions.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/** @jsx createElement */ -/** @jsxFrag Fragment */ -import { cx } from '../../lib'; -import { createButtonComponent } from '../Button'; - -import type { Renderer } from '../../types'; - -export type ChatPromptSuggestionsClassNames = { - root?: string | string[]; - suggestion?: string | string[]; -}; - -export type ChatPromptSuggestionsOwnProps = { - /* - * List of prompt suggestions. - */ - suggestions?: string[]; - /* - * Callback when a suggestion is clicked. - */ - onSuggestionClick: (suggestion: string) => void; - /** - * Optional class names for elements - */ - classNames?: Partial; -}; - -export function createChatPromptSuggestionsComponent({ - createElement, -}: Renderer) { - const Button = createButtonComponent({ createElement }); - - return function ChatPromptSuggestions( - userProps: ChatPromptSuggestionsOwnProps - ) { - const { suggestions = [], onSuggestionClick, classNames = {} } = userProps; - - if (suggestions.length === 0) { - return null; - } - - return ( -
- {suggestions.map((suggestion, index) => ( - - ))} -
- ); - }; -} diff --git a/packages/instantsearch-ui-components/src/components/chat/OnPageSuggestions.tsx b/packages/instantsearch-ui-components/src/components/chat/OnPageSuggestions.tsx new file mode 100644 index 00000000000..787a030c87a --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/chat/OnPageSuggestions.tsx @@ -0,0 +1,175 @@ +/** @jsx createElement */ +/** @jsxFrag Fragment */ +import { cx } from '../../lib'; +import { createButtonComponent } from '../Button'; + +import type { ComponentProps, Renderer } from '../../types'; + +export type OnPageSuggestionsClassNames = { + root?: string | string[]; + header?: string | string[]; + headerTitle?: string | string[]; + suggestion?: string | string[]; + skeleton?: string | string[]; + skeletonItem?: string | string[]; +}; + +export type OnPageSuggestionsTranslations = { + /** + * The title displayed in the header. + */ + headerTitle: string; +}; + +export type OnPageSuggestionsHeaderComponentProps = { + classNames: Partial< + Pick + >; + translations: OnPageSuggestionsTranslations; +}; + +export type OnPageSuggestionsOwnProps = ComponentProps<'div'> & { + /* + * List of prompt suggestions. + */ + suggestions?: string[]; + /* + * Callback when a suggestion is clicked. + */ + onSuggestionClick: (suggestion: string) => void; + /** + * Whether suggestions are currently being fetched. When true and + * `suggestions` is empty, renders `skeletonCount` placeholder pills. + */ + isLoading?: boolean; + /** + * Number of skeleton placeholder pills shown while loading. + * @default 3 + */ + skeletonCount?: number; + /** + * Disables every pill (e.g. when a downstream chat is mid-stream). + */ + disabled?: boolean; + /** + * Component to render the header. Set to `false` to disable the header. + */ + headerComponent?: + | ((props: OnPageSuggestionsHeaderComponentProps) => JSX.Element) + | false; + /** + * Optional translations for the component. + */ + translations?: Partial; + /** + * Optional class names for elements + */ + classNames?: Partial; +}; + +export function createOnPageSuggestionsComponent({ + createElement, +}: Renderer) { + const Button = createButtonComponent({ createElement }); + + function DefaultHeader({ + classNames, + translations, + }: OnPageSuggestionsHeaderComponentProps) { + return ( +
+ + {translations.headerTitle} + +
+ ); + } + + return function OnPageSuggestions( + userProps: OnPageSuggestionsOwnProps + ) { + const { + suggestions = [], + onSuggestionClick, + isLoading = false, + skeletonCount = 3, + disabled = false, + headerComponent, + translations: userTranslations, + classNames = {}, + ...props + } = userProps; + + const translations: OnPageSuggestionsTranslations = { + headerTitle: 'Suggestions', + ...userTranslations, + }; + + const HeaderComponent = + headerComponent === false ? null : headerComponent ?? DefaultHeader; + + const hasContent = suggestions.length > 0 || isLoading; + + return ( +
+ {HeaderComponent && hasContent && ( + + )} + {isLoading && suggestions.length === 0 ? ( +
+ {[...new Array(skeletonCount)].map((_, i) => ( +
+ ))} +
+ ) : ( + suggestions.map((suggestion, index) => ( + + )) + )} +
+ ); + }; +} diff --git a/packages/instantsearch-ui-components/src/components/index.ts b/packages/instantsearch-ui-components/src/components/index.ts index aaa5df4b9e6..f845e3d0bb8 100644 --- a/packages/instantsearch-ui-components/src/components/index.ts +++ b/packages/instantsearch-ui-components/src/components/index.ts @@ -12,7 +12,7 @@ export * from './chat/ChatMessageLoader'; export * from './chat/ChatMessageError'; export * from './chat/ChatGreeting'; export * from './chat/ChatPrompt'; -export * from './chat/ChatPromptSuggestions'; +export * from './chat/OnPageSuggestions'; export * from './chat/ChatToggleButton'; export * from './chat/icons'; export * from './chat/tools/DisplayResultsTool'; diff --git a/packages/instantsearch.css/src/components/chat/_chat-suggestions.scss b/packages/instantsearch.css/src/components/chat/_chat-suggestions.scss index e9624dd3382..afdb10483cd 100644 --- a/packages/instantsearch.css/src/components/chat/_chat-suggestions.scss +++ b/packages/instantsearch.css/src/components/chat/_chat-suggestions.scss @@ -1,13 +1,25 @@ @use '../../shared/_common'; @use '../../shared/_variables'; -.ais-ChatPromptSuggestions { +.ais-OnPageSuggestions { display: flex; flex-direction: column; gap: calc(var(--ais-spacing) * 0.5); } -.ais-ChatPromptSuggestions-suggestion { +.ais-OnPageSuggestions-header { + display: flex; + align-items: center; + gap: calc(var(--ais-spacing) * 0.5); +} + +.ais-OnPageSuggestions-headerTitle { + font-size: var(--ais-font-size-sm); + font-weight: 500; + color: rgba(var(--ais-text-color-rgb), var(--ais-text-color-alpha)); +} + +.ais-OnPageSuggestions-suggestion { font-size: revert; line-height: var(--ais-spacing); width: fit-content; @@ -36,3 +48,47 @@ } } +.ais-OnPageSuggestions-skeleton { + display: flex; + flex-direction: column; + gap: calc(var(--ais-spacing) * 0.5); +} + +.ais-OnPageSuggestions-skeletonItem { + height: calc(var(--ais-spacing) * 2); + border-radius: var(--ais-border-radius-lg); + background-color: rgba(var(--ais-muted-color-rgb), 0.15); + animation: ais-chat-prompt-suggestions-skeleton 1.5s ease-in-out infinite; + + // Varying widths so the skeleton row mimics real pills. + &:nth-child(1) { + width: 65%; + } + + &:nth-child(2) { + width: 80%; + } + + &:nth-child(3) { + width: 55%; + } + + &:nth-child(4) { + width: 70%; + } + + &:nth-child(5) { + width: 60%; + } +} + +@keyframes ais-chat-prompt-suggestions-skeleton { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + diff --git a/packages/instantsearch.js/src/__tests__/common-widgets.test.tsx b/packages/instantsearch.js/src/__tests__/common-widgets.test.tsx index 1a8531fe3ec..1c2e1c21432 100644 --- a/packages/instantsearch.js/src/__tests__/common-widgets.test.tsx +++ b/packages/instantsearch.js/src/__tests__/common-widgets.test.tsx @@ -36,6 +36,7 @@ import { dynamicWidgets, chat, chatTrigger, + onPageSuggestions, EXPERIMENTAL_autocomplete, filterSuggestions, } from '../widgets'; @@ -745,6 +746,25 @@ const testSetups: TestSetupsMap = { }) .start(); }, + createOnPageSuggestionsWidgetTests({ + instantSearchOptions, + widgetParams, + }) { + instantsearch(instantSearchOptions) + .addWidgets([ + onPageSuggestions({ + container: document.body.appendChild(document.createElement('div')), + ...widgetParams, + }), + ]) + .on('error', () => { + /* + * prevent rethrowing InstantSearch errors, so tests can be asserted. + * IRL this isn't needed, as the error doesn't stop execution. + */ + }) + .start(); + }, }; const testOptions: TestOptionsMap = { @@ -782,6 +802,7 @@ const testOptions: TestOptionsMap = { createChatWidgetTests: undefined, createAutocompleteWidgetTests: undefined, createFilterSuggestionsWidgetTests: undefined, + createOnPageSuggestionsWidgetTests: undefined, }; describe('Common widget tests (InstantSearch.js)', () => { diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index b3174d2d172..ae24cc6b5ce 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -1,14 +1,12 @@ -import { - DefaultChatTransport, - lastAssistantMessageIsCompleteWithToolCalls, -} from '../../lib/ai-lite'; +import { lastAssistantMessageIsCompleteWithToolCalls } from '../../lib/ai-lite'; import { Chat, SearchIndexToolType } from '../../lib/chat'; +import { createAgentTransport } from '../../lib/chat/createAgentTransport'; +import { createSendMessageWithContext } from '../../lib/chat/sendMessageWithContext'; import { checkRendering, clearRefinements, createDocumentationMessageGenerator, createSendEventForHits, - getAlgoliaAgent, getAppIdAndApiKey, getRefinements, noop, @@ -19,6 +17,7 @@ import { } from '../../lib/utils'; import { flat } from '../../lib/utils/flat'; +import type { DefaultChatTransport } from '../../lib/ai-lite'; import type { AbstractChat, ChatInit as ChatInitAi, @@ -443,123 +442,25 @@ export default (function connectChat( }; const makeChatInstance = (instantSearchInstance: InstantSearch) => { - let transport; - const { client } = instantSearchInstance; - const [appId, apiKey] = getAppIdAndApiKey(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-')) - ), - })); - - 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 a minimal default body containing only the - // request payload — without leaking transport metadata such as - // resolved headers, api URL, credentials, or `requestMetadata`. - const preparedOrPromise = originalPrepare - ? originalPrepare(params) - : { - body: { - id: params.id, - messageId: params.messageId, - trigger: params.trigger, - messages: params.messages, - ...params.body, - }, - }; - // 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 ('chat' in options) { + return options.chat; } - if ('agentId' in options && options.agentId) { - if (!appId || !apiKey) { - throw new Error( - withUsage( - 'Could not extract Algolia credentials from the search client.' - ) - ); - } - const createApi = (bypassCache = false) => { - const api = new URL( - `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/completions` - ); - const queryParameters: Record = { - ...options.requestOptions?.queryParameters, - compatibilityMode: 'ai-sdk-5', - ...(bypassCache ? { cache: false } : {}), - }; + const transport = createAgentTransport({ + client: instantSearchInstance.client, + agentId: 'agentId' in options ? options.agentId : undefined, + transport: 'transport' in options ? options.transport : undefined, + algoliaAgentSuffix: 'chat', + requestOptions: + 'requestOptions' in options ? options.requestOptions : undefined, + }); - api.search = new URLSearchParams( - queryParameters as Record - ).toString(); - return api.toString(); - }; - const baseApi = createApi(); - transport = new DefaultChatTransport({ - api: baseApi, - headers: { - ...(options.requestOptions?.headers instanceof Headers - ? Object.fromEntries(options.requestOptions.headers.entries()) - : options.requestOptions?.headers), - // Preserve the required Algolia identity headers and chat agent - // marker, even when requestOptions.headers contains the same keys. - 'x-algolia-application-id': appId, - 'x-algolia-api-key': apiKey, - 'x-algolia-agent': `${getAlgoliaAgent(client)}; chat`, - }, - prepareSendMessagesRequest: ({ - id, - messages, - trigger, - messageId, - }) => { - return { - // Bypass cache when regenerating to ensure fresh responses - api: trigger === 'regenerate-message' ? createApi(true) : baseApi, - body: { - id, - messageId, - messages: filterDataParts(messages), - }, - }; - }, - }); - } if (!transport) { throw new Error( withUsage('You need to provide either an `agentId` or a `transport`.') ); } - if ('chat' in options) { - return options.chat; - } - return new Chat({ ...options, sendAutomaticallyWhen, @@ -774,30 +675,10 @@ export default (function connectChat( toolsWithAddToolResult[key] = toolWithAddToolResult; }); - const sendMessageWithContext: typeof _chatInstance.sendMessage = ( - message, - ...rest - ) => { - if (!context || !message) { - return _chatInstance.sendMessage(message, ...rest); - } - - // Resolve once per send; let the server validate the payload and - // surface any contract violations. - const turnContext = - typeof context === 'function' ? context() : context; - - return _chatInstance.sendMessage( - { - ...message, - metadata: { - ...(message.metadata as Record | undefined), - turnContext, - }, - } as Parameters[0], - ...rest - ); - }; + const sendMessageWithContext = createSendMessageWithContext( + _chatInstance, + context + ); return { indexUiState: instantSearchInstance.getUiState()[parent.getIndexId()], diff --git a/packages/instantsearch.js/src/connectors/index.ts b/packages/instantsearch.js/src/connectors/index.ts index d43db5fb5ab..ae3d0c0836f 100644 --- a/packages/instantsearch.js/src/connectors/index.ts +++ b/packages/instantsearch.js/src/connectors/index.ts @@ -56,6 +56,8 @@ export { default as connectRelevantSort } from './relevant-sort/connectRelevantS export { default as connectFrequentlyBoughtTogether } from './frequently-bought-together/connectFrequentlyBoughtTogether'; export { default as connectLookingSimilar } from './looking-similar/connectLookingSimilar'; export { default as connectChat } from './chat/connectChat'; +export { default as connectOnPageSuggestions } from './on-page-suggestions/connectOnPageSuggestions'; +export { default as connectStructuredOutput } from './structured-output/connectStructuredOutput'; export { default as connectFeeds } from './feeds/connectFeeds'; export { default as connectChatTrigger } from './chat/connectChatTrigger'; export { default as connectFilterSuggestions } from './filter-suggestions/connectFilterSuggestions'; diff --git a/packages/instantsearch.js/src/connectors/on-page-suggestions/__tests__/connectOnPageSuggestions-test.ts b/packages/instantsearch.js/src/connectors/on-page-suggestions/__tests__/connectOnPageSuggestions-test.ts new file mode 100644 index 00000000000..443e1d236f1 --- /dev/null +++ b/packages/instantsearch.js/src/connectors/on-page-suggestions/__tests__/connectOnPageSuggestions-test.ts @@ -0,0 +1,979 @@ +/** + * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts + */ + +import { createSearchClient } from '@instantsearch/mocks'; +import algoliasearchHelper from 'algoliasearch-helper'; + +import { createSingleSearchResponse } from '../../../../../../tests/mocks/createAPIResponse'; +import { createInstantSearch } from '../../../../test/createInstantSearch'; +import { + createDisposeOptions, + createInitOptions, + createRenderOptions, +} from '../../../../test/createWidget'; +import connectOnPageSuggestions from '../connectOnPageSuggestions'; + +import type { OnPageSuggestionsConnectorParams } from '../connectOnPageSuggestions'; +import type { SearchResults } from 'algoliasearch-helper'; + +// Matches the connector's internal DEBOUNCE_MS constant. Tests wait this long +// (plus a small buffer) for the debounced fetch to fire. +const DEBOUNCE_WAIT = 320; + +function makeResults( + overrides: { + hits?: Array>; + query?: string; + } = {} +): SearchResults { + const { hits = [{ objectID: '1' }, { objectID: '2' }], query = 'q' } = + overrides; + const response = createSingleSearchResponse({ + hits: hits as unknown as SearchResults['hits'], + query, + }); + const helper = algoliasearchHelper(createSearchClient(), ''); + return new algoliasearchHelper.SearchResults(helper.state, [response]); +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +// Builds a fake `text/event-stream` response whose body replays `events` as +// SSE `data:` lines. Kept as a plain object (not a real `Response`) so the test +// doesn't depend on `Response.body` support in the jsdom environment. +function sseResponse(events: string[]): Response { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + events.forEach((event) => { + controller.enqueue(encoder.encode(`data: ${event}\n\n`)); + }); + controller.close(); + }, + }); + return { + ok: true, + status: 200, + headers: { + get: (name: string) => + name.toLowerCase() === 'content-type' ? 'text/event-stream' : null, + }, + body, + } as unknown as Response; +} + +function taskOutputEvent(suggestions: string[]): string { + return JSON.stringify({ + type: 'data-task-output', + data: { output: { suggestions } }, + }); +} + +function flush(ms = 0) { + return new Promise((r) => setTimeout(r, ms)); +} + +describe('connectOnPageSuggestions', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(() => + Promise.resolve( + jsonResponse({ + output: { suggestions: ['a', 'b', 'c'] }, + }) + ) + ) as unknown as typeof fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + describe('Usage', () => { + it('throws without a render function', () => { + expect(() => { + // @ts-expect-error + connectOnPageSuggestions()({ agentId: 'a' }); + }).toThrowError(/render function is not valid/); + }); + + it('throws when neither agentId nor transport is provided', () => { + const makeWidget = connectOnPageSuggestions(jest.fn()); + expect(() => + makeWidget({} as OnPageSuggestionsConnectorParams) + ).toThrowError(/agentId.*transport/); + }); + + it('returns the widget descriptor', () => { + const widget = connectOnPageSuggestions(jest.fn())({ + agentId: 'a', + }); + expect(widget).toEqual( + expect.objectContaining({ + $$type: 'ais.onPageSuggestions', + init: expect.any(Function), + render: expect.any(Function), + dispose: expect.any(Function), + }) + ); + }); + }); + + describe('fetch lifecycle', () => { + it('fires one request after the debounce window on first results', async () => { + const renderFn = jest.fn(); + const widget = connectOnPageSuggestions(renderFn)({ + agentId: 'a', + }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + + const renderOptions = createRenderOptions({ + helper, + results: makeResults(), + }); + widget.render!(renderOptions); + + // Debounce hasn't fired yet — no fetch. + expect(global.fetch).not.toHaveBeenCalled(); + + await flush(DEBOUNCE_WAIT); + expect(global.fetch).toHaveBeenCalledTimes(1); + await flush(0); + + // After resolution, render fired with the parsed suggestions. + const lastCall = renderFn.mock.calls[renderFn.mock.calls.length - 1][0]; + expect(lastCall.suggestions).toEqual(['a', 'b', 'c']); + expect(lastCall.isLoading).toBe(false); + }); + + it('skips the request when there are no hits', async () => { + const renderFn = jest.fn(); + const widget = connectOnPageSuggestions(renderFn)({ + agentId: 'a', + }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + widget.render!( + createRenderOptions({ helper, results: makeResults({ hits: [] }) }) + ); + + await flush(DEBOUNCE_WAIT); + expect(global.fetch).not.toHaveBeenCalled(); + const lastCall = renderFn.mock.calls[renderFn.mock.calls.length - 1][0]; + expect(lastCall.suggestions).toEqual([]); + expect(lastCall.isLoading).toBe(false); + }); + + it('does not refetch when the state signature is unchanged', async () => { + const renderFn = jest.fn(); + const widget = connectOnPageSuggestions(renderFn)({ + agentId: 'a', + }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + + const results = makeResults({ query: 'shoes' }); + widget.render!(createRenderOptions({ helper, results })); + await flush(DEBOUNCE_WAIT); + expect(global.fetch).toHaveBeenCalledTimes(1); + + // Second render with identical signature → no refetch. + widget.render!(createRenderOptions({ helper, results })); + await flush(DEBOUNCE_WAIT); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('refetches when the query changes', async () => { + const renderFn = jest.fn(); + const widget = connectOnPageSuggestions(renderFn)({ + agentId: 'a', + }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + + widget.render!( + createRenderOptions({ helper, results: makeResults({ query: 'a' }) }) + ); + await flush(DEBOUNCE_WAIT); + expect(global.fetch).toHaveBeenCalledTimes(1); + + widget.render!( + createRenderOptions({ helper, results: makeResults({ query: 'b' }) }) + ); + await flush(DEBOUNCE_WAIT); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it('refetches when a facet refinement changes even if the query does not', async () => { + // Regression: the state signature must be derived from the results' own + // state, not a helper captured at init. In React the captured helper's + // state does not track live refinements, so reading it made facet + // changes (query unchanged) invisible — the pills never refreshed. + const renderFn = jest.fn(); + const widget = connectOnPageSuggestions(renderFn)({ agentId: 'a' }); + const helper = algoliasearchHelper(createSearchClient(), '', { + disjunctiveFacets: ['brand'], + }); + widget.init!(createInitOptions({ helper })); + + const unrefined = new algoliasearchHelper.SearchResults(helper.state, [ + createSingleSearchResponse({ hits: [{ objectID: '1' }] as any, query: '' }), + ]); + widget.render!(createRenderOptions({ helper, results: unrefined })); + await flush(DEBOUNCE_WAIT); + expect(global.fetch).toHaveBeenCalledTimes(1); + + // Same query (''), but the results now carry a facet refinement — this is + // what a real search produces after a RefinementList click. + const refined = new algoliasearchHelper.SearchResults( + helper.state.addDisjunctiveFacetRefinement('brand', 'Apple'), + [ + createSingleSearchResponse({ + hits: [{ objectID: '1' }] as any, + query: '', + }), + ] + ); + widget.render!(createRenderOptions({ helper, results: refined })); + await flush(DEBOUNCE_WAIT); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it('refetches when a numeric refinement changes even if the query does not', async () => { + // The state signature must also track numeric (and tag) refinements, so a + // range-filter change refreshes the pills like a facet change does. + const renderFn = jest.fn(); + const widget = connectOnPageSuggestions(renderFn)({ agentId: 'a' }); + const helper = algoliasearchHelper(createSearchClient(), '', {}); + widget.init!(createInitOptions({ helper })); + + const unrefined = new algoliasearchHelper.SearchResults(helper.state, [ + createSingleSearchResponse({ hits: [{ objectID: '1' }] as any, query: '' }), + ]); + widget.render!(createRenderOptions({ helper, results: unrefined })); + await flush(DEBOUNCE_WAIT); + expect(global.fetch).toHaveBeenCalledTimes(1); + + const refined = new algoliasearchHelper.SearchResults( + helper.state.addNumericRefinement('price', '<=', 500), + [ + createSingleSearchResponse({ + hits: [{ objectID: '1' }] as any, + query: '', + }), + ] + ); + widget.render!(createRenderOptions({ helper, results: refined })); + await flush(DEBOUNCE_WAIT); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it('clears stale pills and exposes the loading state on every refetch', async () => { + // Regression: on a refetch (query/refinement change) the previous pills + // must be cleared so the UI's `isLoading && suggestions.length === 0` + // skeleton fires. Without clearing, stale pills stay on screen and the + // new ones swap in silently — no loading state ever shows after the + // first fetch. + const renderFn = jest.fn(); + const widget = connectOnPageSuggestions(renderFn)({ agentId: 'a' }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + + // First fetch resolves immediately with pills. + widget.render!( + createRenderOptions({ helper, results: makeResults({ query: 'a' }) }) + ); + await flush(DEBOUNCE_WAIT); + await flush(0); + const afterFirst = + renderFn.mock.calls[renderFn.mock.calls.length - 1][0]; + expect(afterFirst.suggestions).toEqual(['a', 'b', 'c']); + expect(afterFirst.isLoading).toBe(false); + + // Hold the second fetch open so we can observe the in-flight render. + let resolveSecond: (response: Response) => void = () => {}; + (global.fetch as jest.Mock).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }) + ); + + widget.render!( + createRenderOptions({ helper, results: makeResults({ query: 'b' }) }) + ); + await flush(DEBOUNCE_WAIT); + + // Mid-refetch: stale pills gone, skeleton state exposed. + const midFlight = + renderFn.mock.calls[renderFn.mock.calls.length - 1][0]; + expect(midFlight.isLoading).toBe(true); + expect(midFlight.suggestions).toEqual([]); + + resolveSecond(jsonResponse({ output: { suggestions: ['x', 'y'] } })); + await flush(0); + const afterSecond = + renderFn.mock.calls[renderFn.mock.calls.length - 1][0]; + expect(afterSecond.isLoading).toBe(false); + expect(afterSecond.suggestions).toEqual(['x', 'y']); + }); + + it('does not render after dispose when an in-flight fetch resolves late', async () => { + const renderFn = jest.fn(); + const widget = connectOnPageSuggestions(renderFn)({ agentId: 'a' }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + + // Hold the fetch open so it resolves only after we dispose. + let resolveFetch: (response: Response) => void = () => {}; + (global.fetch as jest.Mock).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + + widget.render!( + createRenderOptions({ helper, results: makeResults({ query: 'a' }) }) + ); + await flush(DEBOUNCE_WAIT); + expect(global.fetch).toHaveBeenCalledTimes(1); + + widget.dispose!(createDisposeOptions({ helper })); + const callsAfterDispose = renderFn.mock.calls.length; + + // The late resolution must not trigger a render into the torn-down tree. + resolveFetch(jsonResponse({ output: { suggestions: ['x', 'y'] } })); + await flush(0); + expect(renderFn).toHaveBeenCalledTimes(callsAfterDispose); + }); + + it('applies transformItems to the parsed list with query+results metadata', async () => { + const renderFn = jest.fn(); + const transform = jest.fn< + string[], + [string[], { query: string; results: unknown }] + >((items) => items.map((s) => `! ${s}`)); + const widget = connectOnPageSuggestions(renderFn)({ + agentId: 'a', + transformItems: transform, + }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + widget.render!( + createRenderOptions({ + helper, + results: makeResults({ query: 'shoes' }), + }) + ); + await flush(DEBOUNCE_WAIT); + await flush(0); + + const lastCall = renderFn.mock.calls[renderFn.mock.calls.length - 1][0]; + expect(lastCall.suggestions).toEqual(['! a', '! b', '! c']); + // Last call to transform should have been with the post-fetch results. + const [items, meta] = + transform.mock.calls[transform.mock.calls.length - 1]; + expect(items).toEqual(['a', 'b', 'c']); + expect(meta).toEqual( + expect.objectContaining({ + query: 'shoes', + results: expect.objectContaining({ query: 'shoes' }), + }) + ); + }); + + it('forwards `transformHits(hits)` output to the agent as context', async () => { + const transformHits = jest.fn( + (hits: Array>) => + hits.slice(0, 1).map((h) => ({ id: h.objectID })) + ); + const widget = connectOnPageSuggestions(jest.fn())({ + agentId: 'a', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transformHits: transformHits as any, + }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + widget.render!( + createRenderOptions({ + helper, + results: makeResults({ + hits: [{ objectID: '1' }, { objectID: '2' }, { objectID: '3' }], + }), + }) + ); + await flush(DEBOUNCE_WAIT); + + expect(transformHits).toHaveBeenCalledTimes(1); + const [[, init]] = (global.fetch as jest.Mock).mock.calls; + const parsed = JSON.parse((init as RequestInit).body as string); + expect(parsed.task).toBe('on_page_suggestions'); + expect(parsed.input).not.toHaveProperty('pageType'); + expect(parsed.input.hitsSample).toEqual([{ id: '1' }]); + }); + + it('strips InstantSearch hit metadata from the default context', async () => { + const widget = connectOnPageSuggestions(jest.fn())({ agentId: 'a' }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + widget.render!( + createRenderOptions({ + helper, + results: makeResults({ + hits: [ + { + objectID: '1', + name: 'Product 1', + _highlightResult: { name: { value: 'Product 1' } }, + _snippetResult: { name: { value: 'Product 1' } }, + _rankingInfo: { nbTypos: 0 }, + __position: 1, + __queryID: 'q1', + }, + ] as any, + }), + }) + ); + await flush(DEBOUNCE_WAIT); + + const [[, init]] = (global.fetch as jest.Mock).mock.calls; + const parsed = JSON.parse((init as RequestInit).body as string); + expect(parsed.input.hitsSample).toEqual([ + { objectID: '1', name: 'Product 1' }, + ]); + }); + + it('sends the active filters alongside the query and hitsSample', async () => { + const widget = connectOnPageSuggestions(jest.fn())({ agentId: 'a' }); + const helper = algoliasearchHelper(createSearchClient(), '', { + disjunctiveFacets: ['brand'], + }); + helper.addDisjunctiveFacetRefinement('brand', 'Apple'); + helper.addNumericRefinement('price', '<=', 500); + const results = new algoliasearchHelper.SearchResults(helper.state, [ + createSingleSearchResponse({ + hits: [{ objectID: '1' }] as unknown as SearchResults['hits'], + query: 'laptop', + }), + ]); + widget.init!(createInitOptions({ helper })); + widget.render!(createRenderOptions({ helper, results })); + await flush(DEBOUNCE_WAIT); + + const [[, init]] = (global.fetch as jest.Mock).mock.calls; + const parsed = JSON.parse((init as RequestInit).body as string); + expect(parsed.input.query).toBe('laptop'); + expect(parsed.input.filters).toEqual([['brand:Apple'], ['price<=500']]); + }); + + it('OR-groups multiple values on the same disjunctive facet', async () => { + const widget = connectOnPageSuggestions(jest.fn())({ agentId: 'a' }); + const helper = algoliasearchHelper(createSearchClient(), '', { + disjunctiveFacets: ['brand'], + }); + helper.addDisjunctiveFacetRefinement('brand', 'Apple'); + helper.addDisjunctiveFacetRefinement('brand', 'Samsung'); + helper.addNumericRefinement('price', '<=', 500); + const results = new algoliasearchHelper.SearchResults(helper.state, [ + createSingleSearchResponse({ + hits: [{ objectID: '1' }] as unknown as SearchResults['hits'], + query: 'phone', + }), + ]); + widget.init!(createInitOptions({ helper })); + widget.render!(createRenderOptions({ helper, results })); + await flush(DEBOUNCE_WAIT); + + const [[, init]] = (global.fetch as jest.Mock).mock.calls; + const parsed = JSON.parse((init as RequestInit).body as string); + expect(parsed.input.filters).toEqual([ + ['brand:Apple', 'brand:Samsung'], + ['price<=500'], + ]); + }); + + it('omits filters when no refinements are active', async () => { + const widget = connectOnPageSuggestions(jest.fn())({ agentId: 'a' }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + widget.render!( + createRenderOptions({ helper, results: makeResults() }) + ); + await flush(DEBOUNCE_WAIT); + + const [[, init]] = (global.fetch as jest.Mock).mock.calls; + const parsed = JSON.parse((init as RequestInit).body as string); + expect(parsed.input).not.toHaveProperty('filters'); + }); + + it('when `context` is provided, sends only the context object and skips auto-extraction', async () => { + const transformHits = jest.fn(); + const widget = connectOnPageSuggestions(jest.fn())({ + agentId: 'a', + context: { focalProduct: { id: '42' } }, + transformHits, + }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + widget.render!( + createRenderOptions({ + helper, + results: makeResults({ + query: 'should-be-ignored', + hits: [{ objectID: 'h1' }], + }), + }) + ); + await flush(DEBOUNCE_WAIT); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(transformHits).not.toHaveBeenCalled(); + const [[, init]] = (global.fetch as jest.Mock).mock.calls; + const parsed = JSON.parse((init as RequestInit).body as string); + expect(parsed.input).not.toHaveProperty('query'); + expect(parsed.input).not.toHaveProperty('hitsSample'); + expect(parsed.input).not.toHaveProperty('pageType'); + expect(parsed.input.focalProduct).toEqual({ id: '42' }); + }); + + it('still fetches when `context` is provided and there are no hits', async () => { + const widget = connectOnPageSuggestions(jest.fn())({ + agentId: 'a', + context: { focalProduct: { id: '42' } }, + }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + widget.render!( + createRenderOptions({ helper, results: makeResults({ hits: [] }) }) + ); + await flush(DEBOUNCE_WAIT); + + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('exposes `refresh()` which bypasses the debounce and refetches', async () => { + const renderFn = jest.fn(); + const widget = connectOnPageSuggestions(renderFn)({ + agentId: 'a', + }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + widget.render!(createRenderOptions({ helper, results: makeResults() })); + // Without refresh(), the debounce would block any fetch right now. + expect(global.fetch).not.toHaveBeenCalled(); + + const lastCall = renderFn.mock.calls[renderFn.mock.calls.length - 1][0]; + lastCall.refresh(); + await flush(0); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('lets transport.prepareSendMessagesRequest mutate the body', async () => { + const prepare = jest.fn((body: Record) => ({ + body: { ...body, injected: true }, + })); + const widget = connectOnPageSuggestions(jest.fn())({ + transport: { + api: 'https://example.test/agents', + headers: { 'x-foo': 'bar' }, + prepareSendMessagesRequest: prepare, + }, + }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + widget.render!(createRenderOptions({ helper, results: makeResults() })); + await flush(DEBOUNCE_WAIT); + + expect(prepare).toHaveBeenCalledTimes(1); + const [[url, init]] = (global.fetch as jest.Mock).mock.calls; + expect(url).toBe('https://example.test/agents?stream=true'); + const parsed = JSON.parse((init as RequestInit).body as string); + expect(parsed.injected).toBe(true); + expect((init as RequestInit).headers).toMatchObject({ 'x-foo': 'bar' }); + }); + }); + + describe('streaming', () => { + it('requests the streaming endpoint with `stream=true`', async () => { + global.fetch = jest.fn(() => + Promise.resolve(sseResponse([taskOutputEvent(['a', 'b'])])) + ) as unknown as typeof fetch; + + const widget = connectOnPageSuggestions(jest.fn())({ agentId: 'a' }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + widget.render!(createRenderOptions({ helper, results: makeResults() })); + await flush(DEBOUNCE_WAIT); + await flush(10); + + const [[url]] = (global.fetch as jest.Mock).mock.calls; + expect(url).toContain('stream=true'); + }); + + it('renders each accumulated snapshot and resolves with the final list', async () => { + global.fetch = jest.fn(() => + Promise.resolve( + sseResponse([ + JSON.stringify({ type: 'start' }), + // First snapshot is an empty string — filtered out, still loading. + taskOutputEvent(['']), + taskOutputEvent(['What']), + taskOutputEvent(['What phones?']), + taskOutputEvent(['What phones?', 'Any deals?']), + JSON.stringify({ type: 'finish' }), + '[DONE]', + ]) + ) + ) as unknown as typeof fetch; + + const renderFn = jest.fn(); + const widget = connectOnPageSuggestions(renderFn)({ agentId: 'a' }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + widget.render!(createRenderOptions({ helper, results: makeResults() })); + await flush(DEBOUNCE_WAIT); + await flush(10); + + // An intermediate render observed the growing (partial) list. + const snapshots = renderFn.mock.calls.map((c) => c[0].suggestions); + expect(snapshots).toContainEqual(['What']); + expect(snapshots).toContainEqual(['What phones?']); + + // The final render carries the complete list and is no longer loading. + const last = renderFn.mock.calls[renderFn.mock.calls.length - 1][0]; + expect(last.suggestions).toEqual(['What phones?', 'Any deals?']); + expect(last.isLoading).toBe(false); + }); + + it('repairs a raw partial-JSON output payload while streaming', async () => { + // Here `data` is the raw (still-incomplete) JSON text the model emits, + // not a pre-parsed object — exercising the shared repair logic. + global.fetch = jest.fn(() => + Promise.resolve( + sseResponse([ + JSON.stringify({ + type: 'data-task-output', + data: '{"output":{"suggestions":["Wh', + }), + JSON.stringify({ + type: 'data-task-output', + data: '{"output":{"suggestions":["What?"]}}', + }), + '[DONE]', + ]) + ) + ) as unknown as typeof fetch; + + const renderFn = jest.fn(); + const widget = connectOnPageSuggestions(renderFn)({ agentId: 'a' }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + widget.render!(createRenderOptions({ helper, results: makeResults() })); + await flush(DEBOUNCE_WAIT); + await flush(10); + + const snapshots = renderFn.mock.calls.map((c) => c[0].suggestions); + // The unterminated first payload is repaired into a usable partial. + expect(snapshots).toContainEqual(['Wh']); + const last = renderFn.mock.calls[renderFn.mock.calls.length - 1][0]; + expect(last.suggestions).toEqual(['What?']); + }); + + it('falls back to a buffered JSON body when the response is not a stream', async () => { + // A custom transport / non-streaming backend ignores `stream=true` and + // returns a plain JSON body; the connector must still parse it. + global.fetch = jest.fn(() => + Promise.resolve(jsonResponse({ output: { suggestions: ['a', 'b'] } })) + ) as unknown as typeof fetch; + + const renderFn = jest.fn(); + const widget = connectOnPageSuggestions(renderFn)({ agentId: 'a' }); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + widget.render!(createRenderOptions({ helper, results: makeResults() })); + await flush(DEBOUNCE_WAIT); + await flush(10); + + const last = renderFn.mock.calls[renderFn.mock.calls.length - 1][0]; + expect(last.suggestions).toEqual(['a', 'b']); + expect(last.isLoading).toBe(false); + }); + }); + + describe('handoff', () => { + it('onSuggestionClick calls sendMessage on the index chat render state with page-suggestions referer', async () => { + const sendMessage = jest.fn(); + const setOpen = jest.fn(); + const search = createInstantSearch(); + search.renderState = { + [search.helper!.state.index]: { + chat: { + sendMessage, + setOpen, + status: 'ready', + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const renderFn = jest.fn(); + const widget = connectOnPageSuggestions(renderFn)({ + agentId: 'a', + }); + widget.init!( + createInitOptions({ + instantSearchInstance: search, + helper: search.helper!, + }) + ); + widget.render!( + createRenderOptions({ + instantSearchInstance: search, + helper: search.helper!, + results: makeResults(), + }) + ); + await flush(DEBOUNCE_WAIT); + await flush(0); + + const lastCall = renderFn.mock.calls[renderFn.mock.calls.length - 1][0]; + lastCall.onSuggestionClick('try this'); + + expect(setOpen).toHaveBeenCalledWith(true); + // The raw suggestion is wrapped in a grounding prompt, and the page + // context is attached as a flat `turnContext` (hitsSample serialized). + expect(sendMessage).toHaveBeenCalledWith( + { + text: expect.stringContaining('Suggestion: try this'), + metadata: { + turnContext: { + query: 'q', + hitsSample: JSON.stringify([ + { objectID: '1' }, + { objectID: '2' }, + ]), + }, + }, + }, + { headers: { 'x-algolia-referer': 'on-page-suggestions' } } + ); + }); + + it('sendToChat returns true when a chat widget is mounted', async () => { + const sendMessage = jest.fn(); + const setOpen = jest.fn(); + const search = createInstantSearch(); + search.renderState = { + [search.helper!.state.index]: { + chat: { sendMessage, setOpen, status: 'ready' }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const renderFn = jest.fn(); + const widget = connectOnPageSuggestions(renderFn)({ + agentId: 'a', + }); + widget.init!( + createInitOptions({ + instantSearchInstance: search, + helper: search.helper!, + }) + ); + const initCall = renderFn.mock.calls[0][0]; + expect(initCall.sendToChat('hello')).toBe(true); + expect(sendMessage).toHaveBeenCalled(); + }); + + it('sendToChat returns false when no chat widget is in render state', async () => { + const search = createInstantSearch(); + // No chat in renderState. + search.renderState = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const renderFn = jest.fn(); + const widget = connectOnPageSuggestions(renderFn)({ + agentId: 'a', + }); + widget.init!( + createInitOptions({ + instantSearchInstance: search, + helper: search.helper!, + }) + ); + const initCall = renderFn.mock.calls[0][0]; + expect(initCall.sendToChat('hello')).toBe(false); + }); + + it('isChatBusy is true while the chat is mid-stream', async () => { + const search = createInstantSearch(); + search.renderState = { + [search.helper!.state.index]: { + chat: { + sendMessage: jest.fn(), + status: 'streaming', + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const renderFn = jest.fn(); + const widget = connectOnPageSuggestions(renderFn)({ + agentId: 'a', + }); + widget.init!( + createInitOptions({ + instantSearchInstance: search, + helper: search.helper!, + }) + ); + widget.render!( + createRenderOptions({ + instantSearchInstance: search, + helper: search.helper!, + results: makeResults(), + }) + ); + + const lastCall = renderFn.mock.calls[renderFn.mock.calls.length - 1][0]; + expect(lastCall.isChatBusy).toBe(true); + // dispose() to clear the debounce timer scheduled by render() so it + // doesn't fire during the next test and pollute fetch call counts. + widget.dispose!(createDisposeOptions({ helper: search.helper! })); + }); + }); + + describe('SSR + hydration', () => { + const originalWindow = globalThis.window; + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete (globalThis as { window?: Window }).window; + }); + afterEach(() => { + (globalThis as { window?: Window }).window = originalWindow; + }); + + it('registers a server-wait promise during init', () => { + const search = createInstantSearch(); + const registerSpy = jest.spyOn(search, 'registerServerWait'); + const widget = connectOnPageSuggestions(jest.fn())({ + agentId: 'a', + ssrTimeout: 30, + }); + widget.init!( + createInitOptions({ + instantSearchInstance: search, + helper: search.helper!, + }) + ); + expect(registerSpy).toHaveBeenCalledTimes(1); + }); + + it('writes the snapshot when the fetch finishes before the timeout', async () => { + const search = createInstantSearch(); + search.mainHelper!.derive((state) => state); + const widget = connectOnPageSuggestions(jest.fn())({ + agentId: 'a', + ssrTimeout: 500, + }); + widget.init!( + createInitOptions({ + instantSearchInstance: search, + helper: search.helper!, + }) + ); + + search.mainHelper!.derivedHelpers[0].emit('result', { + results: makeResults(), + }); + await flush(20); + + expect(search._initialChatStates).not.toBeNull(); + expect( + (search._initialChatStates as Record) + .onPageSuggestions + ).toEqual({ suggestions: ['a', 'b', 'c'] }); + }); + + it('resolves on the SSR timeout and does not write a snapshot', async () => { + global.fetch = jest.fn( + () => new Promise(() => {}) + ) as unknown as typeof fetch; + + const search = createInstantSearch(); + search.mainHelper!.derive((state) => state); + const widget = connectOnPageSuggestions(jest.fn())({ + agentId: 'a', + ssrTimeout: 20, + }); + widget.init!( + createInitOptions({ + instantSearchInstance: search, + helper: search.helper!, + }) + ); + + search.mainHelper!.derivedHelpers[0].emit('result', { + results: makeResults(), + }); + await flush(50); + + expect(search._initialChatStates).toBeNull(); + }); + + it('hydrates from _initialChatStates on client init and skips the first refetch', async () => { + (globalThis as { window?: Window }).window = originalWindow; + + const search = createInstantSearch(); + search._initialChatStates = { + onPageSuggestions: { suggestions: ['x', 'y'] }, + }; + + const renderFn = jest.fn(); + const widget = connectOnPageSuggestions(renderFn)({ + agentId: 'a', + }); + widget.init!( + createInitOptions({ + instantSearchInstance: search, + helper: search.helper!, + }) + ); + + const initCall = renderFn.mock.calls[0][0]; + expect(initCall.suggestions).toEqual(['x', 'y']); + + widget.render!( + createRenderOptions({ + instantSearchInstance: search, + helper: search.helper!, + results: makeResults({ query: '' }), + }) + ); + await flush(DEBOUNCE_WAIT); + expect(global.fetch).not.toHaveBeenCalled(); + + // A real state change after hydration still triggers a refetch. + widget.render!( + createRenderOptions({ + instantSearchInstance: search, + helper: search.helper!, + results: makeResults({ query: 'new' }), + }) + ); + await flush(DEBOUNCE_WAIT); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/instantsearch.js/src/connectors/on-page-suggestions/connectOnPageSuggestions.ts b/packages/instantsearch.js/src/connectors/on-page-suggestions/connectOnPageSuggestions.ts new file mode 100644 index 00000000000..ae73eb5bb9f --- /dev/null +++ b/packages/instantsearch.js/src/connectors/on-page-suggestions/connectOnPageSuggestions.ts @@ -0,0 +1,631 @@ +import { isChatBusy as isChatStreaming, openChat } from '../../lib/chat'; +import { + checkRendering, + createDocumentationMessageGenerator, + getRefinements, + noop, + safelyRunOnBrowser, + warning, +} from '../../lib/utils'; +import connectStructuredOutput from '../structured-output/connectStructuredOutput'; + +import type { TaskTransport } from '../../lib/tasks'; +import type { + Connector, + DisposeOptions, + Hit, + IndexRenderState, + InitOptions, + InstantSearch, + RenderOptions, + WidgetRenderState, +} from '../../types'; +import type { ChatRenderState } from '../chat/connectChat'; +import type { + StructuredOutputConnectorParams, + StructuredOutputRenderState, +} from '../structured-output/connectStructuredOutput'; +import type { SearchResults } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'on-page-suggestions', + connector: true, +}); + +const RENDER_STATE_KEY = 'onPageSuggestions' as const; +const CHAT_RENDER_STATE_KEY = 'chat' as const; +const DEBOUNCE_MS = 300; +const DEFAULT_SSR_TIMEOUT_MS = 150; + +function parseSuggestions(data: unknown): string[] { + const suggestions = (data as { suggestions?: unknown[] } | null | undefined) + ?.suggestions; + + if (!Array.isArray(suggestions)) { + return []; + } + + return suggestions.filter( + (s: unknown): s is string => typeof s === 'string' && s.trim().length > 0 + ); +} + +function buildSuggestionMessage(suggestion: string): string { + return `The user clicked this on-page suggestion. Use the current page context first, then search only if needed.\n\nSuggestion: ${suggestion}`; +} + +type InstantSearchWithChatStates = InstantSearch & { + _initialChatStates: Record | null; +}; + +type OnPageSuggestionsSnapshot = { + suggestions: string[]; +}; + +function isServerRendering(): boolean { + return safelyRunOnBrowser(() => false, { fallback: () => true }); +} + +// Per-InstantSearch SSR wait cache: two-pass SSR (e.g. React) renders twice on +// the server, so reuse the in-flight fetch across passes instead of firing twice. +const serverWaitRegistry = new WeakMap>(); + +/** Custom transport for the task request. Alias of the generic `TaskTransport`, kept for API stability. */ +export type OnPageSuggestionsTransport = TaskTransport; + +/** Metadata passed to `transformItems`. */ +export type OnPageSuggestionsTransformItemsMetadata = { + query: string; + results: SearchResults | null; +}; + +/** Custom `transformItems` signature for `connectOnPageSuggestions`. */ +export type OnPageSuggestionsTransformItems = ( + items: string[], + metadata: OnPageSuggestionsTransformItemsMetadata +) => string[]; + +/** Receives every hit and returns the subset (or reshaped objects) forwarded to the agent as context. */ +export type OnPageSuggestionsTransformHits = (hits: Hit[]) => unknown[]; + +export type OnPageSuggestionsRenderState = { + /** Backend-generated prompt strings rendered as clickable pills. */ + suggestions: string[]; + /** Whether suggestions are currently being fetched. */ + isLoading: boolean; + /** Default click handler, calling `sendToChat(prompt)`. Override via the `onSuggestionClick` prop. */ + onSuggestionClick: (prompt: string) => void; + /** Hands the prompt to the `connectChat` widget on the same index. `true` if dispatched, else `false`. */ + sendToChat: (prompt: string) => boolean; + /** Imperative refetch that bypasses the debounce. No-op with no results or a fetch in-flight. */ + refresh: () => void; + /** + * Whether the chat widget is currently busy (mid-stream) — surface as `disabled` on the pills. + * Optimistically `false` before the chat mounts and when no chat is present. + */ + isChatBusy: boolean; +}; + +/** Either `agentId` or a custom `transport` is required. */ +export type OnPageSuggestionsSource = + | { + /** ID of the agent configured in the Algolia dashboard. */ + agentId: string; + transport?: never; + } + | { + /** Custom transport. When set, `agentId` and client credentials are ignored. */ + transport: OnPageSuggestionsTransport; + agentId?: never; + }; + +export type OnPageSuggestionsConnectorParams = OnPageSuggestionsSource & { + /** + * Agent Studio configuration to invoke, sent as the `task` field. + * @default 'on_page_suggestions' + */ + configurationId?: string; + /** Transforms hits before use as context (default: first 5, metadata stripped). Ignored with `context`. */ + transformHits?: OnPageSuggestionsTransformHits; + /** Explicit context, replacing the auto-extracted `{ query, filters, hitsSample }`. Object or per-fetch function. */ + context?: Record | (() => Record); + /** Transforms the parsed suggestions before exposing them. Receives `{ query, results }`. */ + transformItems?: OnPageSuggestionsTransformItems; + /** + * Max ms SSR waits for the agent before flushing; on timeout the client refetches after hydration. + * @default 150 + */ + ssrTimeout?: number; +}; + +export type OnPageSuggestionsWidgetDescription = { + $$type: 'ais.onPageSuggestions'; + renderState: OnPageSuggestionsRenderState; + indexRenderState: { + onPageSuggestions: WidgetRenderState< + OnPageSuggestionsRenderState, + OnPageSuggestionsConnectorParams + >; + }; +}; + +export type OnPageSuggestionsConnector = Connector< + OnPageSuggestionsWidgetDescription, + OnPageSuggestionsConnectorParams +>; + +const INTERNAL_HIT_KEYS = [ + '_highlightResult', + '_snippetResult', + '_rankingInfo', + '_distinctSeqID', + '__position', + '__queryID', +] as const; + +function stripInternalHitMetadata(hit: Hit): Record { + const clean: Record = { ...hit }; + INTERNAL_HIT_KEYS.forEach((key) => { + delete clean[key]; + }); + return clean; +} + +const DEFAULT_TRANSFORM_HITS: OnPageSuggestionsTransformHits = (hits) => + hits.slice(0, 5).map(stripInternalHitMetadata); + +function buildFilters(results: SearchResults): string[][] | undefined { + const state = results._state; + if (!state) { + return undefined; + } + + const groups: string[][] = []; + const disjunctiveGroups: Record = {}; + + getRefinements(results, state).forEach((refinement) => { + if (refinement.type === 'numeric') { + groups.push([ + `${refinement.attribute}${refinement.operator}${refinement.numericValue}`, + ]); + return; + } + + const value = + refinement.type === 'exclude' + ? `${refinement.attribute}:-${refinement.name}` + : `${refinement.attribute}:${refinement.name}`; + + if (refinement.type === 'disjunctive') { + const group = disjunctiveGroups[refinement.attribute]; + if (group) { + group.push(value); + } else { + const newGroup = [value]; + disjunctiveGroups[refinement.attribute] = newGroup; + groups.push(newGroup); + } + return; + } + + groups.push([value]); + }); + + return groups.length > 0 ? groups : undefined; +} + +const connectOnPageSuggestions: OnPageSuggestionsConnector = + function connectOnPageSuggestions(renderFn, unmountFn = noop) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + warning( + false, + 'OnPageSuggestions is not yet stable and will change in the future.' + ); + + const { + agentId, + configurationId, + transformHits = DEFAULT_TRANSFORM_HITS, + context, + transformItems = (items) => items, + transport, + ssrTimeout = DEFAULT_SSR_TIMEOUT_MS, + } = widgetParams; + + if (!agentId && !transport) { + throw new Error( + withUsage( + 'The `agentId` option is required unless a custom `transport` is provided.' + ) + ); + } + + let soState: StructuredOutputRenderState | undefined; + let suggestions: string[] = []; + let isLoading = false; + let debounceTimer: ReturnType | undefined; + let lastStateSignature: string | null = null; + let latestRenderOptions: RenderOptions | null = null; + // Set when SSR seeded suggestions; first post-hydration `render()` skips + // its fetch (it just seeds the state signature) so the client doesn't + // immediately overwrite the server snapshot. + let hydratedFromSnapshot = false; + let hydrationAttempted = false; + // Set in `dispose()`. A debounced or in-flight `fetch()` can resolve after + // the widget is unmounted; this guard stops those late callbacks from + // calling `renderFn` into a torn-down container. + let disposed = false; + + const hydrateFromSnapshot = ( + instantSearchInstance: InstantSearch + ): void => { + if (hydrationAttempted) return; + hydrationAttempted = true; + const states = (instantSearchInstance as InstantSearchWithChatStates) + ._initialChatStates; + const snapshot = states?.[RENDER_STATE_KEY] as + | OnPageSuggestionsSnapshot + | undefined; + if (snapshot && Array.isArray(snapshot.suggestions)) { + suggestions = snapshot.suggestions; + hydratedFromSnapshot = true; + } + }; + + const getStateSignature = (results: SearchResults): string => { + const query = results.query || ''; + const state = results._state; + const refinements = state + ? JSON.stringify(state.facetsRefinements) + + JSON.stringify(state.disjunctiveFacetsRefinements) + + JSON.stringify(state.hierarchicalFacetsRefinements) + + JSON.stringify(state.numericRefinements) + + JSON.stringify(state.tagRefinements) + : ''; + return `${query}|${refinements}`; + }; + + const getChatRenderState = ( + renderOptions: InitOptions | RenderOptions + ): Partial | undefined => { + const { instantSearchInstance, parent } = renderOptions; + const indexId = parent ? parent.getIndexId() : ''; + if (!indexId || !instantSearchInstance.renderState?.[indexId]) { + return undefined; + } + return instantSearchInstance.renderState[indexId][ + CHAT_RENDER_STATE_KEY + ] as Partial | undefined; + }; + + const sendToChat = + (renderOptions: InitOptions | RenderOptions) => + (prompt: string): boolean => { + const chatRenderState = getChatRenderState(renderOptions); + if (!chatRenderState || !chatRenderState.sendMessage) { + if (__DEV__) { + warning( + false, + `No chat widget found in render state. Make sure a \`connectChat\` widget is mounted on the same index, or pass an \`onSuggestionClick\` prop to handle the click yourself.` + ); + } + return false; + } + const results = + latestRenderOptions?.results ?? + ('results' in renderOptions ? renderOptions.results : null) ?? + null; + openChat(chatRenderState, { + message: buildSuggestionMessage(prompt), + referer: 'on-page-suggestions', + turnContext: buildTurnContext(results), + }); + return true; + }; + + const resolvePageContext = ( + results: SearchResults | null + ): Record | undefined => { + const resolvedContext = + typeof context === 'function' ? context() : context; + // Explicit context replaces auto-extraction; otherwise derive it from + // the current search state. The task's server-owned instructions decide + // how to interpret the shape — the client doesn't label it. + if (resolvedContext) { + return { ...resolvedContext }; + } + if (!results) { + return undefined; + } + const filters = buildFilters(results); + return { + query: results.query || '', + ...(filters ? { filters } : {}), + hitsSample: transformHits(results.hits as Hit[]), + }; + }; + + const buildInput = (results: SearchResults): Record => + resolvePageContext(results) ?? {}; + + // The same page context, flattened for the chat handoff: `turnContext` is + // a flat `Record` per the Agent Studio contract, so + // non-string values (e.g. `hitsSample`) are serialized. + const buildTurnContext = ( + results: SearchResults | null + ): Record | undefined => { + const pageContext = resolvePageContext(results); + if (!pageContext) { + return undefined; + } + return Object.fromEntries( + Object.entries(pageContext).map(([key, value]) => [ + key, + typeof value === 'string' ? value : JSON.stringify(value), + ]) + ); + }; + + const renderOutward = (renderOptions: InitOptions | RenderOptions) => { + if (disposed) return; + renderFn( + { + ...getWidgetRenderState(renderOptions), + instantSearchInstance: renderOptions.instantSearchInstance, + }, + false + ); + }; + + const fetchAndRender = ( + results: SearchResults, + renderOptions: RenderOptions + ) => { + if (disposed || !soState) return; + const hasContext = context !== undefined; + if (!hasContext && !results?.hits?.length) { + suggestions = []; + isLoading = false; + renderOutward(renderOptions); + return; + } + + soState.submit(buildInput(results)); + }; + + const refresh = () => { + if (isLoading) return; + const results = latestRenderOptions?.results; + if (!results || !latestRenderOptions) return; + clearTimeout(debounceTimer); + lastStateSignature = getStateSignature(results); + fetchAndRender(results, latestRenderOptions); + }; + + const buildServerWait = ( + instantSearchInstance: InstantSearch + ): Promise => { + return new Promise((resolve) => { + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + resolve(); + }; + + const timer = setTimeout(settle, ssrTimeout); + + const derivedHelper = + instantSearchInstance.mainHelper?.derivedHelpers?.[0]; + if (!derivedHelper) { + clearTimeout(timer); + settle(); + return; + } + + const onResult = (event: { results?: SearchResults }) => { + derivedHelper.removeListener('result', onResult); + // The SSR timeout may have already resolved this wait; a late + // `result` event must not fire another agent request. + if (settled || !soState) return; + const results = event?.results; + const hasContext = context !== undefined; + if (!results || (!hasContext && !results.hits?.length)) { + clearTimeout(timer); + settle(); + return; + } + soState + .submit(buildInput(results)) + .then((output) => { + // Skip seeding on error so the client refetches on hydration + // instead of hydrating an empty snapshot that suppresses it. + if (settled || soState?.error) return; + const target = + instantSearchInstance as InstantSearchWithChatStates; + if (!target._initialChatStates) { + target._initialChatStates = {}; + } + target._initialChatStates[RENDER_STATE_KEY] = { + suggestions: parseSuggestions(output), + } satisfies OnPageSuggestionsSnapshot; + }) + .finally(() => { + clearTimeout(timer); + settle(); + }); + }; + derivedHelper.on('result', onResult); + }); + }; + + const getWidgetRenderState = ( + renderOptions: InitOptions | RenderOptions + ): Omit & { + widgetParams: OnPageSuggestionsConnectorParams; + } => { + hydrateFromSnapshot(renderOptions.instantSearchInstance); + + const results = + 'results' in renderOptions ? renderOptions.results : undefined; + const transformed = transformItems(suggestions, { + query: results?.query || '', + results: results || null, + }); + + const chatRenderState = getChatRenderState(renderOptions); + + const isChatBusy = chatRenderState + ? !chatRenderState.sendMessage || isChatStreaming(chatRenderState) + : false; + + const send = sendToChat(renderOptions); + + return { + suggestions: transformed, + isLoading, + onSuggestionClick: send, + sendToChat: send, + refresh, + isChatBusy, + widgetParams, + }; + }; + + // Mirrors each inner render (submit start → skeleton, stream partials, + // resolve/error) into this widget's state and re-renders on the client. + // SSR seeds via the awaited `submit` in `buildServerWait`, so outward + // renders are skipped there. + const handleInnerRender = (renderState: StructuredOutputRenderState) => { + soState = renderState; + // Preserve SSR-hydrated pills until the first generation begins: only + // adopt the inner output once a request is loading or has produced one. + if (renderState.isLoading || renderState.output !== undefined) { + suggestions = parseSuggestions(renderState.output); + } + isLoading = renderState.isLoading; + if (isServerRendering() || !latestRenderOptions) return; + renderOutward(latestRenderOptions); + }; + + const structuredOutputWidget = connectStructuredOutput( + handleInnerRender, + noop + )({ + ...(transport ? { transport } : { agentId }), + task: configurationId ?? 'on_page_suggestions', + stream: true, + } as StructuredOutputConnectorParams); + + return { + $$type: 'ais.onPageSuggestions', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + structuredOutputWidget.init!(initOptions); + + hydrateFromSnapshot(instantSearchInstance); + + if (isServerRendering()) { + let wait = serverWaitRegistry.get(instantSearchInstance); + if (!wait) { + wait = buildServerWait(instantSearchInstance); + serverWaitRegistry.set(instantSearchInstance, wait); + } + instantSearchInstance.registerServerWait(wait); + } + + renderFn( + { + ...getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { results, instantSearchInstance } = renderOptions; + + latestRenderOptions = renderOptions; + + if (!results) { + renderFn( + { + ...getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + return; + } + + const stateSignature = getStateSignature(results); + + // First post-hydration render: seed the signature so future state + // changes still trigger a refetch, but skip the immediate one so we + // don't overwrite the server-seeded suggestions. + if (hydratedFromSnapshot) { + hydratedFromSnapshot = false; + lastStateSignature = stateSignature; + renderFn( + { + ...getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + return; + } + + if (stateSignature !== lastStateSignature) { + lastStateSignature = stateSignature; + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + if (latestRenderOptions?.results) { + fetchAndRender( + latestRenderOptions.results, + latestRenderOptions + ); + } + }, DEBOUNCE_MS); + } + + renderFn( + { + ...getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + dispose(disposeOptions: DisposeOptions) { + disposed = true; + clearTimeout(debounceTimer); + structuredOutputWidget.dispose!(disposeOptions); + unmountFn(); + }, + + getRenderState( + renderState, + renderOptions + ): IndexRenderState & + OnPageSuggestionsWidgetDescription['indexRenderState'] { + return { + ...renderState, + [RENDER_STATE_KEY]: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState(renderOptions) { + return getWidgetRenderState(renderOptions); + }, + }; + }; + }; + +export default connectOnPageSuggestions; diff --git a/packages/instantsearch.js/src/connectors/structured-output/__tests__/connectStructuredOutput-test.ts b/packages/instantsearch.js/src/connectors/structured-output/__tests__/connectStructuredOutput-test.ts new file mode 100644 index 00000000000..9b093cdeffe --- /dev/null +++ b/packages/instantsearch.js/src/connectors/structured-output/__tests__/connectStructuredOutput-test.ts @@ -0,0 +1,282 @@ +/** + * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts + */ + +import { createSearchClient } from '@instantsearch/mocks'; +import algoliasearchHelper from 'algoliasearch-helper'; + +import { createInitOptions } from '../../../../test/createWidget'; +import connectStructuredOutput from '../connectStructuredOutput'; + +import type { StructuredOutputConnectorParams } from '../connectStructuredOutput'; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +// Builds a fake `text/event-stream` response whose body replays `events` as SSE +// `data:` lines. Kept as a plain object (not a real `Response`) so the test +// doesn't depend on `Response.body` support in the jsdom environment. +function sseResponse(events: string[]): Response { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + events.forEach((event) => { + controller.enqueue(encoder.encode(`data: ${event}\n\n`)); + }); + controller.close(); + }, + }); + return { + ok: true, + status: 200, + headers: { + get: (name: string) => + name.toLowerCase() === 'content-type' ? 'text/event-stream' : null, + }, + body, + } as unknown as Response; +} + +function outputEvent(output: unknown): string { + return JSON.stringify({ type: 'data-task-output', data: { output } }); +} + +function flush(ms = 0) { + return new Promise((r) => setTimeout(r, ms)); +} + +function init(params: StructuredOutputConnectorParams) { + const renderFn = jest.fn(); + const widget = connectStructuredOutput(renderFn)(params); + const helper = algoliasearchHelper(createSearchClient(), ''); + widget.init!(createInitOptions({ helper })); + const lastState = () => + renderFn.mock.calls[renderFn.mock.calls.length - 1][0]; + return { renderFn, widget, lastState }; +} + +describe('connectStructuredOutput', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(() => + Promise.resolve(jsonResponse({ output: { suggestions: ['a'] } })) + ) as unknown as typeof fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + describe('Usage', () => { + it('throws without a render function', () => { + expect(() => { + // @ts-expect-error testing invalid input + connectStructuredOutput()({ agentId: 'a', task: 't' }); + }).toThrowError(/render function is not valid/); + }); + + it('throws when neither agentId nor transport is provided', () => { + const makeWidget = connectStructuredOutput(jest.fn()); + expect(() => + makeWidget({ task: 't' } as StructuredOutputConnectorParams) + ).toThrowError(/agentId.*transport/); + }); + + it('throws when task is missing', () => { + const makeWidget = connectStructuredOutput(jest.fn()); + expect(() => + makeWidget({ agentId: 'a' } as StructuredOutputConnectorParams) + ).toThrowError(/task/); + }); + + it('returns the widget descriptor', () => { + const widget = connectStructuredOutput(jest.fn())({ + agentId: 'a', + task: 't', + }); + expect(widget).toEqual( + expect.objectContaining({ + $$type: 'ais.structuredOutput', + init: expect.any(Function), + render: expect.any(Function), + dispose: expect.any(Function), + }) + ); + }); + }); + + describe('render state', () => { + it('exposes an initial idle state before any submit', () => { + const { lastState } = init({ agentId: 'a', task: 't' }); + expect(lastState()).toEqual( + expect.objectContaining({ + output: undefined, + isLoading: false, + error: undefined, + submit: expect.any(Function), + }) + ); + }); + + it('sets isLoading while a submit is in flight and clears it on resolve', async () => { + const { renderFn, lastState } = init({ agentId: 'a', task: 't' }); + + lastState().submit({ foo: 'bar' }); + // Synchronously after submit, the loading state is rendered. + expect(lastState().isLoading).toBe(true); + expect(lastState().error).toBeUndefined(); + + await flush(0); + expect(lastState().isLoading).toBe(false); + // Output is the unwrapped envelope (`{ output }` stripped). + expect(lastState().output).toEqual({ suggestions: ['a'] }); + expect(renderFn).toHaveBeenCalled(); + }); + + it('sends the variables as the task `input` and targets the tasks endpoint', async () => { + const { lastState } = init({ agentId: 'my-agent', task: 'my_task' }); + + lastState().submit({ query: 'shoes' }); + await flush(0); + + const [[url, request]] = (global.fetch as jest.Mock).mock.calls; + expect(url).toContain('/agents/my-agent/'); + expect(url).toContain('stream=true'); + expect(JSON.parse(request.body)).toEqual({ + task: 'my_task', + input: { query: 'shoes' }, + }); + }); + + it('surfaces streamed partial outputs through the render state', async () => { + global.fetch = jest.fn(() => + Promise.resolve( + sseResponse([ + JSON.stringify({ type: 'start' }), + outputEvent({ value: 'a' }), + outputEvent({ value: 'ab' }), + JSON.stringify({ type: 'finish' }), + '[DONE]', + ]) + ) + ) as unknown as typeof fetch; + + const { renderFn, lastState } = init({ agentId: 'a', task: 't' }); + lastState().submit({}); + await flush(0); + + const outputs = renderFn.mock.calls + .map((call) => call[0].output) + .filter(Boolean); + // Intermediate snapshots were surfaced as they streamed in. + expect(outputs).toContainEqual({ value: 'a' }); + expect(lastState().output).toEqual({ value: 'ab' }); + expect(lastState().isLoading).toBe(false); + }); + + it('does not stream partials when `stream` is false', async () => { + const onDataSpy = jest.fn(); + global.fetch = jest.fn(() => { + onDataSpy(); + return Promise.resolve(jsonResponse({ output: { done: true } })); + }) as unknown as typeof fetch; + + const { lastState } = init({ agentId: 'a', task: 't', stream: false }); + lastState().submit({}); + await flush(0); + + const [[url]] = (global.fetch as jest.Mock).mock.calls; + expect(url).not.toContain('stream=true'); + expect(lastState().output).toEqual({ done: true }); + }); + + it('surfaces the error and clears output on a failed submit', async () => { + global.fetch = jest.fn(() => + Promise.resolve(new Response('nope', { status: 500 })) + ) as unknown as typeof fetch; + + const { lastState } = init({ agentId: 'a', task: 't' }); + lastState().submit({}); + await flush(0); + + expect(lastState().output).toBeUndefined(); + expect(lastState().error).toBeInstanceOf(Error); + expect(lastState().error?.message).toMatch(/HTTP error 500/); + expect(lastState().isLoading).toBe(false); + }); + + it('resolves credentials from a custom transport', async () => { + const { lastState } = init({ + transport: { + api: 'https://custom.test/tasks', + headers: { 'x-custom': '1' }, + }, + task: 't', + }); + + lastState().submit({}); + await flush(0); + + const [[url, request]] = (global.fetch as jest.Mock).mock.calls; + expect(url).toContain('https://custom.test/tasks'); + expect(request.headers).toMatchObject({ 'x-custom': '1' }); + }); + }); + + describe('request sequencing', () => { + it('ignores a stale response when a newer submit is in flight', async () => { + const resolvers: Array<(value: Response) => void> = []; + global.fetch = jest.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }) + ) as unknown as typeof fetch; + + const { lastState } = init({ agentId: 'a', task: 't' }); + + // Fire two overlapping submits; the first one resolves *last*. + lastState().submit({ n: 1 }); + lastState().submit({ n: 2 }); + + // Resolve the newer request (2) first, then the stale one (1). + resolvers[1](jsonResponse({ output: { winner: 2 } })); + await flush(0); + resolvers[0](jsonResponse({ output: { winner: 1 } })); + await flush(0); + + // The stale (first) response must not overwrite the latest output. + expect(lastState().output).toEqual({ winner: 2 }); + expect(lastState().isLoading).toBe(false); + }); + }); + + describe('dispose', () => { + it('does not render after dispose when a late submit resolves', async () => { + let resolveFetch: (value: Response) => void = () => {}; + global.fetch = jest.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ) as unknown as typeof fetch; + + const { renderFn, widget, lastState } = init({ agentId: 'a', task: 't' }); + lastState().submit({}); + + const callsBeforeDispose = renderFn.mock.calls.length; + widget.dispose!({} as any); + + resolveFetch(jsonResponse({ output: { late: true } })); + await flush(0); + + // The post-dispose resolution must not trigger another render. + expect(renderFn.mock.calls.length).toBe(callsBeforeDispose); + }); + }); +}); diff --git a/packages/instantsearch.js/src/connectors/structured-output/connectStructuredOutput.ts b/packages/instantsearch.js/src/connectors/structured-output/connectStructuredOutput.ts new file mode 100644 index 00000000000..989db114915 --- /dev/null +++ b/packages/instantsearch.js/src/connectors/structured-output/connectStructuredOutput.ts @@ -0,0 +1,215 @@ +import { createStructuredOutputRunner, resolveEndpoint } from '../../lib/tasks'; +import { + checkRendering, + createDocumentationMessageGenerator, + getAlgoliaAgent, + getAppIdAndApiKey, + noop, +} from '../../lib/utils'; + +import type { StructuredOutputRunner, TaskTransport } from '../../lib/tasks'; +import type { Connector } from '../../types'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'structured-output', + connector: true, +}); + +export type StructuredOutputRenderState = { + /** + * The latest (unwrapped) structured output returned by the task, or + * `undefined` before the first `submit` resolves. + */ + output: TOutput | undefined; + /** + * Whether a `submit` request is currently in flight. + */ + isLoading: boolean; + /** + * The error thrown by the last `submit`, or `undefined` when the last request + * succeeded (or none has run yet). + */ + error: Error | undefined; + /** + * Sends `variables` as the task `input` and updates the render state with the + * result. Clears the previous `output` immediately, then resolves with the + * new output once the request settles (or `undefined` if it failed — the + * failure is surfaced via `error`). The returned promise never rejects. + */ + submit: (variables: Record) => Promise; +}; + +/** + * Either `agentId` or a custom `transport` is required. + */ +export type StructuredOutputSource = + | { + agentId: string; + transport?: never; + } + | { + transport: TaskTransport; + agentId?: never; + }; + +export type StructuredOutputConnectorParams = StructuredOutputSource & { + task: string; + stream?: boolean; +}; + +export type StructuredOutputWidgetDescription = { + $$type: 'ais.structuredOutput'; + renderState: StructuredOutputRenderState; +}; + +export type StructuredOutputConnector = Connector< + StructuredOutputWidgetDescription, + StructuredOutputConnectorParams +>; + +const connectStructuredOutput: StructuredOutputConnector = + function connectStructuredOutput(renderFn, unmountFn = noop) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { agentId, transport, task, stream = true } = widgetParams; + + if (!agentId && !transport) { + throw new Error( + withUsage( + 'The `agentId` option is required unless a custom `transport` is provided.' + ) + ); + } + + if (!task) { + throw new Error(withUsage('The `task` option is required.')); + } + + let runner: StructuredOutputRunner; + let output: unknown; + let isLoading = false; + let error: Error | undefined; + let disposed = false; + let triggerRender: () => void = noop; + let requestId = 0; + + const submit = (variables: Record): Promise => { + if (disposed) return Promise.resolve(undefined); + const currentRequestId = (requestId += 1); + const isStale = () => disposed || currentRequestId !== requestId; + // Clear the previous output so consumers can show a loading state + // rather than stale data while the new request is in flight. + output = undefined; + error = undefined; + isLoading = true; + triggerRender(); + + return runner + .submit(variables, { + onData: stream + ? (partial) => { + if (isStale()) return; + output = partial; + triggerRender(); + } + : undefined, + }) + .then((next) => { + if (isStale()) return; + output = next; + }) + .catch((err) => { + if (isStale()) return; + output = undefined; + error = err instanceof Error ? err : new Error(String(err)); + }) + .finally(() => { + if (isStale()) return; + isLoading = false; + triggerRender(); + }) + .then(() => output); + }; + + const getWidgetRenderState = (): StructuredOutputRenderState & { + widgetParams: StructuredOutputConnectorParams; + } => ({ + output, + isLoading, + error, + submit, + widgetParams, + }); + + return { + $$type: 'ais.structuredOutput', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + if (transport) { + const resolved = resolveEndpoint({ transport }); + runner = createStructuredOutputRunner({ + endpoint: resolved.endpoint, + headers: resolved.headers, + task, + stream, + prepareRequest: resolved.prepareSendMessagesRequest, + }); + } else { + const [appId, apiKey] = getAppIdAndApiKey( + instantSearchInstance.client + ); + + if (!appId || !apiKey) { + throw new Error( + withUsage( + 'Could not extract Algolia credentials from the search client.' + ) + ); + } + + const resolved = resolveEndpoint({ + appId, + apiKey, + agentId, + algoliaAgent: getAlgoliaAgent(instantSearchInstance.client), + }); + runner = createStructuredOutputRunner({ + endpoint: resolved.endpoint, + headers: resolved.headers, + task, + stream, + }); + } + + triggerRender = () => { + renderFn( + { ...getWidgetRenderState(), instantSearchInstance }, + false + ); + }; + + renderFn({ ...getWidgetRenderState(), instantSearchInstance }, true); + }, + + render(renderOptions) { + renderFn( + { + ...getWidgetRenderState(), + instantSearchInstance: renderOptions.instantSearchInstance, + }, + false + ); + }, + + dispose() { + disposed = true; + unmountFn(); + }, + }; + }; + }; + +export default connectStructuredOutput; diff --git a/packages/instantsearch.js/src/lib/InstantSearch.ts b/packages/instantsearch.js/src/lib/InstantSearch.ts index 40a938b605f..7fbdf63d2c7 100644 --- a/packages/instantsearch.js/src/lib/InstantSearch.ts +++ b/packages/instantsearch.js/src/lib/InstantSearch.ts @@ -230,6 +230,7 @@ class InstantSearch< public _searchStalledTimer: any; public _initialUiState: TUiState; public _initialResults: InitialResults | null; + public _initialChatStates: Record | null; public _manuallyResetScheduleSearch: boolean = false; public _resetScheduleSearch?: () => void; public _createURL: CreateURL; @@ -237,6 +238,7 @@ class InstantSearch< public _mainHelperSearch?: AlgoliaSearchHelper['search']; public _hasSearchWidget: boolean = false; public _hasRecommendWidget: boolean = false; + public _serverWaitPromises: Array> = []; public _insights: InstantSearchOptions['insights']; /** * The options the instance was created with, kept verbatim so consumers @@ -277,6 +279,16 @@ Use \`InstantSearch.status === "stalled"\` instead.` return this.status === 'stalled'; } + public registerServerWait(promise: Promise): void { + this._serverWaitPromises.push(promise); + } + + public consumeServerWaitPromises(): Array> { + const promises = this._serverWaitPromises; + this._serverWaitPromises = []; + return promises; + } + public constructor(options: InstantSearchOptions) { super(); @@ -387,6 +399,7 @@ See documentation: ${createDocumentationLink({ this._createURL = defaultCreateURL; this._initialUiState = initialUiState as TUiState; this._initialResults = null; + this._initialChatStates = null; this._insights = insights; diff --git a/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts b/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts index 49abfd6bbe9..af463b12eea 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/consistent-type-assertions */ +import { parsePartialJson } from './parse-partial-json'; import { processStream } from './stream-parser'; import { generateId as defaultGenerateId, @@ -32,96 +33,6 @@ type ActiveResponse = { stream?: ReadableStream; }; -const tryParseJson = (value: string): unknown | undefined => { - try { - return JSON.parse(value); - } catch { - return undefined; - } -}; - -const repairPartialJson = (value: string): string => { - let repaired = value.trim(); - - if (!repaired) { - return repaired; - } - - let inString = false; - let isEscaped = false; - const stack: Array<'{' | '['> = []; - - for (let index = 0; index < repaired.length; index++) { - const char = repaired[index]; - if (inString) { - if (isEscaped) { - isEscaped = false; - } else if (char === '\\') { - isEscaped = true; - } else if (char === '"') { - inString = false; - } - continue; - } - - if (char === '"') { - inString = true; - continue; - } - - if (char === '{' || char === '[') { - stack.push(char); - continue; - } - - if (char === '}' && stack[stack.length - 1] === '{') { - stack.pop(); - continue; - } - - if (char === ']' && stack[stack.length - 1] === '[') { - stack.pop(); - } - } - - if (inString && !isEscaped) { - repaired += '"'; - } - - repaired = repaired.replace(/,\s*$/u, ''); - - if (stack.length > 0) { - repaired += stack - .reverse() - .map((opening) => (opening === '{' ? '}' : ']')) - .join(''); - } - - return repaired.replace(/,\s*([}\]])/gu, '$1'); -}; - -const parseToolInputDelta = ( - accumulatedRawInput: string, - fallbackInput: unknown -): unknown => { - const normalized = accumulatedRawInput.trim(); - if (!normalized) { - return fallbackInput; - } - - const directParsed = tryParseJson(normalized); - if (directParsed !== undefined) { - return directParsed; - } - - const repairedParsed = tryParseJson(repairPartialJson(normalized)); - if (repairedParsed !== undefined) { - return repairedParsed; - } - - return fallbackInput; -}; - const defaultGuardrailFallbackResponse = 'Sorry, we are not able to generate a response at the moment.'; @@ -793,7 +704,7 @@ export abstract class AbstractChat { ? this.shouldRepairToolInput?.(toolName) ?? true : true; const parsedInput = shouldRepair - ? parseToolInputDelta(nextRawInput, existingPart?.input) + ? parsePartialJson(nextRawInput, existingPart?.input) : existingPart?.input; const nextToolPart = { @@ -900,7 +811,7 @@ export abstract class AbstractChat { const nextRawOutput = `${previousRawOutput}${delta}`; toolRawOutputByCallId[toolCallId] = nextRawOutput; - const parsedOutput = parseToolInputDelta( + const parsedOutput = parsePartialJson( nextRawOutput, existingPart?.output ); diff --git a/packages/instantsearch.js/src/lib/ai-lite/index.ts b/packages/instantsearch.js/src/lib/ai-lite/index.ts index ab525ace639..2b51e1500bb 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/index.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/index.ts @@ -20,6 +20,12 @@ export { // Stream parsing export { parseJsonEventStream, processStream } from './stream-parser'; +export { + parsePartialJson, + repairPartialJson, + tryParseJson, +} from './parse-partial-json'; + // Types export type { // Status diff --git a/packages/instantsearch.js/src/lib/ai-lite/parse-partial-json.ts b/packages/instantsearch.js/src/lib/ai-lite/parse-partial-json.ts new file mode 100644 index 00000000000..09140859af6 --- /dev/null +++ b/packages/instantsearch.js/src/lib/ai-lite/parse-partial-json.ts @@ -0,0 +1,89 @@ +export const tryParseJson = (value: string): unknown | undefined => { + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +export const repairPartialJson = (value: string): string => { + let repaired = value.trim(); + + if (!repaired) { + return repaired; + } + + let inString = false; + let isEscaped = false; + const stack: Array<'{' | '['> = []; + + for (let index = 0; index < repaired.length; index++) { + const char = repaired[index]; + if (inString) { + if (isEscaped) { + isEscaped = false; + } else if (char === '\\') { + isEscaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === '{' || char === '[') { + stack.push(char); + continue; + } + + if (char === '}' && stack[stack.length - 1] === '{') { + stack.pop(); + continue; + } + + if (char === ']' && stack[stack.length - 1] === '[') { + stack.pop(); + } + } + + if (inString && !isEscaped) { + repaired += '"'; + } + + repaired = repaired.replace(/,\s*$/u, ''); + + if (stack.length > 0) { + repaired += stack + .reverse() + .map((opening) => (opening === '{' ? '}' : ']')) + .join(''); + } + + return repaired.replace(/,\s*([}\]])/gu, '$1'); +}; + +export const parsePartialJson = ( + accumulatedRawJson: string, + fallbackValue: unknown +): unknown => { + const normalized = accumulatedRawJson.trim(); + if (!normalized) { + return fallbackValue; + } + + const directParsed = tryParseJson(normalized); + if (directParsed !== undefined) { + return directParsed; + } + + const repairedParsed = tryParseJson(repairPartialJson(normalized)); + if (repairedParsed !== undefined) { + return repairedParsed; + } + + return fallbackValue; +}; diff --git a/packages/instantsearch.js/src/lib/chat/__tests__/chat-ssr.test.ts b/packages/instantsearch.js/src/lib/chat/__tests__/chat-ssr.test.ts new file mode 100644 index 00000000000..a5552d860d9 --- /dev/null +++ b/packages/instantsearch.js/src/lib/chat/__tests__/chat-ssr.test.ts @@ -0,0 +1,33 @@ +/** + * @jest-environment node + */ +import { ChatState } from '../chat'; + +// In a Node (server) environment `sessionStorage` is undefined, mirroring +// server-side rendering. Constructing the chat state must not throw and must +// fall back to empty initial messages without attempting to persist. `Chat` +// delegates directly to `ChatState`, so covering the state covers the SSR path. +describe('ChatState (SSR / no sessionStorage)', () => { + it('runs in an environment without sessionStorage', () => { + expect(typeof sessionStorage).toBe('undefined'); + }); + + it('constructs with empty initial messages and does not throw', () => { + let chatState: ChatState; + + expect(() => { + chatState = new ChatState('ssr-agent'); + }).not.toThrow(); + + expect(chatState!.messages).toEqual([]); + }); + + it('does not throw when status changes to ready (persistence skipped)', () => { + const chatState = new ChatState('ssr-agent'); + chatState.messages = [{ role: 'user', content: 'Hello' } as any]; + + expect(() => { + chatState.status = 'ready'; + }).not.toThrow(); + }); +}); diff --git a/packages/instantsearch.js/src/lib/chat/__tests__/openChat.test.ts b/packages/instantsearch.js/src/lib/chat/__tests__/openChat.test.ts index c3f06943b03..31e5bc154da 100644 --- a/packages/instantsearch.js/src/lib/chat/__tests__/openChat.test.ts +++ b/packages/instantsearch.js/src/lib/chat/__tests__/openChat.test.ts @@ -42,6 +42,31 @@ describe('openChat', () => { } ); + test('attaches turnContext to the message metadata when provided', () => { + const chat = createChatRenderState(); + + openChat(chat, { + message: 'macbook', + turnContext: { query: 'macbook', page: 'plp' }, + }); + + expect(chat.sendMessage).toHaveBeenCalledWith( + { + text: 'macbook', + metadata: { turnContext: { query: 'macbook', page: 'plp' } }, + }, + undefined + ); + }); + + test('omits the metadata key entirely when no turnContext is provided', () => { + const chat = createChatRenderState(); + + openChat(chat, { message: 'macbook' }); + + expect(chat.sendMessage).toHaveBeenCalledWith({ text: 'macbook' }, undefined); + }); + test('does not add the x-algolia-referer header when no referer is provided', () => { const chat = createChatRenderState(); diff --git a/packages/instantsearch.js/src/lib/chat/chat.ts b/packages/instantsearch.js/src/lib/chat/chat.ts index 4c33df46a35..0f123c79dbb 100644 --- a/packages/instantsearch.js/src/lib/chat/chat.ts +++ b/packages/instantsearch.js/src/lib/chat/chat.ts @@ -26,10 +26,17 @@ export const CACHE_KEY = 'instantsearch-chat-initial-messages'; function getDefaultInitialMessages( id?: string ): TUIMessage[] { - const initialMessages = sessionStorage.getItem( - CACHE_KEY + (id ? `-${id}` : '') - ); - return initialMessages ? JSON.parse(initialMessages) : []; + if (typeof sessionStorage === 'undefined') { + return []; + } + try { + const initialMessages = sessionStorage.getItem( + CACHE_KEY + (id ? `-${id}` : '') + ); + return initialMessages ? JSON.parse(initialMessages) : []; + } catch { + return []; + } } export class ChatState @@ -61,7 +68,7 @@ export class ChatState } const saveMessagesInLocalStorage = () => { - if (this.status === 'ready') { + if (this.status === 'ready' && typeof sessionStorage !== 'undefined') { try { sessionStorage.setItem( CACHE_KEY + (id ? `-${id}` : ''), diff --git a/packages/instantsearch.js/src/lib/chat/createAgentTransport.ts b/packages/instantsearch.js/src/lib/chat/createAgentTransport.ts new file mode 100644 index 00000000000..05a92742ab4 --- /dev/null +++ b/packages/instantsearch.js/src/lib/chat/createAgentTransport.ts @@ -0,0 +1,166 @@ +import { DefaultChatTransport } from '../ai-lite'; +import { getAlgoliaAgent, getAppIdAndApiKey } from '../utils'; + +import type { SearchClient, CompositionClient } from '../../types'; +import type { UIMessage } from '../ai-lite'; + +/** + * Request options applied to built-in Algolia agent-studio requests (ignored + * when a custom `transport` is provided). + */ +export type AgentRequestOptions = { + /** + * Query parameters merged into the completion request URL. + */ + queryParameters?: Record; + /** + * Headers merged into the completion request. The Algolia identity headers + * and the `x-algolia-agent` marker always win over same-named keys here. + */ + headers?: Record | Headers; +}; + +export type CreateAgentTransportOptions = { + /** The Algolia search client (for credentials extraction). */ + client: SearchClient | CompositionClient; + /** + * The Algolia agent identifier. When provided, the default Algolia + * agent-studio endpoint is used. + */ + agentId?: string; + /** + * A custom transport options bag. When provided, takes precedence over + * `agentId` and is passed to `DefaultChatTransport`. + */ + transport?: ConstructorParameters[0]; + /** + * Optional algolia-agent suffix appended to the user agent (e.g. `'chat'`, + * `'on-page-suggestions'`). + */ + algoliaAgentSuffix?: string; + /** + * Persistent query parameters and headers applied to built-in agent-studio + * requests (ignored when `transport` is provided). + */ + requestOptions?: AgentRequestOptions; +}; + +/** + * Strips `data-*` UI message parts from outgoing messages. The backend + * doesn't accept these — they exist only for client-side UI state. + */ +function filterDataParts( + messages: TUIMessage[] +): TUIMessage[] { + return messages.map((message) => ({ + ...message, + parts: message.parts?.filter( + (part) => !('type' in part && part.type.startsWith('data-')) + ), + })); +} + +/** + * Builds a configured `DefaultChatTransport` for either a custom transport + * or the Algolia agent-studio endpoint, applying the `filterDataParts` shim + * to outgoing messages. + */ +export function createAgentTransport({ + client, + agentId, + transport, + algoliaAgentSuffix = 'chat', + requestOptions, +}: CreateAgentTransportOptions): DefaultChatTransport | undefined { + if (transport) { + const originalPrepare = transport.prepareSendMessagesRequest; + return new DefaultChatTransport({ + ...transport, + prepareSendMessagesRequest: (params) => { + // Call the original prepareSendMessagesRequest if it exists, + // otherwise construct a minimal default body containing only the + // request payload — without leaking transport metadata such as + // resolved headers, api URL, credentials, or `requestMetadata`. + const preparedOrPromise = originalPrepare + ? originalPrepare(params) + : { + body: { + id: params.id, + messageId: params.messageId, + trigger: params.trigger, + messages: params.messages, + ...params.body, + }, + }; + + const applyFilter = (prepared: { body: object }) => ({ + ...prepared, + body: { + ...prepared.body, + messages: filterDataParts( + (prepared.body as { messages: TUIMessage[] }).messages + ), + }, + }); + + if (preparedOrPromise && 'then' in preparedOrPromise) { + return preparedOrPromise.then(applyFilter); + } + return applyFilter(preparedOrPromise); + }, + }); + } + + if (!agentId) { + return undefined; + } + + const [appId, apiKey] = getAppIdAndApiKey(client); + if (!appId || !apiKey) { + throw new Error( + 'Could not extract Algolia credentials from the search client.' + ); + } + + const createApi = (bypassCache = false) => { + const api = new URL( + `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/completions` + ); + const queryParameters: Record = { + ...requestOptions?.queryParameters, + compatibilityMode: 'ai-sdk-5', + ...(bypassCache ? { cache: false } : {}), + }; + + api.search = new URLSearchParams( + queryParameters as Record + ).toString(); + return api.toString(); + }; + const baseApi = createApi(); + + return new DefaultChatTransport({ + api: baseApi, + headers: { + ...(requestOptions?.headers instanceof Headers + ? Object.fromEntries(requestOptions.headers.entries()) + : requestOptions?.headers), + // Preserve the required Algolia identity headers and agent marker, even + // when requestOptions.headers contains the same keys. + 'x-algolia-application-id': appId, + 'x-algolia-api-key': apiKey, + 'x-algolia-agent': `${getAlgoliaAgent(client)}; ${algoliaAgentSuffix}`, + }, + prepareSendMessagesRequest: ({ id, messages, trigger, messageId }) => { + return { + // Bypass cache when regenerating to ensure fresh responses + api: trigger === 'regenerate-message' ? createApi(true) : baseApi, + body: { + id, + messageId, + messages: filterDataParts(messages), + }, + }; + }, + }); +} diff --git a/packages/instantsearch.js/src/lib/chat/openChat.ts b/packages/instantsearch.js/src/lib/chat/openChat.ts index 97696f29650..e54ffcb8408 100644 --- a/packages/instantsearch.js/src/lib/chat/openChat.ts +++ b/packages/instantsearch.js/src/lib/chat/openChat.ts @@ -5,7 +5,10 @@ import type { ChatRenderState } from '../../connectors/chat/connectChat'; * Forwarded to the agent backend as the `x-algolia-referer` header and used * as a correlation tag for attribution. */ -export type ChatReferer = 'prompt-suggestions' | 'ai-mode'; +export type ChatReferer = + | 'prompt-suggestions' + | 'ai-mode' + | 'on-page-suggestions'; export type OpenChatOptions = { /** @@ -19,6 +22,15 @@ export type OpenChatOptions = { * the backend can attribute the traffic to the originating entry point. */ referer?: ChatReferer; + /** + * Ambient page context attached to the outgoing user message as + * `metadata.turnContext` — the same Agent Studio grounding channel the chat + * widget's own `context` uses. Lets an entry point ground the agent's answer + * in the page it was triggered from. Flat `Record` per the + * backend contract. Ignored when the chat widget already attaches its own + * `context` (that one takes precedence for the turn). + */ + turnContext?: Record; }; // Centralizes the "open the chat from an entry point" behavior shared by the @@ -28,7 +40,7 @@ export type OpenChatOptions = { // Returns true when a message was submitted, so callers can clear their input. export function openChat( chatRenderState: Partial | undefined, - { message, referer }: OpenChatOptions = {} + { message, referer, turnContext }: OpenChatOptions = {} ): boolean { if (!chatRenderState) { return false; @@ -47,7 +59,10 @@ export function openChat( } chatRenderState.sendMessage( - { text: trimmed }, + { + text: trimmed, + ...(turnContext ? { metadata: { turnContext } } : {}), + } as Parameters>[0], referer ? { headers: { 'x-algolia-referer': referer } } : undefined ); return true; diff --git a/packages/instantsearch.js/src/lib/chat/sendMessageWithContext.ts b/packages/instantsearch.js/src/lib/chat/sendMessageWithContext.ts new file mode 100644 index 00000000000..e1e6ad91c89 --- /dev/null +++ b/packages/instantsearch.js/src/lib/chat/sendMessageWithContext.ts @@ -0,0 +1,39 @@ +import type { AbstractChat, UIMessage } from '../ai-lite'; + +export type ChatContext = + | Record + | (() => Record); + +/** + * Wraps a chat instance's `sendMessage` so that the configured page/widget + * context is attached to the user message as `metadata.turnContext` per the + * Agent Studio contract. Returns a function with the same shape as + * `Chat#sendMessage`. + * + * When `context` is undefined, or the message is empty, the original + * `sendMessage` is called unchanged. A throwing `context` resolver is + * surfaced to the caller rather than swallowed. + */ +export function createSendMessageWithContext( + chat: AbstractChat, + context: ChatContext | undefined +): typeof chat.sendMessage { + return (message, ...rest) => { + if (!context || !message) { + return chat.sendMessage(message, ...rest); + } + + const turnContext = typeof context === 'function' ? context() : context; + + return chat.sendMessage( + { + ...message, + metadata: { + ...(message.metadata as Record | undefined), + turnContext, + }, + } as Parameters[0], + ...rest + ); + }; +} diff --git a/packages/instantsearch.js/src/lib/server.ts b/packages/instantsearch.js/src/lib/server.ts index 38120198944..2fbd535b049 100644 --- a/packages/instantsearch.js/src/lib/server.ts +++ b/packages/instantsearch.js/src/lib/server.ts @@ -62,19 +62,29 @@ export function waitForResults( return new Promise((resolve, reject) => { let searchResultsReceived = !waitsForSearch; let recommendResultsReceived = !waitsForRecommend; + + const tryResolve = () => { + if (!searchResultsReceived || !recommendResultsReceived) { + return; + } + // Await any promises that widgets registered during SSR init (e.g. the + // on-page-suggestions widget races its agent request against a + // timeout). `allSettled` so a widget rejecting (e.g. abort) doesn't + // crash SSR. + Promise.allSettled(search.consumeServerWaitPromises()).then(() => + resolve(requestParamsList!) + ); + }; + // All derived helpers resolve in the same tick so we're safe only relying // on the first one. helper.derivedHelpers[0].on('result', () => { searchResultsReceived = true; - if (recommendResultsReceived) { - resolve(requestParamsList!); - } + tryResolve(); }); helper.derivedHelpers[0].on('recommend:result', () => { recommendResultsReceived = true; - if (searchResultsReceived) { - resolve(requestParamsList!); - } + tryResolve(); }); // However, we listen to errors that can happen on any derived helper because diff --git a/packages/instantsearch.js/src/lib/tasks/__tests__/fetchTask-test.ts b/packages/instantsearch.js/src/lib/tasks/__tests__/fetchTask-test.ts new file mode 100644 index 00000000000..35f30adc8a8 --- /dev/null +++ b/packages/instantsearch.js/src/lib/tasks/__tests__/fetchTask-test.ts @@ -0,0 +1,190 @@ +/** + * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts + */ + +import { fetchTask } from '../fetchTask'; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +// Builds a fake `text/event-stream` response whose body replays `events` as +// SSE `data:` lines. Kept as a plain object (not a real `Response`) so the test +// doesn't depend on `Response.body` support in the jsdom environment. +function sseResponse(events: string[]): Response { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + events.forEach((event) => { + controller.enqueue(encoder.encode(`data: ${event}\n\n`)); + }); + controller.close(); + }, + }); + return { + ok: true, + status: 200, + headers: { + get: (name: string) => + name.toLowerCase() === 'content-type' ? 'text/event-stream' : null, + }, + body, + } as unknown as Response; +} + +function outputEvent(output: unknown): string { + return JSON.stringify({ type: 'data-task-output', data: { output } }); +} + +const OPTIONS = { + endpoint: 'https://example.test/agents/xyz/tasks', + headers: { 'x-algolia-application-id': 'app' }, + payload: { task: 'some_task', input: { foo: 'bar' } }, +}; + +describe('fetchTask', () => { + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + }); + + it('requests the endpoint with `stream=true`, POSTing the payload as JSON', async () => { + global.fetch = jest.fn(() => + Promise.resolve(jsonResponse({ output: { ok: true } })) + ) as unknown as typeof fetch; + + await fetchTask(OPTIONS); + + const [[url, init]] = (global.fetch as jest.Mock).mock.calls; + expect(url).toBe('https://example.test/agents/xyz/tasks?stream=true'); + expect(init.method).toBe('POST'); + expect(init.headers).toMatchObject({ + 'x-algolia-application-id': 'app', + 'Content-Type': 'application/json', + }); + expect(JSON.parse(init.body)).toEqual(OPTIONS.payload); + }); + + it('appends `stream=true` to an endpoint that already has a query string', async () => { + global.fetch = jest.fn(() => + Promise.resolve(jsonResponse({ output: {} })) + ) as unknown as typeof fetch; + + await fetchTask({ ...OPTIONS, endpoint: `${OPTIONS.endpoint}?v=1` }); + + const [[url]] = (global.fetch as jest.Mock).mock.calls; + expect(url).toBe('https://example.test/agents/xyz/tasks?v=1&stream=true'); + }); + + it('resolves with the buffered JSON body when the response is not a stream', async () => { + global.fetch = jest.fn(() => + Promise.resolve(jsonResponse({ output: { suggestions: ['a', 'b'] } })) + ) as unknown as typeof fetch; + + const onData = jest.fn(); + const result = await fetchTask({ ...OPTIONS, onData }); + + expect(result).toEqual({ output: { suggestions: ['a', 'b'] } }); + // A buffered response never streams, so `onData` is not called. + expect(onData).not.toHaveBeenCalled(); + }); + + it('streams each accumulated snapshot and resolves with the final payload', async () => { + global.fetch = jest.fn(() => + Promise.resolve( + sseResponse([ + JSON.stringify({ type: 'start' }), + outputEvent({ suggestions: [''] }), + outputEvent({ suggestions: ['What'] }), + outputEvent({ suggestions: ['What', 'Any deals?'] }), + JSON.stringify({ type: 'finish' }), + '[DONE]', + ]) + ) + ) as unknown as typeof fetch; + + const seen: unknown[] = []; + const result = await fetchTask({ + ...OPTIONS, + onData: (data) => seen.push(data), + }); + + expect(seen).toEqual([ + { output: { suggestions: [''] } }, + { output: { suggestions: ['What'] } }, + { output: { suggestions: ['What', 'Any deals?'] } }, + ]); + expect(result).toEqual({ output: { suggestions: ['What', 'Any deals?'] } }); + }); + + it('repairs a raw partial-JSON output payload while streaming', async () => { + // `data` is the raw (still-incomplete) JSON text the model emits, not a + // pre-parsed object — exercising the shared partial-JSON repair. + global.fetch = jest.fn(() => + Promise.resolve( + sseResponse([ + JSON.stringify({ + type: 'data-task-output', + data: '{"output":{"suggestions":["Wh', + }), + JSON.stringify({ + type: 'data-task-output', + data: '{"output":{"suggestions":["What?"]}}', + }), + '[DONE]', + ]) + ) + ) as unknown as typeof fetch; + + const seen: unknown[] = []; + const result = await fetchTask({ + ...OPTIONS, + onData: (data) => seen.push(data), + }); + + // The unterminated first payload is repaired into a usable partial. + expect(seen[0]).toEqual({ output: { suggestions: ['Wh'] } }); + expect(result).toEqual({ output: { suggestions: ['What?'] } }); + }); + + it('omits `stream=true` and reads JSON when `stream` is false', async () => { + global.fetch = jest.fn(() => + Promise.resolve(jsonResponse({ output: { suggestions: ['a'] } })) + ) as unknown as typeof fetch; + + const onData = jest.fn(); + const result = await fetchTask({ ...OPTIONS, stream: false, onData }); + + const [[url]] = (global.fetch as jest.Mock).mock.calls; + expect(url).toBe('https://example.test/agents/xyz/tasks'); + expect(result).toEqual({ output: { suggestions: ['a'] } }); + expect(onData).not.toHaveBeenCalled(); + }); + + it('reads JSON even from an event-stream body when `stream` is false', async () => { + // A caller that opted out of streaming should never consume the SSE body, + // even if the server responds with one. + global.fetch = jest.fn(() => + Promise.resolve(sseResponse([outputEvent({ suggestions: ['x'] })])) + ) as unknown as typeof fetch; + + const onData = jest.fn(); + // `sseResponse` has no `.json()`, so reaching the JSON branch would throw — + // asserting the rejection is enough to prove we didn't take the stream path. + await expect( + fetchTask({ ...OPTIONS, stream: false, onData }) + ).rejects.toThrow(); + expect(onData).not.toHaveBeenCalled(); + }); + + it('rejects on a non-ok response', async () => { + global.fetch = jest.fn(() => + Promise.resolve(new Response('nope', { status: 500 })) + ) as unknown as typeof fetch; + + await expect(fetchTask(OPTIONS)).rejects.toThrow('HTTP error 500'); + }); +}); diff --git a/packages/instantsearch.js/src/lib/tasks/endpoint.ts b/packages/instantsearch.js/src/lib/tasks/endpoint.ts new file mode 100644 index 00000000000..d6f5dc2df72 --- /dev/null +++ b/packages/instantsearch.js/src/lib/tasks/endpoint.ts @@ -0,0 +1,70 @@ +export type TaskPrepareRequest = (body: Record) => { + body: Record; +}; + +export type TaskTransport = { + api: string; + headers?: Record; + prepareSendMessagesRequest?: TaskPrepareRequest; +}; + +export type TaskCredentials = { + appId: string; + apiKey: string; + agentId: string; +}; + +export type TaskEndpoint = + | { transport: TaskTransport; credentials?: never } + | { transport?: never; credentials: TaskCredentials }; + +export type ResolvedEndpoint = { + endpoint: string; + headers: Record; + prepareSendMessagesRequest?: TaskTransport['prepareSendMessagesRequest']; +}; + +function buildEndpoint({ + appId, + agentId, +}: { + appId: string; + agentId: string; +}): string { + return `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/tasks`; +} + +export function resolveEndpoint(params: { + transport?: TaskTransport; + appId?: string; + apiKey?: string; + agentId?: string; + algoliaAgent?: string; +}): ResolvedEndpoint { + if (params.transport) { + return { + endpoint: params.transport.api, + headers: params.transport.headers || {}, + prepareSendMessagesRequest: params.transport.prepareSendMessagesRequest, + }; + } + + if (!params.appId || !params.apiKey || !params.agentId) { + throw new Error( + '[tasks] Either `transport` or `{ appId, apiKey, agentId }` is required.' + ); + } + + const headers: Record = { + 'x-algolia-application-id': params.appId, + 'x-algolia-api-key': params.apiKey, + }; + if (params.algoliaAgent) { + headers['x-algolia-agent'] = params.algoliaAgent; + } + + return { + endpoint: buildEndpoint({ appId: params.appId, agentId: params.agentId }), + headers, + }; +} diff --git a/packages/instantsearch.js/src/lib/tasks/fetchTask.ts b/packages/instantsearch.js/src/lib/tasks/fetchTask.ts new file mode 100644 index 00000000000..2e041a30637 --- /dev/null +++ b/packages/instantsearch.js/src/lib/tasks/fetchTask.ts @@ -0,0 +1,141 @@ +import { + parseJsonEventStream, + parsePartialJson, + processStream, +} from '../ai-lite'; + +import type { TaskPrepareRequest } from './endpoint'; + +export type BuildTaskPayloadOptions = { + task: string; + input: Record; + prepareRequest?: TaskPrepareRequest; +}; + +export function buildTaskPayload({ + task, + input, + prepareRequest, +}: BuildTaskPayloadOptions): Record { + const payload: Record = { task, input }; + + return prepareRequest ? prepareRequest(payload).body : payload; +} + +type TaskStreamChunk = { + type?: string; + data?: unknown; +}; + +function withStreamParam(url: string): string { + return url.includes('?') ? `${url}&stream=true` : `${url}?stream=true`; +} + +function resolveStreamedOutput(data: unknown, previous: unknown): unknown { + return typeof data === 'string' ? parsePartialJson(data, previous) : data; +} + +function consumeTaskStream( + body: ReadableStream, + onData?: (data: unknown) => void +): Promise { + return new Promise((resolve, reject) => { + const chunkStream = parseJsonEventStream( + body + ) as unknown as ReadableStream; + let latest: unknown; + processStream( + chunkStream, + (chunk) => { + if (!chunk || chunk.type !== 'data-task-output') { + return; + } + latest = resolveStreamedOutput(chunk.data, latest); + if (onData) { + onData(latest); + } + }, + () => resolve(latest), + reject + ); + }); +} + +export type FetchTaskOptions = { + endpoint: string; + headers: Record; + payload: Record; + onData?: (data: unknown) => void; + stream?: boolean; +}; + +export function fetchTask({ + endpoint, + headers, + payload, + onData, + stream = true, +}: FetchTaskOptions): Promise { + return fetch(stream ? withStreamParam(endpoint) : endpoint, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).then((response) => { + if (!response.ok) { + throw new Error(`HTTP error ${response.status}`); + } + const contentType = response.headers?.get?.('content-type') || ''; + if (stream && response.body && contentType.includes('text/event-stream')) { + return consumeTaskStream(response.body, onData); + } + return response.json(); + }); +} + +function unwrap(envelope: unknown): unknown { + return (envelope as { output?: unknown } | null | undefined)?.output; +} + +export type StructuredOutputRunnerOptions = { + endpoint: string; + headers: Record; + task: string; + stream?: boolean; + prepareRequest?: TaskPrepareRequest; +}; + +export type StructuredOutputSubmitOptions = { + onData?: (output: unknown) => void; +}; + +export type StructuredOutputRunner = { + submit: ( + variables: Record, + options?: StructuredOutputSubmitOptions + ) => Promise; +}; + +export function createStructuredOutputRunner({ + endpoint, + headers, + task, + stream = true, + prepareRequest, +}: StructuredOutputRunnerOptions): StructuredOutputRunner { + return { + submit(variables, { onData } = {}) { + const payload = buildTaskPayload({ + task, + input: variables, + prepareRequest, + }); + return fetchTask({ + endpoint, + headers, + payload, + stream, + onData: onData ? (partial) => onData(unwrap(partial)) : undefined, + }).then(unwrap); + }, + }; +} diff --git a/packages/instantsearch.js/src/lib/tasks/index.ts b/packages/instantsearch.js/src/lib/tasks/index.ts new file mode 100644 index 00000000000..745533e4481 --- /dev/null +++ b/packages/instantsearch.js/src/lib/tasks/index.ts @@ -0,0 +1,21 @@ +export { resolveEndpoint } from './endpoint'; +export { + buildTaskPayload, + createStructuredOutputRunner, + fetchTask, +} from './fetchTask'; + +export type { + ResolvedEndpoint, + TaskPrepareRequest, + TaskTransport, + TaskCredentials, + TaskEndpoint, +} from './endpoint'; +export type { + BuildTaskPayloadOptions, + FetchTaskOptions, + StructuredOutputRunner, + StructuredOutputRunnerOptions, + StructuredOutputSubmitOptions, +} from './fetchTask'; diff --git a/packages/instantsearch.js/src/widgets/__tests__/index.test.ts b/packages/instantsearch.js/src/widgets/__tests__/index.test.ts index d0251de5674..01b5300e36e 100644 --- a/packages/instantsearch.js/src/widgets/__tests__/index.test.ts +++ b/packages/instantsearch.js/src/widgets/__tests__/index.test.ts @@ -173,6 +173,13 @@ function initiateAllWidgets(): Array<[WidgetNames, Widget | IndexWidget]> { return autocomplete; } + case 'onPageSuggestions': { + const onPageSuggestions = widget as Widgets['onPageSuggestions']; + return onPageSuggestions({ + container, + agentId: 'test-agent-id', + }); + } case 'filterSuggestions': { const filterSuggestions = widget as Widgets['filterSuggestions']; return filterSuggestions({ diff --git a/packages/instantsearch.js/src/widgets/index.ts b/packages/instantsearch.js/src/widgets/index.ts index 70176bdcae3..0529e207d9c 100644 --- a/packages/instantsearch.js/src/widgets/index.ts +++ b/packages/instantsearch.js/src/widgets/index.ts @@ -62,5 +62,6 @@ 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 onPageSuggestions } from './on-page-suggestions/on-page-suggestions'; export { default as chatTrigger } from './chat-trigger/chat-trigger'; export { default as filterSuggestions } from './filter-suggestions/filter-suggestions'; diff --git a/packages/instantsearch.js/src/widgets/on-page-suggestions/on-page-suggestions.tsx b/packages/instantsearch.js/src/widgets/on-page-suggestions/on-page-suggestions.tsx new file mode 100644 index 00000000000..27819ae7553 --- /dev/null +++ b/packages/instantsearch.js/src/widgets/on-page-suggestions/on-page-suggestions.tsx @@ -0,0 +1,197 @@ +/** @jsx h */ + +import { createOnPageSuggestionsComponent } from 'instantsearch-ui-components'; +import { Fragment, h, render } from 'preact'; + +import connectOnPageSuggestions from '../../connectors/on-page-suggestions/connectOnPageSuggestions'; +import { + getContainerNode, + createDocumentationMessageGenerator, +} from '../../lib/utils'; + +import type { + OnPageSuggestionsRenderState, + OnPageSuggestionsConnectorParams, + OnPageSuggestionsWidgetDescription, +} from '../../connectors/on-page-suggestions/connectOnPageSuggestions'; +import type { WidgetFactory, Renderer } from '../../types'; +import type { + OnPageSuggestionsClassNames, + OnPageSuggestionsHeaderComponentProps, + OnPageSuggestionsTranslations, +} from 'instantsearch-ui-components'; +import type { ComponentChildren } from 'preact'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'on-page-suggestions', +}); + +const OnPageSuggestions = createOnPageSuggestionsComponent({ + createElement: h, + Fragment: 'fragment', +}); + +export type OnPageSuggestionsCSSClasses = + Partial; + +/** + * Props passed to a custom `templates.layout`. Mirrors the connector render + * state so a layout template owns the full markup. + */ +export type OnPageSuggestionsLayoutTemplateProps = { + suggestions: string[]; + isLoading: boolean; + onSuggestionClick: (prompt: string) => void; + isChatBusy: boolean; +}; + +export type OnPageSuggestionsTemplates = { + /** + * Replaces the default pills layout with custom markup. Receives the full + * render state — the template is responsible for rendering the list, the + * loading state, and the click handlers. + */ + layout?: ( + props: OnPageSuggestionsLayoutTemplateProps + ) => ComponentChildren; + /** + * Replaces the default header. Set to `false` to disable the header. + */ + header?: + | ((props: OnPageSuggestionsHeaderComponentProps) => ComponentChildren) + | false; +}; + +type OnPageSuggestionsWidgetParams = { + /** CSS Selector or HTMLElement to insert the widget. */ + container: string | HTMLElement; + /** CSS classes to add. */ + cssClasses?: OnPageSuggestionsCSSClasses; + /** Custom templates. */ + templates?: OnPageSuggestionsTemplates; + /** Translations for the widget. */ + translations?: Partial; + /** + * Override the default click behavior (handoff to the chat widget). Receives + * the prompt and a `sendToChat` callback you can use to fall through to the + * default behavior after running custom logic (analytics, routing, etc.). + */ + onSuggestionClick?: ( + prompt: string, + helpers: { sendToChat: (prompt: string) => boolean } + ) => void; +}; + +export type OnPageSuggestionsWidget = WidgetFactory< + OnPageSuggestionsWidgetDescription & { + $$widgetType: 'ais.onPageSuggestions'; + }, + OnPageSuggestionsConnectorParams, + OnPageSuggestionsWidgetParams +>; + +const createRenderer = + ({ + containerNode, + cssClasses, + templates, + translations, + onSuggestionClickOverride, + }: { + containerNode: HTMLElement; + cssClasses: OnPageSuggestionsCSSClasses; + templates?: OnPageSuggestionsTemplates; + translations?: Partial; + onSuggestionClickOverride?: OnPageSuggestionsWidgetParams['onSuggestionClick']; + }): Renderer< + OnPageSuggestionsRenderState, + Partial + > => + (props) => { + const { + suggestions, + isLoading, + onSuggestionClick, + isChatBusy, + sendToChat, + } = props; + + const handleClick = onSuggestionClickOverride + ? (prompt: string) => onSuggestionClickOverride(prompt, { sendToChat }) + : onSuggestionClick; + + if (templates?.layout) { + render( + + {templates.layout({ + suggestions, + isLoading, + onSuggestionClick: handleClick, + isChatBusy, + })} + , + containerNode + ); + return; + } + + let headerComponent; + if (templates?.header === false) { + headerComponent = false as const; + } else if (templates?.header) { + const headerTemplate = templates.header; + headerComponent = (headerProps: OnPageSuggestionsHeaderComponentProps) => ( + {headerTemplate(headerProps)} + ); + } + + render( + , + containerNode + ); + }; + +export default (function onPageSuggestions( + widgetParams: OnPageSuggestionsWidgetParams & + OnPageSuggestionsConnectorParams +) { + const { + container, + cssClasses = {}, + templates, + translations, + onSuggestionClick: onSuggestionClickOverride, + ...connectorParams + } = widgetParams || {}; + + if (!container) { + throw new Error(withUsage('The `container` option is required.')); + } + + const containerNode = getContainerNode(container); + + const specializedRenderer = createRenderer({ + containerNode, + cssClasses, + templates, + translations, + onSuggestionClickOverride, + }); + + const makeWidget = connectOnPageSuggestions(specializedRenderer, () => + render(null, containerNode) + ); + + return { + ...makeWidget(connectorParams), + $$widgetType: 'ais.onPageSuggestions', + }; +} satisfies OnPageSuggestionsWidget); diff --git a/packages/instantsearch.js/test/createInstantSearch.ts b/packages/instantsearch.js/test/createInstantSearch.ts index fc97fb33089..834f70426ae 100644 --- a/packages/instantsearch.js/test/createInstantSearch.ts +++ b/packages/instantsearch.js/test/createInstantSearch.ts @@ -35,6 +35,9 @@ export const createInstantSearch = ( insightsClient: null, middleware: [], renderState: {}, + _serverWaitPromises: [] as Array>, + registerServerWait: jest.fn(), + consumeServerWaitPromises: jest.fn(() => []), scheduleStalledRender: defer(jest.fn()), scheduleSearch: defer(jest.fn()), scheduleRender: defer(jest.fn()), @@ -43,6 +46,7 @@ export const createInstantSearch = ( _initialUiState: {}, _initialOptions: { indexName, searchClient: client }, _initialResults: null, + _initialChatStates: null, _createURL: jest.fn(() => '#'), _insights: undefined, _hasRecommendWidget: false, diff --git a/packages/react-instantsearch-core/src/components/InstantSearchSSRProvider.tsx b/packages/react-instantsearch-core/src/components/InstantSearchSSRProvider.tsx index a179ce9860e..2d7b36cf997 100644 --- a/packages/react-instantsearch-core/src/components/InstantSearchSSRProvider.tsx +++ b/packages/react-instantsearch-core/src/components/InstantSearchSSRProvider.tsx @@ -8,6 +8,7 @@ import type { ReactNode } from 'react'; export type InstantSearchServerState = { initialResults: InitialResults; + initialChatStates?: Record; }; export type InstantSearchSSRProviderProps = diff --git a/packages/react-instantsearch-core/src/connectors/useOnPageSuggestions.ts b/packages/react-instantsearch-core/src/connectors/useOnPageSuggestions.ts new file mode 100644 index 00000000000..8720ed74d22 --- /dev/null +++ b/packages/react-instantsearch-core/src/connectors/useOnPageSuggestions.ts @@ -0,0 +1,21 @@ +import connectOnPageSuggestions from 'instantsearch.js/es/connectors/on-page-suggestions/connectOnPageSuggestions'; + +import { useConnector } from '../hooks/useConnector'; + +import type { AdditionalWidgetProperties } from '../hooks/useConnector'; +import type { + OnPageSuggestionsConnectorParams, + OnPageSuggestionsWidgetDescription, +} from 'instantsearch.js/es/connectors/on-page-suggestions/connectOnPageSuggestions'; + +export type UseOnPageSuggestionsProps = OnPageSuggestionsConnectorParams; + +export function useOnPageSuggestions( + props: UseOnPageSuggestionsProps, + additionalWidgetProperties?: AdditionalWidgetProperties +) { + return useConnector< + OnPageSuggestionsConnectorParams, + OnPageSuggestionsWidgetDescription + >(connectOnPageSuggestions, props, additionalWidgetProperties); +} diff --git a/packages/react-instantsearch-core/src/index.ts b/packages/react-instantsearch-core/src/index.ts index c3e98f754e0..0bbfe37be13 100644 --- a/packages/react-instantsearch-core/src/index.ts +++ b/packages/react-instantsearch-core/src/index.ts @@ -9,6 +9,7 @@ export * from './components/InstantSearchSSRProvider'; export * from './connectors/useAutocomplete'; export * from './connectors/useBreadcrumb'; export * from './connectors/useChat'; +export * from './connectors/useOnPageSuggestions'; export * from './connectors/useChatTrigger'; export * from './connectors/useClearRefinements'; export * from './connectors/useConfigure'; diff --git a/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts b/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts index 8269c64c47e..63a4d4a9239 100644 --- a/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts +++ b/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts @@ -53,6 +53,24 @@ export type InternalInstantSearch< * @private */ _preventWidgetCleanup?: boolean; + /** + * Registers a promise that `waitForResults()` must await before resolving + * during SSR. Stripped from public `.d.ts` so it's re-declared here. + * @private + */ + registerServerWait(promise: Promise): void; + /** + * Returns and clears the promises registered with `registerServerWait`. + * Stripped from public `.d.ts` so it's re-declared here. + * @private + */ + consumeServerWaitPromises(): Array>; + /** + * SSR snapshot of chat messages keyed by chat instance id. Re-declared + * here because it's stripped from the public `.d.ts`. + * @private + */ + _initialChatStates: Record | null; }; export function useInstantSearchApi( @@ -63,6 +81,7 @@ export function useInstantSearchApi( const serverState = useInstantSearchSSRContext(); const { waitForResultsRef } = useRSCContext(); const initialResults = serverState?.initialResults; + const initialChatStates = serverState?.initialChatStates; const prevPropsRef = useRef(props); const shouldRenderAtOnce = @@ -108,6 +127,9 @@ export function useInstantSearchApi( // an additional network request. (This is equivalent to monkey-patching // `scheduleSearch` to a noop.) search._initialResults = initialResults || {}; + if (initialChatStates) { + search._initialChatStates = initialChatStates; + } // We don't rely on the `defer` to reset the schedule search, but will call // `search._resetScheduleSearch()` manually in the effect after children // mount in `InstantSearch`. diff --git a/packages/react-instantsearch-core/src/server/getServerState.tsx b/packages/react-instantsearch-core/src/server/getServerState.tsx index 042905d8a55..1185d267740 100644 --- a/packages/react-instantsearch-core/src/server/getServerState.tsx +++ b/packages/react-instantsearch-core/src/server/getServerState.tsx @@ -143,11 +143,13 @@ function execute({ return waitForResults(searchRef.current, skipRecommend); }) .then((requestParamsList) => { + const search = searchRef.current! as InstantSearch & { + _initialChatStates?: Record | null; + }; + const initialChatStates = search._initialChatStates ?? undefined; return { - initialResults: getInitialResults( - searchRef.current!.mainIndex, - requestParamsList - ), + initialResults: getInitialResults(search.mainIndex, requestParamsList), + ...(initialChatStates ? { initialChatStates } : {}), }; }); } diff --git a/packages/react-instantsearch-nextjs/src/InitializePromise.ts b/packages/react-instantsearch-nextjs/src/InitializePromise.ts index 3fdb0583174..d0e49e3198e 100644 --- a/packages/react-instantsearch-nextjs/src/InitializePromise.ts +++ b/packages/react-instantsearch-nextjs/src/InitializePromise.ts @@ -90,7 +90,13 @@ export function InitializePromise({ nonce }: InitializePromiseProps) { if (resolveWaitForResultsRef) { resolveWaitForResultsRef.current = null; } - resolve(); + // Await any promises that widgets registered during SSR init (e.g. the + // on-page-suggestions widget races its agent request against a + // timeout). `allSettled` so a widget rejecting (e.g. abort) doesn't + // crash SSR. + Promise.allSettled(search.consumeServerWaitPromises()).then(() => + resolve() + ); }; const onResult = () => { searchReceived = true; @@ -123,7 +129,13 @@ export function InitializePromise({ nonce }: InitializePromiseProps) { search.mainIndex, search._hasSearchWidget ? requestParamsList || [] : [] ); - insertHTML(createInsertHTML({ options, results, nonce })); + const chatStates = + ( + search as typeof search & { + _initialChatStates?: Record | null; + } + )._initialChatStates ?? undefined; + insertHTML(createInsertHTML({ options, results, chatStates, nonce })); }; if (waitForResultsRef?.current === null) { diff --git a/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx b/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx index 12b2303b7ad..0773cc7c9d3 100644 --- a/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx +++ b/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx @@ -21,9 +21,13 @@ import type { } from 'react-instantsearch-core'; const InstantSearchInitialResults = Symbol.for('InstantSearchInitialResults'); +const InstantSearchInitialChatStates = Symbol.for( + 'InstantSearchInitialChatStates' +); declare global { interface Window { [InstantSearchInitialResults]?: InitialResults; + [InstantSearchInitialChatStates]?: Record; } } @@ -110,7 +114,11 @@ function ServerOrHydrationProvider({ const [initialResults] = useState(() => safelyRunOnBrowser(({ window }) => window[InstantSearchInitialResults]) ); - // After commit, clear the global so a later mount — + const [initialChatStates] = useState | undefined>( + () => + safelyRunOnBrowser(({ window }) => window[InstantSearchInitialChatStates]) + ); + // After commit, clear the globals so a later mount — // typically the destination of an App Router click — does not // recycle this mount's serialized state. useEffect(() => { @@ -118,6 +126,9 @@ function ServerOrHydrationProvider({ if (window[InstantSearchInitialResults] !== undefined) { window[InstantSearchInitialResults] = undefined; } + if (window[InstantSearchInitialChatStates] !== undefined) { + window[InstantSearchInitialChatStates] = undefined; + } }); }, []); // `useInstantSearchApi` reads a truthy `waitForResultsRef` as "SSR results @@ -137,6 +148,7 @@ function ServerOrHydrationProvider({ diff --git a/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise.test.tsx b/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise.test.tsx index 5b1018f7707..c3814e9566e 100644 --- a/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise.test.tsx +++ b/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise.test.tsx @@ -22,6 +22,7 @@ import { InstantSearchRSCContext, InstantSearchSSRProvider, useConnector, + useInstantSearchContext, } from 'react-instantsearch-core'; import { InitializePromise } from '../InitializePromise'; @@ -29,6 +30,14 @@ import { TriggerSearch } from '../TriggerSearch'; import type { PromiseWithState } from 'react-instantsearch-core'; +function SeedChatStates({ value }: { value: Record }) { + const search = useInstantSearchContext() as ReturnType< + typeof useInstantSearchContext + > & { _initialChatStates?: Record | null }; + search._initialChatStates = value; + return null; +} + jest.mock('instantsearch.js/es/lib/utils', () => ({ ...jest.requireActual('instantsearch.js/es/lib/utils'), resetWidgetId: jest.fn(), @@ -202,6 +211,46 @@ test('it waits for recommend only if there are only recommend widgets', async () expect(client.getRecommendations).toHaveBeenCalledTimes(1); }); +test('it injects only the results script when no chat states are present', async () => { + const insertedHTML = jest.fn(); + await renderComponent({ children: , insertedHTML }); + + const element = insertedHTML.mock.calls.at(-1)![0] as React.ReactElement; + expect(element.type).toBe('script'); + expect(element.props.dangerouslySetInnerHTML.__html).not.toContain( + 'InstantSearchInitialChatStates' + ); +}); + +test('it injects the chat states registered during SSR', async () => { + const insertedHTML = jest.fn(); + await renderComponent({ + children: ( + <> + + + + ), + insertedHTML, + }); + + const element = insertedHTML.mock.calls.at(-1)![0] as React.ReactElement; + expect(element.type).toBe(React.Fragment); + + const children = React.Children.toArray(element.props.children) as Array< + React.ReactElement + >; + expect(children).toHaveLength(2); + expect(children[1].props.dangerouslySetInnerHTML.__html).toContain( + 'InstantSearchInitialChatStates' + ); + expect(children[1].props.dangerouslySetInnerHTML.__html).toContain( + 'suggestions' + ); +}); + test('it resolves without a request when no widget requires a search', async () => { const ref: { current: PromiseWithState | null } = { current: null }; const insertedHTML = jest.fn(); diff --git a/packages/react-instantsearch-nextjs/src/__tests__/createInsertHTML.test.tsx b/packages/react-instantsearch-nextjs/src/__tests__/createInsertHTML.test.tsx new file mode 100644 index 00000000000..8055fe235b4 --- /dev/null +++ b/packages/react-instantsearch-nextjs/src/__tests__/createInsertHTML.test.tsx @@ -0,0 +1,100 @@ +/** + * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts + */ +import React from 'react'; + +import { createInsertHTML } from '../createInsertHTML'; + +import type { InitialResults } from 'instantsearch.js'; + +const results = { + indexName: { state: {}, results: [{ hits: [] }] }, +} as unknown as InitialResults; + +/** Returns the `__html` of every injected `' }, + })(); + + const [, chatHtml] = scriptHtml(element); + + // The `<` / `>` are escaped to their unicode form... + expect(chatHtml).toContain('\\u003c/script\\u003e'); + // ...so no raw closing tag survives in the serialized payload. + expect(chatHtml).not.toContain(''); + expect(chatHtml).not.toContain('