From 38a296a765f6e3b165c151f4584ab314e168e53f Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Thu, 21 May 2026 15:15:27 +0100 Subject: [PATCH 1/8] init --- examples/react/getting-started/src/App.tsx | 58 +- examples/react/next-app-router/app/Search.tsx | 78 ++- .../connectChatPageSuggestions-test.ts | 443 ++++++++++++++++ .../connectChatPageSuggestions.ts | 499 ++++++++++++++++++ .../src/connectors/chat/connectChat.ts | 156 +----- .../instantsearch.js/src/connectors/index.ts | 1 + .../instantsearch.js/src/lib/InstantSearch.ts | 28 + .../server-chat-page-suggestions.test.ts | 256 +++++++++ .../instantsearch.js/src/lib/chat/chat.ts | 32 +- .../src/lib/chat/createAgentTransport.ts | 125 +++++ .../instantsearch.js/src/lib/chat/openChat.ts | 2 +- .../src/lib/chat/sendMessageWithContext.ts | 72 +++ packages/instantsearch.js/src/lib/server.ts | 22 +- .../src/widgets/__tests__/index.test.ts | 8 + .../chat-page-suggestions.tsx | 258 +++++++++ .../instantsearch.js/src/widgets/index.ts | 1 + .../src/connectors/useChatPageSuggestions.ts | 31 ++ .../react-instantsearch-core/src/index.ts | 1 + .../src/lib/useInstantSearchApi.ts | 12 + .../src/InitializePromise.ts | 19 +- .../src/widgets/ChatPageSuggestions.tsx | 203 +++++++ .../__tests__/__utils__/all-widgets.tsx | 2 + .../react-instantsearch/src/widgets/index.ts | 1 + 23 files changed, 2137 insertions(+), 171 deletions(-) create mode 100644 packages/instantsearch.js/src/connectors/chat-page-suggestions/__tests__/connectChatPageSuggestions-test.ts create mode 100644 packages/instantsearch.js/src/connectors/chat-page-suggestions/connectChatPageSuggestions.ts create mode 100644 packages/instantsearch.js/src/lib/__tests__/server-chat-page-suggestions.test.ts create mode 100644 packages/instantsearch.js/src/lib/chat/createAgentTransport.ts create mode 100644 packages/instantsearch.js/src/lib/chat/sendMessageWithContext.ts create mode 100644 packages/instantsearch.js/src/widgets/chat-page-suggestions/chat-page-suggestions.tsx create mode 100644 packages/react-instantsearch-core/src/connectors/useChatPageSuggestions.ts create mode 100644 packages/react-instantsearch/src/widgets/ChatPageSuggestions.tsx diff --git a/examples/react/getting-started/src/App.tsx b/examples/react/getting-started/src/App.tsx index ac1d9f2fda6..3102e624963 100644 --- a/examples/react/getting-started/src/App.tsx +++ b/examples/react/getting-started/src/App.tsx @@ -1,6 +1,5 @@ import { liteClient as algoliasearch } from 'algoliasearch/lite'; -import { Hit } from 'instantsearch.js'; -import React from 'react'; +import React, { useRef } from 'react'; import { Configure, Highlight, @@ -12,12 +11,16 @@ import { TrendingItems, Carousel, Chat, + ChatPageSuggestions, FilterSuggestions, CurrentRefinements, } from 'react-instantsearch'; +import { useInstantSearch } from 'react-instantsearch-core'; import { Panel } from './Panel'; +import type { Hit } from 'instantsearch.js'; + import 'instantsearch.css/themes/satellite.css'; import './App.css'; @@ -76,6 +79,9 @@ export function App() { headerComponent={false} /> + + +
@@ -108,6 +114,54 @@ type HitType = Hit<{ description: string; }>; +function PageSuggestions() { + const { uiState } = useInstantSearch(); + const query = uiState.instant_search?.query || ''; + + // Latest uiState lives in a ref so the context getter (and the prompt) keep + // stable references across renders. Passing fresh function/string refs would + // make `useStableValue` in `useConnector` consider the widget props changed + // every time InstantSearch emits a `render` event, which would tear down and + // re-instantiate the widget — including its underlying Chat instance — on + // every render. When the main chat fires a tool call after the handoff, that + // would loop forever and freeze the page. + const latestRef = useRef({ query, refinements: {} as Record }); + latestRef.current = { + query, + refinements: uiState.instant_search?.refinementList || {}, + }; + + const stableContextRef = useRef(() => ({ + query: latestRef.current.query, + refinements: latestRef.current.refinements, + })); + + const stablePromptRef = useRef( + 'In one short sentence, suggest a popular product category to explore.' + ); + + return ( +
+
+ Current query: {query || '(none)'} +
+ +
+ ); +} + function HitComponent({ hit }: { hit: HitType }) { return (
diff --git a/examples/react/next-app-router/app/Search.tsx b/examples/react/next-app-router/app/Search.tsx index a61285a6f3f..7a77138550a 100644 --- a/examples/react/next-app-router/app/Search.tsx +++ b/examples/react/next-app-router/app/Search.tsx @@ -1,12 +1,13 @@ 'use client'; import Link from 'next/link'; -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import { + Chat, + ChatPageSuggestions, Hits, SearchBox, RefinementList, - DynamicWidgets, } from 'react-instantsearch'; import { InstantSearchNext } from 'react-instantsearch-nextjs'; @@ -15,6 +16,13 @@ import { Panel } from '../components/Panel'; import { QueryId } from '../components/QueryId'; import { client } from '../lib/client'; +const isServer = typeof window === 'undefined'; +const PHASE = isServer ? '[SSR]' : '[CSR]'; + +console.log( + `${PHASE} Search.tsx module evaluated at ${new Date().toISOString()}` +); + export default function Search() { return (
- + + + + + +
+ + +
@@ -36,14 +52,62 @@ export default function Search() { Other page + +
); } -function FallbackComponent({ attribute }: { attribute: string }) { +function PageSuggestionsPanel() { + // Keep both context + prompt stable across renders. Fresh function/string + // refs would make `useStableValue` in `useConnector` consider the widget + // props changed on every InstantSearch `render` event, which would tear + // down and re-init the widget (and its Chat instance) on every render. + const stableContextRef = useRef(() => ({})); + const stablePromptRef = useRef('give me some tvs'); + + useEffect(() => { + console.log( + `${PHASE} PageSuggestionsPanel mounted (client hydration) at ${new Date().toISOString()}` + ); + }, []); + + // Logged on both server and client renders. + console.log( + `${PHASE} PageSuggestionsPanel rendering at ${new Date().toISOString()}` + ); + return ( - - - + ( +
+
+ {(item +
+

{(item as any).name}

+
+ )} + loaderComponent={() => { + if (isServer) { + console.log(`${PHASE} ChatPageSuggestions loader rendered`); + } + return ( +
Generating suggestion…
+ ); + }} + errorComponent={({ error }) => { + console.log(`${PHASE} ChatPageSuggestions error: ${error.message}`); + return
{error.message}
; + }} + /> ); } + diff --git a/packages/instantsearch.js/src/connectors/chat-page-suggestions/__tests__/connectChatPageSuggestions-test.ts b/packages/instantsearch.js/src/connectors/chat-page-suggestions/__tests__/connectChatPageSuggestions-test.ts new file mode 100644 index 00000000000..47b7d3a17e5 --- /dev/null +++ b/packages/instantsearch.js/src/connectors/chat-page-suggestions/__tests__/connectChatPageSuggestions-test.ts @@ -0,0 +1,443 @@ +/** + * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts + */ + +import { createSearchClient } from '@instantsearch/mocks'; +import algoliasearchHelper from 'algoliasearch-helper'; + +import { createInstantSearch } from '../../../../test/createInstantSearch'; +import { createInitOptions } from '../../../../test/createWidget'; +import { Chat } from '../../../lib/chat'; +import connectChat from '../../chat/connectChat'; +import connectChatPageSuggestions from '../connectChatPageSuggestions'; + +import type { ChatTransport, UIMessage } from '../../../lib/ai-lite'; +import type { InstantSearch, IndexWidget } from '../../../types'; +import type { ChatPageSuggestionsConnectorParams } from '../connectChatPageSuggestions'; + +function createMockTransport(): ChatTransport { + return { + sendMessages: jest.fn(() => + Promise.resolve( + new ReadableStream({ + start(ctrl) { + ctrl.close(); + }, + }) + ) + ), + reconnectToStream: jest.fn(() => Promise.resolve(null)), + }; +} + +const baseInitialMessage = 'Suggest something useful'; + +function initWidget( + overrides: Partial> = {}, + { instantSearchInstance }: { instantSearchInstance?: InstantSearch } = {} +) { + const renderFn = jest.fn(); + const makeWidget = connectChatPageSuggestions(renderFn); + const widget = makeWidget({ + agentId: 'agentId', + initialUserMessage: baseInitialMessage, + ...overrides, + }); + + const helper = algoliasearchHelper(createSearchClient(), ''); + const initOptions = createInitOptions({ + helper, + state: helper.state, + ...(instantSearchInstance ? { instantSearchInstance } : {}), + }); + + widget.init(initOptions); + + return { widget, helper, renderFn, initOptions }; +} + +describe('connectChatPageSuggestions', () => { + // The connector sends a request on init via the agent transport, which + // hits global fetch. jsdom doesn't provide fetch by default — mock it so + // every test starts from a clean baseline. + const originalFetch = global.fetch; + beforeEach(() => { + global.fetch = jest.fn(() => + Promise.resolve( + new Response(`data: {"type":"finish"}\n\ndata: [DONE]`, { + headers: { 'Content-Type': 'text/event-stream' }, + }) + ) + ) as unknown as typeof fetch; + // Chat persists messages to sessionStorage under a shared key; clear so + // earlier tests don't seed later ones. + sessionStorage.clear(); + }); + afterEach(() => { + global.fetch = originalFetch; + sessionStorage.clear(); + }); + + describe('Usage', () => { + it('throws without a render function', () => { + expect(() => { + // @ts-expect-error + connectChatPageSuggestions()({ + agentId: 'a', + initialUserMessage: 'b', + }); + }).toThrowError(/render function is not valid/); + }); + + it('throws when initialUserMessage is missing', () => { + const makeWidget = connectChatPageSuggestions(jest.fn()); + expect(() => + makeWidget({ + agentId: 'agentId', + } as ChatPageSuggestionsConnectorParams) + ).toThrowError(/initialUserMessage/); + }); + + it('throws when neither agentId nor transport is provided', () => { + const renderFn = jest.fn(); + const makeWidget = connectChatPageSuggestions(renderFn); + const widget = makeWidget({ + initialUserMessage: baseInitialMessage, + } as ChatPageSuggestionsConnectorParams); + + const helper = algoliasearchHelper(createSearchClient(), ''); + expect(() => widget.init(createInitOptions({ helper }))).toThrowError( + /agentId.*transport/ + ); + }); + + it('returns a widget descriptor', () => { + const widget = connectChatPageSuggestions(jest.fn())({ + agentId: 'agentId', + initialUserMessage: baseInitialMessage, + }); + expect(widget).toEqual( + expect.objectContaining({ + $$type: 'ais.chatPageSuggestions', + init: expect.any(Function), + render: expect.any(Function), + dispose: expect.any(Function), + }) + ); + }); + }); + + // Helper that mounts a connector with a caller-owned chat instance so we + // can spy on `sendMessage` BEFORE init runs. + function mountWithChat( + chatInstance: Chat, + params: Partial> = {} + ) { + const renderFn = jest.fn(); + const widget = connectChatPageSuggestions(renderFn)({ + ...({ + chat: chatInstance, + } as unknown as ChatPageSuggestionsConnectorParams), + initialUserMessage: baseInitialMessage, + ...params, + }); + const helper = algoliasearchHelper(createSearchClient(), ''); + const initOptions = createInitOptions({ helper }); + widget.init(initOptions); + return { widget, renderFn, initOptions }; + } + + describe('initial request', () => { + it('sends the initial user message exactly once on init', () => { + const chatInstance = new Chat({ + transport: createMockTransport(), + }); + const sendMessageSpy = jest.spyOn(chatInstance, 'sendMessage'); + mountWithChat(chatInstance); + + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + const arg = sendMessageSpy.mock.calls[0][0] as { text: string }; + expect(arg.text).toBe(baseInitialMessage); + }); + + it('does not resend when initialMessages already contains a user message', () => { + const chatInstance = new Chat({ + transport: createMockTransport(), + }); + chatInstance.messages = [ + { + id: 'preset', + role: 'user', + parts: [{ type: 'text', text: 'pre-existing' }], + } as UIMessage, + ]; + const sendMessageSpy = jest.spyOn(chatInstance, 'sendMessage'); + mountWithChat(chatInstance); + + expect(sendMessageSpy).not.toHaveBeenCalled(); + }); + + it('applies initialMessages onto an empty chat without sending', () => { + const chatInstance = new Chat({ + transport: createMockTransport(), + }); + const sendMessageSpy = jest.spyOn(chatInstance, 'sendMessage'); + const seeded: UIMessage = { + id: 'system', + role: 'system', + parts: [{ type: 'text', text: 'system note' }], + } as UIMessage; + + mountWithChat(chatInstance, { initialMessages: [seeded] }); + + // System messages are applied; the connector still sends the initial + // prompt because no *user* message was seeded. + expect(chatInstance.messages[0]).toEqual(seeded); + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('regenerate', () => { + it('clears messages and resends the prompt', () => { + const chatInstance = new Chat({ + transport: createMockTransport(), + }); + const sendMessageSpy = jest.spyOn(chatInstance, 'sendMessage'); + const { widget, initOptions } = mountWithChat(chatInstance); + + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + const renderState = widget.getWidgetRenderState(initOptions); + renderState.regenerate(); + + expect(sendMessageSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('context', () => { + it('prepends the page context to the initial message', () => { + const chatInstance = new Chat({ + transport: createMockTransport(), + }); + const sendMessageSpy = jest.spyOn(chatInstance, 'sendMessage'); + + mountWithChat(chatInstance, { + context: { route: '/products/123', locale: 'en-US' }, + }); + + const arg = sendMessageSpy.mock.calls[0][0] as { + parts: Array<{ type: string; text: string }>; + }; + expect(arg.parts).toEqual([ + { + type: 'text', + text: '{"route":"/products/123","locale":"en-US"}', + }, + { type: 'text', text: baseInitialMessage }, + ]); + }); + + it('evaluates context function at send time', () => { + const chatInstance = new Chat({ + transport: createMockTransport(), + }); + const sendMessageSpy = jest.spyOn(chatInstance, 'sendMessage'); + let route = '/initial'; + + const { widget, initOptions } = mountWithChat(chatInstance, { + context: () => ({ route }), + }); + + const first = sendMessageSpy.mock.calls[0][0] as { + parts: Array<{ text: string }>; + }; + expect(first.parts[0].text).toBe( + '{"route":"/initial"}' + ); + + route = '/changed'; + widget.getWidgetRenderState(initOptions).regenerate(); + + const second = sendMessageSpy.mock.calls[1][0] as { + parts: Array<{ text: string }>; + }; + expect(second.parts[0].text).toBe( + '{"route":"/changed"}' + ); + }); + + it('skips the context wrapper when no context is configured', () => { + const chatInstance = new Chat({ + transport: createMockTransport(), + }); + const sendMessageSpy = jest.spyOn(chatInstance, 'sendMessage'); + mountWithChat(chatInstance); + + const arg = sendMessageSpy.mock.calls[0][0] as { text: string }; + expect(arg.text).toBe(baseInitialMessage); + }); + }); + + describe('openChat handoff', () => { + it('calls the index chat setOpen + sendMessage with the page-suggestions referer', () => { + const setOpen = jest.fn(); + const indexChatSendMessage = jest.fn(); + const instantSearchInstance = createInstantSearch(); + // Seed the chat slot of the index render state, as connectChat would. + instantSearchInstance.renderState = { + indexName: { + chat: { + setOpen, + sendMessage: indexChatSendMessage, + focusInput: jest.fn(), + status: 'ready', + }, + }, + } as InstantSearch['renderState']; + + const { widget, initOptions } = initWidget({}, { instantSearchInstance }); + const renderState = widget.getWidgetRenderState(initOptions); + + expect(renderState.canHandoff).toBe(true); + renderState.openChat(); + + expect(setOpen).toHaveBeenCalledWith(true); + expect(indexChatSendMessage).toHaveBeenCalledWith( + { text: baseInitialMessage }, + { headers: { 'x-algolia-referer': 'page-suggestions' } } + ); + }); + + it('no-ops when no chat widget is mounted on the index', () => { + const instantSearchInstance = createInstantSearch(); + instantSearchInstance.renderState = {}; + const { widget, initOptions } = initWidget({}, { instantSearchInstance }); + const renderState = widget.getWidgetRenderState(initOptions); + + expect(renderState.canHandoff).toBe(false); + // Should not throw. + expect(() => renderState.openChat()).not.toThrow(); + }); + + it('reports canHandoff=false when the index chat is mid-stream', () => { + const instantSearchInstance = createInstantSearch(); + instantSearchInstance.renderState = { + indexName: { + chat: { + setOpen: jest.fn(), + sendMessage: jest.fn(), + focusInput: jest.fn(), + status: 'streaming', + }, + }, + } as InstantSearch['renderState']; + + const { widget, initOptions } = initWidget({}, { instantSearchInstance }); + const renderState = widget.getWidgetRenderState(initOptions); + expect(renderState.canHandoff).toBe(false); + }); + + it('uses the custom chatType to look up the index chat render state', () => { + const setOpen = jest.fn(); + const indexChatSendMessage = jest.fn(); + const instantSearchInstance = createInstantSearch(); + instantSearchInstance.renderState = { + indexName: { + customChat: { + setOpen, + sendMessage: indexChatSendMessage, + focusInput: jest.fn(), + status: 'ready', + }, + }, + } as unknown as InstantSearch['renderState']; + + const { widget, initOptions } = initWidget( + { chatType: 'customChat' }, + { instantSearchInstance } + ); + const renderState = widget.getWidgetRenderState(initOptions); + renderState.openChat(); + + expect(setOpen).toHaveBeenCalledWith(true); + expect(indexChatSendMessage).toHaveBeenCalled(); + }); + }); + + describe('integrates with the main connectChat widget', () => { + it('forwards the suggestion prompt through openChat to a real chat connector', async () => { + const chatRenderFn = jest.fn(); + const chatWidget = connectChat(chatRenderFn)({ + agentId: 'agentId', + }); + + const instantSearchInstance = createInstantSearch(); + const helper = instantSearchInstance.helper!; + const parent: Pick = { + getIndexId: () => 'indexName', + setIndexUiState: () => {}, + }; + + chatWidget.init( + createInitOptions({ + helper, + instantSearchInstance, + parent: parent as IndexWidget, + }) + ); + const chatRenderState = chatWidget.getWidgetRenderState( + createInitOptions({ + helper, + instantSearchInstance, + parent: parent as IndexWidget, + }) + ); + + const setOpenSpy = jest.spyOn(chatRenderState, 'setOpen'); + const chatSendMessageSpy = jest.spyOn( + chatRenderState, + 'sendMessage' as 'sendMessage' + ); + + instantSearchInstance.renderState = { + indexName: { + chat: chatRenderState, + }, + } as unknown as InstantSearch['renderState']; + + const { widget: suggestionsWidget } = initWidget( + {}, + { instantSearchInstance } + ); + const suggestionsRenderState = suggestionsWidget.getWidgetRenderState( + createInitOptions({ + helper, + instantSearchInstance, + parent: parent as IndexWidget, + }) + ); + + suggestionsRenderState.openChat(); + + expect(setOpenSpy).toHaveBeenCalledWith(true); + expect(chatSendMessageSpy).toHaveBeenCalled(); + }); + }); + + describe('dispose', () => { + it('stops a streaming request on dispose', () => { + const { widget } = initWidget(); + const stopSpy = jest.spyOn(widget.chatInstance, 'stop'); + + // Force the chat into a streaming-like state so dispose's branch runs. + ( + widget.chatInstance as unknown as { _state: { status: string } } + )._state.status = 'streaming'; + + widget.dispose!({ + helper: algoliasearchHelper(createSearchClient(), ''), + } as Parameters>[0]); + + expect(stopSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/instantsearch.js/src/connectors/chat-page-suggestions/connectChatPageSuggestions.ts b/packages/instantsearch.js/src/connectors/chat-page-suggestions/connectChatPageSuggestions.ts new file mode 100644 index 00000000000..f030ad447de --- /dev/null +++ b/packages/instantsearch.js/src/connectors/chat-page-suggestions/connectChatPageSuggestions.ts @@ -0,0 +1,499 @@ +import { DefaultChatTransport } from '../../lib/ai-lite'; +import { Chat, isChatBusy, openChat } from '../../lib/chat'; +import { createAgentTransport } from '../../lib/chat/createAgentTransport'; +import { createSendMessageWithContext } from '../../lib/chat/sendMessageWithContext'; +import { + checkRendering, + createDocumentationMessageGenerator, + noop, + warning, +} from '../../lib/utils'; + +import type { + ChatRenderState, + ChatTransport as ChatTransportOption, +} from '../chat/connectChat'; +import type { ChatContext } from '../../lib/chat/sendMessageWithContext'; +import type { ChatStatus } from '../../lib/ai-lite'; +import type { + AbstractChat, + ChatInit as ChatInitAi, + UIMessage, +} from '../../lib/chat'; +import type { + Connector, + IndexRenderState, + InstantSearch, + Renderer, + Unmounter, + UnknownWidgetParams, + WidgetRenderState, +} from '../../types'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'chat-page-suggestions', + connector: true, +}); + +type ChatInitWithoutTransport = Omit< + ChatInitAi, + 'transport' +>; + +export type ChatPageSuggestionsRenderState< + TUiMessage extends UIMessage = UIMessage +> = { + /** The latest assistant message being streamed, or `undefined` until one arrives. */ + message?: TUiMessage; + /** The streaming status of the underlying chat instance. */ + status: ChatStatus; + /** The last error from the agent, if any. */ + error: Error | undefined; + /** The prompt that drives this suggestion (echo of `initialUserMessage`). */ + prompt: string; + /** Re-runs the agent request with the same prompt + context. */ + regenerate: () => void; + /** + * Stops the in-flight request. + */ + stop: () => void; + /** + * Opens the page's main chat widget with the suggestion's prompt as the + * initial message. Requires a `connectChat` widget to be mounted with + * matching `type` (default `'chat'`). No-ops with a `__DEV__` warning when + * the chat render-state slot is missing. + */ + openChat: () => void; + /** + * Whether the handoff CTA can fire. `false` when the index chat widget is + * mid-stream, isn't mounted, or has no `sendMessage` exposed. + */ + canHandoff: boolean; +} & Pick, 'id' | 'messages' | 'addToolResult'>; + +export type ChatPageSuggestionsConnectorParams< + TUiMessage extends UIMessage = UIMessage +> = ( + | { chat: Chat } + | (ChatInitWithoutTransport & ChatTransportOption) +) & { + /** + * Prompt that drives the suggestion (e.g. "Summarize this product page"). + * Sent once when the widget mounts. + */ + initialUserMessage: string; + /** + * Additional context to send alongside the prompt (e.g. current product, + * filters, query). Serialized to JSON and prepended in a + * `` tag, mirroring `connectChat`'s `context` option. + */ + context?: ChatContext; + /** + * Optional pre-seeded messages (e.g. a system-style instruction). Applied + * only when the widget has no existing messages. + */ + initialMessages?: TUiMessage[]; + /** + * Render-state key of the main chat widget to hand off to with + * `openChat()`. Mirrors `connectChat`'s `type` param. + * @default 'chat' + */ + chatType?: string; + /** + * Identifier of this connector type. Used as the render-state key. + * @default 'chatPageSuggestions' + */ + type?: string; + /** + * Maximum time (in ms) the SSR pipeline waits for the agent response + * before aborting and rendering a placeholder. The client then hydrates + * and continues the request normally. + * @default 150 + */ + ssrTimeoutMs?: number; +}; + +export type ChatPageSuggestionsWidgetDescription< + TUiMessage extends UIMessage = UIMessage +> = { + $$type: 'ais.chatPageSuggestions'; + renderState: ChatPageSuggestionsRenderState; + indexRenderState: { + chatPageSuggestions: WidgetRenderState< + ChatPageSuggestionsRenderState, + ChatPageSuggestionsConnectorParams + >; + }; +}; + +export type ChatPageSuggestionsConnector< + TUiMessage extends UIMessage = UIMessage +> = Connector< + ChatPageSuggestionsWidgetDescription, + ChatPageSuggestionsConnectorParams +>; + +type ChatInstanceWithServerWait = + Chat & { + __chatPageSuggestionsServerWait?: Promise; + }; + +function isServerRendering(): boolean { + return typeof window === 'undefined'; +} + +function getLastAssistantMessage( + messages: TUiMessage[] +): TUiMessage | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'assistant') { + return messages[i]; + } + } + return undefined; +} + +export default (function connectChatPageSuggestions< + TWidgetParams extends UnknownWidgetParams +>( + renderFn: Renderer< + ChatPageSuggestionsRenderState, + TWidgetParams & ChatPageSuggestionsConnectorParams + >, + unmountFn: Unmounter = noop +) { + checkRendering(renderFn, withUsage()); + + return ( + widgetParams: TWidgetParams & ChatPageSuggestionsConnectorParams + ) => { + warning( + false, + 'ChatPageSuggestions is not yet stable and will change in the future.' + ); + + const { + initialUserMessage, + initialMessages, + context, + chatType = 'chat', + type = 'chatPageSuggestions', + ssrTimeoutMs = 150, + ...options + } = widgetParams || {}; + + if (!initialUserMessage) { + throw new Error( + withUsage('The `initialUserMessage` option is required.') + ); + } + + let _chatInstance: ChatInstanceWithServerWait; + let _sendMessageWithContext: typeof _chatInstance.sendMessage; + + const makeChatInstance = (instantSearchInstance: InstantSearch) => { + if ('chat' in options && options.chat) { + return options.chat; + } + + const transport = createAgentTransport({ + client: instantSearchInstance.client, + agentId: 'agentId' in options ? options.agentId : undefined, + transport: 'transport' in options ? options.transport : undefined, + algoliaAgentSuffix: 'chat-page-suggestions', + }); + + if (!transport) { + throw new Error( + withUsage('You need to provide either an `agentId` or a `transport`.') + ); + } + + const { + agentId: _agentId, + transport: _transport, + ...chatInit + } = options as unknown as { + agentId?: string; + transport?: ConstructorParameters[0]; + } & ChatInitWithoutTransport; + + // Page suggestions are ephemeral by design: never persist messages to + // sessionStorage. Otherwise a cached response from a previous prompt + // would be restored on hydration and prevent the fresh request from + // firing (the existing user message makes `shouldSendInitialRequest` + // false). + return new Chat({ + ...chatInit, + id: chatInit.id ?? `instantsearch-${type}`, + transport, + persist: false, + }); + }; + + const runRequest = () => { + _sendMessageWithContext({ text: initialUserMessage } as Parameters< + AbstractChat['sendMessage'] + >[0]); + }; + + const regenerate = () => { + const status = _chatInstance.status; + if (status === 'submitted' || status === 'streaming') { + _chatInstance.stop(); + } + _chatInstance.messages = []; + _chatInstance.clearError(); + runRequest(); + }; + + const stop = () => { + _chatInstance.stop(); + }; + + return { + $$type: 'ais.chatPageSuggestions', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + // `init` can be invoked more than once per widget instance during + // React SSR: `useWidget` calls `addWidgets([widget])` during render + // (line 95-100 in `useWidget.ts`), and a Suspense replay or a + // second SSR pass will call it again on the same widget object. + // `index.addWidgets` does not dedupe, so it re-runs `init`. Without + // this guard we'd spin up a new `Chat` and fire a fresh agent + // request on every replay. + const isFirstInit = !_chatInstance; + + if (isFirstInit) { + _chatInstance = makeChatInstance( + instantSearchInstance + ) as ChatInstanceWithServerWait; + _sendMessageWithContext = createSendMessageWithContext( + _chatInstance, + context + ); + } + + const render = () => { + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + false + ); + }; + + if (isFirstInit) { + const hasExistingMessages = _chatInstance.messages.length > 0; + if (initialMessages?.length && !hasExistingMessages) { + _chatInstance.messages = initialMessages; + } + + _chatInstance['~registerErrorCallback'](render); + _chatInstance['~registerMessagesCallback'](render); + _chatInstance['~registerStatusCallback'](render); + } + + const shouldSendInitialRequest = + isFirstInit && + _chatInstance.messages.filter((m) => m.role === 'user').length === + 0 && + // Idempotency across init() re-invocations. `sendMessage` is async + // (the user message lands in `messages` after a microtask), so a + // second init that runs synchronously after the first would + // re-fire the request before the message is observable. The flag + // is stored on the chat instance so it survives two-pass SSR. + !( + _chatInstance as ChatInstanceWithServerWait & { + __chatPageSuggestionsRequested?: boolean; + } + ).__chatPageSuggestionsRequested; + + if (shouldSendInitialRequest) { + ( + _chatInstance as ChatInstanceWithServerWait & { + __chatPageSuggestionsRequested?: boolean; + } + ).__chatPageSuggestionsRequested = true; + runRequest(); + } + + // On the server, race the in-flight request against `ssrTimeoutMs`. + // If the timeout wins, stop the chat (which aborts the underlying + // fetch) and resolve so the SSR pipeline can finish. + // The promise is stored on the chat instance so two-pass SSR renders + // reuse the same in-flight request rather than refiring. + if (isServerRendering()) { + if (!_chatInstance.__chatPageSuggestionsServerWait) { + const startedAt = Date.now(); + // eslint-disable-next-line no-console + console.log( + `[chat-page-suggestions][SSR] wait started (timeout=${ssrTimeoutMs}ms)` + ); + _chatInstance.__chatPageSuggestionsServerWait = new Promise( + (resolve) => { + // Don't treat the synchronous status === 'ready' as settled: + // sendMessage's status transition is async (a microtask), so + // immediately after `runRequest()` the status is still + // 'ready' even though the request will start. We rely on the + // timeout firing (which calls `stop()`) or the status + // transitioning to a terminal state. + const timer = setTimeout(() => { + const elapsed = Date.now() - startedAt; + // Unsubscribe BEFORE calling stop(): `stop()` synchronously + // sets `status = 'ready'` and synchronously invokes the + // status callbacks, which would otherwise log TERMINAL and + // call resolve() a second time inside this handler. + unsubscribe(); + if ( + _chatInstance.status === 'submitted' || + _chatInstance.status === 'streaming' + ) { + _chatInstance.stop(); + } + // eslint-disable-next-line no-console + console.log( + `[chat-page-suggestions][SSR] wait resolved via TIMEOUT in ${elapsed}ms (status=${_chatInstance.status})` + ); + resolve(); + }, ssrTimeoutMs); + const unsubscribe = _chatInstance['~registerStatusCallback']( + () => { + if ( + _chatInstance.status === 'error' || + _chatInstance.status === 'ready' + ) { + // Only count 'ready' as terminal once a request has + // actually run — i.e., we've seen a non-ready status. + if (!hasLeftReadyState) return; + clearTimeout(timer); + unsubscribe(); + const elapsed = Date.now() - startedAt; + // eslint-disable-next-line no-console + console.log( + `[chat-page-suggestions][SSR] wait resolved via TERMINAL status=${_chatInstance.status} in ${elapsed}ms` + ); + resolve(); + } else { + hasLeftReadyState = true; + } + } + ); + let hasLeftReadyState = false; + } + ); + } + instantSearchInstance.registerServerWait( + _chatInstance.__chatPageSuggestionsServerWait + ); + } + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance: renderOptions.instantSearchInstance, + }, + false + ); + }, + + getRenderState( + renderState, + renderOptions + ): IndexRenderState & + ChatPageSuggestionsWidgetDescription['indexRenderState'] { + return { + ...renderState, + [type as 'chatPageSuggestions']: + this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState(renderOptions) { + const { instantSearchInstance, parent } = renderOptions; + if (!_chatInstance) { + this.init!({ ...renderOptions, uiState: {}, results: undefined }); + } + + const indexId = parent.getIndexId(); + const indexChatRenderState = instantSearchInstance.renderState[indexId] + ? (instantSearchInstance.renderState[indexId][chatType as 'chat'] as + | Partial + | undefined) + : undefined; + + const handoff = () => { + if (!indexChatRenderState) { + if (__DEV__) { + warning( + false, + `No chat widget found in render state for type "${chatType}". Make sure a \`connectChat\` widget with matching \`type\` is mounted on the same index.` + ); + } + return; + } + openChat(indexChatRenderState, { + message: initialUserMessage, + referer: 'page-suggestions', + }); + }; + + const canHandoff = Boolean( + indexChatRenderState && + indexChatRenderState.sendMessage && + !isChatBusy(indexChatRenderState) + ); + + return { + message: getLastAssistantMessage(_chatInstance.messages), + messages: _chatInstance.messages, + id: _chatInstance.id, + status: _chatInstance.status, + error: _chatInstance.error, + prompt: initialUserMessage, + regenerate, + stop, + openChat: handoff, + canHandoff, + addToolResult: _chatInstance.addToolResult, + widgetParams, + }; + }, + + dispose() { + if (_chatInstance) { + const status = _chatInstance.status; + if (status === 'submitted' || status === 'streaming') { + _chatInstance.stop(); + } + } + unmountFn(); + }, + + shouldRender() { + return true; + }, + + get chatInstance() { + return _chatInstance; + }, + }; + }; +} satisfies ChatPageSuggestionsConnector); + +// Re-export so consumers can build typed wrappers without reaching into +// `lib/chat`. +export type { ChatContext } from '../../lib/chat/sendMessageWithContext'; diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index fcfb47d5065..a1ba46a9b5d 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -3,12 +3,13 @@ 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, @@ -372,101 +373,23 @@ 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 baseApi = `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/completions?compatibilityMode=ai-sdk-5`; - transport = new DefaultChatTransport({ - api: baseApi, - headers: { - 'x-algolia-application-id': appId, - 'x-algolia-api-key': apiKey, - 'x-algolia-agent': `${getAlgoliaAgent(client)}; chat`, - }, - prepareSendMessagesRequest: ({ id, messages, trigger, messageId }) => { - return { - // Bypass cache when regenerating to ensure fresh responses - api: - trigger === 'regenerate-message' - ? `${baseApi}&cache=false` - : baseApi, - body: { - id, - messageId, - messages: filterDataParts(messages), - }, - }; - }, - }); - } + const transport = createAgentTransport({ + client: instantSearchInstance.client, + agentId: 'agentId' in options ? options.agentId : undefined, + transport: 'transport' in options ? options.transport : undefined, + algoliaAgentSuffix: 'chat', + }); + 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, transport, @@ -565,8 +488,7 @@ export default (function connectChat( render(); }; - const feedback = - 'feedback' in options ? options.feedback : undefined; + const feedback = 'feedback' in options ? options.feedback : undefined; if (agentId && feedback) { const [appId, apiKey] = getAppIdAndApiKey( initOptions.instantSearchInstance.client @@ -678,56 +600,10 @@ export default (function connectChat( toolsWithAddToolResult[key] = toolWithAddToolResult; }); - const sendMessageWithContext: typeof _chatInstance.sendMessage = ( - message, - ...rest - ) => { - if (!context || !message) { - return _chatInstance.sendMessage(message, ...rest); - } - - const resolvedContext = - typeof context === 'function' ? context() : context; - - let serializedContext: string; - try { - serializedContext = JSON.stringify(resolvedContext); - } catch { - warning( - false, - 'Could not serialize chat context. The message will be sent without context.' - ); - return _chatInstance.sendMessage(message, ...rest); - } - - const contextTextPart = { - type: 'text' as const, - text: ''.concat(serializedContext).concat(''), - }; - - if ('parts' in message && message.parts) { - return _chatInstance.sendMessage({ - ...message, - parts: [contextTextPart, ...message.parts], - text: undefined, - files: undefined, - }, ...rest); - } - - const textContent = - 'text' in message && message.text ? message.text : ''; - - return _chatInstance.sendMessage({ - parts: [ - contextTextPart, - { type: 'text' as const, text: textContent }, - ], - metadata: message.metadata, - messageId: message.messageId, - files: undefined, - text: undefined, - }, ...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 e7c97a3bfdb..42f78c9b0be 100644 --- a/packages/instantsearch.js/src/connectors/index.ts +++ b/packages/instantsearch.js/src/connectors/index.ts @@ -56,5 +56,6 @@ 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 connectChatPageSuggestions } from './chat-page-suggestions/connectChatPageSuggestions'; export { default as connectFeeds } from './feeds/connectFeeds'; export { default as connectFilterSuggestions } from './filter-suggestions/connectFilterSuggestions'; diff --git a/packages/instantsearch.js/src/lib/InstantSearch.ts b/packages/instantsearch.js/src/lib/InstantSearch.ts index 19baddd252a..d7a7c821c15 100644 --- a/packages/instantsearch.js/src/lib/InstantSearch.ts +++ b/packages/instantsearch.js/src/lib/InstantSearch.ts @@ -236,6 +236,13 @@ class InstantSearch< public _mainHelperSearch?: AlgoliaSearchHelper['search']; public _hasSearchWidget: boolean = false; public _hasRecommendWidget: boolean = false; + /** + * Promises that widgets registered during SSR init that `waitForResults` + * must await before resolving. Cleared once the wait resolves so subsequent + * SSR passes start fresh. + * @internal + */ + private _serverWaitPromises: Array> = []; public _insights: InstantSearchOptions['insights']; public middleware: Array<{ creator: Middleware; @@ -266,6 +273,27 @@ Use \`InstantSearch.status === "stalled"\` instead.` return this.status === 'stalled'; } + /** + * Registers a promise that `waitForResults()` must await before resolving + * during server-side rendering. Used by widgets that need to do async work + * (e.g. AI completions) outside of the search/recommend lifecycle. + * @internal + */ + public registerServerWait(promise: Promise): void { + this._serverWaitPromises.push(promise); + } + + /** + * Returns the promises registered with `registerServerWait` and clears the + * internal list. Consumed by `waitForResults()` during SSR. + * @internal + */ + public consumeServerWaitPromises(): Array> { + const promises = this._serverWaitPromises; + this._serverWaitPromises = []; + return promises; + } + public constructor(options: InstantSearchOptions) { super(); diff --git a/packages/instantsearch.js/src/lib/__tests__/server-chat-page-suggestions.test.ts b/packages/instantsearch.js/src/lib/__tests__/server-chat-page-suggestions.test.ts new file mode 100644 index 00000000000..2f556544050 --- /dev/null +++ b/packages/instantsearch.js/src/lib/__tests__/server-chat-page-suggestions.test.ts @@ -0,0 +1,256 @@ +/** + * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts + */ + +import { + createControlledSearchClient, + createSearchClient, +} from '@instantsearch/mocks'; + +import { connectSearchBox } from '../../connectors'; +import connectChatPageSuggestions from '../../connectors/chat-page-suggestions/connectChatPageSuggestions'; +import instantsearch from '../../index.es'; +import { Chat } from '../chat'; +import { waitForResults } from '../server'; + +import type { ChatTransport, UIMessage } from '../ai-lite'; + +function createMockTransport( + opts: { + delayMs?: number; + controllerSpy?: (ctrl: AbortSignal | undefined) => void; + } = {} +): ChatTransport { + const { delayMs = 0, controllerSpy } = opts; + return { + sendMessages: jest.fn(({ abortSignal }) => { + controllerSpy?.(abortSignal); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + resolve( + new ReadableStream({ + start(ctrl) { + ctrl.close(); + }, + }) + ); + }, delayMs); + if (abortSignal) { + abortSignal.addEventListener('abort', () => { + clearTimeout(timer); + reject(new DOMException('aborted', 'AbortError')); + }); + } + }); + }) as ChatTransport['sendMessages'], + reconnectToStream: jest.fn(() => Promise.resolve(null)), + }; +} + +describe('waitForResults — server-wait promises', () => { + test('awaits promises registered via registerServerWait before resolving', async () => { + const { searchClient, searches } = createControlledSearchClient(); + const search = instantsearch({ + indexName: 'indexName', + searchClient, + }).addWidgets([connectSearchBox(() => {})({})]); + + search.start(); + + let registeredResolve: () => void = () => {}; + search.registerServerWait( + new Promise((resolve) => { + registeredResolve = resolve; + }) + ); + + const output = waitForResults(search); + + // Resolve the search before the registered promise. + searches[0].resolver(); + + // The waitForResults promise should be pending until the registered + // promise also resolves. + const wasFinished = await Promise.race([ + output.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 50)), + ]); + expect(wasFinished).toBe(false); + + // Now resolve the registered promise; waitForResults should finish. + registeredResolve(); + await expect(output).resolves.toBeDefined(); + }); + + test('does not crash when a registered promise rejects', async () => { + const { searchClient, searches } = createControlledSearchClient(); + const search = instantsearch({ + indexName: 'indexName', + searchClient, + }).addWidgets([connectSearchBox(() => {})({})]); + + search.start(); + + search.registerServerWait(Promise.reject(new Error('agent down'))); + + const output = waitForResults(search); + searches[0].resolver(); + + await expect(output).resolves.toBeDefined(); + }); +}); + +describe('connectChatPageSuggestions — SSR race', () => { + // The connector branches on `typeof window === 'undefined'`. jsdom defines + // `window`, so monkey-patch the typeof check by deleting `globalThis.window` + // for the duration of each test. + const originalWindow = globalThis.window; + + beforeEach(() => { + // Drop sessionStorage that Chat persists across instances under a + // shared key — otherwise prior tests' state leaks into this one. + if (typeof sessionStorage !== 'undefined') { + sessionStorage.clear(); + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete (globalThis as { window?: Window }).window; + }); + afterEach(() => { + (globalThis as { window?: Window }).window = originalWindow; + if (typeof sessionStorage !== 'undefined') { + sessionStorage.clear(); + } + }); + + test('aborts the agent request when the SSR timeout fires', async () => { + let capturedSignal: AbortSignal | undefined; + const transport = createMockTransport({ + delayMs: 5000, + controllerSpy: (signal) => { + capturedSignal = signal; + }, + }); + + const chatInstance = new Chat({ + id: 'test-fast-timeout', + transport, + }); + const stopSpy = jest.spyOn(chatInstance, 'stop'); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createSearchClient(), + }); + const widget = connectChatPageSuggestions(() => {})({ + chat: chatInstance, + initialUserMessage: 'help', + ssrTimeoutMs: 30, + }); + search.addWidgets([connectSearchBox(() => {})({}), widget]); + search.start(); + + const start = Date.now(); + const wait = waitForResults(search); + + const promises = ( + search as unknown as { + _serverWaitPromises: Array>; + } + )._serverWaitPromises; + expect(promises.length).toBeGreaterThanOrEqual(1); + + // Let the SSR pipeline settle. The chat promise resolves at ssrTimeoutMs, + // and we resolve the search soon after to let waitForResults complete. + await new Promise((r) => setTimeout(r, 50)); + // Stop the chat-page-suggestions race promise: it should already be + // resolved by the timeout. Verify chat.stop was called. + expect(stopSpy).toHaveBeenCalled(); + expect(capturedSignal).toBeDefined(); + + // Resolve the search side so waitForResults can finish. + // (createSearchClient returns immediately, but we need to await the next + // microtask cycle.) + await wait; + + expect(Date.now() - start).toBeLessThan(500); + }); + + test('reuses the same in-flight promise across two SSR passes', async () => { + const transport = createMockTransport({ delayMs: 1000 }); + const chatInstance = new Chat({ + id: 'test-two-pass', + transport, + }); + const sendMessagesSpy = transport.sendMessages as jest.Mock; + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createSearchClient(), + }); + const widget = connectChatPageSuggestions(() => {})({ + chat: chatInstance, + initialUserMessage: 'help', + ssrTimeoutMs: 50, + }); + search.addWidgets([connectSearchBox(() => {})({}), widget]); + search.start(); + + // sendMessage is async — let microtasks flush so transport.sendMessages + // is invoked before we assert. + await new Promise((r) => setTimeout(r, 20)); + + // The widget fired its initial request: assert via the transport spy + // (which sits below the sendMessage wrapper). + expect(sendMessagesSpy).toHaveBeenCalled(); + const transportCallsAfterFirstInit = sendMessagesSpy.mock.calls.length; + const messagesAfterFirstInit = chatInstance.messages.length; + + const promises = ( + search as unknown as { + _serverWaitPromises: Array>; + } + )._serverWaitPromises; + expect(promises.length).toBeGreaterThanOrEqual(1); + const firstPassPromise = promises[promises.length - 1]; + + // Simulate the start of a second SSR pass: drain the registered list (as + // waitForResults would after round one) and re-init the widget. + search.consumeServerWaitPromises(); + + const helper = search.helper!; + widget.init!({ + helper, + state: helper.state, + instantSearchInstance: search, + // @ts-ignore - test helper, IndexWidget shape + parent: search.mainIndex, + uiState: {}, + // eslint-disable-next-line no-undef-init + results: undefined, + // @ts-ignore - test helper + templatesConfig: search.templatesConfig, + // @ts-ignore - test helper + renderState: {}, + // @ts-ignore - test helper + createURL: () => '', + }); + + // The transport must NOT be hit a second time — the in-flight request is + // reused (idempotency flag on the chat instance). + expect(sendMessagesSpy.mock.calls.length).toBe( + transportCallsAfterFirstInit + ); + expect(chatInstance.messages.length).toBe(messagesAfterFirstInit); + + // And the SAME race promise must be re-registered (object-identity + // check confirms it came from the cached `__chatPageSuggestionsServerWait` + // on the chat instance, not a fresh one). + const promisesAfter = ( + search as unknown as { + _serverWaitPromises: Array>; + } + )._serverWaitPromises; + expect(promisesAfter[promisesAfter.length - 1]).toBe(firstPassPromise); + }); +}); diff --git a/packages/instantsearch.js/src/lib/chat/chat.ts b/packages/instantsearch.js/src/lib/chat/chat.ts index a629a8886ab..8e3ebcfb340 100644 --- a/packages/instantsearch.js/src/lib/chat/chat.ts +++ b/packages/instantsearch.js/src/lib/chat/chat.ts @@ -16,10 +16,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 (e) { + return []; + } } export class ChatState @@ -35,11 +42,17 @@ export class ChatState constructor( id: string | undefined = undefined, - initialMessages: TUiMessage[] = getDefaultInitialMessages(id) + initialMessages?: TUiMessage[], + persist: boolean = true ) { - this._messages = initialMessages; + this._messages = + initialMessages ?? + (persist ? getDefaultInitialMessages(id) : []); + if (!persist) { + return; + } const saveMessagesInLocalStorage = () => { - if (this.status === 'ready') { + if (this.status === 'ready' && typeof sessionStorage !== 'undefined') { try { sessionStorage.setItem( CACHE_KEY + (id ? `-${id}` : ''), @@ -148,9 +161,10 @@ export class Chat< constructor({ messages, agentId, + persist, ...init - }: ChatInit & { agentId?: string }) { - const state = new ChatState(agentId, messages); + }: ChatInit & { agentId?: string; persist?: boolean }) { + const state = new ChatState(agentId, messages, persist); super({ ...init, state }); this._state = state; } 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..8dc95d4710f --- /dev/null +++ b/packages/instantsearch.js/src/lib/chat/createAgentTransport.ts @@ -0,0 +1,125 @@ +import { DefaultChatTransport } from '../ai-lite'; +import { getAlgoliaAgent, getAppIdAndApiKey } from '../utils'; + +import type { SearchClient, CompositionClient } from '../../types'; +import type { UIMessage } from '../ai-lite'; + +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'`, + * `'page-suggestions'`). + */ + algoliaAgentSuffix?: string; +}; + +/** + * 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', +}: CreateAgentTransportOptions): DefaultChatTransport { + 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 as unknown as DefaultChatTransport; + } + + const [appId, apiKey] = getAppIdAndApiKey(client); + if (!appId || !apiKey) { + throw new Error( + 'Could not extract Algolia credentials from the search client.' + ); + } + + const baseApi = `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/completions?compatibilityMode=ai-sdk-5`; + + return new DefaultChatTransport({ + api: baseApi, + headers: { + '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' ? `${baseApi}&cache=false` : 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..33b4868a8eb 100644 --- a/packages/instantsearch.js/src/lib/chat/openChat.ts +++ b/packages/instantsearch.js/src/lib/chat/openChat.ts @@ -5,7 +5,7 @@ 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' | 'page-suggestions'; export type OpenChatOptions = { /** 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..c9b308750ec --- /dev/null +++ b/packages/instantsearch.js/src/lib/chat/sendMessageWithContext.ts @@ -0,0 +1,72 @@ +import { warning } from '../utils'; + +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 serialized to JSON, wrapped in `` and + * prepended to the user message as a text part. Returns a function that has + * the same shape as `Chat#sendMessage`. + * + * When `context` is undefined, or the message is empty, the original + * `sendMessage` is called unchanged. + */ +export function createSendMessageWithContext( + chat: AbstractChat, + context: ChatContext | undefined +): typeof chat.sendMessage { + const sendMessage: typeof chat.sendMessage = (message, ...rest) => { + if (!context || !message) { + return chat.sendMessage(message, ...rest); + } + + const resolvedContext = typeof context === 'function' ? context() : context; + + let serializedContext: string; + try { + serializedContext = JSON.stringify(resolvedContext); + } catch { + warning( + false, + 'Could not serialize chat context. The message will be sent without context.' + ); + return chat.sendMessage(message, ...rest); + } + + const contextTextPart = { + type: 'text' as const, + text: ''.concat(serializedContext).concat(''), + }; + + if ('parts' in message && message.parts) { + return chat.sendMessage( + { + ...message, + parts: [contextTextPart, ...message.parts], + text: undefined, + files: undefined, + }, + ...rest + ); + } + + const textContent = 'text' in message && message.text ? message.text : ''; + + return chat.sendMessage( + { + parts: [contextTextPart, { type: 'text' as const, text: textContent }], + metadata: message.metadata, + messageId: message.messageId, + files: undefined, + text: undefined, + }, + ...rest + ); + }; + + return sendMessage; +} diff --git a/packages/instantsearch.js/src/lib/server.ts b/packages/instantsearch.js/src/lib/server.ts index 99cfce33fc4..3b358eb538b 100644 --- a/packages/instantsearch.js/src/lib/server.ts +++ b/packages/instantsearch.js/src/lib/server.ts @@ -53,19 +53,29 @@ export function waitForResults( return new Promise((resolve, reject) => { let searchResultsReceived = !search._hasSearchWidget; let recommendResultsReceived = !search._hasRecommendWidget || skipRecommend; + + const tryResolve = () => { + if (!searchResultsReceived || !recommendResultsReceived) { + return; + } + // Await any promises that widgets registered during SSR init (e.g. the + // chat-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/widgets/__tests__/index.test.ts b/packages/instantsearch.js/src/widgets/__tests__/index.test.ts index c4b9a506700..c27f7defcc2 100644 --- a/packages/instantsearch.js/src/widgets/__tests__/index.test.ts +++ b/packages/instantsearch.js/src/widgets/__tests__/index.test.ts @@ -173,6 +173,14 @@ function initiateAllWidgets(): Array<[WidgetNames, Widget | IndexWidget]> { return autocomplete; } + case 'chatPageSuggestions': { + const chatPageSuggestions = widget as Widgets['chatPageSuggestions']; + return chatPageSuggestions({ + container, + agentId: 'test-agent-id', + initialUserMessage: 'help', + }); + } case 'filterSuggestions': { const filterSuggestions = widget as Widgets['filterSuggestions']; return filterSuggestions({ diff --git a/packages/instantsearch.js/src/widgets/chat-page-suggestions/chat-page-suggestions.tsx b/packages/instantsearch.js/src/widgets/chat-page-suggestions/chat-page-suggestions.tsx new file mode 100644 index 00000000000..3c04dca1846 --- /dev/null +++ b/packages/instantsearch.js/src/widgets/chat-page-suggestions/chat-page-suggestions.tsx @@ -0,0 +1,258 @@ +/** @jsx h */ + +import { cx } from 'instantsearch-ui-components'; +import { h, render } from 'preact'; + +import TemplateComponent from '../../components/Template/Template'; +import connectChatPageSuggestions from '../../connectors/chat-page-suggestions/connectChatPageSuggestions'; +import { prepareTemplateProps } from '../../lib/templating'; +import { + getContainerNode, + createDocumentationMessageGenerator, +} from '../../lib/utils'; + +import type { + ChatPageSuggestionsRenderState, + ChatPageSuggestionsConnectorParams, + ChatPageSuggestionsWidgetDescription, +} from '../../connectors/chat-page-suggestions/connectChatPageSuggestions'; +import type { UIMessage } from '../../lib/chat'; +import type { PreparedTemplateProps } from '../../lib/templating'; +import type { WidgetFactory, Renderer, Template } from '../../types'; +import type { Fragment } from 'preact'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'chat-page-suggestions', +}); + +export type ChatPageSuggestionsCSSClasses = Partial<{ + root: string; + message: string; + loader: string; + error: string; + cta: string; +}>; + +export type ChatPageSuggestionsAssistantTemplateData = { + /** The latest assistant message, if any. */ + message?: UIMessage; + /** Concatenated text content extracted from the message parts. */ + text: string; + /** The chat status driving the streaming UI. */ + status: ChatPageSuggestionsRenderState['status']; +}; + +export type ChatPageSuggestionsErrorTemplateData = { + error: Error; +}; + +export type ChatPageSuggestionsCtaTemplateData = { + /** The prompt being sent — usually echoed inside the CTA label. */ + prompt: string; + /** Whether the CTA can fire. */ + canHandoff: boolean; + /** Click handler that opens the main chat with the prompt. */ + onClick: () => void; +}; + +export type ChatPageSuggestionsTemplates = Partial<{ + /** Renders the streaming assistant message. */ + assistantMessage: Template; + /** Renders the loading state while the agent is generating. */ + loading: Template; + /** Renders an error returned by the agent. */ + error: Template; + /** Renders the "open chat" CTA button. */ + cta: Template; +}>; + +type ChatPageSuggestionsWidgetParams = { + /** CSS Selector or HTMLElement to insert the widget. */ + container: string | HTMLElement; + /** Templates to use for the widget. */ + templates?: ChatPageSuggestionsTemplates; + /** Label shown on the default CTA button. */ + ctaLabel?: string; + /** CSS classes to add. */ + cssClasses?: ChatPageSuggestionsCSSClasses; +}; + +export type ChatPageSuggestionsWidget = WidgetFactory< + ChatPageSuggestionsWidgetDescription & { + $$widgetType: 'ais.chatPageSuggestions'; + }, + ChatPageSuggestionsConnectorParams, + ChatPageSuggestionsWidgetParams +>; + +function getTextContent(message: UIMessage | undefined): string { + if (!message?.parts) return ''; + return message.parts + .map((part) => ('text' in part ? part.text : '')) + .join(''); +} + +const createRenderer = + ({ + renderState, + cssClasses, + containerNode, + templates, + ctaLabel, + }: { + containerNode: HTMLElement; + cssClasses: ChatPageSuggestionsCSSClasses; + renderState: { + templateProps?: PreparedTemplateProps; + }; + templates: ChatPageSuggestionsTemplates; + ctaLabel: string; + }): Renderer< + ChatPageSuggestionsRenderState, + Partial + > => + (props, isFirstRendering) => { + const { + instantSearchInstance, + message, + status, + error, + openChat, + canHandoff, + prompt, + } = props; + + if (isFirstRendering) { + renderState.templateProps = prepareTemplateProps({ + defaultTemplates: {} as unknown as ChatPageSuggestionsTemplates, + templatesConfig: instantSearchInstance.templatesConfig, + templates, + }); + return; + } + + const text = getTextContent(message); + const isStreaming = status === 'streaming' || status === 'submitted'; + const showLoader = isStreaming && !text; + + render( +
+ {error ? ( + templates.error ? ( + + ) : ( +
+ {error.message} +
+ ) + ) : null} + + {showLoader ? ( + templates.loading ? ( + + ) : ( +
+ Generating suggestion… +
+ ) + ) : null} + + {text && !error ? ( + templates.assistantMessage ? ( + + ) : ( +
+ {text} +
+ ) + ) : null} + + {templates.cta ? ( + + ) : ( + + )} +
, + containerNode + ); + }; + +export default (function chatPageSuggestions( + widgetParams: ChatPageSuggestionsWidgetParams & + ChatPageSuggestionsConnectorParams +) { + const { + container, + templates = {}, + cssClasses = {}, + ctaLabel = 'Continue in chat', + ...connectorParams + } = widgetParams || {}; + + if (!container) { + throw new Error(withUsage('The `container` option is required.')); + } + + const containerNode = getContainerNode(container); + + const specializedRenderer = createRenderer({ + containerNode, + cssClasses, + renderState: {}, + templates, + ctaLabel, + }); + + const makeWidget = connectChatPageSuggestions(specializedRenderer, () => + render(null, containerNode) + ); + + return { + ...makeWidget(connectorParams), + $$widgetType: 'ais.chatPageSuggestions', + }; +} satisfies ChatPageSuggestionsWidget); + +// Keep Fragment imported so JSX fragments (if added later) compile. +export type { Fragment }; diff --git a/packages/instantsearch.js/src/widgets/index.ts b/packages/instantsearch.js/src/widgets/index.ts index 9b2144d826a..5f93ba12ad9 100644 --- a/packages/instantsearch.js/src/widgets/index.ts +++ b/packages/instantsearch.js/src/widgets/index.ts @@ -62,4 +62,5 @@ export { default as voiceSearch } from './voice-search/voice-search'; export { default as frequentlyBoughtTogether } from './frequently-bought-together/frequently-bought-together'; export { default as lookingSimilar } from './looking-similar/looking-similar'; export { default as chat } from './chat/chat'; +export { default as chatPageSuggestions } from './chat-page-suggestions/chat-page-suggestions'; export { default as filterSuggestions } from './filter-suggestions/filter-suggestions'; diff --git a/packages/react-instantsearch-core/src/connectors/useChatPageSuggestions.ts b/packages/react-instantsearch-core/src/connectors/useChatPageSuggestions.ts new file mode 100644 index 00000000000..47b1c5cd1b6 --- /dev/null +++ b/packages/react-instantsearch-core/src/connectors/useChatPageSuggestions.ts @@ -0,0 +1,31 @@ +import connectChatPageSuggestions from 'instantsearch.js/es/connectors/chat-page-suggestions/connectChatPageSuggestions'; + +import { useConnector } from '../hooks/useConnector'; + +import type { AdditionalWidgetProperties } from '../hooks/useConnector'; +import type { + ChatPageSuggestionsConnector, + ChatPageSuggestionsConnectorParams, + ChatPageSuggestionsWidgetDescription, +} from 'instantsearch.js/es/connectors/chat-page-suggestions/connectChatPageSuggestions'; +import type { UIMessage } from 'instantsearch.js/es/lib/chat'; + +export type UseChatPageSuggestionsProps< + TUiMessage extends UIMessage = UIMessage +> = ChatPageSuggestionsConnectorParams; + +export function useChatPageSuggestions< + TUiMessage extends UIMessage = UIMessage +>( + props: UseChatPageSuggestionsProps, + additionalWidgetProperties?: AdditionalWidgetProperties +) { + return useConnector< + ChatPageSuggestionsConnectorParams, + ChatPageSuggestionsWidgetDescription + >( + connectChatPageSuggestions as unknown as ChatPageSuggestionsConnector, + props, + additionalWidgetProperties + ); +} diff --git a/packages/react-instantsearch-core/src/index.ts b/packages/react-instantsearch-core/src/index.ts index bb96081d093..4931d6b51e7 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/useChatPageSuggestions'; export * from './connectors/useClearRefinements'; export * from './connectors/useConfigure'; export * from './connectors/useCurrentRefinements'; diff --git a/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts b/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts index 8269c64c47e..a8b2f366659 100644 --- a/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts +++ b/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts @@ -53,6 +53,18 @@ 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>; }; export function useInstantSearchApi( diff --git a/packages/react-instantsearch-nextjs/src/InitializePromise.ts b/packages/react-instantsearch-nextjs/src/InitializePromise.ts index 0cc63a0dc3f..09a4e0efbce 100644 --- a/packages/react-instantsearch-nextjs/src/InitializePromise.ts +++ b/packages/react-instantsearch-nextjs/src/InitializePromise.ts @@ -66,17 +66,24 @@ export function InitializePromise({ nonce }: InitializePromiseProps) { new Promise((resolve) => { let searchReceived = false; let recommendReceived = false; + const tryResolve = () => { + if (search._hasSearchWidget && !searchReceived) return; + if (search._hasRecommendWidget && !recommendReceived) return; + // Await any promises that widgets registered during SSR init (e.g. the + // chat-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() + ); + }; search.mainHelper!.derivedHelpers[0].once('result', () => { searchReceived = true; - if (!search._hasRecommendWidget || recommendReceived) { - resolve(); - } + tryResolve(); }); search.mainHelper!.derivedHelpers[0].once('recommend:result', () => { recommendReceived = true; - if (!search._hasSearchWidget || searchReceived) { - resolve(); - } + tryResolve(); }); }); diff --git a/packages/react-instantsearch/src/widgets/ChatPageSuggestions.tsx b/packages/react-instantsearch/src/widgets/ChatPageSuggestions.tsx new file mode 100644 index 00000000000..889ed55f0e9 --- /dev/null +++ b/packages/react-instantsearch/src/widgets/ChatPageSuggestions.tsx @@ -0,0 +1,203 @@ +import { + createChatMessageComponent, + cx, +} from 'instantsearch-ui-components'; +import React, { createElement, Fragment, useMemo } from 'react'; +import { + useChatPageSuggestions, + useInstantSearch, +} from 'react-instantsearch-core'; + +import { createDefaultTools } from './Chat'; + +import type { + AddToolResultWithOutput, + ChatMessageProps, + ClientSideTool, + ClientSideTools, + Pragma, + RecommendComponentProps, + RecordWithObjectID, + UserClientSideTools, +} from 'instantsearch-ui-components'; +import type { IndexUiState } from 'instantsearch.js'; +import type { ChatStatus } from 'instantsearch.js/es/lib/ai-lite'; +import type { UIMessage } from 'instantsearch.js/es/lib/chat'; +import type { UseChatPageSuggestionsProps } from 'react-instantsearch-core'; + +export type ChatPageSuggestionsClassNames = Partial<{ + root: string; + message: string; + loader: string; + error: string; + cta: string; +}>; + +type ItemComponent = RecommendComponentProps['itemComponent']; + +export type ChatPageSuggestionsProps< + TObject extends RecordWithObjectID = RecordWithObjectID, + TUiMessage extends UIMessage = UIMessage +> = UseChatPageSuggestionsProps & { + classNames?: ChatPageSuggestionsClassNames; + /** Label displayed on the default CTA. Defaults to "Continue in chat". */ + ctaLabel?: string; + /** Tool renderers; merged on top of the same defaults as ``. */ + tools?: UserClientSideTools; + /** Item renderer passed to the default search-index / recommend tools. */ + itemComponent?: ItemComponent; + /** Builds the URL used by the search-index tool when navigating away. */ + getSearchPageURL?: (nextUiState: IndexUiState) => string; + /** + * Custom CTA component. Receives the handler + whether the CTA is + * actionable. Use this to plug your own button. + */ + ctaComponent?: (props: { + onClick: () => void; + disabled: boolean; + prompt: string; + }) => JSX.Element | null; + /** + * Custom renderer for the streaming assistant message. When omitted, the + * message is rendered with the same `ChatMessage` component (and tool + * registry) the main `` widget uses, so tool parts render inline. + */ + messageComponent?: (props: { + message?: TUiMessage; + text: string; + status: ChatStatus; + }) => JSX.Element | null; + /** Custom renderer for the loading state shown while text is empty. */ + loaderComponent?: () => JSX.Element | null; + /** Custom renderer for the error state. */ + errorComponent?: (props: { error: Error }) => JSX.Element | null; +}; + +function getTextContent(message: UIMessage | undefined): string { + if (!message?.parts) return ''; + return message.parts + .map((part) => ('text' in part ? part.text : '')) + .join(''); +} + +const ChatMessageUi = createChatMessageComponent({ + createElement: createElement as Pragma, + Fragment, +}); + +export function ChatPageSuggestions< + TObject extends RecordWithObjectID = RecordWithObjectID, + TUiMessage extends UIMessage = UIMessage +>({ + classNames = {}, + ctaLabel = 'Continue in chat', + ctaComponent: CtaComponent, + messageComponent: MessageComponent, + loaderComponent: LoaderComponent, + errorComponent: ErrorComponent, + tools: userTools, + itemComponent, + getSearchPageURL, + ...connectorProps +}: ChatPageSuggestionsProps) { + const { + message, + status, + error, + openChat, + canHandoff, + prompt, + addToolResult, + } = useChatPageSuggestions(connectorProps); + + const { indexUiState, setIndexUiState } = useInstantSearch(); + + const tools = useMemo(() => { + const defaults = createDefaultTools(itemComponent, getSearchPageURL); + const merged: UserClientSideTools = { ...defaults, ...userTools }; + const wrapped: ClientSideTools = {}; + Object.entries(merged).forEach(([key, tool]) => { + const wrappedTool: ClientSideTool = { + ...tool, + addToolResult: addToolResult as AddToolResultWithOutput, + applyFilters: (() => ({})) as unknown as ClientSideTool['applyFilters'], + sendEvent: () => {}, + }; + wrapped[key] = wrappedTool; + }); + return wrapped; + }, [addToolResult, getSearchPageURL, itemComponent, userTools]); + + const text = getTextContent(message); + const isStreaming = status === 'streaming' || status === 'submitted'; + const showLoader = isStreaming && !text && !message?.parts?.length; + + return ( +
+ {error ? ( + ErrorComponent ? ( + + ) : ( +
+ {error.message} +
+ ) + ) : null} + + {showLoader ? ( + LoaderComponent ? ( + + ) : ( +
+ Generating suggestion… +
+ ) + ) : null} + + {message && !error ? ( + MessageComponent ? ( + + ) : ( +
+ {}} + /> +
+ ) + ) : null} + + {CtaComponent ? ( + + ) : ( + + )} +
+ ); +} diff --git a/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx b/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx index bfca035da2c..257cec7cd1f 100644 --- a/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx +++ b/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx @@ -18,6 +18,7 @@ const NON_WIDGETS = [ 'Snippet', 'PoweredBy', 'Chat', + 'ChatPageSuggestions', 'createDefaultTools', 'SearchIndexToolType', 'RecommendToolType', @@ -30,6 +31,7 @@ type RegularWidgets = Omit; // Non-components that should be excluded from SingleWidget type const NON_COMPONENTS = [ + 'ChatPageSuggestions', 'createDefaultTools', 'SearchIndexToolType', 'RecommendToolType', diff --git a/packages/react-instantsearch/src/widgets/index.ts b/packages/react-instantsearch/src/widgets/index.ts index a3c8bb36f80..80aa3068221 100644 --- a/packages/react-instantsearch/src/widgets/index.ts +++ b/packages/react-instantsearch/src/widgets/index.ts @@ -1,6 +1,7 @@ export * from './Autocomplete'; export * from './Breadcrumb'; export * from './Chat'; +export * from './ChatPageSuggestions'; export * from './ClearRefinements'; export * from './CurrentRefinements'; export * from './FrequentlyBoughtTogether'; From 8d1e0b4ba7c0d023232110be41a2683d3b448c8b Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Thu, 21 May 2026 15:22:38 +0100 Subject: [PATCH 2/8] fix types --- .../__tests__/connectChatPageSuggestions-test.ts | 14 ++++++-------- packages/instantsearch.js/src/lib/InstantSearch.ts | 2 +- .../__tests__/server-chat-page-suggestions.test.ts | 6 +----- .../instantsearch.js/test/createInstantSearch.ts | 3 +++ 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/instantsearch.js/src/connectors/chat-page-suggestions/__tests__/connectChatPageSuggestions-test.ts b/packages/instantsearch.js/src/connectors/chat-page-suggestions/__tests__/connectChatPageSuggestions-test.ts index 47b7d3a17e5..afe7cdffe80 100644 --- a/packages/instantsearch.js/src/connectors/chat-page-suggestions/__tests__/connectChatPageSuggestions-test.ts +++ b/packages/instantsearch.js/src/connectors/chat-page-suggestions/__tests__/connectChatPageSuggestions-test.ts @@ -135,12 +135,10 @@ describe('connectChatPageSuggestions', () => { ) { const renderFn = jest.fn(); const widget = connectChatPageSuggestions(renderFn)({ - ...({ - chat: chatInstance, - } as unknown as ChatPageSuggestionsConnectorParams), + chat: chatInstance, initialUserMessage: baseInitialMessage, ...params, - }); + } as unknown as ChatPageSuggestionsConnectorParams); const helper = algoliasearchHelper(createSearchClient(), ''); const initOptions = createInitOptions({ helper }); widget.init(initOptions); @@ -292,7 +290,7 @@ describe('connectChatPageSuggestions', () => { status: 'ready', }, }, - } as InstantSearch['renderState']; + } as unknown as InstantSearch['renderState']; const { widget, initOptions } = initWidget({}, { instantSearchInstance }); const renderState = widget.getWidgetRenderState(initOptions); @@ -329,7 +327,7 @@ describe('connectChatPageSuggestions', () => { status: 'streaming', }, }, - } as InstantSearch['renderState']; + } as unknown as InstantSearch['renderState']; const { widget, initOptions } = initWidget({}, { instantSearchInstance }); const renderState = widget.getWidgetRenderState(initOptions); @@ -433,9 +431,9 @@ describe('connectChatPageSuggestions', () => { widget.chatInstance as unknown as { _state: { status: string } } )._state.status = 'streaming'; - widget.dispose!({ + (widget.dispose as unknown as (opts?: unknown) => void)({ helper: algoliasearchHelper(createSearchClient(), ''), - } as Parameters>[0]); + }); expect(stopSpy).toHaveBeenCalled(); }); diff --git a/packages/instantsearch.js/src/lib/InstantSearch.ts b/packages/instantsearch.js/src/lib/InstantSearch.ts index d7a7c821c15..0faa7a23d85 100644 --- a/packages/instantsearch.js/src/lib/InstantSearch.ts +++ b/packages/instantsearch.js/src/lib/InstantSearch.ts @@ -242,7 +242,7 @@ class InstantSearch< * SSR passes start fresh. * @internal */ - private _serverWaitPromises: Array> = []; + public _serverWaitPromises: Array> = []; public _insights: InstantSearchOptions['insights']; public middleware: Array<{ creator: Middleware; diff --git a/packages/instantsearch.js/src/lib/__tests__/server-chat-page-suggestions.test.ts b/packages/instantsearch.js/src/lib/__tests__/server-chat-page-suggestions.test.ts index 2f556544050..a978c399ce6 100644 --- a/packages/instantsearch.js/src/lib/__tests__/server-chat-page-suggestions.test.ts +++ b/packages/instantsearch.js/src/lib/__tests__/server-chat-page-suggestions.test.ts @@ -223,18 +223,14 @@ describe('connectChatPageSuggestions — SSR race', () => { helper, state: helper.state, instantSearchInstance: search, - // @ts-ignore - test helper, IndexWidget shape parent: search.mainIndex, uiState: {}, // eslint-disable-next-line no-undef-init results: undefined, - // @ts-ignore - test helper templatesConfig: search.templatesConfig, - // @ts-ignore - test helper renderState: {}, - // @ts-ignore - test helper createURL: () => '', - }); + } as unknown as Parameters>[0]); // The transport must NOT be hit a second time — the in-flight request is // reused (idempotency flag on the chat instance). diff --git a/packages/instantsearch.js/test/createInstantSearch.ts b/packages/instantsearch.js/test/createInstantSearch.ts index bc7fb1d3c3b..1aaaf1f8f37 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()), From 599683b3f7fd045a2a941faf8a6c4edf0c0d6c5a Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Fri, 22 May 2026 15:59:30 +0100 Subject: [PATCH 3/8] fix duplicate requests --- examples/react/getting-started/src/App.tsx | 18 +- examples/react/next-app-router/app/Search.tsx | 48 ++-- .../connectChatPageSuggestions.ts | 233 +++++++++++++----- .../src/connectors/chat/connectChat.ts | 6 +- .../instantsearch.js/src/lib/InstantSearch.ts | 8 + .../server-chat-page-suggestions.test.ts | 2 +- .../test/createInstantSearch.ts | 1 + .../components/InstantSearchSSRProvider.tsx | 6 + .../src/lib/useInstantSearchApi.ts | 10 + .../src/server/getServerState.tsx | 10 +- .../src/InitializePromise.ts | 10 +- .../src/InstantSearchNext.tsx | 8 + .../src/createInsertHTML.tsx | 30 ++- 13 files changed, 282 insertions(+), 108 deletions(-) diff --git a/examples/react/getting-started/src/App.tsx b/examples/react/getting-started/src/App.tsx index 3102e624963..cf8053e2c96 100644 --- a/examples/react/getting-started/src/App.tsx +++ b/examples/react/getting-started/src/App.tsx @@ -131,14 +131,8 @@ function PageSuggestions() { refinements: uiState.instant_search?.refinementList || {}, }; - const stableContextRef = useRef(() => ({ - query: latestRef.current.query, - refinements: latestRef.current.refinements, - })); - - const stablePromptRef = useRef( - 'In one short sentence, suggest a popular product category to explore.' - ); + const stableContextRef = useRef(() => ({})); + const stablePromptRef = useRef('give me some tvs'); return (
( +
+
+ {(item +
+

{(item as any).name}

+
+ )} />
); diff --git a/examples/react/next-app-router/app/Search.tsx b/examples/react/next-app-router/app/Search.tsx index 7a77138550a..b7454c8c669 100644 --- a/examples/react/next-app-router/app/Search.tsx +++ b/examples/react/next-app-router/app/Search.tsx @@ -53,10 +53,7 @@ export default function Search() { Other page - + ); } @@ -85,29 +82,28 @@ function PageSuggestionsPanel() { agentId="eedef238-5468-470d-bc37-f99fa741bd25" initialUserMessage={stablePromptRef.current} context={stableContextRef.current} - ssrTimeoutMs={1500} + ssrTimeoutMs={200} ctaLabel="Continue in chat" - itemComponent={({ item }) => ( -
-
- {(item -
-

{(item as any).name}

-
- )} - loaderComponent={() => { - if (isServer) { - console.log(`${PHASE} ChatPageSuggestions loader rendered`); - } - return ( -
Generating suggestion…
- ); - }} - errorComponent={({ error }) => { - console.log(`${PHASE} ChatPageSuggestions error: ${error.message}`); - return
{error.message}
; - }} + // itemComponent={({ item }) => ( + //
+ //
+ // {(item + //
+ //

{(item as any).name}

+ //
+ // )} + // loaderComponent={() => { + // if (isServer) { + // console.log(`${PHASE} ChatPageSuggestions loader rendered`); + // } + // return ( + //
Generating suggestion…
+ // ); + // }} + // errorComponent={({ error }) => { + // console.log(`${PHASE} ChatPageSuggestions error: ${error.message}`); + // return
{error.message}
; + // }} /> ); } - diff --git a/packages/instantsearch.js/src/connectors/chat-page-suggestions/connectChatPageSuggestions.ts b/packages/instantsearch.js/src/connectors/chat-page-suggestions/connectChatPageSuggestions.ts index f030ad447de..61b18d927c6 100644 --- a/packages/instantsearch.js/src/connectors/chat-page-suggestions/connectChatPageSuggestions.ts +++ b/packages/instantsearch.js/src/connectors/chat-page-suggestions/connectChatPageSuggestions.ts @@ -1,4 +1,3 @@ -import { DefaultChatTransport } from '../../lib/ai-lite'; import { Chat, isChatBusy, openChat } from '../../lib/chat'; import { createAgentTransport } from '../../lib/chat/createAgentTransport'; import { createSendMessageWithContext } from '../../lib/chat/sendMessageWithContext'; @@ -6,20 +5,18 @@ import { checkRendering, createDocumentationMessageGenerator, noop, + safelyRunOnBrowser, warning, } from '../../lib/utils'; -import type { - ChatRenderState, - ChatTransport as ChatTransportOption, -} from '../chat/connectChat'; -import type { ChatContext } from '../../lib/chat/sendMessageWithContext'; +import type { DefaultChatTransport } from '../../lib/ai-lite'; import type { ChatStatus } from '../../lib/ai-lite'; import type { AbstractChat, ChatInit as ChatInitAi, UIMessage, } from '../../lib/chat'; +import type { ChatContext } from '../../lib/chat/sendMessageWithContext'; import type { Connector, IndexRenderState, @@ -29,6 +26,10 @@ import type { UnknownWidgetParams, WidgetRenderState, } from '../../types'; +import type { + ChatRenderState, + ChatTransport as ChatTransportOption, +} from '../chat/connectChat'; const withUsage = createDocumentationMessageGenerator({ name: 'chat-page-suggestions', @@ -136,10 +137,71 @@ export type ChatPageSuggestionsConnector< type ChatInstanceWithServerWait = Chat & { __chatPageSuggestionsServerWait?: Promise; + __chatPageSuggestionsRequested?: boolean; }; +type InstantSearchWithChatStates = InstantSearch & { + _initialChatStates: Record | null; +}; + function isServerRendering(): boolean { - return typeof window === 'undefined'; + return safelyRunOnBrowser(() => false, { fallback: () => true }); +} + +// Stash chat instances per (InstantSearch, id) so that connector closures +// recreated by React (e.g. when `useStableValue` invalidates `useMemo` due to +// an inline function prop) share the same underlying `Chat`. Without this, +// each new closure constructs a fresh `Chat` whose idempotency flag is +// unset, and the initial agent request fires once per closure recreation. +// WeakMap on the InstantSearch instance means entries are reclaimed when the +// search instance itself is GC'd; no explicit cleanup is required. +const chatInstanceRegistry = new WeakMap< + InstantSearch, + Map> +>(); + +// Client-only secondary registry keyed by chat id alone. Next.js App Router +// can produce multiple `` instances during hydration (one +// during the initial render, another after some upstream context settles). +// Each instance gets its own `WeakMap` entry above, so without this fallback +// each one would construct a fresh `Chat` and fire its own initial request. +// +// Scoped to the client bundle by the `typeof window` check so that concurrent +// SSR requests in a Node process never share Chat state across users. +const clientGlobalChatRegistry: Map> | null = + safelyRunOnBrowser(() => new Map>(), { + fallback: () => null, + }); + +function getOrCreateChatInstance( + instantSearchInstance: InstantSearch, + id: string, + factory: () => Chat +): Chat { + let perSearch = chatInstanceRegistry.get(instantSearchInstance); + if (!perSearch) { + perSearch = new Map(); + chatInstanceRegistry.set(instantSearchInstance, perSearch); + } + const existing = perSearch.get(id) as Chat | undefined; + if (existing) { + return existing; + } + if (clientGlobalChatRegistry) { + const fromGlobal = clientGlobalChatRegistry.get(id) as + | Chat + | undefined; + if (fromGlobal) { + perSearch.set(id, fromGlobal as unknown as Chat); + return fromGlobal; + } + } + const created = factory(); + perSearch.set(id, created as unknown as Chat); + if (clientGlobalChatRegistry) { + clientGlobalChatRegistry.set(id, created as unknown as Chat); + } + return created; } function getLastAssistantMessage( @@ -190,6 +252,9 @@ export default (function connectChatPageSuggestions< let _chatInstance: ChatInstanceWithServerWait; let _sendMessageWithContext: typeof _chatInstance.sendMessage; + let unsubscribeError: (() => void) | undefined; + let unsubscribeMessages: (() => void) | undefined; + let unsubscribeStatus: (() => void) | undefined; const makeChatInstance = (instantSearchInstance: InstantSearch) => { if ('chat' in options && options.chat) { @@ -223,15 +288,33 @@ export default (function connectChatPageSuggestions< // would be restored on hydration and prevent the fresh request from // firing (the existing user message makes `shouldSendInitialRequest` // false). - return new Chat({ - ...chatInit, - id: chatInit.id ?? `instantsearch-${type}`, - transport, - persist: false, - }); + const resolvedId = chatInit.id ?? `instantsearch-${type}`; + return getOrCreateChatInstance( + instantSearchInstance, + resolvedId, + () => + new Chat({ + ...chatInit, + id: resolvedId, + transport, + persist: false, + }) + ); }; const runRequest = () => { + // Defensive idempotency at the call site. The `init()` guard alone is + // not enough: when React recreates the connector closure (e.g. unstable + // function props invalidating `useStableValue`), each new closure's + // `init()` runs with `isFirstInit = true` and *can* race the + // flag-on-instance check before the shared `Chat`'s flag has settled. + // Anchoring the guard here — directly around the only `sendMessage` + // call site — ensures one logical agent request per chat session + // regardless of how many init() calls fire. + if (_chatInstance.__chatPageSuggestionsRequested) { + return; + } + _chatInstance.__chatPageSuggestionsRequested = true; _sendMessageWithContext({ text: initialUserMessage } as Parameters< AbstractChat['sendMessage'] >[0]); @@ -244,6 +327,8 @@ export default (function connectChatPageSuggestions< } _chatInstance.messages = []; _chatInstance.clearError(); + // Re-arm the runRequest gate so the user-triggered regeneration fires. + _chatInstance.__chatPageSuggestionsRequested = false; runRequest(); }; @@ -288,36 +373,36 @@ export default (function connectChatPageSuggestions< if (isFirstInit) { const hasExistingMessages = _chatInstance.messages.length > 0; - if (initialMessages?.length && !hasExistingMessages) { + + // Hydrate from the SSR snapshot if one was produced during server + // rendering. We mark the request as already-fired so the initial + // send below is skipped — the server already did the work and the + // client doesn't need to refire it. + const ssrSnapshots = ( + instantSearchInstance as InstantSearchWithChatStates + )._initialChatStates; + const ssrSnapshot = + ssrSnapshots && ssrSnapshots[_chatInstance.id] + ? (ssrSnapshots[_chatInstance.id] as TUiMessage[]) + : undefined; + if (ssrSnapshot && ssrSnapshot.length && !hasExistingMessages) { + _chatInstance.messages = ssrSnapshot; + _chatInstance.__chatPageSuggestionsRequested = true; + } else if (initialMessages?.length && !hasExistingMessages) { _chatInstance.messages = initialMessages; } - _chatInstance['~registerErrorCallback'](render); - _chatInstance['~registerMessagesCallback'](render); - _chatInstance['~registerStatusCallback'](render); + unsubscribeError = _chatInstance['~registerErrorCallback'](render); + unsubscribeMessages = + _chatInstance['~registerMessagesCallback'](render); + unsubscribeStatus = _chatInstance['~registerStatusCallback'](render); } - const shouldSendInitialRequest = - isFirstInit && - _chatInstance.messages.filter((m) => m.role === 'user').length === - 0 && - // Idempotency across init() re-invocations. `sendMessage` is async - // (the user message lands in `messages` after a microtask), so a - // second init that runs synchronously after the first would - // re-fire the request before the message is observable. The flag - // is stored on the chat instance so it survives two-pass SSR. - !( - _chatInstance as ChatInstanceWithServerWait & { - __chatPageSuggestionsRequested?: boolean; - } - ).__chatPageSuggestionsRequested; - - if (shouldSendInitialRequest) { - ( - _chatInstance as ChatInstanceWithServerWait & { - __chatPageSuggestionsRequested?: boolean; - } - ).__chatPageSuggestionsRequested = true; + // Only the first init per closure attempts a send. `runRequest` + // itself enforces idempotency across the shared chat instance, so + // any extra init() calls (two-pass SSR, closure recreations) are + // safe — they no-op inside runRequest if the flag is already set. + if (isFirstInit) { runRequest(); } @@ -371,6 +456,20 @@ export default (function connectChatPageSuggestions< if (!hasLeftReadyState) return; clearTimeout(timer); unsubscribe(); + // Snapshot messages into the InstantSearch instance so + // they ride through the SSR boundary alongside + // `_initialResults`. Only snapshot on success — on error + // we want the client to retry rather than hydrate into + // a broken state. + if (_chatInstance.status === 'ready') { + const target = + instantSearchInstance as InstantSearchWithChatStates; + if (!target._initialChatStates) { + target._initialChatStates = {}; + } + target._initialChatStates[_chatInstance.id] = + _chatInstance.messages; + } const elapsed = Date.now() - startedAt; // eslint-disable-next-line no-console console.log( @@ -429,14 +528,25 @@ export default (function connectChatPageSuggestions< } const indexId = parent.getIndexId(); - const indexChatRenderState = instantSearchInstance.renderState[indexId] - ? (instantSearchInstance.renderState[indexId][chatType as 'chat'] as - | Partial - | undefined) - : undefined; - + const readIndexChatRenderState = (): + | Partial + | undefined => + instantSearchInstance.renderState[indexId] + ? (instantSearchInstance.renderState[indexId][ + chatType as 'chat' + ] as Partial | undefined) + : undefined; + const indexChatRenderState = readIndexChatRenderState(); + + // Read lazily at click time. The chat-page-suggestions widget's + // `getWidgetRenderState` may run before the main chat widget has + // populated `renderState[indexId][chatType]` (e.g. on SSR-hydrated + // first render, when no chat streaming event later triggers a + // re-render). Looking up `setOpen`/`sendMessage` at click time + // guarantees we see them once both widgets have mounted. const handoff = () => { - if (!indexChatRenderState) { + const currentChatRenderState = readIndexChatRenderState(); + if (!currentChatRenderState) { if (__DEV__) { warning( false, @@ -445,17 +555,20 @@ export default (function connectChatPageSuggestions< } return; } - openChat(indexChatRenderState, { + openChat(currentChatRenderState, { message: initialUserMessage, referer: 'page-suggestions', }); }; - const canHandoff = Boolean( - indexChatRenderState && - indexChatRenderState.sendMessage && + // Optimistic when the main chat widget hasn't been observed yet — + // by click time it should be mounted, and `handoff()` reads fresh + // state then. We only flip to `false` when we can see the chat and + // know it's busy. + const canHandoff = indexChatRenderState + ? Boolean(indexChatRenderState.sendMessage) && !isChatBusy(indexChatRenderState) - ); + : true; return { message: getLastAssistantMessage(_chatInstance.messages), @@ -474,12 +587,20 @@ export default (function connectChatPageSuggestions< }, dispose() { - if (_chatInstance) { - const status = _chatInstance.status; - if (status === 'submitted' || status === 'streaming') { - _chatInstance.stop(); - } - } + // Unsubscribe THIS closure's render callbacks from the (potentially + // shared) chat instance. We deliberately do NOT call + // `_chatInstance.stop()` here: when React recreates the connector + // closure (e.g. unstable function props invalidating `useStableValue`), + // the prior closure is disposed but the new closure is mounted on the + // same chat instance. Stopping would abort the request the new closure + // is observing. Real teardown happens when `` itself + // unmounts and the registry's WeakMap entry is reclaimed. + unsubscribeError?.(); + unsubscribeMessages?.(); + unsubscribeStatus?.(); + unsubscribeError = undefined; + unsubscribeMessages = undefined; + unsubscribeStatus = undefined; unmountFn(); }, diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index a1ba46a9b5d..7aa280c3394 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -1,7 +1,4 @@ -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'; @@ -19,6 +16,7 @@ import { } from '../../lib/utils'; import { flat } from '../../lib/utils/flat'; +import type { DefaultChatTransport } from '../../lib/ai-lite'; import type { AbstractChat, ChatInit as ChatInitAi, diff --git a/packages/instantsearch.js/src/lib/InstantSearch.ts b/packages/instantsearch.js/src/lib/InstantSearch.ts index 0faa7a23d85..576dcba8c49 100644 --- a/packages/instantsearch.js/src/lib/InstantSearch.ts +++ b/packages/instantsearch.js/src/lib/InstantSearch.ts @@ -229,6 +229,13 @@ class InstantSearch< public _searchStalledTimer: any; public _initialUiState: TUiState; public _initialResults: InitialResults | null; + /** + * Snapshot of chat-widget messages produced during SSR, keyed by chat + * instance id. Hydrated on the client so chat widgets can skip re-firing + * their initial agent request after server rendering succeeded. + * @internal + */ + public _initialChatStates: Record | null; public _manuallyResetScheduleSearch: boolean = false; public _resetScheduleSearch?: () => void; public _createURL: CreateURL; @@ -403,6 +410,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/__tests__/server-chat-page-suggestions.test.ts b/packages/instantsearch.js/src/lib/__tests__/server-chat-page-suggestions.test.ts index a978c399ce6..c647c3de0f8 100644 --- a/packages/instantsearch.js/src/lib/__tests__/server-chat-page-suggestions.test.ts +++ b/packages/instantsearch.js/src/lib/__tests__/server-chat-page-suggestions.test.ts @@ -219,7 +219,7 @@ describe('connectChatPageSuggestions — SSR race', () => { search.consumeServerWaitPromises(); const helper = search.helper!; - widget.init!({ + widget.init({ helper, state: helper.state, instantSearchInstance: search, diff --git a/packages/instantsearch.js/test/createInstantSearch.ts b/packages/instantsearch.js/test/createInstantSearch.ts index 1aaaf1f8f37..83fe32177c5 100644 --- a/packages/instantsearch.js/test/createInstantSearch.ts +++ b/packages/instantsearch.js/test/createInstantSearch.ts @@ -45,6 +45,7 @@ export const createInstantSearch = ( _searchStalledTimer: null, _initialUiState: {}, _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..d1de1b761ca 100644 --- a/packages/react-instantsearch-core/src/components/InstantSearchSSRProvider.tsx +++ b/packages/react-instantsearch-core/src/components/InstantSearchSSRProvider.tsx @@ -8,6 +8,12 @@ import type { ReactNode } from 'react'; export type InstantSearchServerState = { initialResults: InitialResults; + /** + * Per-chat-id snapshots produced by chat widgets during SSR. Hydrated on + * the client so the widget can render the assistant response without + * refiring the agent request. + */ + initialChatStates?: Record; }; export type InstantSearchSSRProviderProps = diff --git a/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts b/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts index a8b2f366659..f68e86f5f49 100644 --- a/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts +++ b/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts @@ -65,6 +65,12 @@ export type InternalInstantSearch< * @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( @@ -75,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 = @@ -120,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 6d43cedac5e..de251239a81 100644 --- a/packages/react-instantsearch-core/src/server/getServerState.tsx +++ b/packages/react-instantsearch-core/src/server/getServerState.tsx @@ -136,11 +136,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 09a4e0efbce..ae2cd3b544c 100644 --- a/packages/react-instantsearch-nextjs/src/InitializePromise.ts +++ b/packages/react-instantsearch-nextjs/src/InitializePromise.ts @@ -90,7 +90,15 @@ export function InitializePromise({ nonce }: InitializePromiseProps) { const injectInitialResults = () => { const options = { inserted: false }; const results = getInitialResults(search.mainIndex, 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 c32939ac5ec..227a6653919 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; } } @@ -106,6 +110,9 @@ function ServerOrHydrationProvider({ const initialResults = safelyRunOnBrowser( () => window[InstantSearchInitialResults] ); + const initialChatStates = safelyRunOnBrowser( + () => window[InstantSearchInitialChatStates] + ); return ( diff --git a/packages/react-instantsearch-nextjs/src/createInsertHTML.tsx b/packages/react-instantsearch-nextjs/src/createInsertHTML.tsx index 4678eb1d67c..b8300680798 100644 --- a/packages/react-instantsearch-nextjs/src/createInsertHTML.tsx +++ b/packages/react-instantsearch-nextjs/src/createInsertHTML.tsx @@ -8,10 +8,12 @@ export const createInsertHTML = ({ options, results, + chatStates, nonce, }: { options: { inserted: boolean }; results: InitialResults; + chatStates?: Record; nonce?: string; }) => () => { @@ -20,13 +22,25 @@ export const createInsertHTML = } options.inserted = true; return ( -