diff --git a/dependencies.md b/dependencies.md new file mode 100644 index 00000000..9343dae0 --- /dev/null +++ b/dependencies.md @@ -0,0 +1,7 @@ +# Dependencies + +This document is to note the reasons for needing the dependencies we have. + +## nanostores and @nanostores/react + +Used in the sidebar code, nanostores is a small framework-agnostic library for reactive state management, while the associated react package provides a hook for optimised component rendering on state changes. react-redux had some stale state issues when used alongside the native `` element, causing it to not render with the correct state. diff --git a/package.json b/package.json index 01c422df..4d628ed2 100644 --- a/package.json +++ b/package.json @@ -74,15 +74,25 @@ "dependencies": { "@eeacms/volto-matomo": "6.0.0", "@microsoft/fetch-event-source": "2.0.1", + "@nanostores/react": "1.0.0", + "dequal": "2.0.3", "fast-json-patch": "3.1.1", "highlight.js": "11.10.0", "luxon": "3.5.0", "marked": "13.0.3", + "nanostores": "1.0.1", "node-fetch": "2.7.0", "react-markdown": "6.0.3", "react-textarea-autosize": "^8.5.3", "rehype-prism-plus": "1.6.0", "remark-gfm": "3.0.1", + "unist-util-visit": "5.0.0", "uuid": "10.0.0" + }, + "peerDependencies": { + "@plone/volto": ">18.0.0 < 19.0.0" + }, + "imports": { + "#stores/*": "./src/sidebar/stores/*" } } diff --git a/src/ChatBlock/ChatBlockView.jsx b/src/ChatBlock/ChatBlockView.jsx index c35bf2d6..4420c778 100644 --- a/src/ChatBlock/ChatBlockView.jsx +++ b/src/ChatBlock/ChatBlockView.jsx @@ -1,9 +1,17 @@ import React from 'react'; -import withDanswerData from './withDanswerData'; -import ChatWindow from './ChatWindow'; import superagent from 'superagent'; +import ChatWindow from './ChatWindow'; +import withDanswerData from './withDanswerData'; + +import { SidebarChatbotStartButton } from '@eeacms/volto-chatbot/sidebar/components/SidebarChatbotStartButton'; -function ChatBlockView(props) { +const OnPageChat = withDanswerData((props) => [ + 'assistantData', + typeof props.data?.assistant !== 'undefined' + ? superagent.get(`/_da/persona/${props.data.assistant}`).type('json') + : null, + props.data?.assistant, +])(function OnPageChat(props) { const { assistantData, data, isEditMode } = props; return assistantData ? ( @@ -11,12 +19,30 @@ function ChatBlockView(props) { ) : (
Chatbot
); -} +}); -export default withDanswerData((props) => [ - 'assistantData', - typeof props.data?.assistant !== 'undefined' - ? superagent.get(`/_da/persona/${props.data.assistant}`).type('json') - : null, - props.data?.assistant, -])(ChatBlockView); +export default function ChatBlockView(props) { + const { data, isEditMode } = props; + + + if (data.displayMode === 'sidebar') { + if (isEditMode) { + return ( +
+ +
+ ); + } + return ( + + ); + } + + return ; +} diff --git a/src/ChatBlock/ChatMessageBubble.jsx b/src/ChatBlock/ChatMessageBubble.jsx index 74f24e95..2e5e17f0 100644 --- a/src/ChatBlock/ChatMessageBubble.jsx +++ b/src/ChatBlock/ChatMessageBubble.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import visit from 'unist-util-visit'; +import { visit } from 'unist-util-visit'; import loadable from '@loadable/component'; import { Button, Message, MessageContent } from 'semantic-ui-react'; import { trackEvent } from '@eeacms/volto-matomo/utils'; diff --git a/src/ChatBlock/ChatWindow.jsx b/src/ChatBlock/ChatWindow.jsx index 9878ffe4..0def2bbf 100644 --- a/src/ChatBlock/ChatWindow.jsx +++ b/src/ChatBlock/ChatWindow.jsx @@ -111,6 +111,7 @@ function ChatWindow({ {...data} persona={persona} onChoice={handleStarterPromptChoice} + starterPromptsHeading={data.displayMode === 'sidebar' ? null : data.starterPromptsHeading} /> )} diff --git a/src/ChatBlock/schema.js b/src/ChatBlock/schema.js index 07204de3..dbc099bf 100644 --- a/src/ChatBlock/schema.js +++ b/src/ChatBlock/schema.js @@ -72,11 +72,9 @@ export function ChatBlockSchema({ assistants, data }) { 'assistant', 'qgenAsistantId', 'placeholderPrompt', - 'height', + 'starterPromptsHeading', 'enableStarterPrompts', ...(data.enableStarterPrompts ? ['starterPrompts'] : []), - 'starterPromptsHeading', - 'starterPromptsPosition', 'showAssistantPrompts', 'enableQgen', 'enableShowTotalFailMessage', @@ -92,13 +90,23 @@ export function ChatBlockSchema({ assistants, data }) { 'enableFeedback', ...(data.enableFeedback ? ['feedbackReasons'] : []), 'enableMatomoTracking', + ], + }, + { + id: 'displaySettings', + title: 'Display settings', + fields: [ + 'height', + 'starterPromptsPosition', 'scrollToInput', 'showToolCalls', 'showAssistantTitle', 'showAssistantDescription', 'chatTitle', - ], - }, + 'displayMode', + ...(data.displayMode === "sidebar" ? ['sidebarStartButtonText'] : []), + ] + } ], properties: { enableShowTotalFailMessage: { @@ -301,7 +309,7 @@ range is from 0 to 100`, default: 'top', }, starterPromptsHeading: { - title: 'Prompts Heading', + title: data.displayMode === 'sidebar' ? 'Sidebar title' : 'Prompts Heading', type: 'string', description: 'Heading shown above the starter prompts (e.g. "Try the following questions")', @@ -363,6 +371,27 @@ range is from 0 to 100`, title: 'Scroll the page to focus on the chat input', type: 'boolean', }, + displayMode: { + title: 'Display', + type: 'string', + factory: 'Choice', + choices: [ + ['page', 'On page'], + ['sidebar', 'In sidebar'], + ], + // Simulate default value without actually setting it so it isn't saved in data. + placeholder: 'above', + noValueOption: false, + }, + sidebarStartButtonText: { + title: 'Start button text', + type: 'string' + } + // showInSidebar: { + // title: 'Global mode', + // description: 'Render the chatbot within a sidebar which can be shown by clicking a button. First block on the page has the controls.', + // type: 'boolean' + // } }, required: [], }; diff --git a/src/ChatBlock/useBackendChat.js b/src/ChatBlock/useBackendChat.js index 51164933..fecf3edc 100644 --- a/src/ChatBlock/useBackendChat.js +++ b/src/ChatBlock/useBackendChat.js @@ -88,7 +88,7 @@ function upsertToCompleteMessageMap({ } } const newCompleteMessageDetail = { - sessionId: chatSessionId || completeMessageDetail.sessionId, + sessionId: chatSessionId || completeMessageDetail?.sessionId || null, // TODO: sessionid can be null because it was an initial error. what should happen? messageMap: newCompleteMessageMap, }; setCompleteMessageDetail(newCompleteMessageDetail); diff --git a/src/index.js b/src/index.js index 812b70f6..ce1b9b1d 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,8 @@ import installChatBlock from './ChatBlock'; import loadable from '@loadable/component'; +import { SidebarEntrypoint } from "@eeacms/volto-chatbot/sidebar/components/SidebarEntrypoint"; + const applyConfig = (config) => { if (__SERVER__) { const express = require('express'); @@ -40,6 +42,22 @@ const applyConfig = (config) => { installChatBlock(config); + config.settings.appExtras = [ + ...config.settings.appExtras, + { + match: "", + component: SidebarEntrypoint, + }, + ]; + + config.settings["volto-chatbot"] = { + ...(config.settings["volto-chatbot"] || {}), + sidebar: { + startButtonTitle: "Start assistant chat", + sidebarTitle: "Help using this site", + } + }; + return config; }; diff --git a/src/sidebar/components/DefaultChatbotStartButton.jsx b/src/sidebar/components/DefaultChatbotStartButton.jsx new file mode 100644 index 00000000..c87a8729 --- /dev/null +++ b/src/sidebar/components/DefaultChatbotStartButton.jsx @@ -0,0 +1,9 @@ +import { Button } from "semantic-ui-react"; + +export function DefaultChatbotStartButton({ onClick, title }) { + return ( + + ); +} diff --git a/src/sidebar/components/SidebarChatbotStartButton.jsx b/src/sidebar/components/SidebarChatbotStartButton.jsx new file mode 100644 index 00000000..6bba551f --- /dev/null +++ b/src/sidebar/components/SidebarChatbotStartButton.jsx @@ -0,0 +1,24 @@ +import { selectedSidebarChatbot } from '#stores/sidebarStore'; +import config from '@plone/registry'; +import { DefaultChatbotStartButton } from './DefaultChatbotStartButton'; + +export function SidebarChatbotStartButton({ + assistant, + title = 'Start assistant chat', +}) { + const ChatbotStartButton = + config.getComponent('ChatbotStartButton')?.component || + DefaultChatbotStartButton; + + // TODO: Hide the start button until we've checked we support the dialog element and JS is loaded + return ( +
+ { + selectedSidebarChatbot.set(assistant); + }} + title={title || 'Start assistant chat'} + /> +
+ ); +} diff --git a/src/sidebar/components/SidebarDisplay.jsx b/src/sidebar/components/SidebarDisplay.jsx new file mode 100644 index 00000000..02ad37c0 --- /dev/null +++ b/src/sidebar/components/SidebarDisplay.jsx @@ -0,0 +1,79 @@ +import { selectedSidebarChatbot } from "#stores/sidebarStore"; +import ChatWindow from "@eeacms/volto-chatbot/ChatBlock/ChatWindow"; +import { useStore } from "@nanostores/react"; +import Icon from "@plone/volto/components/theme/Icon/Icon"; +import { Button } from "semantic-ui-react"; + +// ChatBlock +import { getBlocksFieldname } from "@plone/volto/helpers"; +import clearSVG from "@plone/volto/icons/clear.svg"; +import { forwardRef } from "react"; +import superagent from "superagent"; +import withDanswerData from "../../ChatBlock/withDanswerData"; + +import config from "@plone/registry"; + +const ChatBlockDisplay = withDanswerData(({ assistant }) => [ + "assistantData", + typeof assistant !== "undefined" && assistant !== null + ? superagent.get(`/_da/persona/${assistant}`).type("json") + : null, + assistant, +])(function ChatBlockDisplay({ data, assistantData }) { + if (!assistantData) { + return null; + } + return ; +}); + +export const SidebarDisplay = forwardRef(function SidebarDisplay( + { content }, + ref, +) { + const $selectedSidebarChatbot = useStore(selectedSidebarChatbot); + + const blocksFieldname = getBlocksFieldname(content) || "blocks"; + + const sidebarBlockData = Object.values(content?.[blocksFieldname] || {}).find( + (block) => + block["@type"] === "danswerChat" && + block.assistant == $selectedSidebarChatbot, + ); + const sidebarTitle = + sidebarBlockData?.starterPromptsHeading || + config.settings["volto-chatbot"]?.sidebar?.sidebarTitle || + "Help using this site"; + + return ( + <> +
+ +
+
+ +

