diff --git a/packages/js/core/src/index.ts b/packages/js/core/src/index.ts index 10d9147d..3cb80732 100644 --- a/packages/js/core/src/index.ts +++ b/packages/js/core/src/index.ts @@ -40,6 +40,7 @@ export type { PreDestroyCallback, PreDestroyEventDetails, MessageSentCallback, + MessageStreamStartedCallback, MessageReceivedCallback, MessageRenderedCallback, } from './types/event'; diff --git a/packages/js/core/src/logic/chat/chatItem/chatItem.model.ts b/packages/js/core/src/logic/chat/chatItem/chatItem.model.ts index 1f0eeeee..85adca7b 100644 --- a/packages/js/core/src/logic/chat/chatItem/chatItem.model.ts +++ b/packages/js/core/src/logic/chat/chatItem/chatItem.model.ts @@ -102,6 +102,7 @@ export class CompChatItem extends BaseComp< this.isItemStreaming = false; this.context.emit('messageRendered', { uid: this.props.uid, + message: messageRendered, }); } } diff --git a/packages/js/core/src/logic/chat/chatRoom/actions/submitPrompt.ts b/packages/js/core/src/logic/chat/chatRoom/actions/submitPrompt.ts index ee43b437..3e614e4b 100644 --- a/packages/js/core/src/logic/chat/chatRoom/actions/submitPrompt.ts +++ b/packages/js/core/src/logic/chat/chatRoom/actions/submitPrompt.ts @@ -58,6 +58,8 @@ export const submitPromptFactory = ({ 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); @@ -88,6 +90,11 @@ export const submitPromptFactory = ({ } conversation.completeChatSegment(segmentId); + context.emit('messageReceived', { + uid: aiMessage.uid, + message: aiMessage.content, + }); + resetPromptBox(true); }); } else { @@ -100,8 +107,19 @@ export const submitPromptFactory = ({ 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); }); } diff --git a/packages/js/core/src/types/event.ts b/packages/js/core/src/types/event.ts index 4a57d1bf..0acf8cab 100644 --- a/packages/js/core/src/types/event.ts +++ b/packages/js/core/src/types/event.ts @@ -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 = { +export type MessageSentEventDetails = { + uid: string; + message: string; +}; + +export type MessageStreamStartedEventDetails = { uid: string; }; @@ -12,6 +17,11 @@ export type MessageRenderedEventDetails = { message: AiMsg; }; +export type MessageReceivedEventDetails = { + uid: string; + message: AiMsg; +}; + export type ErrorEventDetails = { errorId: ExceptionId; message: string; @@ -33,6 +43,24 @@ export type PreDestroyEventDetails = { */ 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. @@ -50,14 +78,6 @@ export type MessageReceivedCallback = (event: MessageReceivedEve */ export type MessageRenderedCallback = (event: MessageRenderedEventDetails) => 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. @@ -79,6 +99,7 @@ export type EventsMap = { ready: ReadyCallback; preDestroy: PreDestroyCallback; messageSent: MessageSentCallback; + messageStreamStarted: MessageStreamStartedCallback; messageReceived: MessageReceivedCallback; messageRendered: MessageReceivedCallback; error: ErrorCallback; diff --git a/packages/js/openai/src/openai/gpt/types/model.ts b/packages/js/openai/src/openai/gpt/types/model.ts index c829507d..9e1781d8 100644 --- a/packages/js/openai/src/openai/gpt/types/model.ts +++ b/packages/js/openai/src/openai/gpt/types/model.ts @@ -1,4 +1,4 @@ -export type OpenAiModel = (string & {}) +export type OpenAiModel = (string & NonNullable) | 'gpt-4-0125-preview' | 'gpt-4-turbo-preview' | 'gpt-4-1106-preview' diff --git a/packages/react/core/src/exports/events/usePreDestroyEventTrigger.ts b/packages/react/core/src/exports/events/usePreDestroyEventTrigger.ts index 40f925c7..8df4e19e 100644 --- a/packages/react/core/src/exports/events/usePreDestroyEventTrigger.ts +++ b/packages/react/core/src/exports/events/usePreDestroyEventTrigger.ts @@ -63,6 +63,7 @@ export const usePreDestroyEventTrigger = ( }; preDestroyCallback(preDestroyEvent); + getChatHistoryRef.current = undefined; }; }, []); }; diff --git a/packages/react/core/src/exports/hooks/useSubmitPromptHandler.ts b/packages/react/core/src/exports/hooks/useSubmitPromptHandler.ts index 913e5240..2baf1938 100644 --- a/packages/react/core/src/exports/hooks/useSubmitPromptHandler.ts +++ b/packages/react/core/src/exports/hooks/useSubmitPromptHandler.ts @@ -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'; @@ -60,6 +60,9 @@ export const useSubmitPromptHandler = (props: SubmitPromptHandlerProps>>({}); + useEffect(() => { domToReactRef.current = { chatSegments, @@ -73,9 +76,13 @@ export const useSubmitPromptHandler = (props: SubmitPromptHandlerProps = 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) { @@ -145,6 +152,12 @@ export const useSubmitPromptHandler = (props: SubmitPromptHandlerProps { handleSegmentItemReceived(userMessage); + if (callbackEvents.current?.messageSent) { + callbackEvents.current.messageSent({ + uid: userMessage.uid, + message: userMessage.content, + }); + } }); chatSegmentObservable.on('aiMessageStreamStarted', (aiStreamedMessage) => { @@ -155,6 +168,9 @@ export const useSubmitPromptHandler = (props: SubmitPromptHandlerProps { @@ -170,6 +186,12 @@ export const useSubmitPromptHandler = (props: SubmitPromptHandlerProps { @@ -207,6 +229,16 @@ export const useSubmitPromptHandler = (props: SubmitPromptHandlerProps { + 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); diff --git a/packages/react/core/src/index.tsx b/packages/react/core/src/index.tsx index b2c41728..b2892121 100644 --- a/packages/react/core/src/index.tsx +++ b/packages/react/core/src/index.tsx @@ -23,7 +23,9 @@ export type { PreDestroyCallback, PreDestroyEventDetails, MessageSentCallback, + MessageStreamStartedCallback, MessageReceivedCallback, + MessageRenderedCallback, } from '@nlux/core'; // Exporting from — shared diff --git a/specs/specs/aiChat/events/js/messageReceived-fetchAdapter.spec.ts b/specs/specs/aiChat/events/js/messageReceived-fetchAdapter.spec.ts new file mode 100644 index 00000000..7b898e5b --- /dev/null +++ b/specs/specs/aiChat/events/js/messageReceived-fetchAdapter.spec.ts @@ -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(); + }); + }); +}); diff --git a/specs/specs/aiChat/events/js/messageReceived-streamAdapter.spec.ts b/specs/specs/aiChat/events/js/messageReceived-streamAdapter.spec.ts new file mode 100644 index 00000000..00741f67 --- /dev/null +++ b/specs/specs/aiChat/events/js/messageReceived-streamAdapter.spec.ts @@ -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(); + }); + }); +}); diff --git a/specs/specs/aiChat/events/react/messageReceived-fetchAdapter.spec.tsx b/specs/specs/aiChat/events/react/messageReceived-fetchAdapter.spec.tsx new file mode 100644 index 00000000..2d016ae6 --- /dev/null +++ b/specs/specs/aiChat/events/react/messageReceived-fetchAdapter.spec.tsx @@ -0,0 +1,74 @@ +import {AiChat} from '@nlux-dev/react/src'; +import {render} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {adapterBuilder} from '../../../../utils/adapterBuilder'; +import {AdapterController} from '../../../../utils/adapters'; +import {waitForReactRenderCycle} from '../../../../utils/wait'; + +describe(' + fetch adapter + events + messageReceived', () => { + let adapterController: AdapterController | undefined; + + beforeEach(() => { + adapterController = adapterBuilder().withFetchText().create(); + }); + + afterEach(() => { + adapterController = undefined; + }); + + describe('When a message is received', () => { + it('It should trigger the messageReceived event', async () => { + // Arrange + const messageReceivedCallback = vi.fn(); + const aiChat = ( + + ); + + const {container} = render(aiChat); + await waitForReactRenderCycle(); + + const textArea: HTMLTextAreaElement = container.querySelector('.nlux-comp-prmptBox > textarea')!; + await userEvent.type(textArea, 'Hello{enter}'); + await waitForReactRenderCycle(); + + // Act + adapterController!.resolve('Yo!'); + await waitForReactRenderCycle(); + + // Assert + expect(messageReceivedCallback).toHaveBeenCalledOnce(); + }); + + it('It should pass the message to the callback', async () => { + // Arrange + const messageReceivedCallback = vi.fn(); + const aiChat = ( + + ); + + const {container} = render(aiChat); + await waitForReactRenderCycle(); + + const textArea: HTMLTextAreaElement = container.querySelector('.nlux-comp-prmptBox > textarea')!; + await userEvent.type(textArea, 'Hello{enter}'); + await waitForReactRenderCycle(); + + // Act + adapterController!.resolve('Yo!'); + await waitForReactRenderCycle(); + + // Assert + expect(messageReceivedCallback).toHaveBeenCalledWith({ + uid: expect.any(String), + message: 'Yo!', + }); + }); + }); +}); diff --git a/specs/specs/aiChat/events/react/messageReceived-streamAdapter.spec.tsx b/specs/specs/aiChat/events/react/messageReceived-streamAdapter.spec.tsx new file mode 100644 index 00000000..8ac3db73 --- /dev/null +++ b/specs/specs/aiChat/events/react/messageReceived-streamAdapter.spec.tsx @@ -0,0 +1,79 @@ +import {AiChat} from '@nlux-dev/react/src'; +import {render} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {adapterBuilder} from '../../../../utils/adapterBuilder'; +import {AdapterController} from '../../../../utils/adapters'; +import {waitForReactRenderCycle} from '../../../../utils/wait'; + +describe(' + stream adapter + events + messageReceived', () => { + let adapterController: AdapterController | undefined; + + beforeEach(() => { + adapterController = adapterBuilder() + .withFetchText(false) + .withStreamText(true) + .create(); + }); + + afterEach(() => { + adapterController = undefined; + }); + + describe('When a message is received', () => { + it('It should trigger the messageReceived event', async () => { + // Arrange + const messageReceivedCallback = vi.fn(); + const aiChat = ( + + ); + + const {container} = render(aiChat); + await waitForReactRenderCycle(); + + const textArea: HTMLTextAreaElement = container.querySelector('.nlux-comp-prmptBox > textarea')!; + await userEvent.type(textArea, 'Hello{enter}'); + await waitForReactRenderCycle(); + + // Act + adapterController!.next('Yo!'); + adapterController!.complete(); + await waitForReactRenderCycle(); + + // Assert + expect(messageReceivedCallback).toHaveBeenCalledOnce(); + }); + + it('It should pass the message to the callback', async () => { + // Arrange + const messageReceivedCallback = vi.fn(); + const aiChat = ( + + ); + + const {container} = render(aiChat); + await waitForReactRenderCycle(); + + const textArea: HTMLTextAreaElement = container.querySelector('.nlux-comp-prmptBox > textarea')!; + await userEvent.type(textArea, 'Hello{enter}'); + await waitForReactRenderCycle(); + + // Act + adapterController!.next('Yo!'); + adapterController!.complete(); + await waitForReactRenderCycle(); + + // Assert + expect(messageReceivedCallback).toHaveBeenCalledWith({ + uid: expect.any(String), + message: 'Yo!', + }); + }); + }); +});