Skip to content

Commit 089c174

Browse files
author
Aymeric Giraudet
authored
feat(chat): add initialMessages option (#6978)
* feat(chat): add `initialMessages` option Allows pre-populating the chat with messages on initialization without triggering an AI response. Unlike `initialUserMessage` which sends a message and triggers a response, `initialMessages` simply sets the messages in the UI. Both options can be used together. * fix: address PR feedback from Copilot - Clear sessionStorage in `sets initialMessages on init` test to avoid order-dependent flakiness - Add test for `initialMessages` being skipped when `resume` is enabled * fix: address PR feedback from FabienMotte - Use `initialMessages?.length` to skip applying empty arrays - Document that `initialUserMessage` is sent after `initialMessages` are applied
1 parent a2ecb31 commit 089c174

3 files changed

Lines changed: 285 additions & 5 deletions

File tree

packages/instantsearch.js/src/connectors/chat/connectChat.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,20 @@ export type ChatConnectorParams<TUiMessage extends UIMessage = UIMessage> = (
176176
* When `resume` is enabled, this message is not sent.
177177
*/
178178
initialUserMessage?: string;
179+
/**
180+
* Messages to pre-populate the chat with when it is initialized.
181+
*
182+
* These messages are set without triggering an AI response. They are only
183+
* applied when the chat has no existing messages yet. If messages were
184+
* restored or otherwise already exist when the widget starts, these messages
185+
* are not applied.
186+
*
187+
* When `resume` is enabled, these messages are not applied.
188+
*
189+
* `initialUserMessage` is sent after `initialMessages` are applied, so an
190+
* assistant welcome followed by a user prompt works.
191+
*/
192+
initialMessages?: TUiMessage[];
179193
};
180194

181195
export type ChatWidgetDescription<TUiMessage extends UIMessage = UIMessage> = {
@@ -279,6 +293,7 @@ export default (function connectChat<TWidgetParams extends UnknownWidgetParams>(
279293
type = 'chat',
280294
context,
281295
initialUserMessage,
296+
initialMessages,
282297
...options
283298
} = widgetParams || {};
284299

@@ -570,6 +585,14 @@ export default (function connectChat<TWidgetParams extends UnknownWidgetParams>(
570585
};
571586
}
572587

588+
const hasExistingMessages = _chatInstance.messages.length > 0;
589+
590+
// Set initialMessages before registering callbacks to avoid
591+
// triggering re-renders during init
592+
if (initialMessages?.length && !resume && !hasExistingMessages) {
593+
_chatInstance.messages = initialMessages;
594+
}
595+
573596
_chatInstance['~registerErrorCallback'](render);
574597
_chatInstance['~registerMessagesCallback'](render);
575598
_chatInstance['~registerStatusCallback'](render);
@@ -578,11 +601,7 @@ export default (function connectChat<TWidgetParams extends UnknownWidgetParams>(
578601
_chatInstance.resumeStream();
579602
}
580603

581-
if (
582-
initialUserMessage &&
583-
!resume &&
584-
_chatInstance.messages.length === 0
585-
) {
604+
if (initialUserMessage && !resume && !hasExistingMessages) {
586605
_chatInstance.sendMessage({ text: initialUserMessage });
587606
}
588607

tests/common/connectors/chat/options.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,162 @@ export function createOptionsTests(
9494
expect(sendMessageSpy).not.toHaveBeenCalled();
9595
});
9696

97+
test('sets initialMessages on init', async () => {
98+
sessionStorage.clear();
99+
const chat = new Chat({});
100+
101+
const options: SetupOptions<ChatConnectorSetup> = {
102+
instantSearchOptions: {
103+
indexName: 'indexName',
104+
searchClient: createSearchClient(),
105+
},
106+
widgetParams: {
107+
chat,
108+
agentId: 'agentId',
109+
initialMessages: [
110+
{
111+
id: '1',
112+
role: 'assistant',
113+
parts: [{ type: 'text', text: 'Welcome! How can I help?' }],
114+
},
115+
],
116+
} as any,
117+
};
118+
119+
await setup(options);
120+
121+
await act(async () => {
122+
await wait(0);
123+
});
124+
125+
expect(chat.messages).toHaveLength(1);
126+
expect(chat.messages[0]).toEqual(
127+
expect.objectContaining({
128+
role: 'assistant',
129+
parts: [{ type: 'text', text: 'Welcome! How can I help?' }],
130+
})
131+
);
132+
});
133+
134+
test('does not set initialMessages when messages already exist', async () => {
135+
const chat = new Chat({
136+
messages: [
137+
{
138+
id: '1',
139+
role: 'user',
140+
parts: [{ type: 'text', text: 'Previous message' }],
141+
},
142+
],
143+
});
144+
145+
const options: SetupOptions<ChatConnectorSetup> = {
146+
instantSearchOptions: {
147+
indexName: 'indexName',
148+
searchClient: createSearchClient(),
149+
},
150+
widgetParams: {
151+
chat,
152+
agentId: 'agentId',
153+
initialMessages: [
154+
{
155+
id: '2',
156+
role: 'assistant',
157+
parts: [{ type: 'text', text: 'Welcome! How can I help?' }],
158+
},
159+
],
160+
} as any,
161+
};
162+
163+
await setup(options);
164+
165+
await act(async () => {
166+
await wait(0);
167+
});
168+
169+
expect(chat.messages).toHaveLength(1);
170+
expect(chat.messages[0]).toEqual(
171+
expect.objectContaining({
172+
role: 'user',
173+
parts: [{ type: 'text', text: 'Previous message' }],
174+
})
175+
);
176+
});
177+
178+
test('applies initialMessages and sends initialUserMessage together', async () => {
179+
sessionStorage.clear();
180+
const chat = new Chat({});
181+
const sendMessageSpy = jest
182+
.spyOn(chat, 'sendMessage')
183+
.mockResolvedValue(undefined);
184+
185+
const options: SetupOptions<ChatConnectorSetup> = {
186+
instantSearchOptions: {
187+
indexName: 'indexName',
188+
searchClient: createSearchClient(),
189+
},
190+
widgetParams: {
191+
chat,
192+
agentId: 'agentId',
193+
initialMessages: [
194+
{
195+
id: '1',
196+
role: 'assistant',
197+
parts: [{ type: 'text', text: 'Welcome! How can I help?' }],
198+
},
199+
],
200+
initialUserMessage: 'Hello, AI!',
201+
} as any,
202+
};
203+
204+
await setup(options);
205+
206+
await act(async () => {
207+
await wait(0);
208+
});
209+
210+
expect(chat.messages).toHaveLength(1);
211+
expect(chat.messages[0]).toEqual(
212+
expect.objectContaining({
213+
role: 'assistant',
214+
parts: [{ type: 'text', text: 'Welcome! How can I help?' }],
215+
})
216+
);
217+
expect(sendMessageSpy).toHaveBeenCalledWith({ text: 'Hello, AI!' });
218+
});
219+
220+
test('does not set initialMessages when resume is enabled', async () => {
221+
sessionStorage.clear();
222+
const chat = new Chat({});
223+
jest.spyOn(chat, 'resumeStream').mockResolvedValue(undefined);
224+
225+
const options: SetupOptions<ChatConnectorSetup> = {
226+
instantSearchOptions: {
227+
indexName: 'indexName',
228+
searchClient: createSearchClient(),
229+
},
230+
widgetParams: {
231+
chat,
232+
agentId: 'agentId',
233+
resume: true,
234+
initialMessages: [
235+
{
236+
id: '1',
237+
role: 'assistant',
238+
parts: [{ type: 'text', text: 'Welcome! How can I help?' }],
239+
},
240+
],
241+
} as any,
242+
};
243+
244+
await setup(options);
245+
246+
await act(async () => {
247+
await wait(0);
248+
});
249+
250+
expect(chat.messages).toHaveLength(0);
251+
});
252+
97253
test('provides `input` state to persist text input', async () => {
98254
const options: SetupOptions<ChatConnectorSetup> = {
99255
instantSearchOptions: {

tests/common/widgets/chat/options.tsx

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,111 @@ export function createOptionsTests(
122122
expect(sendMessageSpy).not.toHaveBeenCalled();
123123
});
124124

125+
test('sets initialMessages on init', async () => {
126+
sessionStorage.clear();
127+
const searchClient = createSearchClient();
128+
129+
const chat = new Chat({});
130+
131+
await setup({
132+
instantSearchOptions: {
133+
indexName: 'indexName',
134+
searchClient,
135+
},
136+
widgetParams: {
137+
javascript: {
138+
...createDefaultWidgetParams(chat),
139+
initialMessages: [
140+
{
141+
id: '1',
142+
role: 'assistant',
143+
parts: [{ type: 'text', text: 'Welcome! How can I help?' }],
144+
},
145+
],
146+
},
147+
react: {
148+
...createDefaultWidgetParams(chat),
149+
initialMessages: [
150+
{
151+
id: '1',
152+
role: 'assistant',
153+
parts: [{ type: 'text', text: 'Welcome! How can I help?' }],
154+
},
155+
],
156+
},
157+
vue: {},
158+
},
159+
});
160+
161+
await act(async () => {
162+
await wait(0);
163+
});
164+
165+
expect(chat.messages).toHaveLength(1);
166+
expect(chat.messages[0]).toEqual(
167+
expect.objectContaining({
168+
role: 'assistant',
169+
parts: [{ type: 'text', text: 'Welcome! How can I help?' }],
170+
})
171+
);
172+
});
173+
174+
test('does not set initialMessages when messages already exist', async () => {
175+
const searchClient = createSearchClient();
176+
177+
const chat = new Chat({
178+
messages: [
179+
{
180+
id: '1',
181+
role: 'user',
182+
parts: [{ type: 'text', text: 'Previous message' }],
183+
},
184+
],
185+
});
186+
187+
await setup({
188+
instantSearchOptions: {
189+
indexName: 'indexName',
190+
searchClient,
191+
},
192+
widgetParams: {
193+
javascript: {
194+
...createDefaultWidgetParams(chat),
195+
initialMessages: [
196+
{
197+
id: '2',
198+
role: 'assistant',
199+
parts: [{ type: 'text', text: 'Welcome! How can I help?' }],
200+
},
201+
],
202+
},
203+
react: {
204+
...createDefaultWidgetParams(chat),
205+
initialMessages: [
206+
{
207+
id: '2',
208+
role: 'assistant',
209+
parts: [{ type: 'text', text: 'Welcome! How can I help?' }],
210+
},
211+
],
212+
},
213+
vue: {},
214+
},
215+
});
216+
217+
await act(async () => {
218+
await wait(0);
219+
});
220+
221+
expect(chat.messages).toHaveLength(1);
222+
expect(chat.messages[0]).toEqual(
223+
expect.objectContaining({
224+
role: 'user',
225+
parts: [{ type: 'text', text: 'Previous message' }],
226+
})
227+
);
228+
});
229+
125230
test('sends messages when prompt is submitted', async () => {
126231
const searchClient = createSearchClient();
127232

0 commit comments

Comments
 (0)