From ae95de5d1a40af47d471e86ff718614e6ca5dcea Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Mon, 7 Apr 2025 12:55:53 +0100 Subject: [PATCH 01/10] feat: highlight triggered or errored block (backend) --- api/src/chat/services/block.service.spec.ts | 6 +-- api/src/chat/services/block.service.ts | 52 ++++++++++++++++++++- api/src/chat/services/bot.service.ts | 21 +++++++-- api/src/utils/test/mocks/conversation.ts | 7 ++- api/src/websocket/websocket.gateway.ts | 28 ++++++++++- api/types/event-emitter.d.ts | 15 ++++++ 6 files changed, 118 insertions(+), 11 deletions(-) diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index 393d91603..ea4265fb3 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -46,7 +46,7 @@ import { } from '@/utils/test/mocks/block'; import { contextBlankInstance, - subscriberContextBlankInstance, + subscriber, } from '@/utils/test/mocks/conversation'; import { nlpEntitiesGreeting } from '@/utils/test/mocks/nlp'; import { @@ -429,7 +429,7 @@ describe('BlockService', () => { ...contextBlankInstance, skip: { [blockProductListMock.id]: 0 }, }, - subscriberContextBlankInstance, + subscriber, false, 'conv_id', ); @@ -463,7 +463,7 @@ describe('BlockService', () => { ...contextBlankInstance, skip: { [blockProductListMock.id]: 2 }, }, - subscriberContextBlankInstance, + subscriber, false, 'conv_id', ); diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 5f0dbbb8b..06dfb3c9d 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -474,10 +474,11 @@ export class BlockService extends BaseService< async processMessage( block: Block | BlockFull, context: Context, - subscriberContext: SubscriberContext, + recipient: Subscriber, fallback = false, conversationId?: string, ): Promise { + const subscriberContext = recipient.context as SubscriberContext; const settings = await this.settingService.getSettings(); const blockMessage: BlockMessage = fallback && block.options?.fallback @@ -566,6 +567,23 @@ export class BlockService extends BaseService< const attachmentPayload = blockMessage.attachment.payload; if (!('id' in attachmentPayload)) { this.checkDeprecatedAttachmentUrl(block); + + const flowId = + typeof block.category === 'object' + ? // @ts-expect-error : block always has category + block!.category.id + : block.category; + if (flowId) { + this.logger.log('triggered: hook:highlight:error'); + this.eventEmitter.emit('hook:highlight:error', { + flowId, + userId: recipient.foreign_id, + blockId: block.id, + }); + } else { + this.logger.warn('Unable to trigger: hook:highlight:error'); + } + throw new Error( 'Remote attachments in blocks are no longer supported!', ); @@ -614,6 +632,22 @@ export class BlockService extends BaseService< }; return envelope; } catch (err) { + const flowId = + typeof block.category === 'object' + ? // @ts-expect-error : block always has category + block!.category.id + : block.category; + if (flowId) { + this.logger.log('triggered: hook:highlight:error'); + this.eventEmitter.emit('hook:highlight:error', { + flowId, + userId: recipient.foreign_id, + blockId: block.id, + }); + } else { + this.logger.warn('Unable to trigger: hook:highlight:error'); + } + this.logger.error( 'Unable to retrieve content for list template process', err, @@ -635,6 +669,22 @@ export class BlockService extends BaseService< return envelope; } catch (e) { + const flowId = + typeof block.category === 'object' + ? // @ts-expect-error : block always has category + block!.category.id + : block.category; + if (flowId) { + this.logger.log('triggered: hook:highlight:error'); + this.eventEmitter.emit('hook:highlight:error', { + flowId, + userId: recipient.foreign_id, + blockId: block.id, + }); + } else { + this.logger.warn('Unable to trigger: hook:highlight:error'); + } + this.logger.error('Plugin was unable to load/process ', e); throw new Error(`Unknown plugin - ${JSON.stringify(blockMessage)}`); } diff --git a/api/src/chat/services/bot.service.ts b/api/src/chat/services/bot.service.ts index 13e6cd576..392a074ef 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -147,11 +147,15 @@ export class BotService { const envelope = await this.blockService.processMessage( block, context, - recipient?.context, + recipient, fallback, convo.id, ); - + this.eventEmitter.emit('hook:highlight:block', { + flowId: block.category!.id, + blockId: block.id, + userId: recipient.foreign_id, + }); if (envelope.format !== OutgoingMessageFormat.system) { await this.sendMessageToSubscriber( envelope, @@ -323,6 +327,13 @@ export class BotService { ); await this.triggerBlock(event, updatedConversation, next, fallback); } catch (err) { + if (next && next.id !== fallbackBlock?.id) { + this.eventEmitter.emit('hook:highlight:error', { + flowId: matchedBlock!.category!.id, + userId: convo.sender.foreign_id, + blockId: next.id!, + }); + } this.logger.error('Unable to store context data!', err); return this.eventEmitter.emit('hook:conversation:end', convo); } @@ -522,11 +533,13 @@ export class BotService { updatedAt: new Date(), attachedBlock: null, } as any as BlockFull; - + const recipient = structuredClone(event.getSender()); + recipient.context.vars = {}; const envelope = await this.blockService.processMessage( globalFallbackBlock, getDefaultConversationContext(), - { vars: {} }, // @TODO: use subscriber ctx + recipient, + // { vars: {} }, // @TODO: use subscriber ctx ); await this.sendMessageToSubscriber( diff --git a/api/src/utils/test/mocks/conversation.ts b/api/src/utils/test/mocks/conversation.ts index 115b9d2ff..dd89182dc 100644 --- a/api/src/utils/test/mocks/conversation.ts +++ b/api/src/utils/test/mocks/conversation.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 Hexastack. All rights reserved. * * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. @@ -8,6 +8,7 @@ import { Block, BlockStub } from '@/chat/schemas/block.schema'; import { ConversationFull } from '@/chat/schemas/conversation.schema'; +import { Subscriber } from '@/chat/schemas/subscriber.schema'; import { Context } from '@/chat/schemas/types/context'; import { SubscriberContext } from '@/chat/schemas/types/subscriberContext'; @@ -34,6 +35,10 @@ export const subscriberContextBlankInstance: SubscriberContext = { vars: {}, }; +export const subscriber = { + context: subscriberContextBlankInstance, +} as Subscriber; + export const contextEmailVarInstance: Context = { ...contextBlankInstance, vars: { diff --git a/api/src/websocket/websocket.gateway.ts b/api/src/websocket/websocket.gateway.ts index f6e1b0f68..9141e4411 100644 --- a/api/src/websocket/websocket.gateway.ts +++ b/api/src/websocket/websocket.gateway.ts @@ -6,7 +6,11 @@ * 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 { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; +import { + EventEmitter2, + IHookOperationMap, + OnEvent, +} from '@nestjs/event-emitter'; import { ConnectedSocket, MessageBody, @@ -254,7 +258,6 @@ export class WebsocketGateway const { sockets } = this.io.sockets; this.logger.log(`Client id: ${client.id} connected`); this.logger.debug(`Number of connected clients: ${sockets?.size}`); - this.eventEmitter.emit(`hook:websocket:connection`, client); } @@ -405,4 +408,25 @@ export class WebsocketGateway ); return response.getPromise(); } + + @OnEvent('hook:highlight:block') + async handleHighlightBlock( + payload: IHookOperationMap['highlight']['operations']['block'], + ) { + this.logger.log( + 'broadcasting event highlight:flow through socketio ', + payload, + ); + // todo: fix emit event to subscriber + this.io.emit('highlight:flow', payload); + } + + @OnEvent('hook:highlight:error') + async highlightBlockErrored( + payload: IHookOperationMap['highlight']['operations']['error'], + ) { + this.logger.warn('hook:highlight:error ', payload); + // todo: fix emit event to subscriber + this.io.emit('highlight:error', payload); + } } diff --git a/api/types/event-emitter.d.ts b/api/types/event-emitter.d.ts index 6d9ce2d3c..6723d8f76 100644 --- a/api/types/event-emitter.d.ts +++ b/api/types/event-emitter.d.ts @@ -120,6 +120,21 @@ declare module '@nestjs/event-emitter' { connection: Socket; } >; + highlight: TDefinition< + object, + { + block: { + userId: string; + flowId: string; + blockId: string; + }; + error: { + userId: string; + flowId: string; + blockId: string; + }; + } + >; } /* hooks */ From b3845b5f6761d4296a1cdf3f52d96d500b77487a Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Mon, 7 Apr 2025 13:01:20 +0100 Subject: [PATCH 02/10] feat: highlight triggered or errored block (frontend) --- .../visual-editor/hooks/useVisualEditor.tsx | 131 +++++++++++++++++- .../v2/CustomDiagramNodes/NodeModel.tsx | 27 +++- .../v2/CustomDiagramNodes/NodeWidget.tsx | 25 +++- frontend/src/styles/visual-editor.css | 66 ++++++++- frontend/src/websocket/socket-hooks.tsx | 3 +- 5 files changed, 239 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx b/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx index 6fdfba6e6..453641504 100644 --- a/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx +++ b/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx @@ -8,11 +8,12 @@ import { debounce } from "@mui/material"; import createEngine, { DiagramModel } from "@projectstorm/react-diagrams"; +import { useRouter } from "next/router"; import * as React from "react"; import { createContext, useContext } from "react"; import { useCreate } from "@/hooks/crud/useCreate"; -import { EntityType } from "@/services/types"; +import { EntityType, RouterType } from "@/services/types"; import { IBlock } from "@/types/block.types"; import { BlockPorts, @@ -20,6 +21,7 @@ import { IVisualEditorContext, VisualEditorContextProps, } from "@/types/visual-editor.types"; +import { useSubscribe } from "@/websocket/socket-hooks"; import { ZOOM_LEVEL } from "../constants"; import { AdvancedLinkFactory } from "../v2/AdvancedLink/AdvancedLinkFactory"; @@ -42,6 +44,8 @@ const addNode = (block: IBlock) => { patterns: (block?.patterns || [""]) as any, message: (block?.message || [""]) as any, starts_conversation: !!block?.starts_conversation, + _hasErrored: false, + _isHighlighted: false, }); node.setPosition(block.position.x, block.position.y); @@ -252,6 +256,7 @@ const VisualEditorProvider: React.FC = ({ children, }) => { const [selectedCategoryId, setSelectedCategoryId] = React.useState(""); + const router = useRouter(); const { mutate: createBlock } = useCreate(EntityType.BLOCK); const createNode = (payload: any) => { payload.position = payload.position || getCentroid(); @@ -267,6 +272,130 @@ const VisualEditorProvider: React.FC = ({ }); }; + async function removeHighlights() { + return new Promise((resolve) => { + if (!engine) { + return; + } + + const nodes = engine.getModel().getNodes() as NodeModel[]; + + nodes.forEach((node) => { + if (node.isHighlighted()) { + node.setHighlighted(false); + node.setSelected(false); + } + if (node.hasErrored()) { + node.setHasErrored(false); + } + }); + + engine.repaintCanvas(); + resolve(true); + }); + } + function isBlockVisibleOnCanvas(block: NodeModel): boolean { + const zoom = engine.getModel().getZoomLevel() / 100; + const canvas = engine.getCanvas(); + const canvasRect = canvas?.getBoundingClientRect(); + + if (!canvasRect) { + return false; + } + + const offsetX = engine.getModel().getOffsetX(); + const offsetY = engine.getModel().getOffsetY(); + const blockX = block.getX() * zoom + offsetX; + const blockY = block.getY() * zoom + offsetY; + const blockScreenX = blockX + (BLOCK_WIDTH * zoom) / 2; + const blockScreenY = blockY + (BLOCK_HEIGHT * zoom) / 2; + + return ( + blockScreenX > 0 && + blockScreenX < canvasRect.width && + blockScreenY > 0 && + blockScreenY < canvasRect.height + ); + } + + function centerBlockInView(block: NodeModel) { + const zoom = engine.getModel().getZoomLevel() / 100; + const canvasRect = engine.getCanvas()?.getBoundingClientRect(); + + if (!canvasRect) { + return; + } + + const centerX = canvasRect.width / 2; + const centerY = canvasRect.height / 2; + const offsetX = centerX - (block.getX() + BLOCK_WIDTH / 2) * zoom; + const offsetY = centerY - (block.getY() + BLOCK_HEIGHT / 2) * zoom; + + engine.getModel().setOffset(offsetX, offsetY); + } + + async function redirectToTriggeredFlow( + currentFlow: string, + triggeredFlow: string, + ) { + if (currentFlow !== triggeredFlow) { + setSelectedCategoryId(triggeredFlow); + } + + const triggeredFlowUrl = `/${RouterType.VISUAL_EDITOR}/flows/${triggeredFlow}`; + + if (window.location.href !== triggeredFlowUrl) { + await router.push(triggeredFlow); + } + } + + async function handleHighlightFlow(payload: any) { + await removeHighlights(); + + await redirectToTriggeredFlow(selectedCategoryId, payload.flowId); + + setTimeout(() => { + const block = engine?.getModel().getNode(payload.blockId) as NodeModel; + + if (!block) { + return; + } + + if (!isBlockVisibleOnCanvas(block)) { + centerBlockInView(block); + } + + block.setSelected(true); + block.setHighlighted(true); + + engine.repaintCanvas(); + }, 200); + } + + async function handleHighlightErroredBlock(payload: any) { + await removeHighlights(); + + await redirectToTriggeredFlow(selectedCategoryId, payload.flowId); + setTimeout(() => { + const block = engine?.getModel().getNode(payload.blockId) as NodeModel; + + if (!block) { + return; + } + + if (!isBlockVisibleOnCanvas(block)) { + centerBlockInView(block); + } + + block.setHasErrored(true); + + engine.repaintCanvas(); + }, 220); + } + useSubscribe("highlight:flow", handleHighlightFlow); + + useSubscribe("highlight:error", handleHighlightErroredBlock); + return ( { + listener: ListenerHandle | undefined; config: { type: TBlock; color: string; @@ -253,15 +255,30 @@ class NodeWidget extends React.Component< this.config = getBlockConfig(this.props.node.message as any); } + componentDidMount() { + this.listener = this.props.node.registerListener({ + stateChanged: () => this.forceUpdate(), + }); + } + + componentWillUnmount() { + if (this.listener) { + this.listener.deregister(); + } + } + render() { const { t, i18n, tReady } = this.props; + const selectedStyling = clsx( + "custom-node", + this.props.node.isSelected() ? "selected" : "", + this.props.node.isHighlighted() ? "high-lighted" : "", + this.props.node.hasErrored() ? "high-light-error" : "", + ); return (
{ const [connected, setConnected] = useState(false); const { toast } = useToast(); const { user } = useAuth(); + // todo: fix we aren't sending auth token const socket = useMemo(() => new SocketIoClient(apiUrl), [apiUrl]); useEffect(() => { From c9b2cb62382e3f6e8c891fd7c6bf0b321c4c6b9a Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Mon, 7 Apr 2025 17:03:21 +0100 Subject: [PATCH 03/10] fix: typo --- frontend/src/components/visual-editor/hooks/useVisualEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx b/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx index 453641504..8ae6dbaae 100644 --- a/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx +++ b/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx @@ -392,7 +392,7 @@ const VisualEditorProvider: React.FC = ({ engine.repaintCanvas(); }, 220); } - useSubscribe("highlight:flow", handleHighlightFlow); + useSubscribe("highlight:block", handleHighlightFlow); useSubscribe("highlight:error", handleHighlightErroredBlock); From 6e16308db1af818ab782a55aa4ca34a7449e6dfc Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Mon, 7 Apr 2025 17:21:54 +0100 Subject: [PATCH 04/10] fix: redirection url typo --- frontend/src/components/visual-editor/hooks/useVisualEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx b/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx index 8ae6dbaae..790f9fec9 100644 --- a/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx +++ b/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx @@ -345,7 +345,7 @@ const VisualEditorProvider: React.FC = ({ const triggeredFlowUrl = `/${RouterType.VISUAL_EDITOR}/flows/${triggeredFlow}`; if (window.location.href !== triggeredFlowUrl) { - await router.push(triggeredFlow); + await router.push(triggeredFlowUrl); } } From 60e72dbf0ec626bc3e6f9d51274a6f427714c9c6 Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Mon, 7 Apr 2025 18:43:28 +0100 Subject: [PATCH 05/10] feat: broadcast to a specific room instead of broadcasting --- api/src/chat/services/block.service.ts | 46 +++++++++++++++++-- api/src/chat/services/bot.service.ts | 4 +- api/src/websocket/websocket.gateway.ts | 10 +--- .../components/visual-editor/v2/Diagrams.tsx | 4 ++ frontend/src/websocket/socket-hooks.tsx | 6 ++- 5 files changed, 54 insertions(+), 16 deletions(-) diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 06dfb3c9d..77136830d 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -6,7 +6,11 @@ * 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 { Injectable } from '@nestjs/common'; +import { + Injectable, + InternalServerErrorException, + Optional, +} from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import EventWrapper from '@/channel/lib/EventWrapper'; @@ -21,6 +25,15 @@ import { PluginType } from '@/plugins/types'; import { SettingService } from '@/setting/services/setting.service'; import { BaseService } from '@/utils/generics/base-service'; import { getRandomElement } from '@/utils/helpers/safeRandom'; +import { + SocketGet, + SocketPost, +} from '@/websocket/decorators/socket-method.decorator'; +import { SocketReq } from '@/websocket/decorators/socket-req.decorator'; +import { SocketRes } from '@/websocket/decorators/socket-res.decorator'; +import { SocketRequest } from '@/websocket/utils/socket-request'; +import { SocketResponse } from '@/websocket/utils/socket-response'; +import { WebsocketGateway } from '@/websocket/websocket.gateway'; import { BlockDto } from '../dto/block.dto'; import { EnvelopeFactory } from '../helpers/envelope-factory'; @@ -46,6 +59,8 @@ export class BlockService extends BaseService< BlockFull, BlockDto > { + private readonly gateway: WebsocketGateway; + constructor( readonly repository: BlockRepository, private readonly contentService: ContentService, @@ -53,8 +68,31 @@ export class BlockService extends BaseService< private readonly pluginService: PluginService, protected readonly i18n: I18nService, protected readonly languageService: LanguageService, + @Optional() gateway?: WebsocketGateway, ) { super(repository); + if (gateway) { + this.gateway = gateway; + } + } + + @SocketGet('/block/subscribe/') + @SocketPost('/block/subscribe/') + subscribe(@SocketReq() req: SocketRequest, @SocketRes() res: SocketResponse) { + debugger; + try { + if (req.session.web?.profile?.id) { + this.gateway.io.socketsJoin(`blocks:${req.session.web.profile.id}`); + return res.status(200).json({ + success: true, + }); + } else { + throw new Error('Unable to join highlight blocks room'); + } + } catch (e) { + this.logger.error('Websocket subscription', e); + throw new InternalServerErrorException(e); + } } /** @@ -577,7 +615,7 @@ export class BlockService extends BaseService< this.logger.log('triggered: hook:highlight:error'); this.eventEmitter.emit('hook:highlight:error', { flowId, - userId: recipient.foreign_id, + userId: recipient.id, blockId: block.id, }); } else { @@ -641,7 +679,7 @@ export class BlockService extends BaseService< this.logger.log('triggered: hook:highlight:error'); this.eventEmitter.emit('hook:highlight:error', { flowId, - userId: recipient.foreign_id, + userId: recipient.id, blockId: block.id, }); } else { @@ -678,7 +716,7 @@ export class BlockService extends BaseService< this.logger.log('triggered: hook:highlight:error'); this.eventEmitter.emit('hook:highlight:error', { flowId, - userId: recipient.foreign_id, + userId: recipient.id, blockId: block.id, }); } else { diff --git a/api/src/chat/services/bot.service.ts b/api/src/chat/services/bot.service.ts index 392a074ef..8acbe11fd 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -154,7 +154,7 @@ export class BotService { this.eventEmitter.emit('hook:highlight:block', { flowId: block.category!.id, blockId: block.id, - userId: recipient.foreign_id, + userId: recipient.id, }); if (envelope.format !== OutgoingMessageFormat.system) { await this.sendMessageToSubscriber( @@ -330,7 +330,7 @@ export class BotService { if (next && next.id !== fallbackBlock?.id) { this.eventEmitter.emit('hook:highlight:error', { flowId: matchedBlock!.category!.id, - userId: convo.sender.foreign_id, + userId: convo.sender.id, blockId: next.id!, }); } diff --git a/api/src/websocket/websocket.gateway.ts b/api/src/websocket/websocket.gateway.ts index 9141e4411..51b61fae8 100644 --- a/api/src/websocket/websocket.gateway.ts +++ b/api/src/websocket/websocket.gateway.ts @@ -413,12 +413,7 @@ export class WebsocketGateway async handleHighlightBlock( payload: IHookOperationMap['highlight']['operations']['block'], ) { - this.logger.log( - 'broadcasting event highlight:flow through socketio ', - payload, - ); - // todo: fix emit event to subscriber - this.io.emit('highlight:flow', payload); + this.io.to(`blocks:${payload.userId}`).emit('highlight:block', payload); } @OnEvent('hook:highlight:error') @@ -426,7 +421,6 @@ export class WebsocketGateway payload: IHookOperationMap['highlight']['operations']['error'], ) { this.logger.warn('hook:highlight:error ', payload); - // todo: fix emit event to subscriber - this.io.emit('highlight:error', payload); + this.io.to(`blocks:${payload.userId}`).emit('highlight:error', payload); } } diff --git a/frontend/src/components/visual-editor/v2/Diagrams.tsx b/frontend/src/components/visual-editor/v2/Diagrams.tsx index e0588e49e..49b8fdb61 100644 --- a/frontend/src/components/visual-editor/v2/Diagrams.tsx +++ b/frontend/src/components/visual-editor/v2/Diagrams.tsx @@ -52,6 +52,7 @@ import { useTranslate } from "@/hooks/useTranslate"; import { EntityType, Format, QueryType, RouterType } from "@/services/types"; import { IBlock } from "@/types/block.types"; import { BlockPorts } from "@/types/visual-editor.types"; +import { useSocketGetQuery } from "@/websocket/socket-hooks"; import { BlockEditFormDialog } from "../BlockEditFormDialog"; import { ZOOM_LEVEL } from "../constants"; @@ -161,6 +162,9 @@ const Diagrams = () => { const getBlockFromCache = useGetFromCache(EntityType.BLOCK); const updateCachedBlock = useUpdateCache(EntityType.BLOCK); const deleteCachedBlock = useDeleteFromCache(EntityType.BLOCK); + + useSocketGetQuery("/block/subscribe/"); + const onCategoryChange = (targetCategory: number) => { if (categories) { const { id } = categories[targetCategory]; diff --git a/frontend/src/websocket/socket-hooks.tsx b/frontend/src/websocket/socket-hooks.tsx index 1ea78df46..b56a453f0 100644 --- a/frontend/src/websocket/socket-hooks.tsx +++ b/frontend/src/websocket/socket-hooks.tsx @@ -41,8 +41,10 @@ export const SocketProvider = (props: PropsWithChildren) => { const [connected, setConnected] = useState(false); const { toast } = useToast(); const { user } = useAuth(); - // todo: fix we aren't sending auth token - const socket = useMemo(() => new SocketIoClient(apiUrl), [apiUrl]); + const socket = useMemo( + () => new SocketIoClient(apiUrl, { auth: user }), + [apiUrl], + ); useEffect(() => { if (user && apiUrl) From e25c836acf62cb69261969a673b2c5d7cdce3631 Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Tue, 8 Apr 2025 08:22:18 +0100 Subject: [PATCH 06/10] fix: minor improvements --- .../visual-editor/hooks/useVisualEditor.tsx | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx b/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx index 790f9fec9..e1752bd3b 100644 --- a/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx +++ b/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx @@ -349,13 +349,19 @@ const VisualEditorProvider: React.FC = ({ } } - async function handleHighlightFlow(payload: any) { + async function handleHighlightFlow(payload: { + flowId: string; + blockId: string; + userId: string; + }) { await removeHighlights(); await redirectToTriggeredFlow(selectedCategoryId, payload.flowId); setTimeout(() => { - const block = engine?.getModel().getNode(payload.blockId) as NodeModel; + const block = engine?.getModel().getNode(payload.blockId) as + | NodeModel + | undefined; if (!block) { return; @@ -372,12 +378,18 @@ const VisualEditorProvider: React.FC = ({ }, 200); } - async function handleHighlightErroredBlock(payload: any) { + async function handleHighlightErroredBlock(payload: { + flowId: string; + blockId: string; + userId: string; + }) { await removeHighlights(); await redirectToTriggeredFlow(selectedCategoryId, payload.flowId); setTimeout(() => { - const block = engine?.getModel().getNode(payload.blockId) as NodeModel; + const block = engine?.getModel().getNode(payload.blockId) as + | NodeModel + | undefined; if (!block) { return; @@ -390,7 +402,7 @@ const VisualEditorProvider: React.FC = ({ block.setHasErrored(true); engine.repaintCanvas(); - }, 220); + }, 200); } useSubscribe("highlight:block", handleHighlightFlow); From 8790b6fab80b65c5cf2d61e8714bf432c82d8ae0 Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Tue, 8 Apr 2025 08:35:11 +0100 Subject: [PATCH 07/10] fix: remove debugger --- api/src/chat/services/block.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 77136830d..1a34ffbca 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -79,7 +79,6 @@ export class BlockService extends BaseService< @SocketGet('/block/subscribe/') @SocketPost('/block/subscribe/') subscribe(@SocketReq() req: SocketRequest, @SocketRes() res: SocketResponse) { - debugger; try { if (req.session.web?.profile?.id) { this.gateway.io.socketsJoin(`blocks:${req.session.web.profile.id}`); From b2c4335526e79f6ac56c35665a336a06f409871d Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Wed, 9 Apr 2025 16:56:09 +0100 Subject: [PATCH 08/10] fix: minor enhancement --- api/src/chat/services/block.service.ts | 5 ++++- api/src/websocket/websocket.gateway.ts | 3 ++- frontend/src/websocket/socket-hooks.tsx | 5 +---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 1a34ffbca..5471f33e9 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -81,11 +81,14 @@ export class BlockService extends BaseService< subscribe(@SocketReq() req: SocketRequest, @SocketRes() res: SocketResponse) { try { if (req.session.web?.profile?.id) { - this.gateway.io.socketsJoin(`blocks:${req.session.web.profile.id}`); + const room = `blocks:${req.session.web.profile.id}`; + this.gateway.io.socketsJoin(room); + this.logger.log('Subscribed to socket room', room); return res.status(200).json({ success: true, }); } else { + this.logger.error('Unable to subscribe to highlight blocks room'); throw new Error('Unable to join highlight blocks room'); } } catch (e) { diff --git a/api/src/websocket/websocket.gateway.ts b/api/src/websocket/websocket.gateway.ts index 51b61fae8..f97b50ea0 100644 --- a/api/src/websocket/websocket.gateway.ts +++ b/api/src/websocket/websocket.gateway.ts @@ -413,6 +413,7 @@ export class WebsocketGateway async handleHighlightBlock( payload: IHookOperationMap['highlight']['operations']['block'], ) { + this.logger.log('highlighting block', payload); this.io.to(`blocks:${payload.userId}`).emit('highlight:block', payload); } @@ -420,7 +421,7 @@ export class WebsocketGateway async highlightBlockErrored( payload: IHookOperationMap['highlight']['operations']['error'], ) { - this.logger.warn('hook:highlight:error ', payload); + this.logger.warn('hook:highlight:error', payload); this.io.to(`blocks:${payload.userId}`).emit('highlight:error', payload); } } diff --git a/frontend/src/websocket/socket-hooks.tsx b/frontend/src/websocket/socket-hooks.tsx index b56a453f0..71b370e03 100644 --- a/frontend/src/websocket/socket-hooks.tsx +++ b/frontend/src/websocket/socket-hooks.tsx @@ -41,10 +41,7 @@ export const SocketProvider = (props: PropsWithChildren) => { const [connected, setConnected] = useState(false); const { toast } = useToast(); const { user } = useAuth(); - const socket = useMemo( - () => new SocketIoClient(apiUrl, { auth: user }), - [apiUrl], - ); + const socket = useMemo(() => new SocketIoClient(apiUrl), [apiUrl]); useEffect(() => { if (user && apiUrl) From 6ee59d450a3fe7ccf396aaa189b19634211af0d0 Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Mon, 5 May 2025 17:20:59 +0100 Subject: [PATCH 09/10] feat: add setting to enable/disable highlight block & fix logic to join socketio room --- .../chat/controllers/block.controller.spec.ts | 6 +++ .../controllers/subscriber.controller.spec.ts | 43 +++++++++++++++++++ api/src/chat/services/block.service.spec.ts | 6 +++ api/src/chat/services/block.service.ts | 39 ++++++++++++----- api/src/setting/seeds/setting.seed-model.ts | 7 +++ api/src/setting/services/setting.service.ts | 5 +++ .../user/controllers/auth.controller.spec.ts | 8 ++++ api/src/websocket/websocket.gateway.spec.ts | 38 +++++++++++++++- api/src/websocket/websocket.gateway.ts | 15 ++++++- .../public/locales/en/chatbot_settings.json | 6 ++- .../public/locales/fr/chatbot_settings.json | 6 ++- 11 files changed, 160 insertions(+), 19 deletions(-) diff --git a/api/src/chat/controllers/block.controller.spec.ts b/api/src/chat/controllers/block.controller.spec.ts index 24945c3de..3ca872d79 100644 --- a/api/src/chat/controllers/block.controller.spec.ts +++ b/api/src/chat/controllers/block.controller.spec.ts @@ -49,12 +49,15 @@ import { BlockCreateDto, BlockUpdateDto } from '../dto/block.dto'; import { BlockRepository } from '../repositories/block.repository'; import { CategoryRepository } from '../repositories/category.repository'; import { LabelRepository } from '../repositories/label.repository'; +import { SubscriberRepository } from '../repositories/subscriber.repository'; import { Block, BlockModel } from '../schemas/block.schema'; import { LabelModel } from '../schemas/label.schema'; +import { SubscriberModel } from '../schemas/subscriber.schema'; import { PayloadType } from '../schemas/types/button'; import { BlockService } from '../services/block.service'; import { CategoryService } from '../services/category.service'; import { LabelService } from '../services/label.service'; +import { SubscriberService } from '../services/subscriber.service'; import { Category, CategoryModel } from './../schemas/category.schema'; import { BlockController } from './block.controller'; @@ -93,6 +96,7 @@ describe('BlockController', () => { RoleModel, PermissionModel, LanguageModel, + SubscriberModel, ]), ], providers: [ @@ -116,6 +120,8 @@ describe('BlockController', () => { PermissionService, LanguageService, PluginService, + SubscriberService, + SubscriberRepository, { provide: I18nService, useValue: { diff --git a/api/src/chat/controllers/subscriber.controller.spec.ts b/api/src/chat/controllers/subscriber.controller.spec.ts index 32361be8b..0e638e5c7 100644 --- a/api/src/chat/controllers/subscriber.controller.spec.ts +++ b/api/src/chat/controllers/subscriber.controller.spec.ts @@ -6,11 +6,19 @@ * 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 { CACHE_MANAGER, CacheModule } from '@nestjs/cache-manager'; +import { EventEmitterModule } from '@nestjs/event-emitter'; import { MongooseModule } from '@nestjs/mongoose'; import { AttachmentRepository } from '@/attachment/repositories/attachment.repository'; import { AttachmentModel } from '@/attachment/schemas/attachment.schema'; import { AttachmentService } from '@/attachment/services/attachment.service'; +import { LoggerModule } from '@/logger/logger.module'; +import { SettingRepository } from '@/setting/repositories/setting.repository'; +import { SettingModel } from '@/setting/schemas/setting.schema'; +import { SettingSeeder } from '@/setting/seeds/setting.seed'; +import { SettingService } from '@/setting/services/setting.service'; +import { SettingModule } from '@/setting/setting.module'; import { InvitationRepository } from '@/user/repositories/invitation.repository'; import { RoleRepository } from '@/user/repositories/role.repository'; import { UserRepository } from '@/user/repositories/user.repository'; @@ -57,6 +65,29 @@ describe('SubscriberController', () => { const { getMocks } = await buildTestingMocks({ controllers: [SubscriberController], imports: [ + CacheModule.register({ + isGlobal: true, + ttl: 60 * 1000, + max: 100, + }), + EventEmitterModule.forRoot({ + // set this to `true` to use wildcards + wildcard: true, + // the delimiter used to segment namespaces + delimiter: ':', + // set this to `true` if you want to emit the newListener event + newListener: false, + // set this to `true` if you want to emit the removeListener event + removeListener: false, + // the maximum amount of listeners that can be assigned to an event + maxListeners: 10, + // show event name in memory leak message when more than maximum amount of listeners is assigned + verboseMemoryLeak: false, + // disable throwing uncaughtException if an error event is emitted and it has no listeners + ignoreErrors: false, + }), + LoggerModule, + SettingModule, rootMongooseTestModule(installSubscriberFixtures), MongooseModule.forFeature([ SubscriberModel, @@ -66,6 +97,7 @@ describe('SubscriberController', () => { InvitationModel, PermissionModel, AttachmentModel, + SettingModel, ]), ], providers: [ @@ -82,6 +114,17 @@ describe('SubscriberController', () => { InvitationRepository, AttachmentService, AttachmentRepository, + SettingService, + SettingSeeder, + SettingRepository, + { + provide: CACHE_MANAGER, + useValue: { + del: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, + }, ], }); [subscriberService, labelService, userService, subscriberController] = diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index ea4265fb3..275555bf5 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -56,15 +56,18 @@ import { import { buildTestingMocks } from '@/utils/test/utils'; import { BlockRepository } from '../repositories/block.repository'; +import { SubscriberRepository } from '../repositories/subscriber.repository'; import { Block, BlockModel } from '../schemas/block.schema'; import { Category, CategoryModel } from '../schemas/category.schema'; import { LabelModel } from '../schemas/label.schema'; +import { SubscriberModel } from '../schemas/subscriber.schema'; import { FileType } from '../schemas/types/attachment'; import { StdOutgoingListMessage } from '../schemas/types/message'; import { CategoryRepository } from './../repositories/category.repository'; import { BlockService } from './block.service'; import { CategoryService } from './category.service'; +import { SubscriberService } from './subscriber.service'; describe('BlockService', () => { let blockRepository: BlockRepository; @@ -91,6 +94,7 @@ describe('BlockService', () => { AttachmentModel, LabelModel, LanguageModel, + SubscriberModel, ]), ], providers: [ @@ -106,6 +110,8 @@ describe('BlockService', () => { ContentService, AttachmentService, LanguageService, + SubscriberService, + SubscriberRepository, { provide: PluginService, useValue: {}, diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 5471f33e9..0e3a04abf 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -52,6 +52,8 @@ import { NlpPattern, PayloadPattern } from '../schemas/types/pattern'; import { Payload, StdQuickReply } from '../schemas/types/quick-reply'; import { SubscriberContext } from '../schemas/types/subscriberContext'; +import { SubscriberService } from './subscriber.service'; + @Injectable() export class BlockService extends BaseService< Block, @@ -68,6 +70,7 @@ export class BlockService extends BaseService< private readonly pluginService: PluginService, protected readonly i18n: I18nService, protected readonly languageService: LanguageService, + private subscriberService: SubscriberService, @Optional() gateway?: WebsocketGateway, ) { super(repository); @@ -78,19 +81,33 @@ export class BlockService extends BaseService< @SocketGet('/block/subscribe/') @SocketPost('/block/subscribe/') - subscribe(@SocketReq() req: SocketRequest, @SocketRes() res: SocketResponse) { + async subscribe( + @SocketReq() req: SocketRequest, + @SocketRes() res: SocketResponse, + ) { try { - if (req.session.web?.profile?.id) { - const room = `blocks:${req.session.web.profile.id}`; - this.gateway.io.socketsJoin(room); - this.logger.log('Subscribed to socket room', room); - return res.status(200).json({ - success: true, - }); - } else { - this.logger.error('Unable to subscribe to highlight blocks room'); - throw new Error('Unable to join highlight blocks room'); + const subscriberForeignId = req.session.passport?.user?.id; + if (!subscriberForeignId) { + this.logger.warn('Missing subscriber foreign ID in session'); + throw new Error('Invalid session or user not authenticated'); } + + const subscriber = await this.subscriberService.findOne({ + foreign_id: subscriberForeignId, + }); + + if (!subscriber) { + this.logger.warn( + `Subscriber not found for foreign ID ${subscriberForeignId}`, + ); + throw new Error('Subscriber not found'); + } + const room = `blocks:${subscriber.id}`; + this.gateway.io.socketsJoin(room); + this.logger.log('Subscribed to socket room', room); + return res.status(200).json({ + success: true, + }); } catch (e) { this.logger.error('Websocket subscription', e); throw new InternalServerErrorException(e); diff --git a/api/src/setting/seeds/setting.seed-model.ts b/api/src/setting/seeds/setting.seed-model.ts index 27cf57852..a7ff54ad2 100644 --- a/api/src/setting/seeds/setting.seed-model.ts +++ b/api/src/setting/seeds/setting.seed-model.ts @@ -85,6 +85,13 @@ export const DEFAULT_SETTINGS = [ weight: 6, translatable: true, }, + { + group: 'chatbot_settings', + label: 'enable_debug', + value: false, + type: SettingType.checkbox, + weight: 7, + }, { group: 'contact', label: 'contact_email_recipient', diff --git a/api/src/setting/services/setting.service.ts b/api/src/setting/services/setting.service.ts index 1f4d5cad4..0def85c04 100644 --- a/api/src/setting/services/setting.service.ts +++ b/api/src/setting/services/setting.service.ts @@ -164,4 +164,9 @@ export class SettingService extends BaseService { const settings = await this.findAll(); return this.buildTree(settings); } + + public async isHighlightEnabled(): Promise { + const settings = await this.getSettings(); + return settings.chatbot_settings.enable_debug ?? false; + } } diff --git a/api/src/user/controllers/auth.controller.spec.ts b/api/src/user/controllers/auth.controller.spec.ts index 0ddd26a1a..eb887969f 100644 --- a/api/src/user/controllers/auth.controller.spec.ts +++ b/api/src/user/controllers/auth.controller.spec.ts @@ -24,6 +24,10 @@ import { LanguageRepository } from '@/i18n/repositories/language.repository'; import { LanguageModel } from '@/i18n/schemas/language.schema'; import { I18nService } from '@/i18n/services/i18n.service'; import { LanguageService } from '@/i18n/services/language.service'; +import { SettingRepository } from '@/setting/repositories/setting.repository'; +import { SettingModel } from '@/setting/schemas/setting.schema'; +import { SettingSeeder } from '@/setting/seeds/setting.seed'; +import { SettingService } from '@/setting/services/setting.service'; import { getRandom } from '@/utils/helpers/safeRandom'; import { installLanguageFixtures } from '@/utils/test/fixtures/language'; import { installUserFixtures } from '@/utils/test/fixtures/user'; @@ -76,6 +80,7 @@ describe('AuthController', () => { InvitationModel, AttachmentModel, LanguageModel, + SettingModel, ]), ], providers: [ @@ -94,6 +99,9 @@ describe('AuthController', () => { LanguageRepository, LanguageService, JwtService, + SettingService, + SettingSeeder, + SettingRepository, { provide: MailerService, useValue: { diff --git a/api/src/websocket/websocket.gateway.spec.ts b/api/src/websocket/websocket.gateway.spec.ts index d64e3a3b9..fe0b2fe74 100644 --- a/api/src/websocket/websocket.gateway.spec.ts +++ b/api/src/websocket/websocket.gateway.spec.ts @@ -6,10 +6,18 @@ * 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 { CacheModule } from '@nestjs/cache-manager'; import { INestApplication } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { MongooseModule } from '@nestjs/mongoose'; import { Socket, io } from 'socket.io-client'; +import { LoggerModule } from '@/logger/logger.module'; +import { SettingRepository } from '@/setting/repositories/setting.repository'; +import { SettingModel } from '@/setting/schemas/setting.schema'; +import { SettingSeeder } from '@/setting/seeds/setting.seed'; +import { SettingService } from '@/setting/services/setting.service'; +import { SettingModule } from '@/setting/setting.module'; import { closeInMongodConnection, rootMongooseTestModule, @@ -29,15 +37,41 @@ describe('WebsocketGateway', () => { const { module } = await buildTestingMocks({ providers: [ WebsocketGateway, - EventEmitter2, + SettingService, + SettingSeeder, + SettingRepository, SocketEventDispatcherService, ], imports: [ + CacheModule.register({ + isGlobal: true, + ttl: 60 * 1000, + max: 100, + }), + EventEmitterModule.forRoot({ + // set this to `true` to use wildcards + wildcard: true, + // the delimiter used to segment namespaces + delimiter: ':', + // set this to `true` if you want to emit the newListener event + newListener: false, + // set this to `true` if you want to emit the removeListener event + removeListener: false, + // the maximum amount of listeners that can be assigned to an event + maxListeners: 10, + // show event name in memory leak message when more than maximum amount of listeners is assigned + verboseMemoryLeak: false, + // disable throwing uncaughtException if an error event is emitted and it has no listeners + ignoreErrors: false, + }), + LoggerModule, + SettingModule, rootMongooseTestModule(({ uri, dbName }) => { process.env.MONGO_URI = uri; process.env.MONGO_DB = dbName; return Promise.resolve(); }), + MongooseModule.forFeature([SettingModel]), ], }); app = module.createNestApplication(); diff --git a/api/src/websocket/websocket.gateway.ts b/api/src/websocket/websocket.gateway.ts index f97b50ea0..5cbd4c22e 100644 --- a/api/src/websocket/websocket.gateway.ts +++ b/api/src/websocket/websocket.gateway.ts @@ -39,6 +39,7 @@ import { config } from '@/config'; import { LoggerService } from '@/logger/logger.service'; import { getSessionStore } from '@/utils/constants/session-store'; +import { SettingService } from './../setting/services/setting.service'; import { IOIncomingMessage, IOMessagePipe } from './pipes/io-message.pipe'; import { SocketEventDispatcherService } from './services/socket-event-dispatcher.service'; import { Room } from './types'; @@ -54,6 +55,7 @@ export class WebsocketGateway private readonly logger: LoggerService, private readonly eventEmitter: EventEmitter2, private readonly socketEventDispatcherService: SocketEventDispatcherService, + private settingService: SettingService, ) {} @WebSocketServer() io: Server; @@ -413,15 +415,24 @@ export class WebsocketGateway async handleHighlightBlock( payload: IHookOperationMap['highlight']['operations']['block'], ) { - this.logger.log('highlighting block', payload); + const isHighlightEnabled = await this.settingService.isHighlightEnabled(); + if (!isHighlightEnabled) { + return; + } this.io.to(`blocks:${payload.userId}`).emit('highlight:block', payload); + this.logger.log('highlighting block', payload); } @OnEvent('hook:highlight:error') async highlightBlockErrored( payload: IHookOperationMap['highlight']['operations']['error'], ) { - this.logger.warn('hook:highlight:error', payload); + const isHighlightEnabled = await this.settingService.isHighlightEnabled(); + if (!isHighlightEnabled) { + return; + } + this.io.to(`blocks:${payload.userId}`).emit('highlight:error', payload); + this.logger.warn('hook:highlight:error', payload); } } diff --git a/frontend/public/locales/en/chatbot_settings.json b/frontend/public/locales/en/chatbot_settings.json index 170af9441..c55e87a24 100644 --- a/frontend/public/locales/en/chatbot_settings.json +++ b/frontend/public/locales/en/chatbot_settings.json @@ -8,13 +8,15 @@ "fallback_block": "Fallback Block", "default_nlu_helper": "Default NLU Helper", "default_llm_helper": "Default LLM Helper", - "default_storage_helper": "Default Storage Helper" + "default_storage_helper": "Default Storage Helper", + "enable_debug": "Enable flow debugger?" }, "help": { "global_fallback": "Global fallback allows you to send custom messages when user entry does not match any of the block messages.", "fallback_message": "If no fallback block is selected, then one of these messages will be sent.", "default_nlu_helper": "The NLU helper is responsible for processing and understanding user inputs, including tasks like intent prediction, language detection, and entity recognition.", "default_llm_helper": "The LLM helper leverages advanced generative AI to perform tasks such as text generation, chat completion, and complex query responses.", - "default_storage_helper": "The storage helper defines where to store attachment files. By default, the default local storage helper stores them locally, but you can choose to use Minio or any other storage solution." + "default_storage_helper": "The storage helper defines where to store attachment files. By default, the default local storage helper stores them locally, but you can choose to use Minio or any other storage solution.", + "enable_debug": "When enabled, this highlights which blocks were executed or failed during a flow run, helping you visually debug the flow logic in real time." } } diff --git a/frontend/public/locales/fr/chatbot_settings.json b/frontend/public/locales/fr/chatbot_settings.json index 4b1ed4201..009a7074d 100644 --- a/frontend/public/locales/fr/chatbot_settings.json +++ b/frontend/public/locales/fr/chatbot_settings.json @@ -8,13 +8,15 @@ "fallback_block": "Bloc de secours", "default_nlu_helper": "Utilitaire NLU par défaut", "default_llm_helper": "Utilitaire LLM par défaut", - "default_storage_helper": "Utilitaire de stockage par défaut" + "default_storage_helper": "Utilitaire de stockage par défaut", + "enable_debug": "Activer la débogueur de flux?" }, "help": { "global_fallback": "La réponse de secours globale vous permet d'envoyer des messages personnalisés lorsque l'entrée de l'utilisateur ne correspond à aucun des messages des blocs.", "fallback_message": "Si aucun bloc de secours n'est sélectionné, l'un de ces messages sera envoyé.", "default_nlu_helper": "Utilitaire du traitement et de la compréhension des entrées des utilisateurs, incluant des tâches telles que la prédiction d'intention, la détection de langue et la reconnaissance d'entités.", "default_llm_helper": "Utilitaire responsable de l'intelligence artificielle générative avancée pour effectuer des tâches telles que la génération de texte, la complétion de chat et les réponses à des requêtes complexes.", - "default_storage_helper": "Utilitaire de stockage définit l'emplacement où stocker les fichiers joints. Par défaut, le stockage local les conserve localement, mais vous pouvez choisir d'utiliser Minio ou toute autre solution de stockage." + "default_storage_helper": "Utilitaire de stockage définit l'emplacement où stocker les fichiers joints. Par défaut, le stockage local les conserve localement, mais vous pouvez choisir d'utiliser Minio ou toute autre solution de stockage.", + "enable_debug": "Lorsqu’il est activé, cela met en surbrillance les blocs qui ont été exécutés ou ont échoué pendant l’exécution d’un flux, ce qui vous aide à déboguer visuellement la logique du flux en temps réel." } } From 8ba83882d4338ee2b057720c82a2cc2cbc718ed3 Mon Sep 17 00:00:00 2001 From: abdou6666 Date: Tue, 6 May 2025 15:55:15 +0100 Subject: [PATCH 10/10] fix: refactor highlight feature & introduce highlight local fallback block --- api/src/chat/services/block.service.ts | 64 ++++---------- api/src/chat/services/bot.service.ts | 14 ++-- api/src/websocket/websocket.gateway.spec.ts | 6 +- api/src/websocket/websocket.gateway.ts | 35 ++++---- api/src/websocket/websocket.module.ts | 20 ++++- api/types/event-emitter.d.ts | 9 +- .../visual-editor/hooks/useVisualEditor.tsx | 83 ++++++++----------- .../v2/CustomDiagramNodes/NodeModel.tsx | 27 ++++-- .../v2/CustomDiagramNodes/NodeWidget.tsx | 1 + frontend/src/styles/visual-editor.css | 29 +++++++ 10 files changed, 155 insertions(+), 133 deletions(-) diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 0e3a04abf..d7f400099 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -624,22 +624,12 @@ export class BlockService extends BaseService< const attachmentPayload = blockMessage.attachment.payload; if (!('id' in attachmentPayload)) { this.checkDeprecatedAttachmentUrl(block); - - const flowId = - typeof block.category === 'object' - ? // @ts-expect-error : block always has category - block!.category.id - : block.category; - if (flowId) { - this.logger.log('triggered: hook:highlight:error'); - this.eventEmitter.emit('hook:highlight:error', { - flowId, - userId: recipient.id, - blockId: block.id, - }); - } else { - this.logger.warn('Unable to trigger: hook:highlight:error'); - } + this.logger.log('triggered: hook:highlight:error'); + this.eventEmitter.emit('hook:highlight:block', { + userId: recipient.id, + blockId: block.id, + highlightType: fallback ? 'fallback' : 'error', + }); throw new Error( 'Remote attachments in blocks are no longer supported!', @@ -689,21 +679,12 @@ export class BlockService extends BaseService< }; return envelope; } catch (err) { - const flowId = - typeof block.category === 'object' - ? // @ts-expect-error : block always has category - block!.category.id - : block.category; - if (flowId) { - this.logger.log('triggered: hook:highlight:error'); - this.eventEmitter.emit('hook:highlight:error', { - flowId, - userId: recipient.id, - blockId: block.id, - }); - } else { - this.logger.warn('Unable to trigger: hook:highlight:error'); - } + this.logger.log('highlighting error'); + this.eventEmitter.emit('hook:highlight:block', { + userId: recipient.id, + blockId: block.id, + highlightType: 'error', + }); this.logger.error( 'Unable to retrieve content for list template process', @@ -726,21 +707,12 @@ export class BlockService extends BaseService< return envelope; } catch (e) { - const flowId = - typeof block.category === 'object' - ? // @ts-expect-error : block always has category - block!.category.id - : block.category; - if (flowId) { - this.logger.log('triggered: hook:highlight:error'); - this.eventEmitter.emit('hook:highlight:error', { - flowId, - userId: recipient.id, - blockId: block.id, - }); - } else { - this.logger.warn('Unable to trigger: hook:highlight:error'); - } + this.logger.log('highlighting error'); + this.eventEmitter.emit('hook:highlight:block', { + userId: recipient.id, + blockId: block.id, + highlightType: 'error', + }); this.logger.error('Plugin was unable to load/process ', e); throw new Error(`Unknown plugin - ${JSON.stringify(blockMessage)}`); diff --git a/api/src/chat/services/bot.service.ts b/api/src/chat/services/bot.service.ts index 8acbe11fd..b735e632f 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -152,9 +152,9 @@ export class BotService { convo.id, ); this.eventEmitter.emit('hook:highlight:block', { - flowId: block.category!.id, blockId: block.id, userId: recipient.id, + highlightType: fallback ? 'fallback' : 'highlight', }); if (envelope.format !== OutgoingMessageFormat.system) { await this.sendMessageToSubscriber( @@ -327,13 +327,11 @@ export class BotService { ); await this.triggerBlock(event, updatedConversation, next, fallback); } catch (err) { - if (next && next.id !== fallbackBlock?.id) { - this.eventEmitter.emit('hook:highlight:error', { - flowId: matchedBlock!.category!.id, - userId: convo.sender.id, - blockId: next.id!, - }); - } + this.eventEmitter.emit('hook:highlight:block', { + userId: convo.sender.id, + blockId: next.id!, + highlightType: fallback ? 'fallback' : 'error', + }); this.logger.error('Unable to store context data!', err); return this.eventEmitter.emit('hook:conversation:end', convo); } diff --git a/api/src/websocket/websocket.gateway.spec.ts b/api/src/websocket/websocket.gateway.spec.ts index fe0b2fe74..34a266fd3 100644 --- a/api/src/websocket/websocket.gateway.spec.ts +++ b/api/src/websocket/websocket.gateway.spec.ts @@ -12,6 +12,8 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import { MongooseModule } from '@nestjs/mongoose'; import { Socket, io } from 'socket.io-client'; +import { BlockRepository } from '@/chat/repositories/block.repository'; +import { BlockModel } from '@/chat/schemas/block.schema'; import { LoggerModule } from '@/logger/logger.module'; import { SettingRepository } from '@/setting/repositories/setting.repository'; import { SettingModel } from '@/setting/schemas/setting.schema'; @@ -41,6 +43,7 @@ describe('WebsocketGateway', () => { SettingSeeder, SettingRepository, SocketEventDispatcherService, + BlockRepository, ], imports: [ CacheModule.register({ @@ -64,6 +67,7 @@ describe('WebsocketGateway', () => { // disable throwing uncaughtException if an error event is emitted and it has no listeners ignoreErrors: false, }), + // ChatModule, LoggerModule, SettingModule, rootMongooseTestModule(({ uri, dbName }) => { @@ -71,7 +75,7 @@ describe('WebsocketGateway', () => { process.env.MONGO_DB = dbName; return Promise.resolve(); }), - MongooseModule.forFeature([SettingModel]), + MongooseModule.forFeature([SettingModel, BlockModel]), ], }); app = module.createNestApplication(); diff --git a/api/src/websocket/websocket.gateway.ts b/api/src/websocket/websocket.gateway.ts index 5cbd4c22e..4bd3e7d3a 100644 --- a/api/src/websocket/websocket.gateway.ts +++ b/api/src/websocket/websocket.gateway.ts @@ -28,6 +28,7 @@ import { Session as ExpressSession, SessionData } from 'express-session'; import { Server, Socket } from 'socket.io'; import { sync as uid } from 'uid-safe'; +import { BlockRepository } from '@/chat/repositories/block.repository'; import { MessageFull } from '@/chat/schemas/message.schema'; import { Subscriber, @@ -56,6 +57,7 @@ export class WebsocketGateway private readonly eventEmitter: EventEmitter2, private readonly socketEventDispatcherService: SocketEventDispatcherService, private settingService: SettingService, + private blockRepository: BlockRepository, ) {} @WebSocketServer() io: Server; @@ -412,27 +414,28 @@ export class WebsocketGateway } @OnEvent('hook:highlight:block') - async handleHighlightBlock( - payload: IHookOperationMap['highlight']['operations']['block'], - ) { + async handleHighlightBlock({ + blockId, + highlightType, + userId, + }: IHookOperationMap['highlight']['operations']['block']) { const isHighlightEnabled = await this.settingService.isHighlightEnabled(); if (!isHighlightEnabled) { return; } - this.io.to(`blocks:${payload.userId}`).emit('highlight:block', payload); - this.logger.log('highlighting block', payload); - } - - @OnEvent('hook:highlight:error') - async highlightBlockErrored( - payload: IHookOperationMap['highlight']['operations']['error'], - ) { - const isHighlightEnabled = await this.settingService.isHighlightEnabled(); - if (!isHighlightEnabled) { + const blockFull = await this.blockRepository.findOneAndPopulate(blockId); + if (!blockFull) { + this.logger.warn( + `Unable to find block to highligh with ${highlightType}`, + blockId, + ); return; } - - this.io.to(`blocks:${payload.userId}`).emit('highlight:error', payload); - this.logger.warn('hook:highlight:error', payload); + this.io.to(`blocks:${userId}`).emit('highlight:block', { + flowId: blockFull.category?.id, + blockId: blockFull.id, + highlightType, + }); + this.logger.log(`highlighting block ${highlightType}`, { blockId }); } } diff --git a/api/src/websocket/websocket.module.ts b/api/src/websocket/websocket.module.ts index f29083304..0eaa1238b 100644 --- a/api/src/websocket/websocket.module.ts +++ b/api/src/websocket/websocket.module.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 Hexastack. All rights reserved. * * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. @@ -7,13 +7,29 @@ */ import { Global, Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; + +import { BlockRepository } from '@/chat/repositories/block.repository'; +import { BlockModel } from '@/chat/schemas/block.schema'; +import { SettingRepository } from '@/setting/repositories/setting.repository'; +import { SettingModel } from '@/setting/schemas/setting.schema'; +import { SettingSeeder } from '@/setting/seeds/setting.seed'; +import { SettingService } from '@/setting/services/setting.service'; import { SocketEventDispatcherService } from './services/socket-event-dispatcher.service'; import { WebsocketGateway } from './websocket.gateway'; @Global() @Module({ - providers: [WebsocketGateway, SocketEventDispatcherService], + imports: [MongooseModule.forFeature([BlockModel, SettingModel])], + providers: [ + WebsocketGateway, + SocketEventDispatcherService, + BlockRepository, + SettingSeeder, + SettingService, + SettingRepository, + ], exports: [WebsocketGateway], }) export class WebsocketModule {} diff --git a/api/types/event-emitter.d.ts b/api/types/event-emitter.d.ts index 6723d8f76..f8d33467d 100644 --- a/api/types/event-emitter.d.ts +++ b/api/types/event-emitter.d.ts @@ -125,13 +125,8 @@ declare module '@nestjs/event-emitter' { { block: { userId: string; - flowId: string; - blockId: string; - }; - error: { - userId: string; - flowId: string; blockId: string; + highlightType: HighlightType; }; } >; @@ -438,3 +433,5 @@ declare module '@nestjs/event-emitter' { H extends G, >(event: customEvent, options?: OnEventOptions): OnEventMethodDecorator; } + +export type HighlightType = 'highlight' | 'error' | 'fallback'; diff --git a/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx b/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx index e1752bd3b..9f370e1c2 100644 --- a/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx +++ b/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx @@ -46,6 +46,7 @@ const addNode = (block: IBlock) => { starts_conversation: !!block?.starts_conversation, _hasErrored: false, _isHighlighted: false, + _hasFallbacked: false, }); node.setPosition(block.position.x, block.position.y); @@ -272,7 +273,7 @@ const VisualEditorProvider: React.FC = ({ }); }; - async function removeHighlights() { + async function clearHighlights() { return new Promise((resolve) => { if (!engine) { return; @@ -283,14 +284,16 @@ const VisualEditorProvider: React.FC = ({ nodes.forEach((node) => { if (node.isHighlighted()) { node.setHighlighted(false); - node.setSelected(false); } + if (node.hasErrored()) { node.setHasErrored(false); } + if (node.hasFallbacked()) { + node.setHasFallbacked(false); + } }); - engine.repaintCanvas(); resolve(true); }); } @@ -346,68 +349,50 @@ const VisualEditorProvider: React.FC = ({ if (window.location.href !== triggeredFlowUrl) { await router.push(triggeredFlowUrl); + await wait(200); } } - async function handleHighlightFlow(payload: { - flowId: string; - blockId: string; - userId: string; - }) { - await removeHighlights(); - - await redirectToTriggeredFlow(selectedCategoryId, payload.flowId); - - setTimeout(() => { - const block = engine?.getModel().getNode(payload.blockId) as - | NodeModel - | undefined; - - if (!block) { - return; - } - - if (!isBlockVisibleOnCanvas(block)) { - centerBlockInView(block); - } - - block.setSelected(true); - block.setHighlighted(true); - - engine.repaintCanvas(); - }, 200); + function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); } - async function handleHighlightErroredBlock(payload: { + async function handleHighlightFlow(payload: { flowId: string; blockId: string; - userId: string; + highlightType: "highlight" | "error" | "fallback"; }) { - await removeHighlights(); - await redirectToTriggeredFlow(selectedCategoryId, payload.flowId); - setTimeout(() => { - const block = engine?.getModel().getNode(payload.blockId) as - | NodeModel - | undefined; - if (!block) { - return; - } + await clearHighlights(); + const block = engine?.getModel().getNode(payload.blockId) as + | NodeModel + | undefined; - if (!isBlockVisibleOnCanvas(block)) { - centerBlockInView(block); - } + if (!block) { + return; + } + + if (!isBlockVisibleOnCanvas(block)) { + centerBlockInView(block); + } - block.setHasErrored(true); + switch (payload.highlightType) { + case "highlight": + block.setHighlighted(true); + break; + case "error": + block.setHasErrored(true); + break; + case "fallback": + block.setHasFallbacked(true); + break; + } - engine.repaintCanvas(); - }, 200); + block.setSelected(true); } useSubscribe("highlight:block", handleHighlightFlow); - useSubscribe("highlight:error", handleHighlightErroredBlock); - return (