{sidebarTitle}

+
+ +
+
+
+ + ); +}); diff --git a/src/sidebar/components/SidebarEntrypoint.jsx b/src/sidebar/components/SidebarEntrypoint.jsx new file mode 100644 index 00000000..c7307730 --- /dev/null +++ b/src/sidebar/components/SidebarEntrypoint.jsx @@ -0,0 +1,33 @@ +import { selectedSidebarChatbot } from "#stores/sidebarStore"; + +import { useStore } from "@nanostores/react"; +import { useEffect, useRef } from "react"; +import { useSelector } from "react-redux"; +import { SidebarDisplay } from "./SidebarDisplay"; + +import './SidebarEntrypoint.scss'; + +export function SidebarEntrypoint() { + const sidebarRef = useRef(); + const $selectedSidebarChatbot = useStore(selectedSidebarChatbot); + const content = useSelector((state) => state.content.data); + + // Effect for programmatic open/ close via store from elsewhere in the app + useEffect(() => { + const isOpen = $selectedSidebarChatbot !== null; + if (sidebarRef.current) { + if (isOpen && !sidebarRef.current.open) { + sidebarRef.current.showModal(); + } else if (!isOpen && sidebarRef.current.open) { + sidebarRef.current.close(); + } + } + }, [$selectedSidebarChatbot]); + + // TODO: Hide the start button until we've checked we support the dialog element and JS is loaded + return ( + <> + + + ); +} diff --git a/src/sidebar/components/SidebarEntrypoint.scss b/src/sidebar/components/SidebarEntrypoint.scss new file mode 100644 index 00000000..c3963774 --- /dev/null +++ b/src/sidebar/components/SidebarEntrypoint.scss @@ -0,0 +1,47 @@ +#chatbot-sidebar { + dialog { + background: white; + padding: 20px; + height: 100%; + max-height: 100vh; + width: min(120ch, 100vw); + max-width: 100vw; + margin-block: 0; + margin-inline-start: 0; + border: 0; + + &::backdrop { + background-color: rgba(0, 32, 74, 0.5); + } + } + .dialogContent { + height: 100%; + display: flex; + flex-direction: column; + } + .heading { + display: flex; + flex-direction: row-reverse; + + h2 { + margin-inline-end: auto; + margin-block: 0; + } + button { + svg { + margin: 0; + display: block; + } + } + } + .blocks-group-wrapper { + margin-block: 0; + padding-block: 0; + height: 100%; + overflow-y: scroll; + } +} + +body:has(#chatbot-sidebar dialog[open]) { + overflow: hidden; +} diff --git a/src/sidebar/stores/sidebarStore.js b/src/sidebar/stores/sidebarStore.js new file mode 100644 index 00000000..004aefe8 --- /dev/null +++ b/src/sidebar/stores/sidebarStore.js @@ -0,0 +1,3 @@ +import { atom } from "nanostores"; + +export const selectedSidebarChatbot = atom(null);