Skip to content

Commit f3b4611

Browse files
committed
feat: support streaming output
Signed-off-by: SuZhou-Joe <suzhou@amazon.com>
1 parent b525d97 commit f3b4611

16 files changed

+519
-127
lines changed

common/types/chat_saved_object_attributes.ts

+14
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,17 @@ export interface SendResponse {
8787
messages: IMessage[];
8888
interactions: Interaction[];
8989
}
90+
91+
export type StreamChunk =
92+
| {
93+
type: 'patch';
94+
body: Pick<SendResponse, 'interactions' | 'messages'>;
95+
}
96+
| {
97+
type: 'error';
98+
body: string;
99+
}
100+
| {
101+
type: 'metadata';
102+
body: Partial<SendResponse>;
103+
};

common/utils/stream/serializer.ts

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { StreamChunk } from '../../types/chat_saved_object_attributes';
7+
8+
const separators = `\n`;
9+
10+
export const streamSerializer = (chunk: StreamChunk): string => {
11+
return `${JSON.stringify(chunk)}${separators}`;
12+
};
13+
14+
export const sreamDeserializer = (content: string): StreamChunk[] => {
15+
return content
16+
.split(separators)
17+
.filter((item) => item)
18+
.map((item) => JSON.parse(item));
19+
};

public/chat_header_button.test.tsx

+21-9
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,9 @@ describe('<HeaderChatButton />', () => {
180180
expect(screen.queryByLabelText('chat flyout mock')).not.toBeVisible();
181181
// sidecar hide
182182
fireEvent.click(toggleButton);
183-
expect(screen.queryByLabelText('chat flyout mock')).toBeVisible();
183+
await waitFor(() => {
184+
expect(screen.queryByLabelText('chat flyout mock')).toBeVisible();
185+
});
184186
});
185187

186188
it('should call send message without active conversation id after input text submitted', async () => {
@@ -246,11 +248,12 @@ describe('<HeaderChatButton />', () => {
246248
code: 'Escape',
247249
charCode: 27,
248250
});
251+
249252
expect(screen.getByLabelText('chat input')).not.toHaveFocus();
250253
expect(screen.getByTitle('press Ctrl + / to start typing')).toBeInTheDocument();
251254
});
252255

