From 7e48eb6067e43e8e9ceccf0d7b9fc59b4ec67d3e Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 3 Mar 2025 09:54:31 +0100 Subject: [PATCH 1/3] feat: add outcome attribute --- .../chat/controllers/block.controller.spec.ts | 3 +- api/src/chat/dto/block.dto.ts | 19 ++- api/src/chat/schemas/block.schema.ts | 6 + api/src/chat/schemas/types/button.ts | 1 + api/src/chat/schemas/types/message.ts | 38 ++++- api/src/chat/services/block.service.ts | 138 +++++++++++---- api/src/chat/services/bot.service.spec.ts | 4 +- api/src/chat/services/bot.service.ts | 160 +++++++++++------- .../i18n/services/translation.service.spec.ts | 1 + api/src/utils/test/fixtures/block.ts | 5 + 10 files changed, 267 insertions(+), 108 deletions(-) diff --git a/api/src/chat/controllers/block.controller.spec.ts b/api/src/chat/controllers/block.controller.spec.ts index 6680c9fb..d7d16bbc 100644 --- a/api/src/chat/controllers/block.controller.spec.ts +++ b/api/src/chat/controllers/block.controller.spec.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 Hexastack. All rights reserved. * * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. @@ -251,6 +251,7 @@ describe('BlockController', () => { name: 'block with nextBlocks', nextBlocks: [hasNextBlocks.id], patterns: ['Hi'], + outcomes: [], trigger_labels: [], assign_labels: [], trigger_channels: [], diff --git a/api/src/chat/dto/block.dto.ts b/api/src/chat/dto/block.dto.ts index b7564714..6abf03f7 100644 --- a/api/src/chat/dto/block.dto.ts +++ b/api/src/chat/dto/block.dto.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 Hexastack. All rights reserved. * * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. @@ -45,6 +45,14 @@ export class BlockCreateDto { @IsPatternList({ message: 'Patterns list is invalid' }) patterns?: Pattern[] = []; + @ApiPropertyOptional({ + description: "Block's outcomes", + type: Array, + }) + @IsOptional() + @IsArray({ message: 'Outcomes are invalid' }) + outcomes?: string[] = []; + @ApiPropertyOptional({ description: 'Block trigger labels', type: Array }) @IsOptional() @IsArray() @@ -120,6 +128,7 @@ export class BlockCreateDto { export class BlockUpdateDto extends PartialType( OmitType(BlockCreateDto, [ 'patterns', + 'outcomes', 'trigger_labels', 'assign_labels', 'trigger_channels', @@ -130,6 +139,14 @@ export class BlockUpdateDto extends PartialType( @IsPatternList({ message: 'Patterns list is invalid' }) patterns?: Pattern[]; + @ApiPropertyOptional({ + description: "Block's outcomes", + type: Array, + }) + @IsOptional() + @IsArray({ message: 'Outcomes are invalid' }) + outcomes?: string[]; + @ApiPropertyOptional({ description: 'Block trigger labels', type: Array }) @IsOptional() @IsArray() diff --git a/api/src/chat/schemas/block.schema.ts b/api/src/chat/schemas/block.schema.ts index befb45a1..7d24d11c 100644 --- a/api/src/chat/schemas/block.schema.ts +++ b/api/src/chat/schemas/block.schema.ts @@ -42,6 +42,12 @@ export class BlockStub extends BaseSchema { }) patterns: Pattern[]; + @Prop({ + type: Object, + default: [], + }) + outcomes: string[]; + @Prop([ { type: MongooseSchema.Types.ObjectId, diff --git a/api/src/chat/schemas/types/button.ts b/api/src/chat/schemas/types/button.ts index 56a150c2..fb209779 100644 --- a/api/src/chat/schemas/types/button.ts +++ b/api/src/chat/schemas/types/button.ts @@ -40,4 +40,5 @@ export enum PayloadType { attachments = 'attachments', quick_reply = 'quick_reply', button = 'button', + outcome = 'outcome', } diff --git a/api/src/chat/schemas/types/message.ts b/api/src/chat/schemas/types/message.ts index d86e20cd..b0ca494e 100644 --- a/api/src/chat/schemas/types/message.ts +++ b/api/src/chat/schemas/types/message.ts @@ -6,14 +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). */ -/* - * 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 { z } from 'zod'; import { PluginName } from '@/plugins/types'; @@ -62,6 +54,7 @@ export enum OutgoingMessageFormat { attachment = 'attachment', list = 'list', carousel = 'carousel', + system = 'system', } export const outgoingMessageFormatSchema = z.nativeEnum(OutgoingMessageFormat); @@ -147,6 +140,15 @@ export type StdOutgoingAttachmentMessage = z.infer< typeof stdOutgoingAttachmentMessageSchema >; +export const stdOutgoingSystemMessageSchema = z.object({ + outcome: z.string().optional(), // "any" or any other string (in snake case) + data: z.any().optional(), +}); + +export type StdOutgoingSystemMessage = z.infer< + typeof stdOutgoingSystemMessageSchema +>; + export const pluginNameSchema = z .string() .regex(/-plugin$/) as z.ZodType; @@ -290,7 +292,16 @@ export type StdOutgoingAttachmentEnvelope = z.infer< typeof stdOutgoingAttachmentEnvelopeSchema >; -export const stdOutgoingEnvelopeSchema = z.union([ +export const stdOutgoingSystemEnvelopeSchema = z.object({ + format: z.literal(OutgoingMessageFormat.system), + message: stdOutgoingSystemMessageSchema, +}); + +export type StdOutgoingSystemEnvelope = z.infer< + typeof stdOutgoingSystemEnvelopeSchema +>; + +export const stdOutgoingMessageEnvelopeSchema = z.union([ stdOutgoingTextEnvelopeSchema, stdOutgoingQuickRepliesEnvelopeSchema, stdOutgoingButtonsEnvelopeSchema, @@ -298,6 +309,15 @@ export const stdOutgoingEnvelopeSchema = z.union([ stdOutgoingAttachmentEnvelopeSchema, ]); +export type StdOutgoingMessageEnvelope = z.infer< + typeof stdOutgoingMessageEnvelopeSchema +>; + +export const stdOutgoingEnvelopeSchema = z.union([ + stdOutgoingMessageEnvelopeSchema, + stdOutgoingSystemEnvelopeSchema, +]); + export type StdOutgoingEnvelope = z.infer; // is-valid-message-text validation diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 7f5625f3..04028bc8 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -11,6 +11,7 @@ import { OnEvent } from '@nestjs/event-emitter'; import { AttachmentService } from '@/attachment/services/attachment.service'; import EventWrapper from '@/channel/lib/EventWrapper'; +import { ChannelName } from '@/channel/types'; import { ContentService } from '@/cms/services/content.service'; import { CONSOLE_CHANNEL_NAME } from '@/extensions/channels/console/settings'; import { NLU } from '@/helper/types'; @@ -27,11 +28,13 @@ import { BlockDto } from '../dto/block.dto'; import { BlockRepository } from '../repositories/block.repository'; import { Block, BlockFull, BlockPopulate } from '../schemas/block.schema'; import { Label } from '../schemas/label.schema'; +import { Subscriber } from '../schemas/subscriber.schema'; import { Context } from '../schemas/types/context'; import { BlockMessage, OutgoingMessageFormat, StdOutgoingEnvelope, + StdOutgoingSystemEnvelope, } from '../schemas/types/message'; import { NlpPattern, PayloadPattern } from '../schemas/types/pattern'; import { Payload, StdQuickReply } from '../schemas/types/quick-reply'; @@ -57,10 +60,75 @@ export class BlockService extends BaseService< super(repository); } + /** + * Filters an array of blocks based on the specified channel. + * + * This function ensures that only blocks that are either: + * - Not restricted to specific trigger channels (`trigger_channels` is undefined or empty), or + * - Explicitly allow the given channel (or the console channel) + * + * are included in the returned array. + * + * @param blocks - The list of blocks to be filtered. + * @param channel - The name of the channel to filter blocks by. + * + * @returns The filtered array of blocks that are allowed for the given channel. + */ + filterBlocksByChannel( + blocks: B[], + channel: ChannelName, + ) { + return blocks.filter((b) => { + return ( + !b.trigger_channels || + b.trigger_channels.length === 0 || + [...b.trigger_channels, CONSOLE_CHANNEL_NAME].includes(channel) + ); + }); + } + + /** + * Filters an array of blocks based on subscriber labels. + * + * This function selects blocks that either: + * - Have no trigger labels (making them applicable to all subscribers), or + * - Contain at least one trigger label that matches a label from the provided list. + * + * The filtered blocks are then **sorted** in descending order by the number of trigger labels, + * ensuring that blocks with more specific targeting (more trigger labels) are prioritized. + * + * @param blocks - The list of blocks to be filtered. + * @param labels - The list of subscriber labels to match against. + * @returns The filtered and sorted list of blocks. + */ + filterBlocksBySubscriberLabels( + blocks: B[], + profile?: Subscriber, + ) { + if (!profile) { + return blocks; + } + + return ( + blocks + .filter((b) => { + const triggerLabels = b.trigger_labels.map((l) => + typeof l === 'string' ? l : l.id, + ); + return ( + triggerLabels.length === 0 || + triggerLabels.some((l) => profile.labels.includes(l)) + ); + }) + // Priority goes to block who target users with labels + .sort((a, b) => b.trigger_labels.length - a.trigger_labels.length) + ); + } + /** * Find a block whose patterns matches the received event * - * @param blocks blocks Starting/Next blocks in the conversation flow + * @param filteredBlocks blocks Starting/Next blocks in the conversation flow * @param event Received channel's message * * @returns The block that matches @@ -77,37 +145,15 @@ export class BlockService extends BaseService< let block: BlockFull | undefined = undefined; const payload = event.getPayload(); - // Perform a filter on the specific channels - const channel = event.getHandler().getName(); - blocks = blocks.filter((b) => { - return ( - !b.trigger_channels || - b.trigger_channels.length === 0 || - [...b.trigger_channels, CONSOLE_CHANNEL_NAME].includes(channel) - ); - }); - - // Perform a filter on trigger labels - let userLabels: string[] = []; - const profile = event.getSender(); - if (profile && Array.isArray(profile.labels)) { - userLabels = profile.labels.map((l) => l); - } - - blocks = blocks - .filter((b) => { - const trigger_labels = b.trigger_labels.map(({ id }) => id); - return ( - trigger_labels.length === 0 || - trigger_labels.some((l) => userLabels.includes(l)) - ); - }) - // Priority goes to block who target users with labels - .sort((a, b) => b.trigger_labels.length - a.trigger_labels.length); + // Perform a filter to get the candidates blocks + const filteredBlocks = this.filterBlocksBySubscriberLabels( + this.filterBlocksByChannel(blocks, event.getHandler().getName()), + event.getSender(), + ); // Perform a payload match & pick last createdAt if (payload) { - block = blocks + block = filteredBlocks .filter((b) => { return this.matchPayload(payload, b); }) @@ -131,7 +177,7 @@ export class BlockService extends BaseService< } // Perform a text pattern match - block = blocks + block = filteredBlocks .filter((b) => { return this.matchText(text, b); }) @@ -141,7 +187,7 @@ export class BlockService extends BaseService< if (!block && nlp) { // Find block pattern having the best match of nlp entities let nlpBest = 0; - blocks.forEach((b, index, self) => { + filteredBlocks.forEach((b, index, self) => { const nlpPattern = this.matchNLP(nlp, b); if (nlpPattern && nlpPattern.length > nlpBest) { nlpBest = nlpPattern.length; @@ -295,6 +341,36 @@ export class BlockService extends BaseService< }); } + /** + * Matches an outcome-based block from a list of available blocks + * based on the outcome of a system message. + * + * @param blocks - An array of blocks to search for a matching outcome. + * @param envelope - The system message envelope containing the outcome to match. + * + * @returns - Returns the first matching block if found, otherwise returns `undefined`. + */ + matchOutcome( + blocks: Block[], + event: EventWrapper, + envelope: StdOutgoingSystemEnvelope, + ) { + // Perform a filter to get the candidates blocks + const filteredBlocks = this.filterBlocksBySubscriberLabels( + this.filterBlocksByChannel(blocks, event.getHandler().getName()), + event.getSender(), + ); + return filteredBlocks.find((b) => { + return b.patterns + .filter( + (p) => typeof p === 'object' && 'type' in p && p.type === 'outcome', + ) + .some((p: PayloadPattern) => + ['any', envelope.message.outcome].includes(p.value), + ); + }); + } + /** * Replaces tokens with their context variables values in the provided text message * diff --git a/api/src/chat/services/bot.service.spec.ts b/api/src/chat/services/bot.service.spec.ts index c4bf517a..db918a8a 100644 --- a/api/src/chat/services/bot.service.spec.ts +++ b/api/src/chat/services/bot.service.spec.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 Hexastack. All rights reserved. * * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. @@ -201,7 +201,7 @@ describe('BlockService', () => { let hasBotSpoken = false; const clearMock = jest - .spyOn(botService, 'findBlockAndSendReply') + .spyOn(botService, 'triggerBlock') .mockImplementation( ( actualEvent: WebEventWrapper, diff --git a/api/src/chat/services/bot.service.ts b/api/src/chat/services/bot.service.ts index 96e87519..6548bd98 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -21,8 +21,11 @@ import { getDefaultConversationContext, } from '../schemas/conversation.schema'; import { Context } from '../schemas/types/context'; -import { IncomingMessageType } from '../schemas/types/message'; -import { SubscriberContext } from '../schemas/types/subscriberContext'; +import { + IncomingMessageType, + OutgoingMessageFormat, + StdOutgoingMessageEnvelope, +} from '../schemas/types/message'; import { BlockService } from './block.service'; import { ConversationService } from './conversation.service'; @@ -40,40 +43,24 @@ export class BotService { ) {} /** - * Sends a processed message to the user based on a specified content block. - * Replaces tokens within the block with context data, handles fallback scenarios, - * and assigns relevant labels to the user. + * Sends a message to the subscriber via the appropriate messaging channel and handles related events. * - * @param event - The incoming message or action that triggered the bot's response. + * @param envelope - The outgoing message envelope containing the bot's response. * @param block - The content block containing the message and options to be sent. * @param context - Optional. The conversation context object, containing relevant data for personalization. * @param fallback - Optional. Boolean flag indicating if this is a fallback message when no appropriate response was found. - * @param conversationId - Optional. The conversation ID to link the message to a specific conversation thread. - * - * @returns A promise that resolves with the message response, including the message ID. */ async sendMessageToSubscriber( + envelope: StdOutgoingMessageEnvelope, event: EventWrapper, block: BlockFull, context?: Context, fallback?: boolean, - conservationId?: string, ) { - context = context || getDefaultConversationContext(); - fallback = typeof fallback !== 'undefined' ? fallback : false; const options = block.options; - this.logger.debug('Sending message ... ', event.getSenderForeignId()); - // Process message : Replace tokens with context data and then send the message const recipient = event.getSender(); - const envelope = await this.blockService.processMessage( - block, - context, - recipient?.context as SubscriberContext, - fallback, - conservationId, - ); // Send message through the right channel - + this.logger.debug('Sending message ... ', event.getSenderForeignId()); const response = await event .getHandler() .sendMessage(event, envelope, options, context); @@ -114,35 +101,56 @@ export class BotService { ); this.logger.debug('Assigned labels ', blockLabels); - return response; } /** - * Finds an appropriate reply block and sends it to the user. - * If there are additional blocks or attached blocks, it continues the conversation flow. - * Ends the conversation if no further blocks are available. + * Processes and executes a block, handling its associated messages and flow logic. + * + * The function performs the following steps: + * 1. Retrieves the conversation context and recipient information. + * 2. Generates an outgoing message envelope from the block. + * 3. Sends the message to the subscriber unless it's a system message. + * 4. Handles block chaining: + * - If the block has an attached block, it recursively triggers the attached block. + * - If the block has multiple possible next blocks, it determines the next block based on the outcome of the system message. + * - If there are next blocks but no outcome-based matching, it updates the conversation state for the next steps. + * 5. If no further blocks exist, it ends the flow execution. * * @param event - The incoming message or action that initiated this response. * @param convo - The current conversation context and flow. * @param block - The content block to be processed and sent. * @param fallback - Boolean indicating if this is a fallback response in case no appropriate reply was found. * - * @returns A promise that continues or ends the conversation based on available blocks. + * @returns A promise that either continues or ends the flow execution based on the available blocks. */ - async findBlockAndSendReply( + async triggerBlock( event: EventWrapper, convo: Conversation, block: BlockFull, - fallback: boolean, + fallback: boolean = false, ) { try { - await this.sendMessageToSubscriber( - event, + const context = convo.context || getDefaultConversationContext(); + const recipient = event.getSender(); + + const envelope = await this.blockService.processMessage( block, - convo.context, + context, + recipient?.context, fallback, convo.id, ); + + if (envelope.format !== OutgoingMessageFormat.system) { + await this.sendMessageToSubscriber( + envelope, + event, + block, + context, + fallback, + ); + } + if (block.attachedBlock) { // Sequential messaging ? try { @@ -154,12 +162,7 @@ export class BotService { 'No attached block to be found with id ' + block.attachedBlock, ); } - return await this.findBlockAndSendReply( - event, - convo, - attachedBlock, - fallback, - ); + return await this.triggerBlock(event, convo, attachedBlock, fallback); } catch (err) { this.logger.error('Unable to retrieve attached block', err); this.eventEmitter.emit('hook:conversation:end', convo, true); @@ -168,20 +171,47 @@ export class BotService { Array.isArray(block.nextBlocks) && block.nextBlocks.length > 0 ) { - // Conversation continues : Go forward to next blocks - this.logger.debug('Conversation continues ...', convo.id); - const nextIds = block.nextBlocks.map(({ id }) => id); try { - await this.conversationService.updateOne(convo.id, { - current: block.id, - next: nextIds, - }); + if (envelope.format === OutgoingMessageFormat.system) { + // System message: Trigger the next block based on the outcome + this.logger.debug( + 'Matching the outcome against the next blocks ...', + convo.id, + ); + const match = this.blockService.matchOutcome( + block.nextBlocks, + event, + envelope, + ); + + if (match) { + const nextBlock = await this.blockService.findOneAndPopulate( + match.id, + ); + if (!nextBlock) { + throw new Error( + 'No attached block to be found with id ' + + block.attachedBlock, + ); + } + return await this.triggerBlock(event, convo, nextBlock, fallback); + } else { + this.logger.warn( + 'Block outcome did not match any of the next blocks', + convo, + ); + } + } else { + // Conversation continues : Go forward to next blocks + this.logger.debug('Conversation continues ...', convo.id); + const nextIds = block.nextBlocks.map(({ id }) => id); + await this.conversationService.updateOne(convo.id, { + current: block.id, + next: nextIds, + }); + } } catch (err) { - this.logger.error( - 'Unable to update conversation when going next', - convo, - err, - ); + this.logger.error('Unable to continue the flow', convo, err); return; } } else { @@ -275,12 +305,7 @@ export class BotService { // Otherwise, old captured const value may be replaced by another const value !fallback, ); - await this.findBlockAndSendReply( - event, - updatedConversation, - next, - fallback, - ); + await this.triggerBlock(event, updatedConversation, next, fallback); } catch (err) { this.logger.error('Unable to store context data!', err); return this.eventEmitter.emit('hook:conversation:end', convo, true); @@ -376,12 +401,7 @@ export class BotService { subscriber.id, block.name, ); - return this.findBlockAndSendReply( - event, - updatedConversation, - block, - false, - ); + return this.triggerBlock(event, updatedConversation, block, false); } catch (err) { this.logger.error('Unable to store context data!', err); this.eventEmitter.emit('hook:conversation:end', convo, true); @@ -459,7 +479,7 @@ export class BotService { 'No global fallback block defined, sending a message ...', err, ); - this.sendMessageToSubscriber(event, { + const globalFallbackBlock = { id: 'global-fallback', name: 'Global Fallback', message: settings.chatbot_settings.fallback_message, @@ -473,7 +493,19 @@ export class BotService { createdAt: new Date(), updatedAt: new Date(), attachedBlock: null, - } as any as BlockFull); + } as any as BlockFull; + + const envelope = await this.blockService.processMessage( + globalFallbackBlock, + getDefaultConversationContext(), + { vars: {} }, // @TODO: use subscriber ctx + ); + + await this.sendMessageToSubscriber( + envelope as StdOutgoingMessageEnvelope, + event, + globalFallbackBlock, + ); } } // Do nothing ... diff --git a/api/src/i18n/services/translation.service.spec.ts b/api/src/i18n/services/translation.service.spec.ts index 508d544e..9e7a0565 100644 --- a/api/src/i18n/services/translation.service.spec.ts +++ b/api/src/i18n/services/translation.service.spec.ts @@ -136,6 +136,7 @@ describe('TranslationService', () => { const block: Block = { name: 'Ollama Plugin', patterns: [], + outcomes: [], assign_labels: [], trigger_channels: [], trigger_labels: [], diff --git a/api/src/utils/test/fixtures/block.ts b/api/src/utils/test/fixtures/block.ts index a73bc1e3..5698f02f 100644 --- a/api/src/utils/test/fixtures/block.ts +++ b/api/src/utils/test/fixtures/block.ts @@ -35,6 +35,7 @@ export const blocks: TBlockFixtures['values'][] = [ { name: 'hasNextBlocks', patterns: ['Hi'], + outcomes: [], category: null, options: { typing: 0, @@ -53,6 +54,7 @@ export const blocks: TBlockFixtures['values'][] = [ { name: 'hasPreviousBlocks', patterns: ['colors'], + outcomes: [], category: null, options: { typing: 0, @@ -90,6 +92,7 @@ export const blocks: TBlockFixtures['values'][] = [ { name: 'buttons', patterns: ['about'], + outcomes: [], category: null, options: { typing: 0, @@ -127,6 +130,7 @@ export const blocks: TBlockFixtures['values'][] = [ { name: 'attachment', patterns: ['image'], + outcomes: [], category: null, options: { typing: 0, @@ -153,6 +157,7 @@ export const blocks: TBlockFixtures['values'][] = [ { name: 'test', patterns: ['yes'], + outcomes: [], category: null, //to be verified options: { From 34c299337dff736a5dd27f3e6209193af0142156 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 3 Mar 2025 09:55:23 +0100 Subject: [PATCH 2/3] feat: add ability to select an outcome in any block triggers --- frontend/public/locales/en/translation.json | 7 + frontend/public/locales/fr/translation.json | 7 + .../visual-editor/BlockEditForm.tsx | 1 + .../visual-editor/form/PluginMessageForm.tsx | 4 +- .../form/inputs/triggers/OutcomeInput.tsx | 151 ++++++++++++++++++ .../form/inputs/triggers/PatternInput.tsx | 29 ++-- .../form/inputs/triggers/PatternsInput.tsx | 47 ++++-- .../visual-editor/hooks/useVisualEditor.tsx | 2 +- frontend/src/types/block.types.ts | 4 +- frontend/src/types/message.types.ts | 1 + frontend/src/utils/string.ts | 4 + 11 files changed, 228 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/visual-editor/form/inputs/triggers/OutcomeInput.tsx diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 322c63df..cb27a832 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -247,6 +247,13 @@ "triggers": "Triggers", "payloads": "Payloads", "general_payloads": "General Payloads", + "exact_match": "Exact Match", + "pattern_match": "Pattern Match", + "intent_match": "Intent Match", + "interaction": "Interaction", + "outcome_match": "Outcome Match", + "outcome": "Outcome", + "any_outcome": "Any Outcome", "capture": "Capture?", "context_var": "Context Var", "text_message": "Text message", diff --git a/frontend/public/locales/fr/translation.json b/frontend/public/locales/fr/translation.json index fa45a278..2aed77d6 100644 --- a/frontend/public/locales/fr/translation.json +++ b/frontend/public/locales/fr/translation.json @@ -247,6 +247,13 @@ "triggers": "Déclencheurs", "payloads": "Payloads", "general_payloads": "Payloads généraux", + "exact_match": "Comparaison Exacte", + "pattern_match": "Expression Régulière", + "intent_match": "Intention", + "interaction": "Interaction", + "outcome": "Résultat", + "outcome_match": "Résultat", + "any_outcome": "N'importe quel résultat", "capture": "Capturer?", "context_var": "Variable contextuelle", "text_message": "Message texte", diff --git a/frontend/src/components/visual-editor/BlockEditForm.tsx b/frontend/src/components/visual-editor/BlockEditForm.tsx index 9630288b..f0a506e1 100644 --- a/frontend/src/components/visual-editor/BlockEditForm.tsx +++ b/frontend/src/components/visual-editor/BlockEditForm.tsx @@ -59,6 +59,7 @@ export const BlockEditForm: FC> = ({ const DEFAULT_VALUES = { name: block?.name || "", patterns: block?.patterns || [], + outcomes: block?.outcomes || [], trigger_labels: block?.trigger_labels || [], trigger_channels: block?.trigger_channels || [], options: block?.options || { diff --git a/frontend/src/components/visual-editor/form/PluginMessageForm.tsx b/frontend/src/components/visual-editor/form/PluginMessageForm.tsx index 36b7432d..9bbb3d3d 100644 --- a/frontend/src/components/visual-editor/form/PluginMessageForm.tsx +++ b/frontend/src/components/visual-editor/form/PluginMessageForm.tsx @@ -17,6 +17,7 @@ import { EntityType } from "@/services/types"; import { IBlockAttributes } from "@/types/block.types"; import { StdPluginMessage } from "@/types/message.types"; +import { getNamespace } from "@/utils/string"; import { useBlock } from "./BlockFormProvider"; const PluginMessageForm = () => { @@ -63,8 +64,7 @@ const PluginMessageForm = () => { )} diff --git a/frontend/src/components/visual-editor/form/inputs/triggers/OutcomeInput.tsx b/frontend/src/components/visual-editor/form/inputs/triggers/OutcomeInput.tsx new file mode 100644 index 00000000..0864106c --- /dev/null +++ b/frontend/src/components/visual-editor/form/inputs/triggers/OutcomeInput.tsx @@ -0,0 +1,151 @@ +/* + * Copyright © 2024 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 { + Autocomplete, + Box, + Chip, + InputAdornment, + Skeleton, + Typography, +} from "@mui/material"; +import { useMemo, useState } from "react"; + +import { Input } from "@/app-components/inputs/Input"; +import { useGetFromCache } from "@/hooks/crud/useGet"; +import { useTranslate } from "@/hooks/useTranslate"; +import { theme } from "@/layout/themes/theme"; +import { EntityType } from "@/services/types"; +import { IBlock, PayloadPattern } from "@/types/block.types"; + +import { PayloadType } from "@/types/message.types"; +import { getNamespace } from "@/utils/string"; +import { useBlock } from "../../BlockFormProvider"; + +type PayloadOption = PayloadPattern & { + group: string; +}; + +type OutcomeInputProps = { + defaultValue: PayloadPattern; + onChange: (pattern: PayloadPattern) => void; +}; + +export const OutcomeInput = ({ defaultValue, onChange }: OutcomeInputProps) => { + const block = useBlock(); + const [selectedValue, setSelectedValue] = useState(defaultValue); + const getBlockFromCache = useGetFromCache(EntityType.BLOCK); + const { t } = useTranslate(); + // Gather previous blocks outcomes + const options = useMemo( + () => + (block?.previousBlocks || []) + .map((b) => getBlockFromCache(b)) + .filter((b) => b && Array.isArray(b.outcomes) && b.outcomes.length > 0) + .map((b) => b as IBlock) + .reduce( + (acc, b) => { + const outcomes = (b.outcomes || []).map((outcome) => ({ + label: t(`label.${outcome}` as any, { + defaultValue: outcome, + ns: + "plugin" in b.message + ? getNamespace(b.message.plugin) + : undefined, + }), + value: outcome, + group: b.name, + type: PayloadType.outcome, + })); + + return acc.concat(outcomes); + }, + [ + { + label: t("label.any_outcome"), + value: "any", + type: PayloadType.outcome, + group: "general", + }, + ] as PayloadOption[], + ), + [block?.previousBlocks, getBlockFromCache], + ); + + const isOptionsReady = + !defaultValue || options.find((o) => o.value === defaultValue.value); + + if (!isOptionsReady) { + return ( + + ); + } + + const selected = defaultValue + ? options.find((o) => o.value === defaultValue.value) + : undefined; + + return ( + { + setSelectedValue(value); + const { group: _g, ...payloadPattern } = value; + + onChange(payloadPattern); + }} + groupBy={({ group }) => group ?? t("label.other")} + getOptionLabel={({ label }) => label} + isOptionEqualToValue={(option, value) => option.value === value.value} + renderGroup={({ key, group, children }) => ( +
  • + + {t(`label.${group}`, { defaultValue: group })} + + {children} +
  • + )} + renderInput={(props) => ( + + + + ), + }} + /> + )} + /> + ); +}; diff --git a/frontend/src/components/visual-editor/form/inputs/triggers/PatternInput.tsx b/frontend/src/components/visual-editor/form/inputs/triggers/PatternInput.tsx index 0dbc6be9..753a12c6 100644 --- a/frontend/src/components/visual-editor/form/inputs/triggers/PatternInput.tsx +++ b/frontend/src/components/visual-editor/form/inputs/triggers/PatternInput.tsx @@ -23,6 +23,7 @@ import { PayloadPattern, } from "@/types/block.types"; +import { OutcomeInput } from "./OutcomeInput"; import { PostbackInput } from "./PostbackInput"; const isRegex = (str: Pattern) => { @@ -38,6 +39,8 @@ const getType = (pattern: Pattern): PatternType => { return "menu"; } else if (pattern?.type === "content") { return "content"; + } else if (pattern?.type === "outcome") { + return "outcome"; } else { return "payload"; } @@ -67,7 +70,6 @@ const PatternInput: FC = ({ } = useFormContext(); const [pattern, setPattern] = useState(value); const patternType = getType(value); - const isPostbackType = ["payload", "content", "menu"].includes(patternType); const registerInput = ( errorMessage: string, idx: number, @@ -100,15 +102,22 @@ const PatternInput: FC = ({ onChange={setPattern} /> )} - - {isPostbackType ? ( - { - payload && setPattern(payload); - }} - defaultValue={pattern as PayloadPattern} - /> - ) : null} + {["payload", "content", "menu"].includes(patternType) ? ( + { + payload && setPattern(payload); + }} + defaultValue={pattern as PayloadPattern} + /> + ) : null} + {patternType === "outcome" ? ( + { + payload && setPattern(payload); + }} + defaultValue={pattern as PayloadPattern} + /> + ) : null} {typeof value === "string" && patternType === "regex" ? ( = ({ value, onChange }) => { const actions: DropdownButtonAction[] = useMemo( () => [ - { icon: , name: "Exact Match", defaultValue: "" }, - { icon: , name: "Pattern Match", defaultValue: "//" }, - { icon: , name: "Intent Match", defaultValue: [] }, { - icon: , - name: "Interaction", + icon: , + name: t("label.exact_match"), + defaultValue: "", + }, + { icon: , name: t("label.pattern_match"), defaultValue: "//" }, + { + icon: , + name: t("label.intent_match"), + defaultValue: [], + }, + { + icon: , + name: t("label.interaction"), defaultValue: { label: t("label.get_started"), value: "GET_STARTED", @@ -92,6 +99,16 @@ const PatternsInput: FC = ({ value, onChange }) => { group: "general", }, }, + { + icon: , + name: t("label.outcome_match"), + defaultValue: { + label: t("label.any_outcome"), + value: "any", + type: PayloadType.outcome, + group: "general", + }, + }, ], // eslint-disable-next-line react-hooks/exhaustive-deps [], @@ -129,7 +146,7 @@ const PatternsInput: FC = ({ value, onChange }) => { color="error" onClick={() => removeInput(idx)} > - + )) @@ -140,7 +157,7 @@ const PatternsInput: FC = ({ value, onChange }) => { label={t("button.add_pattern")} actions={actions} onClick={(action) => addInput(action.defaultValue as Pattern)} - icon={} + icon={} /> ); diff --git a/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx b/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx index 6fdfba6e..0772d4cc 100644 --- a/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx +++ b/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx @@ -256,7 +256,7 @@ const VisualEditorProvider: React.FC = ({ const createNode = (payload: any) => { payload.position = payload.position || getCentroid(); payload.category = payload.category || selectedCategoryId; - + console.log("====", payload); return createBlock(payload, { onSuccess({ id }) { addNode({ diff --git a/frontend/src/types/block.types.ts b/frontend/src/types/block.types.ts index cda2a834..0a4917d0 100644 --- a/frontend/src/types/block.types.ts +++ b/frontend/src/types/block.types.ts @@ -64,7 +64,7 @@ export interface PayloadPattern { value: string; // @todo : rename 'attachment' to 'attachments' // @todo: If undefined, that means the payload could be either quick_reply or button - // We will move soon so that it will be a required attribute + // We should update soon so that it will be a required attribute type?: PayloadType; } @@ -81,12 +81,14 @@ export type PatternType = | "nlp" | "menu" | "content" + | "outcome" | "payload" | "text"; export interface IBlockAttributes { name: string; patterns?: Pattern[]; + outcomes?: string[]; trigger_labels?: string[]; trigger_channels?: string[]; assign_labels?: string[]; diff --git a/frontend/src/types/message.types.ts b/frontend/src/types/message.types.ts index be3cac88..7ea7711c 100644 --- a/frontend/src/types/message.types.ts +++ b/frontend/src/types/message.types.ts @@ -30,6 +30,7 @@ export enum PayloadType { content = "content", quick_reply = "quick_reply", button = "button", + outcome = "outcome", } export enum FileType { diff --git a/frontend/src/utils/string.ts b/frontend/src/utils/string.ts index 00f6227c..77fd41d4 100644 --- a/frontend/src/utils/string.ts +++ b/frontend/src/utils/string.ts @@ -14,3 +14,7 @@ export const slugify = (str: string) => { .replace(/\s+/g, "-") .replace(/-+/g, "_"); }; + +export const getNamespace = (extensionName: string) => { + return extensionName.replaceAll("-", "_"); +}; From 969a60d6b7b04f15228cb47ad83df3569b0d236e Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 3 Mar 2025 11:16:41 +0100 Subject: [PATCH 3/3] fix: lint --- .../src/components/visual-editor/form/PluginMessageForm.tsx | 4 ++-- .../visual-editor/form/inputs/triggers/OutcomeInput.tsx | 5 ++--- .../src/components/visual-editor/hooks/useVisualEditor.tsx | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/visual-editor/form/PluginMessageForm.tsx b/frontend/src/components/visual-editor/form/PluginMessageForm.tsx index 9bbb3d3d..cf9edf90 100644 --- a/frontend/src/components/visual-editor/form/PluginMessageForm.tsx +++ b/frontend/src/components/visual-editor/form/PluginMessageForm.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. @@ -16,8 +16,8 @@ import { useFind } from "@/hooks/crud/useFind"; import { EntityType } from "@/services/types"; import { IBlockAttributes } from "@/types/block.types"; import { StdPluginMessage } from "@/types/message.types"; - import { getNamespace } from "@/utils/string"; + import { useBlock } from "./BlockFormProvider"; const PluginMessageForm = () => { diff --git a/frontend/src/components/visual-editor/form/inputs/triggers/OutcomeInput.tsx b/frontend/src/components/visual-editor/form/inputs/triggers/OutcomeInput.tsx index 0864106c..a5b04bc9 100644 --- a/frontend/src/components/visual-editor/form/inputs/triggers/OutcomeInput.tsx +++ b/frontend/src/components/visual-editor/form/inputs/triggers/OutcomeInput.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. @@ -22,9 +22,9 @@ import { useTranslate } from "@/hooks/useTranslate"; import { theme } from "@/layout/themes/theme"; import { EntityType } from "@/services/types"; import { IBlock, PayloadPattern } from "@/types/block.types"; - import { PayloadType } from "@/types/message.types"; import { getNamespace } from "@/utils/string"; + import { useBlock } from "../../BlockFormProvider"; type PayloadOption = PayloadPattern & { @@ -76,7 +76,6 @@ export const OutcomeInput = ({ defaultValue, onChange }: OutcomeInputProps) => { ), [block?.previousBlocks, getBlockFromCache], ); - const isOptionsReady = !defaultValue || options.find((o) => o.value === defaultValue.value); diff --git a/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx b/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx index 0772d4cc..6fdfba6e 100644 --- a/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx +++ b/frontend/src/components/visual-editor/hooks/useVisualEditor.tsx @@ -256,7 +256,7 @@ const VisualEditorProvider: React.FC = ({ const createNode = (payload: any) => { payload.position = payload.position || getCentroid(); payload.category = payload.category || selectedCategoryId; - console.log("====", payload); + return createBlock(payload, { onSuccess({ id }) { addNode({