fix(frontend): resolve file conflicts

This commit is contained in:
yassinedorbozgithub
2025-05-30 17:44:48 +01:00
67 changed files with 864 additions and 326 deletions

17
api/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}
}

View File

@@ -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 });

View File

@@ -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);
}
}

View File

@@ -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', () => {

View File

@@ -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<B extends BlockStub>(
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;

View File

@@ -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) {

View File

@@ -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<typeof LLM_NLU_HELPER_NAME>
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<NLU.ParseEntities> {

View File

@@ -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>(Role.name, roleSchema);
const UserModel = mongoose.model<User>(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>(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>(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>(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;
},
};

View File

@@ -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 },
});

View File

@@ -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);

View File

@@ -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()

View File

@@ -232,7 +232,7 @@ describe('NlpEntityService', () => {
{
value: 'jhon',
expressions: ['john', 'joohn', 'jhonny'],
builtin: true,
builtin: false,
doc: '',
},
],

View File

@@ -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,
},
{

View File

@@ -138,6 +138,7 @@ describe('PermissionRepository', () => {
expect(permissionModel.deleteOne).toHaveBeenCalledWith({
_id: permissionToDelete.id,
builtin: { $ne: true },
});
expect(result).toEqual({

View File

@@ -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 <Inbox />;
};
InboxPage.getLayout = function getLayout(page: ReactElement) {
return <Layout hasNoPadding>{page}</Layout>;
};
export default InboxPage;
export const FALLBACK_DEFAULT_NLU_PENALTY_FACTOR = 0.95;

View File

@@ -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(

View File

@@ -555,13 +555,15 @@ export abstract class BaseRepository<
}
async deleteOne(criteria: string | TFilterQuery<T>): Promise<DeleteResult> {
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<T>): Promise<DeleteResult> {
return await this.model.deleteMany(criteria);
return await this.model.deleteMany({ ...criteria, builtin: { $ne: true } });
}
async preCreateValidate(

View File

@@ -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: '',
},
{

View File

@@ -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();
}
}

View File

@@ -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",

View File

@@ -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."
}
}

View File

@@ -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",

View File

@@ -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."
}
}

View File

@@ -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",

View File

