diff --git a/bundlesize.config.json b/bundlesize.config.json index 7fe3d8f13f..e0b8c7045d 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,7 +10,7 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "129 kB" + "maxSize": "130 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", @@ -22,7 +22,7 @@ }, { "path": "packages/react-instantsearch/dist/umd/ReactInstantSearch.min.js", - "maxSize": "101.5 kB" + "maxSize": "102 kB" }, { "path": "packages/vue-instantsearch/vue2/umd/index.js", diff --git a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts index 5ae6f095ef..fe332eb15b 100644 --- a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts +++ b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts @@ -30,7 +30,7 @@ describe('connectChat', () => { ...(!('agentId' in widgetParams) ? { agentId: 'agentId' } : {}), disableTriggerValidation: true, ...widgetParams, - }); + } as ChatConnectorParams); const helper = algoliasearchHelper(createSearchClient(), ''); @@ -70,6 +70,62 @@ describe('connectChat', () => { }) ); }); + + it('types requestOptions as agentId-only', () => { + const assertChatConnectorParams = ( + params: TParams + ) => params; + const customChat = undefined as unknown as Chat; + + const agentParams = assertChatConnectorParams({ + agentId: 'agentId', + requestOptions: { + queryParameters: { cache: false }, + headers: { 'x-algolia-referer': 'chat-widget' }, + }, + }); + + const legacyAgentWithTransportParams = assertChatConnectorParams({ + agentId: 'agentId', + transport: { api: 'https://custom.api' }, + }); + + // @ts-expect-error requestOptions is only valid with agentId + assertChatConnectorParams({ + transport: { api: 'https://custom.api' }, + requestOptions: { + queryParameters: { cache: false }, + }, + }); + + // @ts-expect-error requestOptions is not valid when a custom transport is provided + assertChatConnectorParams({ + agentId: 'agentId', + transport: { api: 'https://custom.api' }, + requestOptions: { + queryParameters: { cache: false }, + }, + }); + + assertChatConnectorParams({ + // @ts-expect-error requestOptions is not valid with a custom chat instance + chat: customChat, + requestOptions: { + queryParameters: { cache: false }, + }, + }); + + expect(agentParams.requestOptions?.queryParameters).toEqual({ + cache: false, + }); + expect(agentParams.requestOptions?.headers).toEqual({ + 'x-algolia-referer': 'chat-widget', + }); + expect(legacyAgentWithTransportParams).toEqual({ + agentId: 'agentId', + transport: { api: 'https://custom.api' }, + }); + }); }); describe('getWidgetRenderState', () => { @@ -1060,8 +1116,9 @@ data: [DONE]`, }); function getRequestPayload() { - const [, init] = fetchMock.mock.calls[0]; + const [url, init] = fetchMock.mock.calls[0]; return { + url: String(url), headers: init.headers as Record, body: JSON.parse(init.body as string), }; @@ -1108,6 +1165,120 @@ data: [DONE]`, ); }); + it('sends persistent query parameters on agent requests', async () => { + const { widget } = getInitializedWidget({ + agentId: 'agentId', + requestOptions: { + queryParameters: { + cache: false, + hitsPerPage: 4, + explain: true, + userToken: 'user-1', + }, + }, + }); + + await widget.chatInstance.sendMessage({ text: 'hello' }); + + const { url } = getRequestPayload(); + const searchParams = new URL(url).searchParams; + expect(searchParams.get('compatibilityMode')).toBe('ai-sdk-5'); + expect(searchParams.get('cache')).toBe('false'); + expect(searchParams.get('hitsPerPage')).toBe('4'); + expect(searchParams.get('explain')).toBe('true'); + expect(searchParams.get('userToken')).toBe('user-1'); + }); + + it('keeps the built-in compatibility mode on agent requests', async () => { + const { widget } = getInitializedWidget({ + agentId: 'agentId', + requestOptions: { + queryParameters: { + compatibilityMode: 'custom', + userToken: 'user-1', + }, + }, + }); + + await widget.chatInstance.sendMessage({ text: 'hello' }); + + const { url } = getRequestPayload(); + const searchParams = new URL(url).searchParams; + expect(searchParams.get('compatibilityMode')).toBe('ai-sdk-5'); + expect(searchParams.get('userToken')).toBe('user-1'); + }); + + it('sends persistent headers on agent requests', async () => { + const { widget } = getInitializedWidget({ + agentId: 'agentId', + requestOptions: { + headers: { + 'x-algolia-referer': 'chat-widget', + 'x-session-id': 'session-1', + }, + }, + }); + + await widget.chatInstance.sendMessage({ text: 'hello' }); + + const { headers } = getRequestPayload(); + expect(headers).toEqual( + expect.objectContaining({ + 'x-algolia-application-id': 'appId', + 'x-algolia-api-key': 'apiKey', + 'x-algolia-referer': 'chat-widget', + 'x-session-id': 'session-1', + }) + ); + }); + + it('sends persistent Headers instance on agent requests', async () => { + const { widget } = getInitializedWidget({ + agentId: 'agentId', + requestOptions: { + headers: new Headers({ + 'x-algolia-referer': 'chat-widget', + 'x-session-id': 'session-1', + }), + }, + }); + + await widget.chatInstance.sendMessage({ text: 'hello' }); + + const { headers } = getRequestPayload(); + expect(headers).toEqual( + expect.objectContaining({ + 'x-algolia-application-id': 'appId', + 'x-algolia-api-key': 'apiKey', + 'x-algolia-referer': 'chat-widget', + 'x-session-id': 'session-1', + }) + ); + }); + + it('keeps the x-algolia-agent chat marker even when requestOptions tries to override it', async () => { + const { widget } = getInitializedWidget({ + agentId: 'agentId', + requestOptions: { + headers: { + 'x-algolia-application-id': 'spoofed-app', + 'x-algolia-api-key': 'spoofed-key', + 'x-algolia-agent': 'spoofed-agent', + 'x-algolia-referer': 'chat-widget', + }, + }, + }); + + await widget.chatInstance.sendMessage({ text: 'hello' }); + + const { headers } = getRequestPayload(); + expect(headers['x-algolia-application-id']).toBe('appId'); + expect(headers['x-algolia-api-key']).toBe('apiKey'); + expect(headers['x-algolia-agent']).toContain('; chat'); + expect(headers['x-algolia-agent']).not.toBe('spoofed-agent'); + expect(headers['x-algolia-referer']).toBe('chat-widget'); + }); + it('does not register `chat` on the search client user-agent', () => { const addAlgoliaAgent = jest.fn(); const client = Object.assign(createSearchClient(), { @@ -1147,6 +1318,41 @@ data: [DONE]`, }); }); + it('lets per-call headers override persistent headers for one request', async () => { + const { widget } = getInitializedWidget({ + agentId: 'agentId', + requestOptions: { + headers: { + 'x-algolia-referer': 'chat-widget', + }, + }, + }); + + await widget.chatInstance.sendMessage( + { text: 'hello' }, + { headers: { 'x-algolia-referer': 'prompt-suggestions' } } + ); + await widget.chatInstance.sendMessage({ text: 'follow-up' }); + + const firstHeaders = fetchMock.mock.calls[0][1].headers as Record< + string, + string + >; + const secondHeaders = fetchMock.mock.calls[1][1].headers as Record< + string, + string + >; + + expect(firstHeaders).toHaveProperty( + 'x-algolia-referer', + 'prompt-suggestions' + ); + expect(secondHeaders).toHaveProperty( + 'x-algolia-referer', + 'chat-widget' + ); + }); + it('does not carry over the x-algolia-referer to follow-up messages', async () => { const { widget } = getInitializedWidget({ agentId: 'agentId' }); @@ -1172,13 +1378,33 @@ data: [DONE]`, expect(secondHeaders).not.toHaveProperty('x-algolia-referer'); }); + it('forces cache=false when regenerating with persistent cache query parameter', async () => { + const { widget } = getInitializedWidget({ + agentId: 'agentId', + requestOptions: { + queryParameters: { + cache: true, + }, + }, + }); + + await widget.chatInstance.regenerate(); + + const { url } = getRequestPayload(); + expect(new URL(url).searchParams.get('cache')).toBe('false'); + }); + it('does not duplicate transport metadata in the request body', async () => { const { widget } = getInitializedWidget({ agentId: 'agentId' }); await widget.chatInstance.sendMessage({ text: 'hello' }); const { body } = getRequestPayload(); - expect(Object.keys(body).sort()).toEqual(['id', 'messageId', 'messages']); + expect(Object.keys(body).sort()).toEqual([ + 'id', + 'messageId', + 'messages', + ]); expect(body).not.toHaveProperty('headers'); expect(body).not.toHaveProperty('api'); expect(body).not.toHaveProperty('credentials'); @@ -1250,11 +1476,10 @@ data: [DONE]`, return new Chat({ transport: createMockTransport() }); } - function createChatWidgetWithContext( - params: Omit, 'transport' | 'agentId'> & { - chat: Chat; - } - ) { + function createChatWidgetWithContext(params: { + chat: Chat; + context?: ChatConnectorParams['context']; + }) { const renderFn = jest.fn(); const makeWidget = connectChat(renderFn); const widget = makeWidget({ diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index b6d1a671c7..b5d60c3cf4 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -122,18 +122,50 @@ export type ChatInitWithoutTransport = Omit< 'transport' >; -export type ChatTransport = { - transport?: ConstructorParameters[0]; -} & ( +export type ChatAgentRequestOptions = { + /** + * Query parameters to send with built-in Agent Studio completion requests. + */ + queryParameters?: Record; + /** + * Headers to send with built-in Agent Studio completion requests. + */ + headers?: Record | Headers; +}; + +export type ChatTransport = | { agentId: string; + transport?: never; + /** + * Request options to send with built-in Agent Studio completion requests. + */ + requestOptions?: ChatAgentRequestOptions; /** * Whether to enable feedback (thumbs up/down) on assistant messages. */ feedback?: boolean; } - | { agentId?: undefined; feedback?: never } -); + | { + agentId: string; + transport?: ConstructorParameters[0]; + feedback?: boolean; + requestOptions?: never; + } + | { + agentId?: undefined; + transport?: ConstructorParameters[0]; + feedback?: never; + requestOptions?: never; + }; + +export type ChatCustomInstance = { + chat: Chat; + agentId?: undefined; + transport?: ConstructorParameters[0]; + feedback?: never; + requestOptions?: never; +}; export type ApplyFiltersParams = { query?: string; @@ -144,7 +176,7 @@ export type ChatInit = ChatInitWithoutTransport & ChatTransport; export type ChatConnectorParams = ( - | { chat: Chat } + | ChatCustomInstance | ChatInit ) & { /** @@ -473,21 +505,43 @@ export default (function connectChat( ); } - const baseApi = `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/completions?compatibilityMode=ai-sdk-5`; + const createApi = (bypassCache = false) => { + const api = new URL( + `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/completions` + ); + const queryParameters: Record = { + ...options.requestOptions?.queryParameters, + compatibilityMode: 'ai-sdk-5', + ...(bypassCache ? { cache: false } : {}), + }; + + api.search = new URLSearchParams( + queryParameters as Record + ).toString(); + return api.toString(); + }; + const baseApi = createApi(); transport = new DefaultChatTransport({ api: baseApi, headers: { + ...(options.requestOptions?.headers instanceof Headers + ? Object.fromEntries(options.requestOptions.headers.entries()) + : options.requestOptions?.headers), + // Preserve the required Algolia identity headers and chat agent + // marker, even when requestOptions.headers contains the same keys. 'x-algolia-application-id': appId, 'x-algolia-api-key': apiKey, 'x-algolia-agent': `${getAlgoliaAgent(client)}; chat`, }, - prepareSendMessagesRequest: ({ id, messages, trigger, messageId }) => { + prepareSendMessagesRequest: ({ + id, + messages, + trigger, + messageId, + }) => { return { // Bypass cache when regenerating to ensure fresh responses - api: - trigger === 'regenerate-message' - ? `${baseApi}&cache=false` - : baseApi, + api: trigger === 'regenerate-message' ? createApi(true) : baseApi, body: { id, messageId,