Skip to content

Commit

Permalink
Added handling for messageReceived event
Browse files Browse the repository at this point in the history
  • Loading branch information
salmenus committed May 13, 2024
1 parent ff0c2b1 commit 7ec1457
Show file tree
Hide file tree
Showing 12 changed files with 348 additions and 12 deletions.
1 change: 1 addition & 0 deletions packages/js/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type {
PreDestroyCallback,
PreDestroyEventDetails,
MessageSentCallback,
MessageStreamStartedCallback,
MessageReceivedCallback,
MessageRenderedCallback,
} from './types/event';
Expand Down
1 change: 1 addition & 0 deletions packages/js/core/src/logic/chat/chatItem/chatItem.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export class CompChatItem<AiMsg> extends BaseComp<
this.isItemStreaming = false;
this.context.emit('messageRendered', {
uid: this.props.uid,
message: messageRendered,
});
}
}
18 changes: 18 additions & 0 deletions packages/js/core/src/logic/chat/chatRoom/actions/submitPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export const submitPromptFactory = <AiMsg>({

result.observable.on('userMessageReceived', (userMessage) => {
conversation.addChatItem(segmentId, userMessage);
context.emit('messageSent', {uid: userMessage.uid, message: userMessage.content});

domOp(() => {
if (autoScrollController) {
const chatSegmentContainer = conversation.getChatSegmentContainer(segmentId);
Expand Down Expand Up @@ -88,6 +90,11 @@ export const submitPromptFactory = <AiMsg>({
}

conversation.completeChatSegment(segmentId);
context.emit('messageReceived', {
uid: aiMessage.uid,
message: aiMessage.content,
});

resetPromptBox(true);
});
} else {
Expand All @@ -100,8 +107,19 @@ export const submitPromptFactory = <AiMsg>({
conversation.addChunk(segmentId, chatItemId, aiMessageChunk);
});

result.observable.on('aiMessageStreamed', (aiMessage) => {
if (aiMessage.status === 'complete') {
context.emit('messageReceived', {
uid: aiMessage.uid,
// We only pass the response to custom renderer when the status is 'complete'.
message: aiMessage.content as AiMsg,
});
}
});

result.observable.on('complete', () => {
conversation.completeChatSegment(segmentId);

resetPromptBox(false);
});
}
Expand Down
39 changes: 30 additions & 9 deletions packages/js/core/src/types/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import {ChatItem} from '../../../../shared/src/types/conversation';
import {ExceptionId} from '../../../../shared/src/types/exceptions';
import {AiChatPropsInEvents} from './aiChat/props';

export type MessageReceivedEventDetails<AiMsg> = {
export type MessageSentEventDetails = {
uid: string;
message: string;
};

export type MessageStreamStartedEventDetails = {
uid: string;
};

Expand All @@ -12,6 +17,11 @@ export type MessageRenderedEventDetails<AiMsg> = {
message: AiMsg;
};

export type MessageReceivedEventDetails<AiMsg> = {
uid: string;
message: AiMsg;
};

export type ErrorEventDetails = {
errorId: ExceptionId;
message: string;
Expand All @@ -33,6 +43,24 @@ export type PreDestroyEventDetails<AiMsg> = {
*/
export type ErrorCallback = (errorDetails: ErrorEventDetails) => void;

/**
* The callback for when a message is sent.
* This is called when the chat component sends the message to the adapter.
*
* @param message The message that was sent.
*/
export type MessageSentCallback = (event: MessageSentEventDetails) => void;

/**
* The callback for when a response starts streaming from the adapter.
* This is called when the chat component receives the first part of the response from the adapter.
* This does not mean that the message has been rendered yet. You should use the messageRendered event
* if you want to know when the message has been rendered.
*
* @param event The event details such as the uid of the message.
*/
export type MessageStreamStartedCallback = (event: MessageStreamStartedEventDetails) => void;

/**
* The callback for when a message is received.
* This is called when the chat component receives the full response from the adapter.
Expand All @@ -50,14 +78,6 @@ export type MessageReceivedCallback<AiMsg = string> = (event: MessageReceivedEve
*/
export type MessageRenderedCallback<AiMsg = string> = (event: MessageRenderedEventDetails<AiMsg>) => void;

/**
* The callback for when a message is sent.
* This is called when the chat component sends the message to the adapter.
*
* @param message The message that was sent.
*/
export type MessageSentCallback = (message: string) => void;

/**
* The callback for when the chat component is ready.
* This is called when the chat component is fully initialized and ready to be used.
Expand All @@ -79,6 +99,7 @@ export type EventsMap<AiMsg> = {
ready: ReadyCallback<AiMsg>;
preDestroy: PreDestroyCallback<AiMsg>;
messageSent: MessageSentCallback;
messageStreamStarted: MessageStreamStartedCallback;
messageReceived: MessageReceivedCallback<AiMsg>;
messageRendered: MessageReceivedCallback<AiMsg>;
error: ErrorCallback;
Expand Down
2 changes: 1 addition & 1 deletion packages/js/openai/src/openai/gpt/types/model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type OpenAiModel = (string & {})
export type OpenAiModel = (string & NonNullable<unknown>)
| 'gpt-4-0125-preview'
| 'gpt-4-turbo-preview'
| 'gpt-4-1106-preview'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const usePreDestroyEventTrigger = <AiMsg>(
};

preDestroyCallback(preDestroyEvent);
getChatHistoryRef.current = undefined;
};
}, []);
};
36 changes: 34 additions & 2 deletions packages/react/core/src/exports/hooks/useSubmitPromptHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {PromptBoxOptions} from '@nlux/core';
import {EventsMap, PromptBoxOptions} from '@nlux/core';
import {MutableRefObject, useCallback, useEffect, useMemo, useRef} from 'react';
import {submitPrompt} from '../../../../../shared/src/services/submitPrompt/submitPromptImpl';
import {ChatAdapter} from '../../../../../shared/src/types/adapters/chat/chatAdapter';
Expand Down Expand Up @@ -60,6 +60,9 @@ export const useSubmitPromptHandler = <AiMsg>(props: SubmitPromptHandlerProps<Ai
setPrompt,
});

// Callback events can be used by the non-React DOM update code
const callbackEvents = useRef<Partial<EventsMap<AiMsg>>>({});

useEffect(() => {
domToReactRef.current = {
chatSegments,
Expand All @@ -73,9 +76,13 @@ export const useSubmitPromptHandler = <AiMsg>(props: SubmitPromptHandlerProps<Ai
const adapterExtras: ChatAdapterExtras<AiMsg> = useAdapterExtras(
aiChatProps,
initialSegment ? [initialSegment, ...chatSegments] : chatSegments,
aiChatProps.conversationOptions?.historyPayloadSize as any,
aiChatProps.conversationOptions?.historyPayloadSize,
);

useEffect(() => {
callbackEvents.current = aiChatProps.events || {};
}, [aiChatProps.events]);

return useCallback(
() => {
if (!adapterToUse) {
Expand Down Expand Up @@ -145,6 +152,12 @@ export const useSubmitPromptHandler = <AiMsg>(props: SubmitPromptHandlerProps<Ai

chatSegmentObservable.on('userMessageReceived', (userMessage) => {
handleSegmentItemReceived(userMessage);
if (callbackEvents.current?.messageSent) {
callbackEvents.current.messageSent({
uid: userMessage.uid,
message: userMessage.content,
});
}
});

chatSegmentObservable.on('aiMessageStreamStarted', (aiStreamedMessage) => {
Expand All @@ -155,6 +168,9 @@ export const useSubmitPromptHandler = <AiMsg>(props: SubmitPromptHandlerProps<Ai
}

streamedMessageIds.add(aiStreamedMessage.uid);
if (callbackEvents.current?.messageStreamStarted) {
callbackEvents.current.messageStreamStarted({uid: aiStreamedMessage.uid});
}
});

chatSegmentObservable.on('aiMessageReceived', (aiMessage) => {
Expand All @@ -170,6 +186,12 @@ export const useSubmitPromptHandler = <AiMsg>(props: SubmitPromptHandlerProps<Ai
);

domToReactRef.current.setChatSegments(newChatSegments);
if (callbackEvents.current?.messageReceived) {
callbackEvents.current.messageReceived({
uid: aiMessage.uid,
message: aiMessage.content,
});
}
});

chatSegmentObservable.on('complete', (completeChatSegment) => {
Expand Down Expand Up @@ -207,6 +229,16 @@ export const useSubmitPromptHandler = <AiMsg>(props: SubmitPromptHandlerProps<Ai
});
});

chatSegmentObservable.on('aiMessageStreamed', (streamedMessage) => {
if (callbackEvents.current?.messageReceived) {
callbackEvents.current?.messageReceived({
uid: streamedMessage.uid,
// In streamed messages, the AiMsg is always a string
message: streamedMessage.content as AiMsg,
});
}
});

chatSegmentObservable.on('error', (exception) => {
const parts = domToReactRef.current.chatSegments;
const newParts = parts.filter((part) => part.uid !== chatSegment.uid);
Expand Down
2 changes: 2 additions & 0 deletions packages/react/core/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ export type {
PreDestroyCallback,
PreDestroyEventDetails,
MessageSentCallback,
MessageStreamStartedCallback,
MessageReceivedCallback,
MessageRenderedCallback,
} from '@nlux/core';

// Exporting from — shared
Expand Down
53 changes: 53 additions & 0 deletions specs/specs/aiChat/events/js/messageReceived-fetchAdapter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {AiChat, createAiChat} from '@nlux-dev/core/src';
import userEvent from '@testing-library/user-event';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import '@testing-library/jest-dom';
import {adapterBuilder} from '../../../../utils/adapterBuilder';
import {AdapterController} from '../../../../utils/adapters';
import {waitForRenderCycle} from '../../../../utils/wait';

describe('createAiChat() + fetch adapter + events + messageReceived', () => {
let adapterController: AdapterController | undefined = undefined;
let rootElement: HTMLElement;
let aiChat: AiChat | undefined;

beforeEach(() => {
adapterController = adapterBuilder()
.withFetchText(true)
.withStreamText(false)
.create();

rootElement = document.createElement('div');
document.body.append(rootElement);
});

afterEach(() => {
adapterController = undefined;
aiChat?.unmount();
rootElement?.remove();
aiChat = undefined;
});

describe('When a message is received', () => {
it('It should trigger the messageReceived event', async () => {
// Arrange
const messageReceivedCallback = vi.fn();
aiChat = createAiChat()
.withAdapter(adapterController!.adapter)
.on('messageReceived', messageReceivedCallback);

aiChat.mount(rootElement);
await waitForRenderCycle();

const textArea: HTMLTextAreaElement = rootElement.querySelector('.nlux-comp-prmptBox > textarea')!;
await userEvent.type(textArea, 'Hello{enter}');

// Act
adapterController!.resolve('Yo!');
await waitForRenderCycle();

// Assert
expect(messageReceivedCallback).toHaveBeenCalledOnce();
});
});
});
54 changes: 54 additions & 0 deletions specs/specs/aiChat/events/js/messageReceived-streamAdapter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {AiChat, createAiChat} from '@nlux-dev/core/src';
import userEvent from '@testing-library/user-event';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import '@testing-library/jest-dom';
import {adapterBuilder} from '../../../../utils/adapterBuilder';
import {AdapterController} from '../../../../utils/adapters';
import {waitForRenderCycle} from '../../../../utils/wait';

describe('createAiChat() + stream adapter + events + messageReceived', () => {
let adapterController: AdapterController | undefined = undefined;
let rootElement: HTMLElement;
let aiChat: AiChat | undefined;

beforeEach(() => {
adapterController = adapterBuilder()
.withFetchText(false)
.withStreamText(true)
.create();

rootElement = document.createElement('div');
document.body.append(rootElement);
});

afterEach(() => {
adapterController = undefined;
aiChat?.unmount();
rootElement?.remove();
aiChat = undefined;
});

describe('When a message is received', () => {
it('It should trigger the messageReceived event', async () => {
// Arrange
const messageReceivedCallback = vi.fn();
aiChat = createAiChat()
.withAdapter(adapterController!.adapter)
.on('messageReceived', messageReceivedCallback);

aiChat.mount(rootElement);
await waitForRenderCycle();

const textArea: HTMLTextAreaElement = rootElement.querySelector('.nlux-comp-prmptBox > textarea')!;
await userEvent.type(textArea, 'Hello{enter}');

// Act
adapterController!.next('Yo!');
adapterController!.complete();
await waitForRenderCycle();

// Assert
expect(messageReceivedCallback).toHaveBeenCalledOnce();
});
});
});
Loading

0 comments on commit 7ec1457

Please sign in to comment.