mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
feat: toast PII filter pii_matches SSE event in the chat UI
Adds a usePiiHandler hook that consumes the pii_matches SSE event emitted by the message PII filter in warn mode and surfaces an info toast listing the redacted pattern labels so the user can see what was scrubbed before the prompt reached the LLM. Block mode is already handled by the existing error path on the server (throws an Error that flows through sendCompletion) and needs no new client wiring. Pattern-label formatting lives in a dependency-free piiLabels module so it can be unit tested without dragging in the React / @librechat/client runtime that the existing SSE test suites cannot resolve in this environment. Wires the new handler into both useSSE and useResumableSSE before the generic data.type branch so the new event type is routed to the PII handler instead of being mis-dispatched to contentHandler.
This commit is contained in:
parent
940194d773
commit
be476cd97b
7 changed files with 111 additions and 0 deletions
38
client/src/hooks/SSE/__tests__/usePiiHandler.spec.ts
Normal file
38
client/src/hooks/SSE/__tests__/usePiiHandler.spec.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import type { PiiPatternMatch } from '~/hooks/SSE/piiLabels';
|
||||
import { formatPiiLabels } from '~/hooks/SSE/piiLabels';
|
||||
|
||||
describe('formatPiiLabels', () => {
|
||||
it('returns empty string when matches is undefined', () => {
|
||||
expect(formatPiiLabels(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when matches is empty', () => {
|
||||
expect(formatPiiLabels([])).toBe('');
|
||||
});
|
||||
|
||||
it('joins multiple distinct pattern labels with a comma', () => {
|
||||
const matches: PiiPatternMatch[] = [
|
||||
{ patternId: 'a', patternLabel: 'Anthropic API key', count: 1 },
|
||||
{ patternId: 'b', patternLabel: 'GitHub token', count: 2 },
|
||||
];
|
||||
expect(formatPiiLabels(matches)).toBe('Anthropic API key, GitHub token');
|
||||
});
|
||||
|
||||
it('dedupes pattern labels by display name', () => {
|
||||
const matches: PiiPatternMatch[] = [
|
||||
{ patternId: 'a', patternLabel: 'Anthropic API key', count: 1 },
|
||||
{ patternId: 'b', patternLabel: 'Anthropic API key', count: 1 },
|
||||
{ patternId: 'c', patternLabel: 'GitHub token', count: 1 },
|
||||
];
|
||||
expect(formatPiiLabels(matches)).toBe('Anthropic API key, GitHub token');
|
||||
});
|
||||
|
||||
it('skips entries without a usable label', () => {
|
||||
const matches = [
|
||||
{ patternId: 'a', patternLabel: '', count: 1 },
|
||||
{ patternId: 'b', patternLabel: ' ', count: 1 },
|
||||
{ patternId: 'c', patternLabel: 'GitHub token', count: 1 },
|
||||
] as PiiPatternMatch[];
|
||||
expect(formatPiiLabels(matches)).toBe('GitHub token');
|
||||
});
|
||||
});
|
||||
31
client/src/hooks/SSE/piiLabels.ts
Normal file
31
client/src/hooks/SSE/piiLabels.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export type PiiPatternMatch = {
|
||||
patternId: string;
|
||||
patternLabel: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type PiiEvent = {
|
||||
type: 'pii_matches';
|
||||
matches?: PiiPatternMatch[];
|
||||
};
|
||||
|
||||
const dedupeLabels = (matches: PiiPatternMatch[]): string[] => {
|
||||
const seen = new Set<string>();
|
||||
const labels: string[] = [];
|
||||
for (const match of matches) {
|
||||
const label = match?.patternLabel?.trim();
|
||||
if (!label || seen.has(label)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(label);
|
||||
labels.push(label);
|
||||
}
|
||||
return labels;
|
||||
};
|
||||
|
||||
export const formatPiiLabels = (matches?: PiiPatternMatch[]): string => {
|
||||
if (!matches || matches.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return dedupeLabels(matches).join(', ');
|
||||
};
|
||||
|
|
@ -43,6 +43,7 @@ import useAttachmentHandler from '~/hooks/SSE/useAttachmentHandler';
|
|||
import useContentHandler from '~/hooks/SSE/useContentHandler';
|
||||
import useStepHandler from '~/hooks/SSE/useStepHandler';
|
||||
import { useApplyAgentTemplate } from '~/hooks/Agents';
|
||||
import usePiiHandler from '~/hooks/SSE/usePiiHandler';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import { MESSAGE_UPDATE_INTERVAL } from '~/common';
|
||||
import { useLiveAnnouncer } from '~/Providers';
|
||||
|
|
@ -285,6 +286,7 @@ export default function useEventHandlers({
|
|||
lastAnnouncementTimeRef,
|
||||
});
|
||||
const attachmentHandler = useAttachmentHandler(queryClient);
|
||||
const { piiMatchesHandler } = usePiiHandler();
|
||||
|
||||
/** Wipe the per-subagent Recoil atoms on conversation navigation.
|
||||
* Historical subagent dialogs rehydrate from the persisted
|
||||
|
|
@ -1079,5 +1081,6 @@ export default function useEventHandlers({
|
|||
attachmentHandler,
|
||||
abortConversation,
|
||||
resetContentHandler,
|
||||
piiMatchesHandler,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
28
client/src/hooks/SSE/usePiiHandler.ts
Normal file
28
client/src/hooks/SSE/usePiiHandler.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import type { PiiEvent } from '~/hooks/SSE/piiLabels';
|
||||
import { formatPiiLabels } from '~/hooks/SSE/piiLabels';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
|
||||
export type { PiiEvent, PiiPatternMatch } from '~/hooks/SSE/piiLabels';
|
||||
|
||||
export default function usePiiHandler() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const piiMatchesHandler = useCallback(
|
||||
(data: PiiEvent) => {
|
||||
const labels = formatPiiLabels(data.matches);
|
||||
if (!labels) {
|
||||
return;
|
||||
}
|
||||
showToast({
|
||||
message: localize('com_ui_pii_redacted', { 0: labels }),
|
||||
status: 'info',
|
||||
});
|
||||
},
|
||||
[localize, showToast],
|
||||
);
|
||||
|
||||
return { piiMatchesHandler };
|
||||
}
|
||||
|
|
@ -453,6 +453,7 @@ export default function useResumableSSE(
|
|||
syncStepMessage,
|
||||
attachmentHandler,
|
||||
resetContentHandler,
|
||||
piiMatchesHandler,
|
||||
} = useEventHandlers({
|
||||
setMessages,
|
||||
getMessages,
|
||||
|
|
@ -712,6 +713,11 @@ export default function useResumableSSE(
|
|||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'pii_matches') {
|
||||
piiMatchesHandler(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type != null) {
|
||||
const { text, index } = data;
|
||||
if (text != null && index !== textIndex) {
|
||||
|
|
@ -975,6 +981,7 @@ export default function useResumableSSE(
|
|||
clearStepMaps,
|
||||
messageHandler,
|
||||
errorHandler,
|
||||
piiMatchesHandler,
|
||||
setIsSubmitting,
|
||||
getMessages,
|
||||
setMessages,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export default function useSSE(
|
|||
titleHandler,
|
||||
attachmentHandler,
|
||||
abortConversation,
|
||||
piiMatchesHandler,
|
||||
} = useEventHandlers({
|
||||
setMessages,
|
||||
getMessages,
|
||||
|
|
@ -123,6 +124,8 @@ export default function useSSE(
|
|||
setActiveRunId(runId);
|
||||
/* synchronize messages to Assistants API as well as with real DB ID's */
|
||||
syncHandler(data, { ...submission, userMessage } as EventSubmission);
|
||||
} else if (data.type === 'pii_matches') {
|
||||
piiMatchesHandler(data);
|
||||
} else if (data.type != null) {
|
||||
const { text, index } = data;
|
||||
if (text != null && index !== textIndex) {
|
||||
|
|
|
|||
|
|
@ -1344,6 +1344,7 @@
|
|||
"com_ui_permissions_failed_load": "Failed to load permissions. Please try again.",
|
||||
"com_ui_permissions_failed_update": "Failed to update permissions. Please try again.",
|
||||
"com_ui_permissions_updated_success": "Permissions updated successfully",
|
||||
"com_ui_pii_redacted": "Redacted from your message: {{0}}",
|
||||
"com_ui_pin": "Pin",
|
||||
"com_ui_plus_n_more": "+{{0}} more",
|
||||
"com_ui_preferences_updated": "Preferences updated successfully",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue