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:
Dustin Healy 2026-06-07 17:55:53 -07:00
parent 940194d773
commit be476cd97b
7 changed files with 111 additions and 0 deletions

View 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');
});
});

View 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(', ');
};

View file

@ -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,
};
}

View 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 };
}

View file

@ -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,

View file

@ -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) {

View file

@ -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",