@@ -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<FileUploadProps> = ({
const [attachment, setAttachment] = useState<IAttachment | undefined>(
undefined,
);
const uid = useId();
const { t } = useTranslate();
const dialogs = useDialogs();
const [isDragOver, setIsDragOver] = useState<boolean>(false);
@@ -154,11 +155,11 @@ const AttachmentUploader: FC<FileUploadProps> = ({
<HiddenInput
onChange={handleChange}
accept={accept}
id="file-upload"
id={`file-upload${uid}`}
type="file"
/>
<FileUploadLabel
htmlFor="file-upload"
htmlFor={`file-upload${uid}`}
isDragOver={isDragOver}
onMouseEnter={() => setIsDragOver(true)}
onMouseLeave={() => setIsDragOver(false)}
@@ -221,7 +222,7 @@ const AttachmentUploader: FC<FileUploadProps> = ({
{
defaultValues: { accept, onChange },
},
{ maxWidth: "xl" },
{ maxWidth: "xl", isSingleton: true },
)
}
>

View File

@@ -106,7 +106,7 @@ export const Login = () => {
/>
<PasswordInput
label={t("placeholder.password")}
label={t("label.password")}
error={!!errors.password}
required
InputProps={{

View File

@@ -198,7 +198,7 @@ export const Register = () => {
</ContentItem>
<ContentItem>
<PasswordInput
label={t("label.auth_pass")}
label={t("label.password")}
error={!!errors.password}
required
{...register("password")}

View File

@@ -65,7 +65,7 @@ export const ResetPassword = () => {
</Typography>
<PasswordInput
autoFocus
label={t("placeholder.password")}
label={t("label.password")}
error={!!errors.password}
required
InputProps={{

View File

@@ -106,7 +106,7 @@ const AutoCompleteEntitySelect = <
return (
<AutoCompleteSelect<Value, Label, Multiple>
{...(options.length && { value })}
value={value}
onChange={onChange}
label={label}
multiple={multiple}

View File

@@ -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}

View File

@@ -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<TextFieldProps, "value" | "onChange"> {
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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<Input
type="search"
inputRef={ref}
InputProps={{
startAdornment: <Adornment Icon={SearchIcon} />,
endAdornment: clearable && (
<InputAdornment position="end" onClick={handleClear}>
<IconButton size="small" sx={{ marginRight: -1 }}>
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
}}
placeholder={t("placeholder.keywords")}
{...props}
// value={inputValue}
defaultValue={defaultValue}
onChange={handleChange}
/>
);
};

View File

@@ -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<SVGProps<SVGSVGElement>> = ({ ...props }) => {
const NoDataIcon: FC<SVGProps<SVGSVGElement>> = ({ width = 96, ...props }) => {
return (
<svg
height="150"
viewBox="0 0 184 152"
xmlns="http://www.w3.org/2000/svg"
fill="none"
width={width}
viewBox="0 0 452 257"
aria-hidden
focusable="false"
{...props}
>
<title>No data</title>
<g fill="none" fillRule="evenodd">
<g transform="translate(24 31.67)">
<ellipse
fillOpacity=".8"
fill="#F5F5F7"
cx="67.797"
cy="106.89"
rx="67.797"
ry="12.668"
/>
<path
d="M122.034 69.674L98.109 40.229c-1.148-1.386-2.826-2.225-4.593-2.225h-51.44c-1.766 0-3.444.839-4.592 2.225L13.56 69.674v15.383h108.475V69.674z"
fill="#AEB8C2"
/>
<path
d="M101.537 86.214L80.63 61.102c-1.001-1.207-2.507-1.867-4.048-1.867H31.724c-1.54 0-3.047.66-4.048 1.867L6.769 86.214v13.792h94.768V86.214z"
fill="url(#linearGradient-1)"
transform="translate(13.56)"
/>
<path
d="M33.83 0h67.933a4 4 0 0 1 4 4v93.344a4 4 0 0 1-4 4H33.83a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4z"
fill="#F5F5F7"
/>
<path
d="M42.678 9.953h50.237a2 2 0 0 1 2 2V36.91a2 2 0 0 1-2 2H42.678a2 2 0 0 1-2-2V11.953a2 2 0 0 1 2-2zM42.94 49.767h49.713a2.262 2.262 0 1 1 0 4.524H42.94a2.262 2.262 0 0 1 0-4.524zM42.94 61.53h49.713a2.262 2.262 0 1 1 0 4.525H42.94a2.262 2.262 0 0 1 0-4.525zM121.813 105.032c-.775 3.071-3.497 5.36-6.735 5.36H20.515c-3.238 0-5.96-2.29-6.734-5.36a7.309 7.309 0 0 1-.222-1.79V69.675h26.318c2.907 0 5.25 2.448 5.25 5.42v.04c0 2.971 2.37 5.37 5.277 5.37h34.785c2.907 0 5.277-2.421 5.277-5.393V75.1c0-2.972 2.343-5.426 5.25-5.426h26.318v33.569c0 .617-.077 1.216-.221 1.789z"
fill="#DCE0E6"
/>
</g>
<path
d="M149.121 33.292l-6.83 2.65a1 1 0 0 1-1.317-1.23l1.937-6.207c-2.589-2.944-4.109-6.534-4.109-10.408C138.802 8.102 148.92 0 161.402 0 173.881 0 184 8.102 184 18.097c0 9.995-10.118 18.097-22.599 18.097-4.528 0-8.744-1.066-12.28-2.902z"
fill="#DCE0E6"
/>
<g transform="translate(149.65 15.383)" fill="#FFF">
<ellipse cx="20.654" cy="3.167" rx="2.849" ry="2.815" />
<path d="M5.698 5.63H0L2.898.704zM9.259.704h4.985V5.63H9.259z" />
</g>
</g>
<path
className="no-rows-primary"
d="M348 69c-46.392 0-84 37.608-84 84s37.608 84 84 84 84-37.608 84-84-37.608-84-84-84Zm-104 84c0-57.438 46.562-104 104-104s104 46.562 104 104-46.562 104-104 104-104-46.562-104-104Z"
/>
<path
className="no-rows-primary"
d="M308.929 113.929c3.905-3.905 10.237-3.905 14.142 0l63.64 63.64c3.905 3.905 3.905 10.236 0 14.142-3.906 3.905-10.237 3.905-14.142 0l-63.64-63.64c-3.905-3.905-3.905-10.237 0-14.142Z"
/>
<path
className="no-rows-primary"
d="M308.929 191.711c-3.905-3.906-3.905-10.237 0-14.142l63.64-63.64c3.905-3.905 10.236-3.905 14.142 0 3.905 3.905 3.905 10.237 0 14.142l-63.64 63.64c-3.905 3.905-10.237 3.905-14.142 0Z"
/>
<path
className="no-rows-secondary"
d="M0 10C0 4.477 4.477 0 10 0h380c5.523 0 10 4.477 10 10s-4.477 10-10 10H10C4.477 20 0 15.523 0 10ZM0 59c0-5.523 4.477-10 10-10h231c5.523 0 10 4.477 10 10s-4.477 10-10 10H10C4.477 69 0 64.523 0 59ZM0 106c0-5.523 4.477-10 10-10h203c5.523 0 10 4.477 10 10s-4.477 10-10 10H10c-5.523 0-10-4.477-10-10ZM0 153c0-5.523 4.477-10 10-10h195.5c5.523 0 10 4.477 10 10s-4.477 10-10 10H10c-5.523 0-10-4.477-10-10ZM0 200c0-5.523 4.477-10 10-10h203c5.523 0 10 4.477 10 10s-4.477 10-10 10H10c-5.523 0-10-4.477-10-10ZM0 247c0-5.523 4.477-10 10-10h231c5.523 0 10 4.477 10 10s-4.477 10-10 10H10c-5.523 0-10-4.477-10-10Z"
/>
</svg>
);
};

View File

@@ -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 = <T extends GridValidRowModel = any>({
autoHeight={autoHeight}
disableRowSelectionOnClick={disableRowSelectionOnClick}
slots={slots}
slotProps={{
loadingOverlay: {
variant: "linear-progress",
noRowsVariant: "skeleton",
},
}}
showCellVerticalBorder={showCellVerticalBorder}
showColumnVerticalBorder={showColumnVerticalBorder}
sx={sx}

View File

@@ -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 (
<Grid
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
height: "fit-content",
gap: 1,
opacity: 0.5,
paddingY: 1,
}}
>
<StyledGridOverlay>
<NoDataIcon />
<Grid item>
<Typography
style={{
color: "text.secondary",
}}
>
{t("label.no_data")}
</Typography>
</Grid>
</Grid>
<Box sx={{ mt: 2 }}>{t("label.no_data")}</Box>
</StyledGridOverlay>
);
};

View File

@@ -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 ? (
<Box

View File

@@ -43,9 +43,12 @@ export const Categories = () => {
const { toast } = useToast();
const dialogs = useDialogs();
const hasPermission = useHasPermission();
const { onSearch, searchPayload } = useSearch<ICategory>({
$iLike: ["label"],
});
const { onSearch, searchPayload, searchText } = useSearch<ICategory>(
{
$iLike: ["label"],
},
{ syncUrl: true },
);
const { dataGridProps } = useFind(
{ entity: EntityType.CATEGORY },
{
@@ -142,7 +145,7 @@ export const Categories = () => {
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{hasPermission(EntityType.CATEGORY, PermissionAction.CREATE) ? (
<Grid item>

View File

@@ -40,9 +40,12 @@ export const ContentTypes = () => {
const router = useRouter();
const dialogs = useDialogs();
// data fetching
const { onSearch, searchPayload } = useSearch<IContentType>({
$iLike: ["name"],
});
const { onSearch, searchPayload, searchText } = useSearch<IContentType>(
{
$iLike: ["name"],
},
{ syncUrl: true },
);
const { dataGridProps } = useFind(
{ entity: EntityType.CONTENT_TYPE },
{
@@ -99,7 +102,7 @@ export const ContentTypes = () => {
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{hasPermission(EntityType.CONTENT_TYPE, PermissionAction.CREATE) ? (
<Grid item>

View File

@@ -57,10 +57,13 @@ export const Contents = () => {
const queryClient = useQueryClient();
const dialogs = useDialogs();
// data fetching
const { onSearch, searchPayload } = useSearch<IContent>({
$eq: [{ entity: String(query.id) }],
$iLike: ["title"],
});
const { onSearch, searchPayload, searchText } = useSearch<IContent>(
{
$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 = () => {
>
<Grid justifyContent="flex-end" gap={1} container alignItems="center">
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{hasPermission(EntityType.CONTENT, PermissionAction.CREATE) ? (
<ButtonGroup sx={{ marginLeft: "auto" }}>

View File

@@ -43,9 +43,12 @@ export const ContextVars = () => {
const { toast } = useToast();
const dialogs = useDialogs();
const hasPermission = useHasPermission();
const { onSearch, searchPayload } = useSearch<IContextVar>({
$iLike: ["label"],
});
const { onSearch, searchPayload, searchText } = useSearch<IContextVar>(
{
$iLike: ["label"],
},
{ syncUrl: true },
);
const { dataGridProps } = useFind(
{ entity: EntityType.CONTEXT_VAR },
{
@@ -176,7 +179,7 @@ export const ContextVars = () => {
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{hasPermission(EntityType.CONTEXT_VAR, PermissionAction.CREATE) ? (
<Grid item>

View File

@@ -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: {
<Conversation
onClick={() => {
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}

View File

@@ -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 (
<div
dangerouslySetInnerHTML={{
__html: linkedText,
}}
/>
);
} catch (error) {
return <div>{text}</div>;
}
}
/**
* @description this function constructs the message children basen on message type
*/
@@ -79,7 +105,9 @@ export function getMessageContent(
if ("text" in message) {
content.push(
<Message.TextContent key={messageEntity.id} text={message.text} />,
<Message.CustomContent key={messageEntity.id}>
{formatMessageText(message.text)}
</Message.CustomContent>,
);
}
let chips: { title: string }[] = [];

View File

@@ -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;
}

View File

@@ -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<ISubscriber>({
$or: ["first_name", "last_name"],
});
const { onSearch, searchPayload, searchText } = useSearch<ISubscriber>(
{
$or: ["first_name", "last_name"],
},
{ syncUrl: true },
);
const [channels, setChannels] = useState<string[]>([]);
const [assignment, setAssignment] = useState<AssignedTo>(AssignedTo.ALL);
@@ -46,13 +50,10 @@ export const Inbox = () => {
<Grid item width="100%" height="100%" overflow="hidden">
<MainContainer style={{ height: "100%" }}>
<Sidebar position="left">
<Grid paddingX={1} paddingTop={1}>
<Search
value={searchText}
onClearClick={() => onSearch("")}
className="changeColor"
onChange={(v) => onSearch(v)}
placeholder="Search..."
<Grid paddingX={1} paddingTop={2} paddingBottom={1} mx={1}>
<FilterTextfield
onChange={onSearch}
defaultValue={searchText}
/>
</Grid>
<Grid

View File

@@ -42,9 +42,12 @@ export const Labels = () => {
const { toast } = useToast();
const dialogs = useDialogs();
const hasPermission = useHasPermission();
const { onSearch, searchPayload } = useSearch<ILabel>({
$or: ["name", "title"],
});
const { onSearch, searchPayload, searchText } = useSearch<ILabel>(
{
$or: ["name", "title"],
},
{ syncUrl: true },
);
const { dataGridProps } = useFind(
{ entity: EntityType.LABEL, format: Format.FULL },
{
@@ -173,7 +176,7 @@ export const Labels = () => {
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{hasPermission(EntityType.LABEL, PermissionAction.CREATE) ? (
<Grid item>

View File

@@ -43,9 +43,12 @@ export const Languages = () => {
const dialogs = useDialogs();
const queryClient = useQueryClient();
const hasPermission = useHasPermission();
const { onSearch, searchPayload } = useSearch<ILanguage>({
$or: ["title", "code"],
});
const { onSearch, searchPayload, searchText } = useSearch<ILanguage>(
{
$or: ["title", "code"],
},
{ syncUrl: true },
);
const { dataGridProps, refetch } = useFind(
{ entity: EntityType.LANGUAGE },
{
@@ -197,7 +200,7 @@ export const Languages = () => {
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{hasPermission(EntityType.LANGUAGE, PermissionAction.CREATE) ? (
<Grid item>

View File

@@ -37,9 +37,13 @@ type MediaLibraryProps = {
export const MediaLibrary = ({ onSelect, accept }: MediaLibraryProps) => {
const { t } = useTranslate();
const formatFileSize = useFormattedFileSize();
const { onSearch, searchPayload } = useSearch<IAttachment>({
$iLike: ["name"],
});
const { onSearch, searchPayload, searchText } = useSearch<IAttachment>(
{
$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"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
</Grid>
</PageHeader>

View File

@@ -80,9 +80,12 @@ const NlpEntity = () => {
},
});
const [selectedNlpEntities, setSelectedNlpEntities] = useState<string[]>([]);
const { onSearch, searchPayload } = useSearch<INlpEntity>({
$or: ["name", "doc"],
});
const { onSearch, searchPayload, searchText } = useSearch<INlpEntity>(
{
$or: ["name", "doc"],
},
{ syncUrl: true },
);
const { dataGridProps: nlpEntityGrid } = useFind(
{
entity: EntityType.NLP_ENTITY,
@@ -224,7 +227,7 @@ const NlpEntity = () => {
flexShrink={0}
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{hasPermission(EntityType.NLP_ENTITY, PermissionAction.CREATE) ? (
@@ -267,6 +270,7 @@ const NlpEntity = () => {
<DataGrid
columns={nlpEntityColumns}
{...nlpEntityGrid}
isRowSelectable={({ row }) => !row.builtin}
checkboxSelection
onRowSelectionModelChange={handleSelectionChange}
/>

View File

@@ -86,13 +86,16 @@ export default function NlpSample() {
EntityType.NLP_SAMPLE_ENTITY,
);
const getLanguageFromCache = useGetFromCache(EntityType.LANGUAGE);
const { onSearch, searchPayload } = useSearch<INlpSample>({
$eq: [
...(type !== "all" ? [{ type }] : []),
...(language ? [{ language }] : []),
],
$iLike: ["text"],
});
const { onSearch, searchPayload, searchText } = useSearch<INlpSample>(
{
$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() {
>
<FilterTextfield
onChange={onSearch}
defaultValue={searchText}
fullWidth={false}
sx={{ minWidth: "256px" }}
/>

View File

@@ -51,10 +51,13 @@ export const NlpValues = ({ entityId }: { entityId: string }) => {
entity: EntityType.NLP_ENTITY,
format: Format.FULL,
});
const { onSearch, searchPayload } = useSearch<INlpValue>({
$eq: [{ entity: entityId }],
$or: ["doc", "value"],
});
const { onSearch, searchPayload, searchText } = useSearch<INlpValue>(
{
$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 }}
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield
onChange={onSearch}
defaultValue={searchText}
/>
</Grid>
<ButtonGroup sx={{ marginLeft: "auto" }}>
{hasPermission(

View File

@@ -168,7 +168,7 @@ export const ProfileForm: FC<ProfileFormProps> = ({ user }) => {
</ContentItem>
<ContentItem>
<PasswordInput
label={t("placeholder.password")}
label={t("label.password")}
{...register("password")}
required
error={!!errors.password}

View File

@@ -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.
@@ -40,9 +40,12 @@ export const Roles = () => {
const { toast } = useToast();
const dialogs = useDialogs();
const hasPermission = useHasPermission();
const { onSearch, searchPayload } = useSearch<IRole>({
$iLike: ["name"],
});
const { onSearch, searchPayload, searchText } = useSearch<IRole>(
{
$iLike: ["name"],
},
{ syncUrl: true },
);
const { dataGridProps } = useFind(
{ entity: EntityType.ROLE },
{
@@ -140,7 +143,7 @@ export const Roles = () => {
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{hasPermission(EntityType.ROLE, PermissionAction.CREATE) ? (
<Grid item>

View File

@@ -118,8 +118,8 @@ const SettingInput: React.FC<RenderSettingInputProps> = ({
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}

View File

@@ -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<string>("");
const { onSearch, searchPayload } = useSearch<ISubscriber>({
$eq: labelFilter ? [{ labels: [labelFilter] }] : [],
$or: ["first_name", "last_name"],
});
const { onSearch, searchPayload, searchText } = useSearch<ISubscriber>(
{
$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%"
>
<FilterTextfield onChange={onSearch} fullWidth={true} />
<FilterTextfield
onChange={onSearch}
fullWidth={true}
defaultValue={searchText}
/>
<Input
select
label={t("label.labels")}

View File

@@ -44,9 +44,12 @@ export const Translations = () => {
hasCount: false,
},
);
const { onSearch, searchPayload } = useSearch<ITranslation>({
$iLike: ["str"],
});
const { onSearch, searchPayload, searchText } = useSearch<ITranslation>(
{
$iLike: ["str"],
},
{ syncUrl: true },
);
const { dataGridProps, refetch: refreshTranslations } = useFind(
{ entity: EntityType.TRANSLATION },
{
@@ -152,7 +155,7 @@ export const Translations = () => {
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
<Grid item>
<Button

View File

@@ -76,7 +76,7 @@ export const EditUserForm: FC<ComponentFormProps<IUser, IRole[]>> = ({
<ContentItem>
<Input
disabled
label={t("label.auth_user")}
label={t("label.full_name")}
value={user ? getFullName(user) : undefined}
InputProps={{
readOnly: true,

View File

@@ -53,9 +53,12 @@ export const Users = () => {
},
});
const hasPermission = useHasPermission();
const { onSearch, searchPayload } = useSearch<IUser>({
$or: ["first_name", "last_name", "email"],
});
const { onSearch, searchPayload, searchText } = useSearch<IUser>(
{
$or: ["first_name", "last_name", "email"],
},
{ syncUrl: true },
);
const { data: roles } = useFind(
{
entity: EntityType.ROLE,
@@ -102,7 +105,7 @@ export const Users = () => {
headerName: t("label.name"),
sortable: false,
disableColumnMenu: true,
valueGetter: (params, val) => `${val.first_name} ${val.last_name}`,
valueGetter: (_params, val) => `${val.first_name} ${val.last_name}`,
headerAlign: "left",
renderHeader,
},
@@ -198,7 +201,7 @@ export const Users = () => {
width="max-content"
>
<Grid item>
<FilterTextfield onChange={onSearch} />
<FilterTextfield onChange={onSearch} defaultValue={searchText} />
</Grid>
{!ssoEnabled &&
hasPermission(EntityType.USER, PermissionAction.CREATE) ? (

View File

@@ -47,7 +47,7 @@ const ListMessageForm = () => {
formState: { errors },
} = useFormContext();
const contentTypeId = watch("options.content.entity");
const displayMode = watch("options.content.display_mode");
const displayMode = watch("options.content.display");
const { data: contentType } = useGet(contentTypeId, {
entity: EntityType.CONTENT_TYPE,
});
@@ -61,10 +61,9 @@ const ListMessageForm = () => {
<FormControl>
<FormLabel>{t("label.display_mode")}</FormLabel>
<Controller
rules={{ required: true }}
control={control}
defaultValue={content?.display || "list"}
name="options.content.display_mode"
defaultValue={content?.display || OutgoingMessageFormat.list}
name="options.content.display"
render={({ field }) => (
<RadioGroup row {...field}>
{[
@@ -75,10 +74,7 @@ const ListMessageForm = () => {
key={display}
value={display}
control={
<Radio
defaultChecked={display === content?.display}
{...register("options.content.display")}
/>
<Radio defaultChecked={display === content?.display} />
}
label={t(`label.${display}`)}
/>
@@ -117,16 +113,26 @@ const ListMessageForm = () => {
label={t("label.content_limit")}
type="number"
inputProps={{
maxLength: 25,
step: "1",
min: 2,
max: 4,
maxLength: 2,
step: 1,
min: displayMode === OutgoingMessageFormat.list ? 2 : 1,
max: displayMode === OutgoingMessageFormat.list ? 4 : 10,
}}
{...register("options.content.limit", {
validate: {
min: (value) =>
(value && value >= 2 && value <= 4) ||
t("message.invalid_list_limit"),
limitRange: (value) => {
if (
displayMode === OutgoingMessageFormat.list &&
(value < 2 || value > 4)
) {
return t("message.invalid_list_limit");
} else if (
displayMode === OutgoingMessageFormat.carousel &&
(value < 1 || value > 10)
) {
return t("message.invalid_carousel_limit");
}
},
},
valueAsNumber: true,
})}
@@ -182,7 +188,7 @@ const ListMessageForm = () => {
}}
defaultValue={content?.fields?.title}
render={({ field }) => {
const { onChange, value, ...rest } = field;
const { onChange, ...rest } = field;
const options = (contentType?.fields || []).filter(
({ type }) => ContentFieldType.TEXT === type,
);
@@ -194,7 +200,6 @@ const ListMessageForm = () => {
labelKey="label"
label={t("label.title")}
multiple={false}
{...(options.length && { value })}
{...rest}
onChange={(_e, selected) => onChange(selected?.name)}
error={!!errors?.options?.["content"]?.fields?.title}
@@ -212,7 +217,7 @@ const ListMessageForm = () => {
control={control}
defaultValue={content?.fields?.subtitle}
render={({ field }) => {
const { onChange, value, ...rest } = field;
const { onChange, ...rest } = field;
const options = (contentType?.fields || []).filter(
({ type }) =>
ContentFieldType.TEXT === type ||
@@ -227,7 +232,6 @@ const ListMessageForm = () => {
label={t("label.subtitle")}
multiple={false}
onChange={(_e, selected) => onChange(selected?.name)}
{...(options.length && { value })}
{...rest}
/>
);
@@ -240,7 +244,7 @@ const ListMessageForm = () => {
control={control}
defaultValue={content?.fields?.image_url}
render={({ field }) => {
const { onChange, value, ...rest } = field;
const { onChange, ...rest } = field;
const options = (contentType?.fields || []).filter(({ type }) =>
[ContentFieldType.FILE].includes(type),
);
@@ -253,7 +257,6 @@ const ListMessageForm = () => {
label={t("label.image_url")}
multiple={false}
onChange={(_e, selected) => onChange(selected?.name)}
{...(options.length && { value })}
{...rest}
/>
);
@@ -266,7 +269,7 @@ const ListMessageForm = () => {
control={control}
defaultValue={content?.fields?.url}
render={({ field }) => {
const { onChange, value, ...rest } = field;
const { onChange, ...rest } = field;
const options = (contentType?.fields || []).filter(({ type }) =>
[ContentFieldType.URL].includes(type),
);
@@ -279,7 +282,6 @@ const ListMessageForm = () => {
label={t("label.url")}
multiple={false}
onChange={(_e, selected) => onChange(selected?.name)}
{...(options.length && { value })}
{...rest}
/>
);

View File

@@ -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.
@@ -86,6 +86,7 @@ export const useFind = <
);
}
},
keepPreviousData: true,
...otherOptions,
});
const data = (ids || [])
@@ -99,7 +100,7 @@ export const useFind = <
dataGridProps: {
...dataGridPaginationProps,
rows: data || [],
loading: normalizedQuery.isLoading,
loading: normalizedQuery.isLoading || normalizedQuery.isFetching,
},
};
};

View File

@@ -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.
@@ -74,6 +74,7 @@ export const useInfiniteFind = <
return result;
},
keepPreviousData: true,
...(otherOptions || {}),
});

View File

@@ -6,9 +6,7 @@
* 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 { debounce } from "@mui/material";
import { useRouter } from "next/router";
import { ChangeEvent, useCallback, useEffect, useState } from "react";
import { ChangeEvent, useState } from "react";
import {
TBuildInitialParamProps,
@@ -16,6 +14,8 @@ import {
TParamItem,
} from "@/types/search.types";
import { useUrlQueryParam } from "./useUrlQueryParam";
const buildOrParams = <T,>({ params, searchText }: TBuildParamProps<T>) => ({
or: params?.map((field) => ({
[field]: { contains: searchText },
@@ -52,49 +52,39 @@ const buildNeqInitialParams = <T,>({
{},
);
export const useSearch = <T,>(params: TParamItem<T>) => {
const router = useRouter();
const [searchText, setSearchText] = useState<string>(
(router.query.search as string) || "",
);
interface SearchHookOptions {
syncUrl?: boolean;
}
useEffect(() => {
if (router.query.search !== searchText) {
setSearchText((router.query.search as string) || "");
}
}, [router.query.search]);
const updateQueryParams = useCallback(
debounce(async (newSearchText: string) => {
await router.replace(
{
pathname: router.pathname,
query: { ...router.query, search: newSearchText || undefined },
},
undefined,
{ shallow: true },
);
}, 300),
[router],
);
const onSearch = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | string,
) => {
const newSearchText = typeof e === "string" ? e : e.target.value;
setSearchText(newSearchText);
updateQueryParams(newSearchText);
};
export const useSearch = <T,>(
params: TParamItem<T>,
options: SearchHookOptions = { syncUrl: false },
) => {
const { syncUrl } = options;
const [searchQuery, setSearchQuery] = useUrlQueryParam("search", "");
const [search, setSearch] = useState<string>("");
const {
$eq: eqInitialParams,
$iLike: iLikeParams,
$neq: neqInitialParams,
$or: orParams,
} = params;
const searchText = syncUrl ? searchQuery : search;
return {
searchText,
onSearch,
onSearch: (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | string,
) => {
const newValue =
typeof e === "object" ? e.target.value.toString() : e.toString();
if (syncUrl) {
setSearchQuery(newValue);
} else {
setSearch(newValue);
}
},
searchPayload: {
where: {
...buildEqInitialParams({ initialParams: eqInitialParams }),

View File

@@ -0,0 +1,119 @@
/*
* 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 { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react';
type QueryParamSerializer<T> = {
parse: (raw: string | string[] | undefined) => T;
stringify: (val: T) => string | string[] | undefined;
};
export function defaultSerializer<T = string>(): QueryParamSerializer<T> {
return {
parse: (raw) => {
if (Array.isArray(raw)) return raw[0] as unknown as T;
if (typeof raw === "undefined") return "" as unknown as T;
return raw as unknown as T;
},
stringify: (val) => val as unknown as string,
};
}
export function booleanSerializer(): QueryParamSerializer<boolean> {
return {
parse: (raw) => raw === "true",
stringify: (val) => (val ? "true" : "false"),
};
}
export function numberSerializer(): QueryParamSerializer<number> {
return {
parse: (raw) => {
const value = Number(Array.isArray(raw) ? raw[0] : raw);
return isNaN(value) ? 0 : value;
},
stringify: (val) => val.toString(),
};
}
export function arraySerializer(): QueryParamSerializer<string[]> {
return {
parse: (raw) => (Array.isArray(raw) ? raw : raw ? [raw] : []),
stringify: (val) => val,
};
}
export const useUrlQueryParam = <T>(
key: string,
defaultValue: T,
serializer: QueryParamSerializer<T> = defaultSerializer(),
): [T, (val: T) => void] => {
const router = useRouter();
const [value, setValue] = useState<T>(() => {
// On initial load, use query or default
const initial = router.query[key];
if (initial === undefined) return defaultValue;
// parse value if needed (e.g., numbers)
try {
return serializer.parse(initial) as T;
} catch {
return initial as unknown as T;
}
});
// Sync from URL to state on changes (including initial load when ready)
useEffect(() => {
if (!router.isReady) return;
const urlValue = router.query[key];
let parsedVal: T = defaultValue;
if (urlValue !== undefined) {
try {
parsedVal = serializer.parse(urlValue);
} catch {
parsedVal = urlValue as unknown as T;
}
} else {
parsedVal = defaultValue;
}
if (parsedVal !== value) {
setValue(parsedVal);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady, router.query[key]]);
// Update URL when state changes
const updateValue = useCallback(
(val: T) => {
setValue(val);
if (!router.isReady) return;
const newQuery = { ...router.query };
if (
val === defaultValue ||
val === undefined ||
serializer.stringify(val) === ""
) {
delete newQuery[key];
} else {
newQuery[key] = serializer.stringify(val);
}
router.push({ pathname: router.pathname, query: newQuery }, undefined, {
shallow: true,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[router, key, defaultValue],
);
return [value, updateValue];
};

View File

@@ -6,8 +6,6 @@
* 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 qs from "qs";
export const buildURL = (baseUrl: string, relativePath: string): string => {
try {
return new URL(relativePath).toString();
@@ -39,12 +37,3 @@ export const isAbsoluteUrl = (value: string = ""): boolean => {
return false;
}
};
// todo: in the future we might need to extract this logic into a hook
export const extractQueryParamsUrl = (fullUrl: string): string => {
const extractedQueryParams = qs.parse(new URL(fullUrl).search, {
ignoreQueryPrefix: true,
});
return qs.stringify(extractedQueryParams);
};

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "hexabot",
"version": "2.2.7",
"version": "2.2.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hexabot",
"version": "2.2.7",
"version": "2.2.8",
"license": "AGPL-3.0-only",
"workspaces": [
"frontend",

View File

@@ -1,5 +1,10 @@
a.chatLink {
color: inherit !important;
.sc-message--text-content a.chatLink {
text-decoration: underline;
transition: color 0.2s ease;
&:hover {
color: var(--launcher-color);
}
}
p.sc-message--text-content {
margin: 0 !important;

View File

@@ -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.
@@ -34,6 +34,7 @@ const TextMessage: React.FC<TextMessageProps> = ({ message }) => {
messageTextRef.current.innerHTML = Autolinker.link(text, {
className: "chatLink",
truncate: { length: 50, location: "smart" },
sanitizeHtml: true,
});
}
};
@@ -50,6 +51,7 @@ const TextMessage: React.FC<TextMessageProps> = ({ message }) => {
style={{
color: colors.text,
backgroundColor: colors.bg,
["--launcher-color" as string]: allColors.launcher.bg,
}}
>
<p className="sc-message--text-content" ref={messageTextRef}>