From ca63ccef4bd82857e2175595db5416ac8425601c Mon Sep 17 00:00:00 2001 From: Martin Lopez Date: Fri, 4 Jul 2025 16:53:35 -0300 Subject: [PATCH 1/7] build: increase version to next major --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 2d609f5..7ea3b5a 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ org.vaadin.addons.flowingcode chat-assistant-addon - 2.0.1-SNAPSHOT + 3.0.0-SNAPSHOT Chat Assistant Add-on Chat Assistant Add-on for Vaadin Flow From 9f076479a62b148ee1cf707fcd4b6e6b20e3d4ac Mon Sep 17 00:00:00 2001 From: Martin Lopez Date: Fri, 4 Jul 2025 16:54:14 -0300 Subject: [PATCH 2/7] build: update vaadin version to 24.8 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 7ea3b5a..51bba02 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ Chat Assistant Add-on for Vaadin Flow - 24.4.11 + 24.8.0 4.10.0 17 17 From 8283a60d9027678ece43acf4586754d176015978 Mon Sep 17 00:00:00 2001 From: Martin Lopez Date: Fri, 4 Jul 2025 16:54:32 -0300 Subject: [PATCH 3/7] build: increase markdown editor add-on dependency to 1.1.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 51bba02..5995b39 100644 --- a/pom.xml +++ b/pom.xml @@ -18,7 +18,7 @@ ${project.basedir}/drivers 11.0.12 3.10.0 - 1.0.0 + 1.1.0 true From 3fd081652a89d12ed4a0734743d1222f87200dea Mon Sep 17 00:00:00 2001 From: Martin Lopez Date: Fri, 4 Jul 2025 16:53:07 -0300 Subject: [PATCH 4/7] feat: add new implementation based on vaadin and react components Add new implementation based on popover and material ui react components, removing completely the dependence with the wc-chatbot old component Closes #17 Closes #18 --- .../addons/chatassistant/ChatAssistant.java | 206 +++++++++--------- .../META-INF/frontend/react/animated-fab.tsx | 78 +++++++ .../frontend/styles/chat-assistant-styles.css | 40 ++-- .../chatassistant/ChatAssistantDemo.java | 4 - 4 files changed, 204 insertions(+), 124 deletions(-) create mode 100644 src/main/resources/META-INF/frontend/react/animated-fab.tsx diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java index 2afbcba..483a399 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java @@ -22,13 +22,14 @@ import com.flowingcode.vaadin.addons.chatassistant.model.Message; import com.vaadin.flow.component.AttachEvent; +import com.vaadin.flow.component.ClickNotifier; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.ComponentEventListener; import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.avatar.Avatar; import com.vaadin.flow.component.dependency.CssImport; import com.vaadin.flow.component.dependency.JsModule; import com.vaadin.flow.component.dependency.NpmPackage; -import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.VaadinIcon; @@ -36,10 +37,11 @@ import com.vaadin.flow.component.messages.MessageInput.SubmitEvent; import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.popover.Popover; +import com.vaadin.flow.component.react.ReactAdapterComponent; import com.vaadin.flow.component.virtuallist.VirtualList; import com.vaadin.flow.data.provider.DataProvider; import com.vaadin.flow.data.renderer.ComponentRenderer; -import com.vaadin.flow.dom.DomEvent; import com.vaadin.flow.shared.Registration; import java.time.LocalDateTime; import java.util.ArrayList; @@ -53,17 +55,24 @@ * @author mmlopez */ @SuppressWarnings("serial") -@NpmPackage(value = "wc-chatbot", version = "0.2.0") -@JsModule("wc-chatbot/dist/wc-chatbot.js") +@NpmPackage(value = "react-draggable", version = "4.4.6") +@NpmPackage(value = "@mui/material", version = "7.1.2") +@NpmPackage(value = "@mui/icons-material", version = "6.1.0") +@NpmPackage(value = "@emotion/react", version = "11.14.0") +@NpmPackage(value = "@emotion/styled", version = "11.14.0") +@JsModule("./react/animated-fab.tsx") +@Tag("animated-fab") @CssImport("./styles/chat-assistant-styles.css") -@Tag("chat-bot") -public class ChatAssistant extends Div { - +public class ChatAssistant extends ReactAdapterComponent implements ClickNotifier { + private static final String CHAT_HEADER_CLASS_NAME = "chat-header"; + private static final String PADDING_SMALL = "0.5em"; + private Component headerComponent; - private Component footerComponent; - private VerticalLayout footerContainer; + private VerticalLayout container; + private Component footerContainer; private VirtualList content = new VirtualList<>(); + private Popover chatWindow; private List messages; private MessageInput messageInput; private Span whoIsTyping; @@ -94,48 +103,87 @@ public ChatAssistant(boolean markdownEnabled) { */ public ChatAssistant(List messages, boolean markdownEnabled) { this.messages = messages; - content.getElement().setAttribute("slot", "content"); - content.setItems(messages); + initializeHeader(); + initializeFooter(); + initializeContent(markdownEnabled); + initializeChatWindow(); + initializeAvatar(); + } - content.setRenderer(new ComponentRenderer( - message -> new ChatMessage(message, markdownEnabled), (component, message) -> { - ((ChatMessage) component).setMessage(message); - return component; - })); - this.add(content); + private void initializeHeader() { + Icon minimize = VaadinIcon.CLOSE.create(); + minimize.addClickListener(ev -> setMinimized(!minimized)); + Span title = new Span("Chat Assistant"); + title.setWidthFull(); + HorizontalLayout header = new HorizontalLayout(title, minimize); + header.setWidthFull(); + headerComponent = header; + } + + private void initializeFooter() { messageInput = new MessageInput(); messageInput.setSizeFull(); - defaultSubmitListenerRegistration = messageInput - .addSubmitListener(se -> this.sendMessage(Message.builder().messageTime(LocalDateTime.now()) + messageInput.getStyle().set("padding", PADDING_SMALL); + defaultSubmitListenerRegistration = messageInput.addSubmitListener(se -> + sendMessage(Message.builder().messageTime(LocalDateTime.now()) .name("User").content(se.getValue()).build())); whoIsTyping = new Span(); whoIsTyping.setClassName("chat-assistant-who-is-typing"); whoIsTyping.setVisible(false); - footerContainer = new VerticalLayout(whoIsTyping); - footerContainer.setSpacing(false); - footerContainer.setMargin(false); - footerContainer.setPadding(false); - footerContainer.getElement().setAttribute("slot", "footer"); - add(footerContainer); - this.setFooterComponent(messageInput); - this.getElement().addEventListener("bot-button-clicked", this::handleClick).addEventData("event.detail"); - - Icon minimize = VaadinIcon.CHEVRON_DOWN_SMALL.create(); - minimize.addClickListener(ev -> this.setMinimized(!minimized)); - Span title = new Span("Chat Assistant"); - title.setWidthFull(); - HorizontalLayout headerBar = new HorizontalLayout(title, minimize); - headerBar.setWidthFull(); - this.setHeaderComponent(headerBar); + VerticalLayout footer = new VerticalLayout(whoIsTyping, messageInput); + footer.setSizeFull(); + footer.setSpacing(false); + footer.setMargin(false); + footer.setPadding(false); + footerContainer = footer; } - private void handleClick(DomEvent event) { - minimized = event.getEventData().getObject("event.detail").getBoolean("minimized"); - if (!minimized) { - refreshContent(); - } + private void initializeContent(boolean markdownEnabled) { + content.setItems(messages); + content.setRenderer(new ComponentRenderer<>(message -> new ChatMessage(message, markdownEnabled), + (component, message) -> { + ((ChatMessage) component).setMessage(message); + return component; + })); + content.setMinHeight("400px"); + content.setMinWidth("400px"); + container = new VerticalLayout(headerComponent, content, footerContainer); + container.setClassName("chat-assistant-container-vertical-layout"); + container.setPadding(false); + container.setMargin(false); + container.setSpacing(false); + container.setSizeFull(); } - + + private void initializeChatWindow() { + VerticalLayout resizableVL = new VerticalLayout(); + resizableVL.setClassName("chat-assistant-resizable-vertical-layout"); + resizableVL.add(container); + chatWindow = new Popover(); + chatWindow.add(resizableVL); + chatWindow.setOpenOnClick(false); + chatWindow.setCloseOnOutsideClick(false); + chatWindow.addOpenedChangeListener(ev->{ + minimized = !ev.isOpened(); + }); + this.getElement().addEventListener("avatar-clicked", ev ->{ + if (this.minimized) { + chatWindow.open(); + } else { + chatWindow.close(); + } + }); + } + + private void initializeAvatar() { + Avatar avatar = new Avatar("AI"); + avatar.setSizeFull(); + this.getElement().appendChild(avatar.getElement()); + this.addAttachListener(ev -> this.getElement().executeJs("return;") + .then(ev2 -> this.getElement().executeJs("this.childNodes[1].childNodes[0].appendChild($0)", avatar.getElement()) + .then(ev3 -> chatWindow.setTarget(avatar)))); + } + /** * Sets the data provider of the internal VirtualList. * @@ -182,45 +230,10 @@ public Registration setSubmitListener(ComponentEventListener listen defaultSubmitListenerRegistration.remove(); return messageInput.addSubmitListener(listener); } - - protected void onAttach(AttachEvent attachEvent) { - if (!minimized) { - getElement().executeJs("setTimeout(() => this.toggle())"); - this.getElement().executeJs("return;").then((ev) -> { - refreshContent(); - }); - } - this.getElement().executeJs("setTimeout(() => this.shadowRoot.querySelector($0).innerHTML = $1)", - ".chatbot-body", ""); - this.getElement().executeJs( - "this.shadowRoot.querySelector($0).style.setProperty('padding', '0px');", - ".chatbot-body"); - this.getElement().executeJs(""" - setTimeout(() => { - let chatbot = this; - let chatBotContainer = this.shadowRoot.querySelector($1); - this.shadowRoot.querySelector($0).addEventListener("click", function() { - let buttonClickedEvent = new CustomEvent("bot-button-clicked", { - detail: { - minimized: chatBotContainer.classList.contains('animation-scale-out'), - }, - }); - chatbot.dispatchEvent(buttonClickedEvent); - }); - }) - """, ".bot-button", ".chatbot-container"); - if (footerComponent!=null) { - this.setFooterComponent(footerComponent); - } - if (headerComponent!=null) { - this.setHeaderComponent(headerComponent); - } - } private void refreshContent() { - this.content.getDataProvider().refreshAll(); - this.content.getElement().executeJs("this.requestContentUpdate();"); - this.content.scrollToEnd(); + content.getDataProvider().refreshAll(); + content.scrollToEnd(); } /** @@ -250,13 +263,17 @@ public void updateMessage(Message message) { * @param minimized true for hiding the chat window and false for displaying it */ public void setMinimized(boolean minimized) { - if (!minimized && this.minimized) { - getElement().executeJs("setTimeout(() => {this.toggle();})"); - this.refreshContent(); - } else if (minimized && !this.minimized) { - getElement().executeJs("setTimeout(() => {this.toggle();})"); + if (this.minimized != minimized) { + this.minimized = minimized; + if (!minimized) { + refreshContent(); + } + } + if (minimized && chatWindow.isOpened()) { + chatWindow.close(); + } else if (!minimized && !chatWindow.isOpened()) { + chatWindow.open(); } - this.minimized = minimized; } /** @@ -274,14 +291,12 @@ public boolean isMinimized() { * @param component to be used as a replacement for the header */ public void setHeaderComponent(Component component) { - if (headerComponent!=null) { - this.remove(headerComponent); + if (headerComponent != null) { + container.remove(headerComponent); } component.addClassName(CHAT_HEADER_CLASS_NAME); - this.headerComponent = component; - this.getElement().executeJs("setTimeout(() => this.shadowRoot.querySelector($0).innerHTML = $1)", ".chatbot-header", ""); - component.getElement().setAttribute("slot", "header"); - this.add(headerComponent); + headerComponent = component; + container.addComponentAsFirst(headerComponent); } /** @@ -300,12 +315,9 @@ public Component getHeaderComponent() { */ public void setFooterComponent(Component component) { Objects.requireNonNull(component, "Component cannot not be null"); - if (footerComponent!=null) { - this.footerContainer.remove(footerComponent); - } - this.getElement().executeJs("setTimeout(() => this.shadowRoot.querySelector($0).innerHTML = $1)", ".chat-footer", ""); - this.footerComponent = component; - footerContainer.add(footerComponent); + container.remove(footerContainer); + footerContainer = component; + container.add(footerContainer); } /** @@ -314,7 +326,7 @@ public void setFooterComponent(Component component) { * @return component used as the footer of the chat window */ public Component getFooterComponent() { - return footerComponent; + return footerContainer; } /** diff --git a/src/main/resources/META-INF/frontend/react/animated-fab.tsx b/src/main/resources/META-INF/frontend/react/animated-fab.tsx new file mode 100644 index 0000000..fbe9517 --- /dev/null +++ b/src/main/resources/META-INF/frontend/react/animated-fab.tsx @@ -0,0 +1,78 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import {useState} from 'react'; +import Draggable from 'react-draggable'; +import Fab from '@mui/material/Fab'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; +import { ReactAdapterElement, type RenderHooks } from 'Frontend/generated/flow/ReactAdapter'; + +const lumoTheme = createTheme({ + palette: { + primary: { + main: 'var(--lumo-primary-color)', + light: 'var(--lumo-primary-color-50pct)', + dark: 'var(--lumo-primary-color-20pct)', + contrastText: 'rgb(var(--lumo-primary-contrast-color))', + }, + }, + components: { + MuiFab: { + styleOverrides: { + root: ({ theme }) => ({ + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + '&:hover': { + backgroundColor: 'var(--lumo-primary-color-50pct)', + }, + }), + }, + }, + } + }); + + +class AnimatedFABElement extends ReactAdapterElement { + private draggableNodeRef = React.createRef(); + + protected override render(hooks: RenderHooks): ReactElement | null { + const [isDragging, setIsDragging] = useState(false); + const eventControl = (event: { type: any; }) => { + if (event.type === 'mousemove' || event.type === 'touchmove') { + setIsDragging(true) + } + if (event.type === 'mouseup' || event.type === 'touchend') { + setTimeout(() => { + setIsDragging(false); + }, 100); + } + } + return ( + + +
{if (!isDragging) {this.dispatchEvent(new CustomEvent('avatar-clicked'));}}} + onTouchEndCapture={(event) => {if (!isDragging) {this.dispatchEvent(new CustomEvent('avatar-clicked'));}}} + ref={this.draggableNodeRef} + style={{ + position: 'fixed', + bottom: 16, + right: 16 + }} + > + + +
+
+
+ ); + } +} + +customElements.define('animated-fab', AnimatedFABElement); diff --git a/src/main/resources/META-INF/frontend/styles/chat-assistant-styles.css b/src/main/resources/META-INF/frontend/styles/chat-assistant-styles.css index b9bc597..8e61f9e 100644 --- a/src/main/resources/META-INF/frontend/styles/chat-assistant-styles.css +++ b/src/main/resources/META-INF/frontend/styles/chat-assistant-styles.css @@ -17,26 +17,20 @@ * limitations under the License. * #L% */ - chat-bot { - --chatbot-avatar-bg-color: var(--lumo-primary-color-10pct); - --chatbot-avatar-margin: 10%; - --chatbot-header-bg-color: var(--lumo-primary-color-50pct); - --chatbot-header-title-color: var(--lumo-header-text-color); - --chatbot-body-bg-color: var(--lumo-primary-color-10pct); - --chatbot-send-button-color: var(--lumo-primary-color); - font-size: var(--lumo-font-size-l); - font-family: var(--lumo-font-family); - } - - chat-bot .chat-header { - color: var(--lumo-base-color); - } - - chat-bot::part(chat-bubble) { - --chat-bubble-color: var(--lumo-success-color); - --chat-bubble-right-color: white; - --chat-bubble-font-color: white; - --chat-bubble-font-right-color: var(--lumo-contrast-90pct); - --chat-bubble-avatar-color: var(--lumo-secondary-color); - --chat-bubble-delay: 0.2s; - } +vaadin-vertical-layout.chat-assistant-resizable-vertical-layout { + transform: rotate(180deg); /* This rotation, along with the one below, is a "double rotation trick." */ + margin: -0.125em -0.375em 0; + overflow: hidden; + width: 439px; + resize: both; + min-height: min-content; + min-width: min-content; +} + +vaadin-vertical-layout.chat-assistant-container-vertical-layout { + min-height: min-content; + transform: rotate(180deg); /* This second rotation completes the "double rotation trick." + Together, these two rotations position the resize handle in the upper-left corner. + This new position is more suitable for resizing the chat window because the chat bubble + is positioned by default in the bottom-right of the view. */ +} \ No newline at end of file diff --git a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java index 7bf57e8..127ecc7 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistantDemo.java @@ -26,10 +26,6 @@ import com.vaadin.flow.component.UI; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.dependency.CssImport; -import com.vaadin.flow.component.html.Span; -import com.vaadin.flow.component.icon.Icon; -import com.vaadin.flow.component.icon.VaadinIcon; -import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.router.PageTitle; From a0516cd8dc6ba68c3e4a675fae249ce1eef85f06 Mon Sep 17 00:00:00 2001 From: Martin Lopez Date: Tue, 22 Jul 2025 14:07:25 -0300 Subject: [PATCH 5/7] docs: update README to reflect usage of Vaadin web components --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1b69ab6..77bc4f5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # Chat Assistant Add-on -Vaadin Add-on that displays a chat assistant floating window using [wc-chatbot](https://github.com/yishiashia/wc-chatbot). +Vaadin Add-on that displays a chat assistant floating window using [Material UI's FAB](https://mui.com/material-ui/react-floating-action-button/) and Vaadin web components. ## Features From 87bab817a249bcc5c463e4c6920794ecba04d365 Mon Sep 17 00:00:00 2001 From: Martin Lopez Date: Fri, 4 Jul 2025 16:55:25 -0300 Subject: [PATCH 6/7] fix: use corrected DataColorMode enum value --- .../flowingcode/vaadin/addons/chatassistant/ChatMessage.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java index 8d124d9..5f04897 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatMessage.java @@ -99,7 +99,7 @@ private void updateLoadingState(Message message) { } if (markdownEnabled) { MarkdownViewer mdv = new MarkdownViewer(message.getContent()); - mdv.setDataColorMode(DataColorMode.LIGTH); + mdv.setDataColorMode(DataColorMode.LIGHT); this.add(mdv); } else { this.getElement().executeJs("this.appendChild(document.createTextNode($0));", message.getContent()); From 44e115307a600aa2e045403893ea4a324d5a0505 Mon Sep 17 00:00:00 2001 From: Martin Lopez Date: Mon, 21 Jul 2025 17:29:25 -0300 Subject: [PATCH 7/7] feat: add method to set custom message renderer in chat assistant --- .../vaadin/addons/chatassistant/ChatAssistant.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java index 483a399..1e39224 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java +++ b/src/main/java/com/flowingcode/vaadin/addons/chatassistant/ChatAssistant.java @@ -42,6 +42,7 @@ import com.vaadin.flow.component.virtuallist.VirtualList; import com.vaadin.flow.data.provider.DataProvider; import com.vaadin.flow.data.renderer.ComponentRenderer; +import com.vaadin.flow.data.renderer.Renderer; import com.vaadin.flow.shared.Registration; import java.time.LocalDateTime; import java.util.ArrayList; @@ -357,4 +358,14 @@ public void scrollToEnd() { this.content.scrollToEnd(); } + /** + * Allows changing the renderer used to display messages in the chat window. + * + * @param renderer the renderer to use for rendering {@link Message} objects, it cannot be null + */ + public void setMessagesRenderer(Renderer renderer) { + Objects.requireNonNull(renderer, "Renderer cannot not be null"); + content.setRenderer(renderer); + } + }