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 393d91603..275555bf5 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 { @@ -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: {}, @@ -429,7 +435,7 @@ describe('BlockService', () => { ...contextBlankInstance, skip: { [blockProductListMock.id]: 0 }, }, - subscriberContextBlankInstance, + subscriber, false, 'conv_id', ); @@ -463,7 +469,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..d7f400099 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'; @@ -39,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, @@ -46,6 +61,8 @@ export class BlockService extends BaseService< BlockFull, BlockDto > { + private readonly gateway: WebsocketGateway; + constructor( readonly repository: BlockRepository, private readonly contentService: ContentService, @@ -53,8 +70,48 @@ 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); + if (gateway) { + this.gateway = gateway; + } + } + + @SocketGet('/block/subscribe/') + @SocketPost('/block/subscribe/') + async subscribe( + @SocketReq() req: SocketRequest, + @SocketRes() res: SocketResponse, + ) { + try { + 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); + } } /** @@ -474,10 +531,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 +624,13 @@ export class BlockService extends BaseService< const attachmentPayload = blockMessage.attachment.payload; if (!('id' in attachmentPayload)) { this.checkDeprecatedAttachmentUrl(block); + 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!', ); @@ -614,6 +679,13 @@ export class BlockService extends BaseService< }; return envelope; } catch (err) { + 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', err, @@ -635,6 +707,13 @@ export class BlockService extends BaseService< return envelope; } catch (e) { + 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 13e6cd576..b735e632f 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', { + blockId: block.id, + userId: recipient.id, + highlightType: fallback ? 'fallback' : 'highlight', + }); if (envelope.format !== OutgoingMessageFormat.system) { await this.sendMessageToSubscriber( envelope, @@ -323,6 +327,11 @@ export class BotService { ); await this.triggerBlock(event, updatedConversation, next, fallback); } catch (err) { + 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); } @@ -522,11 +531,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/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/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.spec.ts b/api/src/websocket/websocket.gateway.spec.ts index d64e3a3b9..34a266fd3 100644 --- a/api/src/websocket/websocket.gateway.spec.ts +++ b/api/src/websocket/websocket.gateway.spec.ts @@ -6,10 +6,20 @@ * 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 { 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'; +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 +39,43 @@ describe('WebsocketGateway', () => { const { module } = await buildTestingMocks({ providers: [ WebsocketGateway, - EventEmitter2, + SettingService, + SettingSeeder, + SettingRepository, SocketEventDispatcherService, + BlockRepository, ], 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, + }), + // ChatModule, + LoggerModule, + SettingModule, rootMongooseTestModule(({ uri, dbName }) => { process.env.MONGO_URI = uri; process.env.MONGO_DB = dbName; return Promise.resolve(); }), + MongooseModule.forFeature([SettingModel, BlockModel]), ], }); app = module.createNestApplication(); diff --git a/api/src/websocket/websocket.gateway.ts b/api/src/websocket/websocket.gateway.ts index f6e1b0f68..4bd3e7d3a 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, @@ -24,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, @@ -35,6 +40,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'; @@ -50,6 +56,8 @@ export class WebsocketGateway private readonly logger: LoggerService, private readonly eventEmitter: EventEmitter2, private readonly socketEventDispatcherService: SocketEventDispatcherService, + private settingService: SettingService, + private blockRepository: BlockRepository, ) {} @WebSocketServer() io: Server; @@ -254,7 +262,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 +412,30 @@ export class WebsocketGateway ); return response.getPromise(); } + + @OnEvent('hook:highlight:block') + async handleHighlightBlock({ + blockId, + highlightType, + userId, + }: IHookOperationMap['highlight']['operations']['block']) { + const isHighlightEnabled = await this.settingService.isHighlightEnabled(); + if (!isHighlightEnabled) { + return; + } + 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:${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 6d9ce2d3c..f8d33467d 100644 --- a/api/types/event-emitter.d.ts +++ b/api/types/event-emitter.d.ts @@ -120,6 +120,16 @@ declare module '@nestjs/event-emitter' { connection: Socket; } >; + highlight: TDefinition< + object, + { + block: { + userId: string; + blockId: string; + highlightType: HighlightType; + }; + } + >; } /* hooks */ @@ -423,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/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." } } diff --git a/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx b/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx index 6fdfba6e6..9f370e1c2 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,9 @@ const addNode = (block: IBlock) => { patterns: (block?.patterns || [""]) as any, message: (block?.message || [""]) as any, starts_conversation: !!block?.starts_conversation, + _hasErrored: false, + _isHighlighted: false, + _hasFallbacked: false, }); node.setPosition(block.position.x, block.position.y); @@ -252,6 +257,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 +273,126 @@ const VisualEditorProvider: React.FC = ({ }); }; + async function clearHighlights() { + return new Promise((resolve) => { + if (!engine) { + return; + } + + const nodes = engine.getModel().getNodes() as NodeModel[]; + + nodes.forEach((node) => { + if (node.isHighlighted()) { + node.setHighlighted(false); + } + + if (node.hasErrored()) { + node.setHasErrored(false); + } + if (node.hasFallbacked()) { + node.setHasFallbacked(false); + } + }); + + 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(triggeredFlowUrl); + await wait(200); + } + } + + function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + async function handleHighlightFlow(payload: { + flowId: string; + blockId: string; + highlightType: "highlight" | "error" | "fallback"; + }) { + await redirectToTriggeredFlow(selectedCategoryId, payload.flowId); + + await clearHighlights(); + const block = engine?.getModel().getNode(payload.blockId) as + | NodeModel + | undefined; + + if (!block) { + return; + } + + if (!isBlockVisibleOnCanvas(block)) { + centerBlockInView(block); + } + + switch (payload.highlightType) { + case "highlight": + block.setHighlighted(true); + break; + case "error": + block.setHasErrored(true); + break; + case "fallback": + block.setHasFallbacked(true); + break; + } + + block.setSelected(true); + } + useSubscribe("highlight:block", handleHighlightFlow); + return ( { + listener: ListenerHandle | undefined; config: { type: TBlock; color: string; @@ -253,15 +255,31 @@ 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" : "", + this.props.node.hasFallbacked() ? "fallback-highlight" : "", + ); return (
{ 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/styles/visual-editor.css b/frontend/src/styles/visual-editor.css index c1ec9d0d3..5afd71f77 100644 --- a/frontend/src/styles/visual-editor.css +++ b/frontend/src/styles/visual-editor.css @@ -34,7 +34,7 @@ } .start-point { - color: #FFF; + color: #fff; position: absolute; top: 50%; left: 50%; @@ -183,7 +183,7 @@ margin-top: 5px; border-radius: 100%; box-shadow: 0 0 8px #0003; - transition: all .4s ease 0s; + transition: all 0.4s ease 0s; } .circle-out-porters { position: absolute; @@ -194,11 +194,11 @@ .circle-porter-in { position: absolute; top: 50%; - transform: translateY(-50%) scale(.75); + transform: translateY(-50%) scale(0.75); left: -12px; } .circle-porter-out { - transform: scale(.75); + transform: scale(0.75); right: -12px; } @@ -211,3 +211,90 @@ cursor: grab; transform: translateY(-50%) scale(1.1); } + +.node:has(.high-lighted) { + box-shadow: 0 0 14px 6px rgba(0, 153, 255, 0.6); + z-index: 10; + transform: scale(1.04); + cursor: grab; + transition: transform 0.3s ease, box-shadow 0.3s ease; + border-radius: 10px; + animation: highlightPulse 0.8s ease-out infinite; +} + +@keyframes highlightPulse { + 0% { + box-shadow: 0 0 0 rgba(0, 153, 255, 0.2); + transform: scale(1); + } + 20% { + box-shadow: 0 0 30px 12px rgba(0, 153, 255, 1); + transform: scale(1.1); + } + 60% { + box-shadow: 0 0 20px 6px rgba(0, 153, 255, 0.7); + transform: scale(1.06); + } + 100% { + box-shadow: 0 0 10px 4px rgba(0, 153, 255, 0.6); + transform: scale(1.04); + } +} + +.node:has(.high-light-error) { + box-shadow: 0 0 14px 6px rgba(255, 0, 0, 0.6); + z-index: 10; + transform: scale(1.04); + cursor: grab; + transition: transform 0.3s ease, box-shadow 0.3s ease; + border-radius: 10px; + animation: highlightPulseError 0.8s ease-out infinite; +} + +@keyframes highlightPulseError { + 0% { + box-shadow: 0 0 0 rgba(255, 0, 0, 0.2); + transform: scale(1); + } + 20% { + box-shadow: 0 0 30px 12px rgba(255, 0, 0, 1); + transform: scale(1.1); + } + 60% { + box-shadow: 0 0 20px 6px rgba(255, 0, 0, 0.7); + transform: scale(1.06); + } + 100% { + box-shadow: 0 0 10px 4px rgba(255, 0, 0, 0.6); + transform: scale(1.04); + } +} + +.node:has(.fallback-highlight) { + box-shadow: 0 0 14px 6px rgba(0, 255, 0, 0.6); + z-index: 10; + transform: scale(1.04); + cursor: grab; + transition: transform 0.3s ease, box-shadow 0.3s ease; + border-radius: 10px; + animation: highlightPulseFallback 0.8s ease-out infinite; +} + +@keyframes highlightPulseFallback { + 0% { + box-shadow: 0 0 0 rgba(0, 255, 0, 0.2); + transform: scale(1); + } + 20% { + box-shadow: 0 0 30px 12px rgba(0, 255, 0, 1); + transform: scale(1.1); + } + 60% { + box-shadow: 0 0 20px 6px rgba(0, 255, 0, 0.7); + transform: scale(1.06); + } + 100% { + box-shadow: 0 0 10px 4px rgba(0, 255, 0, 0.6); + transform: scale(1.04); + } +} diff --git a/frontend/src/websocket/socket-hooks.tsx b/frontend/src/websocket/socket-hooks.tsx index 91ec00578..71b370e03 100644 --- a/frontend/src/websocket/socket-hooks.tsx +++ b/frontend/src/websocket/socket-hooks.tsx @@ -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.