253-
it('should focus on chat input when pressing global shortcut', () => {
256+
it('should focus on chat input when pressing global shortcut', async () => {
254257
render(
255258
<HeaderChatButton
256259
application={applicationServiceMock.createStartContract()}
@@ -267,10 +270,12 @@ describe('<HeaderChatButton />', () => {
267270
charCode: 111,
268271
ctrlKey: true,
269272
});
270-
expect(screen.getByLabelText('chat input')).toHaveFocus();
273+
await waitFor(() => {
274+
expect(screen.getByLabelText('chat input')).toHaveFocus();
275+
});
271276
});
272277

273-
it('should not focus on chat input when no access and pressing global shortcut', () => {
278+
it('should not focus on chat input when no access and pressing global shortcut', async () => {
274279
render(
275280
<HeaderChatButton
276281
application={applicationServiceMock.createStartContract()}
@@ -287,7 +292,9 @@ describe('<HeaderChatButton />', () => {
287292
charCode: 111,
288293
metaKey: true,
289294
});
290-
expect(screen.getByLabelText('chat input')).not.toHaveFocus();
295+
await waitFor(() => {
296+
expect(screen.getByLabelText('chat input')).not.toHaveFocus();
297+
});
291298
});
292299

293300
it('should call sidecar hide and close when button unmount and chat flyout is visible', async () => {
@@ -307,11 +314,16 @@ describe('<HeaderChatButton />', () => {
307314

308315
fireEvent.click(getByLabelText('toggle chat flyout icon'));
309316

310-
expect(sideCarHideMock).not.toHaveBeenCalled();
311-
expect(sideCarRefMock.close).not.toHaveBeenCalled();
317+
await waitFor(() => {
318+
expect(sideCarHideMock).not.toHaveBeenCalled();
319+
expect(sideCarRefMock.close).not.toHaveBeenCalled();
320+
});
321+
312322
unmount();
313-
expect(sideCarHideMock).toHaveBeenCalled();
314-
expect(sideCarRefMock.close).toHaveBeenCalled();
323+
await waitFor(() => {
324+
expect(sideCarHideMock).toHaveBeenCalled();
325+
expect(sideCarRefMock.close).toHaveBeenCalled();
326+
});
315327
});
316328

317329
it('should render toggle chat flyout button icon', () => {

public/components/blink_cursor.scss

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.assistant_blinkCursor {
2+
border-right: 2px solid black;
3+
animation: cursor-blink 0.8s infinite;
4+
}
5+
6+
@keyframes cursor-blink {
7+
50% { border-right-color: transparent; }
8+
}

public/components/blink_cursor.tsx

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import React from 'react';
7+
import './blink_cursor.scss';
8+
9+
export const BlinkCursor = () => {
10+
return <span className="assistant_blinkCursor" />;
11+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { EuiMarkdownFormat, EuiMarkdownFormatProps } from '@elastic/eui';
7+
import React from 'react';
8+
import { useRef } from 'react';
9+
import { useEffect } from 'react';
10+
11+
function getLastTextNode(node: HTMLElement | Node): Node | null | undefined {
12+
if (node.nodeType === node.TEXT_NODE) {
13+
return node;
14+
}
15+
const children = node.childNodes;
16+
for (let i = children.length - 1; i >= 0; i--) {
17+
const child = children[i];
18+
const result = getLastTextNode(child);
19+
if (result) {
20+
return result;
21+
}
22+
return null;
23+
}
24+
}
25+
26+
export const MarkdownWithBlinkCursor = (props: EuiMarkdownFormatProps & { loading?: boolean }) => {
27+
const ref = useRef<HTMLDivElement | null>(null);
28+
useEffect(() => {
29+
let blinkCursorNode: undefined | HTMLSpanElement;
30+
if (props.loading && ref.current) {
31+
const lastNode = getLastTextNode(ref.current);
32+
blinkCursorNode = document.createElement('span');
33+
blinkCursorNode.classList.add('assistant_blinkCursor');
34+
if (lastNode) {
35+
lastNode.parentNode?.appendChild(blinkCursorNode);
36+
}
37+
}
38+
39+
return () => {
40+
blinkCursorNode?.remove();
41+
};
42+
}, [props.children, props.loading]);
43+
return (
44+
<div className="markdown_with_blink_cursor" ref={ref}>
45+
<EuiMarkdownFormat>{props.children}</EuiMarkdownFormat>
46+
</div>
47+
);
48+
};

public/hooks/use_chat_actions.test.tsx

+51-18
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,19 @@ const SEND_MESSAGE_RESPONSE = {
6161
],
6262
};
6363

64+
const mockPureFetchResponse = (props: { headers?: Headers; responseJson?: unknown } = {}) => {
65+
const {
66+
headers = new Headers({ 'Content-Type': 'application/json' }),
67+
responseJson = {},
68+
} = props;
69+
return {
70+
response: {
71+
headers,
72+
json: () => Promise.resolve(responseJson),
73+
},
74+
};
75+
};
76+
6477
describe('useChatActions hook', () => {
6578
const httpMock = httpServiceMock.createStartContract();
6679
const chatStateDispatchMock = jest.fn();
@@ -112,7 +125,11 @@ describe('useChatActions hook', () => {
112125
});
113126

114127
it('should send message correctly', async () => {
115-
httpMock.post.mockResolvedValueOnce(SEND_MESSAGE_RESPONSE);
128+
httpMock.post.mockImplementationOnce(async () =>
129+
mockPureFetchResponse({
130+
responseJson: SEND_MESSAGE_RESPONSE,
131+
})
132+
);
116133
jest.spyOn(chatStateHookExports, 'useChatState').mockReturnValue({
117134
chatState: {
118135
messages: [SEND_MESSAGE_RESPONSE.messages[0] as IMessage],
@@ -134,6 +151,8 @@ describe('useChatActions hook', () => {
134151
input: INPUT_MESSAGE,
135152
}),
136153
query: dataSourceServiceMock.getDataSourceQuery(),
154+
asResponse: true,
155+
pureFetch: true,
137156
});
138157

139158
// it should send dispatch `receive` action to remove the message without messageId
@@ -209,6 +228,8 @@ describe('useChatActions hook', () => {
209228
input: { type: 'input', content: 'message that send as input', contentType: 'text' },
210229
}),
211230
query: dataSourceServiceMock.getDataSourceQuery(),
231+
pureFetch: true,
232+
asResponse: true,
212233
});
213234
});
214235

@@ -276,7 +297,11 @@ describe('useChatActions hook', () => {
276297
});
277298

278299
it('should regenerate message', async () => {
279-
httpMock.put.mockResolvedValue(SEND_MESSAGE_RESPONSE);
300+
httpMock.put.mockImplementationOnce(async () =>
301+
mockPureFetchResponse({
302+
responseJson: SEND_MESSAGE_RESPONSE,
303+
})
304+
);
280305
jest
281306
.spyOn(chatContextHookExports, 'useChatContext')
282307
.mockReturnValue({ ...chatContextMock, conversationId: 'conversation_id_mock' });
@@ -300,14 +325,12 @@ describe('useChatActions hook', () => {
300325
interactionId: 'interaction_id_mock',
301326
}),
302327
query: dataSourceServiceMock.getDataSourceQuery(),
328+
pureFetch: true,
329+
asResponse: true,
303330
});
304-
expect(chatStateDispatchMock).toHaveBeenCalledWith(
305-
expect.objectContaining({ type: 'receive', payload: { messages: [], interactions: [] } })
306-
);
307-
308331
expect(chatStateDispatchMock).toHaveBeenCalledWith(
309332
expect.objectContaining({
310-
type: 'patch',
333+
type: 'receive',
311334
payload: {
312335
messages: SEND_MESSAGE_RESPONSE.messages,
313336
interactions: SEND_MESSAGE_RESPONSE.interactions,
@@ -317,12 +340,18 @@ describe('useChatActions hook', () => {
317340
});
318341

319342
it('should not handle regenerate response if the regenerate operation has already aborted', async () => {
320-
const AbortControllerMock = jest.spyOn(window, 'AbortController').mockImplementation(() => ({
321-
signal: { aborted: true },
322-
abort: jest.fn(),
323-
}));
343+
const AbortControllerMock = jest.spyOn(window, 'AbortController').mockImplementation(() => {
344+
return {
345+
signal: AbortSignal.abort(),
346+
abort: jest.fn(),
347+
};
348+
});
324349

325-
httpMock.put.mockResolvedValue(SEND_MESSAGE_RESPONSE);
350+
httpMock.put.mockImplementationOnce(async () =>
351+
mockPureFetchResponse({
352+
responseJson: SEND_MESSAGE_RESPONSE,
353+
})
354+
);
326355
jest
327356
.spyOn(chatContextHookExports, 'useChatContext')
328357
.mockReturnValue({ ...chatContextMock, conversationId: 'conversation_id_mock' });
@@ -337,6 +366,8 @@ describe('useChatActions hook', () => {
337366
interactionId: 'interaction_id_mock',
338367
}),
339368
query: dataSourceServiceMock.getDataSourceQuery(),
369+
pureFetch: true,
370+
asResponse: true,
340371
});
341372
expect(chatStateDispatchMock).not.toHaveBeenCalledWith(
342373
expect.objectContaining({ type: 'receive' })
@@ -359,10 +390,12 @@ describe('useChatActions hook', () => {
359390
});
360391

361392
it('should not handle regenerate error if the regenerate operation has already aborted', async () => {
362-
const AbortControllerMock = jest.spyOn(window, 'AbortController').mockImplementation(() => ({
363-
signal: { aborted: true },
364-
abort: jest.fn(),
365-
}));
393+
const AbortControllerMock = jest.spyOn(window, 'AbortController').mockImplementation(() => {
394+
return {
395+
signal: AbortSignal.abort(),
396+
abort: jest.fn(),
397+
};
398+
});
366399
httpMock.put.mockImplementationOnce(() => {
367400
throw new Error();
368401
});
@@ -393,7 +426,7 @@ describe('useChatActions hook', () => {
393426
it('should abort send action after reset chat', async () => {
394427
const abortFn = jest.fn();
395428
const AbortControllerMock = jest.spyOn(window, 'AbortController').mockImplementation(() => ({
396-
signal: { aborted: true },
429+
signal: AbortSignal.abort(),
397430
abort: abortFn,
398431
}));
399432
const { result } = renderHook(() => useChatActions());
@@ -407,7 +440,7 @@ describe('useChatActions hook', () => {
407440
it('should abort load action after reset chat', async () => {
408441
const abortFn = jest.fn();
409442
const AbortControllerMock = jest.spyOn(window, 'AbortController').mockImplementation(() => ({
410-
signal: { aborted: true },
443+
signal: AbortSignal.abort(),
411444
abort: abortFn,
412445
}));
413446
const { result } = renderHook(() => useChatActions());

0 commit comments

Comments
 (0)