From be476cd97b769aa67e2b1896a3f0135ffd14d142 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:55:53 -0700 Subject: [PATCH] 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. --- .../hooks/SSE/__tests__/usePiiHandler.spec.ts | 38 +++++++++++++++++++ client/src/hooks/SSE/piiLabels.ts | 31 +++++++++++++++ client/src/hooks/SSE/useEventHandlers.ts | 3 ++ client/src/hooks/SSE/usePiiHandler.ts | 28 ++++++++++++++ client/src/hooks/SSE/useResumableSSE.ts | 7 ++++ client/src/hooks/SSE/useSSE.ts | 3 ++ client/src/locales/en/translation.json | 1 + 7 files changed, 111 insertions(+) create mode 100644 client/src/hooks/SSE/__tests__/usePiiHandler.spec.ts create mode 100644 client/src/hooks/SSE/piiLabels.ts create mode 100644 client/src/hooks/SSE/usePiiHandler.ts diff --git a/client/src/hooks/SSE/__tests__/usePiiHandler.spec.ts b/client/src/hooks/SSE/__tests__/usePiiHandler.spec.ts new file mode 100644 index 0000000000..14af713900 --- /dev/null +++ b/client/src/hooks/SSE/__tests__/usePiiHandler.spec.ts @@ -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'); + }); +}); diff --git a/client/src/hooks/SSE/piiLabels.ts b/client/src/hooks/SSE/piiLabels.ts new file mode 100644 index 0000000000..8cb2e0beba --- /dev/null +++ b/client/src/hooks/SSE/piiLabels.ts @@ -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(); + 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(', '); +}; diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 29aef8a1dd..fac3dabaea 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -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, }; } diff --git a/client/src/hooks/SSE/usePiiHandler.ts b/client/src/hooks/SSE/usePiiHandler.ts new file mode 100644 index 0000000000..f869f7aa43 --- /dev/null +++ b/client/src/hooks/SSE/usePiiHandler.ts @@ -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 }; +} diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index 084eb6ab2c..0815f2c281 100644 --- a/client/src/hooks/SSE/useResumableSSE.ts +++ b/client/src/hooks/SSE/useResumableSSE.ts @@ -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, diff --git a/client/src/hooks/SSE/useSSE.ts b/client/src/hooks/SSE/useSSE.ts index 509b7c213e..6c6147c7b0 100644 --- a/client/src/hooks/SSE/useSSE.ts +++ b/client/src/hooks/SSE/useSSE.ts @@ -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) { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index b7d2e4ae96..fab342441a 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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",