diff --git a/api/src/helper/helper.module.ts b/api/src/helper/helper.module.ts index f60fd4e3..90f800f2 100644 --- a/api/src/helper/helper.module.ts +++ b/api/src/helper/helper.module.ts @@ -10,6 +10,7 @@ import { HttpModule } from '@nestjs/axios'; import { Global, Module } from '@nestjs/common'; import { InjectDynamicProviders } from 'nestjs-dynamic-providers'; +import { ChatModule } from '@/chat/chat.module'; import { CmsModule } from '@/cms/cms.module'; import { NlpModule } from '@/nlp/nlp.module'; @@ -26,7 +27,7 @@ import { HelperService } from './helper.service'; 'dist/.hexabot/custom/extensions/helpers/**/*.helper.js', ) @Module({ - imports: [HttpModule, NlpModule, CmsModule], + imports: [HttpModule, NlpModule, CmsModule, ChatModule], controllers: [HelperController], providers: [HelperService], exports: [HelperService], diff --git a/frontend/src/app-components/buttons/FormButtons.tsx b/frontend/src/app-components/buttons/FormButtons.tsx index a4c56429..19b7c3ac 100644 --- a/frontend/src/app-components/buttons/FormButtons.tsx +++ b/frontend/src/app-components/buttons/FormButtons.tsx @@ -29,6 +29,7 @@ export const DialogFormButtons = ({ return ( } {...cancelButtonProps} > - {t(cancelButtonTitle)} + {cancelButtonProps?.text || t(cancelButtonTitle)} ); diff --git a/frontend/src/app-components/inputs/Selectable.tsx b/frontend/src/app-components/inputs/Selectable.tsx index 09ab6197..2eab600c 100644 --- a/frontend/src/app-components/inputs/Selectable.tsx +++ b/frontend/src/app-components/inputs/Selectable.tsx @@ -25,18 +25,19 @@ import { const SelectableBox = styled(Box)({ position: "relative", - height: "30px", marginBottom: "1rem", "& .highlight, & .editable": { position: "absolute", top: 0, display: "block", width: "100%", - padding: "4px", + padding: "0 4px", + lineHeight: 1.5, + whiteSpaceCollapse: "preserve", }, "& .editable": { + position: "relative", backgroundColor: "transparent", - padding: "0px 4px", color: "#000", }, }); @@ -169,10 +170,10 @@ const Selectable: FC = ({ ) { const inputContainer = editableRef.current; let substring: string = ""; - let input: HTMLInputElement | null = null; + let input: HTMLTextAreaElement | null = null; if (inputContainer) { - input = inputContainer.querySelector("input"); + input = inputContainer.querySelector("textarea"); if ( input && @@ -267,6 +268,7 @@ const Selectable: FC = ({ /> ))} > = ({ data: { defaultValues: nlpDatasetSample }, @@ -28,15 +64,134 @@ export const NlpSampleForm: FC> = ({ }) => { const { t } = useTranslate(); const { toast } = useToast(); - const { mutate: updateSample } = useUpdate(EntityType.NLP_SAMPLE, { + const options = { onError: () => { toast.error(t("message.internal_server_error")); }, onSuccess: () => { toast.success(t("message.success_save")); }, + }; + const { mutate: createSample } = useCreate< + EntityType.NLP_SAMPLE, + INlpDatasetSampleAttributes, + INlpSample, + INlpSampleFull + >(EntityType.NLP_SAMPLE, { + ...options, + onSuccess: () => { + options.onSuccess(); + refetchAllEntities(); + reset({ + ...defaultValues, + text: "", + }); + }, }); - const onSubmitForm = (form: INlpSampleFormAttributes) => { + const { mutate: updateSample } = useUpdate< + EntityType.NLP_SAMPLE, + INlpDatasetSampleAttributes + >(EntityType.NLP_SAMPLE, options); + const { + allTraitEntities, + allKeywordEntities, + allPatternEntities, + refetchAllEntities, + } = useNlp(); + const getNlpValueFromCache = useGetFromCache(EntityType.NLP_VALUE); + const defaultValues: INlpSampleFormAttributes = useMemo( + () => ({ + type: nlpDatasetSample?.type || NlpSampleType.train, + text: nlpDatasetSample?.text || "", + language: nlpDatasetSample?.language || null, + traitEntities: [...allTraitEntities.values()].map((e) => { + return { + entity: e.name, + value: + (nlpDatasetSample?.entities || []).find( + (se) => se.entity === e.name, + )?.value || "", + }; + }) as INlpDatasetTraitEntity[], + keywordEntities: (nlpDatasetSample?.entities || []).filter((e) => + allKeywordEntities.has(e.entity), + ) as INlpDatasetKeywordEntity[], + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [allKeywordEntities, allTraitEntities, JSON.stringify(nlpDatasetSample)], + ); + const { handleSubmit, control, register, reset, setValue, watch } = + useForm({ + defaultValues, + }); + const currentText = watch("text"); + const currentType = watch("type"); + const { apiClient } = useApiClient(); + const [patternEntities, setPatternEntities] = useState< + INlpDatasetPatternEntity[] + >([]); + const { fields: traitEntities, update: updateTraitEntity } = useFieldArray({ + control, + name: "traitEntities", + }); + const { + fields: keywordEntities, + insert: insertKeywordEntity, + update: updateKeywordEntity, + remove: removeKeywordEntity, + } = useFieldArray({ + control, + name: "keywordEntities", + }); + // Auto-predict on text change + const debounceSetText = useCallback( + debounce((text: string) => { + setValue("text", text); + }, 400), + [setValue], + ); + const { isLoading } = useQuery({ + queryKey: ["nlp-prediction", currentText], + queryFn: async () => { + return await apiClient.predictNlp(currentText); + }, + onSuccess: (prediction) => { + const predictedTraitEntities: INlpDatasetTraitEntity[] = + prediction.entities.filter((e) => allTraitEntities.has(e.entity)); + const predictedKeywordEntities = prediction.entities.filter((e) => + allKeywordEntities.has(e.entity), + ) as INlpDatasetKeywordEntity[]; + const predictedPatternEntities = prediction.entities.filter((e) => + allPatternEntities.has(e.entity), + ) as INlpDatasetKeywordEntity[]; + const language = prediction.entities.find( + ({ entity }) => entity === "language", + ); + + setValue("language", language?.value || ""); + setValue("traitEntities", predictedTraitEntities); + setValue("keywordEntities", predictedKeywordEntities); + setPatternEntities(predictedPatternEntities); + }, + enabled: + // Inbox sample update + nlpDatasetSample?.type === "inbox" || + // New sample + (!nlpDatasetSample && !!currentText), + }); + const findInsertIndex = (newItem: INlpDatasetKeywordEntity): number => { + const index = keywordEntities.findIndex( + (entity) => entity.start && newItem.start && entity.start > newItem.start, + ); + + return index === -1 ? keywordEntities.length : index; + }; + const [selection, setSelection] = useState<{ + value: string; + start: number; + end: number; + } | null>(null); + const onSubmitForm = async (form: INlpSampleFormAttributes) => { if (nlpDatasetSample?.id) { updateSample( { @@ -54,15 +209,328 @@ export const NlpSampleForm: FC> = ({ }, }, ); + } else { + createSample({ + text: form.text, + type: form.type, + entities: [...form.traitEntities, ...form.keywordEntities], + language: form.language, + }); } }; + useEffect(() => { + reset(defaultValues); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(defaultValues)]); + + const cancelButtonProps = { + sx: { + display: "inline-block", + overflow: "hidden", + textAlign: "left", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + "& .MuiButton-startIcon": { + top: "4px", + margin: "auto 4px auto auto", + display: "inline-block", + position: "relative", + }, + }, + color: "secondary", + variant: "contained", + onClick: () => { + const newKeywordEntity = { + ...selection, + entity: "", + } as INlpDatasetKeywordEntity; + const newIndex = findInsertIndex(newKeywordEntity); + + selection && insertKeywordEntity(newIndex, newKeywordEntity); + setSelection(null); + }, + disabled: !selection?.value, + startIcon: , + } satisfies FormButtonsProps["cancelButtonProps"]; + const confirmButtonProps = { + sx: { minWidth: "120px" }, + value: "button.validate", + variant: "contained", + disabled: !( + currentText !== "" && + currentType !== NlpSampleType.inbox && + traitEntities.every((e) => e.value !== "") && + keywordEntities.every((e) => e.value !== "") + ), + onClick: handleSubmit(onSubmitForm), + } satisfies FormButtonsProps["confirmButtonProps"]; + return ( - {}} {...WrapperProps}> - + {}} + {...WrapperProps} + cancelButtonProps={{ + ...WrapperProps?.cancelButtonProps, + text: !selection?.value + ? t("button.select_some_text") + : t("button.add_nlp_entity", { 0: selection.value }), + ...cancelButtonProps, + }} + confirmButtonProps={{ + ...WrapperProps?.confirmButtonProps, + ...confirmButtonProps, + }} + > +
+ + + + {t("title.nlp_train")} + + + {t("label.type")} + + {Object.values(NlpSampleType) + .filter((type) => type !== "inbox") + .map((type, index) => ( + } + label={t(`label.${type}`)} + /> + ))} + + + + + { + newSelection !== selection?.value && + setSelection({ + value: newSelection, + start, + end, + }); + }} + onChange={({ text, entities }) => { + debounceSetText(text); + setValue( + "keywordEntities", + entities.map(({ entity, value, start, end }) => ({ + entity, + value, + start, + end, + })), + ); + setPatternEntities([]); + }} + loading={isLoading} + /> + + + + { + const { onChange, ...rest } = field; + + return ( + + fullWidth={true} + autoFocus + searchFields={["title", "code"]} + entity={EntityType.LANGUAGE} + format={Format.BASIC} + labelKey="title" + idKey="code" + label={t("label.language")} + multiple={false} + {...field} + onChange={(_e, selected) => { + onChange(selected?.code); + }} + {...rest} + /> + ); + }} + /> + + {traitEntities.map((traitEntity, index) => ( + + { + const { onChange: _, value, ...rest } = field; + const options = ( + allTraitEntities.get(traitEntity.entity)?.values || [] + ).map((v) => getNlpValueFromCache(v)!); + + return ( + <> + + fullWidth={true} + options={options} + idKey="value" + labelKey="value" + label={value.entity} + multiple={false} + value={value.value} + onChange={(_e, selected, ..._) => { + updateTraitEntity(index, { + entity: value.entity, + value: selected?.value || "", + }); + }} + {...rest} + /> + {value?.confidence && + typeof value?.confidence === "number" && ( + + )} + + ); + }} + /> + + ))} + + + {keywordEntities.map((keywordEntity, index) => ( + + removeKeywordEntity(index)}> + + + { + const { onChange: _, ...rest } = field; + const options = [...allKeywordEntities.values()]; + + return ( + + fullWidth={true} + options={options} + idKey="name" + labelKey="name" + label={t("label.nlp_entity")} + multiple={false} + onChange={(_e, selected, ..._) => { + updateKeywordEntity(index, { + ...keywordEntities[index], + entity: selected?.name || "", + }); + }} + {...rest} + /> + ); + }} + /> + { + const { onChange: _, value, ...rest } = field; + const options = ( + allKeywordEntities.get(keywordEntity.entity)?.values || [] + ).map((v) => getNlpValueFromCache(v)!); + + return ( + + sx={{ width: "50%" }} + idKey="value" + labelKey="value" + label={t("label.value")} + multiple={false} + options={options} + value={value} + freeSolo={true} + getOptionLabel={(option) => { + return typeof option === "string" + ? option + : option.value; + }} + onChange={(_e, selected, ..._) => { + selected && + updateKeywordEntity(index, { + ...keywordEntity, + value: + typeof selected === "string" + ? selected + : selected.value, + }); + }} + {...rest} + /> + ); + }} + /> + + ))} + + + + {nlpDatasetSample ? null : ( + <> + + + + )} + +
); }; diff --git a/frontend/src/components/nlp/components/NlpTrainForm.tsx b/frontend/src/components/nlp/components/NlpTrainForm.tsx deleted file mode 100644 index fb9873de..00000000 --- a/frontend/src/components/nlp/components/NlpTrainForm.tsx +++ /dev/null @@ -1,460 +0,0 @@ -/* - * 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 AddIcon from "@mui/icons-material/Add"; -import Check from "@mui/icons-material/Check"; -import DeleteIcon from "@mui/icons-material/Delete"; -import { - Box, - Button, - Chip, - debounce, - FormControl, - FormControlLabel, - FormLabel, - IconButton, - Radio, - RadioGroup, - Typography, -} from "@mui/material"; -import { FC, useCallback, useEffect, useMemo, useState } from "react"; -import { Controller, useFieldArray, useForm } from "react-hook-form"; -import { useQuery } from "react-query"; - -import { ContentContainer, ContentItem } from "@/app-components/dialogs"; -import AutoCompleteEntitySelect from "@/app-components/inputs/AutoCompleteEntitySelect"; -import AutoCompleteSelect from "@/app-components/inputs/AutoCompleteSelect"; -import Selectable from "@/app-components/inputs/Selectable"; -import { useGetFromCache } from "@/hooks/crud/useGet"; -import { useApiClient } from "@/hooks/useApiClient"; -import { useNlp } from "@/hooks/useNlp"; -import { useTranslate } from "@/hooks/useTranslate"; -import { EntityType, Format } from "@/services/types"; -import { ILanguage } from "@/types/language.types"; -import { INlpEntity } from "@/types/nlp-entity.types"; -import { - INlpDatasetKeywordEntity, - INlpDatasetPatternEntity, - INlpDatasetSample, - INlpDatasetTraitEntity, - INlpSampleFormAttributes, - NlpSampleType, -} from "@/types/nlp-sample.types"; -import { INlpValue } from "@/types/nlp-value.types"; - -type NlpDatasetSampleProps = { - sample?: INlpDatasetSample; - submitForm: (params: INlpSampleFormAttributes) => void; -}; - -const NlpDatasetSample: FC = ({ - sample, - submitForm, -}) => { - const { t } = useTranslate(); - const { - allTraitEntities, - allKeywordEntities, - allPatternEntities, - refetchAllEntities, - } = useNlp(); - const getNlpValueFromCache = useGetFromCache(EntityType.NLP_VALUE); - const defaultValues: INlpSampleFormAttributes = useMemo( - () => ({ - type: sample?.type || NlpSampleType.train, - text: sample?.text || "", - language: sample?.language || null, - traitEntities: [...allTraitEntities.values()].map((e) => { - return { - entity: e.name, - value: - (sample?.entities || []).find((se) => se.entity === e.name) - ?.value || "", - }; - }) as INlpDatasetTraitEntity[], - keywordEntities: (sample?.entities || []).filter((e) => - allKeywordEntities.has(e.entity), - ) as INlpDatasetKeywordEntity[], - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [allKeywordEntities, allTraitEntities, JSON.stringify(sample)], - ); - const { handleSubmit, control, register, reset, setValue, watch } = - useForm({ - defaultValues, - }); - const currentText = watch("text"); - const currentType = watch("type"); - const { apiClient } = useApiClient(); - const [patternEntities, setPatternEntities] = useState< - INlpDatasetPatternEntity[] - >([]); - const { fields: traitEntities, update: updateTraitEntity } = useFieldArray({ - control, - name: "traitEntities", - }); - const { - fields: keywordEntities, - insert: insertKeywordEntity, - update: updateKeywordEntity, - remove: removeKeywordEntity, - } = useFieldArray({ - control, - name: "keywordEntities", - }); - // Auto-predict on text change - const debounceSetText = useCallback( - debounce((text: string) => { - setValue("text", text); - }, 400), - [setValue], - ); - const { isLoading } = useQuery({ - queryKey: ["nlp-prediction", currentText], - queryFn: async () => { - return await apiClient.predictNlp(currentText); - }, - onSuccess: (prediction) => { - const predictedTraitEntities: INlpDatasetTraitEntity[] = - prediction.entities.filter((e) => allTraitEntities.has(e.entity)); - const predictedKeywordEntities = prediction.entities.filter((e) => - allKeywordEntities.has(e.entity), - ) as INlpDatasetKeywordEntity[]; - const predictedPatternEntities = prediction.entities.filter((e) => - allPatternEntities.has(e.entity), - ) as INlpDatasetKeywordEntity[]; - const language = prediction.entities.find( - ({ entity }) => entity === "language", - ); - - setValue("language", language?.value || ""); - setValue("traitEntities", predictedTraitEntities); - setValue("keywordEntities", predictedKeywordEntities); - setPatternEntities(predictedPatternEntities); - }, - enabled: - // Inbox sample update - sample?.type === "inbox" || - // New sample - (!sample && !!currentText), - }); - const findInsertIndex = (newItem: INlpDatasetKeywordEntity): number => { - const index = keywordEntities.findIndex( - (entity) => entity.start && newItem.start && entity.start > newItem.start, - ); - - return index === -1 ? keywordEntities.length : index; - }; - const [selection, setSelection] = useState<{ - value: string; - start: number; - end: number; - } | null>(null); - const onSubmitForm = (form: INlpSampleFormAttributes) => { - submitForm(form); - refetchAllEntities(); - reset({ - ...defaultValues, - text: "", - }); - }; - - useEffect(() => { - reset(defaultValues); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(defaultValues)]); - - return ( - -
- - - - {t("title.nlp_train")} - - - {t("label.type")} - - {Object.values(NlpSampleType) - .filter((type) => type !== "inbox") - .map((type, index) => ( - } - label={t(`label.${type}`)} - /> - ))} - - - - - { - setSelection({ - value: selection, - start, - end, - }); - }} - onChange={({ text, entities }) => { - debounceSetText(text); - setValue( - "keywordEntities", - entities.map(({ entity, value, start, end }) => ({ - entity, - value, - start, - end, - })), - ); - setPatternEntities([]); - }} - loading={isLoading} - /> - - - {/* Language selection */} - - { - const { onChange, ...rest } = field; - - return ( - - fullWidth={true} - autoFocus - searchFields={["title", "code"]} - entity={EntityType.LANGUAGE} - format={Format.BASIC} - labelKey="title" - idKey="code" - label={t("label.language")} - multiple={false} - {...field} - onChange={(_e, selected) => { - onChange(selected?.code); - }} - {...rest} - /> - ); - }} - /> - - {/* Trait entities */} - {traitEntities.map((traitEntity, index) => ( - - { - const { onChange: _, value, ...rest } = field; - const options = ( - allTraitEntities.get(traitEntity.entity)?.values || [] - ).map((v) => getNlpValueFromCache(v)!); - - return ( - <> - - fullWidth={true} - options={options} - idKey="value" - labelKey="value" - label={value.entity} - multiple={false} - value={value.value} - onChange={(_e, selected, ..._) => { - updateTraitEntity(index, { - entity: value.entity, - value: selected?.value || "", - }); - }} - {...rest} - /> - {value?.confidence && - typeof value?.confidence === "number" && ( - - )} - - ); - }} - /> - - ))} - - { - /* Keyword entities */ - } - - {keywordEntities.map((keywordEntity, index) => ( - - removeKeywordEntity(index)}> - - - { - const { onChange: _, ...rest } = field; - const options = [...allKeywordEntities.values()]; - - return ( - - fullWidth={true} - options={options} - idKey="name" - labelKey="name" - label={t("label.nlp_entity")} - multiple={false} - onChange={(_e, selected, ..._) => { - updateKeywordEntity(index, { - ...keywordEntities[index], - entity: selected?.name || "", - }); - }} - {...rest} - /> - ); - }} - /> - { - const { onChange: _, value, ...rest } = field; - const options = ( - allKeywordEntities.get(keywordEntity.entity)?.values || [] - ).map((v) => getNlpValueFromCache(v)!); - - return ( - - sx={{ width: "50%" }} - idKey="value" - labelKey="value" - label={t("label.value")} - multiple={false} - options={options} - value={value} - freeSolo={true} - getOptionLabel={(option) => { - return typeof option === "string" - ? option - : option.value; - }} - onChange={(_e, selected, ..._) => { - selected && - updateKeywordEntity(index, { - ...keywordEntity, - value: - typeof selected === "string" - ? selected - : selected.value, - }); - }} - {...rest} - /> - ); - }} - /> - - ))} - - - - - - - -
-
- ); -}; - -NlpDatasetSample.displayName = "NlpTrain"; - -export default NlpDatasetSample; diff --git a/frontend/src/components/nlp/index.tsx b/frontend/src/components/nlp/index.tsx index b38e372b..642c4972 100644 --- a/frontend/src/components/nlp/index.tsx +++ b/frontend/src/components/nlp/index.tsx @@ -13,22 +13,14 @@ import { useRouter } from "next/router"; import React from "react"; import { TabPanel } from "@/app-components/tabs/TabPanel"; -import { useCreate } from "@/hooks/crud/useCreate"; import { useFind } from "@/hooks/crud/useFind"; -import { useToast } from "@/hooks/useToast"; import { useTranslate } from "@/hooks/useTranslate"; import { PageHeader } from "@/layout/content/PageHeader"; import { EntityType, Format } from "@/services/types"; -import { - INlpDatasetSampleAttributes, - INlpSample, - INlpSampleFormAttributes, - INlpSampleFull, -} from "@/types/nlp-sample.types"; import NlpDatasetCounter from "./components/NlpDatasetCounter"; import NlpSample from "./components/NlpSample"; -import NlpDatasetSample from "./components/NlpTrainForm"; +import { NlpSampleForm } from "./components/NlpSampleForm"; import { NlpValues } from "./components/NlpValue"; const NlpEntity = dynamic(() => import("./components/NlpEntity")); @@ -61,28 +53,6 @@ export const Nlp = ({ ); }; const { t } = useTranslate(); - const { toast } = useToast(); - const { mutate: createSample } = useCreate< - EntityType.NLP_SAMPLE, - INlpDatasetSampleAttributes, - INlpSample, - INlpSampleFull - >(EntityType.NLP_SAMPLE, { - onError: () => { - toast.error(t("message.internal_server_error")); - }, - onSuccess: () => { - toast.success(t("message.success_save")); - }, - }); - const onSubmitForm = (params: INlpSampleFormAttributes) => { - createSample({ - text: params.text, - type: params.type, - entities: [...params.traitEntities, ...params.keywordEntities], - language: params.language, - }); - }; return ( @@ -90,8 +60,8 @@ export const Nlp = ({ - - + + diff --git a/frontend/src/types/common/dialogs.types.ts b/frontend/src/types/common/dialogs.types.ts index 6dc13258..872721f5 100644 --- a/frontend/src/types/common/dialogs.types.ts +++ b/frontend/src/types/common/dialogs.types.ts @@ -162,8 +162,8 @@ export interface FormDialogProps export interface FormButtonsProps { onSubmit?: (e: BaseSyntheticEvent) => void; onCancel?: () => void; - cancelButtonProps?: ButtonProps; - confirmButtonProps?: ButtonProps; + cancelButtonProps?: ButtonProps & { text?: string }; + confirmButtonProps?: ButtonProps & { text?: string }; } export type TPayload = { diff --git a/widget/src/components/Message.scss b/widget/src/components/Message.scss index 655638cc..5a5b08bc 100644 --- a/widget/src/components/Message.scss +++ b/widget/src/components/Message.scss @@ -55,6 +55,8 @@ .sc-message--wrapper { display: flex; flex-direction: column; + overflow: hidden; + word-break: break-all; .sc-message--text { padding: 10px 20px;