diff --git a/api/package-lock.json b/api/package-lock.json index 92f81953..f0e04971 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -27,6 +27,7 @@ "@resvg/resvg-js": "^2.6.2", "@socket.io/redis-adapter": "^8.3.0", "@tekuconcept/nestjs-csrf": "^1.1.0", + "async-mutex": "^0.5.0", "bcryptjs": "^2.4.3", "cache-manager": "^5.3.2", "cache-manager-redis-yet": "^4.1.2", @@ -7411,10 +7412,9 @@ "optional": true }, "node_modules/async-mutex": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.1.tgz", - "integrity": "sha512-WfoBo4E/TbCX1G95XTjbWTE3X2XLG0m1Xbv2cwOtuPdyH9CZvnaA5nCt1ucjaKEgW2A5IF71hxrRhr83Je5xjA==", - "dev": true, + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", "dependencies": { "tslib": "^2.4.0" } @@ -15003,6 +15003,15 @@ "node": ">=14.20.1" } }, + "node_modules/mongodb-memory-server-core/node_modules/async-mutex": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.1.tgz", + "integrity": "sha512-WfoBo4E/TbCX1G95XTjbWTE3X2XLG0m1Xbv2cwOtuPdyH9CZvnaA5nCt1ucjaKEgW2A5IF71hxrRhr83Je5xjA==", + "dev": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/mongodb-memory-server-core/node_modules/bson": { "version": "5.5.1", "resolved": "https://registry.npmjs.org/bson/-/bson-5.5.1.tgz", diff --git a/api/package.json b/api/package.json index 86bb8c8e..2372f76e 100644 --- a/api/package.json +++ b/api/package.json @@ -62,6 +62,7 @@ "@resvg/resvg-js": "^2.6.2", "@socket.io/redis-adapter": "^8.3.0", "@tekuconcept/nestjs-csrf": "^1.1.0", + "async-mutex": "^0.5.0", "bcryptjs": "^2.4.3", "cache-manager": "^5.3.2", "cache-manager-redis-yet": "^4.1.2", diff --git a/api/src/app.instance.ts b/api/src/app.instance.ts index c83df405..5c3760c1 100644 --- a/api/src/app.instance.ts +++ b/api/src/app.instance.ts @@ -9,7 +9,7 @@ import { INestApplication } from '@nestjs/common'; export class AppInstance { - private static app: INestApplication; + private static app: INestApplication | null = null; static setApp(app: INestApplication) { this.app = app; @@ -21,4 +21,13 @@ export class AppInstance { } return this.app; } + + /** + * Checks whether the application context is initialized. + * This may return `false` in environments where the app instance is not set, + * such as when running in test env or CLI mode without a full application bootstrap. + */ + static isReady(): boolean { + return this.app !== null; + } } diff --git a/api/src/attachment/attachment.module.ts b/api/src/attachment/attachment.module.ts index c3fadbb6..77966bf0 100644 --- a/api/src/attachment/attachment.module.ts +++ b/api/src/attachment/attachment.module.ts @@ -12,6 +12,7 @@ import { Module, OnApplicationBootstrap } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { PassportModule } from '@nestjs/passport'; +import { AppInstance } from '@/app.instance'; import { config } from '@/config'; import { UserModule } from '@/user/user.module'; @@ -34,6 +35,10 @@ import { AttachmentService } from './services/attachment.service'; }) export class AttachmentModule implements OnApplicationBootstrap { onApplicationBootstrap() { + if (!AppInstance.isReady()) { + return; + } + // Ensure the directories exists if (!existsSync(config.parameters.uploadDir)) { mkdirSync(config.parameters.uploadDir, { recursive: true }); diff --git a/api/src/channel/channel.service.ts b/api/src/channel/channel.service.ts index 00c987c0..1968e9d0 100644 --- a/api/src/channel/channel.service.ts +++ b/api/src/channel/channel.service.ts @@ -120,13 +120,13 @@ export class ChannelService { */ @SocketGet(`/webhook/${WEB_CHANNEL_NAME}/`) @SocketPost(`/webhook/${WEB_CHANNEL_NAME}/`) - handleWebsocketForWebChannel( + async handleWebsocketForWebChannel( @SocketReq() req: SocketRequest, @SocketRes() res: SocketResponse, ) { this.logger.log('Channel notification (Web Socket) : ', req.method); const handler = this.getChannelHandler(WEB_CHANNEL_NAME); - return handler.handle(req, res); + return await handler.handle(req, res); } /** @@ -195,6 +195,6 @@ export class ChannelService { } const handler = this.getChannelHandler(CONSOLE_CHANNEL_NAME); - return handler.handle(req, res); + return await handler.handle(req, res); } } diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index b945cacc..f4d45440 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -47,6 +47,7 @@ import { NlpValueService } from '@/nlp/services/nlp-value.service'; import { NlpService } from '@/nlp/services/nlp.service'; import { PluginService } from '@/plugins/plugins.service'; import { SettingService } from '@/setting/services/setting.service'; +import { FALLBACK_DEFAULT_NLU_PENALTY_FACTOR } from '@/utils/constants/nlp'; import { blockFixtures, installBlockFixtures, @@ -196,6 +197,7 @@ describe('BlockService', () => { })), getSettings: jest.fn(() => ({ contact: { company_name: 'Your company name' }, + chatbot_settings: { default_nlu_penalty_factor: 0.95 }, })), }, }, @@ -467,9 +469,11 @@ describe('BlockService', () => { blockService, 'calculateNluPatternMatchScore', ); + const bestBlock = blockService.matchBestNLP( blocks, mockNlpGreetingNameEntities, + FALLBACK_DEFAULT_NLU_PENALTY_FACTOR, ); // Ensure calculateBlockScore was called at least once for each block @@ -509,7 +513,11 @@ describe('BlockService', () => { blockService, 'calculateNluPatternMatchScore', ); - const bestBlock = blockService.matchBestNLP(blocks, nlp); + const bestBlock = blockService.matchBestNLP( + blocks, + nlp, + FALLBACK_DEFAULT_NLU_PENALTY_FACTOR, + ); // Ensure calculateBlockScore was called at least once for each block expect(calculateBlockScoreSpy).toHaveBeenCalledTimes(3); // Called for each block @@ -530,6 +538,7 @@ describe('BlockService', () => { const bestBlock = blockService.matchBestNLP( blocks, mockNlpGreetingNameEntities, + FALLBACK_DEFAULT_NLU_PENALTY_FACTOR, ); // Assert that undefined is returned when no blocks are available @@ -542,6 +551,7 @@ describe('BlockService', () => { const matchingScore = blockService.calculateNluPatternMatchScore( mockNlpGreetingNamePatterns, mockNlpGreetingNameEntities, + FALLBACK_DEFAULT_NLU_PENALTY_FACTOR, ); expect(matchingScore).toBeGreaterThan(0); @@ -551,15 +561,29 @@ describe('BlockService', () => { const scoreWithoutPenalty = blockService.calculateNluPatternMatchScore( mockNlpGreetingNamePatterns, mockNlpGreetingNameEntities, + FALLBACK_DEFAULT_NLU_PENALTY_FACTOR, ); const scoreWithPenalty = blockService.calculateNluPatternMatchScore( mockNlpGreetingAnyNamePatterns, mockNlpGreetingNameEntities, + FALLBACK_DEFAULT_NLU_PENALTY_FACTOR, ); expect(scoreWithoutPenalty).toBeGreaterThan(scoreWithPenalty); }); + + it('should handle invalid case for penalty factor values', async () => { + // Test with invalid penalty (should use fallback) + const scoreWithInvalidPenalty = + blockService.calculateNluPatternMatchScore( + mockNlpGreetingAnyNamePatterns, + mockNlpGreetingNameEntities, + -1, + ); + + expect(scoreWithInvalidPenalty).toBeGreaterThan(0); // Should use fallback value + }); }); describe('matchPayload', () => { diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 47bc9cc1..d5a20d7a 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -20,6 +20,7 @@ import { NlpService } from '@/nlp/services/nlp.service'; import { PluginService } from '@/plugins/plugins.service'; import { PluginType } from '@/plugins/types'; import { SettingService } from '@/setting/services/setting.service'; +import { FALLBACK_DEFAULT_NLU_PENALTY_FACTOR } from '@/utils/constants/nlp'; import { BaseService } from '@/utils/generics/base-service'; import { getRandomElement } from '@/utils/helpers/safeRandom'; @@ -180,8 +181,23 @@ export class BlockService extends BaseService< const scoredEntities = await this.nlpService.computePredictionScore(nlp); + const settings = await this.settingService.getSettings(); + let penaltyFactor = + settings.chatbot_settings?.default_nlu_penalty_factor; + if (!penaltyFactor) { + this.logger.warn( + 'Using fallback NLU penalty factor value: %s', + FALLBACK_DEFAULT_NLU_PENALTY_FACTOR, + ); + penaltyFactor = FALLBACK_DEFAULT_NLU_PENALTY_FACTOR; + } + if (scoredEntities.entities.length > 0) { - block = this.matchBestNLP(filteredBlocks, scoredEntities); + block = this.matchBestNLP( + filteredBlocks, + scoredEntities, + penaltyFactor, + ); } } } @@ -351,6 +367,7 @@ export class BlockService extends BaseService< matchBestNLP( blocks: B[], scoredEntities: NLU.ScoredEntities, + penaltyFactor: number, ): B | undefined { const bestMatch = blocks.reduce( (bestMatch, block) => { @@ -365,10 +382,10 @@ export class BlockService extends BaseService< const score = this.calculateNluPatternMatchScore( patterns, scoredEntities, + penaltyFactor, ); return Math.max(maxScore, score); }, 0); - return score > bestMatch.score ? { block, score } : bestMatch; }, { block: undefined, score: 0 }, @@ -390,14 +407,13 @@ export class BlockService extends BaseService< * * @param patterns - A list of patterns to evaluate against the NLU prediction. * @param prediction - The scored entities resulting from NLU inference. - * @param [penaltyFactor=0.95] - Optional penalty factor to apply for generic matches (default is 0.95). * * @returns The total aggregated match score based on matched patterns and their computed scores. */ calculateNluPatternMatchScore( patterns: NlpPattern[], prediction: NLU.ScoredEntities, - penaltyFactor = 0.95, + penaltyFactor: number, ): number { if (!patterns.length || !prediction.entities.length) { return 0; diff --git a/api/src/extension/extension.module.ts b/api/src/extension/extension.module.ts index 8d43c8ab..6aeb28a3 100644 --- a/api/src/extension/extension.module.ts +++ b/api/src/extension/extension.module.ts @@ -8,6 +8,7 @@ import { Global, Module, OnApplicationBootstrap } from '@nestjs/common'; +import { AppInstance } from '@/app.instance'; import { LoggerService } from '@/logger/logger.service'; import { CleanupService } from './cleanup.service'; @@ -24,6 +25,11 @@ export class ExtensionModule implements OnApplicationBootstrap { ) {} async onApplicationBootstrap() { + if (!AppInstance.isReady()) { + // bypass in test or CLI env + return; + } + try { await this.cleanupService.pruneExtensionSettings(); } catch (error) { diff --git a/api/src/extensions/helpers/llm-nlu/index.helper.ts b/api/src/extensions/helpers/llm-nlu/index.helper.ts index 5c7c9b2f..521f0393 100644 --- a/api/src/extensions/helpers/llm-nlu/index.helper.ts +++ b/api/src/extensions/helpers/llm-nlu/index.helper.ts @@ -6,10 +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, OnModuleInit } from '@nestjs/common'; +import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import Handlebars from 'handlebars'; +import { AppInstance } from '@/app.instance'; import { HelperService } from '@/helper/helper.service'; import BaseNlpHelper from '@/helper/lib/base-nlp-helper'; import { HelperType, LLM, NLU } from '@/helper/types'; @@ -24,7 +25,7 @@ import { LLM_NLU_HELPER_NAME } from './settings'; @Injectable() export default class LlmNluHelper extends BaseNlpHelper - implements OnModuleInit + implements OnApplicationBootstrap { private languageClassifierPrompt: string; @@ -50,13 +51,18 @@ export default class LlmNluHelper @OnEvent('hook:language:*') @OnEvent('hook:llm_nlu_helper:language_classifier_prompt_template') async buildLanguageClassifierPrompt() { - const settings = await this.getSettings(); - if (settings) { + try { + const settings = await this.getSettings(); const languages = await this.languageService.findAll(); const delegate = Handlebars.compile( settings.language_classifier_prompt_template, ); this.languageClassifierPrompt = delegate({ languages }); + } catch (error) { + this.logger.warn( + 'Settings for LLM NLU helper not found or invalid, language classifier prompt will not be built.', + error, + ); } } @@ -64,8 +70,8 @@ export default class LlmNluHelper @OnEvent('hook:nlpValue:*') @OnEvent('hook:llm_nlu_helper:trait_classifier_prompt_template') async buildClassifiersPrompt() { - const settings = await this.getSettings(); - if (settings) { + try { + const settings = await this.getSettings(); const traitEntities = await this.nlpEntityService.findAndPopulate({ lookups: 'trait', }); @@ -75,14 +81,30 @@ export default class LlmNluHelper entity, }), })); + } catch (error) { + this.logger.warn( + 'Settings for LLM NLU helper not found or invalid, trait classifier prompts will not be built.', + error, + ); } } - async onModuleInit() { - super.onModuleInit(); + async onApplicationBootstrap() { + if (!AppInstance.isReady()) { + // bypass in Test / CLI env + return; + } - await this.buildLanguageClassifierPrompt(); - await this.buildClassifiersPrompt(); + try { + this.logger.log('Initializing LLM NLU helper, building prompts...'); + // Build prompts for language and trait classifiers + // This is done on application bootstrap to ensure that the settings are loaded + // and the prompts are built before any requests are made to the helper. + await this.buildLanguageClassifierPrompt(); + await this.buildClassifiersPrompt(); + } catch (error) { + this.logger.error('Unable to initialize LLM NLU helper', error); + } } async predict(text: string): Promise { diff --git a/api/src/migration/migrations/1748492346868-v-2-2-9.migration.ts b/api/src/migration/migrations/1748492346868-v-2-2-9.migration.ts new file mode 100644 index 00000000..ad3190a7 --- /dev/null +++ b/api/src/migration/migrations/1748492346868-v-2-2-9.migration.ts @@ -0,0 +1,135 @@ +/* + * 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. + * 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 mongoose from 'mongoose'; + +import blockSchema, { Block } from '@/chat/schemas/block.schema'; +import roleSchema, { Role } from '@/user/schemas/role.schema'; +import userSchema, { User } from '@/user/schemas/user.schema'; + +import { MigrationServices } from '../types'; + +/** + * @returns The admin user or null + */ +const getAdminUser = async () => { + const RoleModel = mongoose.model(Role.name, roleSchema); + const UserModel = mongoose.model(User.name, userSchema); + + const adminRole = await RoleModel.findOne({ name: 'admin' }); + const user = await UserModel.findOne({ roles: [adminRole!._id] }).sort({ + createdAt: 'asc', + }); + + return user!; +}; + +const migrateBlockOptionsContentLimit = async (services: MigrationServices) => { + const BlockModel = mongoose.model(Block.name, blockSchema).collection; + + const adminUser = await getAdminUser(); + + if (!adminUser) { + services.logger.warn('Unable to process block, no admin user found'); + return; + } + + try { + await BlockModel.updateMany( + { 'options.content.limit': { $exists: true } }, + [ + { + $set: { + 'options.content.limit': { $toInt: '$options.content.limit' }, + }, + }, + ], + ); + } catch (error) { + services.logger.error(`Failed to update limit : ${error.message}`); + + throw error instanceof Error ? error : new Error(error); + } +}; + +const migrateBlockOptionsContentButtonsUrl = async ( + services: MigrationServices, +) => { + const BlockModel = mongoose.model(Block.name, blockSchema).collection; + + try { + await BlockModel.updateMany( + { 'options.content.buttons.url': false }, + { + $set: { + 'options.content.buttons.$[].url': '', + }, + }, + ); + } catch (error) { + services.logger.error(`Failed to update button url : ${error.message}`); + + throw error instanceof Error ? error : new Error(error); + } +}; + +const migrateBlockOptionsFallback = async (services: MigrationServices) => { + const BlockModel = mongoose.model(Block.name, blockSchema).collection; + + try { + await BlockModel.updateMany( + { 'options.fallback.max_attempts': { $exists: true, $type: 'string' } }, + [ + { + $set: { + 'options.fallback.max_attempts': { + $toInt: '$options.fallback.max_attempts', + }, + }, + }, + ], + ); + } catch (error) { + services.logger.error(`Failed to update max_attempts : ${error.message}`); + throw error instanceof Error ? error : new Error(error); + } + + try { + await BlockModel.updateMany({ 'options.fallback.message': { $size: 0 } }, [ + { + $set: { + 'options.fallback.max_attempts': 0, + 'options.fallback.active': false, + }, + }, + ]); + } catch (error) { + services.logger.error( + `Failed to update max_attempts, active : ${error.message}`, + ); + throw error instanceof Error ? error : new Error(error); + } +}; + +module.exports = { + async up(services: MigrationServices) { + try { + await migrateBlockOptionsContentLimit(services); + await migrateBlockOptionsContentButtonsUrl(services); + await migrateBlockOptionsFallback(services); + + return true; + } catch (error) { + services.logger.error(`Migration failed : ${error.message}`); + throw error instanceof Error ? error : new Error(error); + } + }, + async down(_services: MigrationServices) { + return true; + }, +}; diff --git a/api/src/nlp/controllers/nlp-entity.controller.ts b/api/src/nlp/controllers/nlp-entity.controller.ts index 1deb0c83..d0693c87 100644 --- a/api/src/nlp/controllers/nlp-entity.controller.ts +++ b/api/src/nlp/controllers/nlp-entity.controller.ts @@ -222,6 +222,7 @@ export class NlpEntityController extends BaseController< if (!ids?.length) { throw new BadRequestException('No IDs provided for deletion.'); } + const deleteResult = await this.nlpEntityService.deleteMany({ _id: { $in: ids }, }); diff --git a/api/src/nlp/controllers/nlp-value.controller.spec.ts b/api/src/nlp/controllers/nlp-value.controller.spec.ts index 19e80937..981f61c4 100644 --- a/api/src/nlp/controllers/nlp-value.controller.spec.ts +++ b/api/src/nlp/controllers/nlp-value.controller.spec.ts @@ -163,7 +163,7 @@ describe('NlpValueController', () => { entity: intentNlpEntity!.id, value: 'updated', expressions: [], - builtin: true, + builtin: false, doc: '', }; const result = await nlpValueController.updateOne( @@ -191,7 +191,6 @@ describe('NlpValueController', () => { describe('deleteMany', () => { it('should delete multiple nlp values', async () => { const valuesToDelete = [positiveValue!.id, negativeValue!.id]; - const result = await nlpValueController.deleteMany(valuesToDelete); expect(result.deletedCount).toEqual(valuesToDelete.length); diff --git a/api/src/nlp/dto/nlp-entity.dto.ts b/api/src/nlp/dto/nlp-entity.dto.ts index c986a707..c49e1fc3 100644 --- a/api/src/nlp/dto/nlp-entity.dto.ts +++ b/api/src/nlp/dto/nlp-entity.dto.ts @@ -63,6 +63,14 @@ export class NlpEntityCreateDto { } export class NlpEntityUpdateDto { + @ApiPropertyOptional({ description: 'Name of the nlp entity', type: String }) + @Matches(/^[a-zA-Z0-9_]+$/, { + message: 'Only alphanumeric characters and underscores are allowed.', + }) + @IsString() + @IsOptional() + name?: string; + @ApiPropertyOptional({ type: String }) @IsString() @IsOptional() diff --git a/api/src/nlp/services/nlp-entity.service.spec.ts b/api/src/nlp/services/nlp-entity.service.spec.ts index 91858e10..61eac63a 100644 --- a/api/src/nlp/services/nlp-entity.service.spec.ts +++ b/api/src/nlp/services/nlp-entity.service.spec.ts @@ -232,7 +232,7 @@ describe('NlpEntityService', () => { { value: 'jhon', expressions: ['john', 'joohn', 'jhonny'], - builtin: true, + builtin: false, doc: '', }, ], diff --git a/api/src/setting/seeds/setting.seed-model.ts b/api/src/setting/seeds/setting.seed-model.ts index 27cf5785..c65d9be3 100644 --- a/api/src/setting/seeds/setting.seed-model.ts +++ b/api/src/setting/seeds/setting.seed-model.ts @@ -24,6 +24,18 @@ export const DEFAULT_SETTINGS = [ }, weight: 1, }, + { + group: 'chatbot_settings', + label: 'default_nlu_penalty_factor', + value: 0.95, + type: SettingType.number, + config: { + min: 0, + max: 1, + step: 0.01, + }, + weight: 2, + }, { group: 'chatbot_settings', label: 'default_llm_helper', @@ -36,7 +48,7 @@ export const DEFAULT_SETTINGS = [ idKey: 'name', labelKey: 'name', }, - weight: 2, + weight: 3, }, { group: 'chatbot_settings', @@ -50,14 +62,14 @@ export const DEFAULT_SETTINGS = [ idKey: 'name', labelKey: 'name', }, - weight: 3, + weight: 4, }, { group: 'chatbot_settings', label: 'global_fallback', value: true, type: SettingType.checkbox, - weight: 4, + weight: 5, }, { group: 'chatbot_settings', @@ -72,7 +84,7 @@ export const DEFAULT_SETTINGS = [ idKey: 'id', labelKey: 'name', }, - weight: 5, + weight: 6, }, { group: 'chatbot_settings', @@ -82,7 +94,7 @@ export const DEFAULT_SETTINGS = [ "I'm really sorry but i don't quite understand what you are saying :(", ] as string[], type: SettingType.multiple_text, - weight: 6, + weight: 7, translatable: true, }, { diff --git a/api/src/user/repositories/permission.repository.spec.ts b/api/src/user/repositories/permission.repository.spec.ts index 5a32ad19..8e5ffbe2 100644 --- a/api/src/user/repositories/permission.repository.spec.ts +++ b/api/src/user/repositories/permission.repository.spec.ts @@ -138,6 +138,7 @@ describe('PermissionRepository', () => { expect(permissionModel.deleteOne).toHaveBeenCalledWith({ _id: permissionToDelete.id, + builtin: { $ne: true }, }); expect(result).toEqual({ diff --git a/frontend/src/pages/inbox.tsx b/api/src/utils/constants/nlp.ts similarity index 56% rename from frontend/src/pages/inbox.tsx rename to api/src/utils/constants/nlp.ts index 5932b595..fb281d5d 100644 --- a/frontend/src/pages/inbox.tsx +++ b/api/src/utils/constants/nlp.ts @@ -1,22 +1,9 @@ /* - * 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. * 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 { ReactElement } from "react"; - -import { Inbox } from "@/components/inbox"; -import { Layout } from "@/layout"; - -const InboxPage = () => { - return ; -}; - -InboxPage.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default InboxPage; +export const FALLBACK_DEFAULT_NLU_PENALTY_FACTOR = 0.95; diff --git a/api/src/utils/generics/base-repository.spec.ts b/api/src/utils/generics/base-repository.spec.ts index a11a1ae4..57cdd65b 100644 --- a/api/src/utils/generics/base-repository.spec.ts +++ b/api/src/utils/generics/base-repository.spec.ts @@ -306,6 +306,7 @@ describe('BaseRepository', () => { expect(dummyModel.deleteOne).toHaveBeenCalledWith({ _id: createdId, + builtin: { $ne: true }, }); expect(result).toEqualPayload({ acknowledged: true, deletedCount: 1 }); }); @@ -318,12 +319,13 @@ describe('BaseRepository', () => { expect(dummyModel.deleteOne).toHaveBeenCalledWith({ dummy: 'dummy test 2', + builtin: { $ne: true }, }); expect(result).toEqualPayload({ acknowledged: true, deletedCount: 1 }); }); it('should call lifecycle hooks appropriately when deleting by id', async () => { - const criteria = createdId; + jest.spyOn(dummyModel, 'deleteOne'); // Spies for lifecycle hooks const spyBeforeDelete = jest @@ -333,7 +335,12 @@ describe('BaseRepository', () => { .spyOn(dummyRepository, 'postDelete') .mockResolvedValue(); - await dummyRepository.deleteOne(criteria); + await dummyRepository.deleteOne(createdId); + + expect(dummyModel.deleteOne).toHaveBeenCalledWith({ + _id: createdId, + builtin: { $ne: true }, + }); // Verifying that lifecycle hooks are called with correct parameters expect(spyBeforeDelete).toHaveBeenCalledTimes(1); @@ -341,6 +348,7 @@ describe('BaseRepository', () => { expect.objectContaining({ $useProjection: true }), { _id: new Types.ObjectId(createdId), + builtin: { $ne: true }, }, ); expect(spyAfterDelete).toHaveBeenCalledWith( diff --git a/api/src/utils/generics/base-repository.ts b/api/src/utils/generics/base-repository.ts index 16672835..ccfabb09 100644 --- a/api/src/utils/generics/base-repository.ts +++ b/api/src/utils/generics/base-repository.ts @@ -555,13 +555,15 @@ export abstract class BaseRepository< } async deleteOne(criteria: string | TFilterQuery): Promise { + const filter = typeof criteria === 'string' ? { _id: criteria } : criteria; + return await this.model - .deleteOne(typeof criteria === 'string' ? { _id: criteria } : criteria) + .deleteOne({ ...filter, builtin: { $ne: true } }) .exec(); } async deleteMany(criteria: TFilterQuery): Promise { - return await this.model.deleteMany(criteria); + return await this.model.deleteMany({ ...criteria, builtin: { $ne: true } }); } async preCreateValidate( diff --git a/api/src/utils/test/fixtures/nlpvalue.ts b/api/src/utils/test/fixtures/nlpvalue.ts index d83a582a..92937125 100644 --- a/api/src/utils/test/fixtures/nlpvalue.ts +++ b/api/src/utils/test/fixtures/nlpvalue.ts @@ -18,35 +18,35 @@ export const nlpValueFixtures: NlpValueCreateDto[] = [ entity: '0', value: 'positive', expressions: [], - builtin: true, + builtin: false, doc: '', }, { entity: '0', value: 'negative', expressions: [], - builtin: true, + builtin: false, doc: '', }, { entity: '1', value: 'jhon', expressions: ['john', 'joohn', 'jhonny'], - builtin: true, + builtin: false, doc: '', }, { entity: '0', value: 'greeting', expressions: ['heello', 'Hello', 'hi', 'heyy'], - builtin: true, + builtin: false, doc: '', }, { entity: '0', value: 'goodbye', expressions: ['bye', 'bye bye'], - builtin: true, + builtin: false, doc: '', }, { diff --git a/api/src/websocket/services/socket-event-dispatcher.service.ts b/api/src/websocket/services/socket-event-dispatcher.service.ts index 326bb3e9..1abb58f1 100644 --- a/api/src/websocket/services/socket-event-dispatcher.service.ts +++ b/api/src/websocket/services/socket-event-dispatcher.service.ts @@ -14,7 +14,9 @@ import { } from '@nestjs/common'; import { ModulesContainer } from '@nestjs/core'; import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper'; -import { EventEmitter2 } from '@nestjs/event-emitter'; +import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; +import { Mutex } from 'async-mutex'; +import { Socket } from 'socket.io'; import { SocketEventMetadataStorage } from '../storage/socket-event-metadata.storage'; import { SocketRequest } from '../utils/socket-request'; @@ -39,12 +41,21 @@ export class SocketEventDispatcherService implements OnModuleInit { private readonly modulesContainer: ModulesContainer, ) {} + @OnEvent('hook:websocket:connection') + handleConnection(client: Socket) { + client.data.mutex = new Mutex(); + } + async handleEvent( socketMethod: SocketMethod, path: string, req: SocketRequest, res: SocketResponse, ) { + // Prevent racing conditions from the same socket + const socketData = req.socket.data; + const release = await socketData.mutex.acquire(); + try { const handlers = this.routeHandlers[socketMethod]; const foundHandler = Array.from(handlers.entries()).find(([key, _]) => { @@ -62,6 +73,8 @@ export class SocketEventDispatcherService implements OnModuleInit { return await handler(req, res); } catch (error) { return this.handleException(error, res); + } finally { + release(); } } diff --git a/frontend/package.json b/frontend/package.json index d09f6b13..3f72d1cd 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "@mui/x-data-grid": "^7.3.2", "@projectstorm/react-canvas-core": "^7.0.3", "@projectstorm/react-diagrams": "^7.0.4", + "autolinker": "^4.1.5", "axios": "^1.7.7", "eazychart-css": "^0.2.1-alpha.0", "eazychart-react": "^0.8.0-alpha.0", @@ -38,17 +39,17 @@ "normalizr": "^3.6.2", "notistack": "^3.0.1", "qs": "^6.12.1", + "random-seed": "^0.3.0", "react": "^18", "react-dom": "^18", "react-hook-form": "^7.51.5", "react-i18next": "^14.1.1", "react-query": "^3.39.3", - "socket.io-client": "^4.7.5", - "random-seed": "^0.3.0" + "socket.io-client": "^4.7.5" }, "devDependencies": { - "@types/qs": "^6.9.15", "@types/node": "20.12.12", + "@types/qs": "^6.9.15", "@types/random-seed": "^0.3.5", "@types/react": "18.3.2", "@types/react-dom": "^18", diff --git a/frontend/public/locales/en/chatbot_settings.json b/frontend/public/locales/en/chatbot_settings.json index 170af944..bde69e7b 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", + "default_nlu_penalty_factor": "NLU Penalty Factor" }, "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.", + "default_nlu_penalty_factor": "The NLU penalty factor is a coefficient (between 0 and 1) applied exclusively to NLU-based entity matching. It reduces the score contribution of patterns that match broadly (e.g. using wildcard values like Any) rather than specific entity values. This helps the engine prioritize blocks triggered by more precise NLU matches, without affecting other matching strategies such as text, regex, or interaction triggers." } } diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index e3ed3a75..fd7b1ec1 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -103,6 +103,7 @@ "message_is_required": "Message is required", "context_var_is_required": "You need to add a context variable", "invalid_list_limit": "List limit must be >=2 and <= 4", + "invalid_carousel_limit": "List limit must be >=1 and <= 10", "no_content_type": "No content type available, please create one first", "invalid_max_fallback_attempt_limit": "Max fallback attempt limit must have positive value", "regex_is_invalid": "Regex is invalid", @@ -123,7 +124,7 @@ "video_error": "Video not found", "missing_fields_error": "Please make sure that all required fields are filled", "weight_required_error": "Weight is required or invalid", - "weight_positive_number_error": "Weight must be a strictly positive number" + "weight_positive_number_error": "Weight must be a strictly positive number" }, "menu": { "terms": "Terms of Use", @@ -490,7 +491,9 @@ "original_text": "Original Text", "inputs": "Inputs", "outputs": "Outputs", - "any": "- Any -" + "any": "- Any -", + "full_name": "First and last name", + "password": "Password" }, "placeholder": { "your_username": "Your username", @@ -498,7 +501,6 @@ "your_password": "Your password", "username": "Username", "email": "E-mail address", - "full_name": "First and last name", "password": "Password", "password2": "Confirm your password", "timezone": "Timezone", @@ -527,7 +529,9 @@ "end_date": "End Date", "nlp_value": "Value", "type_message_here": "Type message here ....", - "mark_as_default": "By Default" + "mark_as_default": "By Default", + "pattern": "Pattern", + "full_name": "First and last name" }, "button": { "login": "Sign In", diff --git a/frontend/public/locales/fr/chatbot_settings.json b/frontend/public/locales/fr/chatbot_settings.json index 4b1ed420..9d44b07c 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", + "default_nlu_penalty_factor": "Facteur de pénalité NLU" }, "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.", + "default_nlu_penalty_factor": "Le facteur de pénalité NLU est un coefficient (entre 0 et 1) appliqué exclusivement aux correspondances d'entités basées sur NLU. Il réduit la contribution au score des motifs qui correspondent de manière générale (par exemple, en utilisant des valeurs génériques comme Any) plutôt que des valeurs d'entité spécifiques. Cela permet au chatbot de donner la priorité aux blocs déclenchés par des correspondances NLU plus précises, sans affecter d'autres stratégies de correspondance telles que le texte, les expressions regex ou les déclencheurs d'interaction." } } diff --git a/frontend/public/locales/fr/translation.json b/frontend/public/locales/fr/translation.json index ad69c256..0e746c98 100644 --- a/frontend/public/locales/fr/translation.json +++ b/frontend/public/locales/fr/translation.json @@ -103,6 +103,7 @@ "message_is_required": "Le message est requis", "context_var_is_required": "Vous devez ajouter une variable contextuelle", "invalid_list_limit": "La limite doit être >=2 et <= 4", + "invalid_carousel_limit": "La limite doit être >=1 et <= 10", "no_content_type": "Il n'y a aucun type de contenu pour le moment, veuillez en ajouter un.", "invalid_max_fallback_attempt_limit": "La limite des tentatives de secours doit être un nombre positif.", "regex_is_invalid": "Le regex est invalide", @@ -491,7 +492,9 @@ "original_text": "Texte par défaut", "inputs": "Ports d'entrée", "outputs": "Ports de sortie", - "any": "- Toutes -" + "any": "- Toutes -", + "full_name": "Nom et Prénom", + "password": "Mot de passe" }, "placeholder": { "your_username": "Votre nom d'utilisateur", @@ -499,7 +502,6 @@ "your_password": "Votre mot de passe", "username": "Nom d'utilisateur", "email": "Adresse e-mail", - "full_name": "Nom et Prénom", "password": "Mot de passe", "password2": "Confirmez votre mot de passe", "timezone": "Fuseau horaire", @@ -528,7 +530,9 @@ "end_date": "Date de fin", "nlp_value": "Valeur", "type_message_here": "Ecrivez quelque chose ici ....", - "mark_as_default": "Par Défaut" + "mark_as_default": "Par Défaut", + "pattern": "Motif", + "full_name": "Nom et Prénom" }, "button": { "login": "Se connecter", diff --git a/frontend/src/app-components/attachment/AttachmentUploader.tsx b/frontend/src/app-components/attachment/AttachmentUploader.tsx index fcd3752b..b49dd316 100644 --- a/frontend/src/app-components/attachment/AttachmentUploader.tsx +++ b/frontend/src/app-components/attachment/AttachmentUploader.tsx @@ -17,7 +17,7 @@ import { styled, Typography, } from "@mui/material"; -import { ChangeEvent, DragEvent, FC, useState } from "react"; +import { ChangeEvent, DragEvent, FC, useId, useState } from "react"; import { useUpload } from "@/hooks/crud/useUpload"; import { useDialogs } from "@/hooks/useDialogs"; @@ -88,6 +88,7 @@ const AttachmentUploader: FC = ({ const [attachment, setAttachment] = useState( undefined, ); + const uid = useId(); const { t } = useTranslate(); const dialogs = useDialogs(); const [isDragOver, setIsDragOver] = useState(false); @@ -154,11 +155,11 @@ const AttachmentUploader: FC = ({ setIsDragOver(true)} onMouseLeave={() => setIsDragOver(false)} @@ -221,7 +222,7 @@ const AttachmentUploader: FC = ({ { defaultValues: { accept, onChange }, }, - { maxWidth: "xl" }, + { maxWidth: "xl", isSingleton: true }, ) } > diff --git a/frontend/src/app-components/auth/Login.tsx b/frontend/src/app-components/auth/Login.tsx index fe9d841b..9b54bbeb 100755 --- a/frontend/src/app-components/auth/Login.tsx +++ b/frontend/src/app-components/auth/Login.tsx @@ -106,7 +106,7 @@ export const Login = () => { /> { { - {...(options.length && { value })} + value={value} onChange={onChange} label={label} multiple={multiple} diff --git a/frontend/src/app-components/inputs/AutoCompleteSelect.tsx b/frontend/src/app-components/inputs/AutoCompleteSelect.tsx index e0d5f1a9..439cc6d5 100644 --- a/frontend/src/app-components/inputs/AutoCompleteSelect.tsx +++ b/frontend/src/app-components/inputs/AutoCompleteSelect.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. @@ -11,6 +11,7 @@ import Autocomplete, { AutocompleteProps, AutocompleteValue, } from "@mui/material/Autocomplete"; +import stringify from "fast-json-stable-stringify"; import { forwardRef, useCallback, useMemo } from "react"; import { Input } from "@/app-components/inputs/Input"; @@ -112,7 +113,7 @@ const AutoCompleteSelect = < {...rest} ref={ref} size="small" - key={JSON.stringify(value)} + key={`${stringify(options)}_${stringify(value)}`} disabled={isDisabled} defaultValue={selected} multiple={multiple} diff --git a/frontend/src/app-components/inputs/FilterTextfield.tsx b/frontend/src/app-components/inputs/FilterTextfield.tsx index cd58df06..0d7cc302 100644 --- a/frontend/src/app-components/inputs/FilterTextfield.tsx +++ b/frontend/src/app-components/inputs/FilterTextfield.tsx @@ -1,31 +1,100 @@ /* - * 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. * 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 ClearIcon from "@mui/icons-material/Clear"; import SearchIcon from "@mui/icons-material/Search"; -import { TextFieldProps } from "@mui/material"; +import { + debounce, + IconButton, + InputAdornment, + TextFieldProps, +} from "@mui/material"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useTranslate } from "@/hooks/useTranslate"; import { Adornment } from "./Adornment"; import { Input } from "./Input"; -export const FilterTextfield = (props: TextFieldProps) => { - const { t } = useTranslate(); +export interface FilterTextFieldProps + extends Omit { + onChange: (value: string) => void; + delay?: number; + clearable: boolean; + defaultValue?: string; +} + +export const FilterTextfield = ({ + onChange: onSearch, + defaultValue = "", + delay = 500, + clearable = true, + ...props +}) => { + const { t } = useTranslate(); + const ref = useRef(null); + const isTyping = useRef(false); + const toggleTyping = useMemo( + () => + debounce((value: boolean) => { + isTyping.current = value; + }, delay * 2), + [delay], + ); + const debouncedSearch = useMemo( + () => + debounce((value: string) => { + onSearch?.(value); + toggleTyping(false); + }, delay), + // eslint-disable-next-line react-hooks/exhaustive-deps + [onSearch, delay], + ); + const handleChange = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value; + + toggleTyping(true); + debouncedSearch(value); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [debouncedSearch], + ); + const handleClear = useCallback(() => { + debouncedSearch(""); + }, [debouncedSearch]); + + useEffect(() => { + // Avoid infinite loop cycle (input => URL update => default value) + if (defaultValue !== ref.current?.value && !isTyping.current) { + ref.current && (ref.current.value = defaultValue); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultValue]); - //TODO: replace the native delete text button by a styled custom button return ( , + endAdornment: clearable && ( + + + + + + ), }} placeholder={t("placeholder.keywords")} {...props} + // value={inputValue} + defaultValue={defaultValue} + onChange={handleChange} /> ); }; diff --git a/frontend/src/app-components/svg/NoDataIcon.tsx b/frontend/src/app-components/svg/NoDataIcon.tsx index ee7752a3..b7918f2e 100644 --- a/frontend/src/app-components/svg/NoDataIcon.tsx +++ b/frontend/src/app-components/svg/NoDataIcon.tsx @@ -1,59 +1,40 @@ /* - * 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. * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import React, { FC, SVGProps } from "react"; +import { FC, SVGProps } from "react"; -const NoDataIcon: FC> = ({ ...props }) => { +const NoDataIcon: FC> = ({ width = 96, ...props }) => { return ( - No data - - - - - - - - - - - - - - + + + + ); }; diff --git a/frontend/src/app-components/tables/DataGrid.tsx b/frontend/src/app-components/tables/DataGrid.tsx index e49d1e72..8816ae72 100644 --- a/frontend/src/app-components/tables/DataGrid.tsx +++ b/frontend/src/app-components/tables/DataGrid.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. @@ -73,6 +73,12 @@ export const DataGrid = ({ autoHeight={autoHeight} disableRowSelectionOnClick={disableRowSelectionOnClick} slots={slots} + slotProps={{ + loadingOverlay: { + variant: "linear-progress", + noRowsVariant: "skeleton", + }, + }} showCellVerticalBorder={showCellVerticalBorder} showColumnVerticalBorder={showColumnVerticalBorder} sx={sx} diff --git a/frontend/src/app-components/tables/NoDataOverlay.tsx b/frontend/src/app-components/tables/NoDataOverlay.tsx index 78b15dbc..d4220f18 100644 --- a/frontend/src/app-components/tables/NoDataOverlay.tsx +++ b/frontend/src/app-components/tables/NoDataOverlay.tsx @@ -1,42 +1,46 @@ /* - * 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. * 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 { Grid, Typography } from "@mui/material"; +import Box from "@mui/material/Box"; +import { styled } from "@mui/material/styles"; import { useTranslate } from "@/hooks/useTranslate"; import NoDataIcon from "../svg/NoDataIcon"; +const StyledGridOverlay = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + height: "100%", + minHeight: "200px", + "& .no-rows-primary": { + fill: "#3D4751", + ...theme.applyStyles("light", { + fill: "#AEB8C2", + }), + }, + "& .no-rows-secondary": { + fill: "#1D2126", + ...theme.applyStyles("light", { + fill: "#E8EAED", + }), + }, +})); + export const NoDataOverlay = () => { const { t } = useTranslate(); return ( - + - - - {t("label.no_data")} - - - + {t("label.no_data")} + ); }; diff --git a/frontend/src/app-components/widget/ChatWidget.tsx b/frontend/src/app-components/widget/ChatWidget.tsx index 201d5edd..9b958fcd 100644 --- a/frontend/src/app-components/widget/ChatWidget.tsx +++ b/frontend/src/app-components/widget/ChatWidget.tsx @@ -9,7 +9,7 @@ import { Avatar, Box } from "@mui/material"; import UiChatWidget from "hexabot-chat-widget/src/UiChatWidget"; import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; +import { useMemo } from "react"; import { getAvatarSrc } from "@/components/inbox/helpers/mapMessages"; import { useAuth } from "@/hooks/useAuth"; @@ -17,7 +17,6 @@ import { useConfig } from "@/hooks/useConfig"; import { useSetting } from "@/hooks/useSetting"; import i18n from "@/i18n/config"; import { EntityType, RouterType } from "@/services/types"; -import { generateId } from "@/utils/generateId"; import { ChatWidgetHeader } from "./ChatWidgetHeader"; @@ -30,11 +29,10 @@ export const ChatWidget = () => { const isVisualEditor = pathname.startsWith(`/${RouterType.VISUAL_EDITOR}`); const allowedDomainsSetting = useSetting(SETTING_TYPE, "allowed_domains"); const themeColorSetting = useSetting(SETTING_TYPE, "theme_color"); - const [key, setKey] = useState(generateId()); - - useEffect(() => { - setKey(generateId()); - }, [allowedDomainsSetting, themeColorSetting]); + const key = useMemo( + () => `${allowedDomainsSetting}_${themeColorSetting}`, + [allowedDomainsSetting, themeColorSetting], + ); return isAuthenticated ? ( { const { toast } = useToast(); const dialogs = useDialogs(); const hasPermission = useHasPermission(); - const { onSearch, searchPayload } = useSearch({ - $iLike: ["label"], - }); + const { onSearch, searchPayload, searchText } = useSearch( + { + $iLike: ["label"], + }, + { syncUrl: true }, + ); const { dataGridProps } = useFind( { entity: EntityType.CATEGORY }, { @@ -142,7 +145,7 @@ export const Categories = () => { width="max-content" > - + {hasPermission(EntityType.CATEGORY, PermissionAction.CREATE) ? ( diff --git a/frontend/src/components/content-types/index.tsx b/frontend/src/components/content-types/index.tsx index 69fe5827..951cb00f 100644 --- a/frontend/src/components/content-types/index.tsx +++ b/frontend/src/components/content-types/index.tsx @@ -40,9 +40,12 @@ export const ContentTypes = () => { const router = useRouter(); const dialogs = useDialogs(); // data fetching - const { onSearch, searchPayload } = useSearch({ - $iLike: ["name"], - }); + const { onSearch, searchPayload, searchText } = useSearch( + { + $iLike: ["name"], + }, + { syncUrl: true }, + ); const { dataGridProps } = useFind( { entity: EntityType.CONTENT_TYPE }, { @@ -99,7 +102,7 @@ export const ContentTypes = () => { width="max-content" > - + {hasPermission(EntityType.CONTENT_TYPE, PermissionAction.CREATE) ? ( diff --git a/frontend/src/components/contents/index.tsx b/frontend/src/components/contents/index.tsx index e95d5111..e577d9de 100644 --- a/frontend/src/components/contents/index.tsx +++ b/frontend/src/components/contents/index.tsx @@ -57,10 +57,13 @@ export const Contents = () => { const queryClient = useQueryClient(); const dialogs = useDialogs(); // data fetching - const { onSearch, searchPayload } = useSearch({ - $eq: [{ entity: String(query.id) }], - $iLike: ["title"], - }); + const { onSearch, searchPayload, searchText } = useSearch( + { + $eq: [{ entity: String(query.id) }], + $iLike: ["title"], + }, + { syncUrl: true }, + ); const hasPermission = useHasPermission(); const { dataGridProps } = useFind( { entity: EntityType.CONTENT, format: Format.FULL }, @@ -157,7 +160,7 @@ export const Contents = () => { > - + {hasPermission(EntityType.CONTENT, PermissionAction.CREATE) ? ( diff --git a/frontend/src/components/context-vars/index.tsx b/frontend/src/components/context-vars/index.tsx index c2a617b6..1095b6a8 100644 --- a/frontend/src/components/context-vars/index.tsx +++ b/frontend/src/components/context-vars/index.tsx @@ -43,9 +43,12 @@ export const ContextVars = () => { const { toast } = useToast(); const dialogs = useDialogs(); const hasPermission = useHasPermission(); - const { onSearch, searchPayload } = useSearch({ - $iLike: ["label"], - }); + const { onSearch, searchPayload, searchText } = useSearch( + { + $iLike: ["label"], + }, + { syncUrl: true }, + ); const { dataGridProps } = useFind( { entity: EntityType.CONTEXT_VAR }, { @@ -176,7 +179,7 @@ export const ContextVars = () => { width="max-content" > - + {hasPermission(EntityType.CONTEXT_VAR, PermissionAction.CREATE) ? ( diff --git a/frontend/src/components/inbox/components/ConversationsList.tsx b/frontend/src/components/inbox/components/ConversationsList.tsx index 3a1b1c8b..da1e4876 100644 --- a/frontend/src/components/inbox/components/ConversationsList.tsx +++ b/frontend/src/components/inbox/components/ConversationsList.tsx @@ -21,7 +21,6 @@ import { useTranslate } from "@/hooks/useTranslate"; import { Title } from "@/layout/content/Title"; import { EntityType, RouterType } from "@/services/types"; import { normalizeDate } from "@/utils/date"; -import { extractQueryParamsUrl } from "@/utils/URL"; import { getAvatarSrc } from "../helpers/mapMessages"; import { useChat } from "../hooks/ChatContext"; @@ -33,8 +32,8 @@ export const SubscribersList = (props: { searchPayload: any; assignedTo: AssignedTo; }) => { - const { query, push } = useRouter(); - const subscriber = query.subscriber?.toString() || null; + const router = useRouter(); + const subscriber = router.query.subscriber?.toString() || null; const { apiUrl } = useConfig(); const { t, i18n } = useTranslate(); const chat = useChat(); @@ -75,10 +74,17 @@ export const SubscribersList = (props: { { chat.setSubscriberId(subscriber.id); - push({ - pathname: `/${RouterType.INBOX}/subscribers/${subscriber.id}`, - query: extractQueryParamsUrl(window.location.href), - }); + router.push( + { + pathname: `/${RouterType.INBOX}/subscribers/[subscriber]`, + query: { + ...router.query, + subscriber: subscriber.id, + }, + }, + undefined, + { shallow: true }, + ); }} className="changeColor" key={subscriber.id} diff --git a/frontend/src/components/inbox/helpers/mapMessages.tsx b/frontend/src/components/inbox/helpers/mapMessages.tsx index 165d52ab..5daf0186 100644 --- a/frontend/src/components/inbox/helpers/mapMessages.tsx +++ b/frontend/src/components/inbox/helpers/mapMessages.tsx @@ -10,6 +10,7 @@ import { Message, MessageModel } from "@chatscope/chat-ui-kit-react"; import MenuRoundedIcon from "@mui/icons-material/MenuRounded"; import ReplyIcon from "@mui/icons-material/Reply"; import { Chip, Grid } from "@mui/material"; +import Autolinker from "autolinker"; import { ReactNode } from "react"; import { ROUTES } from "@/services/api.class"; @@ -60,6 +61,31 @@ export function isSubsequent( ); } +/** + * @description Detects URLs in text and converts them to clickable links using Autolinker + */ +function formatMessageText(text: string): ReactNode { + try { + const linkedText = Autolinker.link(text, { + className: "chat-link", + newWindow: true, + truncate: { length: 50, location: "middle" }, + stripPrefix: false, + sanitizeHtml: true, + }); + + return ( +
+ ); + } catch (error) { + return
{text}
; + } +} + /** * @description this function constructs the message children basen on message type */ @@ -79,7 +105,9 @@ export function getMessageContent( if ("text" in message) { content.push( - , + + {formatMessageText(message.text)} + , ); } let chips: { title: string }[] = []; diff --git a/frontend/src/components/inbox/inbox.css b/frontend/src/components/inbox/inbox.css index 330e6c2b..416e3570 100644 --- a/frontend/src/components/inbox/inbox.css +++ b/frontend/src/components/inbox/inbox.css @@ -105,3 +105,14 @@ div .cs-message-input__content-editor-container, padding: 15px 5px !important; box-shadow: 0px 4px 10px 10px rgba(0, 0, 0, 0.066); } + +/* Styles for autolinked chat messages */ +.chat-link { + color: inherit !important; + text-decoration: underline !important; + word-break: break-all !important; +} + +.chat-link:hover { + opacity: 0.8; +} diff --git a/frontend/src/components/inbox/index.tsx b/frontend/src/components/inbox/index.tsx index e57e8192..22564d47 100644 --- a/frontend/src/components/inbox/index.tsx +++ b/frontend/src/components/inbox/index.tsx @@ -6,12 +6,13 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { MainContainer, Search, Sidebar } from "@chatscope/chat-ui-kit-react"; +import { MainContainer, Sidebar } from "@chatscope/chat-ui-kit-react"; import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css"; import { Grid, MenuItem } from "@mui/material"; import { useState } from "react"; import AutoCompleteEntitySelect from "@/app-components/inputs/AutoCompleteEntitySelect"; +import { FilterTextfield } from "@/app-components/inputs/FilterTextfield"; import { Input } from "@/app-components/inputs/Input"; import { useSearch } from "@/hooks/useSearch"; import { useTranslate } from "@/hooks/useTranslate"; @@ -26,9 +27,12 @@ import { AssignedTo } from "./types"; export const Inbox = () => { const { t } = useTranslate(); - const { onSearch, searchPayload, searchText } = useSearch({ - $or: ["first_name", "last_name"], - }); + const { onSearch, searchPayload, searchText } = useSearch( + { + $or: ["first_name", "last_name"], + }, + { syncUrl: true }, + ); const [channels, setChannels] = useState([]); const [assignment, setAssignment] = useState(AssignedTo.ALL); @@ -46,13 +50,10 @@ export const Inbox = () => { - - onSearch("")} - className="changeColor" - onChange={(v) => onSearch(v)} - placeholder="Search..." + + { const { toast } = useToast(); const dialogs = useDialogs(); const hasPermission = useHasPermission(); - const { onSearch, searchPayload } = useSearch({ - $or: ["name", "title"], - }); + const { onSearch, searchPayload, searchText } = useSearch( + { + $or: ["name", "title"], + }, + { syncUrl: true }, + ); const { dataGridProps } = useFind( { entity: EntityType.LABEL, format: Format.FULL }, { @@ -173,7 +176,7 @@ export const Labels = () => { width="max-content" > - + {hasPermission(EntityType.LABEL, PermissionAction.CREATE) ? ( diff --git a/frontend/src/components/languages/index.tsx b/frontend/src/components/languages/index.tsx index 74b6be11..c14da822 100644 --- a/frontend/src/components/languages/index.tsx +++ b/frontend/src/components/languages/index.tsx @@ -43,9 +43,12 @@ export const Languages = () => { const dialogs = useDialogs(); const queryClient = useQueryClient(); const hasPermission = useHasPermission(); - const { onSearch, searchPayload } = useSearch({ - $or: ["title", "code"], - }); + const { onSearch, searchPayload, searchText } = useSearch( + { + $or: ["title", "code"], + }, + { syncUrl: true }, + ); const { dataGridProps, refetch } = useFind( { entity: EntityType.LANGUAGE }, { @@ -197,7 +200,7 @@ export const Languages = () => { width="max-content" > - + {hasPermission(EntityType.LANGUAGE, PermissionAction.CREATE) ? ( diff --git a/frontend/src/components/media-library/index.tsx b/frontend/src/components/media-library/index.tsx index 15ea6854..847c7fbe 100644 --- a/frontend/src/components/media-library/index.tsx +++ b/frontend/src/components/media-library/index.tsx @@ -37,9 +37,13 @@ type MediaLibraryProps = { export const MediaLibrary = ({ onSelect, accept }: MediaLibraryProps) => { const { t } = useTranslate(); const formatFileSize = useFormattedFileSize(); - const { onSearch, searchPayload } = useSearch({ - $iLike: ["name"], - }); + const { onSearch, searchPayload, searchText } = useSearch( + { + $iLike: ["name"], + }, + // Sync URL only in the media library page (not the modal) + { syncUrl: !onSelect }, + ); const { dataGridProps } = useFind( { entity: EntityType.ATTACHMENT }, { @@ -151,7 +155,7 @@ export const MediaLibrary = ({ onSelect, accept }: MediaLibraryProps) => { width="max-content" > - + diff --git a/frontend/src/components/nlp/components/NlpEntity.tsx b/frontend/src/components/nlp/components/NlpEntity.tsx index 83b7c959..a8e76cd1 100644 --- a/frontend/src/components/nlp/components/NlpEntity.tsx +++ b/frontend/src/components/nlp/components/NlpEntity.tsx @@ -80,9 +80,12 @@ const NlpEntity = () => { }, }); const [selectedNlpEntities, setSelectedNlpEntities] = useState([]); - const { onSearch, searchPayload } = useSearch({ - $or: ["name", "doc"], - }); + const { onSearch, searchPayload, searchText } = useSearch( + { + $or: ["name", "doc"], + }, + { syncUrl: true }, + ); const { dataGridProps: nlpEntityGrid } = useFind( { entity: EntityType.NLP_ENTITY, @@ -224,7 +227,7 @@ const NlpEntity = () => { flexShrink={0} > - + {hasPermission(EntityType.NLP_ENTITY, PermissionAction.CREATE) ? ( @@ -267,6 +270,7 @@ const NlpEntity = () => { !row.builtin} checkboxSelection onRowSelectionModelChange={handleSelectionChange} /> diff --git a/frontend/src/components/nlp/components/NlpSample.tsx b/frontend/src/components/nlp/components/NlpSample.tsx index c9f214f9..49b3eca1 100644 --- a/frontend/src/components/nlp/components/NlpSample.tsx +++ b/frontend/src/components/nlp/components/NlpSample.tsx @@ -86,13 +86,16 @@ export default function NlpSample() { EntityType.NLP_SAMPLE_ENTITY, ); const getLanguageFromCache = useGetFromCache(EntityType.LANGUAGE); - const { onSearch, searchPayload } = useSearch({ - $eq: [ - ...(type !== "all" ? [{ type }] : []), - ...(language ? [{ language }] : []), - ], - $iLike: ["text"], - }); + const { onSearch, searchPayload, searchText } = useSearch( + { + $eq: [ + ...(type !== "all" ? [{ type }] : []), + ...(language ? [{ language }] : []), + ], + $iLike: ["text"], + }, + { syncUrl: true }, + ); const { mutate: deleteNlpSample } = useDelete(EntityType.NLP_SAMPLE, { onError: () => { toast.error(t("message.internal_server_error")); @@ -317,6 +320,7 @@ export default function NlpSample() { > diff --git a/frontend/src/components/nlp/components/NlpValue.tsx b/frontend/src/components/nlp/components/NlpValue.tsx index aac1c9a3..789a96a4 100644 --- a/frontend/src/components/nlp/components/NlpValue.tsx +++ b/frontend/src/components/nlp/components/NlpValue.tsx @@ -51,10 +51,13 @@ export const NlpValues = ({ entityId }: { entityId: string }) => { entity: EntityType.NLP_ENTITY, format: Format.FULL, }); - const { onSearch, searchPayload } = useSearch({ - $eq: [{ entity: entityId }], - $or: ["doc", "value"], - }); + const { onSearch, searchPayload, searchText } = useSearch( + { + $eq: [{ entity: entityId }], + $or: ["doc", "value"], + }, + { syncUrl: true }, + ); const { dataGridProps } = useFind( { entity: EntityType.NLP_VALUE, format: Format.FULL }, { @@ -228,7 +231,10 @@ export const NlpValues = ({ entityId }: { entityId: string }) => { sx={{ width: "max-content", gap: 1 }} > - + {hasPermission( diff --git a/frontend/src/components/profile/profile.tsx b/frontend/src/components/profile/profile.tsx index 31abe9dc..70752827 100644 --- a/frontend/src/components/profile/profile.tsx +++ b/frontend/src/components/profile/profile.tsx @@ -168,7 +168,7 @@ export const ProfileForm: FC = ({ user }) => { { const { toast } = useToast(); const dialogs = useDialogs(); const hasPermission = useHasPermission(); - const { onSearch, searchPayload } = useSearch({ - $iLike: ["name"], - }); + const { onSearch, searchPayload, searchText } = useSearch( + { + $iLike: ["name"], + }, + { syncUrl: true }, + ); const { dataGridProps } = useFind( { entity: EntityType.ROLE }, { @@ -140,7 +143,7 @@ export const Roles = () => { width="max-content" > - + {hasPermission(EntityType.ROLE, PermissionAction.CREATE) ? ( diff --git a/frontend/src/components/settings/SettingInput.tsx b/frontend/src/components/settings/SettingInput.tsx index 62f336d7..51fc181f 100644 --- a/frontend/src/components/settings/SettingInput.tsx +++ b/frontend/src/components/settings/SettingInput.tsx @@ -118,8 +118,8 @@ const SettingInput: React.FC = ({ entity={EntityType.BLOCK} format={Format.BASIC} labelKey="name" - label={t("label.fallback_block")} - helperText={t("help.fallback_block")} + label={t("label.fallback_message")} + helperText={t("help.fallback_message")} multiple={false} onChange={(_e, selected, ..._) => onChange(selected?.id || "")} {...rest} diff --git a/frontend/src/components/subscribers/index.tsx b/frontend/src/components/subscribers/index.tsx index 23cf33d2..3c03078c 100644 --- a/frontend/src/components/subscribers/index.tsx +++ b/frontend/src/components/subscribers/index.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. @@ -43,10 +43,13 @@ export const Subscribers = () => { { hasCount: false }, ); const [labelFilter, setLabelFilter] = useState(""); - const { onSearch, searchPayload } = useSearch({ - $eq: labelFilter ? [{ labels: [labelFilter] }] : [], - $or: ["first_name", "last_name"], - }); + const { onSearch, searchPayload, searchText } = useSearch( + { + $eq: labelFilter ? [{ labels: [labelFilter] }] : [], + $or: ["first_name", "last_name"], + }, + { syncUrl: true }, + ); const { dataGridProps } = useFind( { entity: EntityType.SUBSCRIBER, format: Format.FULL }, { params: searchPayload }, @@ -172,7 +175,11 @@ export const Subscribers = () => { flexWrap="nowrap" width="50%" > - + { hasCount: false, }, ); - const { onSearch, searchPayload } = useSearch({ - $iLike: ["str"], - }); + const { onSearch, searchPayload, searchText } = useSearch( + { + $iLike: ["str"], + }, + { syncUrl: true }, + ); const { dataGridProps, refetch: refreshTranslations } = useFind( { entity: EntityType.TRANSLATION }, { @@ -152,7 +155,7 @@ export const Translations = () => { width="max-content" > - +