@memori.ai/memori-react 8.19.3 → 8.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +86 -3
  3. package/dist/components/ContentPreviewModal/ContentPreviewModal.css +0 -1
  4. package/dist/components/FilePreview/FilePreview.js +4 -1
  5. package/dist/components/FilePreview/FilePreview.js.map +1 -1
  6. package/dist/components/MemoriWidget/MemoriWidget.d.ts +2 -1
  7. package/dist/components/MemoriWidget/MemoriWidget.js +73 -37
  8. package/dist/components/MemoriWidget/MemoriWidget.js.map +1 -1
  9. package/dist/components/layouts/fullpage.css +115 -0
  10. package/dist/helpers/piiDetection.d.ts +5 -0
  11. package/dist/helpers/piiDetection.js +45 -0
  12. package/dist/helpers/piiDetection.js.map +1 -0
  13. package/dist/index.js +2 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/testUtils.d.ts +5 -0
  16. package/dist/testUtils.js +18 -0
  17. package/dist/testUtils.js.map +1 -0
  18. package/dist/types/layout.d.ts +16 -0
  19. package/dist/types/layout.js +3 -0
  20. package/dist/types/layout.js.map +1 -0
  21. package/dist/version.d.ts +1 -1
  22. package/dist/version.js +1 -1
  23. package/esm/components/ContentPreviewModal/ContentPreviewModal.css +0 -1
  24. package/esm/components/FilePreview/FilePreview.js +4 -1
  25. package/esm/components/FilePreview/FilePreview.js.map +1 -1
  26. package/esm/components/MemoriWidget/MemoriWidget.d.ts +2 -1
  27. package/esm/components/MemoriWidget/MemoriWidget.js +73 -37
  28. package/esm/components/MemoriWidget/MemoriWidget.js.map +1 -1
  29. package/esm/components/layouts/fullpage.css +115 -0
  30. package/esm/helpers/piiDetection.d.ts +5 -0
  31. package/esm/helpers/piiDetection.js +41 -0
  32. package/esm/helpers/piiDetection.js.map +1 -0
  33. package/esm/index.js +2 -2
  34. package/esm/index.js.map +1 -1
  35. package/esm/testUtils.d.ts +5 -0
  36. package/esm/testUtils.js +15 -0
  37. package/esm/testUtils.js.map +1 -0
  38. package/esm/types/layout.d.ts +16 -0
  39. package/esm/types/layout.js +2 -0
  40. package/esm/types/layout.js.map +1 -0
  41. package/esm/version.d.ts +1 -1
  42. package/esm/version.js +1 -1
  43. package/package.json +1 -1
  44. package/src/components/ContentPreviewModal/ContentPreviewModal.css +0 -1
  45. package/src/components/FilePreview/FilePreview.tsx +4 -1
  46. package/src/components/MemoriWidget/MemoriWidget.stories.tsx +2 -0
  47. package/src/components/MemoriWidget/MemoriWidget.tsx +76 -37
  48. package/src/helpers/piiDetection.test.ts +186 -0
  49. package/src/helpers/piiDetection.ts +91 -0
  50. package/src/index.stories.tsx +53 -0
  51. package/src/index.tsx +1 -1
  52. package/src/types/layout.ts +50 -0
  53. package/src/version.ts +1 -1
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { render as rtlRender, } from '@testing-library/react';
3
+ import { AlertProvider, AlertViewport } from '@memori.ai/ui';
4
+ function AlertProviderWrapper({ children }) {
5
+ return (_jsxs(AlertProvider, { children: [children, _jsx(AlertViewport, {})] }));
6
+ }
7
+ function render(ui, options) {
8
+ return rtlRender(ui, {
9
+ wrapper: AlertProviderWrapper,
10
+ ...options,
11
+ });
12
+ }
13
+ export * from '@testing-library/react';
14
+ export { render };
15
+ //# sourceMappingURL=testUtils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testUtils.js","sourceRoot":"","sources":["../src/testUtils.tsx"],"names":[],"mappings":";AACA,OAAO,EACL,MAAM,IAAI,SAAS,GAGpB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAM7D,SAAS,oBAAoB,CAAC,EAAE,QAAQ,EAAiC;IACvE,OAAO,CACL,MAAC,aAAa,eACX,QAAQ,EACT,KAAC,aAAa,KAAG,IACH,CACjB,CAAC;AACJ,CAAC;AAUD,SAAS,MAAM,CACb,EAAsB,EACtB,OAAwC;IAExC,OAAO,SAAS,CAAC,EAAE,EAAE;QACnB,OAAO,EAAE,oBAAoB;QAC7B,GAAG,OAAO;KACX,CAAC,CAAC;AACL,CAAC;AAGD,cAAc,wBAAwB,CAAC;AACvC,OAAO,EAAE,MAAM,EAAE,CAAC"}
@@ -0,0 +1,16 @@
1
+ export type LayoutName = 'DEFAULT' | 'FULLPAGE' | 'TOTEM' | 'CHAT' | 'WEBSITE_ASSISTANT' | 'HIDDEN_CHAT' | 'ZOOMED_FULL_BODY';
2
+ export interface PiiDetectionRule {
3
+ id: string;
4
+ label: Record<string, string>;
5
+ pattern: string;
6
+ message: Record<string, string>;
7
+ }
8
+ export interface PiiDetectionConfig {
9
+ enabled: boolean;
10
+ rules: PiiDetectionRule[];
11
+ errorMessage: Record<string, string>;
12
+ }
13
+ export type LayoutProp = LayoutName | {
14
+ name: LayoutName;
15
+ piiDetection?: PiiDetectionConfig;
16
+ };
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=layout.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layout.js","sourceRoot":"","sources":["../../src/types/layout.ts"],"names":[],"mappings":""}
package/esm/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const version = "8.19.3";
1
+ export declare const version = "8.21.0";
package/esm/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const version = '8.19.3';
1
+ export const version = '8.21.0';
2
2
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "8.19.3",
2
+ "version": "8.21.0",
3
3
  "name": "@memori.ai/memori-react",
