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