diff --git a/README.md b/README.md index bf982149..7c66070e 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,11 @@ cross platforms, with a focus on performance and usability. ## Community & Support 🙏 +--- + * **Star The Repo** 🌟 ― If you like NLUX, please star the repo to show your support. +--- * **[GitHub Discussions](https://github.com/nluxai/nlux/discussions)** ― Ask questions, report issues, and share your ideas with the community. diff --git a/packages/js/core/src/components/chat/chat-room/chat-room.model.ts b/packages/js/core/src/components/chat/chat-room/chat-room.model.ts index 4ef052cb..0892e968 100644 --- a/packages/js/core/src/components/chat/chat-room/chat-room.model.ts +++ b/packages/js/core/src/components/chat/chat-room/chat-room.model.ts @@ -1,6 +1,7 @@ import {BaseComp} from '../../../core/comp/base'; import {comp} from '../../../core/comp/comp'; import {CompEventListener, Model} from '../../../core/comp/decorators'; +import {HistoryPayloadSize} from '../../../core/options/conversationOptions'; import {BotPersona, UserPersona} from '../../../core/options/personaOptions'; import {NluxContext} from '../../../types/context'; import {ConversationItem} from '../../../types/conversation'; @@ -69,6 +70,10 @@ export class CompChatRoom extends BaseComp< } } + public getConversationContentForAdapter(historyPayloadSize: HistoryPayloadSize = 'max') { + return this.conversation.getConversationContentForAdapter(historyPayloadSize); + } + public hide() { this.setProp('visible', false); } @@ -78,6 +83,13 @@ export class CompChatRoom extends BaseComp< this.promptBoxInstance?.focusTextInput(); } + @CompEventListener('chat-room-ready') + onChatRoomReady(event: MouseEvent) { + this.context.emit('ready', { + aiChatProps: this.context.aiChatProps, + }); + } + public setProps(props: Partial) { if (props.hasOwnProperty('containerMaxHeight')) { this.setProp('containerMaxHeight', props.containerMaxHeight ?? undefined); @@ -118,20 +130,6 @@ export class CompChatRoom extends BaseComp< this.setProp('visible', true); } - @CompEventListener('show-chat-room-clicked') - showChatRoom(event: MouseEvent) { - if (event.stopPropagation) { - event.stopPropagation(); - } - - if (event.preventDefault) { - event.preventDefault(); - } - - this.setProp('visible', true); - this.promptBoxInstance?.focusTextInput(); - } - private addConversation( scrollWhenGenerating: boolean, streamingAnimationSpeed: number, diff --git a/packages/js/core/src/components/chat/chat-room/chat-room.render.ts b/packages/js/core/src/components/chat/chat-room/chat-room.render.ts index 9d97eda6..52aac9f5 100644 --- a/packages/js/core/src/components/chat/chat-room/chat-room.render.ts +++ b/packages/js/core/src/components/chat/chat-room/chat-room.render.ts @@ -74,6 +74,7 @@ export const renderChatRoom: CompRenderer< const promptBoxElement = getElement(chatRoomElement, `:scope > .${__('prompt-box-container')}`); appendToRoot(chatRoomElement); + compEvent('chat-room-ready')(); return { elements: { diff --git a/packages/js/core/src/components/chat/chat-room/chat-room.types.ts b/packages/js/core/src/components/chat/chat-room/chat-room.types.ts index a51dbc70..c29f9947 100644 --- a/packages/js/core/src/components/chat/chat-room/chat-room.types.ts +++ b/packages/js/core/src/components/chat/chat-room/chat-room.types.ts @@ -1,8 +1,7 @@ import {BotPersona, UserPersona} from '../../../core/options/personaOptions'; import {ConversationItem} from '../../../types/conversation'; -export type CompChatRoomEvents = 'close-chat-room-clicked' - | 'show-chat-room-clicked' +export type CompChatRoomEvents = 'chat-room-ready' | 'messages-container-clicked'; export type CompChatRoomProps = { diff --git a/packages/js/core/src/components/chat/chat-room/chat-room.update.ts b/packages/js/core/src/components/chat/chat-room/chat-room.update.ts index a815e92f..38a5af27 100644 --- a/packages/js/core/src/components/chat/chat-room/chat-room.update.ts +++ b/packages/js/core/src/components/chat/chat-room/chat-room.update.ts @@ -6,13 +6,6 @@ export const updateChatRoom: CompUpdater { - if (propName === 'visible') { - if (elements?.chatRoomContainer) { - elements.chatRoomContainer.style.display = newValue ? '' : 'none'; - } - return; - } - if (propName === 'containerMaxHeight' && elements?.chatRoomContainer) { elements.chatRoomContainer.style.maxHeight = typeof newValue === 'number' ? `${newValue}px` diff --git a/packages/js/core/src/core/renderer/renderer.ts b/packages/js/core/src/core/renderer/renderer.ts index e17c9e83..44b89361 100644 --- a/packages/js/core/src/core/renderer/renderer.ts +++ b/packages/js/core/src/core/renderer/renderer.ts @@ -245,6 +245,19 @@ export class NluxRenderer { }); } + // + // Trigger pre-destroy event + // + this.context.emit('preDestroy', { + aiChatProps: this.context.aiChatProps, + conversationHistory: this.chatRoom.getConversationContentForAdapter( + this.context.aiChatProps.conversationOptions?.historyPayloadSize, + ) ?? [], + }); + + // + // Destroy UI components + // if (this.exceptionsBox) { this.exceptionsBox.destroy(); } diff --git a/packages/js/core/src/index.ts b/packages/js/core/src/index.ts index 5865ed98..60524082 100644 --- a/packages/js/core/src/index.ts +++ b/packages/js/core/src/index.ts @@ -29,9 +29,13 @@ export type {AiChatInternalProps} from './types/props'; export type { EventName, EventCallback, + EventsMap, ErrorCallback, ErrorEventDetails, - EventsMap, + ReadyCallback, + ReadyEventDetails, + PreDestroyCallback, + PreDestroyEventDetails, MessageSentCallback, MessageReceivedCallback, } from './types/event'; diff --git a/packages/js/core/src/types/event.ts b/packages/js/core/src/types/event.ts index 81e8df3d..86b9d888 100644 --- a/packages/js/core/src/types/event.ts +++ b/packages/js/core/src/types/event.ts @@ -1,20 +1,33 @@ import {ExceptionId} from '../exceptions/exceptions'; +import {ConversationItem} from './conversation'; +import {AiChatProps} from './props'; export type ErrorEventDetails = { errorId: ExceptionId; message: string; }; -export type ErrorCallback = (errorDetails: ErrorEventDetails) => void; +export type ReadyEventDetails = { + aiChatProps: AiChatProps; +} -export type MessageReceivedCallback = (message: string) => void; +export type PreDestroyEventDetails = { + aiChatProps: AiChatProps; + conversationHistory: Readonly; +} +export type ErrorCallback = (errorDetails: ErrorEventDetails) => void; +export type MessageReceivedCallback = (message: string) => void; export type MessageSentCallback = (message: string) => void; +export type ReadyCallback = (readyDetails: ReadyEventDetails) => void; +export type PreDestroyCallback = (preDestroyDetails: PreDestroyEventDetails) => void; export type EventsMap = { - error: ErrorCallback; + ready: ReadyCallback; + preDestroy: PreDestroyCallback; messageSent: MessageSentCallback; messageReceived: MessageReceivedCallback; + error: ErrorCallback; }; export type EventName = keyof EventsMap; diff --git a/packages/react/core/src/index.tsx b/packages/react/core/src/index.tsx index 3c94433f..bcbef60b 100644 --- a/packages/react/core/src/index.tsx +++ b/packages/react/core/src/index.tsx @@ -15,9 +15,13 @@ export type { export type { EventName, EventCallback, + EventsMap, ErrorCallback, ErrorEventDetails, - EventsMap, + ReadyCallback, + ReadyEventDetails, + PreDestroyCallback, + PreDestroyEventDetails, MessageSentCallback, MessageReceivedCallback, } from '@nlux/core'; diff --git a/pipeline/npm/core/README.md b/pipeline/npm/core/README.md index 4c4ccdd7..65bbfdd7 100644 --- a/pipeline/npm/core/README.md +++ b/pipeline/npm/core/README.md @@ -90,7 +90,7 @@ from [`@nlux/themes`](https://www.npmjs.com/package/@nlux/themes) or use the CDN hosted version from below: ```jsx - + ``` This CDN is provided for demo purposes only and it's not scalable. diff --git a/pipeline/npm/versions.json b/pipeline/npm/versions.json index d3f97c97..bf7ca527 100644 --- a/pipeline/npm/versions.json +++ b/pipeline/npm/versions.json @@ -1,6 +1,6 @@ { "inherit": true, - "nlux": "0.11.0", + "nlux": "0.11.2", "peerDependencies": { "react": "18.2.0", "react-dom": "18.2.0" diff --git a/specs/specs/core/events/js/preDestroy.spec.ts b/specs/specs/core/events/js/preDestroy.spec.ts new file mode 100644 index 00000000..6d802b8d --- /dev/null +++ b/specs/specs/core/events/js/preDestroy.spec.ts @@ -0,0 +1,128 @@ +import {AiChat, createAiChat} from '@nlux-dev/core/src'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {adapterBuilder} from '../../../../utils/adapterBuilder'; +import {AdapterController} from '../../../../utils/adapters'; +import {submit, type} from '../../../../utils/userInteractions'; +import {waitForRenderCycle} from '../../../../utils/wait'; + +describe('When pre-destroy event handler is used with a Vanilla JS Component', () => { + let rootElement: HTMLElement; + let adapterController: AdapterController | undefined = undefined; + let aiChat: AiChat | undefined; + + beforeEach(() => { + rootElement = document.createElement('div'); + document.body.append(rootElement); + }); + + afterEach(() => { + adapterController = undefined; + aiChat?.unmount(); + rootElement?.remove(); + aiChat = undefined; + }); + + it('should be called when the component is about to get destroyed', async () => { + const preDestroyCallback = vi.fn(); + + adapterController = adapterBuilder().withFetchText().create(); + aiChat = createAiChat() + .withAdapter(adapterController.adapter) + .on('preDestroy', preDestroyCallback); + + await waitForRenderCycle(); + expect(preDestroyCallback).not.toHaveBeenCalled(); + + aiChat.mount(rootElement); + await waitForRenderCycle(); + expect(preDestroyCallback).not.toHaveBeenCalled(); + + aiChat.unmount(); + await waitForRenderCycle(); + expect(preDestroyCallback).toHaveBeenCalledOnce(); + + expect(preDestroyCallback).toHaveBeenCalledWith({ + aiChatProps: expect.objectContaining({ + adapter: adapterController.adapter, + }), + conversationHistory: [], + }); + }); + + it('should be called with conversation history and options', async () => { + const preDestroyCallback = vi.fn(); + + adapterController = adapterBuilder().withFetchText().create(); + aiChat = createAiChat() + .withAdapter(adapterController.adapter) + .withClassName('my-class') + .withConversationOptions({ + scrollWhenGenerating: false, + }) + .withInitialConversation([ + { + role: 'user', + message: 'Hello', + }, + { + role: 'ai', + message: 'Hi', + }, + ]) + .on('preDestroy', preDestroyCallback); + + + aiChat.mount(rootElement); + await waitForRenderCycle(); + + await type('Tell me a joke'); + await waitForRenderCycle(); + + await submit(); + await waitForRenderCycle(); + + adapterController.resolve('Why did the chicken cross the road? To get to the other side.'); + await waitForRenderCycle(); + + aiChat.unmount(); + await waitForRenderCycle(); + + expect(preDestroyCallback).toHaveBeenCalledWith(expect.objectContaining({ + aiChatProps: expect.objectContaining({ + adapter: adapterController.adapter, + className: 'my-class', + conversationOptions: { + scrollWhenGenerating: false, + }, + initialConversation: [ + { + role: 'user', + message: 'Hello', + }, + { + role: 'ai', + message: 'Hi', + }, + ], + }), + conversationHistory: [ + { + role: 'user', + message: 'Hello', + }, + { + role: 'ai', + message: 'Hi', + }, + { + role: 'user', + message: 'Tell me a joke', + }, + { + role: 'ai', + message: 'Why did the chicken cross the road? To get to the other side.', + }, + ], + })); + }); +}); diff --git a/specs/specs/core/events/js/ready.spec.ts b/specs/specs/core/events/js/ready.spec.ts new file mode 100644 index 00000000..53ddef6f --- /dev/null +++ b/specs/specs/core/events/js/ready.spec.ts @@ -0,0 +1,62 @@ +import {AiChat, createAiChat} from '@nlux-dev/core/src'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {adapterBuilder} from '../../../../utils/adapterBuilder'; +import {AdapterController} from '../../../../utils/adapters'; +import {waitForRenderCycle} from '../../../../utils/wait'; + +describe('When ready event handler is used with a Vanilla JS Component', () => { + let rootElement: HTMLElement; + let adapterController: AdapterController | undefined = undefined; + let aiChat: AiChat | undefined; + + beforeEach(() => { + rootElement = document.createElement('div'); + document.body.append(rootElement); + }); + + afterEach(() => { + adapterController = undefined; + aiChat?.unmount(); + rootElement?.remove(); + aiChat = undefined; + }); + + it('should be called when the component is ready', async () => { + const readyCallback = vi.fn(); + + adapterController = adapterBuilder().withFetchText().create(); + aiChat = createAiChat() + .withAdapter(adapterController.adapter) + .on('ready', readyCallback); + + await waitForRenderCycle(); + expect(readyCallback).not.toHaveBeenCalled(); + + aiChat.mount(rootElement); + await waitForRenderCycle(); + expect(readyCallback).toHaveBeenCalledOnce(); + }); + + it('should be called with initialisation options', async () => { + const readyCallback = vi.fn(); + + adapterController = adapterBuilder().withFetchText().create(); + aiChat = createAiChat() + .withAdapter(adapterController.adapter) + .on('ready', readyCallback) + .withClassName('test-class'); + + await waitForRenderCycle(); + expect(readyCallback).not.toHaveBeenCalled(); + + aiChat.mount(rootElement); + await waitForRenderCycle(); + + expect(readyCallback).toHaveBeenCalledWith(expect.objectContaining({ + aiChatProps: { + adapter: adapterController.adapter, + className: 'test-class', + }, + })); + }); +}); diff --git a/specs/specs/core/events/react/preDestroy.spec.tsx b/specs/specs/core/events/react/preDestroy.spec.tsx new file mode 100644 index 00000000..c85a6a29 --- /dev/null +++ b/specs/specs/core/events/react/preDestroy.spec.tsx @@ -0,0 +1,111 @@ +import {AiChat} from '@nlux/react'; +import {render} from '@testing-library/react'; +import React from 'react'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {adapterBuilder} from '../../../../utils/adapterBuilder'; +import {AdapterController} from '../../../../utils/adapters'; +import {submit, type} from '../../../../utils/userInteractions'; +import {waitForRenderCycle} from '../../../../utils/wait'; + +describe('When pre-destroy event handler is used with React JS AiChat component', () => { + let adapterController: AdapterController; + + beforeEach(() => { + adapterController = adapterBuilder().withFetchText().create(); + }); + + it('should be called when the component is about to get destroyed', async () => { + const preDestroy = vi.fn(); + const component = ; + + const {unmount} = render(component); + await waitForRenderCycle(); + expect(preDestroy).not.toHaveBeenCalled(); + + unmount(); + await waitForRenderCycle(); + expect(preDestroy).toHaveBeenCalledOnce(); + }); + + it('should be called with conversation history and options', async () => { + const preDestroyCallback = vi.fn(); + const component = ; + + const {unmount} = render(component); + await waitForRenderCycle(); + + await type('Tell me a joke'); + await waitForRenderCycle(); + + await submit(); + await waitForRenderCycle(); + + adapterController.resolve('Why did the chicken cross the road? To get to the other side.'); + await waitForRenderCycle(); + + unmount(); + await waitForRenderCycle(); + + expect(preDestroyCallback).toHaveBeenCalledWith(expect.objectContaining({ + aiChatProps: expect.objectContaining({ + adapter: adapterController.adapter, + className: 'my-class', + conversationOptions: { + scrollWhenGenerating: false, + }, + initialConversation: [ + { + role: 'user', + message: 'Hello', + }, + { + role: 'ai', + message: 'Hi', + }, + ], + }), + conversationHistory: [ + { + role: 'user', + message: 'Hello', + }, + { + role: 'ai', + message: 'Hi', + }, + { + role: 'user', + message: 'Tell me a joke', + }, + { + role: 'ai', + message: 'Why did the chicken cross the road? To get to the other side.', + }, + ], + })); + }); +}); diff --git a/specs/specs/core/events/react/ready.spec.tsx b/specs/specs/core/events/react/ready.spec.tsx new file mode 100644 index 00000000..5b4cfc3f --- /dev/null +++ b/specs/specs/core/events/react/ready.spec.tsx @@ -0,0 +1,50 @@ +import {AiChat} from '@nlux/react'; +import {render} from '@testing-library/react'; +import React from 'react'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {adapterBuilder} from '../../../../utils/adapterBuilder'; +import {AdapterController} from '../../../../utils/adapters'; +import {waitForRenderCycle} from '../../../../utils/wait'; + +describe('When ready event handler is used with React JS AiChat component', () => { + let adapterController: AdapterController; + + beforeEach(() => { + adapterController = adapterBuilder().withFetchText().create(); + }); + + it('should be called when the component is mounted', async () => { + const readyCallback = vi.fn(); + const component = ; + + render(component); + await waitForRenderCycle(); + expect(readyCallback).toHaveBeenCalledOnce(); + }); + + it('should be called with initialisation options', async () => { + const readyCallback = vi.fn(); + const component = ; + + render(component); + await waitForRenderCycle(); + + expect(readyCallback).toHaveBeenCalledWith(expect.objectContaining({ + aiChatProps: { + adapter: adapterController.adapter, + className: 'test-class', + }, + })); + }); +});