4
4
  "author": "Memori Srl",
5
5
  "main": "dist/index.js",
@@ -15,7 +15,6 @@
15
15
  overflow: hidden;
16
16
  min-width: 500px;
17
17
  min-height: 500px;
18
- max-height: 80vh;
19
18
  border-radius: 16px;
20
19
  background: var(--memori-content-preview-bg, #fafafa);
21
20
  box-shadow:
@@ -5,6 +5,7 @@ import Button from '../ui/Button';
5
5
  import ContentPreviewModal from '../ContentPreviewModal';
6
6
  import Snippet from '../Snippet/Snippet';
7
7
  import { stripHTML, stripDocumentAttachmentTags } from '../../helpers/utils';
8
+ import { getFileExtensionFromMime } from '../MediaWidget/MediaItemWidget.utils';
8
9
 
9
10
  type FilePreviewProps = {
10
11
  previewFiles: any;
@@ -147,7 +148,9 @@ FilePreviewProps) => {
147
148
  <div className="memori--preview-file-info">
148
149
  <span className="memori--preview-filename">{file.name}</span>
149
150
  <span className="memori--preview-filetype">
150
- {getFileType(file.name, file.type)}
151
+ {file.mimeType
152
+ ? getFileExtensionFromMime(file.mimeType)
153
+ : getFileType(file.name, file.type)}
151
154
  </span>
152
155
  </div>
153
156
 
@@ -259,3 +259,5 @@ WithUserAvatarAsElement.args = {
259
259
  tenant,
260
260
  userAvatar: <span>USER</span>,
261
261
  };
262
+
263
+
@@ -18,6 +18,8 @@ import {
18
18
  } from '@memori.ai/memori-api-client/src/types';
19
19
  import { ArtifactData } from '../MemoriArtifactSystem/types/artifact.types';
20
20
  import { ArtifactAPIBridge } from '../MemoriArtifactSystem/utils/ArtifactAPI';
21
+ import type { LayoutName, PiiDetectionConfig } from '../../types/layout';
22
+ import { checkPii } from '../../helpers/piiDetection'; // PII check when integrationConfig.layout has piiDetection.enabled
21
23
 
22
24
  // Libraries
23
25
  import React, {
@@ -378,14 +380,7 @@ export interface Props {
378
380
  memoriLang?: string;
379
381
  multilingual?: boolean;
380
382
  integration?: Integration;
381
- layout?:
382
- | 'DEFAULT'
383
- | 'FULLPAGE'
384
- | 'TOTEM'
385
- | 'CHAT'
386
- | 'WEBSITE_ASSISTANT'
387
- | 'HIDDEN_CHAT'
388
- | 'ZOOMED_FULL_BODY';
383
+ layout?: LayoutName;
389
384
  customLayout?: React.FC<LayoutProps>;
390
385
  showShare?: boolean;
391
386
  showCopyButton?: boolean;
@@ -604,7 +599,20 @@ const MemoriWidget = ({
604
599
  const [memoriTyping, setMemoriTyping] = useState<boolean>(false);
605
600
  const [typingText, setTypingText] = useState<string>();
606
601
 
607
- const selectedLayout = layout || integrationConfig?.layout || 'DEFAULT';
602
+ // Layout: from prop (string only) or integrationConfig. PII detection is only from integrationConfig (customData.layout as object with piiDetection).
603
+ const layoutName =
604
+ typeof layout === 'string'
605
+ ? layout
606
+ : typeof integrationConfig?.layout === 'string'
607
+ ? integrationConfig.layout
608
+ : integrationConfig?.layout?.name;
609
+ const selectedLayout = layoutName || 'DEFAULT';
610
+ const piiDetection: PiiDetectionConfig | undefined =
611
+ typeof integrationConfig?.layout === 'object' &&
612
+ integrationConfig?.layout !== null &&
613
+ integrationConfig?.layout?.piiDetection?.enabled
614
+ ? integrationConfig.layout.piiDetection
615
+ : undefined;
608
616
 
609
617
  const defaultEnableAudio =
610
618
  enableAudio ?? integrationConfig?.enableAudio ?? true;
@@ -808,6 +816,65 @@ const MemoriWidget = ({
808
816
  (window.getMemoriState() as MemoriSession)?.sessionID;
809
817
  if (!sessionID || !text?.length) return;
810
818
 
819
+ // Build full message text (same as what will be sent) so we can run PII check on it.
820
+ // Order: user text -> optional translation -> appended document attachment content.
821
+ let msg = text;
822
+ if (
823
+ !hidden &&
824
+ translate &&
825
+ isMultilanguageEnabled &&
826
+ userLang.toUpperCase() !== language.toUpperCase()
827
+ ) {
828
+ const translation = await getTranslation(
829
+ text,
830
+ language,
831
+ userLang,
832
+ baseUrl
833
+ );
834
+ msg = translation.text;
835
+ }
836
+ const mediaDocuments = media?.filter(
837
+ m => (m as any).type === 'document' && m.properties?.isAttachedFile
838
+ );
839
+ if (mediaDocuments && mediaDocuments.length > 0) {
840
+ const documentContents = mediaDocuments
841
+ .map(doc => doc.content)
842
+ .join(' ');
843
+ msg = msg + ' ' + documentContents;
844
+ }
845
+
846
+ // PII check: when layout has piiDetection.enabled, run regex rules on the full msg.
847
+ // If any rule matches, add the user message to history, then push the system error bubble and return without sending.
848
+ if (piiDetection?.enabled) {
849
+ const piiResult = checkPii(
850
+ msg,
851
+ piiDetection,
852
+ userLang?.toLowerCase() || 'en'
853
+ );
854
+ if (piiResult.matched && piiResult.errorText) {
855
+ if (!hidden) {
856
+ pushMessage({
857
+ text: text,
858
+ translatedText,
859
+ fromUser: true,
860
+ media: media ?? [],
861
+ initial: sessionId
862
+ ? !!newSessionId && newSessionId !== sessionId
863
+ : !!newSessionId,
864
+ });
865
+ }
866
+ pushMessage({
867
+ text: piiResult.errorText,
868
+ emitter: 'system',
869
+ fromUser: false,
870
+ initial: false,
871
+ contextVars: {},
872
+ date: new Date().toISOString(),
873
+ });
874
+ return;
875
+ }
876
+ }
877
+
811
878
  // Add user message to chat history if not hidden
812
879
  if (!hidden)
813
880
  pushMessage({
@@ -824,37 +891,9 @@ const MemoriWidget = ({
824
891
  setMemoriTyping(true);
825
892
  setTypingText(typingText);
826
893
 
827
- let msg = text;
828
894
  let gotError = false;
829
895
 
830
896
  try {
831
- // Translate message if needed
832
- if (
833
- !hidden &&
834
- translate &&
835
- isMultilanguageEnabled &&
836
- userLang.toUpperCase() !== language.toUpperCase()
837
- ) {
838
- const translation = await getTranslation(
839
- text,
840
- language,
841
- userLang,
842
- baseUrl
843
- );
844
- msg = translation.text;
845
- }
846
-
847
- // Handle document attachments
848
- const mediaDocuments = media?.filter(
849
- m => (m as any).type === 'document' && m.properties?.isAttachedFile
850
- );
851
- if (mediaDocuments && mediaDocuments.length > 0) {
852
- const documentContents = mediaDocuments
853
- .map(doc => doc.content)
854
- .join(' ');
855
- msg = msg + ' ' + documentContents;
856
- }
857
-
858
897
  // Add chat reference link to the message if it exists
859
898
  // if (chatLogID) {
860
899
  // msg =
@@ -0,0 +1,186 @@
1
+ import { checkPii } from './piiDetection';
2
+
3
+ const baseConfig = {
4
+ enabled: true,
5
+ rules: [
6
+ {
7
+ id: 'email',
8
+ label: { it: 'Email', en: 'Email' },
9
+ pattern: '\\b[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}\\b',
10
+ message: { it: 'Contiene email.', en: 'Contains email.' },
11
+ },
12
+ {
13
+ id: 'iban',
14
+ label: { it: 'IBAN', en: 'IBAN' },
15
+ pattern: '\\b[A-Z]{2}\\d{2}(?:[ ]?[A-Z0-9]{4}){3,7}(?:[ ]?[A-Z0-9]{1,4})?\\b',
16
+ message: { it: 'Contiene IBAN.', en: 'Contains IBAN.' },
17
+ },
18
+ ],
19
+ errorMessage: {
20
+ it: 'Dati sensibili.',
21
+ en: 'Sensitive data.',
22
+ },
23
+ };
24
+
25
+ describe('checkPii', () => {
26
+ describe('edge cases: no match', () => {
27
+ it('returns no match when config is disabled', () => {
28
+ expect(
29
+ checkPii('test@example.com', { ...baseConfig, enabled: false }, 'en')
30
+ ).toEqual({ matched: false });
31
+ });
32
+
33
+ it('returns no match when config is null', () => {
34
+ expect(checkPii('hello', null as any, 'en')).toEqual({ matched: false });
35
+ });
36
+
37
+ it('returns no match when config is undefined', () => {
38
+ expect(checkPii('hello', undefined as any, 'en')).toEqual({
39
+ matched: false,
40
+ });
41
+ });
42
+
43
+ it('returns no match when rules is empty array', () => {
44
+ expect(
45
+ checkPii('test@example.com', { ...baseConfig, rules: [] }, 'en')
46
+ ).toEqual({ matched: false });
47
+ });
48
+
49
+ it('returns no match when rules is not an array', () => {
50
+ expect(
51
+ checkPii('test@example.com', { ...baseConfig, rules: null as any }, 'en')
52
+ ).toEqual({ matched: false });
53
+ });
54
+
55
+ it('returns no match when no rule matches the text', () => {
56
+ expect(checkPii('just plain hello world', baseConfig, 'en')).toEqual({
57
+ matched: false,
58
+ });
59
+ });
60
+
61
+ it('skips rules with empty pattern (avoids matching everything)', () => {
62
+ const configWithEmptyPattern = {
63
+ ...baseConfig,
64
+ rules: [
65
+ { id: 'empty', label: { it: 'Empty', en: 'Empty' }, pattern: '', message: { en: 'Empty' } },
66
+ {
67
+ id: 'space',
68
+ label: { it: 'Space', en: 'Space' },
69
+ pattern: ' ',
70
+ message: { en: 'Space' },
71
+ },
72
+ ],
73
+ };
74
+ expect(checkPii('anything at all', configWithEmptyPattern, 'en')).toEqual(
75
+ { matched: false }
76
+ );
77
+ });
78
+
79
+ it('treats invalid regex as no match and does not throw', () => {
80
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
81
+ const configBadRegex = {
82
+ ...baseConfig,
83
+ rules: [
84
+ {
85
+ id: 'bad',
86
+ label: { it: 'Bad', en: 'Bad' },
87
+ pattern: '[unclosed',
88
+ message: { en: 'Bad' },
89
+ },
90
+ ],
91
+ };
92
+ expect(checkPii('test', configBadRegex, 'en')).toEqual({ matched: false });
93
+ expect(warnSpy).toHaveBeenCalledWith(
94
+ '[PII] Invalid regex for rule:',
95
+ 'bad',
96
+ '[unclosed',
97
+ expect.any(Error)
98
+ );
99
+ warnSpy.mockRestore();
100
+ });
101
+ });
102
+
103
+ describe('single rule match', () => {
104
+ it('returns match and errorText when email pattern matches', () => {
105
+ const result = checkPii('contact me at test@example.com please', baseConfig, 'en');
106
+ expect(result.matched).toBe(true);
107
+ expect(result.errorText).toContain('Sensitive data.');
108
+ expect(result.errorText).toContain('Contains email.');
109
+ expect(result.errorText).not.toContain('Contains IBAN.');
110
+ });
111
+
112
+ it('returns match with Italian messages when lang is it', () => {
113
+ const result = checkPii('test@example.com', baseConfig, 'it');
114
+ expect(result.matched).toBe(true);
115
+ expect(result.errorText).toContain('Dati sensibili.');
116
+ expect(result.errorText).toContain('Contiene email.');
117
+ });
118
+
119
+ it('falls back to en when lang has no translation', () => {
120
+ const result = checkPii('test@example.com', baseConfig, 'fr');
121
+ expect(result.matched).toBe(true);
122
+ expect(result.errorText).toContain('Sensitive data.');
123
+ expect(result.errorText).toContain('Contains email.');
124
+ });
125
+
126
+ it('falls back to first available message when errorMessage has no en or requested lang', () => {
127
+ const configNoEn = {
128
+ ...baseConfig,
129
+ errorMessage: { it: 'Solo italiano' },
130
+ };
131
+ const result = checkPii('test@example.com', configNoEn, 'de');
132
+ expect(result.matched).toBe(true);
133
+ expect(result.errorText).toContain('Solo italiano');
134
+ });
135
+ });
136
+
137
+ describe('multiple rules and deduplication', () => {
138
+ it('returns match with both rule messages when text matches email and IBAN', () => {
139
+ const text = 'send to test@example.com and IT60X0542811101000000123456';
140
+ const result = checkPii(text, baseConfig, 'en');
141
+ expect(result.matched).toBe(true);
142
+ expect(result.errorText).toContain('Sensitive data.');
143
+ expect(result.errorText).toContain('Contains email.');
144
+ expect(result.errorText).toContain('Contains IBAN.');
145
+ });
146
+
147
+ it('deduplicates by rule id when multiple rules share same id', () => {
148
+ const configDupId = {
149
+ ...baseConfig,
150
+ rules: [
151
+ {
152
+ id: 'email',
153
+ label: { it: 'Email 1', en: 'Email 1' },
154
+ pattern: '\\b[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}\\b',
155
+ message: { en: 'Email 1' },
156
+ },
157
+ {
158
+ id: 'email',
159
+ label: { it: 'Email 2', en: 'Email 2' },
160
+ pattern: '@',
161
+ message: { en: 'Email 2' },
162
+ },
163
+ ],
164
+ };
165
+ const result = checkPii('test@example.com', configDupId, 'en');
166
+ expect(result.matched).toBe(true);
167
+ const lines = result.errorText!.split('\n');
168
+ const emailLines = lines.filter(l => l.includes('Email'));
169
+ expect(emailLines.length).toBe(1);
170
+ });
171
+ });
172
+
173
+ describe('lang normalization', () => {
174
+ it('handles empty string lang with fallback to en', () => {
175
+ const result = checkPii('test@example.com', baseConfig, '');
176
+ expect(result.matched).toBe(true);
177
+ expect(result.errorText).toContain('Sensitive data.');
178
+ });
179
+
180
+ it('normalizes lang to lowercase for lookup', () => {
181
+ const result = checkPii('test@example.com', baseConfig, 'EN');
182
+ expect(result.matched).toBe(true);
183
+ expect(result.errorText).toContain('Sensitive data.');
184
+ });
185
+ });
186
+ });
@@ -0,0 +1,91 @@
1
+ import type { PiiDetectionConfig, PiiDetectionRule } from '../types/layout';
2
+
3
+ /**
4
+ * PII detection helper: runs regex rules on message text and returns whether any matched
5
+ * and the localized error text to show in the chat (as a system error bubble).
6
+ *
7
+ * Edge cases handled:
8
+ * - config disabled, null, or missing rules → no match
9
+ * - empty or non-array rules → no match
10
+ * - empty or whitespace-only regex pattern → skipped (avoid matching everything)
11
+ * - invalid regex (throws) → caught, treated as no match, console.warn
12
+ * - multiple rules with same id → deduplicated so message appears once
13
+ * - missing message/errorMessage for lang → fallback: lang → en → first value → default string
14
+ */
15
+
16
+ /**
17
+ * Picks a localized string from a Record with fallback: lang -> 'en' -> first value -> fallback.
18
+ * Used for errorMessage and each rule's message so the bubble uses the chat-selected language.
19
+ */
20
+ function getLocalized(
21
+ record: Record<string, string> | undefined,
22
+ lang: string,
23
+ fallback: string
24
+ ): string {
25
+ if (!record || typeof record !== 'object') return fallback;
26
+ const normalizedLang = lang?.toLowerCase() || 'en';
27
+ return (
28
+ record[normalizedLang] ??
29
+ record.en ??
30
+ record['en'] ??
31
+ Object.values(record)[0] ??
32
+ fallback
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Runs PII detection on text: tests each rule's regex and returns matched rules and error text.
38
+ * - Deduplicates by rule.id so the same "kind" of PII doesn't appear twice in the message.
39
+ * - Invalid regexes are caught and treated as no match (with a console.warn).
40
+ *
41
+ * @param text - Full message text to check (including any attached document content).
42
+ * @param config - PII config from layout (enabled, rules, errorMessage).
43
+ * @param lang - Chat-selected language (e.g. userLang) for picking localized strings.
44
+ * @returns { matched: false } if no rule matches, or { matched: true, errorText } with the full error string to show.
45
+ */
46
+ export function checkPii(
47
+ text: string,
48
+ config: PiiDetectionConfig,
49
+ lang: string
50
+ ): { matched: boolean; errorText?: string } {
51
+ if (!config?.enabled || !Array.isArray(config.rules) || config.rules.length === 0) {
52
+ return { matched: false };
53
+ }
54
+
55
+ const normalizedLang = lang?.toLowerCase() || 'en';
56
+ const matchedRulesById = new Map<string, PiiDetectionRule>();
57
+
58
+ for (const rule of config.rules) {
59
+ if (!rule.pattern || !rule.pattern.trim()) {
60
+ continue; // empty pattern would match everything; skip
61
+ }
62
+ try {
63
+ const regex = new RegExp(rule.pattern);
64
+ if (regex.test(text)) {
65
+ if (!matchedRulesById.has(rule.id)) {
66
+ matchedRulesById.set(rule.id, rule);
67
+ }
68
+ }
69
+ } catch (err) {
70
+ console.warn('[PII] Invalid regex for rule:', rule.id, rule.pattern, err);
71
+ }
72
+ }
73
+
74
+ if (matchedRulesById.size === 0) {
75
+ return { matched: false };
76
+ }
77
+
78
+ // Build one string: main errorMessage line + one line per matched rule (in selected language).
79
+ const mainMessage = getLocalized(
80
+ config.errorMessage,
81
+ normalizedLang,
82
+ 'The message contains personal or sensitive data.'
83
+ );
84
+ const ruleMessages = Array.from(matchedRulesById.values()).map(rule => {
85
+ const labelFallback = getLocalized(rule.label, normalizedLang, rule.id);
86
+ return getLocalized(rule.message, normalizedLang, labelFallback);
87
+ });
88
+ const errorText = [mainMessage, ...ruleMessages].join('\n');
89
+
90
+ return { matched: true, errorText };
91
+ }
@@ -133,4 +133,57 @@ WithPrivateAgent.args = {
133
133
  showSettings: true,
134
134
  showShare: true,
135
135
  integrationID: '19f95abe-3493-4568-971d-14471480e5bc',
136
+ };
137
+
138
+ // PII detection: only via integration customData (layout as object with piiDetection). Try sending an email or IBAN to see the error bubble.
139
+ const piiDetectionConfig = {
140
+ enabled: true,
141
+ rules: [
142
+ {
143
+ id: 'email',
144
+ label: { it: 'Email', en: 'Email' },
145
+ pattern: '\\b[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}\\b',
146
+ message: {
147
+ it: 'Il messaggio contiene un indirizzo email.',
148
+ en: 'The message contains an email address.',
149
+ },
150
+ },
151
+ {
152
+ id: 'iban',
153
+ label: { it: 'IBAN', en: 'IBAN' },
154
+ pattern: '\\b[A-Z]{2}\\d{2}(?:[ ]?[A-Z0-9]{4}){3,7}(?:[ ]?[A-Z0-9]{1,4})?\\b',
155
+ message: {
156
+ it: 'Il messaggio contiene un codice IBAN.',
157
+ en: 'The message contains an IBAN code.',
158
+ },
159
+ },
160
+ ],
161
+ errorMessage: {
162
+ it: 'Il messaggio contiene dati personali o sensibili.',
163
+ en: 'The message contains personal or sensitive data.',
164
+ },
165
+ };
166
+
167
+ /** Story: PII detection via integration config only. Send a message containing an email (e.g. test@example.com) or IBAN to see the red error bubble. */
168
+ export const WithPiiDetection = Template.bind({});
169
+ WithPiiDetection.args = {
170
+ memoriName: 'Layout Storybook',
171
+ ownerUserName: 'andrea.patini',
172
+ memoriID: 'ae20fc5a-cc15-4db9-b7dd-2cd4a621b85e',
173
+ ownerUserID: '91dbc9ba-b684-4fbe-9828-b5980af6cda9',
174
+ tenantID: 'aisuru-staging.aclambda.online',
175
+ engineURL: 'https://engine-staging.memori.ai/memori/v2',
176
+ apiURL: 'https://backend-staging.memori.ai/api/v2',
177
+ uiLang: 'IT',
178
+ spokenLang: 'IT',
179
+ integration: {
180
+ integrationID: 'pii-demo',
181
+ customData: JSON.stringify({
182
+ layout: {
183
+ name: 'DEFAULT',
184
+ piiDetection: piiDetectionConfig,
185
+ },
186
+ lang: 'it',
187
+ }),
188
+ },
136
189
  };
package/src/index.tsx CHANGED
@@ -383,7 +383,7 @@ const Memori: React.FC<Props> = ({
383
383
  enableAudio,
384
384
  defaultSpeakerActive,
385
385
  useMathFormatting,
386
- memoriLang: spokenLang ?? memori?.culture?.split('-')?.[0],
386
+ memoriLang: uiLang ?? spokenLang ?? memori?.culture?.split('-')?.[0],
387
387
  };
388
388
 
389
389
  const [pulseSent, setPulseSent] = useState(false);
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Layout types and PII (Personally Identifiable Information) detection config.
3
+ * The widget's `layout` prop is always a string (LayoutName). PII is only configured
4
+ * via integration: integration.customData (JSON) can have layout as an object (LayoutProp).
5
+ */
6
+
7
+ /** Layout name (string union used for layout selection across the app). */
8
+ export type LayoutName =
9
+ | 'DEFAULT'
10
+ | 'FULLPAGE'
11
+ | 'TOTEM'
12
+ | 'CHAT'
13
+ | 'WEBSITE_ASSISTANT'
14
+ | 'HIDDEN_CHAT'
15
+ | 'ZOOMED_FULL_BODY';
16
+
17
+ /**
18
+ * Single PII detection rule: one regex pattern and its localized violation message.
19
+ * - id: unique key (used to deduplicate when multiple rules share the same id).
20
+ * - label: localized human-readable name (e.g. { it: "Email", en: "Email" }).
21
+ * - pattern: regex string (e.g. "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b").
22
+ * - message: { [lang]: string } for the chat-selected language when multilingual is on (e.g. { it: "...", en: "..." }).
23
+ */
24
+ export interface PiiDetectionRule {
25
+ id: string;
26
+ label: Record<string, string>;
27
+ pattern: string;
28
+ message: Record<string, string>;
29
+ }
30
+
31
+ /**
32
+ * PII detection config attached to the layout when enabled.
33
+ * - enabled: when true, messages are checked before sending.
34
+ * - rules: list of regex rules; if any matches, the message is blocked and an error is shown.
35
+ * - errorMessage: localized main line shown in the error bubble (e.g. "The message contains personal or sensitive data.").
36
+ */
37
+ export interface PiiDetectionConfig {
38
+ enabled: boolean;
39
+ rules: PiiDetectionRule[];
40
+ errorMessage: Record<string, string>;
41
+ }
42
+
43
+ /**
44
+ * Layout as object: only used inside integration customData (not as the layout prop).
45
+ * When customData.layout is this shape and piiDetection.enabled is true, the widget
46
+ * runs PII checks before sending and shows an error bubble if any rule matches.
47
+ */
48
+ export type LayoutProp =
49
+ | LayoutName
50
+ | { name: LayoutName; piiDetection?: PiiDetectionConfig };
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // This file is auto-generated. Do not edit manually.
2
- export const version = '8.19.3';
2
+ export const version = '8.21.0';