@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.
- package/CHANGELOG.md +30 -0
- package/README.md +86 -3
- package/dist/components/ContentPreviewModal/ContentPreviewModal.css +0 -1
- package/dist/components/FilePreview/FilePreview.js +4 -1
- package/dist/components/FilePreview/FilePreview.js.map +1 -1
- package/dist/components/MemoriWidget/MemoriWidget.d.ts +2 -1
- package/dist/components/MemoriWidget/MemoriWidget.js +73 -37
- package/dist/components/MemoriWidget/MemoriWidget.js.map +1 -1
- package/dist/components/layouts/fullpage.css +115 -0
- package/dist/helpers/piiDetection.d.ts +5 -0
- package/dist/helpers/piiDetection.js +45 -0
- package/dist/helpers/piiDetection.js.map +1 -0
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/testUtils.d.ts +5 -0
- package/dist/testUtils.js +18 -0
- package/dist/testUtils.js.map +1 -0
- package/dist/types/layout.d.ts +16 -0
- package/dist/types/layout.js +3 -0
- package/dist/types/layout.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/esm/components/ContentPreviewModal/ContentPreviewModal.css +0 -1
- package/esm/components/FilePreview/FilePreview.js +4 -1
- package/esm/components/FilePreview/FilePreview.js.map +1 -1
- package/esm/components/MemoriWidget/MemoriWidget.d.ts +2 -1
- package/esm/components/MemoriWidget/MemoriWidget.js +73 -37
- package/esm/components/MemoriWidget/MemoriWidget.js.map +1 -1
- package/esm/components/layouts/fullpage.css +115 -0
- package/esm/helpers/piiDetection.d.ts +5 -0
- package/esm/helpers/piiDetection.js +41 -0
- package/esm/helpers/piiDetection.js.map +1 -0
- package/esm/index.js +2 -2
- package/esm/index.js.map +1 -1
- package/esm/testUtils.d.ts +5 -0
- package/esm/testUtils.js +15 -0
- package/esm/testUtils.js.map +1 -0
- package/esm/types/layout.d.ts +16 -0
- package/esm/types/layout.js +2 -0
- package/esm/types/layout.js.map +1 -0
- package/esm/version.d.ts +1 -1
- package/esm/version.js +1 -1
- package/package.json +1 -1
- package/src/components/ContentPreviewModal/ContentPreviewModal.css +0 -1
- package/src/components/FilePreview/FilePreview.tsx +4 -1
- package/src/components/MemoriWidget/MemoriWidget.stories.tsx +2 -0
- package/src/components/MemoriWidget/MemoriWidget.tsx +76 -37
- package/src/helpers/piiDetection.test.ts +186 -0
- package/src/helpers/piiDetection.ts +91 -0
- package/src/index.stories.tsx +53 -0
- package/src/index.tsx +1 -1
- package/src/types/layout.ts +50 -0
- package/src/version.ts +1 -1
package/esm/testUtils.js
ADDED
|
@@ -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 @@
|
|
|
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.
|
|
1
|
+
export declare const version = "8.21.0";
|
package/esm/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const version = '8.
|
|
1
|
+
export const version = '8.21.0';
|
|
2
2
|
//# sourceMappingURL=version.js.map
|
package/package.json
CHANGED
|
@@ -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
|
-
{
|
|
151
|
+
{file.mimeType
|
|
152
|
+
? getFileExtensionFromMime(file.mimeType)
|
|
153
|
+
: getFileType(file.name, file.type)}
|
|
151
154
|
</span>
|
|
152
155
|
</div>
|
|
153
156
|
|
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/index.stories.tsx
CHANGED
|
@@ -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.
|
|
2
|
+
export const version = '8.21.0';
|