From b493dba2a3680ac8278efe0a108c6b3b7c0b2feb Mon Sep 17 00:00:00 2001 From: Salmen Hichri Date: Wed, 27 Mar 2024 05:06:39 +0000 Subject: [PATCH] Rewriting the UI components rendering layer to support custom components --- .../themes/src/naked/components/AiChat.css | 8 + .../src/naked/components/ChatPicture.css | 40 ++ .../src/naked/components/ConversationItem.css | 17 + .../themes/src/naked/components/Loader.css | 38 ++ .../themes/src/naked/components/Message.css | 22 + .../themes/src/naked/components/PromptBox.css | 63 +++ .../src/naked/components/WelcomeMessage.css | 2 + .../themes/src/naked/components/animation.css | 27 + .../themes/src/naked/components/colors.css | 5 + .../js/core/src/comp/ChatPicture/create.ts | 32 ++ .../js/core/src/comp/ChatPicture/props.ts | 4 + .../js/core/src/comp/ChatPicture/update.ts | 28 + .../utils/createPhotoContainerFromUrl.ts | 28 + .../utils/updateContentOnPictureChange.ts | 41 ++ .../ChatPicture/utils/updateNameOnPicture.ts | 22 + .../core/src/comp/ConversationItem/create.ts | 38 ++ .../core/src/comp/ConversationItem/props.ts | 11 + .../core/src/comp/ConversationItem/update.ts | 79 +++ .../utils/applyNewDirectionClassName.ts | 17 + .../js/core/src/comp/ExceptionsBox/create.ts | 14 + .../js/core/src/comp/ExceptionsBox/props.ts | 3 + .../js/core/src/comp/ExceptionsBox/update.ts | 16 + packages/js/core/src/comp/Loader/create.ts | 8 + packages/js/core/src/comp/Message/create.ts | 36 ++ packages/js/core/src/comp/Message/props.ts | 17 + packages/js/core/src/comp/Message/update.ts | 67 +++ .../utils/applyNewDirectionClassName.ts | 17 + .../Message/utils/applyNewStatusClassName.ts | 18 + .../Message/utils/createMessageContent.ts | 5 + .../utils/updateContentOnMessageChange.ts | 15 + .../utils/updateContentOnStatusChange.ts | 43 ++ packages/js/core/src/comp/PromptBox/create.ts | 35 ++ packages/js/core/src/comp/PromptBox/props.ts | 7 + packages/js/core/src/comp/PromptBox/update.ts | 34 ++ .../utils/applyNewStatusClassName.ts | 16 + .../utils/updateContentOnStatusChange.ts | 37 ++ packages/js/core/src/comp/SendIcon/create.ts | 17 + .../js/core/src/comp/WelcomeMessage/create.ts | 30 ++ .../js/core/src/comp/WelcomeMessage/props.ts | 5 + .../js/core/src/comp/WelcomeMessage/update.ts | 52 ++ .../utils/updateWelcomeMessageText.ts | 21 + .../miscellaneous/exceptions-box/render.ts | 2 +- packages/js/core/src/core/aiChat/comp/base.ts | 14 + .../core/src/core/aiChat/comp/controller.ts | 5 + .../src/core/aiChat/controller/controller.ts | 4 +- .../src/core/aiChat/events/eventManager.ts | 19 +- .../core/src/core/aiChat/renderer/renderer.ts | 2 +- .../core/aiChat/services/submitPrompt/impl.ts | 121 +++++ .../services/submitPrompt/submitPrompt.ts | 15 + packages/js/core/src/index.ts | 93 +++- packages/js/core/src/types/conversation.ts | 4 +- .../js/core/src/types/conversationPart.ts | 115 +++++ packages/js/core/src/types/dom/DomCreator.ts | 6 + packages/js/core/src/types/dom/DomUpdater.ts | 6 + .../src/comp/ChatPicture/ChatPictureComp.tsx | 34 ++ .../react/core/src/comp/ChatPicture/props.ts | 6 + .../comp/Conversation/ConversationComp.tsx | 76 +++ .../react/core/src/comp/Conversation/props.ts | 25 + .../utils/useInitialMessagesOnce.tsx | 30 ++ .../ConversationItem/ConversationItemComp.tsx | 21 + .../core/src/comp/ConversationItem/props.ts | 12 + .../CustomConversationItemComp.tsx | 41 ++ .../src/comp/CustomConversationItem/props.ts | 13 + .../comp/ExceptionsBox/ExceptionsBoxComp.tsx | 9 + .../core/src/comp/ExceptionsBox/props.ts | 3 + .../react/core/src/comp/Loader/LoaderComp.tsx | 10 + .../core/src/comp/Message/MessageComp.tsx | 40 ++ packages/react/core/src/comp/Message/props.ts | 9 + .../core/src/comp/PromptBox/PromptBoxComp.tsx | 34 ++ .../react/core/src/comp/PromptBox/props.ts | 17 + .../core/src/comp/SendIcon/SendIconComp.tsx | 18 + .../WelcomeMessage/WelcomeMessageComp.tsx | 25 + .../core/src/comp/WelcomeMessage/props.ts | 7 + .../AiChat/handleNewPropsReceived.ts | 93 ---- .../core/src/components/AiChat/index.tsx | 124 ----- .../react/core/src/components/AiChat/props.ts | 66 --- packages/react/core/src/exp/AiChat.tsx | 117 +++++ .../personaOptions.tsx} | 0 packages/react/core/src/exp/props.tsx | 25 + packages/react/core/src/index.tsx | 28 +- .../src/utils/reactPersonasToCorePersonas.tsx | 2 +- pipeline/npm/core/README.md | 10 +- pipeline/npm/versions.json | 2 +- samples/comp-dom-expo/.gitignore | 24 + samples/comp-dom-expo/index.html | 17 + samples/comp-dom-expo/package.json | 15 + .../src/components/ChatPicture.ts | 109 ++++ .../src/components/ConversationItem.ts | 85 ++++ .../comp-dom-expo/src/components/Loader.ts | 24 + .../comp-dom-expo/src/components/Message.ts | 81 +++ .../comp-dom-expo/src/components/PromptBox.ts | 79 +++ .../src/components/WelcomeMessage.ts | 74 +++ samples/comp-dom-expo/src/style.css | 29 ++ samples/comp-dom-expo/src/vite-env.d.ts | 1 + samples/comp-dom-expo/tsconfig.json | 23 + samples/comp-react-expo/.eslintrc.cjs | 18 + samples/comp-react-expo/.gitignore | 24 + samples/comp-react-expo/README.md | 30 ++ samples/comp-react-expo/index.html | 13 + samples/comp-react-expo/package.json | 28 + samples/comp-react-expo/src/App.css | 14 + samples/comp-react-expo/src/App.tsx | 20 + .../comp-react-expo/src/comp/AiChatExpo.tsx | 119 +++++ .../src/comp/ChatPictureExpo.tsx | 52 ++ .../src/comp/ConversationItemExpo.tsx | 83 +++ .../comp-react-expo/src/comp/LoaderExpo.tsx | 18 + .../comp-react-expo/src/comp/MessageExpo.tsx | 66 +++ .../src/comp/PromptBoxExpo.tsx | 44 ++ .../src/comp/WelcomeMessageExpo.tsx | 47 ++ samples/comp-react-expo/src/index.css | 7 + samples/comp-react-expo/src/main.tsx | 10 + samples/comp-react-expo/src/vite-env.d.ts | 1 + samples/comp-react-expo/tsconfig.json | 25 + samples/comp-react-expo/tsconfig.node.json | 11 + samples/comp-react-expo/vite.config.ts | 7 + .../dom/ChatPicture/ChatPicture-html.spec.ts | 139 +++++ .../dom/ChatPicture/ChatPicture-url.spec.ts | 24 + .../ConversationItem-incoming.spec.ts | 102 ++++ .../ConversationItem-message-update.spec.ts | 75 +++ .../ConversationItem-picture-update.spec.ts | 59 +++ .../dom/ExceptionsBox/ExceptionsBox.spec.ts | 43 ++ .../dom/Message/Message-direction.spec.ts | 87 ++++ .../comp/dom/Message/Message-loading.spec.ts | 117 +++++ .../comp/dom/Message/Message-received.spec.ts | 187 +++++++ .../dom/Message/Message-streaming.spec.ts | 32 ++ .../PromptBox/PromptBox-submitting.spec.ts | 70 +++ .../dom/PromptBox/PromptBox-typing.spec.ts | 141 +++++ .../dom/PromptBox/PromptBox-waiting.spec.ts | 69 +++ .../WelcomeMessage-update.spec.ts | 71 +++ .../dom/WelcomeMessage/WelcomeMessage.spec.ts | 69 +++ .../CustomConversationItem.spec.tsx | 38 ++ .../ConversationItem.spec.tsx | 21 + .../specs/core/services/submitPrompt.spec.ts | 481 ++++++++++++++++++ yarn.lock | 467 ++++++++++++++++- 134 files changed, 5489 insertions(+), 339 deletions(-) create mode 100644 packages/css/themes/src/naked/components/AiChat.css create mode 100644 packages/css/themes/src/naked/components/ChatPicture.css create mode 100644 packages/css/themes/src/naked/components/ConversationItem.css create mode 100644 packages/css/themes/src/naked/components/Loader.css create mode 100644 packages/css/themes/src/naked/components/Message.css create mode 100644 packages/css/themes/src/naked/components/PromptBox.css create mode 100644 packages/css/themes/src/naked/components/WelcomeMessage.css create mode 100644 packages/css/themes/src/naked/components/animation.css create mode 100644 packages/css/themes/src/naked/components/colors.css create mode 100644 packages/js/core/src/comp/ChatPicture/create.ts create mode 100644 packages/js/core/src/comp/ChatPicture/props.ts create mode 100644 packages/js/core/src/comp/ChatPicture/update.ts create mode 100644 packages/js/core/src/comp/ChatPicture/utils/createPhotoContainerFromUrl.ts create mode 100644 packages/js/core/src/comp/ChatPicture/utils/updateContentOnPictureChange.ts create mode 100644 packages/js/core/src/comp/ChatPicture/utils/updateNameOnPicture.ts create mode 100644 packages/js/core/src/comp/ConversationItem/create.ts create mode 100644 packages/js/core/src/comp/ConversationItem/props.ts create mode 100644 packages/js/core/src/comp/ConversationItem/update.ts create mode 100644 packages/js/core/src/comp/ConversationItem/utils/applyNewDirectionClassName.ts create mode 100644 packages/js/core/src/comp/ExceptionsBox/create.ts create mode 100644 packages/js/core/src/comp/ExceptionsBox/props.ts create mode 100644 packages/js/core/src/comp/ExceptionsBox/update.ts create mode 100644 packages/js/core/src/comp/Loader/create.ts create mode 100644 packages/js/core/src/comp/Message/create.ts create mode 100644 packages/js/core/src/comp/Message/props.ts create mode 100644 packages/js/core/src/comp/Message/update.ts create mode 100644 packages/js/core/src/comp/Message/utils/applyNewDirectionClassName.ts create mode 100644 packages/js/core/src/comp/Message/utils/applyNewStatusClassName.ts create mode 100644 packages/js/core/src/comp/Message/utils/createMessageContent.ts create mode 100644 packages/js/core/src/comp/Message/utils/updateContentOnMessageChange.ts create mode 100644 packages/js/core/src/comp/Message/utils/updateContentOnStatusChange.ts create mode 100644 packages/js/core/src/comp/PromptBox/create.ts create mode 100644 packages/js/core/src/comp/PromptBox/props.ts create mode 100644 packages/js/core/src/comp/PromptBox/update.ts create mode 100644 packages/js/core/src/comp/PromptBox/utils/applyNewStatusClassName.ts create mode 100644 packages/js/core/src/comp/PromptBox/utils/updateContentOnStatusChange.ts create mode 100644 packages/js/core/src/comp/SendIcon/create.ts create mode 100644 packages/js/core/src/comp/WelcomeMessage/create.ts create mode 100644 packages/js/core/src/comp/WelcomeMessage/props.ts create mode 100644 packages/js/core/src/comp/WelcomeMessage/update.ts create mode 100644 packages/js/core/src/comp/WelcomeMessage/utils/updateWelcomeMessageText.ts create mode 100644 packages/js/core/src/core/aiChat/comp/controller.ts create mode 100644 packages/js/core/src/core/aiChat/services/submitPrompt/impl.ts create mode 100644 packages/js/core/src/core/aiChat/services/submitPrompt/submitPrompt.ts create mode 100644 packages/js/core/src/types/conversationPart.ts create mode 100644 packages/js/core/src/types/dom/DomCreator.ts create mode 100644 packages/js/core/src/types/dom/DomUpdater.ts create mode 100644 packages/react/core/src/comp/ChatPicture/ChatPictureComp.tsx create mode 100644 packages/react/core/src/comp/ChatPicture/props.ts create mode 100644 packages/react/core/src/comp/Conversation/ConversationComp.tsx create mode 100644 packages/react/core/src/comp/Conversation/props.ts create mode 100644 packages/react/core/src/comp/Conversation/utils/useInitialMessagesOnce.tsx create mode 100644 packages/react/core/src/comp/ConversationItem/ConversationItemComp.tsx create mode 100644 packages/react/core/src/comp/ConversationItem/props.ts create mode 100644 packages/react/core/src/comp/CustomConversationItem/CustomConversationItemComp.tsx create mode 100644 packages/react/core/src/comp/CustomConversationItem/props.ts create mode 100644 packages/react/core/src/comp/ExceptionsBox/ExceptionsBoxComp.tsx create mode 100644 packages/react/core/src/comp/ExceptionsBox/props.ts create mode 100644 packages/react/core/src/comp/Loader/LoaderComp.tsx create mode 100644 packages/react/core/src/comp/Message/MessageComp.tsx create mode 100644 packages/react/core/src/comp/Message/props.ts create mode 100644 packages/react/core/src/comp/PromptBox/PromptBoxComp.tsx create mode 100644 packages/react/core/src/comp/PromptBox/props.ts create mode 100644 packages/react/core/src/comp/SendIcon/SendIconComp.tsx create mode 100644 packages/react/core/src/comp/WelcomeMessage/WelcomeMessageComp.tsx create mode 100644 packages/react/core/src/comp/WelcomeMessage/props.ts delete mode 100644 packages/react/core/src/components/AiChat/handleNewPropsReceived.ts delete mode 100644 packages/react/core/src/components/AiChat/index.tsx delete mode 100644 packages/react/core/src/components/AiChat/props.ts create mode 100644 packages/react/core/src/exp/AiChat.tsx rename packages/react/core/src/{components/AiChat/personaOptions.ts => exp/personaOptions.tsx} (100%) create mode 100644 packages/react/core/src/exp/props.tsx create mode 100644 samples/comp-dom-expo/.gitignore create mode 100644 samples/comp-dom-expo/index.html create mode 100644 samples/comp-dom-expo/package.json create mode 100644 samples/comp-dom-expo/src/components/ChatPicture.ts create mode 100644 samples/comp-dom-expo/src/components/ConversationItem.ts create mode 100644 samples/comp-dom-expo/src/components/Loader.ts create mode 100644 samples/comp-dom-expo/src/components/Message.ts create mode 100644 samples/comp-dom-expo/src/components/PromptBox.ts create mode 100644 samples/comp-dom-expo/src/components/WelcomeMessage.ts create mode 100644 samples/comp-dom-expo/src/style.css create mode 100644 samples/comp-dom-expo/src/vite-env.d.ts create mode 100644 samples/comp-dom-expo/tsconfig.json create mode 100644 samples/comp-react-expo/.eslintrc.cjs create mode 100644 samples/comp-react-expo/.gitignore create mode 100644 samples/comp-react-expo/README.md create mode 100644 samples/comp-react-expo/index.html create mode 100644 samples/comp-react-expo/package.json create mode 100644 samples/comp-react-expo/src/App.css create mode 100644 samples/comp-react-expo/src/App.tsx create mode 100644 samples/comp-react-expo/src/comp/AiChatExpo.tsx create mode 100644 samples/comp-react-expo/src/comp/ChatPictureExpo.tsx create mode 100644 samples/comp-react-expo/src/comp/ConversationItemExpo.tsx create mode 100644 samples/comp-react-expo/src/comp/LoaderExpo.tsx create mode 100644 samples/comp-react-expo/src/comp/MessageExpo.tsx create mode 100644 samples/comp-react-expo/src/comp/PromptBoxExpo.tsx create mode 100644 samples/comp-react-expo/src/comp/WelcomeMessageExpo.tsx create mode 100644 samples/comp-react-expo/src/index.css create mode 100644 samples/comp-react-expo/src/main.tsx create mode 100644 samples/comp-react-expo/src/vite-env.d.ts create mode 100644 samples/comp-react-expo/tsconfig.json create mode 100644 samples/comp-react-expo/tsconfig.node.json create mode 100644 samples/comp-react-expo/vite.config.ts create mode 100644 specs/specs/comp/dom/ChatPicture/ChatPicture-html.spec.ts create mode 100644 specs/specs/comp/dom/ChatPicture/ChatPicture-url.spec.ts create mode 100644 specs/specs/comp/dom/ConversationItem/ConversationItem-incoming.spec.ts create mode 100644 specs/specs/comp/dom/ConversationItem/ConversationItem-message-update.spec.ts create mode 100644 specs/specs/comp/dom/ConversationItem/ConversationItem-picture-update.spec.ts create mode 100644 specs/specs/comp/dom/ExceptionsBox/ExceptionsBox.spec.ts create mode 100644 specs/specs/comp/dom/Message/Message-direction.spec.ts create mode 100644 specs/specs/comp/dom/Message/Message-loading.spec.ts create mode 100644 specs/specs/comp/dom/Message/Message-received.spec.ts create mode 100644 specs/specs/comp/dom/Message/Message-streaming.spec.ts create mode 100644 specs/specs/comp/dom/PromptBox/PromptBox-submitting.spec.ts create mode 100644 specs/specs/comp/dom/PromptBox/PromptBox-typing.spec.ts create mode 100644 specs/specs/comp/dom/PromptBox/PromptBox-waiting.spec.ts create mode 100644 specs/specs/comp/dom/WelcomeMessage/WelcomeMessage-update.spec.ts create mode 100644 specs/specs/comp/dom/WelcomeMessage/WelcomeMessage.spec.ts create mode 100644 specs/specs/comp/react/ConversationItem/CustomConversationItem.spec.tsx create mode 100644 specs/specs/comp/react/CustomConversationItem/ConversationItem.spec.tsx create mode 100644 specs/specs/core/services/submitPrompt.spec.ts diff --git a/packages/css/themes/src/naked/components/AiChat.css b/packages/css/themes/src/naked/components/AiChat.css new file mode 100644 index 00000000..498c6e76 --- /dev/null +++ b/packages/css/themes/src/naked/components/AiChat.css @@ -0,0 +1,8 @@ +@import './animation.css'; +@import './colors.css'; +@import './ChatPicture.css'; +@import './ConversationItem.css'; +@import './Loader.css'; +@import './Message.css'; +@import './PromptBox.css'; +@import './WelcomeMessage.css'; diff --git a/packages/css/themes/src/naked/components/ChatPicture.css b/packages/css/themes/src/naked/components/ChatPicture.css new file mode 100644 index 00000000..3769b262 --- /dev/null +++ b/packages/css/themes/src/naked/components/ChatPicture.css @@ -0,0 +1,40 @@ +.nlux_comp_cht_pic { + position: relative; + display: inline-block; + overflow: hidden; + align-items: stretch; + justify-content: stretch; + width: 50px; + color: var(--foreground-color); + border: 1px solid var(--border-color); + border-radius: 20%; + background-color: var(--background-color); + box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.1); + aspect-ratio: 1; + + > .cht_pic_ctn { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + aspect-ratio: 1; + + > .cht_pic_ltr { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + } + + > .cht_pic_img { + position: absolute; + top: 0; + left: 0; + display: block; + width: 100%; + height: 100%; + background-size: cover; + } + } +} diff --git a/packages/css/themes/src/naked/components/ConversationItem.css b/packages/css/themes/src/naked/components/ConversationItem.css new file mode 100644 index 00000000..0cf9c936 --- /dev/null +++ b/packages/css/themes/src/naked/components/ConversationItem.css @@ -0,0 +1,17 @@ +.nlux_comp_cnv_itm { + display: flex; + flex-direction: row; + gap: 0.5em; + + > .nlux_comp_msg { + flex: 1; + } +} + +.nlux_comp_cnv_itm.nlux_cnv_itm_incoming { + flex-direction: row-reverse; +} + +.nlux_comp_cnv_itm.nlux_cnv_itm_outgoing { + flex-direction: row; +} diff --git a/packages/css/themes/src/naked/components/Loader.css b/packages/css/themes/src/naked/components/Loader.css new file mode 100644 index 00000000..3fc17b8d --- /dev/null +++ b/packages/css/themes/src/naked/components/Loader.css @@ -0,0 +1,38 @@ +.nlux_msg_ldr { + display: flex; + align-items: center; + justify-content: center; + + > .spn_ldr_ctn { + width: 17px; + + > .spn_ldr { + display: inline-block; + width: 15px; + height: 15px; + + transform: rotateZ(45deg); + border-radius: 50%; + perspective: 1000px; + + &:before, + &:after { + position: absolute; + top: 0; + left: 0; + display: block; + width: inherit; + height: inherit; + content: ''; + transform: rotateX(70deg); + animation: 1s nlux-loader-spin linear infinite; + border-radius: 50%; + } + + &:after { + transform: rotateY(70deg); + animation-delay: .4s; + } + } + } +} diff --git a/packages/css/themes/src/naked/components/Message.css b/packages/css/themes/src/naked/components/Message.css new file mode 100644 index 00000000..312d08b3 --- /dev/null +++ b/packages/css/themes/src/naked/components/Message.css @@ -0,0 +1,22 @@ +@import './colors.css'; + +.nlux_comp_msg { + display: flex; + align-items: center; + justify-content: flex-start; + min-height: 1.5em; + margin: 0; + padding: 0.5em; + text-align: left; + color: var(--foreground-color); + border: 1px solid var(--border-color); + border-radius: 0.25em; + background-color: var(--background-color); + box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.1); +} + +.nlux_comp_msg.nlux_msg_loading { + display: flex; + flex: 0; + justify-content: center; +} diff --git a/packages/css/themes/src/naked/components/PromptBox.css b/packages/css/themes/src/naked/components/PromptBox.css new file mode 100644 index 00000000..e58bd916 --- /dev/null +++ b/packages/css/themes/src/naked/components/PromptBox.css @@ -0,0 +1,63 @@ +.nlux_comp_prmpt_box { + display: flex; + align-items: stretch; + flex-direction: row; + justify-content: center; + padding: 0.5em; + + color: var(--foreground-color); + border: 1px solid var(--border-color); + border-radius: 0.25em; + background-color: var(--background-color); + box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.1); + gap: 0.5em; + + > textarea { + flex: 1; + resize: none; + border: none; + background-color: transparent; + } + + > button { + display: flex; + align-items: center; + justify-content: center; + width: 50px; + padding: 0; + aspect-ratio: 1; + + > .nlux_snd_icn { + width: 50%; + } + + > .nlux_msg_ldr { + display: none; + } + } + + > button:disabled { + cursor: not-allowed; + } +} + +.nlux_comp_prmpt_box.nlux_prmpt_typing { + button > .nlux_snd_icn { + display: inline-block; + } + + button > .nlux_msg_ldr { + display: none; + } +} + +.nlux_comp_prmpt_box.nlux_prmpt_submitting, +.nlux_comp_prmpt_box.nlux_prmpt_waiting { + button > .nlux_snd_icn { + display: none; + } + + button > .nlux_msg_ldr { + display: inline-block; + } +} diff --git a/packages/css/themes/src/naked/components/WelcomeMessage.css b/packages/css/themes/src/naked/components/WelcomeMessage.css new file mode 100644 index 00000000..b756127b --- /dev/null +++ b/packages/css/themes/src/naked/components/WelcomeMessage.css @@ -0,0 +1,2 @@ +.nlux_comp_wlc_msg { +} diff --git a/packages/css/themes/src/naked/components/animation.css b/packages/css/themes/src/naked/components/animation.css new file mode 100644 index 00000000..92853b6e --- /dev/null +++ b/packages/css/themes/src/naked/components/animation.css @@ -0,0 +1,27 @@ +@keyframes nlux-loader-spin { + 0%, + 100% { + box-shadow: .2em 0px 0 0px currentcolor; + } + 12% { + box-shadow: .2em .2em 0 0 currentcolor; + } + 25% { + box-shadow: 0 .2em 0 0px currentcolor; + } + 37% { + box-shadow: -.2em .2em 0 0 currentcolor; + } + 50% { + box-shadow: -.2em 0 0 0 currentcolor; + } + 62% { + box-shadow: -.2em -.2em 0 0 currentcolor; + } + 75% { + box-shadow: 0px -.2em 0 0 currentcolor; + } + 87% { + box-shadow: .2em -.2em 0 0 currentcolor; + } +} diff --git a/packages/css/themes/src/naked/components/colors.css b/packages/css/themes/src/naked/components/colors.css new file mode 100644 index 00000000..6daa94c2 --- /dev/null +++ b/packages/css/themes/src/naked/components/colors.css @@ -0,0 +1,5 @@ +.nlux_root { + --foreground-color: #333333; + --background-color: #f9f9f9; + --border-color: #cccccc; +} diff --git a/packages/js/core/src/comp/ChatPicture/create.ts b/packages/js/core/src/comp/ChatPicture/create.ts new file mode 100644 index 00000000..76ec803d --- /dev/null +++ b/packages/js/core/src/comp/ChatPicture/create.ts @@ -0,0 +1,32 @@ +import {DomCreator} from '../../types/dom/DomCreator'; +import {ChatPictureProps} from './props'; +import {createPhotoContainerFromUrl} from './utils/createPhotoContainerFromUrl'; + +export const className = 'nlux_comp_cht_pic'; + +export const createChatPictureDom: DomCreator = ( + props, +): HTMLElement => { + const element = document.createElement('div'); + element.classList.add(className); + + if (!props.picture && !props.name) { + return element; + } + + if (props.name) { + element.title = props.name; + } + + // When the picture is an HTMLElement, we clone it and append it to the persona dom + // without any further processing! + if (props.picture && props.picture instanceof HTMLElement) { + element.append(props.picture.cloneNode(true)); + return element; + } + + // Alternatively, treat the picture as a string representing a URL of the photo to + // be loaded and render the photo accordingly. + element.append(createPhotoContainerFromUrl(props.picture, props.name)); + return element; +}; diff --git a/packages/js/core/src/comp/ChatPicture/props.ts b/packages/js/core/src/comp/ChatPicture/props.ts new file mode 100644 index 00000000..45dec0ee --- /dev/null +++ b/packages/js/core/src/comp/ChatPicture/props.ts @@ -0,0 +1,4 @@ +export type ChatPictureProps = { + name?: string; + picture?: string | HTMLElement; +}; diff --git a/packages/js/core/src/comp/ChatPicture/update.ts b/packages/js/core/src/comp/ChatPicture/update.ts new file mode 100644 index 00000000..84ed0d0a --- /dev/null +++ b/packages/js/core/src/comp/ChatPicture/update.ts @@ -0,0 +1,28 @@ +import {DomUpdater} from '../../types/dom/DomUpdater'; +import {ChatPictureProps} from './props'; +import {updateContentOnPictureChange} from './utils/updateContentOnPictureChange'; +import {updateNameOnPicture} from './utils/updateNameOnPicture'; + +export const updateChatPictureDom: DomUpdater = ( + element, + propsBefore, + propsAfter, +): void => { + if (propsBefore.picture === propsAfter.picture && propsBefore.name === propsAfter.name) { + return; + } + + if (propsBefore.picture !== propsAfter.picture) { + updateContentOnPictureChange(element, propsBefore, propsAfter); + } + + if (propsAfter.name) { + if (propsBefore.name !== propsAfter.name) { + element.title = propsAfter.name; + updateNameOnPicture(element, propsBefore, propsAfter); + } + } else { + element.title = ''; + updateNameOnPicture(element, propsBefore, propsAfter); + } +}; diff --git a/packages/js/core/src/comp/ChatPicture/utils/createPhotoContainerFromUrl.ts b/packages/js/core/src/comp/ChatPicture/utils/createPhotoContainerFromUrl.ts new file mode 100644 index 00000000..2d91208c --- /dev/null +++ b/packages/js/core/src/comp/ChatPicture/utils/createPhotoContainerFromUrl.ts @@ -0,0 +1,28 @@ +export const renderedPhotoContainerClassName = 'cht_pic_ctn'; +export const renderedPhotoClassName = 'cht_pic_img'; +export const renderedInitialsClassName = 'cht_pic_ltr'; + +export const createPhotoContainerFromUrl = (url: string | undefined, name: string | undefined): HTMLElement => { + // We print the first letter of the name in the persona photo + // Just in case the photo URL does not load + const letterContainer = document.createElement('span'); + letterContainer.classList.add(renderedInitialsClassName); + const letter = name && name.length > 0 ? name[0].toUpperCase() : ''; + if (letter.length > 0) { + letterContainer.append(letter); + } + + const photoContainer = document.createElement('div'); + photoContainer.classList.add(renderedPhotoContainerClassName); + photoContainer.append(letterContainer); + + // We load the photo in the foreground + if (url) { + const photoDomElement = document.createElement('div'); + photoDomElement.classList.add(renderedPhotoClassName); + photoDomElement.style.backgroundImage = `url("${encodeURI(url)}")`; + photoContainer.append(photoDomElement); + } + + return photoContainer; +}; diff --git a/packages/js/core/src/comp/ChatPicture/utils/updateContentOnPictureChange.ts b/packages/js/core/src/comp/ChatPicture/utils/updateContentOnPictureChange.ts new file mode 100644 index 00000000..db0fcd81 --- /dev/null +++ b/packages/js/core/src/comp/ChatPicture/utils/updateContentOnPictureChange.ts @@ -0,0 +1,41 @@ +import {ChatPictureProps} from '../props'; +import {createPhotoContainerFromUrl} from './createPhotoContainerFromUrl'; + +export const updateContentOnPictureChange = ( + element: HTMLElement, + propsBefore: ChatPictureProps, + propsAfter: ChatPictureProps, +): void => { + if (propsBefore.picture === propsAfter.picture) { + return; + } + + if (typeof propsAfter.picture === 'string' && typeof propsBefore.picture === 'string') { + // When the picture is a string, we update the photo container with the new URL + const photoDomElement: HTMLElement | null = element.querySelector( + '* > .cht_pic_ctn > .cht_pic_img', + ); + + if (photoDomElement !== null) { + photoDomElement.style.backgroundImage = `url("${encodeURI(propsAfter.picture)}")`; + } + } else { + if (typeof propsAfter.picture === 'string') { + // When the new picture is a string and the old one is not — + // we create a new photo container from the URL + const newPhotoDomElement = createPhotoContainerFromUrl( + propsAfter.picture, + propsAfter.name, + ); + element.replaceChildren(newPhotoDomElement); + } else { + // When the picture is an HTMLElement, we clone it and append it to the persona dom + if (propsAfter.picture) { + element.replaceChildren(propsAfter.picture.cloneNode(true)); + } else { + // If the new picture is null, we remove the old one + element.replaceChildren(); + } + } + } +}; diff --git a/packages/js/core/src/comp/ChatPicture/utils/updateNameOnPicture.ts b/packages/js/core/src/comp/ChatPicture/utils/updateNameOnPicture.ts new file mode 100644 index 00000000..3bbba5fe --- /dev/null +++ b/packages/js/core/src/comp/ChatPicture/utils/updateNameOnPicture.ts @@ -0,0 +1,22 @@ +import {ChatPictureProps} from '../props'; + +export const updateNameOnPicture = ( + element: HTMLElement, + propsBefore: ChatPictureProps, + propsAfter: ChatPictureProps, +): void => { + if (propsBefore.name === propsAfter.name) { + return; + } + + if (typeof propsAfter.picture === 'string') { + const letter = propsAfter.name && propsAfter.name.length > 0 ? + propsAfter.name[0].toUpperCase() : ''; + + const letterContainer = element.querySelector( + '* > .cht_pic_ctn > .cht_pic_ltr', + ); + + letterContainer?.replaceChildren(letter); + } +}; diff --git a/packages/js/core/src/comp/ConversationItem/create.ts b/packages/js/core/src/comp/ConversationItem/create.ts new file mode 100644 index 00000000..5aecb903 --- /dev/null +++ b/packages/js/core/src/comp/ConversationItem/create.ts @@ -0,0 +1,38 @@ +import {DomCreator} from '../../types/dom/DomCreator'; +import {createChatPictureDom} from '../ChatPicture/create'; +import {ChatPictureProps} from '../ChatPicture/props'; +import {createMessageDom} from '../Message/create'; +import {MessageProps} from '../Message/props'; +import {ConversationItemProps} from './props'; +import {applyNewDirectionClassName} from './utils/applyNewDirectionClassName'; + +export const className = 'nlux_comp_cnv_itm'; + +export const createConversationItemDom: DomCreator = ( + props, +): HTMLElement => { + const element = document.createElement('div'); + element.classList.add(className); + + const messageProps: MessageProps = { + direction: props.direction, + status: props.status, + loader: props.loader, + message: props.message, + }; + + const message = createMessageDom(messageProps); + + if (props.name !== undefined || props.picture !== undefined) { + const chatPictureProps: ChatPictureProps = { + name: props.name, + picture: props.picture, + }; + const persona = createChatPictureDom(chatPictureProps); + element.append(persona); + } + + applyNewDirectionClassName(element, props.direction); + element.append(message); + return element; +}; diff --git a/packages/js/core/src/comp/ConversationItem/props.ts b/packages/js/core/src/comp/ConversationItem/props.ts new file mode 100644 index 00000000..fe74b857 --- /dev/null +++ b/packages/js/core/src/comp/ConversationItem/props.ts @@ -0,0 +1,11 @@ +import {MessageDirection, MessageStatus} from '../Message/props'; + +export type ConversationItemProps = { + direction: MessageDirection; + status: MessageStatus; + loader?: HTMLElement; + message?: string; + + name?: string; + picture?: string | HTMLElement; +}; diff --git a/packages/js/core/src/comp/ConversationItem/update.ts b/packages/js/core/src/comp/ConversationItem/update.ts new file mode 100644 index 00000000..df3e8dac --- /dev/null +++ b/packages/js/core/src/comp/ConversationItem/update.ts @@ -0,0 +1,79 @@ +import {DomUpdater} from '../../types/dom/DomUpdater'; +import {className as chatPictureClassName} from '../ChatPicture/create'; +import {updateChatPictureDom} from '../ChatPicture/update'; +import {className as messageClassName} from '../Message/create'; +import {updateMessageDom} from '../Message/update'; +import {ConversationItemProps} from './props'; +import {applyNewDirectionClassName} from './utils/applyNewDirectionClassName'; + +export const updateConversationItemDom: DomUpdater = ( + element, + propsBefore, + propsAfter, +) => { + if ( + propsBefore.direction === propsAfter.direction && + propsBefore.status === propsAfter.status && + propsBefore.message === propsAfter.message && + propsBefore.loader === propsAfter.loader && + propsBefore.name === propsAfter.name && + propsBefore.picture === propsAfter.picture + ) { + return; + } + + if (!propsAfter || ( + !propsAfter.hasOwnProperty('direction') && + !propsAfter.hasOwnProperty('status') && + !propsAfter.hasOwnProperty('message') && + !propsAfter.hasOwnProperty('loader') && + !propsAfter.hasOwnProperty('name') && + !propsAfter.hasOwnProperty('picture') + )) { + return; + } + + if (propsBefore.direction !== propsAfter.direction) { + applyNewDirectionClassName(element, propsAfter.direction); + } + + if ( + propsBefore.direction !== propsAfter.direction || + propsBefore.status !== propsAfter.status || + propsBefore.message !== propsAfter.message || + propsBefore.loader !== propsAfter.loader + ) { + const messageDom = element.querySelector(`.${messageClassName}`); + if (messageDom) { + updateMessageDom(messageDom, { + direction: propsBefore.direction, + status: propsBefore.status, + message: propsBefore.message, + loader: propsBefore.loader, + }, { + direction: propsAfter.direction, + status: propsAfter.status, + message: propsAfter.message, + loader: propsAfter.loader, + }); + } + } + + if ( + propsBefore.name !== propsAfter.name || + propsBefore.picture !== propsAfter.picture + ) { + const personaDom = element.querySelector(`.${chatPictureClassName}`); + if (personaDom) { + updateChatPictureDom(personaDom, { + name: propsBefore.name, + picture: propsBefore.picture, + }, { + name: propsAfter.name, + picture: propsAfter.picture, + }); + } + } + + // TODO - Handle prop changes +}; diff --git a/packages/js/core/src/comp/ConversationItem/utils/applyNewDirectionClassName.ts b/packages/js/core/src/comp/ConversationItem/utils/applyNewDirectionClassName.ts new file mode 100644 index 00000000..3af2f1d0 --- /dev/null +++ b/packages/js/core/src/comp/ConversationItem/utils/applyNewDirectionClassName.ts @@ -0,0 +1,17 @@ +import {MessageDirection} from '../../Message/props'; + +export const directionClassName: {[key: string]: string} = { + incoming: 'nlux_cnv_itm_incoming', + outgoing: 'nlux_cnv_itm_outgoing', +}; + +export const applyNewDirectionClassName = (element: HTMLElement, direction: MessageDirection) => { + const directions = Object.keys(directionClassName); + directions.forEach((directionName) => { + element.classList.remove(directionClassName[directionName]); + }); + + if (directionClassName[direction]) { + element.classList.add(directionClassName[direction]); + } +}; diff --git a/packages/js/core/src/comp/ExceptionsBox/create.ts b/packages/js/core/src/comp/ExceptionsBox/create.ts new file mode 100644 index 00000000..6dc7607c --- /dev/null +++ b/packages/js/core/src/comp/ExceptionsBox/create.ts @@ -0,0 +1,14 @@ +import {DomCreator} from '../../types/dom/DomCreator'; +import {ExceptionsBoxProps} from './props'; + +export const className = 'nlux_comp_exp_box'; + +export const createExceptionsBoxDom: DomCreator = (props) => { + const exceptionsBox = document.createElement('div'); + exceptionsBox.classList.add(className); + exceptionsBox.appendChild( + document.createTextNode(props.message), + ); + + return exceptionsBox; +}; diff --git a/packages/js/core/src/comp/ExceptionsBox/props.ts b/packages/js/core/src/comp/ExceptionsBox/props.ts new file mode 100644 index 00000000..c98c4893 --- /dev/null +++ b/packages/js/core/src/comp/ExceptionsBox/props.ts @@ -0,0 +1,3 @@ +export type ExceptionsBoxProps = { + message: string; +}; diff --git a/packages/js/core/src/comp/ExceptionsBox/update.ts b/packages/js/core/src/comp/ExceptionsBox/update.ts new file mode 100644 index 00000000..85433b56 --- /dev/null +++ b/packages/js/core/src/comp/ExceptionsBox/update.ts @@ -0,0 +1,16 @@ +import {DomUpdater} from '../../types/dom/DomUpdater'; +import {ExceptionsBoxProps} from './props'; + +export const updateExceptionsBox: DomUpdater = ( + element, + propsBefore, + propsAfter, +) => { + if (propsBefore.message === propsAfter.message) { + return; + } + + element.replaceChildren( + document.createTextNode(propsAfter.message), + ); +}; diff --git a/packages/js/core/src/comp/Loader/create.ts b/packages/js/core/src/comp/Loader/create.ts new file mode 100644 index 00000000..9c29f2ce --- /dev/null +++ b/packages/js/core/src/comp/Loader/create.ts @@ -0,0 +1,8 @@ +export const className = 'nlux_msg_ldr'; + +export const createLoaderDom = () => { + const loader = document.createElement('div'); + loader.classList.add(className); + loader.innerHTML = `
`; + return loader; +}; diff --git a/packages/js/core/src/comp/Message/create.ts b/packages/js/core/src/comp/Message/create.ts new file mode 100644 index 00000000..2cabe4d9 --- /dev/null +++ b/packages/js/core/src/comp/Message/create.ts @@ -0,0 +1,36 @@ +import {DomCreator} from '../../types/dom/DomCreator'; +import {createLoaderDom} from '../Loader/create'; +import {MessageProps, MessageStatus} from './props'; +import {applyNewDirectionClassName} from './utils/applyNewDirectionClassName'; +import {applyNewStatusClassName} from './utils/applyNewStatusClassName'; +import {createMessageContent} from './utils/createMessageContent'; + +export const className = 'nlux_comp_msg'; + +export const createMessageDom: DomCreator = (props): HTMLElement => { + const element = document.createElement('div'); + element.classList.add(className); + + const status: MessageStatus = props.status ? props.status : 'rendered'; + applyNewStatusClassName(element, status); + applyNewDirectionClassName(element, props.direction); + + if (status === 'streaming') { + return element; + } + + if (status === 'loading') { + element.append(props.loader ?? createLoaderDom()); + return element; + } + + // + // Default status is — rendered + // + + if (props.message) { + element.append(createMessageContent(props.message)); + } + + return element; +}; diff --git a/packages/js/core/src/comp/Message/props.ts b/packages/js/core/src/comp/Message/props.ts new file mode 100644 index 00000000..d3b2d309 --- /dev/null +++ b/packages/js/core/src/comp/Message/props.ts @@ -0,0 +1,17 @@ +/** + * A message component's possible statuses. + * + * - loading — A loader is displayed and the message property is ignored. + * - streaming — A content of the message is handled by a DOM streaming service and the message property is ignored. + * - rendered — The message component is in final state and the message text rendering complete (if provided). + */ +export type MessageStatus = 'loading' | 'streaming' | 'rendered'; + +export type MessageDirection = 'incoming' | 'outgoing'; + +export type MessageProps = { + direction: MessageDirection; + status: MessageStatus; + loader?: HTMLElement; + message?: string; +}; diff --git a/packages/js/core/src/comp/Message/update.ts b/packages/js/core/src/comp/Message/update.ts new file mode 100644 index 00000000..ad6c5458 --- /dev/null +++ b/packages/js/core/src/comp/Message/update.ts @@ -0,0 +1,67 @@ +import {DomUpdater} from '../../types/dom/DomUpdater'; +import {createLoaderDom} from '../Loader/create'; +import {MessageProps} from './props'; +import {applyNewDirectionClassName} from './utils/applyNewDirectionClassName'; +import {applyNewStatusClassName} from './utils/applyNewStatusClassName'; +import {updateContentOnMessageChange} from './utils/updateContentOnMessageChange'; +import {updateContentOnStatusChange} from './utils/updateContentOnStatusChange'; + +export const updateMessageDom: DomUpdater = ( + element, + propsBefore, + propsAfter, +) => { + if ( + propsBefore.message === propsAfter.message && + propsBefore.status === propsAfter.status && + propsBefore.direction === propsAfter.direction && + propsBefore.loader === propsAfter.loader + ) { + return; + } + + if (!propsAfter || ( + !propsAfter.hasOwnProperty('message') && + !propsAfter.hasOwnProperty('status') && + !propsAfter.hasOwnProperty('direction') && + !propsAfter.hasOwnProperty('loader') + )) { + return; + } + + // + // Direction change + // + if (propsBefore.direction !== propsAfter.direction) { + applyNewDirectionClassName(element, propsAfter.direction); + } + + // + // Status change + // + const currentStatus = propsAfter.status; + if (propsBefore.status !== currentStatus) { + applyNewStatusClassName(element, propsAfter.status); + updateContentOnStatusChange(element, propsBefore, propsAfter); + return; + } + + // + // No status change + // + if (currentStatus === 'loading') { + if (propsBefore.loader !== propsAfter.loader) { + element.replaceChildren(); + element.append(propsAfter.loader ?? createLoaderDom()); + } + + // We do not need to update the message content in case of loading status + return; + } + + if (currentStatus === 'rendered') { + if (propsBefore.message !== propsAfter.message) { + updateContentOnMessageChange(element, propsBefore, propsAfter); + } + } +}; diff --git a/packages/js/core/src/comp/Message/utils/applyNewDirectionClassName.ts b/packages/js/core/src/comp/Message/utils/applyNewDirectionClassName.ts new file mode 100644 index 00000000..84f5a055 --- /dev/null +++ b/packages/js/core/src/comp/Message/utils/applyNewDirectionClassName.ts @@ -0,0 +1,17 @@ +import {MessageDirection} from '../props'; + +export const directionClassName: {[key: string]: string} = { + incoming: 'nlux_msg_incoming', + outgoing: 'nlux_msg_outgoing', +}; + +export const applyNewDirectionClassName = (element: HTMLElement, direction: MessageDirection) => { + const directions = Object.keys(directionClassName); + directions.forEach((directionName) => { + element.classList.remove(directionClassName[directionName]); + }); + + if (directionClassName[direction]) { + element.classList.add(directionClassName[direction]); + } +}; diff --git a/packages/js/core/src/comp/Message/utils/applyNewStatusClassName.ts b/packages/js/core/src/comp/Message/utils/applyNewStatusClassName.ts new file mode 100644 index 00000000..9cda5604 --- /dev/null +++ b/packages/js/core/src/comp/Message/utils/applyNewStatusClassName.ts @@ -0,0 +1,18 @@ +import {MessageStatus} from '../props'; + +export const statusClassName: {[key: string]: string} = { + loading: 'nlux_msg_loading', + streaming: 'nlux_msg_streaming', + rendered: 'nlux_msg_rendered', +}; + +export const applyNewStatusClassName = (element: HTMLElement, status: MessageStatus) => { + const statuses = Object.keys(statusClassName); + statuses.forEach((statusName) => { + element.classList.remove(statusClassName[statusName]); + }); + + if (statusClassName[status]) { + element.classList.add(statusClassName[status]); + } +}; diff --git a/packages/js/core/src/comp/Message/utils/createMessageContent.ts b/packages/js/core/src/comp/Message/utils/createMessageContent.ts new file mode 100644 index 00000000..c6988d56 --- /dev/null +++ b/packages/js/core/src/comp/Message/utils/createMessageContent.ts @@ -0,0 +1,5 @@ +export const createMessageContent = (message: string): HTMLElement | Text => { + // Render message as a text node to avoid XSS + // TODO - Handle markdown rendering + return document.createTextNode(message); +}; diff --git a/packages/js/core/src/comp/Message/utils/updateContentOnMessageChange.ts b/packages/js/core/src/comp/Message/utils/updateContentOnMessageChange.ts new file mode 100644 index 00000000..890b623b --- /dev/null +++ b/packages/js/core/src/comp/Message/utils/updateContentOnMessageChange.ts @@ -0,0 +1,15 @@ +import {MessageProps} from '../props'; +import {createMessageContent} from './createMessageContent'; + +export const updateContentOnMessageChange = ( + element: HTMLElement, + propsBefore: MessageProps, + propsAfter: MessageProps, +) => { + if (propsBefore.message === propsAfter.message) { + return; + } + + element.replaceChildren(); + element.append(createMessageContent(propsAfter.message ?? '')); +}; diff --git a/packages/js/core/src/comp/Message/utils/updateContentOnStatusChange.ts b/packages/js/core/src/comp/Message/utils/updateContentOnStatusChange.ts new file mode 100644 index 00000000..2b3f1c5c --- /dev/null +++ b/packages/js/core/src/comp/Message/utils/updateContentOnStatusChange.ts @@ -0,0 +1,43 @@ +import {createLoaderDom} from '../../Loader/create'; +import {MessageProps} from '../props'; + +export const updateContentOnStatusChange = ( + element: HTMLElement, + propsBefore: MessageProps, + propsAfter: MessageProps, +) => { + const newStatus = propsAfter.status; + const loader = propsAfter.loader; + + if (newStatus === 'loading') { + if (element.hasChildNodes()) { + element.replaceChildren(); + } + + if (loader) { + element.append(loader); + } else { + element.append(createLoaderDom()); + } + + return; + } + + if (propsBefore.status === 'loading' && element.hasChildNodes()) { + element.replaceChildren(); + } + + if (newStatus === 'streaming') { + return; + } + + if (newStatus === 'rendered') { + const innerHtml = propsAfter.message ? propsAfter.message : ''; + const textNode = document.createTextNode(innerHtml); + element.classList.add('nlux_msg_rendered'); + element.replaceChildren(); + element.append(textNode); + + return; + } +}; diff --git a/packages/js/core/src/comp/PromptBox/create.ts b/packages/js/core/src/comp/PromptBox/create.ts new file mode 100644 index 00000000..4688a3e3 --- /dev/null +++ b/packages/js/core/src/comp/PromptBox/create.ts @@ -0,0 +1,35 @@ +import {DomCreator} from '../../types/dom/DomCreator'; +import {createLoaderDom} from '../Loader/create'; +import {createSendIconDom} from '../SendIcon/create'; +import {PromptBoxProps} from './props'; +import {applyNewStatusClassName} from './utils/applyNewStatusClassName'; + +export const className = 'nlux_comp_prmpt_box'; + +export const createPromptBoxDom: DomCreator = (props) => { + const element = document.createElement('div'); + element.classList.add(className); + + const textarea = document.createElement('textarea'); + textarea.placeholder = props.placeholder ?? ''; + textarea.value = props.message ?? ''; + + const submitButton = document.createElement('button'); + submitButton.append(createSendIconDom()); + submitButton.append(createLoaderDom()); + + element.append(textarea); + element.append(submitButton); + applyNewStatusClassName(element, props.status); + + if (props.status === 'submitting') { + textarea.disabled = true; + submitButton.disabled = true; + } + + if (props.status === 'waiting') { + submitButton.disabled = true; + } + + return element; +}; diff --git a/packages/js/core/src/comp/PromptBox/props.ts b/packages/js/core/src/comp/PromptBox/props.ts new file mode 100644 index 00000000..5b901e28 --- /dev/null +++ b/packages/js/core/src/comp/PromptBox/props.ts @@ -0,0 +1,7 @@ +export type PromptBoxStatus = 'typing' | 'submitting' | 'waiting'; + +export type PromptBoxProps = { + status: PromptBoxStatus; + message?: string; + placeholder?: string; +}; diff --git a/packages/js/core/src/comp/PromptBox/update.ts b/packages/js/core/src/comp/PromptBox/update.ts new file mode 100644 index 00000000..b8b90329 --- /dev/null +++ b/packages/js/core/src/comp/PromptBox/update.ts @@ -0,0 +1,34 @@ +import {DomUpdater} from '../../types/dom/DomUpdater'; +import {PromptBoxProps} from './props'; +import {applyNewStatusClassName} from './utils/applyNewStatusClassName'; +import {updateContentOnStatusChange} from './utils/updateContentOnStatusChange'; + +export const updatePromptBoxDom: DomUpdater = ( + element, + propsBefore, + propsAfter, +) => { + if ( + propsBefore.status === propsAfter.status && + propsBefore.message === propsAfter.message && + propsBefore.placeholder === propsAfter.placeholder + ) { + return; + } + + if (propsBefore.status !== propsAfter.status) { + applyNewStatusClassName(element, propsAfter.status); + updateContentOnStatusChange(element, propsBefore, propsAfter); + return; + } + + if (propsBefore.placeholder !== propsAfter.placeholder) { + const textArea: HTMLTextAreaElement = element.querySelector('* > textarea')!; + textArea.placeholder = propsAfter.placeholder ?? ''; + } + + if (propsBefore.message !== propsAfter.message) { + const textArea: HTMLTextAreaElement = element.querySelector('* > textarea')!; + textArea.value = propsAfter.message ?? ''; + } +}; diff --git a/packages/js/core/src/comp/PromptBox/utils/applyNewStatusClassName.ts b/packages/js/core/src/comp/PromptBox/utils/applyNewStatusClassName.ts new file mode 100644 index 00000000..d340cf13 --- /dev/null +++ b/packages/js/core/src/comp/PromptBox/utils/applyNewStatusClassName.ts @@ -0,0 +1,16 @@ +import {PromptBoxStatus} from '../props'; + +export const statusClassName: {[key: string]: string} = { + typing: 'nlux_prmpt_typing', + submitting: 'nlux_prmpt_submitting', + waiting: 'nlux_prmpt_waiting', +}; + +export const applyNewStatusClassName = (element: HTMLElement, status: PromptBoxStatus) => { + const statuses = Object.keys(statusClassName); + statuses.forEach((statusName) => { + element.classList.remove(statusClassName[statusName]); + }); + + element.classList.add(statusClassName[status]); +}; diff --git a/packages/js/core/src/comp/PromptBox/utils/updateContentOnStatusChange.ts b/packages/js/core/src/comp/PromptBox/utils/updateContentOnStatusChange.ts new file mode 100644 index 00000000..86251b26 --- /dev/null +++ b/packages/js/core/src/comp/PromptBox/utils/updateContentOnStatusChange.ts @@ -0,0 +1,37 @@ +import {PromptBoxProps} from '../props'; + +export const updateContentOnStatusChange = ( + element: HTMLElement, + propsBefore: PromptBoxProps, + propsAfter: PromptBoxProps, +) => { + if (propsBefore.status === propsAfter.status) { + return; + } + + const textArea: HTMLTextAreaElement = element.querySelector('* > textarea')!; + if ((propsAfter.status === 'typing' || propsAfter.status === 'waiting') && textArea.disabled) { + textArea.disabled = false; + } else { + if (propsAfter.status === 'submitting' && !textArea.disabled) { + textArea.disabled = true; + } + } + + const submitButton: HTMLButtonElement = element.querySelector('* > button')!; + if (propsAfter.status === 'typing' && submitButton.disabled) { + submitButton.disabled = false; + } else { + if ((propsAfter.status === 'waiting' || propsAfter.status === 'submitting') && !submitButton.disabled) { + submitButton.disabled = true; + } + } + + if (propsBefore.placeholder !== propsAfter.placeholder) { + textArea.placeholder = propsAfter.placeholder ?? ''; + } + + if (propsBefore.message !== propsAfter.message) { + textArea.value = propsAfter.message ?? ''; + } +}; diff --git a/packages/js/core/src/comp/SendIcon/create.ts b/packages/js/core/src/comp/SendIcon/create.ts new file mode 100644 index 00000000..e70b63b9 --- /dev/null +++ b/packages/js/core/src/comp/SendIcon/create.ts @@ -0,0 +1,17 @@ +export const className = 'nlux_snd_icn'; + +export const createSendIconDom = () => { + const sendIcon = document.createElement('div'); + sendIcon.classList.add(className); + sendIcon.innerHTML = `
` + + `` + + + `` + + + `` + + + `` + + `
`; + + return sendIcon; +}; diff --git a/packages/js/core/src/comp/WelcomeMessage/create.ts b/packages/js/core/src/comp/WelcomeMessage/create.ts new file mode 100644 index 00000000..49a682a0 --- /dev/null +++ b/packages/js/core/src/comp/WelcomeMessage/create.ts @@ -0,0 +1,30 @@ +import {DomCreator} from '../../types/dom/DomCreator'; +import {createChatPictureDom} from '../ChatPicture/create'; +import {WelcomeMessageProps} from './props'; +import {updateWelcomeMessageText} from './utils/updateWelcomeMessageText'; + +export const className = 'nlux_comp_wlc_msg'; +export const personaNameClassName = 'nlux_comp_wlc_msg_prs_nm'; + +export const createWelcomeMessageDom: DomCreator = ( + props, +): HTMLElement => { + const element = document.createElement('div'); + element.classList.add(className); + + const personaPicture = createChatPictureDom({ + name: props.name, + picture: props.picture, + }); + element.append(personaPicture); + + const personaName = document.createElement('div'); + const nameTextNode = document.createTextNode(props.name); + personaName.append(nameTextNode); + personaName.classList.add(personaNameClassName); + element.append(personaName); + + updateWelcomeMessageText(element, props.message); + + return element; +}; diff --git a/packages/js/core/src/comp/WelcomeMessage/props.ts b/packages/js/core/src/comp/WelcomeMessage/props.ts new file mode 100644 index 00000000..99d236db --- /dev/null +++ b/packages/js/core/src/comp/WelcomeMessage/props.ts @@ -0,0 +1,5 @@ +export type WelcomeMessageProps = { + name: string; + picture: string | HTMLElement; + message?: string; +}; diff --git a/packages/js/core/src/comp/WelcomeMessage/update.ts b/packages/js/core/src/comp/WelcomeMessage/update.ts new file mode 100644 index 00000000..8c73dffb --- /dev/null +++ b/packages/js/core/src/comp/WelcomeMessage/update.ts @@ -0,0 +1,52 @@ +import {DomUpdater} from '../../types/dom/DomUpdater'; +import {className as chatPictureClassName} from '../ChatPicture/create'; +import {updateChatPictureDom} from '../ChatPicture/update'; +import {personaNameClassName} from './create'; +import {WelcomeMessageProps} from './props'; +import {updateWelcomeMessageText} from './utils/updateWelcomeMessageText'; + +export const updateWelcomeMessageDom: DomUpdater = ( + element, + propsBefore, + propsAfter, +): void => { + if ( + propsBefore.message === propsAfter.message && + propsBefore.name === propsAfter.name && + propsBefore.picture === propsAfter.picture + ) { + return; + } + + if (propsBefore.message !== propsAfter.message) { + updateWelcomeMessageText(element, propsAfter.message); + } + + if (propsBefore.name !== propsAfter.name) { + const nameElement = element.querySelector(`.${personaNameClassName}`); + if (nameElement) { + const nameTextNode = document.createTextNode(propsAfter.name); + nameElement.replaceChildren(nameTextNode); + } + } + + if ( + propsBefore.picture !== propsAfter.picture || + propsBefore.name !== propsAfter.name + ) { + const pictureElement = element.querySelector(`.${chatPictureClassName}`); + if (pictureElement) { + updateChatPictureDom( + pictureElement, + { + name: propsBefore.name, + picture: propsBefore.picture, + }, + { + name: propsAfter.name, + picture: propsAfter.picture, + }, + ); + } + } +}; diff --git a/packages/js/core/src/comp/WelcomeMessage/utils/updateWelcomeMessageText.ts b/packages/js/core/src/comp/WelcomeMessage/utils/updateWelcomeMessageText.ts new file mode 100644 index 00000000..639b223e --- /dev/null +++ b/packages/js/core/src/comp/WelcomeMessage/utils/updateWelcomeMessageText.ts @@ -0,0 +1,21 @@ +export const welcomeMessageTextClassName = 'nlux_comp_wlc_msg_txt'; + +export const updateWelcomeMessageText = ( + root: HTMLElement, + newWelcomeMessage: string | undefined, +) => { + const welcomeMessageContainer = root.querySelector(`.${welcomeMessageTextClassName}`); + if (newWelcomeMessage === '' || newWelcomeMessage === undefined) { + welcomeMessageContainer?.remove(); + return; + } + + if (welcomeMessageContainer) { + welcomeMessageContainer.textContent = newWelcomeMessage; + } else { + const welcomeMessageTextContainer = document.createElement('div'); + welcomeMessageTextContainer.classList.add(welcomeMessageTextClassName); + welcomeMessageTextContainer.textContent = newWelcomeMessage; + root.appendChild(welcomeMessageTextContainer); + } +}; diff --git a/packages/js/core/src/components/miscellaneous/exceptions-box/render.ts b/packages/js/core/src/components/miscellaneous/exceptions-box/render.ts index d47aa235..ff9eac3b 100644 --- a/packages/js/core/src/components/miscellaneous/exceptions-box/render.ts +++ b/packages/js/core/src/components/miscellaneous/exceptions-box/render.ts @@ -70,7 +70,7 @@ export const renderExceptionsBox: CompRenderer< actions: { hide: () => { exceptionContainer.style.display = 'none'; - messageElement.innerHTML = ''; + messageElement.replaceChildren(); }, show: () => { exceptionContainer.style.display = ''; diff --git a/packages/js/core/src/core/aiChat/comp/base.ts b/packages/js/core/src/core/aiChat/comp/base.ts index 8427b053..aff7aac6 100644 --- a/packages/js/core/src/core/aiChat/comp/base.ts +++ b/packages/js/core/src/core/aiChat/comp/base.ts @@ -6,11 +6,21 @@ import {warn} from '../../../x/warn'; import {NluxError, NluxUsageError} from '../../error'; import {CompRegistry} from './registry'; +export type CompStatus = 'unmounted' | 'rendered' | 'active' | 'destroyed'; + export abstract class BaseComp { static __compEventListeners: Map | null = null; static __compId: string | null = null; static __renderer: CompRenderer | null = null; static __updater: CompUpdater | null = null; + + /** + * The current status of the component. + * @type {CompStatus} + * @private + */ + private __status: CompStatus = 'unmounted'; + /** * A reference to the component definition, as retrieved from the registry. * @protected @@ -174,6 +184,10 @@ export abstract class BaseComp this.destroyComponent(); } + public get status(): CompStatus { + return this.__status; + } + public destroyListItemComponent() { this.destroyComponent(true); } diff --git a/packages/js/core/src/core/aiChat/comp/controller.ts b/packages/js/core/src/core/aiChat/comp/controller.ts new file mode 100644 index 00000000..341e750f --- /dev/null +++ b/packages/js/core/src/core/aiChat/comp/controller.ts @@ -0,0 +1,5 @@ +export interface CompController { + control: (comp: HTMLElement) => void; + release: () => void; + update: (props: CompProps) => void; +} diff --git a/packages/js/core/src/core/aiChat/controller/controller.ts b/packages/js/core/src/core/aiChat/controller/controller.ts index 14cdb109..26b354d3 100644 --- a/packages/js/core/src/core/aiChat/controller/controller.ts +++ b/packages/js/core/src/core/aiChat/controller/controller.ts @@ -8,7 +8,7 @@ import {EventManager} from '../events/eventManager'; import {NluxRenderer} from '../renderer/renderer'; import {createControllerContext} from './context'; -export class NluxController { +export class NluxController { private readonly eventManager = new EventManager(); private readonly nluxInstanceId = uid(); @@ -28,7 +28,7 @@ export class NluxController { this.renderer.renderEx(exception.type, exception.message); }; - private renderer: NluxRenderer | null = null; + private renderer: NluxRenderer | null = null; private readonly rootCompId: string; private readonly rootElement: HTMLElement; diff --git a/packages/js/core/src/core/aiChat/events/eventManager.ts b/packages/js/core/src/core/aiChat/events/eventManager.ts index c5071c9f..5545c3cb 100644 --- a/packages/js/core/src/core/aiChat/events/eventManager.ts +++ b/packages/js/core/src/core/aiChat/events/eventManager.ts @@ -2,7 +2,10 @@ import {EventName, EventsMap} from '../../../types/event'; export class EventManager { - public emit = (event: EventToEmit, ...params: Parameters) => { + public emit = ( + event: EventToEmit, + ...params: Parameters + ) => { if (!this.eventListeners.has(event)) { return; } @@ -16,7 +19,10 @@ export class EventManager { }); }; - public on = (event: EventToAdd, callback: EventsMap[EventToAdd]) => { + public on = ( + event: EventToAdd, + callback: EventsMap[EventToAdd], + ) => { if (!this.eventListeners.has(event)) { this.eventListeners.set(event, new Set()); } @@ -28,7 +34,10 @@ export class EventManager { public removeAllEventListenersForAllEvent = () => this.eventListeners.clear(); - public removeEventListener = (event: EventToUpdate, callback: EventsMap[EventToUpdate]) => { + public removeEventListener = ( + event: EventToUpdate, + callback: EventsMap[EventToUpdate], + ) => { if (!this.eventListeners.has(event)) { return; } @@ -39,7 +48,9 @@ export class EventManager { } }; - public updateEventListeners = (events: Partial) => { + public updateEventListeners = ( + events: Partial, + ) => { // // Replace all listeners for events present in the new events object // This overwrites any existing listeners for these events! But it will not remove diff --git a/packages/js/core/src/core/aiChat/renderer/renderer.ts b/packages/js/core/src/core/aiChat/renderer/renderer.ts index 2338cc6a..2de309a0 100644 --- a/packages/js/core/src/core/aiChat/renderer/renderer.ts +++ b/packages/js/core/src/core/aiChat/renderer/renderer.ts @@ -15,7 +15,7 @@ import {LayoutOptions} from '../options/layoutOptions'; import {PersonaOptions} from '../options/personaOptions'; import {PromptBoxOptions} from '../options/promptBoxOptions'; -export class NluxRenderer { +export class NluxRenderer { private static readonly defaultThemeId = 'nova'; private readonly __context: ControllerContext; diff --git a/packages/js/core/src/core/aiChat/services/submitPrompt/impl.ts b/packages/js/core/src/core/aiChat/services/submitPrompt/impl.ts new file mode 100644 index 00000000..bded4be5 --- /dev/null +++ b/packages/js/core/src/core/aiChat/services/submitPrompt/impl.ts @@ -0,0 +1,121 @@ +import {ChatAdapterExtras} from '../../../../types/adapters/chat/chaAdapterExtras'; +import {ChatAdapter, DataTransferMode} from '../../../../types/adapters/chat/chatAdapter'; +import { + ConversationPart, + ConversationPartAiMessage, + ConversationPartChunkCallback, + ConversationPartCompleteCallback, + ConversationPartErrorCallback, + ConversationPartEvent, + ConversationPartEventsMap, + ConversationPartUpdateCallback, + ConversationPartUserMessage, +} from '../../../../types/conversationPart'; +import {uid} from '../../../../x/uid'; +import {SubmitPrompt} from './submitPrompt'; + +export const submitPrompt: SubmitPrompt = ( + prompt: string, + adapter: ChatAdapter, + extras: ChatAdapterExtras, + preferredDataTransferMode: DataTransferMode, +) => { + const callbacksByEvent: Map> = new Map(); + const addListener = (event: ConversationPartEvent, callback: ConversationPartEventsMap[ConversationPartEvent]) => { + if (!callbacksByEvent.has(event)) { callbacksByEvent.set(event, new Set()); } + callbacksByEvent.get(event)!.add(callback); + }; + + const removeListener = (event: ConversationPartEvent, callback: Function) => { + if (!callbacksByEvent.has(event)) { return; } + callbacksByEvent.get(event)!.delete(callback); + } + + const supportedDataTransferModes: DataTransferMode[] = []; + if (adapter.streamText !== undefined) { supportedDataTransferModes.push('stream'); } + if (adapter.fetchText !== undefined) { supportedDataTransferModes.push('fetch'); } + if (supportedDataTransferModes.length === 0) { + throw new Error('The adapter does not support any data transfer modes'); + } + + const dataTransferMode = supportedDataTransferModes.length === 1 + ? supportedDataTransferModes[0] + : preferredDataTransferMode; + + const userMessage: ConversationPartUserMessage = { + uid: uid(), + participantRole: 'user', + time: new Date(), + content: prompt, + }; + + const aiMessage: ConversationPartAiMessage = { + uid: uid(), + participantRole: 'ai', + time: new Date(), + type: dataTransferMode === 'stream' ? 'stream' : 'message', + }; + + const part: ConversationPart = { + uid: uid(), + status: 'active', + messages: [userMessage, aiMessage], + on: addListener, + removeListener: removeListener, + } + + // + // Handle message in streaming mode + // + if (dataTransferMode === 'stream') { + adapter.streamText!(prompt, { + next: (chunk: ResponseType) => { + callbacksByEvent.get('chunk')?.forEach(callback => { + const messageCallback = callback as ConversationPartChunkCallback; + messageCallback(aiMessage.uid, chunk); + }); + }, + complete: () => { + part.status = 'complete'; + callbacksByEvent.get('complete')?.forEach(callback => { + const completeCallback = callback as ConversationPartCompleteCallback; + completeCallback(); + }); + callbacksByEvent.clear(); + }, + error: (error: Error) => { + part.status = 'error'; + callbacksByEvent.get('error')?.forEach(callback => { + const errorCallback = callback as ConversationPartErrorCallback; + errorCallback(error); + }); + callbacksByEvent.clear(); + }, + }, extras); + + return part; + } + + // + // Handle message in fetch mode + // + adapter.fetchText!(prompt, extras).then((message: any) => { + aiMessage.content = message; + part.status = 'complete'; + callbacksByEvent.get('update')?.forEach(callback => { + const messageCallback = callback as ConversationPartUpdateCallback; + messageCallback('ai', 'complete', message); + }); + callbacksByEvent.get('complete')?.forEach(callback => { + const completeCallback = callback as ConversationPartCompleteCallback; + completeCallback(); + }); + callbacksByEvent.clear(); + }).catch((error: Error) => { + part.status = 'error'; + callbacksByEvent.get('error')?.forEach(callback => callback(error)); + callbacksByEvent.clear(); + }); + + return part; +}; diff --git a/packages/js/core/src/core/aiChat/services/submitPrompt/submitPrompt.ts b/packages/js/core/src/core/aiChat/services/submitPrompt/submitPrompt.ts new file mode 100644 index 00000000..cd9674cc --- /dev/null +++ b/packages/js/core/src/core/aiChat/services/submitPrompt/submitPrompt.ts @@ -0,0 +1,15 @@ +import {ChatAdapterExtras} from '../../../../types/adapters/chat/chaAdapterExtras'; +import {ChatAdapter, DataTransferMode} from '../../../../types/adapters/chat/chatAdapter'; +import {ConversationPart} from '../../../../types/conversationPart'; + +/** + * Represents a function that can be used to submit a prompt to the chat adapter. + * This function will return a conversation part controller that can be used to control the conversation part + * that was created as a result of submitting the prompt. + */ +export type SubmitPrompt = ( + prompt: string, + adapter: ChatAdapter, + adapterExtras: ChatAdapterExtras, + preferredDataTransferMode: DataTransferMode, +) => ConversationPart; diff --git a/packages/js/core/src/index.ts b/packages/js/core/src/index.ts index 7de9baef..55b2de6e 100644 --- a/packages/js/core/src/index.ts +++ b/packages/js/core/src/index.ts @@ -164,12 +164,103 @@ export type { CreateHighlighterOptions, } from './core/aiChat/highlighter/highlighter'; -// OTHER ____________________ +// CONVERSATION _____________ export type { ConversationItem, } from './types/conversation'; +export type { + ConversationPart, + ConversationPartHandler, + ConversationPartStatus, + ConversationPartAiMessage, + ConversationPartUserMessage, + ConversationPartItem, + ConversationPartItemType, + ConversationPartEvent, + ConversationPartUpdateCallback, + ConversationPartChunkCallback, + ConversationPartCompleteCallback, + ConversationPartErrorCallback, +} from './types/conversationPart'; + export type { ParticipantRole, } from './types/participant'; + +// DOM COMPONENTS ___________ + +export type { + ChatPictureProps, +} from './comp/ChatPicture/props'; + +export { + className as compChatPictureClassName, +} from './comp/ChatPicture/create'; + +export type { + ConversationItemProps, +} from './comp/ConversationItem/props'; + +export { + className as compConversationItemClassName, +} from './comp/ConversationItem/create'; + +export type { + ExceptionsBoxProps, +} from './comp/ExceptionsBox/props'; + +export { + className as compExceptionsBoxClassName, +} from './comp/ExceptionsBox/create'; + +export { + className as compLoaderClassName, +} from './comp/Loader/create'; + +export type { + MessageStatus, + MessageDirection, + MessageProps, +} from './comp/Message/props'; + +export { + className as compMessageClassName, +} from './comp/Message/create'; + +export type { + PromptBoxStatus, + PromptBoxProps, +} from './comp/PromptBox/props'; + +export { + className as compPromptBoxClassName, +} from './comp/PromptBox/create'; + +export { + className as compSendIconClassName, +} from './comp/SendIcon/create'; + +export type { + WelcomeMessageProps, +} from './comp/WelcomeMessage/props'; + +export { + className as compWelcomeMessageClassName, + personaNameClassName as compWelcomeMessagePersonaNameClassName, +} from './comp/WelcomeMessage/create'; + +export { + welcomeMessageTextClassName as compWelcomeMessageTextClassName, +} from './comp/WelcomeMessage/utils/updateWelcomeMessageText'; + +// SERVICES _________________ + +export type { + SubmitPrompt, +} from './core/aiChat/services/submitPrompt/submitPrompt'; + +export { + submitPrompt, +} from './core/aiChat/services/submitPrompt/impl'; diff --git a/packages/js/core/src/types/conversation.ts b/packages/js/core/src/types/conversation.ts index f34a7f1c..4992e64f 100644 --- a/packages/js/core/src/types/conversation.ts +++ b/packages/js/core/src/types/conversation.ts @@ -1,6 +1,6 @@ import {ParticipantRole} from './participant'; -export type ConversationItem = { +export type ConversationItem = { role: ParticipantRole; - message: string; + message: MessageType; } diff --git a/packages/js/core/src/types/conversationPart.ts b/packages/js/core/src/types/conversationPart.ts new file mode 100644 index 00000000..11663226 --- /dev/null +++ b/packages/js/core/src/types/conversationPart.ts @@ -0,0 +1,115 @@ +import {MessageStatus} from '../comp/Message/props'; +import {DataTransferMode} from './adapters/chat/chatAdapter'; + +export type ConversationPartItemType = 'message' | 'stream'; + +export type ConversationPartAiMessage = { + uid: string; + participantRole: 'ai', + time: Date; + type: ConversationPartItemType; + content?: MessageType; +} + +export type ConversationPartUserMessage = { + uid: string; + participantRole: 'user'; + time: Date; + content: string; +} + +export type ConversationPartItem = ConversationPartAiMessage | ConversationPartUserMessage; + +/** + * The status of a conversation part. + * - active: The conversation part started and is still ongoing. + * - error: The conversation part has ended with an error (example: no AI response, or streaming failure). + * - complete: The conversation part has ended successfully and no more messages will be added to it (example: + * streaming is complete, or the AI has finished replying to a user message). + */ +export type ConversationPartStatus = 'active' | 'complete' | 'error'; + +/** + * The events that can be emitted by a conversation part. + * - message: A new message has been added to the conversation part (either from the user or the AI). + * - update: A message has been updated (example: the AI message has been fully loaded, or message status has changed). + * - chunk: A new chunk of data has been added to the conversation part (example: streaming data to a message). + * - complete: The conversation part has ended successfully. + * - error: The conversation part has ended with an error. + */ +export type ConversationPartEvent = 'update' | 'chunk' | 'complete' | 'error'; + +export type ConversationPartUpdateCallback = ( + messageId: string, + newStatus: ConversationPartStatus, + newContent?: string, +) => void; + +export type ConversationPartChunkCallback = ( + messageId: string, + chunk: string, +) => void; + +export type ConversationPartCompleteCallback = () => void; + +export type ConversationPartErrorCallback = ( + error: Error, +) => void; + +export type ConversationPartEventsMap = { + update: ConversationPartUpdateCallback; + chunk: ConversationPartChunkCallback; + complete: () => void; + error: (error: Error) => void; +}; + +/** + * A conversation is of exchanges between two or more parties. + * This ConversationPart type represents a single part of a conversation: A single exchange. + * It can either be a message from a user followed by multiple messages from an AI as a reply, + * or it can be of set of consecutive messages from an AI agent triggered by an event. + * + * A conversation part guarantees the validity of all the messages it contains: + * For example, if the user sends a message and the AI reply fails, the entire conversation part will be + * marked as failed. + * + */ +export type ConversationPart = { + uid: string; + status: ConversationPartStatus; + messages: ConversationPartItem[]; + on: ( + event: ConversationPartEvent, + callback: ConversationPartEventsMap[ConversationPartEvent], + ) => void; + removeListener: ( + event: ConversationPartEvent, + callback: Function, + ) => void; +}; + +/** + * The handler for a conversation part. + * This handler is used to control the conversation part and add messages to it. It's the return value of the + * submitPrompt function and it should be used to add messages to the conversation part, update messages, and + * control the conversation part lifecycle. + */ +export interface ConversationPartHandler { + addAiMessage: ( + status: MessageStatus, + dataTransferMode: DataTransferMode, + content?: ResponseType, + ) => string, + addUserMessage: ( + status: MessageStatus, + content?: string, + ) => string; + chunk: (chunk: ResponseType) => void, + complete: () => void, + error: (error: Error) => void, + updateMessage: ( + id: string, + status: MessageStatus, + content?: string, + ) => void, +} diff --git a/packages/js/core/src/types/dom/DomCreator.ts b/packages/js/core/src/types/dom/DomCreator.ts new file mode 100644 index 00000000..b14dcbe9 --- /dev/null +++ b/packages/js/core/src/types/dom/DomCreator.ts @@ -0,0 +1,6 @@ +/** + * Very simple type for a function that creates a DOM element. + * This type is used for functions that exist in dom.ts file and that create DOM elements without any logic + * or event listeners. They are used for initial rendering only, including server-side rendering. + */ +export type DomCreator = (props: T) => HTMLElement; diff --git a/packages/js/core/src/types/dom/DomUpdater.ts b/packages/js/core/src/types/dom/DomUpdater.ts new file mode 100644 index 00000000..04a8da34 --- /dev/null +++ b/packages/js/core/src/types/dom/DomUpdater.ts @@ -0,0 +1,6 @@ +/** + * A function that updates a DOM element with new props. + * The function is used for updating DOM elements that were created by DomCreator functions. + * It should take into consideration the before and after props and update the DOM element accordingly. + */ +export type DomUpdater = (element: HTMLElement, propsBefore: T, propsAfter: T) => void; diff --git a/packages/react/core/src/comp/ChatPicture/ChatPictureComp.tsx b/packages/react/core/src/comp/ChatPicture/ChatPictureComp.tsx new file mode 100644 index 00000000..d3f861d2 --- /dev/null +++ b/packages/react/core/src/comp/ChatPicture/ChatPictureComp.tsx @@ -0,0 +1,34 @@ +import { + renderedInitialsClassName, + renderedPhotoClassName, + renderedPhotoContainerClassName, +} from '@nlux-dev/core/src/comp/ChatPicture/utils/createPhotoContainerFromUrl'; +import {compChatPictureClassName} from '@nlux/core'; +import React from 'react'; +import {ChatPictureProps} from './props'; + +export const ChatPictureComp = (props: ChatPictureProps) => { + const isPictureUrl = typeof props.picture === 'string'; + const isPictureElement = !isPictureUrl && React.isValidElement(props.picture); + + return ( +
+ {isPictureElement && props.picture} + {!isPictureElement && isPictureUrl && ( +
+ + {props.name && props.name.length > 0 ? props.name[0].toUpperCase() : ''} + + {props.picture && ( +
+ )} +
+ )} +
+ ); +}; diff --git a/packages/react/core/src/comp/ChatPicture/props.ts b/packages/react/core/src/comp/ChatPicture/props.ts new file mode 100644 index 00000000..3097de3f --- /dev/null +++ b/packages/react/core/src/comp/ChatPicture/props.ts @@ -0,0 +1,6 @@ +import {ReactElement} from 'react'; + +export type ChatPictureProps = { + name?: string; + picture?: string | ReactElement; +}; diff --git a/packages/react/core/src/comp/Conversation/ConversationComp.tsx b/packages/react/core/src/comp/Conversation/ConversationComp.tsx new file mode 100644 index 00000000..5a8869b4 --- /dev/null +++ b/packages/react/core/src/comp/Conversation/ConversationComp.tsx @@ -0,0 +1,76 @@ +import {MessageDirection, ParticipantRole} from '@nlux/core'; +import React, {ReactElement} from 'react'; +import {ConversationItemComp} from '../ConversationItem/ConversationItemComp'; +import {WelcomeMessageComp} from '../WelcomeMessage/WelcomeMessageComp'; +import {ConversationCompProps} from './props'; + +const roleToDirection = (role: ParticipantRole): MessageDirection => { + switch (role) { + case 'user': + return 'outgoing'; + } + + return 'incoming'; +}; + +const pictureFromMessageAndPersona = (role: ParticipantRole, personaOptions: ConversationCompProps['personaOptions']) => { + if (role === 'ai') { + return personaOptions?.bot?.picture; + } + + if (role === 'user') { + return personaOptions?.user?.picture; + } + + return undefined; +}; + +const nameFromMessageAndPersona = (role: ParticipantRole, personaOptions: ConversationCompProps['personaOptions']) => { + if (role === 'ai') { + return personaOptions?.bot?.name; + } + + if (role === 'user') { + return personaOptions?.user?.name; + } + + return undefined; + +}; + +export const ConversationComp: ( + props: ConversationCompProps, +) => ReactElement = ( + props, +) => { + const {messages, personaOptions} = props; + const hasMessages = messages && messages.length > 0; + const hasAiPersona = personaOptions?.bot?.name && personaOptions.bot.picture; + const showWelcomeMessage = hasAiPersona && !hasMessages; + + return ( + <> + {showWelcomeMessage && ( + + + )} + {hasMessages && messages.map((message) => { + return ( + + ); + })} + + ); +}; diff --git a/packages/react/core/src/comp/Conversation/props.ts b/packages/react/core/src/comp/Conversation/props.ts new file mode 100644 index 00000000..ee9351bb --- /dev/null +++ b/packages/react/core/src/comp/Conversation/props.ts @@ -0,0 +1,25 @@ +import {ConversationItem, ConversationOptions, HighlighterExtension, ParticipantRole} from '@nlux/core'; +import {ReactNode} from 'react'; +import {PersonaOptions} from '../../exp/personaOptions'; + +export type ConversationAiMessage = { + id: string; + role: ParticipantRole; + message: MessageType; +} + +export type ConversationUserMessage = { + id: string; + role: ParticipantRole; + message: string; +} + +export type ConversationMessage = ConversationAiMessage | ConversationUserMessage; + +export type ConversationCompProps = { + messages: ConversationMessage[]; + conversationOptions?: ConversationOptions; + personaOptions?: PersonaOptions; + customAiMessageComponent?: (message: MessageType) => ReactNode; + syntaxHighlighter?: HighlighterExtension; +}; diff --git a/packages/react/core/src/comp/Conversation/utils/useInitialMessagesOnce.tsx b/packages/react/core/src/comp/Conversation/utils/useInitialMessagesOnce.tsx new file mode 100644 index 00000000..5df844ef --- /dev/null +++ b/packages/react/core/src/comp/Conversation/utils/useInitialMessagesOnce.tsx @@ -0,0 +1,30 @@ +import {ConversationItem, uid} from '@nlux/core'; +import {useMemo} from 'react'; +import {ConversationMessage} from '../props'; + +export const useInitialMessagesOnce: ( + initialItems?: ConversationItem[], +) => ConversationMessage[] | undefined = ( + initialItems, +) => { + return useMemo(() => { + if (initialItems) { + const newIds: string[] = []; + return initialItems.map((item) => { + let newId = uid(); + + // Ensure that the new id is unique + while (newIds.some((id) => id === newId)) { + newId = uid(); + } + + newIds.push(newId); + return { + id: newId, + role: item.role, + message: item.message, + }; + }); + } + }, []); +}; diff --git a/packages/react/core/src/comp/ConversationItem/ConversationItemComp.tsx b/packages/react/core/src/comp/ConversationItem/ConversationItemComp.tsx new file mode 100644 index 00000000..329e24af --- /dev/null +++ b/packages/react/core/src/comp/ConversationItem/ConversationItemComp.tsx @@ -0,0 +1,21 @@ +import React, {ReactElement} from 'react'; +import {CustomConversationItemComp} from '../CustomConversationItem/CustomConversationItemComp'; +import {ConversationItemProps} from './props'; + +export const ConversationItemComp: ( + props: ConversationItemProps, +) => ReactElement = ( + props, +) => { + return ( + + ); +}; diff --git a/packages/react/core/src/comp/ConversationItem/props.ts b/packages/react/core/src/comp/ConversationItem/props.ts new file mode 100644 index 00000000..dc2a4221 --- /dev/null +++ b/packages/react/core/src/comp/ConversationItem/props.ts @@ -0,0 +1,12 @@ +import {MessageDirection, MessageStatus} from '@nlux/core'; +import {ReactElement, ReactNode} from 'react'; + +export type ConversationItemProps = { + direction: MessageDirection; + status: MessageStatus; + loader?: ReactElement; + message?: MessageType | string; + customRenderer?: (message: MessageType) => ReactNode; + name?: string; + picture?: string | ReactElement; +}; diff --git a/packages/react/core/src/comp/CustomConversationItem/CustomConversationItemComp.tsx b/packages/react/core/src/comp/CustomConversationItem/CustomConversationItemComp.tsx new file mode 100644 index 00000000..fc922f7b --- /dev/null +++ b/packages/react/core/src/comp/CustomConversationItem/CustomConversationItemComp.tsx @@ -0,0 +1,41 @@ +import {className as conversationItemCoreClassName} from '@nlux-dev/core/src/comp/ConversationItem/create'; +import {directionClassName} from '@nlux-dev/core/src/comp/ConversationItem/utils/applyNewDirectionClassName'; +import React, {ReactElement, ReactNode, useMemo} from 'react'; +import {ChatPictureComp} from '../ChatPicture/ChatPictureComp'; +import {MessageComp} from '../Message/MessageComp'; +import {CustomConversationItemProps} from './props'; + +export const CustomConversationItemComp: ( + props: CustomConversationItemProps, +) => ReactElement = ( + props, +) => { + const picture = useMemo(() => { + if (props.picture === undefined && props.name === undefined) { + return null; + } + + return ; + }, [props.picture, props.name]); + + const compDirectionClassName = props.direction + ? directionClassName[props.direction] + : directionClassName['incoming']; + + const className = `${conversationItemCoreClassName} ${compDirectionClassName}`; + const message: ReactNode = props.customRender && props.message !== undefined + ? props.customRender(props.message as any) + : <>{props.message !== undefined ? props.message : ''}; + + return ( +
+ {picture} + +
+ ); +}; diff --git a/packages/react/core/src/comp/CustomConversationItem/props.ts b/packages/react/core/src/comp/CustomConversationItem/props.ts new file mode 100644 index 00000000..89b15e67 --- /dev/null +++ b/packages/react/core/src/comp/CustomConversationItem/props.ts @@ -0,0 +1,13 @@ +import {MessageDirection, MessageStatus} from '@nlux/core'; +import {ReactElement, ReactNode} from 'react'; + +export type CustomConversationItemProps = { + direction: MessageDirection; + status: MessageStatus; + loader?: ReactElement; + message?: MessageType | string; + customRender?: (message: MessageType) => ReactNode; + + name?: string; + picture?: string | ReactElement; +}; diff --git a/packages/react/core/src/comp/ExceptionsBox/ExceptionsBoxComp.tsx b/packages/react/core/src/comp/ExceptionsBox/ExceptionsBoxComp.tsx new file mode 100644 index 00000000..17bf3019 --- /dev/null +++ b/packages/react/core/src/comp/ExceptionsBox/ExceptionsBoxComp.tsx @@ -0,0 +1,9 @@ +import {compExceptionsBoxClassName} from '@nlux/core'; +import React from 'react'; +import {ExceptionsBoxProps} from './props'; + +export const ExceptionsBoxComp = (props: ExceptionsBoxProps) => { + return ( +
{props.message}
+ ); +}; diff --git a/packages/react/core/src/comp/ExceptionsBox/props.ts b/packages/react/core/src/comp/ExceptionsBox/props.ts new file mode 100644 index 00000000..c98c4893 --- /dev/null +++ b/packages/react/core/src/comp/ExceptionsBox/props.ts @@ -0,0 +1,3 @@ +export type ExceptionsBoxProps = { + message: string; +}; diff --git a/packages/react/core/src/comp/Loader/LoaderComp.tsx b/packages/react/core/src/comp/Loader/LoaderComp.tsx new file mode 100644 index 00000000..f05066ce --- /dev/null +++ b/packages/react/core/src/comp/Loader/LoaderComp.tsx @@ -0,0 +1,10 @@ +import {compLoaderClassName} from '@nlux/core'; +import React from 'react'; + +export const LoaderComp = () => { + return ( +
+
+
+ ); +}; diff --git a/packages/react/core/src/comp/Message/MessageComp.tsx b/packages/react/core/src/comp/Message/MessageComp.tsx new file mode 100644 index 00000000..3357a814 --- /dev/null +++ b/packages/react/core/src/comp/Message/MessageComp.tsx @@ -0,0 +1,40 @@ +import {className as messageDomClassName} from '@nlux-dev/core/src/comp/Message/create'; +import {directionClassName} from '@nlux-dev/core/src/comp/Message/utils/applyNewDirectionClassName'; +import {statusClassName} from '@nlux-dev/core/src/comp/Message/utils/applyNewStatusClassName'; +import React from 'react'; +import {LoaderComp} from '../Loader/LoaderComp'; +import {MessageProps} from './props'; + +export const MessageComp = (props: MessageProps) => { + const compStatusClassName = props.status + ? statusClassName[props.status] + : statusClassName['rendered']; + + const compDirectionClassName = props.direction + ? directionClassName[props.direction] + : directionClassName['incoming']; + + const className = `${messageDomClassName} ${compStatusClassName} ${compDirectionClassName}`; + + if (props.status === 'streaming') { + return ( +
+ ); + } + + if (props.status === 'loading') { + const loader = props.loader ?? ; + return ( +
+ {loader} +
+ ); + } + + // TODO - Handle markdown rendering + return ( +
+ {props.message} +
+ ); +}; diff --git a/packages/react/core/src/comp/Message/props.ts b/packages/react/core/src/comp/Message/props.ts new file mode 100644 index 00000000..84019e0c --- /dev/null +++ b/packages/react/core/src/comp/Message/props.ts @@ -0,0 +1,9 @@ +import {MessageDirection, MessageStatus} from '@nlux/core'; +import {ReactElement, ReactNode} from 'react'; + +export type MessageProps = { + direction: MessageDirection; + status: MessageStatus; + loader?: ReactElement; + message?: ReactNode; +}; diff --git a/packages/react/core/src/comp/PromptBox/PromptBoxComp.tsx b/packages/react/core/src/comp/PromptBox/PromptBoxComp.tsx new file mode 100644 index 00000000..4b46e830 --- /dev/null +++ b/packages/react/core/src/comp/PromptBox/PromptBoxComp.tsx @@ -0,0 +1,34 @@ +import {statusClassName} from '@nlux-dev/core/src/comp/PromptBox/utils/applyNewStatusClassName'; +import {compPromptBoxClassName} from '@nlux/core'; +import React from 'react'; +import {LoaderComp} from '../Loader/LoaderComp'; +import {SendIconComp} from '../SendIcon/SendIconComp'; +import {PromptBoxProps} from './props'; + +export const PromptBoxComp = (props: PromptBoxProps) => { + const compClassNameFromStats = statusClassName[props.status] || ''; + const className = `${compPromptBoxClassName} ${compClassNameFromStats}`; + + const disableTextarea = props.status === 'submitting'; + const disableButton = !props.canSubmit || props.status === 'submitting' || props.status === 'waiting'; + const showSendIcon = props.status === 'typing'; + + return ( +
+ ')); + expect(html).toEqual( + expect.stringContaining('