diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 585ec74f..6b34de94 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -63,6 +63,10 @@ export class BlockService extends BaseService { blocks: BlockFull[], event: EventWrapper, ): Promise { + if (!blocks.length) { + return undefined; + } + // Search for block matching a given event let block: BlockFull | undefined = undefined; const payload = event.getPayload(); diff --git a/api/src/chat/services/bot.service.ts b/api/src/chat/services/bot.service.ts index fb26c8d9..ac11ac24 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -443,7 +443,7 @@ export class BotService { }); if (!blocks.length) { - return this.logger.debug('No starting message blocks was found'); + this.logger.debug('No starting message blocks was found'); } // Search for a block match diff --git a/api/src/setting/seeds/setting.seed-model.ts b/api/src/setting/seeds/setting.seed-model.ts index 5882c4cb..ce239097 100644 --- a/api/src/setting/seeds/setting.seed-model.ts +++ b/api/src/setting/seeds/setting.seed-model.ts @@ -67,7 +67,7 @@ export const settingModels: SettingCreateDto[] = [ { group: 'nlp_settings', label: 'threshold', - value: 0.9, + value: 0.1, type: SettingType.number, config: { min: 0, diff --git a/api/src/websocket/websocket.gateway.spec.ts b/api/src/websocket/websocket.gateway.spec.ts index 1f255b30..690b3efe 100644 --- a/api/src/websocket/websocket.gateway.spec.ts +++ b/api/src/websocket/websocket.gateway.spec.ts @@ -68,11 +68,8 @@ describe('WebsocketGateway', () => { it('should connect successfully', async () => { ioClient.connect(); await new Promise((resolve) => { - // ioClient.on('connect', () => { - // console.log('connected'); - // }); - ioClient.on('message', (data) => { - expect(data.statusCode).toBe(200); + ioClient.on('connect', () => { + expect(true).toBe(true); resolve(); }); }); diff --git a/api/src/websocket/websocket.gateway.ts b/api/src/websocket/websocket.gateway.ts index 2d4b69c9..371b0f90 100644 --- a/api/src/websocket/websocket.gateway.ts +++ b/api/src/websocket/websocket.gateway.ts @@ -255,22 +255,6 @@ export class WebsocketGateway this.logger.debug(`Number of connected clients: ${sockets?.size}`); this.eventEmitter.emit(`hook:websocket:connection`, client); - // @TODO : Revisit once we don't use anymore in frontend - const response = new SocketResponse(); - client.send( - response - .setHeaders({ - 'access-control-allow-origin': - config.security.cors.allowOrigins.join(','), - vary: 'Origin', - 'access-control-allow-credentials': - config.security.cors.allowCredentials.toString(), - }) - .status(200) - .json({ - success: true, - }), - ); } async handleDisconnect(client: Socket): Promise { diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 25dc2f7b..3fafa429 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,7 +1,7 @@ /** @type {import('next').NextConfig} */ import withTM from "next-transpile-modules"; -const nextConfig = withTM(["hexabot-widget"])({ +const nextConfig = withTM(["hexabot-chat-widget"])({ async rewrites() { return [ { @@ -11,7 +11,7 @@ const nextConfig = withTM(["hexabot-widget"])({ ]; }, webpack(config) { - if (process.env.NODE_ENV==="development") { + if (process.env.NODE_ENV === "development") { config.watchOptions = { poll: 1000, aggregateTimeout: 300, diff --git a/frontend/package.json b/frontend/package.json index e23deafc..a70edc76 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,7 +29,7 @@ "axios": "^1.7.7", "eazychart-css": "^0.2.1-alpha.0", "eazychart-react": "^0.8.0-alpha.0", - "hexabot-widget": "*", + "hexabot-chat-widget": "*", "next": "^14.2.13", "next-transpile-modules": "^10.0.1", "normalizr": "^3.6.2", diff --git a/frontend/src/app-components/widget/ChatWidget.tsx b/frontend/src/app-components/widget/ChatWidget.tsx index d89c3271..e8008c85 100644 --- a/frontend/src/app-components/widget/ChatWidget.tsx +++ b/frontend/src/app-components/widget/ChatWidget.tsx @@ -7,7 +7,7 @@ */ import { Avatar, Box } from "@mui/material"; -import UiChatWidget from "hexabot-widget/src/UiChatWidget"; +import UiChatWidget from "hexabot-chat-widget/src/UiChatWidget"; import { usePathname } from "next/navigation"; import { getAvatarSrc } from "@/components/inbox/helpers/mapMessages"; diff --git a/package-lock.json b/package-lock.json index 8b57ac1b..a7536a3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "axios": "^1.7.7", "eazychart-css": "^0.2.1-alpha.0", "eazychart-react": "^0.8.0-alpha.0", - "hexabot-widget": "*", + "hexabot-chat-widget": "*", "next": "^14.2.13", "next-transpile-modules": "^10.0.1", "normalizr": "^3.6.2", @@ -6356,6 +6356,10 @@ "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.5.tgz", "integrity": "sha512-G7HLD+WKcrOyJP5VQwYZNC3Z6FcQ7YYjEFiFoIj8PfEr73mu421o8B1N5DKUcc8K37EsJ2XXWA8DtrDz/2dReg==" }, + "node_modules/hexabot-chat-widget": { + "resolved": "widget", + "link": true + }, "node_modules/hexabot-cli": { "resolved": "cli", "link": true @@ -6364,10 +6368,6 @@ "resolved": "frontend", "link": true }, - "node_modules/hexabot-widget": { - "resolved": "widget", - "link": true - }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -9918,7 +9918,7 @@ } }, "widget": { - "name": "hexabot-widget", + "name": "hexabot-chat-widget", "version": "2.0.0", "license": "AGPL-3.0-only", "dependencies": { diff --git a/widget/README.md b/widget/README.md index 64f272ba..151149c7 100644 --- a/widget/README.md +++ b/widget/README.md @@ -104,6 +104,17 @@ To prevent the website css from conflicting with the chat widget css, we can lev ``` +If you would like to use the official widget and benefit from updates automatically, you can consider using the cdn url: +`https://cdn.jsdelivr.net/npm/hexabot-chat-widget@2.0.4/dist/` + +or lastest from major version: +`https://cdn.jsdelivr.net/npm/hexabot-chat-widget@2/dist/` + +JsDelivr uses the package published in the NPM registry : https://www.npmjs.com/package/hexabot-widget + +## Examples +As a proof of concept we developed a Wordpress plugin to embed the chat widget in a Wordpress website : [https://github.com/hexastack/hexabot-wordpress-live-chat-widget](https://github.com/hexastack/hexabot-wordpress-live-chat-widget) + ## Customization You can customize the look and feel of the chat widget by modifying the widget’s scss styles or behavior. The widget allows you to: diff --git a/widget/package.json b/widget/package.json index 4d7a3b03..d9814a36 100644 --- a/widget/package.json +++ b/widget/package.json @@ -1,6 +1,6 @@ { - "name": "hexabot-live-chat-widget", - "version": "2.0.0-rc.2", + "name": "hexabot-chat-widget", + "version": "2.0.0", "description": "Hexabot is a solution for creating and managing chatbots across multiple channels, leveraging AI for advanced conversational capabilities. It provides a user-friendly interface for building, training, and deploying chatbots with integrated support for various messaging platforms.", "author": "Hexastack", "license": "AGPL-3.0-only", diff --git a/widget/src/components/Launcher.tsx b/widget/src/components/Launcher.tsx index 31f2ec88..e85cf5d5 100644 --- a/widget/src/components/Launcher.tsx +++ b/widget/src/components/Launcher.tsx @@ -15,7 +15,6 @@ import { useChat } from '../providers/ChatProvider'; import { useColors } from '../providers/ColorProvider'; import { useSocketLifecycle } from '../providers/SocketProvider'; import { useWidget, WidgetContextType } from '../providers/WidgetProvider'; - import './Launcher.scss'; type LauncherProps = PropsWithChildren<{ diff --git a/widget/src/components/UserSubscription.tsx b/widget/src/components/UserSubscription.tsx index d754cd4e..de1c789a 100644 --- a/widget/src/components/UserSubscription.tsx +++ b/widget/src/components/UserSubscription.tsx @@ -6,7 +6,13 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import React, { SyntheticEvent, useCallback, useEffect, useState } from 'react'; +import React, { + SyntheticEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { useTranslation } from '../hooks/useTranslation'; import { useChat } from '../providers/ChatProvider'; @@ -39,6 +45,7 @@ const UserSubscription: React.FC = () => { } = useChat(); const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); + const isInitialized = useRef(false); const handleSubmit = useCallback( async (event?: React.FormEvent) => { event?.preventDefault(); @@ -83,7 +90,7 @@ const UserSubscription: React.FC = () => { data: { type: TOutgoingMessageType.postback, data: { - text: 'GET_STARTED', //TODO:use translation here? + text: t('messages.get_started'), payload: 'GET_STARTED', }, author: profile.foreign_id, @@ -113,14 +120,18 @@ const UserSubscription: React.FC = () => { ); useEffect(() => { - const profile = localStorage.getItem('profile'); + // User already subscribed ? (example : refreshed the page) + if (!isInitialized.current) { + isInitialized.current = true; + const profile = localStorage.getItem('profile'); - if (profile) { - const parsedProfile = JSON.parse(profile); + if (profile) { + const parsedProfile = JSON.parse(profile); - setFirstName(parsedProfile.first_name); - setLastName(parsedProfile.last_name); - handleSubmit(); + setFirstName(parsedProfile.first_name); + setLastName(parsedProfile.last_name); + handleSubmit(); + } } }, [handleSubmit, setScreen]); diff --git a/widget/src/providers/ChatProvider.tsx b/widget/src/providers/ChatProvider.tsx index cd4fffcb..c5fefb55 100644 --- a/widget/src/providers/ChatProvider.tsx +++ b/widget/src/providers/ChatProvider.tsx @@ -241,15 +241,14 @@ const ChatProvider: React.FC<{ ) { if ('author' in newIOMessage) { newIOMessage.direction = - newIOMessage.author === participants[1].foreign_id || - newIOMessage.author === participants[1].id - ? Direction.sent - : Direction.received; + newIOMessage.author === 'chatbot' + ? Direction.received + : Direction.sent; newIOMessage.read = true; newIOMessage.delivery = true; } - messages.push(newIOMessage as TMessage); + setMessages([...messages, newIOMessage as TMessage]); setScroll(0); } @@ -310,30 +309,29 @@ const ChatProvider: React.FC<{ }>( `/webhook/${config.channel}/?verification_token=${config.token}&first_name=${firstName}&last_name=${lastName}`, ); - const { messages, profile } = body; - localStorage.setItem('profile', JSON.stringify(profile)); - // @TODO : condition mix on id VS foreign_id - messages.forEach((message) => { - const direction = - message.author === profile.foreign_id || - message.author === profile.id - ? Direction.sent - : Direction.received; - - message.direction = direction; - if (message.direction === Direction.sent) { - message.read = true; - message.delivery = false; - } - }); - setMessages(messages); + localStorage.setItem('profile', JSON.stringify(body.profile)); + setMessages( + body.messages.map((message) => { + return { + ...message, + direction: + message.author === body.profile.foreign_id || + message.author === body.profile.id + ? Direction.sent + : Direction.received, + read: message.direction === Direction.sent || message.read, + delivery: + message.direction === Direction.sent || message.delivery, + } as TMessage; + }), + ); setParticipants([ ...participants, { - id: profile.foreign_id, - foreign_id: profile.foreign_id, - name: `${profile.first_name} ${profile.last_name}`, + id: body.profile.foreign_id, + foreign_id: body.profile.foreign_id, + name: `${body.profile.first_name} ${body.profile.last_name}`, }, ]); setConnectionState(3); diff --git a/widget/src/providers/SocketProvider.tsx b/widget/src/providers/SocketProvider.tsx index 5174b953..2497b92b 100644 --- a/widget/src/providers/SocketProvider.tsx +++ b/widget/src/providers/SocketProvider.tsx @@ -12,43 +12,42 @@ import { useContext, useEffect, useRef, - useState, } from 'react'; import { useConfig } from './ConfigProvider'; -import { builSocketIoClient, SocketIoClient } from '../utils/SocketIoClient'; +import { getSocketIoClient, SocketIoClient } from '../utils/SocketIoClient'; interface socketContext { socket: SocketIoClient; - connected: boolean; } const socketContext = createContext({ socket: {} as SocketIoClient, - connected: false, }); export const SocketProvider = (props: PropsWithChildren) => { const config = useConfig(); - const socketRef = useRef(builSocketIoClient(config)); - const [connected, setConnected] = useState(false); - - useEffect(() => { - socketRef.current.init({ + const socketRef = useRef( + getSocketIoClient(config, { onConnect: () => { - setConnected(true); + // eslint-disable-next-line no-console + console.info( + 'Hexabot Live Chat : Successfully established WS Connection!', + ); }, onConnectError: () => { - setConnected(false); + // eslint-disable-next-line no-console + console.error('Hexabot Live Chat : Failed to establish WS Connection!'); }, onDisconnect: () => { - setConnected(false); + // eslint-disable-next-line no-console + console.info('Hexabot Live Chat : Disconnected WS.'); }, - }); - }, []); + }), + ); return ( - + {props.children} ); @@ -58,12 +57,6 @@ export const useSocket = () => { return useContext(socketContext); }; -export const useSocketConnected = () => { - const { connected } = useSocket(); - - return connected; -}; - export const useSubscribe = (event: string, callback: (arg: T) => void) => { const { socket } = useSocket(); @@ -83,5 +76,6 @@ export const useSocketLifecycle = () => { return () => { socket.disconnect(); }; - }, [socket]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); }; diff --git a/widget/src/translations/en/translation.json b/widget/src/translations/en/translation.json index 89df5389..5e499db9 100644 --- a/widget/src/translations/en/translation.json +++ b/widget/src/translations/en/translation.json @@ -11,6 +11,7 @@ "back": "Back" }, "messages": { + "get_started": "Get Started", "file_message": { "browser_audio_unsupport": "Browser does not support the audio element.", "browser_video_unsupport": "Browser does not support the video element.", diff --git a/widget/src/translations/fr/translation.json b/widget/src/translations/fr/translation.json index dcf9119b..acd2bda9 100644 --- a/widget/src/translations/fr/translation.json +++ b/widget/src/translations/fr/translation.json @@ -11,6 +11,7 @@ "back": "Retour" }, "messages": { + "get_started": "Démarrer", "file_message": { "browser_audio_unsupport": "Le navigateur ne prend pas en charge l'élément audio.", "browser_video_unsupport": "Le navigateur ne prend pas en charge l'élément vidéo.", diff --git a/widget/src/utils/SocketIoClient.ts b/widget/src/utils/SocketIoClient.ts index 8baa1d37..a90859a3 100644 --- a/widget/src/utils/SocketIoClient.ts +++ b/widget/src/utils/SocketIoClient.ts @@ -16,6 +16,12 @@ import { type SocketIoClientConfig = Partial; +type SocketIoEventHandlers = { + onConnect?: () => void; + onDisconnect?: (reason: string, details: unknown) => void; + onConnectError?: (error: Error) => void; +}; + export class SocketIoClient { /** * Default configuration for the socket client @@ -50,9 +56,11 @@ export class SocketIoClient { private config: SocketIoClientConfig; - private initialized: boolean = false; - - constructor(apiUrl: string, socketConfig?: SocketIoClientConfig) { + constructor( + apiUrl: string, + socketConfig: SocketIoClientConfig, + handlers: SocketIoEventHandlers, + ) { this.config = { ...SocketIoClient.defaultConfig, ...socketConfig, @@ -61,6 +69,7 @@ export class SocketIoClient { const url = new URL(apiUrl); this.socket = io(url.origin, this.config); + this.init(handlers); } /** @@ -71,16 +80,10 @@ export class SocketIoClient { onConnect, onDisconnect, onConnectError, - }: { - onConnect?: () => void; - onDisconnect?: (reason: string, details: unknown) => void; - onConnectError?: (error: Error) => void; - }) { - if (!this.initialized) this.socket.connect(); + }: SocketIoEventHandlers) { onConnect && this.uniqueOn('connect', onConnect); onDisconnect && this.uniqueOn('disconnect', onDisconnect); onConnectError && this.uniqueOn('connect_error', onConnectError); - this.initialized = true; } /** @@ -100,7 +103,6 @@ export class SocketIoClient { */ public disconnect() { this.socket.disconnect(); - this.initialized = false; } /** @@ -184,10 +186,28 @@ export class SocketIoClient { } } -export const builSocketIoClient = (config: Config) => - new SocketIoClient(config.apiUrl, { - query: { - channel: config.channel, - verification_token: config.token, - }, - }); +let socketIoClient: SocketIoClient; + +/** + * Returns a singleton instance of the socket io client + * + * @param config The socket connection config + * @param handlers Event handlers + * @returns Socket io client instance + */ +export const getSocketIoClient = (config: Config, handlers: SocketIoEventHandlers) => { + if (!socketIoClient) { + socketIoClient = new SocketIoClient( + config.apiUrl, + { + query: { + channel: config.channel, + verification_token: config.token, + }, + }, + handlers, + ); + } + + return socketIoClient; +}; diff --git a/widget/vite.config.ts b/widget/vite.config.ts index 998d7ad1..67d5795e 100644 --- a/widget/vite.config.ts +++ b/widget/vite.config.ts @@ -5,28 +5,31 @@ import { defineConfig } from "vite"; import dts from "vite-plugin-dts"; -export default defineConfig({ - plugins: [react(), dts()], - server: { - host: "0.0.0.0", - }, - define: { - "process.env": process.env, - }, - build: { - lib: { - entry: resolve(__dirname, "src/ChatWidget.tsx"), - name: "HexabotWidget", - fileName: (format) => `hexabot-widget.${format}.js`, +export default defineConfig(({ mode }) => { + return { + plugins: [react(), dts()], + server: { + host: '0.0.0.0', }, - rollupOptions: { - external: ["react", "react-dom"], - output: { - globals: { - react: "React", - "react-dom": "ReactDOM", + define: { + 'process.env': + mode === 'development' ? { 'process.env': process.env } : {}, + }, + build: { + lib: { + entry: resolve(__dirname, 'src/ChatWidget.tsx'), + name: 'HexabotWidget', + fileName: (format) => `hexabot-widget.${format}.js`, + }, + rollupOptions: { + external: ['react', 'react-dom'], + output: { + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + }, }, }, }, - }, + }; });